Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

285 lines
10 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/spf13/cobra"
"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/platform"
"gopkg.in/yaml.v3"
)
var (
toolsReceiptFormat string
toolsReceiptSince time.Duration
)
var toolsReceiptCmd = &cobra.Command{
Use: "receipt",
Short: "Print a context-budget receipt: the MCP tool surface the CLI path did NOT mount",
Long: `Emits an inspectable "context budget receipt" describing the transport and
tool-surface counts the daemon would advertise over MCP. Driving Gortex through
the CLI (or a skill that shells the CLI) mounts no tool schemas into the model's
context, so this receipt is the auditable record of the per-call "tax" avoided.
When a daemon tracks the repo the receipt reports its active preset and the
live / deferred tool counts; when no daemon is reachable it still emits a
receipt recording that no surface was mounted.
The receipt also reports the CLI verbs the current agent session drove and the
safety steps those verbs ran. Capture is per-session and opt-in: set
GORTEX_SESSION_ID before invoking gortex so each verb is recorded under that
correlation id; the receipt then reads back exactly this session's verbs. With
no session id set the receipt falls back to verbs run within the --since window
(across all sessions), and those two fields are empty when nothing was
recorded.`,
RunE: runToolsReceipt,
}
func init() {
toolsReceiptCmd.Flags().StringVar(&toolsReceiptFormat, "format", "yaml", "output format: yaml or json")
toolsReceiptCmd.Flags().DurationVar(&toolsReceiptSince, "since", time.Hour, "when GORTEX_SESSION_ID is unset, include CLI verbs run within this window")
toolsCmd.AddCommand(toolsReceiptCmd)
}
// receiptSidecarPath resolves the sidecar database the CLI-verb ledger is read
// from for Part B. A package var so tests can redirect it away from the real
// ~/.gortex. Mirrors cliEventSidecarPath (the write side in root.go).
var receiptSidecarPath = func() string {
return persistence.DefaultSidecarPath(platform.DataDir())
}
// receiptNow is the clock the receipt uses to window CLIEventsSince. A package
// var so tests can pin it.
var receiptNow = time.Now
// getenvReceipt is the os.Getenv seam the receipt reads through, so tests can
// stub the session-id lookup without mutating the process environment.
var getenvReceipt = os.Getenv
// receiptSessionID reads the per-session correlation id the receipt scopes its
// CLI-verb read by. A seam (over os.Getenv) so tests can drive it.
var receiptSessionID = func() string { return getenvReceipt("GORTEX_SESSION_ID") }
// verbSafetyTool maps a CLI verb (cliCommandDim form, e.g. "edit.verify") to
// the MCP safety tool it corresponds to. The receipt derives safety_steps_run
// from the recorded verbs through this map — only the documented safety steps
// of the edit/verify cycle are mapped; every other verb (query.stats, etc.)
// maps to nothing.
var verbSafetyTool = map[string]string{
"edit.verify": "verify_change",
"edit.guards": "check_guards",
"edit.tests": "get_test_targets",
"edit.contract": "change_contract",
"feedback.record": "feedback",
"feedback.query": "feedback",
}
// safetyStepsForVerbs returns the DISTINCT safety tools the given verbs ran,
// via verbSafetyTool, in a stable (sorted) order. Verbs with no safety mapping
// contribute nothing.
func safetyStepsForVerbs(verbs []string) []string {
seen := make(map[string]struct{})
var out []string
for _, v := range verbs {
tool, ok := verbSafetyTool[v]
if !ok {
continue
}
if _, dup := seen[tool]; dup {
continue
}
seen[tool] = struct{}{}
out = append(out, tool)
}
sort.Strings(out)
return out
}
// toolProfileCounts is the slice of the daemon's tool_profile response the
// receipt reads: the active preset and the live / deferred surface counts.
type toolProfileCounts struct {
Preset string `json:"preset"`
PresetMode string `json:"preset_mode"`
LiveCount int `json:"live_count"`
DeferredCount int `json:"deferred_count"`
Live []string `json:"live"`
Deferred []string `json:"deferred"`
}
// contextBudgetReceipt is the rendered receipt body. Field order is fixed via
// struct order so both the YAML and JSON renderings are deterministic.
type contextBudgetReceipt struct {
Transport string `json:"transport" yaml:"transport"`
Repo string `json:"repo" yaml:"repo"`
DaemonPreset string `json:"daemon_preset,omitempty" yaml:"daemon_preset,omitempty"`
DaemonPresetMode string `json:"daemon_preset_mode,omitempty" yaml:"daemon_preset_mode,omitempty"`
AdvertisedTools int `json:"advertised_tools" yaml:"advertised_tools"`
RegisteredToolSchemas int `json:"registered_tool_schemas" yaml:"registered_tool_schemas"`
DeferredCapabilities receiptDeferredCapabilities `json:"deferred_capabilities" yaml:"deferred_capabilities"`
Decision string `json:"decision" yaml:"decision"`
// Part B: the CLI verbs the current agent session drove and the safety
// steps derived from them. Always present (empty lists when nothing was
// recorded) so the receipt shape is stable. Note explains how to enable
// per-session capture when it is empty.
CLIVerbsUsed []string `json:"cli_verbs_used" yaml:"cli_verbs_used"`
SafetyStepsRun []string `json:"safety_steps_run" yaml:"safety_steps_run"`
Note string `json:"note,omitempty" yaml:"note,omitempty"`
}
type receiptDeferredCapabilities struct {
Count int `json:"count" yaml:"count"`
}
// receiptEnvelope wraps the receipt body under the single top-level key both
// renderings carry.
type receiptEnvelope struct {
GortexContextBudget contextBudgetReceipt `json:"gortex_context_budget" yaml:"gortex_context_budget"`
}
func runToolsReceipt(cmd *cobra.Command, _ []string) error {
abs, err := filepath.Abs(toolsIndex)
if err != nil {
abs = toolsIndex
}
receipt := buildContextBudgetReceipt(abs)
return renderReceipt(cmd, receipt, toolsReceiptFormat)
}
// buildContextBudgetReceipt asks the daemon for its tool_profile and renders
// the receipt. Any error reaching the daemon (no daemon running, repo not
// tracked, or a tool failure) is treated as "no surface mounted" rather than a
// hard failure — the receipt must be emittable even with nothing mounted.
func buildContextBudgetReceipt(absRepo string) contextBudgetReceipt {
var receipt contextBudgetReceipt
raw, err := toolsDaemonTool(toolsIndex, "tool_profile", map[string]any{})
if err != nil {
receipt = contextBudgetReceipt{
Transport: "cli_only",
Repo: absRepo,
AdvertisedTools: 0,
RegisteredToolSchemas: 0,
DeferredCapabilities: receiptDeferredCapabilities{Count: 0},
Decision: "no_surface_mounted",
}
} else {
var counts toolProfileCounts
_ = json.Unmarshal(raw, &counts)
// Prefer the explicit *_count fields; fall back to the array lengths so a
// profile shape that omits the counts still yields a faithful receipt.
live := counts.LiveCount
if live == 0 && len(counts.Live) > 0 {
live = len(counts.Live)
}
deferred := counts.DeferredCount
if deferred == 0 && len(counts.Deferred) > 0 {
deferred = len(counts.Deferred)
}
receipt = contextBudgetReceipt{
Transport: "skill_cli",
Repo: absRepo,
DaemonPreset: counts.Preset,
DaemonPresetMode: counts.PresetMode,
AdvertisedTools: live,
RegisteredToolSchemas: 0,
DeferredCapabilities: receiptDeferredCapabilities{Count: deferred},
Decision: "full_surface_not_mounted",
}
}
attachReceiptPartB(&receipt)
return receipt
}
// attachReceiptPartB fills cli_verbs_used / safety_steps_run from the
// consent-free CLI-verb ledger. The read is best-effort: if the sidecar can't
// be opened the two fields stay empty lists and Part A is emitted unchanged —
// never a hard error.
//
// Scope: with GORTEX_SESSION_ID set, exactly that session's verbs (the precise
// per-session capture); otherwise the verbs run within the --since window
// across all sessions. The verbs are de-duplicated in first-seen order;
// safety_steps_run is derived from them through verbSafetyTool.
func attachReceiptPartB(receipt *contextBudgetReceipt) {
// Empty (never nil) so both renderings show an explicit empty list.
receipt.CLIVerbsUsed = []string{}
receipt.SafetyStepsRun = []string{}
sessionID := receiptSessionID()
if sessionID == "" {
receipt.Note = "set GORTEX_SESSION_ID to capture this session's CLI verbs and safety steps"
}
sc, err := persistence.OpenSidecar(receiptSidecarPath())
if err != nil || sc == nil {
return
}
defer func() { _ = sc.Close() }()
var events []persistence.CLIEvent
if sessionID != "" {
events, err = sc.CLIEventsBySession(sessionID)
} else {
events, err = sc.CLIEventsSince(receiptNow().Add(-toolsReceiptSince))
}
if err != nil {
return
}
verbs := distinctVerbs(events)
if len(verbs) > 0 {
receipt.CLIVerbsUsed = verbs
}
if steps := safetyStepsForVerbs(verbs); len(steps) > 0 {
receipt.SafetyStepsRun = steps
}
}
// distinctVerbs returns the distinct verbs from the events in first-seen
// (chronological) order — the events arrive oldest-first from the ledger.
func distinctVerbs(events []persistence.CLIEvent) []string {
seen := make(map[string]struct{}, len(events))
out := make([]string, 0, len(events))
for _, ev := range events {
if ev.Verb == "" {
continue
}
if _, dup := seen[ev.Verb]; dup {
continue
}
seen[ev.Verb] = struct{}{}
out = append(out, ev.Verb)
}
return out
}
// renderReceipt writes the receipt as YAML (default) or JSON under the
// gortex_context_budget top-level key.
func renderReceipt(cmd *cobra.Command, receipt contextBudgetReceipt, format string) error {
out := cmd.OutOrStdout()
env := receiptEnvelope{GortexContextBudget: receipt}
switch format {
case "json":
enc := json.NewEncoder(out)
enc.SetIndent("", " ")
return enc.Encode(env)
case "yaml", "":
b, err := yaml.Marshal(env)
if err != nil {
return err
}
_, err = fmt.Fprint(out, string(b))
return err
default:
return fmt.Errorf("unknown --format %q (want yaml or json)", format)
}
}