diff --git a/.github/ISSUE_TEMPLATE/adapter_request.yml b/.github/ISSUE_TEMPLATE/adapter_request.yml index 700274b..0668485 100644 --- a/.github/ISSUE_TEMPLATE/adapter_request.yml +++ b/.github/ISSUE_TEMPLATE/adapter_request.yml @@ -7,7 +7,7 @@ body: id: host attributes: label: Agent host/runtime - placeholder: Codex, Claude Code, OpenCode, Cursor, VS Code, etc. + placeholder: Codex, Claude Code validations: required: true - type: textarea diff --git a/AGENTS.md b/AGENTS.md index 22dd9ab..a881423 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,13 @@ TFY is a Rust-first token-saving middleware for AI-agent I/O boundaries. It shou 4. **Truthful adapter claims** — MCP support means host-routed MCP tool/resource integration. Do not claim private Codex hook interception, provider prompt mutation, or universal shell interception unless implemented and tested. 5. **Preview-only output validation** — do not claim workspace apply authority until explicit authority/provenance gates and tests exist. +## Product routing rules + +- Bare project-local `tfy start --agent` is the simple product path: it must prepare the generic TFY agent wrapper plus Codex and Claude Code official project hook routes by default; public product wording should name supported hosts only. +- Do not regress bare `tfy start --agent` back to wrapper-only behavior unless the product direction is explicitly changed with tests and docs. +- Do not implement that default by aliasing it to `--host all`; this would silently expand scope beyond the Codex/Claude supported named-host path. +- Setup/configuration remains `configured_unverified` and `active=false` until real route-bound raw/ledger/no-negative/positive-savings evidence exists. Never treat host config creation as launch support. + ## Code ownership map - `crates/tfy-core/` — reusable token-saving primitives, raw store, redaction, context/code compression, command feedback. diff --git a/README.md b/README.md index b722897..b2b34a1 100644 --- a/README.md +++ b/README.md @@ -108,22 +108,29 @@ Repository/development harness: - `scripts/verify.sh` — local full verification gate. -## Developer Preview installation +## Installation and first start -The source build remains the authority path, but the preview distribution is designed for dogfooding from a normal project directory instead of running `cargo run` inside the TFY checkout. The npm package name is `@ium/tfy-cli` because the unscoped `tfy` npm name is already occupied and unscoped `tfy-cli` is blocked by npm similarity policy; the installed command must still be `tfy`. +The source build remains the authority path today. The npm package name is `@ium/tfy-cli` because the unscoped `tfy` npm name is already occupied and unscoped `tfy-cli` is blocked by npm similarity policy; the installed command must still be `tfy`. + +TFY is stable-first: the normal user path is `npm install -g @ium/tfy-cli` once the npm `latest` tag points at a reviewed stable release. Until that first stable release exists, use the explicit preview channel for development/dogfooding only. ```sh +# Stable path after the first stable release exists: +npm install -g @ium/tfy-cli + +# Current preview-only dogfood path: npm install -g @ium/tfy-cli@preview -tfy start --agent --host codex -tfy start --human -# interactive start offers this one-time future-shell hook bootstrap for marked repos: -# Install the one-time hook when prompted, or run later: -tfy setup --human --apply -# automation/non-interactive marker creation when not entering the managed shell: -tfy start --human --auto-activate + +# Enable TFY in the current project: +tfy start # interactive choice when attached to a TTY +tfy start --human # human managed shell path +tfy start --agent # AI-agent wrapper + Codex/Claude Code hook setup path + tfy status --json ``` +Human and agent modes use the same raw-first/no-negative/custom command-summary pipeline. Human mode routes commands from the TFY-managed project shell. Agent mode routes commands when the AI-agent host is configured to call TFY. The default supported path prepares the generated TFY agent wrapper/executor fallback plus Codex and Claude Code official PreToolUse Bash hook surfaces; route evidence still stays gated as `configured_unverified` until a real host invocation proves raw/ledger/no-negative/positive-savings behavior. MCP remains available for advanced tool/resource integration; it is not the default command-interception story. + The npm package is a thin installer/launcher. It downloads the matching GitHub Release archive plus checksum and exposes the `tfy` command. The public names are intentionally different: | Layer | Name | Why | @@ -169,7 +176,7 @@ node scripts/npm-publish-plan.js --version 0.1.1 --channel stable --source-ref m The npm package defaults its `publishConfig.tag` to `preview` as a safety rail; stable publishes must explicitly use the generated `--tag latest` command. The release workflow uses Trusted Publishing/OIDC rather than a long-lived npm token, and the user-owned npm-side Trusted Publisher setup is documented in `docs/RELEASE_READINESS.md`. The publish helper also prints the required `scripts/npm-dist-tag-check.js` guard; the canonical dist-tag cleanup checklist lives in `docs/RELEASE_READINESS.md`. -For AI-agent use, `tfy start --agent --host codex` writes project MCP configuration, but that only proves configuration. Launch support still requires real host invocation plus TFY raw/ledger/no-negative/positive-savings evidence. For human use on supported platform shells (Linux bash, macOS zsh, Windows PowerShell), interactive project-only `tfy start --human` records lifecycle intent, creates/refreshes the trusted repo-local future-shell marker/script as persistent repo state, and enters a TFY-managed project-scoped shell session where safely resolved command names are wrapped/proxied by TFY and summarized only after raw evidence is stored and only when beneficial. During that interactive start, TFY reports whether the one-time user rc/profile hook is already installed; if it is missing, TFY offers a default-No prompt to install it for future supported shells. Non-interactive plain `tfy start --human` remains lifecycle intent-only; automation that wants marker creation without entering a shell must use `tfy start --human --auto-activate`, and neither path silently edits shell startup files. `tfy setup --human --apply` remains the short manual command for the same explicit hook install; the longer `tfy human auto-activate install --shell --rcfile --apply` remains supported for explicit startup-file control. New supported shell sessions that read that hook auto-activate only inside marked repos; TFY pins the absolute executable and validates deterministic activation content before sourcing. Outside managed/marked sessions, `tfy shell ` is raw passthrough convenience and `tfy shell -- ` is the TFY summarizing wrapper. TFY does not claim universal or global terminal interception. +For AI-agent use, bare `tfy start --agent` records project intent, creates `.tfy/agent/tfy-agent-wrapper`, and prepares the safe project-local Codex `.codex/config.toml` plus Claude Code `.claude/settings.json` official hook routes. If the user later opens Codex in that project, the Codex route is ready; if they open Claude Code, the Claude route is ready. `tfy start --agent --host codex` and other named-host options remain explicit/narrow host setup routes, but setup only proves configuration. Launch support still requires real host invocation plus TFY raw/ledger/no-negative/positive-savings evidence. For human use on supported platform shells (Linux bash, macOS zsh, Windows PowerShell), interactive project-only `tfy start --human` records lifecycle intent, creates/refreshes the trusted repo-local future-shell marker/script as persistent repo state, and enters a TFY-managed project-scoped shell session where safely resolved command names are wrapped/proxied by TFY and summarized only after raw evidence is stored and only when beneficial. During that interactive start, TFY reports whether the one-time user rc/profile hook is already installed; if it is missing, TFY offers a default-No prompt to install it for future supported shells. Non-interactive plain `tfy start --human` remains lifecycle intent-only; automation that wants marker creation without entering a shell must use `tfy start --human --auto-activate`, and neither path silently edits shell startup files. `tfy setup --human --apply` remains the short manual command for the same explicit hook install; the longer `tfy human auto-activate install --shell --rcfile --apply` remains supported for explicit startup-file control. New supported shell sessions that read that hook auto-activate only inside marked repos; TFY pins the absolute executable and validates deterministic activation content before sourcing. Outside managed/marked sessions, `tfy shell ` is raw passthrough convenience and `tfy shell -- ` is the TFY summarizing wrapper. TFY does not claim universal or global terminal interception. ## Implementation status @@ -201,12 +208,8 @@ cargo run -p tfy-cli -- setup --human # dry-run explicit human rc hook cargo run -p tfy-cli -- setup --human --apply # one-time opt-in hook for trusted TFY-marked repos cargo run -p tfy-cli -- setup --ai --codex --dry-run cargo run -p tfy-cli -- setup --ai --host claude-code --dry-run -cargo run -p tfy-cli -- setup --ai --host cursor --dry-run cargo run -p tfy-cli -- setup --ai --host codex --apply --project cargo run -p tfy-cli -- setup --ai --host claude-code --apply --project -cargo run -p tfy-cli -- setup --ai --host cursor --apply --project -cargo run -p tfy-cli -- setup --ai --host opencode --dry-run -cargo run -p tfy-cli -- setup --ai --host hermes --dry-run cargo run -p tfy-cli -- status --json cargo run -p tfy-cli -- explain cargo run -p tfy-cli -- restore-display --payload payload.json --json @@ -215,7 +218,7 @@ cargo run -p tfy-cli -- workspace validate --payload workspace-plan.json --json cargo run -p tfy-cli -- workspace refactor-plan --payload workspace-plan.json --chunk-size 5 --json cargo run -p tfy-cli -- mcp capabilities cargo run -p tfy-cli -- mcp install --target codex --dry-run -cargo run -p tfy-cli -- mcp install --target cursor --dry-run +cargo run -p tfy-cli -- mcp install --target claude-code --dry-run cargo test --quiet ``` @@ -239,27 +242,26 @@ Current implementation status: - Implemented: Rust core primitives, Rust CLI, `tfy-runtime` envelope/capability/event contract with explicit origin/provenance fields, Tool Gateway text-first net-savings entrypoint, explicit debug/adapter JSON/JSONL entrypoints, shell wrapper, AI-agent wrapper, Context Gateway CLI, Output Gateway preview/validate CLI plus content-addressed single-file selected-scope `--apply`, display/file restore formatters, WorkspaceApplyPlan validate/apply for proof-gated multi-file modify/add/delete/rename/move and unique-anchor fuzzy operations, refactor chunk planning, State Gateway append/project CLI, raw refs, redaction, code index/expand/full/restore, evaluation. - Implemented adapter v1: `tfy adapter` generic-shell command-boundary shim, dry-run installer, session ledger, command-family-aware savings report, and P0 Tool Gateway summaries for Git, `gh pr checks`, Cargo, TypeScript no-emit, and common test-runner output. - Implemented MCP foundation v2: `tfy mcp serve` stdio JSON-RPC server, MCP tool/resource discovery, raw/report/state resources, Codex MCP dry-run/setup snippet generation, and an agent-native Code I/O workflow (`tfy_scope_list`, enriched `tfy_context_get`, preview-only `tfy_output_validate`, proof-gated `tfy_output_apply`, display-only `tfy_restore_display`, and workspace `tfy_workspace_validate`/`tfy_workspace_apply` for validated plans). -- Implemented product UX P1: project/global lifecycle commands (`tfy start`, `tfy stop`, `tfy status --agent/--human`, `tfy fuckyou`, `tfy global start|stop|fuckyou`) plus global default aliases (`tfy use always|stop|cancel|fuckyou`) as configuration-intent state, arrow-key TUI selection for bare interactive lifecycle commands, target aliases (`agent`/`ai`, `human`, `both`), lifecycle `route_state`/`active` fields that stay below launch support without evidence, `tfy start --agent` / `tfy start agent` as safe default project auto-configuration for the Codex project MCP route (`.codex/config.toml`), `tfy start --agent --no-apply` as lifecycle-intent-only escape hatch, `tfy start --agent --host codex` as explicit Codex project `.codex/config.toml` writer, `tfy start --agent --host claude-code` as explicit Claude Code project `.mcp.json` writer, `tfy start --agent --host cursor` as explicit Cursor project `.cursor/mcp.json` writer, and `tfy start agent --host all --apply` as ordered Codex → Claude Code → Cursor safe-writer orchestration, `tfy setup --ai --codex`, named-host setup snippets for Codex, Claude Code, Cursor, OpenCode, and Hermes through `tfy setup --ai --host ` / `tfy mcp install --target --dry-run`, safe reversible Codex, Claude Code, and Cursor project config apply/uninstall for `.codex/config.toml`, `.mcp.json`, and `.cursor/mcp.json`, `tfy hook capabilities` / `tfy hook install --dry-run` / `tfy hook run` test shim as an official-host-hook planning and equivalence surface, `tfy status` with `lifecycle_summary`, `tfy explain`, `tfy launch-report`, `tfy init` marker-bounded Codex guidance, `tfy doctor` local readiness diagnostics, `tfy smoke --mcp` automated Code I/O smoke, `tfy smoke --all` combined adapter+MCP evidence smoke, host checklist smoke, and `tfy gain` command-output ledger-derived savings reporting. `status --json` exposes a lifecycle_summary plus explicit effective project-over-global desired/configured/active state and next-action guidance, including desired_source=none in fresh projects and project_parse_error when local lifecycle state is malformed; `launch-report --json` exposes the minimum v1 host matrix (`mcp_stdio`, `tfy_agent_adapter`, `generic_shell`, plus non-required named hosts) with canonical evidence statuses plus a claim evidence ladder (`config_snippet_available` → `config_written` → `host_launched` → `verified_host_mcp_invocation` / `verified_host_hook` → `route_evidence_recorded` → `savings_verified` → `launch_supported`) and compatibility claim tiers (`not_configured`, `configurable`, `applied_unverified`, `smoke_routed`, `verified_local_mcp`, `launch_supported`, `planned_discovery`; lifecycle status additionally uses `configured_unverified` for a safe config write that still has no host evidence). The v1 pass gate requires `mcp_stdio`, `tfy_agent_adapter`, and `generic_shell` to become `launch_supported`; named hosts remain below launch support until real host invocation plus host-bound TFY ledger/raw/no-negative/positive-savings evidence exists. Hook targets remain dry-run/planned unless official host docs plus e2e route evidence exist; Codex hook support is unsupported without a public official hook route. OpenClaw is `planned_discovery`. -- Explicitly out of scope: provider/API request proxying and editor auto-integration. Not claimed: private/hidden Codex prompt hooks or universal human-shell interception. Supported AI routing is through wrapper/adapter/MCP host configuration, and fuzzy workspace apply is implemented only for proof-gated unique-anchor edits. +- Implemented product UX P1: project/global lifecycle commands (`tfy start`, `tfy stop`, `tfy status --agent/--human`, `tfy fuckyou`, `tfy global start|stop|fuckyou`) plus global default aliases (`tfy use always|stop|cancel|fuckyou`) as configuration-intent state, arrow-key TUI selection for bare interactive lifecycle commands, target aliases (`agent`/`ai`, `human`, `both`), lifecycle `route_state`/`active` fields that stay below launch support without evidence, `tfy start --agent` / `tfy start agent` as safe default project setup for `.tfy/agent/tfy-agent-wrapper` plus Codex and Claude Code official project hook routes, `tfy start --agent --no-apply` as lifecycle-intent-only escape hatch, `tfy start --agent --host codex` as explicit Codex project `.codex/config.toml` PreToolUse Bash hook writer, `tfy start --agent --host claude-code` as explicit Claude Code project `.claude/settings.json` PreToolUse Bash hook writer, `tfy start agent --host all --apply` as ordered Codex → Claude Code safe-writer orchestration. Setup snippets remain available through `tfy setup --ai --host ` / `tfy mcp install --target --dry-run`; Codex and Claude Code hook setup is official-doc-backed but evidence-gated. Safe reversible project config apply/uninstall is implemented for Codex `.codex/config.toml`, Claude Code `.claude/settings.json`, legacy Claude Code `.mcp.json` cleanup when TFY-owned. `tfy hook capabilities` / `tfy hook install --dry-run` / `tfy hook run` now model official-host-hook routing for Codex and Claude Code plus a test shim, and all hook routes remain thin routers into shared TFY gateways. `status --json` exposes lifecycle summary and effective project-over-global desired/configured/active state; `launch-report --json` exposes the minimum v1 host matrix (`tfy_agent_adapter`, `generic_shell`, plus advanced/complementary `mcp_stdio` and non-required named hosts) with evidence ladder (`config_snippet_available` → `config_written` → `host_launched` → `verified_host_mcp_invocation` / `verified_host_hook` → `route_evidence_recorded` → `savings_verified` → `launch_supported`). The v1 pass gate requires `tfy_agent_adapter` and `generic_shell` to become `launch_supported`; MCP is advanced/complementary. Codex and Claude Code remain below launch support until real host invocation plus host-bound TFY ledger/raw/no-negative/positive-savings evidence exists. +- Explicitly out of scope: provider/API request proxying and editor auto-integration. Not claimed: private/hidden Codex prompt hooks or universal human-shell interception. Supported AI routing is through wrapper/adapter host configuration, with MCP as an advanced complementary route, and fuzzy workspace apply is implemented only for proof-gated unique-anchor edits. -`tfy start --agent` automatically configures the highest-priority supported host route with an implemented safe reversible project writer: Codex project `.codex/config.toml`. Claude Code project `.mcp.json` and Cursor project `.cursor/mcp.json` are available through explicit `--host claude-code`, `--host cursor`, or `--host all`; Codex AGENTS.md guidance through `tfy init` remains optional guidance, not the MCP route itself. Use `tfy start --agent --no-apply` when lifecycle intent should be recorded without host config mutation. TFY should not claim automatic model input/output interception for a runtime until that runtime adapter exists and passes the relevant gates. The MCP foundation is a supported MCP tool/resource integration point; it still requires the host agent to route through MCP and is not a private Codex hook or universal shell interception layer. Hook support is a thin router posture only: no hook route may implement summarization, redaction, restore, apply, or claim promotion outside the shared TFY gateways, and host-specific hook writers stay dry-run/planned until official docs, kill-switch, uninstall, and e2e evidence exist. Setup/config success is not token-savings success: launch-supported status requires real host invocation plus route-bound TFY raw/ledger/no-negative/positive-savings/overhead evidence, and is demoted if the evidence is stale, reverify fails, or the host/route/config/version scope changes. +`tfy start --agent` creates the project agent command wrapper at `.tfy/agent/tfy-agent-wrapper` and also writes the default safe Codex and Claude Code official project hook routes. `tfy start --agent --host codex` remains a narrower explicit route and writes a TFY-owned `[[hooks.PreToolUse]]` Bash route in `.codex/config.toml` that calls `.tfy/agent/codex-pre-tool-use`; users still rely on Codex's trusted `.codex/` layer and may inspect/manage hooks through the host. `tfy start --agent --host claude-code` writes a TFY-owned PreToolUse Bash route in `.claude/settings.json` that calls `.tfy/agent/claude-pre-tool-use`. `tfy agent run` remains the wrapper/manual fallback, and `tfy hook run --host codex|claude-code` is the official-host-hook ingress: as a PreToolUse command hook it rewrites the pending Bash command to execute through TFY rather than executing that pending command inside the hook process, then the rewritten command records host provenance when the host runs it. MCP remains a complementary tool/resource integration point for supported/configurable hosts, not the default command-interception path. Use `tfy start --agent --no-apply` when lifecycle intent should be recorded without host config mutation. Setup/config success is not token-savings success: launch-supported status requires real host invocation plus route-bound TFY raw/ledger/no-negative/positive-savings/overhead evidence, and is demoted if the evidence is stale, reverify fails, or the host/route/config/version scope changes. -## MCP/Codex adapter foundation +## Product lifecycle and advanced MCP foundation Product-facing lifecycle path: ```bash tfy start # interactive TUI wizard for agent/human/both when attached to a TTY -tfy start --agent # project AI-agent lifecycle + safe default Codex MCP route configuration +tfy start --agent # project AI-agent lifecycle + wrapper + Codex/Claude Code hooks tfy start --human # enter supported platform-shell project-scoped human auto-intercept session and mark repo tfy setup --human --apply # manually install the same explicit user rc hook offered by interactive start tfy start --human --auto-activate # non-interactive/automation marker creation without entering a shell -tfy start ai # positional alias for --agent; also safe-configures Codex by default +tfy start ai # positional alias for --agent; same default wrapper + Codex/Claude setup tfy start both # records agent+human intent; run `tfy start --human` alone to enter the human session tfy start --agent --no-apply # lifecycle intent only; no host config writes -tfy start --agent --host codex # writes project .codex/config.toml, active=false until evidence -tfy start --agent --host claude-code # writes project .mcp.json, active=false until evidence/approval -tfy start --agent --host cursor # writes project .cursor/mcp.json, active=false until evidence +tfy start --agent --host codex # writes project .codex/config.toml PreToolUse Bash hook, active=false until evidence +tfy start --agent --host claude-code # writes project .claude/settings.json PreToolUse Bash hook, active=false until evidence tfy status --agent --json tfy stop --agent # disable intent without deleting raw evidence tfy fuckyou --agent --yes # scoped TFY-owned lifecycle cleanup; preserves .tfy/raw by default @@ -268,7 +270,7 @@ tfy use always --agent # convenience alias for user-global default-on intent tfy use cancel --agent # convenience alias for user-global default-off intent ``` -Lifecycle commands write project state to `.tfy/lifecycle.json` and global state under `$TFY_HOME`, `$XDG_CONFIG_HOME/tfy`, or `~/.tfy`; global host options record default guidance only and do not mutate per-project host config; `start` prepares raw and ledger directories (`.tfy/raw`, `.tfy/state`, `.tfy/adapter`, `.tfy/agent`, `.tfy/mcp`) before later command/context evidence is recorded. Bare interactive `start`, `stop`, and `fuckyou` open an arrow-key TUI; piped stdin choices remain supported for scripts, and missing non-TTY choices fail closed. They do not prove host invocation or token savings by themselves: agent mode auto-configures only supported safe project routes, defaulting to Codex first, and still requires the host to reload/use the TFY route, while human mode does not globally intercept ordinary terminal commands. On supported platform shells (Linux bash, macOS zsh, Windows PowerShell), `tfy start --human` records lifecycle intent and, from an interactive project-only terminal run, creates/refreshes `.tfy/human/auto-activate.json` plus deterministic shell-specific activation content (`auto-activate.bash`, `auto-activate.zsh`, or `auto-activate.ps1`) before offering a default-No prompt to install the one-time user rc/profile hook when it is missing, then entering a TFY-managed project-scoped shell session. Non-interactive plain start remains lifecycle intent-only, while `tfy start --human --auto-activate` is the explicit automation path for marker creation without entering a shell; neither path silently mutates shell rcfiles. Only the accepted interactive prompt or an explicit one-time user rc/profile hook command makes new supported shell sessions auto-activate in trusted marked repos: use `tfy setup --human` to dry-run and `tfy setup --human --apply` to install later; the longer `tfy human auto-activate install --shell --rcfile --apply` remains supported for power users. npm install prints this opt-in guidance but never mutates shell rcfiles. The hook uses a pinned absolute TFY executable, validates/regenerates deterministic repo activation content, and never trusts PATH-resolved `tfy`. Allowlisted commands route through raw-first command-output capture before summary selection; the current ledger stores a combined raw output ref. Shell-local functions, aliases, builtins, direct paths, explicit `tfy-human-bypass`, TFY gateway, and outside-scope commands run raw without a summary claim; automatic interactive/TUI/stateful subcommand classification is not claimed in v1. `tfy human shell --no-auto-intercept` keeps only the managed shell environment without allowlisted wrappers, and `tfy human install --dry-run|--output ` generates the sourceable script. `tfy start --agent` records lifecycle desire and, unless `--no-apply` is passed, writes the Codex project MCP route as `configured_unverified`; it still keeps `active=false` until lifecycle desire is on and route-bound raw/ledger/no-negative/positive-savings evidence exists. `tfy status` reports a user-facing lifecycle summary plus effective project-over-global desired/configured/active state and next action guidance. Outside a TFY-managed human session, `tfy shell ` runs raw without TFY savings/artifacts; use `tfy shell -- ` for the explicit TFY wrapper. Raw evidence remains managed through `tfy raw`; `fuckyou` preserves `.tfy/raw` and shared ledgers by default. +Lifecycle commands write project state to `.tfy/lifecycle.json` and global state under `$TFY_HOME`, `$XDG_CONFIG_HOME/tfy`, or `~/.tfy`; global host options record default guidance only and do not mutate per-project host config; `start` prepares raw and ledger directories (`.tfy/raw`, `.tfy/state`, `.tfy/adapter`, `.tfy/agent`, `.tfy/mcp`) before later command/context evidence is recorded. Bare interactive `start`, `stop`, and `fuckyou` open an arrow-key TUI; piped stdin choices remain supported for scripts, and missing non-TTY choices fail closed. They do not prove host invocation or token savings by themselves: agent mode prepares the project wrapper plus Codex/Claude Code hook routes by default and still requires the chosen host to actually invoke TFY, while human mode does not globally intercept ordinary terminal commands. On supported platform shells (Linux bash, macOS zsh, Windows PowerShell), `tfy start --human` records lifecycle intent and, from an interactive project-only terminal run, creates/refreshes `.tfy/human/auto-activate.json` plus deterministic shell-specific activation content (`auto-activate.bash`, `auto-activate.zsh`, or `auto-activate.ps1`) before offering a default-No prompt to install the one-time user rc/profile hook when it is missing, then entering a TFY-managed project-scoped shell session. Non-interactive plain start remains lifecycle intent-only, while `tfy start --human --auto-activate` is the explicit automation path for marker creation without entering a shell; neither path silently mutates shell rcfiles. Only the accepted interactive prompt or an explicit one-time user rc/profile hook command makes new supported shell sessions auto-activate in trusted marked repos: use `tfy setup --human` to dry-run and `tfy setup --human --apply` to install later; the longer `tfy human auto-activate install --shell --rcfile --apply` remains supported for power users. npm install prints this opt-in guidance but never mutates shell rcfiles. The hook uses a pinned absolute TFY executable, validates/regenerates deterministic repo activation content, and never trusts PATH-resolved `tfy`. Allowlisted commands route through raw-first command-output capture before summary selection; the current ledger stores a combined raw output ref. Shell-local functions, aliases, builtins, direct paths, explicit `tfy-human-bypass`, TFY gateway, and outside-scope commands run raw without a summary claim; automatic interactive/TUI/stateful subcommand classification is not claimed in v1. `tfy human shell --no-auto-intercept` keeps only the managed shell environment without allowlisted wrappers, and `tfy human install --dry-run|--output ` generates the sourceable script. `tfy start --agent` records lifecycle desire and, unless `--no-apply` is passed, writes `.tfy/agent/tfy-agent-wrapper` plus Codex/Claude Code project hook routes as `configured_unverified`; it still keeps `active=false` until lifecycle desire is on and route-bound raw/ledger/no-negative/positive-savings evidence exists. `tfy status` reports a user-facing lifecycle summary plus effective project-over-global desired/configured/active state and next action guidance. Outside a TFY-managed human session, `tfy shell ` runs raw without TFY savings/artifacts; use `tfy shell -- ` for the explicit TFY wrapper. Raw evidence remains managed through `tfy raw`; `fuckyou` preserves `.tfy/raw` and shared ledgers by default. Product-facing happy path: @@ -278,11 +280,12 @@ tfy init --codex --project --apply tfy setup --human --apply # opt-in human auto-activation hook for trusted marked repos tfy setup --ai --host codex --apply --project tfy setup --ai --host claude-code --apply --project -tfy setup --ai --host cursor --apply --project tfy doctor --codex tfy smoke --mcp tfy smoke --all --json # emits adapter + agent + MCP ledger paths for launch-report evidence tfy smoke --codex +tfy smoke --host codex --live --json # opt-in live Codex CLI hook smoke; emits host_evidence when it passes +tfy smoke --host claude-code --live --json # opt-in live Claude Code hook smoke; emits host_evidence when it passes tfy gain # reports no-data until command-output savings events exist # smoke --all emits ledger= and host_evidence= entries that can be passed to launch-report. tfy launch-report --all --ledger --ledger --ledger --host-evidence --json @@ -290,7 +293,7 @@ tfy launch-report --all --ledger --ledger --ledg `tfy init` defaults to safe project dry-run behavior. `--apply` writes only a TFY-owned marker block between `` and ``; `tfy init --uninstall --codex --project --apply` removes only that block and preserves non-TFY content. Global mode targets `~/.codex/AGENTS.md` instruction guidance and still does not directly mutate `~/.codex/config.toml` in P0. -TFY can run as an MCP stdio server for agent hosts that support MCP: +TFY can also run as an MCP stdio server for agent hosts that support MCP. This is an advanced/complementary tool-resource route, not the default command-wrapper path: ```bash tfy mcp capabilities @@ -309,7 +312,7 @@ MCP Code I/O workflow: 5. `tfy_restore_display` turns compact/restored code into human-readable display text only; it does not create apply authority. 6. `tfy_workspace_validate` and `tfy_workspace_apply` validate/apply explicit WorkspaceApplyPlan operations with a plan hash plus per-operation proofs, including exact multi-file operations (one mutation per target file per plan) and conservative unique-anchor fuzzy edits with required base and preview proof hashes. -This is MCP tool/resource integration. It does not claim private Codex hook interception, provider prompt mutation, or universal shell interception without host MCP routing. +This is MCP tool/resource integration. It does not claim private Codex hook interception, provider prompt mutation, universal shell interception, or default command interception without explicit host routing. Launch reporting uses exact byte counts recorded from raw/model-visible gateway payloads and a conservative `ceil(bytes/4)` token proxy when tokenizer-specific counts are unavailable. Local smoke ledgers can raise required routes to `verified_local_mcp`, but `launch_supported` additionally requires `--host-evidence` JSON proving setup, real host invocation, config scope/path, route type, smoke id, ledger/raw artifacts, and no-negative plus positive savings for each route. `tfy explain` discloses the local raw-store contract: TFY stores raw command/context evidence under `.tfy/raw` or configured `--raw-dir` plus gateway ledgers such as `.tfy/mcp/ledger.jsonl` and `.tfy/adapter/ledger.jsonl`; TFY does not upload raw evidence. First-class raw lifecycle commands are available: `tfy raw --list`, `tfy raw --inspect`, `tfy raw --export `, and `tfy raw --prune --dry-run/--apply`; ledger files remain explicit local files. diff --git a/crates/tfy-cli/src/adapter.rs b/crates/tfy-cli/src/adapter.rs index e36291e..8b8f6c6 100644 --- a/crates/tfy-cli/src/adapter.rs +++ b/crates/tfy-cli/src/adapter.rs @@ -92,29 +92,6 @@ pub(crate) fn execute_adapter(cmd: AdapterCmd) -> Result<()> { "automatic_interception": "mcp_host_routing_required_not_private_hook", "claim_gate": "real_host_invocation_plus_ledger_raw_no_negative_savings_evidence" }, - { - "target": "cursor", - "status": "config_snippet_available", - "automatic_interception": "mcp_host_routing_required_not_private_hook", - "claim_gate": "real_host_invocation_plus_ledger_raw_no_negative_savings_evidence" - }, - { - "target": "opencode", - "status": "config_snippet_available", - "automatic_interception": "mcp_host_routing_required_not_private_hook", - "claim_gate": "real_host_invocation_plus_ledger_raw_no_negative_savings_evidence" - }, - { - "target": "hermes", - "status": "config_snippet_available", - "automatic_interception": "mcp_host_routing_required_not_private_hook", - "claim_gate": "real_host_invocation_plus_ledger_raw_no_negative_savings_evidence" - }, - { - "target": "openclaw", - "status": "planned_discovery", - "automatic_interception": "not_claimed_without_official_route_evidence" - }, { "target": "mcp", "status": "supported", @@ -122,16 +99,6 @@ pub(crate) fn execute_adapter(cmd: AdapterCmd) -> Result<()> { "gateways": ["tool", "context", "output", "state"], "transport": "stdio", "claim_gate": "mcp_server_fixture_tests" - }, - { - "target": "editor", - "status": "planned", - "automatic_interception": "not_claimed_without_host_e2e_tests" - }, - { - "target": "provider", - "status": "planned_optional_adapter", - "automatic_interception": "not_core_correctness" } ] })), diff --git a/crates/tfy-cli/src/hook.rs b/crates/tfy-cli/src/hook.rs index caeac40..1657e9c 100644 --- a/crates/tfy-cli/src/hook.rs +++ b/crates/tfy-cli/src/hook.rs @@ -1,7 +1,8 @@ use crate::gateways::execute_structured_tool_gateway_with_origin; use crate::util::print_json; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use clap::Subcommand; +use std::io::{self, Read}; use std::path::PathBuf; use tfy_runtime::{ AdapterKind, Origin, OriginHost, OriginInvocation, RouteClaimTier, RouteEvidence, @@ -19,7 +20,7 @@ pub(crate) enum HookCmd { #[arg(long)] dry_run: bool, }, - /// Test-only hook ingress shim. Routes an event command through the shared Tool Gateway. + /// Official host hook ingress. PreToolUse rewrites host Bash input; explicit commands execute through the shared Tool Gateway. Run { #[arg(long, default_value = "test-shim")] host: String, @@ -54,22 +55,18 @@ pub(crate) fn execute_hook(cmd: HookCmd) -> Result<()> { match cmd { HookCmd::Capabilities => print_json(&serde_json::json!({ "adapter_version": env!("CARGO_PKG_VERSION"), - "default_policy": "disabled_until_official_host_docs_and_e2e_evidence", + "default_policy": "official_docs_backed_for_codex_and_claude_code_but_launch_evidence_gated", "role": "thin_router_to_shared_tfy_gateways", "does_not_implement": ["summarization", "redaction", "restore", "apply", "claim_promotion"], "kill_switch": { "env": "TFY_HOOK_DISABLE=1", - "per_host": "planned", - "uninstall": "required_before_host_writer_support" + "per_host": "same TFY-owned project config uninstall path as setup", + "uninstall": "tfy fuckyou --agent --yes or host-specific setup uninstall" }, "targets": [ {"target":"test-shim","status":"supported_for_equivalence_tests","claim_tier":"route_evidence_recorded"}, - {"target":"codex","status":"unsupported_without_public_official_hook","claim_tier":"unsupported"}, - {"target":"cursor","status":"planned_official_docs_required","claim_tier":"planned_discovery"}, - {"target":"opencode","status":"planned_official_docs_required","claim_tier":"planned_discovery"}, - {"target":"claude-code","status":"planned_official_docs_required","claim_tier":"planned_discovery"}, - {"target":"hermes","status":"planned_official_docs_required","claim_tier":"planned_discovery"}, - {"target":"openclaw","status":"planned_discovery","claim_tier":"planned_discovery"} + {"target":"codex","status":"supported_configured_unverified","claim_tier":"config_written"}, + {"target":"claude-code","status":"supported_configured_unverified","claim_tier":"config_written"} ], "not_claimed": ["private_codex_hook", "provider_prompt_gateway", "universal_shell_interception", "editor_auto_integration"] })), @@ -90,11 +87,11 @@ pub(crate) fn execute_hook(cmd: HookCmd) -> Result<()> { if std::env::var("TFY_HOOK_DISABLE").ok().as_deref() == Some("1") { bail!("TFY hook route disabled by TFY_HOOK_DISABLE=1"); } - if !host.eq_ignore_ascii_case("test-shim") { - bail!( - "hook run is supported only for test-shim until official host docs, uninstall, kill-switch, and e2e evidence exist for target '{host}'" - ); + ensure_supported_hook_host(&host)?; + if command.is_empty() && is_real_hook_host(&host) { + return print_host_rewrite(&host, raw_dir, ledger, session); } + let command = resolve_hook_command(&host, command)?; execute_structured_tool_gateway_with_origin( command, raw_dir, @@ -114,40 +111,63 @@ pub(crate) fn execute_hook(cmd: HookCmd) -> Result<()> { } } +fn ensure_supported_hook_host(host: &str) -> Result<()> { + if host.eq_ignore_ascii_case("test-shim") || is_real_hook_host(host) { + return Ok(()); + } + bail!("host '{host}' is not available in TFY agent mode; supported hosts: codex, claude-code") +} + +fn is_real_hook_host(host: &str) -> bool { + matches!( + host.to_ascii_lowercase().as_str(), + "codex" | "claude-code" | "claude" + ) +} + fn execute_hook_install(target: &str, dry_run: bool) -> Result<()> { if !dry_run { bail!("hook install is dry-run only until host official docs, kill-switch, uninstall, and e2e evidence exist"); } let status = match target { "test-shim" => "supported_for_equivalence_tests", - "codex" => "unsupported_without_public_official_hook", - "cursor" | "opencode" | "claude-code" | "hermes" | "openclaw" => { - "planned_official_docs_required" - } - other => bail!("unknown hook target '{other}'"), + "codex" | "claude-code" => "supported_configured_unverified", + other => bail!( + "target '{}' is not available in TFY agent mode; supported hosts: codex, claude-code", + other + ), }; println!("TFY hook install dry-run"); println!("target={target} status={status}"); println!("boundary=official host hooks only; no private Codex/provider/universal interception"); println!("kill_switch=TFY_HOOK_DISABLE=1"); - println!("route=host hook event -> tfy hook run/test shim -> shared Tool Gateway"); + println!("route=host hook event -> tfy hook run --host {target} -> shared Tool Gateway"); + if matches!(target, "codex" | "claude-code") { + println!("launch_supported=false until real host evidence proves route-bound raw/ledger/no-negative/positive-savings"); + } Ok(()) } fn hook_route_evidence(host: &str) -> RouteEvidence { let parsed = parse_hook_host(host); let supported_test_shim = host.eq_ignore_ascii_case("test-shim"); + let supported_official_host = matches!( + host.to_ascii_lowercase().as_str(), + "codex" | "claude-code" | "claude" + ); RouteEvidence { ingress: RouteIngressKind::HostHook, host: parsed, claim_tier: if supported_test_shim { RouteClaimTier::RouteEvidenceRecorded + } else if supported_official_host { + RouteClaimTier::ConfigWritten } else { RouteClaimTier::PlannedDiscovery }, - official_docs_backed: supported_test_shim, + official_docs_backed: supported_test_shim || supported_official_host, kill_switch_available: true, - uninstall_available: supported_test_shim, + uninstall_available: supported_test_shim || supported_official_host, ..RouteEvidence::cli_gateway() } } @@ -155,7 +175,195 @@ fn hook_route_evidence(host: &str) -> RouteEvidence { fn parse_hook_host(host: &str) -> OriginHost { match host.to_ascii_lowercase().as_str() { "codex" => OriginHost::Codex, + "claude-code" | "claude" => OriginHost::ClaudeCode, "omx" => OriginHost::Omx, - _ => OriginHost::Generic, + "test-shim" | "generic" => OriginHost::Generic, + _ => OriginHost::Unknown, } } + +fn resolve_hook_command(host: &str, command: Vec) -> Result> { + if !command.is_empty() { + return Ok(command); + } + if host.eq_ignore_ascii_case("test-shim") { + bail!("hook run requires a command after -- for test-shim"); + } + let mut stdin = String::new(); + io::stdin() + .read_to_string(&mut stdin) + .context("read host hook JSON from stdin")?; + let value: serde_json::Value = serde_json::from_str(&stdin).context( + "parse host hook JSON from stdin; unsupported or malformed hook payload fails closed", + )?; + validate_pre_tool_use_shell_payload(&value)?; + extract_hook_command(&value) + .ok_or_else(|| anyhow!("unsupported hook payload: missing shell command field")) +} + +fn print_host_rewrite( + host: &str, + raw_dir: PathBuf, + ledger: PathBuf, + session: String, +) -> Result<()> { + let mut stdin = String::new(); + io::stdin() + .read_to_string(&mut stdin) + .context("read host hook JSON from stdin")?; + let value: serde_json::Value = serde_json::from_str(&stdin).context( + "parse host hook JSON from stdin; unsupported or malformed hook payload fails closed", + )?; + validate_pre_tool_use_shell_payload(&value)?; + let original = extract_hook_command_text(&value) + .ok_or_else(|| anyhow!("unsupported hook payload: missing shell command field"))?; + if command_is_tfy_hook_route(&original) { + print_json(&serde_json::json!({}))?; + return Ok(()); + } + let rewritten = rewrite_command_through_tfy_hook(host, &raw_dir, &ledger, &session, &original)?; + print_json(&serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "updatedInput": { + "command": rewritten + } + } + })) +} + +fn validate_pre_tool_use_shell_payload(value: &serde_json::Value) -> Result<()> { + let Some(event) = value + .pointer("/hook_event_name") + .or_else(|| value.pointer("/hookEventName")) + .and_then(serde_json::Value::as_str) + else { + bail!("unsupported hook payload: missing hook_event_name=PreToolUse"); + }; + if event != "PreToolUse" { + bail!("unsupported hook payload: only PreToolUse events are routed through TFY"); + } + if !hook_payload_targets_shell(value) { + bail!("unsupported hook payload: only Bash/shell command events are routed through TFY"); + } + Ok(()) +} + +fn hook_payload_targets_shell(value: &serde_json::Value) -> bool { + let candidates = [ + "/tool_name", + "/tool", + "/name", + "/matcher", + "/tool_input/tool_name", + "/input/tool_name", + ]; + for pointer in candidates { + if let Some(text) = value.pointer(pointer).and_then(serde_json::Value::as_str) { + let lower = text.to_ascii_lowercase(); + if matches!(lower.as_str(), "bash" | "shell" | "sh") { + return true; + } + } + } + false +} + +fn extract_hook_command(value: &serde_json::Value) -> Option> { + let string_paths = [ + "/command", + "/tool_input/command", + "/input/command", + "/arguments/command", + "/params/command", + ]; + for pointer in string_paths { + if let Some(command) = value.pointer(pointer).and_then(serde_json::Value::as_str) { + if !command.trim().is_empty() { + return Some(vec!["sh".into(), "-c".into(), command.into()]); + } + } + } + let array_paths = ["/argv", "/command_argv", "/tool_input/argv", "/input/argv"]; + for pointer in array_paths { + if let Some(array) = value.pointer(pointer).and_then(serde_json::Value::as_array) { + let argv = array + .iter() + .map(serde_json::Value::as_str) + .collect::>>()?; + if !argv.is_empty() && argv.iter().all(|part| !part.trim().is_empty()) { + return Some(argv.into_iter().map(str::to_string).collect()); + } + } + } + None +} + +fn extract_hook_command_text(value: &serde_json::Value) -> Option { + let string_paths = [ + "/command", + "/tool_input/command", + "/input/command", + "/arguments/command", + "/params/command", + ]; + string_paths.iter().find_map(|pointer| { + value + .pointer(pointer) + .and_then(serde_json::Value::as_str) + .filter(|command| !command.trim().is_empty()) + .map(str::to_string) + }) +} + +fn rewrite_command_through_tfy_hook( + host: &str, + raw_dir: &PathBuf, + ledger: &PathBuf, + session: &str, + original: &str, +) -> Result { + let exe = std::env::current_exe().context("resolve current tfy executable")?; + let exe = exe + .to_str() + .ok_or_else(|| anyhow!("current tfy executable path is not valid UTF-8"))?; + let raw_dir = absolute_path(raw_dir)?; + let ledger = absolute_path(ledger)?; + Ok(format!( + "{} hook run --host {} --session {} --raw-dir {} --ledger {} -- sh -c {}", + shell_single_quote(exe), + shell_single_quote(canonical_hook_host(host)), + shell_single_quote(session), + shell_single_quote(&raw_dir.display().to_string()), + shell_single_quote(&ledger.display().to_string()), + shell_single_quote(original) + )) +} + +fn absolute_path(path: &PathBuf) -> Result { + if path.is_absolute() { + Ok(path.clone()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn canonical_hook_host(host: &str) -> &str { + if host.eq_ignore_ascii_case("claude") { + "claude-code" + } else { + host + } +} + +fn command_is_tfy_hook_route(command: &str) -> bool { + command.contains(" hook run --host ") + || command.contains(" hook run --host=") + || command.starts_with("tfy hook run --host ") + || command.starts_with("tfy hook run --host=") +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} diff --git a/crates/tfy-cli/src/main.rs b/crates/tfy-cli/src/main.rs index 006c7a7..078fea2 100644 --- a/crates/tfy-cli/src/main.rs +++ b/crates/tfy-cli/src/main.rs @@ -63,7 +63,7 @@ enum Cmd { #[command(subcommand)] cmd: AdapterCmd, }, - /// MCP stdio server and Codex setup commands for agent-native tool/resource integration. + /// MCP stdio server and host setup commands for advanced agent-native tool/resource integration. Mcp { #[command(subcommand)] cmd: McpCmd, @@ -78,13 +78,13 @@ enum Cmd { #[command(subcommand)] cmd: HumanCmd, }, - /// Product-facing setup lifecycle for Codex/MCP guidance. Bare `tfy init` is a safe dry-run. + /// Product-facing setup lifecycle guidance. Bare `tfy init` is a safe dry-run. Init(InitCmd), /// Diagnose local TFY and optional Codex-facing integration readiness. Doctor(DoctorCmd), - /// Run local MCP smoke tests or print host-facing smoke checklists. + /// Run local adapter/agent/MCP smoke tests or print host-facing smoke checklists. Smoke(SmokeCmd), - /// Report measured TFY savings from adapter/MCP ledgers. + /// Report measured TFY savings from adapter/agent/MCP ledgers. Gain(GainCmd), /// Easy setup for AI-agent host routing or explicit human shell auto-activation. Setup(SetupCmd), diff --git a/crates/tfy-cli/src/mcp.rs b/crates/tfy-cli/src/mcp.rs index 0eea80e..55542eb 100644 --- a/crates/tfy-cli/src/mcp.rs +++ b/crates/tfy-cli/src/mcp.rs @@ -63,7 +63,7 @@ fn mcp_capabilities() -> serde_json::Value { "stdout_contract": "json_rpc_only", "logs": "stderr_or_file_only", "automatic_interception": "mcp_host_routing_required_not_private_hook", - "supported_targets": ["mcp-stdio", "codex-setup-snippet", "claude-code-setup-snippet", "cursor-setup-snippet", "opencode-setup-snippet", "hermes-setup-snippet", "openclaw-planned-discovery"], + "supported_targets": ["mcp-stdio", "codex-setup-snippet", "claude-code-setup-snippet"], "not_claimed": ["private_codex_hook", "provider_prompt_gateway", "universal_shell_interception"], "tools": mcp_tools(), "resource_templates": mcp_resource_templates(), @@ -119,19 +119,10 @@ fn mcp_install_text(target: &str, session: &str) -> Result { "claude-code" | "claude" => format!( "TFY MCP Claude Code setup dry-run\n\nclaude mcp add tfy -- tfy mcp serve --session {session} --ledger .tfy/mcp/ledger.jsonl --raw-dir .tfy/raw\n\n# Project .mcp.json snippet\n{{\n \"mcpServers\": {{\n \"tfy\": {{\"command\": \"tfy\", \"args\": {args}}}\n }}\n}}\n\n{boundary}\n" ), - "cursor" => format!( - "TFY MCP Cursor setup dry-run\n\n# ~/.cursor/mcp.json or project .cursor/mcp.json snippet\n{{\n \"mcpServers\": {{\n \"tfy\": {{\"command\": \"tfy\", \"args\": {args}}}\n }}\n}}\n\n{boundary}\n" + other => bail!( + "target '{}' is not available in TFY agent mode; supported hosts: codex, claude-code", + other ), - "opencode" | "open-code" => format!( - "TFY MCP OpenCode setup dry-run\n\n# opencode.json(c) snippet; JSONC apply is dry-run only until comment-preserving writer exists\n{{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"mcp\": {{\n \"tfy\": {{\"type\": \"local\", \"command\": [\"tfy\", \"mcp\", \"serve\", \"--session\", \"{session}\", \"--ledger\", \".tfy/mcp/ledger.jsonl\", \"--raw-dir\", \".tfy/raw\"], \"enabled\": true}}\n }}\n}}\n\n{boundary}\n" - ), - "hermes" => format!( - "TFY MCP Hermes setup dry-run\n\nhermes mcp add tfy --command tfy --args mcp serve --session {session} --ledger .tfy/mcp/ledger.jsonl --raw-dir .tfy/raw\n\n# ~/.hermes/config.yaml snippet\nmcp_servers:\n tfy:\n command: \"tfy\"\n args: [\"mcp\", \"serve\", \"--session\", \"{session}\", \"--ledger\", \".tfy/mcp/ledger.jsonl\", \"--raw-dir\", \".tfy/raw\"]\n enabled: true\n tools:\n include: [tfy_tool_run, tfy_raw_get, tfy_scope_list, tfy_context_get, tfy_output_validate, tfy_output_apply, tfy_state_project, tfy_adapter_report]\n\n{boundary}\n" - ), - "openclaw" => format!( - "TFY MCP OpenClaw setup dry-run\n\nstatus=planned_discovery\nNo setup snippet is emitted until official/current evidence proves a TFY-consumable MCP or wrapper route.\n\n{boundary}\n" - ), - _ => bail!("mcp install target '{target}' is not implemented; supported targets: codex, claude-code, cursor, opencode, hermes, openclaw"), }; Ok(body) } diff --git a/crates/tfy-cli/src/product.rs b/crates/tfy-cli/src/product.rs index b47b53f..832c554 100644 --- a/crates/tfy-cli/src/product.rs +++ b/crates/tfy-cli/src/product.rs @@ -14,8 +14,9 @@ use std::collections::BTreeMap; use std::fs; use std::io::{BufRead, BufReader, ErrorKind, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tfy_core::{raw_output_bytes, summarize_command_output_with_policy, ToolPolicy}; use tfy_runtime::{load_events, AdapterKind, GatewayEvent, OriginInvocation}; @@ -59,7 +60,7 @@ pub(crate) struct InitCmd { pub(crate) struct DoctorCmd { #[arg(long)] pub codex: bool, - /// Named AI-agent host to diagnose (codex, claude-code, cursor, opencode, hermes, openclaw). + /// Named AI-agent host to diagnose (codex, claude-code). #[arg(long = "host")] pub host: Vec, #[arg(long)] @@ -81,6 +82,12 @@ pub(crate) struct SmokeCmd { /// Named AI-agent host checklist/smoke target. #[arg(long = "host")] pub host: Vec, + /// Run an installed host CLI against a temporary project and emit launch-report host evidence. + #[arg(long)] + pub live: bool, + /// Maximum Claude Code API spend for live host smoke, when the host supports it. + #[arg(long, default_value = "0.50")] + pub max_budget_usd: String, } #[derive(Args, Clone)] @@ -103,10 +110,10 @@ pub(crate) struct SetupCmd { /// Prepare explicit human shell auto-activation for trusted TFY-marked current directories. Dry-run by default; writes only with --apply. #[arg(long)] pub human: bool, - /// Print Codex MCP/instruction setup guidance. + /// Print Codex integration setup guidance. #[arg(long)] pub codex: bool, - /// Named AI-agent host to configure (codex, claude-code, cursor, opencode, hermes, openclaw). + /// Named AI-agent host to configure (codex, claude-code). #[arg(long = "host")] pub host: Vec, #[arg(long)] @@ -149,10 +156,10 @@ pub(crate) struct StartCmd { /// Target alias: agent/ai, human, or both. #[arg(value_name = "TARGET")] pub target_alias: Option, - /// Named AI-agent host setup route (codex, claude-code, cursor, opencode, hermes, openclaw, all). + /// Named AI-agent host setup route (codex, claude-code, all). #[arg(long)] pub host: Option, - /// Apply only safe, implemented host configuration writers. Unsupported hosts stay guidance-only. + /// Apply only safe Codex/Claude Code project config writers; unknown hosts fail closed. #[arg(long)] pub apply: bool, /// Record lifecycle intent only; do not write supported host config. @@ -434,6 +441,8 @@ struct HumanLifecycleState { #[derive(Debug, Clone, Serialize, Deserialize)] struct LifecycleHostRoute { + #[serde(default = "default_lifecycle_route_type")] + route_type: String, route_state: String, active: bool, #[serde(default)] @@ -459,6 +468,10 @@ struct LifecycleHostRoute { message: String, } +fn default_lifecycle_route_type() -> String { + "unknown".into() +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct HostConfigProvenance { host: String, @@ -496,9 +509,10 @@ struct LifecycleFile { fn default_agent_intended_routes() -> Vec { vec![ - "mcp_stdio".into(), "agent_wrapper".into(), "generic_shell_adapter".into(), + "official_host_hook_when_configured".into(), + "mcp_stdio_complementary".into(), ] } @@ -637,6 +651,14 @@ struct HostIntegration { evidence_gate: &'static [&'static str], } +#[derive(Clone, Copy)] +struct LiveHostSmokeSpec { + host: &'static str, + binary: &'static str, + timeout: Duration, + needs_noninteractive_bypass: bool, +} + #[derive(Serialize)] struct LaunchReadinessReport { status: String, @@ -1581,26 +1603,77 @@ fn target_names(targets: &[LifecycleTarget]) -> String { fn host_selection(host: Option<&str>) -> Result> { match host { - Some(host) if host.trim().eq_ignore_ascii_case("all") => Ok(host_registry()), + Some(host) if host.trim().eq_ignore_ascii_case("all") => Ok(host_registry() + .into_iter() + .filter(|host| matches!(host.id, "codex" | "claude-code")) + .collect()), Some(host) => Ok(vec![host_integration(host)?]), None => Ok(Vec::new()), } } +fn lifecycle_agent_wrapper_route( + config_path: Option, + message: impl Into, +) -> LifecycleHostRoute { + LifecycleHostRoute { + route_type: "agent_wrapper".into(), + route_state: "configured_unverified".into(), + active: false, + configured: true, + route_configured: true, + host_reload_required: false, + host_route_available_after_reload: false, + host_approval_required: false, + mcp_invocation_observed: false, + route_verified: false, + savings_verified: false, + normal_workflow_supported: false, + config_path, + claim_tier: "configured_unverified".into(), + message: message.into(), + } +} + fn lifecycle_host_route( route_state: &str, config_path: Option, claim_tier: &str, message: impl Into, ) -> LifecycleHostRoute { - lifecycle_host_route_with_approval(route_state, config_path, claim_tier, false, message) + lifecycle_host_route_with_type( + route_state, + config_path, + claim_tier, + false, + "mcp_stdio", + message, + ) +} + +fn lifecycle_official_hook_route( + route_state: &str, + config_path: Option, + claim_tier: &str, + host_approval_required: bool, + message: impl Into, +) -> LifecycleHostRoute { + lifecycle_host_route_with_type( + route_state, + config_path, + claim_tier, + host_approval_required, + "official_host_hook", + message, + ) } -fn lifecycle_host_route_with_approval( +fn lifecycle_host_route_with_type( route_state: &str, config_path: Option, claim_tier: &str, host_approval_required: bool, + route_type: &str, message: impl Into, ) -> LifecycleHostRoute { let configured = matches!( @@ -1621,6 +1694,7 @@ fn lifecycle_host_route_with_approval( let route_verified = matches!(route_state, "verified_host_invocation" | "launch_supported"); let savings_verified = route_state == "launch_supported"; LifecycleHostRoute { + route_type: route_type.into(), route_state: route_state.into(), active: false, configured, @@ -1638,6 +1712,37 @@ fn lifecycle_host_route_with_approval( } } +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn install_project_agent_wrapper(session: &str) -> Result { + let wrapper = PathBuf::from(".tfy/agent/tfy-agent-wrapper"); + if let Some(parent) = wrapper.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let exe = std::env::current_exe().context("resolve current tfy executable")?; + let exe = exe + .to_str() + .ok_or_else(|| anyhow!("current tfy executable path is not valid UTF-8"))?; + let exe = shell_single_quote(exe); + let script = format!( + r#"#!/usr/bin/env sh +# TFY AI-agent command wrapper. Configure an AI agent runtime command executor to call this file. +# Generated by `tfy start --agent`; it does not modify human shell startup files. +exec {exe} agent run --host "${{TFY_AGENT_HOST:-generic}}" --session "${{TFY_SESSION_ID:-{session}}}" -- "$@" +"# + ); + fs::write(&wrapper, script).with_context(|| format!("write {}", wrapper.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod {}", wrapper.display()))?; + } + Ok(wrapper) +} + pub(crate) fn execute_start(cmd: StartCmd) -> Result<()> { execute_lifecycle_start(LifecycleScope::Project, cmd) } @@ -1706,50 +1811,59 @@ fn execute_lifecycle_start(scope: LifecycleScope, cmd: StartCmd) -> Result<()> { .host .as_deref() .is_some_and(|host| host.trim().eq_ignore_ascii_case("all")); - let mut selected_hosts = host_selection(cmd.host.as_deref())?; + let default_agent_wrapper_setup = scope == LifecycleScope::Project + && targets.contains(&LifecycleTarget::Agent) + && cmd.host.is_none() + && !cmd.no_apply; + let default_agent_official_hook_setup = default_agent_wrapper_setup; + let selected_hosts = if default_agent_official_hook_setup { + vec![host_integration("codex")?, host_integration("claude-code")?] + } else { + host_selection(cmd.host.as_deref())? + }; + if selected_hosts + .iter() + .any(|host| host.status == "unsupported") + { + let unsupported = selected_hosts + .iter() + .find(|host| host.status == "unsupported") + .expect("unsupported host present"); + bail!( + "host '{}' is unsupported in TFY current product scope; supported named hosts: codex, claude-code; generic wrapper fallback remains available", + unsupported.id + ); + } let auto_apply_supported_agent_routes = scope == LifecycleScope::Project && targets.contains(&LifecycleTarget::Agent) && !cmd.no_apply - && (cmd.host.is_none() - || cmd.host.as_deref().is_some_and(|host| { - matches!( - host.trim().to_ascii_lowercase().as_str(), - "codex" | "claude-code" | "claude" | "cursor" | "all" - ) - })); - if selected_hosts.is_empty() && auto_apply_supported_agent_routes { - selected_hosts = vec![host_integration("codex")?]; - } - let should_apply_supported_routes = - scope == LifecycleScope::Project && (cmd.apply || auto_apply_supported_agent_routes); + && cmd.host.as_deref().is_some_and(|host| { + matches!( + host.trim().to_ascii_lowercase().as_str(), + "codex" | "claude-code" | "claude" | "all" + ) + }); + let should_apply_supported_routes = scope == LifecycleScope::Project + && (cmd.apply || auto_apply_supported_agent_routes || default_agent_official_hook_setup); let mut state = read_lifecycle(scope)?; let now = now_stamp(); for target in &targets { match target { LifecycleTarget::Agent => { let mut agent = agent_started(&now); + if default_agent_wrapper_setup { + let wrapper_path = install_project_agent_wrapper(&cmd.session)?; + agent.host_routes.insert( + "agent-wrapper".into(), + lifecycle_agent_wrapper_route( + Some(wrapper_path.display().to_string()), + "project TFY agent command wrapper created; configure the AI agent command executor to call this wrapper, then verify real wrapper invocation plus raw/ledger/no-negative/positive-savings evidence before active=true", + ), + ); + } for host in &selected_hosts { if host.id == "codex" && should_apply_supported_routes { - let result = configure_codex_project_mcp( - &cmd.session, - HostConfigScope::Project, - false, - false, - )?; - agent.host_routes.insert( - host.id.into(), - lifecycle_host_route( - "configured_unverified", - result - .get("config_path") - .and_then(Value::as_str) - .map(str::to_string), - "configured_unverified", - "safe project .codex/config.toml writer ran; open/reload Codex in a trusted project, then verify real host invocation plus raw/ledger/no-negative/positive-savings evidence before active=true", - ), - ); - } else if host.id == "claude-code" && should_apply_supported_routes { - let result = configure_claude_project_mcp( + let result = configure_codex_project_hook( &cmd.session, HostConfigScope::Project, false, @@ -1757,7 +1871,7 @@ fn execute_lifecycle_start(scope: LifecycleScope, cmd: StartCmd) -> Result<()> { )?; agent.host_routes.insert( host.id.into(), - lifecycle_host_route_with_approval( + lifecycle_official_hook_route( "configured_unverified", result .get("config_path") @@ -1765,11 +1879,11 @@ fn execute_lifecycle_start(scope: LifecycleScope, cmd: StartCmd) -> Result<()> { .map(str::to_string), "configured_unverified", true, - "safe project .mcp.json writer ran; open/reload Claude Code and approve the project MCP server if prompted, then verify real host invocation plus raw/ledger/no-negative/positive-savings evidence before active=true", + "safe project .codex/config.toml PreToolUse Bash hook writer ran; trust the project .codex layer, review/enable the hook with Codex /hooks, then verify real host invocation plus raw/ledger/no-negative/positive-savings evidence before active=true", ), ); - } else if host.id == "cursor" && should_apply_supported_routes { - let result = configure_cursor_project_mcp( + } else if host.id == "claude-code" && should_apply_supported_routes { + let result = configure_claude_project_hook( &cmd.session, HostConfigScope::Project, false, @@ -1777,14 +1891,15 @@ fn execute_lifecycle_start(scope: LifecycleScope, cmd: StartCmd) -> Result<()> { )?; agent.host_routes.insert( host.id.into(), - lifecycle_host_route( + lifecycle_official_hook_route( "configured_unverified", result .get("config_path") .and_then(Value::as_str) .map(str::to_string), "configured_unverified", - "safe project .cursor/mcp.json writer ran; restart/reload Cursor, then verify real host invocation plus raw/ledger/no-negative/positive-savings evidence before active=true", + false, + "safe project .claude/settings.json PreToolUse Bash hook writer ran; reload Claude Code, then verify real host invocation plus raw/ledger/no-negative/positive-savings evidence before active=true", ), ); } else if should_apply_supported_routes && !host_all { @@ -1817,6 +1932,9 @@ fn execute_lifecycle_start(scope: LifecycleScope, cmd: StartCmd) -> Result<()> { } if cmd.verify { agent.support_status = "verification_requested_route_evidence_required".into(); + } else if default_agent_official_hook_setup { + agent.support_status = + "default_agent_routes_configured_verification_required".into(); } else if agent .host_routes .values() @@ -1863,7 +1981,7 @@ fn execute_lifecycle_start(scope: LifecycleScope, cmd: StartCmd) -> Result<()> { && (cmd.host.is_some() || cmd.apply || cmd.no_apply) { println!( - "global_host_apply=false reason=project_scoped_host_config_required next_action=run `tfy start --agent` inside each project to write Codex/Claude/Cursor config" + "global_host_apply=false reason=project_scoped_host_config_required next_action=run `tfy start --agent` inside each project to create the wrapper, or explicit `--host ...` to write Codex/Claude Code config" ); } if targets.contains(&LifecycleTarget::Agent) { @@ -1873,30 +1991,33 @@ fn execute_lifecycle_start(scope: LifecycleScope, cmd: StartCmd) -> Result<()> { .map(|agent| agent.support_status.as_str()) .unwrap_or("host_route_configuration_required"); println!("agent route_state=intent_recorded active=false support_status={support_status} private_hook_interception=false provider_prompt_gateway=false"); - println!("Configure supported host routing through tfy mcp serve, tfy agent run, or tfy adapter run; lifecycle start does not mark any host launch-supported."); + if default_agent_official_hook_setup { + println!("Agent mode uses the shared TFY command-output pipeline through configured command routes: the TFY agent wrapper fallback plus Codex and Claude Code official project hooks by default. MCP remains an advanced complementary route, not the default command interception path."); + } else { + println!("Agent mode uses the shared TFY command-output pipeline through a configured command route: the TFY agent wrapper/executor route and official Codex/Claude Code host hooks when configured. MCP remains an advanced complementary route, not the default command interception path."); + } + if default_agent_wrapper_setup { + println!("Installed TFY agent command wrapper: .tfy/agent/tfy-agent-wrapper"); + println!("Configure your AI agent host command executor to call: .tfy/agent/tfy-agent-wrapper -- "); + println!("Status: configured, not active."); + println!("Next: run the AI agent through that wrapper and collect raw/ledger/no-negative/positive-savings evidence before active=true."); + println!("host=agent-wrapper route_state=configured_unverified active=false route_configured=true host_reload_required=false host_approval_required=false host_route_available_after_reload=false config_path=.tfy/agent/tfy-agent-wrapper"); + } for host in &selected_hosts { if host.id == "codex" && should_apply_supported_routes { - println!("Configured Codex project MCP route: .codex/config.toml"); - println!("Open/reload Codex in a trusted project to make the route visible."); + println!("Configured Codex project PreToolUse Bash hook route: .codex/config.toml"); + println!("Trust the project .codex layer, then use Codex /hooks to review and enable the TFY hook."); println!("Status: configured, not active."); - println!("Next: open Codex and invoke a TFY MCP tool to verify real host routing."); - println!("host=codex route_state=configured_unverified active=false route_configured=true host_reload_required=true host_approval_required=false host_route_available_after_reload=false config_path=.codex/config.toml"); + println!("Next: run a real Codex Bash command through the hook and collect route-bound raw/ledger/no-negative/positive-savings evidence."); + println!("host=codex route_state=configured_unverified active=false route_configured=true host_reload_required=true host_approval_required=true host_route_available_after_reload=false config_path=.codex/config.toml"); } else if host.id == "claude-code" && should_apply_supported_routes { - println!("Configured Claude Code project MCP route: .mcp.json"); - println!("Open/reload Claude Code and approve the project MCP server if prompted."); + println!("Configured Claude Code project PreToolUse Bash hook route: .claude/settings.json"); + println!("Reload Claude Code so the project hook config is visible."); println!("Status: configured, not active."); println!( - "Next: open Claude Code and invoke a TFY MCP tool to verify real host routing." + "Next: run a real Claude Code Bash command through the hook and collect route-bound raw/ledger/no-negative/positive-savings evidence." ); - println!("host=claude-code route_state=configured_unverified active=false route_configured=true host_reload_required=true host_approval_required=true host_route_available_after_reload=false config_path=.mcp.json"); - } else if host.id == "cursor" && should_apply_supported_routes { - println!("Configured Cursor project MCP route: .cursor/mcp.json"); - println!("Restart/reload Cursor to make the route visible."); - println!("Status: configured, not active."); - println!( - "Next: open Cursor and invoke a TFY MCP tool to verify real host routing." - ); - println!("host=cursor route_state=configured_unverified active=false route_configured=true host_reload_required=true host_route_available_after_reload=false config_path=.cursor/mcp.json"); + println!("host=claude-code route_state=configured_unverified active=false route_configured=true host_reload_required=true host_approval_required=false host_route_available_after_reload=false config_path=.claude/settings.json"); } else if should_apply_supported_routes { println!( "host={} route_state={} active=false configured=false apply=guidance_only", @@ -2319,12 +2440,18 @@ pub(crate) fn execute_smoke(cmd: SmokeCmd) -> Result<()> { let host_reports: Vec<_> = cmd .host .iter() - .map(|host| host_smoke_report(host)) + .map(|host| { + if cmd.live { + run_live_host_hook_smoke(host, &cmd.max_budget_usd) + } else { + host_smoke_report(host) + } + }) .collect::>()?; if cmd.json { print_json(&json!({ - "status": if host_reports.iter().all(|h| h["status"] != "unsupported") {"pass"} else {"warn"}, - "mode": "host", + "status": if host_reports.iter().all(|h| h["status"] == "pass" || h["status"] == "checklist") {"pass"} else {"warn"}, + "mode": if cmd.live { "host_live" } else { "host" }, "hosts": host_reports }))?; } else { @@ -2653,20 +2780,20 @@ fn host_registry() -> Vec { display: "Codex", status: "config_snippet_available", required_for_v1: false, - config: "codex mcp add tfy -- tfy mcp serve ... or ~/.codex/config.toml [mcp_servers.tfy]", - transport: "MCP stdio", - official_source: "https://developers.openai.com/codex/mcp", - config_strategy: "Codex project .codex/config.toml [mcp_servers.tfy] or codex mcp add", + config: "project .codex/config.toml [[hooks.PreToolUse]] matcher=^Bash$ command hook", + transport: "Official Codex PreToolUse Bash hook", + official_source: "https://developers.openai.com/codex/hooks and https://developers.openai.com/codex/config-advanced", + config_strategy: "Codex project .codex/config.toml PreToolUse Bash command hook; project .codex layer and /hooks trust required", apply_strategy: "safe TOML writer for project .codex/config.toml with TFY marker block, backup, provenance, idempotency, and uninstall", - smoke_strategy: "local MCP smoke plus host invocation artifact", - host_evidence_strategy: "host-bound MCP ledger/raw evidence with Codex invocation artifact", - setup: "project .codex/config.toml MCP route plus optional AGENTS.md guidance through tfy init", - normal_workflow: "Codex may call TFY MCP tools after host MCP routing is configured; setup alone is not token-savings proof", + smoke_strategy: "local hook smoke plus real Codex invocation artifact", + host_evidence_strategy: "host-bound official_host_hook ledger/raw evidence with Codex invocation artifact", + setup: "project .codex/config.toml PreToolUse Bash hook plus optional AGENTS.md guidance through tfy init", + normal_workflow: "Codex may route Bash tool execution through TFY after project .codex trust and /hooks review; setup alone is not token-savings proof", launch_claim: "not launch-supported until real Codex invocation artifact plus TFY ledger/raw/no-negative/positive-savings evidence exists", evidence_gate: &[ - "official OpenAI MCP setup source pinned", - "local TFY MCP initialize/tools-list smoke", - "real Codex invocation artifact", + "official OpenAI Codex hook/config source pinned", + "local TFY official-host-hook smoke", + "real Codex hook invocation artifact", "raw/model byte ledger with no-negative-savings proof", "overhead baseline or explicit exception", ], @@ -2676,162 +2803,52 @@ fn host_registry() -> Vec { display: "Claude Code", status: "config_snippet_available", required_for_v1: false, - config: "claude mcp add ... or project .mcp.json mcpServers.tfy", - transport: "MCP stdio/http per Claude support", - official_source: "https://docs.anthropic.com/en/docs/claude-code/mcp", - config_strategy: "project .mcp.json mcpServers.tfy or claude mcp add", - apply_strategy: "safe JSON writer for project .mcp.json with backup/provenance/idempotency/uninstall; project approval may be required in Claude Code", - smoke_strategy: "Claude MCP list/invocation artifact when available; checklist otherwise", - host_evidence_strategy: "host-bound MCP ledger/raw evidence with Claude invocation artifact", - setup: "Claude MCP command and .mcp.json snippet; hooks are follow-up only when official/tested", - normal_workflow: "Claude Code uses TFY only through configured MCP tools; setup alone is not token-savings proof", + config: "project .claude/settings.json hooks.PreToolUse Bash command hook", + transport: "Official Claude Code PreToolUse Bash hook", + official_source: "https://docs.anthropic.com/en/docs/claude-code/hooks and https://docs.anthropic.com/en/docs/claude-code/hooks-guide", + config_strategy: "project .claude/settings.json PreToolUse Bash command hook", + apply_strategy: "safe JSON writer for project .claude/settings.json with backup/provenance/idempotency/uninstall", + smoke_strategy: "local hook smoke plus real Claude Code invocation artifact", + host_evidence_strategy: "host-bound official_host_hook ledger/raw evidence with Claude Code invocation artifact", + setup: "Claude Code project .claude/settings.json PreToolUse Bash hook", + normal_workflow: "Claude Code may route Bash tool execution through TFY after hook config is loaded; setup alone is not token-savings proof", launch_claim: "not launch-supported until real Claude invocation artifact plus TFY ledger/raw/no-negative/positive-savings evidence exists", evidence_gate: &[ - "official Claude MCP source pinned", - "local TFY MCP initialize/tools-list smoke", - "real Claude invocation artifact", - "raw/model byte ledger with no-negative-savings proof", - "overhead baseline or explicit exception", - ], - }, - HostIntegration { - id: "cursor", - display: "Cursor", - status: "config_snippet_available", - required_for_v1: false, - config: "~/.cursor/mcp.json or project .cursor/mcp.json mcpServers.tfy", - transport: "MCP host routing", - official_source: "https://docs.cursor.com/context/model-context-protocol", - config_strategy: "project .cursor/mcp.json or global ~/.cursor/mcp.json mcpServers.tfy", - apply_strategy: "safe JSON writer for project .cursor/mcp.json with backup/idempotency/uninstall", - smoke_strategy: "Cursor MCP invocation artifact plus local MCP smoke; checklist until host CLI smoke is available", - host_evidence_strategy: "host-bound MCP ledger/raw evidence with Cursor invocation artifact", - setup: "Cursor ~/.cursor/mcp.json or project .cursor/mcp.json snippet plus rule/instruction guidance", - normal_workflow: "Cursor agent can use TFY MCP tools after MCP server is enabled; setup alone is not token-savings proof", - launch_claim: "not launch-supported until real Cursor invocation artifact plus TFY ledger/raw/no-negative/positive-savings evidence exists", - evidence_gate: &[ - "Cursor/OpenAI MCP setup source pinned", - "local TFY MCP initialize/tools-list smoke", - "real Cursor invocation artifact", - "raw/model byte ledger with no-negative-savings proof", - "overhead baseline or explicit exception", - ], - }, - HostIntegration { - id: "opencode", - display: "OpenCode", - status: "config_snippet_available", - required_for_v1: false, - config: "opencode.json(c) mcp.tfy local server entry", - transport: "MCP local server", - official_source: "https://opencode.ai/docs/mcp-servers", - config_strategy: "opencode.json(c) mcp local server entry", - apply_strategy: "dry-run/manual until comment-preserving JSONC writer or CLI route is tested", - smoke_strategy: "OpenCode MCP invocation artifact; checklist until automated route exists", - host_evidence_strategy: "host-bound MCP ledger/raw evidence with OpenCode invocation artifact", - setup: "OpenCode opencode.json(c) MCP snippet; JSONC apply remains dry-run until comment-preserving writer exists", - normal_workflow: "OpenCode can discover TFY MCP tools from config; setup alone is not token-savings proof", - launch_claim: "not launch-supported until real OpenCode invocation artifact plus TFY ledger/raw/no-negative/positive-savings evidence exists", - evidence_gate: &[ - "official OpenCode MCP setup source pinned", - "local TFY MCP initialize/tools-list smoke", - "real OpenCode invocation artifact", - "raw/model byte ledger with no-negative-savings proof", - "overhead baseline or explicit exception", - ], - }, - HostIntegration { - id: "hermes", - display: "Hermes", - status: "config_snippet_available", - required_for_v1: false, - config: "~/.hermes/config.yaml mcp_servers.tfy or hermes mcp add tfy ... with tools.include filtering", - transport: "MCP stdio/http per Hermes support", - official_source: "https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp", - config_strategy: "~/.hermes/config.yaml mcp_servers or hermes mcp add", - apply_strategy: "dry-run/manual until YAML writer or Hermes CLI route is tested", - smoke_strategy: "Hermes MCP test/dashboard artifact plus local MCP smoke", - host_evidence_strategy: "host-bound MCP ledger/raw evidence with Hermes invocation artifact", - setup: "Nous Hermes mcp_servers YAML / hermes mcp add guidance with least-surface tool include list", - normal_workflow: "Hermes discovers TFY MCP tools at startup/reload; setup alone is not token-savings proof", - launch_claim: "not launch-supported until real Hermes invocation artifact plus TFY ledger/raw/no-negative/positive-savings evidence exists", - evidence_gate: &[ - "official Nous Hermes MCP source pinned", - "local TFY MCP initialize/tools-list smoke", - "real Hermes invocation artifact", + "official Claude Code hook source pinned", + "local TFY official-host-hook smoke", + "real Claude Code hook invocation artifact", "raw/model byte ledger with no-negative-savings proof", "overhead baseline or explicit exception", ], }, - HostIntegration { - id: "openclaw", - display: "OpenClaw", - status: "planned_discovery", - required_for_v1: false, - config: "unknown; no TFY-consumable host route accepted yet", - transport: "unknown", - official_source: "none accepted yet", - config_strategy: "none; official/current route not proven", - apply_strategy: "unsupported", - smoke_strategy: "planned discovery only", - host_evidence_strategy: "ignored until route proof changes registry", - setup: "no setup/apply/smoke support until official docs or installed CLI proof shows a route", - normal_workflow: "discovery-only; TFY must not claim OpenClaw automatic routing", - launch_claim: "unsupported/planned until official/current evidence proves a safe route", - evidence_gate: &[ - "official docs or installed local CLI proof required", - "route-specific setup and invocation artifacts required before support", - ], - }, ] } +fn agent_mode_host_unavailable(host: &str) -> anyhow::Error { + anyhow!( + "host '{}' is not available in TFY agent mode; supported hosts: codex, claude-code", + host + ) +} + fn host_integration(host: &str) -> Result { let normalized = host.trim().to_ascii_lowercase().replace('_', "-"); host_registry() .into_iter() .find(|candidate| { - candidate.id == normalized - || (candidate.id == "claude-code" && normalized == "claude") - || (candidate.id == "opencode" && normalized == "open-code") - }) - .ok_or_else(|| { - anyhow!( - "unknown host '{host}'; supported hosts: {}", - host_registry() - .iter() - .map(|h| h.id) - .collect::>() - .join(", ") - ) + candidate.id == normalized || (candidate.id == "claude-code" && normalized == "claude") }) -} - -fn host_mcp_args(session: &str) -> String { - format!( - "[\"mcp\", \"serve\", \"--session\", \"{session}\", \"--ledger\", \".tfy/mcp/ledger.jsonl\", \"--raw-dir\", \".tfy/raw\"]" - ) + .ok_or_else(|| agent_mode_host_unavailable(host)) } fn host_setup_snippet(host: &HostIntegration, session: &str) -> String { - let args = host_mcp_args(session); match host.id { "codex" => format!( - "codex mcp add tfy -- tfy mcp serve --session {session} --ledger .tfy/mcp/ledger.jsonl --raw-dir .tfy/raw\n\n[mcp_servers.tfy]\ncommand = \"tfy\"\nargs = {args}\n" + "# .codex/config.toml\n# Trust the project .codex layer, then review/enable this with Codex /hooks.\n[[hooks.PreToolUse]]\nmatcher = \"^Bash$\"\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"./.tfy/agent/codex-pre-tool-use\"\ntimeout = 30\nstatusMessage = \"TFY summarizing Bash output\"\n# Generated hook script runs: tfy hook run --host codex --session {session}\n" ), "claude-code" => format!( - "claude mcp add tfy -- tfy mcp serve --session {session} --ledger .tfy/mcp/ledger.jsonl --raw-dir .tfy/raw\n\n{{\n \"mcpServers\": {{\n \"tfy\": {{\n \"command\": \"tfy\",\n \"args\": {args}\n }}\n }}\n}}\n" - ), - "cursor" => format!( - "{{\n \"mcpServers\": {{\n \"tfy\": {{\n \"command\": \"tfy\",\n \"args\": {args}\n }}\n }}\n}}\n" - ), - "opencode" => format!( - "{{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"mcp\": {{\n \"tfy\": {{\n \"type\": \"local\",\n \"command\": [\"tfy\", \"mcp\", \"serve\", \"--session\", \"{session}\", \"--ledger\", \".tfy/mcp/ledger.jsonl\", \"--raw-dir\", \".tfy/raw\"],\n \"enabled\": true\n }}\n }}\n}}\n" + "{{\n \"hooks\": {{\n \"PreToolUse\": [{{\n \"matcher\": \"Bash\",\n \"hooks\": [{{\n \"type\": \"command\",\n \"command\": \"./.tfy/agent/claude-pre-tool-use\",\n \"timeout\": 30\n }}]\n }}]\n }}\n}}\n# Generated hook script runs: tfy hook run --host claude-code --session {session}\n" ), - "hermes" => format!( - "hermes mcp add tfy --command tfy --args mcp serve --session {session} --ledger .tfy/mcp/ledger.jsonl --raw-dir .tfy/raw\n\nmcp_servers:\n tfy:\n command: \"tfy\"\n args: [\"mcp\", \"serve\", \"--session\", \"{session}\", \"--ledger\", \".tfy/mcp/ledger.jsonl\", \"--raw-dir\", \".tfy/raw\"]\n enabled: true\n tools:\n include: [tfy_tool_run, tfy_raw_get, tfy_scope_list, tfy_context_get, tfy_output_validate, tfy_output_apply, tfy_state_project, tfy_adapter_report]\n" - ), - "openclaw" => "OpenClaw is planned_discovery only; no TFY setup snippet is emitted until official/current evidence proves a safe route.\n".into(), _ => unreachable!("host registry returned unknown host"), } } @@ -2844,6 +2861,12 @@ fn print_host_setup( uninstall: bool, scope: HostConfigScope, ) -> Result<()> { + if host.status == "unsupported" { + bail!( + "host '{}' is not available in TFY agent mode; supported hosts: codex, claude-code", + host.id + ); + } if host.status == "planned_discovery" { println!("TFY setup host={} status=planned_discovery", host.id); println!("source={}", host.official_source); @@ -2851,11 +2874,10 @@ fn print_host_setup( println!("{}", host.launch_claim); return Ok(()); } - if (apply || uninstall) && matches!(host.id, "codex" | "claude-code" | "cursor") { + if (apply || uninstall) && matches!(host.id, "codex" | "claude-code") { let result = match host.id { - "codex" => configure_codex_project_mcp(session, scope, dry_run, uninstall)?, - "claude-code" => configure_claude_project_mcp(session, scope, dry_run, uninstall)?, - "cursor" => configure_cursor_project_mcp(session, scope, dry_run, uninstall)?, + "codex" => configure_codex_project_hook(session, scope, dry_run, uninstall)?, + "claude-code" => configure_claude_project_hook(session, scope, dry_run, uninstall)?, _ => unreachable!(), }; let (status, claim_tier) = if dry_run { @@ -2901,73 +2923,132 @@ fn print_host_setup( println!("--- snippet ---"); print!("{}", host_setup_snippet(&host, session)); println!("--- boundary ---"); - println!("MCP host routing only; no private hidden hooks, provider prompt mutation, editor auto-integration, or universal human-shell interception."); + println!("Official host hook routing for Codex/Claude Code when documented; MCP remains complementary; no private hidden hooks, provider prompt mutation, editor auto-integration, or universal human-shell interception."); println!("{}", host.launch_claim); Ok(()) } -fn host_config_args(session: &str) -> Vec { - vec![ - "mcp".into(), - "serve".into(), - "--session".into(), - session.into(), - "--ledger".into(), - ".tfy/mcp/ledger.jsonl".into(), - "--raw-dir".into(), - ".tfy/raw".into(), - ] +fn provenance_path(host: &str) -> PathBuf { + PathBuf::from(".tfy") + .join("host-config") + .join(format!("{host}.json")) +} + +fn remove_host_provenance(host: &str) -> Result<()> { + let path = provenance_path(host); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| format!("remove {}", path.display())), + } +} + +fn backup_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("config"); + path.with_file_name(format!("{file_name}.tfy-backup")) +} + +fn host_hook_script_path(host: &str) -> PathBuf { + PathBuf::from(".tfy") + .join("agent") + .join(format!("{host}-pre-tool-use")) +} + +fn install_host_hook_script(host: &str, session: &str) -> Result { + let project_root = std::env::current_dir()?; + let relative = host_hook_script_path(host); + if let Some(parent) = relative.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let exe = std::env::current_exe().context("resolve current tfy executable")?; + let exe = exe + .to_str() + .ok_or_else(|| anyhow!("current tfy executable path is not valid UTF-8"))?; + let exe = shell_single_quote(exe); + let route_host = match host { + // Keep the short script filename while all runtime evidence uses the canonical host id. + "claude" => "claude-code", + other => other, + }; + let raw_dir = shell_single_quote(&project_root.join(".tfy/raw").display().to_string()); + let ledger = shell_single_quote( + &project_root + .join(format!(".tfy/hook/{route_host}-ledger.jsonl")) + .display() + .to_string(), + ); + let script = format!( + r#"#!/usr/bin/env sh +# TFY official host hook router for {route_host}. Reads host hook JSON from stdin. +# Generated by TFY; does not mutate human shell startup files. +exec {exe} hook run --host {route_host} --session "${{TFY_SESSION_ID:-{session}}}" --raw-dir {raw_dir} --ledger {ledger} +"# + ); + fs::write(&relative, script).with_context(|| format!("write {}", relative.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&relative, fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod {}", relative.display()))?; + } + Ok(std::env::current_dir()?.join(relative)) +} + +fn codex_tfy_block(script_path: &Path) -> String { + let command = script_path + .display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\""); + format!( + "# TFY:HOST-CONFIG:START codex\n[[hooks.PreToolUse]]\nmatcher = \"^Bash$\"\n[[hooks.PreToolUse.hooks]]\ntype = \"command\"\ncommand = \"{command}\"\ntimeout = 30\nstatusMessage = \"TFY summarizing Bash output\"\n# TFY:HOST-CONFIG:END codex\n" + ) } fn hash_text(text: &str) -> String { format!("{:x}", Sha256::digest(text.as_bytes())) } -fn host_config_provenance( +fn write_host_hook_provenance( host: &str, path: &Path, managed_key_path: &str, session: &str, entry_text: &str, -) -> HostConfigProvenance { - let args = host_config_args(session); - let args_json = serde_json::to_string(&args).unwrap_or_default(); + script_path: &Path, +) -> Result<()> { let now = now_stamp(); - HostConfigProvenance { + let args = vec![ + "hook".into(), + "run".into(), + "--host".into(), + host.into(), + "--session".into(), + session.into(), + "--raw-dir".into(), + ".tfy/raw".into(), + "--ledger".into(), + format!(".tfy/hook/{host}-ledger.jsonl"), + ]; + let args_json = serde_json::to_string(&args).unwrap_or_default(); + let provenance = HostConfigProvenance { host: host.into(), config_path: path.display().to_string(), managed_key_path: managed_key_path.into(), - command: "tfy".into(), + command: script_path.display().to_string(), args, - command_args_hash: hash_text(&format!("tfy {args_json}")), + command_args_hash: hash_text(&format!("{} {args_json}", script_path.display())), session: session.into(), - ledger_path: ".tfy/mcp/ledger.jsonl".into(), + ledger_path: format!(".tfy/hook/{host}-ledger.jsonl"), raw_dir: ".tfy/raw".into(), created_at: now.clone(), updated_at: now, tfy_version: env!("CARGO_PKG_VERSION").into(), uninstall_safety_hash: hash_text(entry_text), - } -} - -fn provenance_path(host: &str) -> PathBuf { - PathBuf::from(".tfy") - .join("host-config") - .join(format!("{host}.json")) -} - -fn write_host_provenance( - host: &str, - path: &Path, - managed_key_path: &str, - session: &str, - entry_text: &str, - dry_run: bool, -) -> Result<()> { - if dry_run { - return Ok(()); - } - let provenance = host_config_provenance(host, path, managed_key_path, session, entry_text); + }; let path = provenance_path(host); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -2979,34 +3060,6 @@ fn write_host_provenance( .with_context(|| format!("write {}", path.display())) } -fn remove_host_provenance(host: &str) -> Result<()> { - let path = provenance_path(host); - match fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => Err(err).with_context(|| format!("remove {}", path.display())), - } -} - -fn backup_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("config"); - path.with_file_name(format!("{file_name}.tfy-backup")) -} - -fn codex_tfy_block(session: &str) -> String { - let args = host_config_args(session) - .into_iter() - .map(|arg| format!("\"{}\"", arg.replace('\\', "\\\\").replace('"', "\\\""))) - .collect::>() - .join(", "); - format!( - "# TFY:HOST-CONFIG:START codex\n[mcp_servers.tfy]\ncommand = \"tfy\"\nargs = [{args}]\n# TFY:HOST-CONFIG:END codex\n" - ) -} - fn validate_codex_toml_config(path: &Path, text: &str) -> Result<()> { let mut current_table: Vec = Vec::new(); for (index, line) in text.lines().enumerate() { @@ -3174,7 +3227,7 @@ fn has_unmarked_codex_tfy_table(text: &str) -> bool { }) } -fn configure_codex_project_mcp( +fn configure_codex_project_hook( session: &str, scope: HostConfigScope, dry_run: bool, @@ -3199,7 +3252,12 @@ fn configure_codex_project_mcp( path.display() ); } - let block = codex_tfy_block(session); + let script_path = if uninstall || dry_run { + std::env::current_dir()?.join(host_hook_script_path("codex")) + } else { + install_host_hook_script("codex", session)? + }; + let block = codex_tfy_block(&script_path); if !uninstall { if !base.ends_with('\n') && !base.is_empty() { base.push('\n'); @@ -3227,7 +3285,14 @@ fn configure_codex_project_mcp( if uninstall { remove_host_provenance("codex")?; } else { - write_host_provenance("codex", &path, "mcp_servers.tfy", session, &block, dry_run)?; + write_host_hook_provenance( + "codex", + &path, + "hooks.PreToolUse[matcher=^Bash$]", + session, + &block, + &script_path, + )?; } } Ok(json!({ @@ -3242,7 +3307,7 @@ fn configure_codex_project_mcp( })) } -fn cleanup_codex_project_mcp_if_tfy_owned() -> Result<()> { +fn cleanup_codex_project_hook_if_tfy_owned() -> Result<()> { let path = PathBuf::from(".codex").join("config.toml"); let existing = match fs::read_to_string(&path) { Ok(text) => text, @@ -3262,16 +3327,16 @@ fn cleanup_codex_project_mcp_if_tfy_owned() -> Result<()> { remove_host_provenance("codex") } -fn configure_claude_project_mcp( +fn configure_claude_project_hook( session: &str, scope: HostConfigScope, dry_run: bool, uninstall: bool, ) -> Result { if scope == HostConfigScope::Global { - bail!("claude-code --global apply is not implemented here; use project .mcp.json for automatic lifecycle routing"); + bail!("claude-code --global apply is not implemented here; use project .claude/settings.json for automatic lifecycle routing"); } - let path = PathBuf::from(".mcp.json"); + let path = PathBuf::from(".claude").join("settings.json"); let backup = backup_path(&path); let existing = match fs::read_to_string(&path) { Ok(text) => Some(text), @@ -3279,47 +3344,32 @@ fn configure_claude_project_mcp( Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), }; let mut root: Value = match existing.as_deref() { - Some(text) if !text.trim().is_empty() => serde_json::from_str(text) - .with_context(|| format!("parse existing Claude Code MCP config {}", path.display()))?, + Some(text) if !text.trim().is_empty() => serde_json::from_str(text).with_context(|| { + format!("parse existing Claude Code hook config {}", path.display()) + })?, _ => json!({}), }; let root_obj = root.as_object_mut().ok_or_else(|| { anyhow!( - "Claude Code MCP config must be a JSON object: {}", - path.display() - ) - })?; - let servers = root_obj.entry("mcpServers").or_insert_with(|| json!({})); - let servers_obj = servers.as_object_mut().ok_or_else(|| { - anyhow!( - "Claude Code mcpServers must be a JSON object: {}", + "Claude Code hook config must be a JSON object: {}", path.display() ) })?; - let desired_tfy = claude_tfy_server_config(session); - if uninstall { - if let Some(existing_tfy) = servers_obj.get("tfy") { - if !json_tfy_entry_is_managed(existing_tfy) { - bail!( - "Claude Code mcpServers.tfy is not TFY-owned; refusing to remove user-managed config in {}", - path.display() - ); - } - } - servers_obj.remove("tfy"); - if servers_obj.is_empty() { - root_obj.remove("mcpServers"); - } - } else { - if let Some(existing_tfy) = servers_obj.get("tfy") { - if !json_tfy_entry_is_managed(existing_tfy) { - bail!( - "Claude Code mcpServers.tfy already exists and is not TFY-owned; refusing to overwrite user-managed config in {}", - path.display() - ); - } + remove_claude_tfy_hook_entries(root_obj)?; + let mut installed_script_path = None; + let mut installed_entry_text = None; + if !uninstall { + let script_path = if dry_run { + std::env::current_dir()?.join(host_hook_script_path("claude")) + } else { + install_host_hook_script("claude", session)? + }; + insert_claude_tfy_hook_entry(root_obj, &script_path)?; + if !dry_run { + installed_entry_text = + Some(serde_json::to_string(&claude_tfy_hook_entry(&script_path))?); + installed_script_path = Some(script_path); } - servers_obj.insert("tfy".into(), desired_tfy); } if !dry_run { if let Some(parent) = path @@ -3342,14 +3392,17 @@ fn configure_claude_project_mcp( } if uninstall { remove_host_provenance("claude-code")?; - } else { - write_host_provenance( + } else if let (Some(script_path), Some(entry_text)) = ( + installed_script_path.as_ref(), + installed_entry_text.as_ref(), + ) { + write_host_hook_provenance( "claude-code", &path, - "mcpServers.tfy", + "hooks.PreToolUse[matcher=Bash]", session, - &serde_json::to_string(&claude_tfy_server_config(session))?, - dry_run, + entry_text, + script_path, )?; } } @@ -3364,6 +3417,113 @@ fn configure_claude_project_mcp( })) } +fn claude_tfy_hook_entry(script_path: &Path) -> Value { + json!({ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": script_path.display().to_string(), + "timeout": 30 + }] + }) +} + +fn insert_claude_tfy_hook_entry( + root_obj: &mut serde_json::Map, + script_path: &Path, +) -> Result<()> { + let hooks = root_obj.entry("hooks").or_insert_with(|| json!({})); + let hooks_obj = hooks + .as_object_mut() + .ok_or_else(|| anyhow!("Claude Code hooks must be a JSON object"))?; + let pre = hooks_obj.entry("PreToolUse").or_insert_with(|| json!([])); + let pre_array = pre + .as_array_mut() + .ok_or_else(|| anyhow!("Claude Code hooks.PreToolUse must be a JSON array"))?; + pre_array.push(claude_tfy_hook_entry(script_path)); + Ok(()) +} + +fn remove_claude_tfy_hook_entries(root_obj: &mut serde_json::Map) -> Result<()> { + let managed_command = read_host_provenance("claude-code")?.map(|provenance| provenance.command); + let Some(hooks) = root_obj.get_mut("hooks") else { + return Ok(()); + }; + let hooks_obj = hooks + .as_object_mut() + .ok_or_else(|| anyhow!("Claude Code hooks must be a JSON object"))?; + let mut remove_hooks = false; + if let Some(pre) = hooks_obj.get_mut("PreToolUse") { + let pre_array = pre + .as_array_mut() + .ok_or_else(|| anyhow!("Claude Code hooks.PreToolUse must be a JSON array"))?; + pre_array + .retain(|entry| !claude_hook_entry_is_tfy_owned(entry, managed_command.as_deref())); + if pre_array.is_empty() { + hooks_obj.remove("PreToolUse"); + } + } + if hooks_obj.is_empty() { + remove_hooks = true; + } + if remove_hooks { + root_obj.remove("hooks"); + } + Ok(()) +} + +fn read_host_provenance(host: &str) -> Result> { + let path = provenance_path(host); + match fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text) + .map(Some) + .with_context(|| format!("parse {}", path.display())), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err).with_context(|| format!("read {}", path.display())), + } +} + +fn claude_hook_entry_is_tfy_owned(entry: &Value, managed_command: Option<&str>) -> bool { + let Some(managed_command) = managed_command else { + return false; + }; + entry + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command") + .and_then(Value::as_str) + .is_some_and(|command| command == managed_command) + }) + }) +} + +fn cleanup_claude_project_hook_if_tfy_owned() -> Result<()> { + let path = PathBuf::from(".claude").join("settings.json"); + let existing = match fs::read_to_string(&path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), + }; + if existing.trim().is_empty() { + return Ok(()); + } + let mut root: Value = serde_json::from_str(&existing) + .with_context(|| format!("parse existing Claude Code hook config {}", path.display()))?; + let Some(root_obj) = root.as_object_mut() else { + return Ok(()); + }; + remove_claude_tfy_hook_entries(root_obj)?; + if root.as_object().is_some_and(|object| object.is_empty()) { + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + } else { + fs::write(&path, format!("{}\n", serde_json::to_string_pretty(&root)?)) + .with_context(|| format!("write {}", path.display()))?; + } + remove_host_provenance("claude-code") +} + fn cleanup_claude_project_mcp_if_tfy_owned() -> Result<()> { let path = PathBuf::from(".mcp.json"); let existing = match fs::read_to_string(&path) { @@ -3405,13 +3565,14 @@ fn cleanup_claude_project_mcp_if_tfy_owned() -> Result<()> { } fn cleanup_project_agent_host_configs() -> Result<()> { - cleanup_codex_project_mcp_if_tfy_owned()?; + cleanup_codex_project_hook_if_tfy_owned()?; + cleanup_claude_project_hook_if_tfy_owned()?; cleanup_claude_project_mcp_if_tfy_owned()?; - cleanup_cursor_project_mcp_if_tfy_owned()?; + cleanup_legacy_project_mcp_if_tfy_owned()?; Ok(()) } -fn cleanup_cursor_project_mcp_if_tfy_owned() -> Result<()> { +fn cleanup_legacy_project_mcp_if_tfy_owned() -> Result<()> { let path = PathBuf::from(".cursor").join("mcp.json"); let existing = match fs::read_to_string(&path) { Ok(text) => text, @@ -3422,7 +3583,7 @@ fn cleanup_cursor_project_mcp_if_tfy_owned() -> Result<()> { return Ok(()); } let mut root: Value = serde_json::from_str(&existing) - .with_context(|| format!("parse existing Cursor MCP config {}", path.display()))?; + .with_context(|| format!("parse existing legacy MCP config {}", path.display()))?; let Some(root_obj) = root.as_object_mut() else { return Ok(()); }; @@ -3435,7 +3596,7 @@ fn cleanup_cursor_project_mcp_if_tfy_owned() -> Result<()> { let Some(existing_tfy) = servers_obj.get("tfy") else { return Ok(()); }; - if !cursor_tfy_entry_is_managed(existing_tfy) { + if !json_tfy_entry_is_managed(existing_tfy) { return Ok(()); } servers_obj.remove("tfy"); @@ -3451,133 +3612,6 @@ fn cleanup_cursor_project_mcp_if_tfy_owned() -> Result<()> { Ok(()) } -fn configure_cursor_project_mcp( - session: &str, - scope: HostConfigScope, - dry_run: bool, - uninstall: bool, -) -> Result { - if scope == HostConfigScope::Global { - bail!("cursor --global apply is not implemented safely yet; use --project for reversible .cursor/mcp.json writes"); - } - let path = PathBuf::from(".cursor").join("mcp.json"); - let backup = cursor_backup_path(&path); - let existing = match fs::read_to_string(&path) { - Ok(text) => Some(text), - Err(err) if err.kind() == ErrorKind::NotFound => None, - Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), - }; - if uninstall && existing.is_none() { - return Ok(cursor_config_result( - scope, &path, &backup, dry_run, uninstall, - )); - } - let mut root: Value = match existing.as_deref() { - Some(text) if !text.trim().is_empty() => serde_json::from_str(text) - .with_context(|| format!("parse existing Cursor MCP config {}", path.display()))?, - _ => json!({}), - }; - let root_obj = root.as_object_mut().ok_or_else(|| { - anyhow!( - "Cursor MCP config must be a JSON object: {}", - path.display() - ) - })?; - let servers = root_obj.entry("mcpServers").or_insert_with(|| json!({})); - let servers_obj = servers.as_object_mut().ok_or_else(|| { - anyhow!( - "Cursor mcpServers must be a JSON object: {}", - path.display() - ) - })?; - let desired_tfy = cursor_tfy_server_config(session); - if uninstall { - if let Some(existing_tfy) = servers_obj.get("tfy") { - if !cursor_tfy_entry_is_managed(existing_tfy) { - bail!( - "Cursor mcpServers.tfy is not TFY-owned; refusing to remove user-managed config in {}", - path.display() - ); - } - } - servers_obj.remove("tfy"); - if servers_obj.is_empty() { - root_obj.remove("mcpServers"); - } - } else { - if let Some(existing_tfy) = servers_obj.get("tfy") { - if !cursor_tfy_entry_is_managed(existing_tfy) { - bail!( - "Cursor mcpServers.tfy already exists and is not TFY-owned; refusing to overwrite user-managed config in {}", - path.display() - ); - } - } - servers_obj.insert("tfy".into(), desired_tfy); - } - if !dry_run { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - if uninstall && root.as_object().is_some_and(|object| object.is_empty()) { - if path.exists() { - fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; - } - remove_host_provenance("cursor")?; - return Ok(cursor_config_result( - scope, &path, &backup, dry_run, uninstall, - )); - } - if existing.is_some() && !backup.exists() && !uninstall { - fs::copy(&path, &backup) - .with_context(|| format!("backup {} to {}", path.display(), backup.display()))?; - } - let rendered = format!("{}\n", serde_json::to_string_pretty(&root)?); - fs::write(&path, rendered).with_context(|| format!("write {}", path.display()))?; - if uninstall { - remove_host_provenance("cursor")?; - } else { - write_host_provenance( - "cursor", - &path, - "mcpServers.tfy", - session, - &serde_json::to_string(&cursor_tfy_server_config(session))?, - dry_run, - )?; - } - } - Ok(cursor_config_result( - scope, &path, &backup, dry_run, uninstall, - )) -} - -fn cursor_tfy_server_config(session: &str) -> Value { - json!({ - "command": "tfy", - "args": [ - "mcp", "serve", - "--session", session, - "--ledger", ".tfy/mcp/ledger.jsonl", - "--raw-dir", ".tfy/raw" - ], - "tfy_managed": true - }) -} - -fn claude_tfy_server_config(session: &str) -> Value { - json!({ - "command": "tfy", - "args": [ - "mcp", "serve", - "--session", session, - "--ledger", ".tfy/mcp/ledger.jsonl", - "--raw-dir", ".tfy/raw" - ], - "tfy_managed": true - }) -} - fn json_tfy_entry_is_managed(entry: &Value) -> bool { entry .get("tfy_managed") @@ -3585,43 +3619,13 @@ fn json_tfy_entry_is_managed(entry: &Value) -> bool { .unwrap_or(false) } -fn cursor_tfy_entry_is_managed(entry: &Value) -> bool { - json_tfy_entry_is_managed(entry) -} - -fn cursor_config_result( - scope: HostConfigScope, - path: &Path, - backup: &Path, - dry_run: bool, - uninstall: bool, -) -> Value { - json!({ - "scope": scope.label(), - "config_path": path.display().to_string(), - "backup_path": backup.display().to_string(), - "provenance_path": provenance_path("cursor").display().to_string(), - "dry_run": dry_run, - "applied": !dry_run, - "action": if uninstall { "uninstall" } else { "install" }, - }) -} - -fn cursor_backup_path(path: &Path) -> PathBuf { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("mcp.json"); - path.with_file_name(format!("{file_name}.tfy-backup")) -} - fn host_doctor_report(host: &str) -> Result { let host = host_integration(host)?; Ok(json!({ "host": host.id, "display": host.display, "status": host.status, - "message": if host.status == "planned_discovery" { + "message": if matches!(host.status, "planned_discovery" | "unsupported") { host.setup } else { "config snippet available; local MCP doctor checks prove TFY server health, not real host invocation" @@ -3637,11 +3641,30 @@ fn host_doctor_report(host: &str) -> Result { fn host_smoke_report(host: &str) -> Result { let host = host_integration(host)?; + if host.status == "unsupported" { + return Ok(json!({ + "host": host.id, + "display": host.display, + "status": "unsupported", + "claim_tier": "unsupported", + "message": host.setup, + "required_host_evidence": host.evidence_gate, + "setup_success_is_not_savings_success": true, + })); + } Ok(json!({ "host": host.id, "display": host.display, - "status": if host.status == "planned_discovery" { "unsupported" } else { "checklist" }, - "claim_tier": if host.status == "planned_discovery" { "planned_discovery" } else { "configurable" }, + "status": if host.status == "planned_discovery" { + "unsupported" + } else { + "checklist" + }, + "claim_tier": if host.status == "planned_discovery" { + "planned_discovery" + } else { + "configurable" + }, "message": if host.status == "planned_discovery" { host.setup } else { @@ -3673,6 +3696,352 @@ fn host_smoke_report(host: &str) -> Result { })) } +fn live_host_smoke_spec(host: &str) -> Option { + match host { + "codex" => Some(LiveHostSmokeSpec { + host: "codex", + binary: "codex", + timeout: Duration::from_secs(180), + needs_noninteractive_bypass: true, + }), + "claude-code" => Some(LiveHostSmokeSpec { + host: "claude-code", + binary: "claude", + timeout: Duration::from_secs(180), + needs_noninteractive_bypass: true, + }), + _ => None, + } +} + +fn configure_live_host_smoke_command( + command: &mut Command, + spec: LiveHostSmokeSpec, + prompt: &str, + max_budget_usd: &str, +) { + match spec.host { + "codex" => { + if spec.needs_noninteractive_bypass { + command.args([ + "--dangerously-bypass-hook-trust", + "--dangerously-bypass-approvals-and-sandbox", + ]); + } + command.args(["exec", "--skip-git-repo-check", "--json", prompt]); + } + "claude-code" => { + command.args(["-p"]); + if spec.needs_noninteractive_bypass { + command.args(["--permission-mode", "bypassPermissions"]); + } + command.args([ + "--allowedTools", + "Bash", + "--include-hook-events", + "--output-format", + "stream-json", + "--verbose", + "--max-budget-usd", + max_budget_usd, + prompt, + ]); + } + _ => unreachable!("live host smoke spec exists only for supported hosts"), + } +} + +fn run_live_host_hook_smoke(host: &str, max_budget_usd: &str) -> Result { + let integration = host_integration(host)?; + let spec = live_host_smoke_spec(integration.id).ok_or_else(|| { + anyhow!( + "live official-hook smoke is only implemented for codex and claude-code; host '{}' uses {}", + integration.id, + integration.transport + ) + })?; + if !host_official_hook_launch_supported(integration.id) { + bail!( + "live official-hook smoke is only implemented for codex and claude-code; host '{}' uses {}", + integration.id, + integration.transport + ); + } + let host_version = host_version(spec.binary) + .with_context(|| format!("resolve installed {} version for live smoke", spec.binary))?; + let root = std::env::temp_dir().join(format!( + "tfy-live-{}-{}-{}", + integration.id, + std::process::id(), + stable_id(&format!("live-{}", integration.id)) + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?; + fs::write( + root.join("README.md"), + "TFY live host hook smoke workspace. The host should run one Bash command only.\n", + )?; + let setup = Command::new(std::env::current_exe().context("resolve current tfy executable")?) + .current_dir(&root) + .env("CARGO_TERM_COLOR", "never") + .args(["start", "--agent", "--host", integration.id]) + .output() + .with_context(|| format!("configure {} live smoke project", integration.id))?; + if !setup.status.success() { + bail!( + "live host smoke setup failed for {}: {}", + integration.id, + String::from_utf8_lossy(&setup.stderr) + ); + } + let invocation = root.join(".tfy").join("host-smoke"); + fs::create_dir_all(&invocation)?; + let stdout_path = invocation.join(format!("{}-stdout.txt", integration.id)); + let stderr_path = invocation.join(format!("{}-stderr.txt", integration.id)); + let command_sample = "for i in $(seq 1 80); do echo tfy-live-host-hook-smoke-$i; done"; + let prompt = format!( + "In this temporary trusted smoke workspace, run exactly one Bash shell command and then stop. Do not edit files. Command: {command_sample}" + ); + let baseline_start = Instant::now(); + let baseline = Command::new("sh") + .current_dir(&root) + .args(["-c", command_sample]) + .output() + .context("run live smoke baseline command")?; + if !baseline.status.success() { + bail!( + "baseline smoke command failed: {}", + String::from_utf8_lossy(&baseline.stderr) + ); + } + let baseline_ms = millis_u64(baseline_start.elapsed()); + let host_start = Instant::now(); + let mut host_command = Command::new(spec.binary); + host_command.current_dir(&root); + // Live smoke is an explicit opt-in release/evidence check. Some host CLIs need + // noninteractive bypass flags for automation, so TFY compensates by validating + // the hook ledger contains exactly the intended command before emitting evidence. + configure_live_host_smoke_command(&mut host_command, spec, &prompt, max_budget_usd); + let host_output = output_with_timeout( + &mut host_command, + spec.timeout, + &stdout_path, + &stderr_path, + &format!("{} live hook smoke", integration.id), + )?; + let overhead_ms = millis_u64(host_start.elapsed()); + if !host_output.status.success() { + bail!( + "live host smoke failed for {}; stdout={} stderr={}", + integration.id, + stdout_path.display(), + stderr_path.display() + ); + } + let provenance_path = root.join(provenance_path(integration.id)); + let provenance: HostConfigProvenance = serde_json::from_str( + &fs::read_to_string(&provenance_path) + .with_context(|| format!("read {}", provenance_path.display()))?, + ) + .with_context(|| format!("parse {}", provenance_path.display()))?; + let ledger = root.join(&provenance.ledger_path); + let raw_dir = root.join(&provenance.raw_dir); + let (raw_ref, raw_bytes, model_bytes) = + official_hook_command_evidence(&ledger, integration.id, command_sample) + .with_context(|| format!("verify {} hook ledger evidence", integration.id))?; + let raw_artifact = raw_dir.join(format!("{raw_ref}.json")); + if !raw_artifact.is_file() { + bail!( + "raw artifact missing after live host smoke: {}", + raw_artifact.display() + ); + } + let setup_artifact = provenance_path; + let config_path = root.join(&provenance.config_path); + let evidence = root + .join(".tfy") + .join("host-evidence") + .join(format!("{}-host-evidence.json", integration.id)); + if let Some(parent) = evidence.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + &evidence, + serde_json::to_string_pretty(&json!({ + "hosts": [{ + "host": integration.id, + "tfy_version": env!("CARGO_PKG_VERSION"), + "host_id": integration.id, + "host_version": host_version.trim(), + "setup_verified": true, + "real_invocation_verified": true, + "setup_artifact": setup_artifact, + "invocation_artifact": stdout_path, + "config_scope": "project", + "config_path": config_path, + "route_type": "official_host_hook", + "ledger_artifact": ledger, + "raw_artifact": raw_artifact, + "redacted_public_bytes": raw_bytes, + "model_visible_bytes": model_bytes, + "timestamp": now_stamp(), + "smoke_id": format!("{}-live-hook-smoke", integration.id), + "official_docs_backed": true, + "kill_switch_available": true, + "uninstall_available": true, + "overhead_ms": overhead_ms, + "baseline_ms": baseline_ms, + "overhead_exception": "live host smoke includes model planning latency; route launch evidence verifies official hook ingress and TFY raw/model byte gates" + }] + }))? + "\n", + )?; + Ok(json!({ + "host": integration.id, + "display": integration.display, + "status": "pass", + "claim_tier": "host_evidence_recorded", + "message": "live host CLI invoked TFY official hook route and produced launch-report host evidence", + "workspace": root, + "host_version": host_version.trim(), + "ledger": ledger, + "raw_artifact": raw_dir.join(format!("{raw_ref}.json")), + "host_evidence": evidence, + "launch_report_command": format!("tfy launch-report --host-evidence {} --json", evidence.display()), + "redacted_public_bytes": raw_bytes, + "model_visible_bytes": model_bytes, + "setup_success_is_not_savings_success": true, + "savings_success_verified": true + })) +} + +fn millis_u64(duration: std::time::Duration) -> u64 { + duration.as_millis().try_into().unwrap_or(u64::MAX) +} + +fn host_version(binary: &str) -> Result { + let output = Command::new(binary) + .arg("--version") + .output() + .with_context(|| format!("run {binary} --version"))?; + if !output.status.success() { + bail!( + "{binary} --version failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { + bail!("{binary} --version produced no stdout"); + } + Ok(text) +} + +fn output_with_timeout( + command: &mut Command, + timeout: Duration, + stdout_path: &Path, + stderr_path: &Path, + label: &str, +) -> Result { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().with_context(|| format!("spawn {label}"))?; + let start = Instant::now(); + loop { + if child.try_wait()?.is_some() { + let output = child + .wait_with_output() + .with_context(|| format!("collect {label} output"))?; + fs::write(stdout_path, &output.stdout)?; + fs::write(stderr_path, &output.stderr)?; + return Ok(output); + } + if start.elapsed() >= timeout { + let _ = child.kill(); + let output = child + .wait_with_output() + .with_context(|| format!("collect timed-out {label} output"))?; + fs::write(stdout_path, &output.stdout)?; + fs::write(stderr_path, &output.stderr)?; + bail!( + "{label} timed out after {}s; stdout={} stderr={}", + timeout.as_secs(), + stdout_path.display(), + stderr_path.display() + ); + } + thread::sleep(Duration::from_millis(100)); + } +} + +fn official_hook_command_evidence( + ledger: &Path, + host: &str, + expected_command: &str, +) -> Result<(String, usize, usize)> { + let events = load_events(ledger).with_context(|| format!("read {}", ledger.display()))?; + let mut matches = Vec::new(); + for event in events { + if event.origin.invocation != OriginInvocation::OfficialHostHook { + continue; + } + let host_matches = match host { + "codex" => event.origin.host == tfy_runtime::OriginHost::Codex, + "claude-code" => event.origin.host == tfy_runtime::OriginHost::ClaudeCode, + _ => false, + }; + if !host_matches { + continue; + } + if let GatewayEvent::ToolCommandCompleted { + command, + raw_ref, + raw_bytes, + model_bytes, + raw_chars, + summary_chars, + model_chars, + negative_savings_avoided, + .. + } = event.payload + { + let raw_size = if raw_bytes == 0 { raw_chars } else { raw_bytes }; + let model_size = if model_bytes != 0 { + model_bytes + } else if model_chars != 0 { + model_chars + } else { + summary_chars + }; + if model_size > raw_size && !negative_savings_avoided { + bail!("official hook evidence has negative savings"); + } + if raw_size <= model_size { + bail!("official hook evidence did not prove positive savings"); + } + if raw_ref.trim().is_empty() { + bail!("official hook evidence missing raw_ref"); + } + matches.push((command, raw_ref, raw_size, model_size)); + } + } + if matches.len() != 1 { + bail!( + "expected exactly one official-host-hook command for {host}, found {}", + matches.len() + ); + } + let (command, raw_ref, raw_size, model_size) = matches.remove(0); + let expected_recorded_command = format!("sh -c {expected_command}"); + if command != expected_recorded_command { + bail!( + "official hook command mismatch for {host}: expected {:?}, got {:?}", + expected_recorded_command, + command + ); + } + Ok((raw_ref, raw_size, model_size)) +} + fn raw_ref_path(raw_dir: &Path, raw_ref: &str) -> Result { if !valid_raw_ref(raw_ref) { bail!("invalid raw ref: {raw_ref}"); @@ -4274,7 +4643,7 @@ fn effective_agent_status( } else if !desired { "Run `tfy start --agent` in this project, or configure global agent defaults with `tfy use always --agent`.".into() } else if host_routes_empty { - "Run `tfy start --agent` to auto-configure the Codex project MCP route, or use `tfy start --agent --no-apply` for lifecycle intent only.".into() + "Run `tfy start --agent` to prepare the project agent wrapper plus Codex and Claude Code official hook routes, or use `tfy start --agent --no-apply` for lifecycle intent only.".into() } else { "Configured but not verified: run the configured host and collect route-bound raw/ledger/no-negative/positive-savings/overhead evidence before active=true.".into() }, @@ -4375,6 +4744,8 @@ fn unsupported_ingress() -> Vec { fn host_readiness_from_integration(host: &HostIntegration) -> HostReadiness { let claim_tier = if host.status == "planned_discovery" { "planned_discovery" + } else if host.status == "unsupported" { + "unsupported" } else { "configurable" }; @@ -4385,12 +4756,14 @@ fn host_readiness_from_integration(host: &HostIntegration) -> HostReadiness { claim_tier: claim_tier.into(), evidence_tiers: initial_host_evidence_tiers(host.status), next_evidence_tier: next_evidence_tier(&initial_host_evidence_tiers(host.status)).into(), - supported_ingress: if host.status == "planned_discovery" { + supported_ingress: if matches!(host.status, "planned_discovery" | "unsupported") { Vec::new() + } else if matches!(host.id, "codex" | "claude-code") { + vec!["official_host_hook".into(), "agent_wrapper_fallback".into()] } else { vec!["mcp_stdio".into()] }, - equivalence_ingress: if host.status == "planned_discovery" { + equivalence_ingress: if matches!(host.status, "planned_discovery" | "unsupported") { Vec::new() } else { vec!["official_host_hook_test_shim_only".into()] @@ -4427,6 +4800,9 @@ fn next_evidence_tier(observed: &[String]) -> &'static str { { return "official_route_discovery"; } + if observed.iter().any(|observed| observed == "unsupported") { + return "not_applicable"; + } let has = |tier: &str| observed.iter().any(|observed| observed == tier); if has("launch_supported") { "complete" @@ -4450,7 +4826,7 @@ fn minimum_v1_host_matrix() -> Vec { HostReadiness { host: "mcp_stdio".into(), status: "not_configured".into(), - required_for_v1: true, + required_for_v1: false, claim_tier: "not_configured".into(), evidence_tiers: Vec::new(), next_evidence_tier: "config_written".into(), @@ -4524,17 +4900,17 @@ fn minimum_v1_host_matrix() -> Vec { claim_tier: "configurable".into(), evidence_tiers: vec!["config_snippet_available".into()], next_evidence_tier: "config_written".into(), - supported_ingress: vec!["mcp_stdio".into()], + supported_ingress: vec!["official_host_hook".into(), "agent_wrapper_fallback".into()], equivalence_ingress: vec!["official_host_hook_test_shim_only".into()], unsupported_ingress: unsupported_ingress(), - config_strategy: "Codex project .codex/config.toml MCP route plus optional project AGENTS.md guidance".into(), - apply_strategy: "safe project .codex/config.toml writer with TFY marker block, backup, provenance, idempotency, and uninstall".into(), - smoke_strategy: "local MCP smoke plus real Codex invocation artifact".into(), - host_evidence_strategy: "Codex-bound MCP ledger/raw evidence with config path and smoke id".into(), - setup: "Codex project .codex/config.toml MCP route plus optional TFY AGENTS.md guidance".into(), - normal_workflow: "Codex remains normal only after official/configurable MCP or hook routing proves real host invocation; checklist-only guidance is not launch support".into(), + config_strategy: "Codex project .codex/config.toml PreToolUse Bash hook; project .codex layer and /hooks trust required".into(), + apply_strategy: "safe project .codex/config.toml hook writer with TFY marker block, backup, provenance, idempotency, and uninstall".into(), + smoke_strategy: "local hook smoke plus real Codex invocation artifact".into(), + host_evidence_strategy: "Codex-bound official_host_hook ledger/raw evidence with config path and smoke id".into(), + setup: "Codex project .codex/config.toml PreToolUse Bash hook plus optional TFY AGENTS.md guidance".into(), + normal_workflow: "Codex remains normal only after project .codex trust, /hooks review, and route-bound evidence; checklist-only guidance is not launch support".into(), evidence_gate: vec![ - "Codex host actually invokes TFY MCP".into(), + "Codex host actually invokes TFY official host hook".into(), "ledger events from Codex session".into(), "raw recovery and no-negative-savings".into(), ], @@ -4543,7 +4919,7 @@ fn minimum_v1_host_matrix() -> Vec { ]; for host in host_registry() .into_iter() - .filter(|host| host.id != "codex") + .filter(|host| host.id == "claude-code") { hosts.push(host_readiness_from_integration(&host)); } @@ -5082,14 +5458,11 @@ fn route_evidence_has_allowed_launch_route(route: &RouteEvidence) -> bool { } fn host_accepts_launch_evidence(host: &str) -> bool { - host_integration(host).is_ok_and(|integration| integration.status != "planned_discovery") + host_integration(host).is_ok_and(|integration| integration.status == "config_snippet_available") } -fn host_official_hook_launch_supported(_host: &str) -> bool { - // No named production host currently has an implemented official-hook writer with - // docs, uninstall, kill-switch, and e2e evidence. The hook surface is test-shim - // only until a host is explicitly enabled here with regression coverage. - false +fn host_official_hook_launch_supported(host: &str) -> bool { + live_host_smoke_spec(host).is_some() } fn host_artifact_exists(base: &Path, artifact: &Path) -> bool { @@ -5143,7 +5516,6 @@ fn apply_launch_evidence( && route.positive_savings; (local, local && route_host_ready(route), Some(route)) } - "openclaw" => (false, false, None), "codex" => { let named = evidence.named_hosts.get("codex"); let local = named @@ -5392,7 +5764,7 @@ fn build_release_tier_report( release_evidence: &ReleaseEvidenceSummary, launch_blockers: &[String], ) -> ReleaseTierReport { - let required_routes_ready = ["generic_shell", "tfy_agent_adapter", "mcp_stdio"] + let required_routes_ready = ["generic_shell", "tfy_agent_adapter"] .iter() .all(|required| { host_matrix @@ -5405,7 +5777,7 @@ fn build_release_tier_report( let preview_blockers = tier_blockers(&[ ( required_routes_ready, - "required routes generic_shell/tfy_agent_adapter/mcp_stdio are not launch_supported", + "required routes generic_shell/tfy_agent_adapter are not launch_supported", ), ( release_evidence.cargo_install_verified, @@ -5475,7 +5847,7 @@ fn build_release_tier_report( blockers: rc_blockers, }; let named_host_launch = host_matrix.iter().any(|host| { - !host.required_for_v1 && host.status == "launch_supported" && host.host != "openclaw" + matches!(host.host.as_str(), "codex" | "claude-code") && host.status == "launch_supported" }); let ga_blockers = tier_blockers(&[ (rc_ready.status == "ready", "rc_ready is blocked"), @@ -5547,7 +5919,7 @@ fn build_launch_readiness_report( blockers.push("negative savings detected in gain ledgers".into()); } if gain.commands == 0 { - blockers.push("no command-output savings data found; run adapter/MCP tool workflows before launch claims".into()); + blockers.push("no command-output savings data found; run adapter/agent/MCP tool workflows before launch claims".into()); } if !host_evidence.no_negative_savings { blockers.push("negative default model-visible savings or missing no-negative-savings evidence detected".into()); @@ -5576,7 +5948,7 @@ fn build_launch_readiness_report( unsupported_claim_audit: UnsupportedClaimAudit { status: "pass".into(), audited_claims: not_supported_surfaces(), - rule: "unsupported provider/API prompt proxy, editor-internal auto hook, private Codex hook interception, and universal terminal interception claims must remain not_supported/planned; MCP and official-host-hook routes may promote only through config_written, host_launched, verified host invocation, route evidence, and savings_verified gates".into(), + rule: "provider/API prompt proxy, editor-internal auto hook, private Codex hook interception, and universal terminal interception claims must remain not_supported; MCP and official-host-hook routes may promote only through config_written, host_launched, verified host invocation, route evidence, and savings_verified gates".into(), }, blockers, not_supported: status.not_supported, @@ -5596,7 +5968,7 @@ fn build_explain_report() -> ProductExplainReport { ProductExplainReport { ai_transport: "AI sees compact scope/function-level code such as `function f0(a,b){const c=a+b;return c;}` when that saves tokens.".into(), file_and_user_output: "Before code is written or shown to a human, TFY restores original/readable names, indentation, and line breaks into canonical file code.".into(), - automatic_routing: "TFY can automatically configure supported AI-agent host routes where safe writers exist, then the host can use TFY through wrapper/adapter/MCP setup; launch support is granted only after host-bound evidence proves routing and savings.".into(), + automatic_routing: "TFY can prepare the project agent wrapper by default and can configure supported AI-agent host routes where safe writers exist; MCP remains an advanced complementary route. Launch support is granted only after host-bound evidence proves routing and savings.".into(), apply_model: "Writes are validated with plan hash, per-operation proof, preview hash, origin checks, safe paths, rollback journal, and fuzzy unique-anchor gates.".into(), what_tfy_changed: vec![ "configured AI command/context/output boundaries route through TFY instead of sending raw noisy payloads directly to the model".into(), diff --git a/crates/tfy-cli/tests/adapter_gateway.rs b/crates/tfy-cli/tests/adapter_gateway.rs index 516536e..9bfa553 100644 --- a/crates/tfy-cli/tests/adapter_gateway.rs +++ b/crates/tfy-cli/tests/adapter_gateway.rs @@ -203,7 +203,7 @@ fn adapter_capabilities_marks_generic_shell_and_mcp_supported() { && target["automatic_interception"] == "mcp_host_routing_required_not_private_hook")); assert!(targets .iter() - .any(|target| target["target"] == "provider" && target["status"] != "supported")); + .all(|target| target["target"] != "provider" && target["target"] != "editor")); } #[test] diff --git a/crates/tfy-cli/tests/hook_gateway.rs b/crates/tfy-cli/tests/hook_gateway.rs index a362122..34514ed 100644 --- a/crates/tfy-cli/tests/hook_gateway.rs +++ b/crates/tfy-cli/tests/hook_gateway.rs @@ -1,4 +1,22 @@ -use std::process::Command; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run_with_stdin(args: &[&str], stdin: &str) -> std::process::Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_tfy")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} fn run_plain(args: &[&str]) -> String { let output = Command::new(env!("CARGO_BIN_EXE_tfy")) @@ -113,23 +131,149 @@ fn hook_kill_switch_fails_closed() { } #[test] -fn hook_run_fails_closed_for_real_hosts_until_official_e2e_support_exists() { +fn hook_run_rewrites_codex_pre_tool_use_without_executing_pending_command() { + let dir = tempfile::tempdir().unwrap(); + let raw = dir.path().join("raw"); + let ledger = dir.path().join("ledger.jsonl"); + let side_effect = dir.path().join("side-effect"); + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": format!("printf side > {}", side_effect.display())} + }); + let output = run_with_stdin( + &[ + "hook", + "run", + "--host", + "codex", + "--raw-dir", + raw.to_str().unwrap(), + "--ledger", + ledger.to_str().unwrap(), + "--session", + "codex-hook", + ], + &payload.to_string(), + ); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !side_effect.exists(), + "PreToolUse must not execute the pending command" + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let rewritten = json["hookSpecificOutput"]["updatedInput"]["command"] + .as_str() + .unwrap(); + assert_eq!(json["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow"); + assert!(rewritten.contains(" hook run --host "), "{rewritten}"); + assert!(rewritten.contains("codex"), "{rewritten}"); + assert!(rewritten.contains(raw.to_str().unwrap()), "{rewritten}"); + assert!(rewritten.contains(ledger.to_str().unwrap()), "{rewritten}"); +} + +#[test] +fn rewritten_hook_command_records_official_hook_provenance_when_host_executes_it() { + let dir = tempfile::tempdir().unwrap(); + let raw = dir.path().join("raw"); + let ledger = dir.path().join("ledger.jsonl"); + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "printf ok"} + }); + let output = run_with_stdin( + &[ + "hook", + "run", + "--host", + "claude-code", + "--raw-dir", + raw.to_str().unwrap(), + "--ledger", + ledger.to_str().unwrap(), + "--session", + "claude-hook", + ], + &payload.to_string(), + ); + assert!(output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let rewritten = json["hookSpecificOutput"]["updatedInput"]["command"] + .as_str() + .unwrap(); + let executed = Command::new("sh").args(["-c", rewritten]).output().unwrap(); + assert!( + executed.status.success(), + "stderr={}", + String::from_utf8_lossy(&executed.stderr) + ); + assert_eq!(String::from_utf8_lossy(&executed.stdout), "ok"); + let ledger_text = std::fs::read_to_string(&ledger).unwrap(); + assert!(ledger_text.contains("official_host_hook"), "{ledger_text}"); + assert!(ledger_text.contains("claude_code"), "{ledger_text}"); +} + +#[test] +fn hook_run_fails_closed_for_unsupported_host_even_with_explicit_command() { let output = Command::new(env!("CARGO_BIN_EXE_tfy")) .args([ "hook", "run", "--host", - "codex", + "cursor", "--", "sh", "-c", - "printf ok", + "printf no", ]) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("supported only for test-shim"), "{stderr}"); + assert!( + stderr.contains("not available in TFY agent mode"), + "{stderr}" + ); + assert!( + stderr.contains("supported hosts: codex, claude-code"), + "{stderr}" + ); +} + +#[test] +fn hook_run_fails_closed_for_non_bash_and_ambiguous_payloads() { + for payload in [ + r#"{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file":"README.md"}}"#, + r#"{"hook_event_name":"PreToolUse","tool_name":"PowerShell","tool_input":{"command":"Write-Output ok"}}"#, + r#"{"hook_event_name":"PreToolUse","tool_name":"ShellScript","tool_input":{"command":"printf ok"}}"#, + ] { + let non_bash = run_with_stdin(&["hook", "run", "--host", "codex"], payload); + assert!(!non_bash.status.success(), "payload={payload}"); + let stderr = String::from_utf8_lossy(&non_bash.stderr); + assert!(stderr.contains("only Bash/shell"), "{stderr}"); + } + + let ambiguous = run_with_stdin( + &["hook", "run", "--host", "claude-code"], + r#"{"command":"printf ok"}"#, + ); + assert!(!ambiguous.status.success()); + let stderr = String::from_utf8_lossy(&ambiguous.stderr); + assert!(stderr.contains("hook_event_name=PreToolUse"), "{stderr}"); +} + +#[test] +fn hook_run_fails_closed_on_malformed_real_host_payload() { + let output = run_with_stdin(&["hook", "run", "--host", "claude-code"], "not-json"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("parse host hook JSON"), "{stderr}"); } #[test] @@ -142,7 +286,7 @@ fn hook_capabilities_and_install_are_truthful_dry_run_surfaces() { let json: serde_json::Value = serde_json::from_slice(&caps.stdout).unwrap(); assert_eq!( json["default_policy"], - "disabled_until_official_host_docs_and_e2e_evidence" + "official_docs_backed_for_codex_and_claude_code_but_launch_evidence_gated" ); assert!(json["not_claimed"] .as_array() @@ -155,7 +299,7 @@ fn hook_capabilities_and_install_are_truthful_dry_run_surfaces() { .iter() .find(|target| target["target"] == "codex") .unwrap(); - assert_eq!(codex["claim_tier"], "unsupported"); + assert_eq!(codex["claim_tier"], "config_written"); let dry = Command::new(env!("CARGO_BIN_EXE_tfy")) .args(["hook", "install", "--target", "codex", "--dry-run"]) @@ -163,7 +307,7 @@ fn hook_capabilities_and_install_are_truthful_dry_run_surfaces() { .unwrap(); assert!(dry.status.success()); let text = String::from_utf8_lossy(&dry.stdout); - assert!(text.contains("unsupported_without_public_official_hook")); + assert!(text.contains("supported_configured_unverified")); assert!(text.contains("kill_switch=TFY_HOOK_DISABLE=1")); let apply = Command::new(env!("CARGO_BIN_EXE_tfy")) diff --git a/crates/tfy-cli/tests/product_ux.rs b/crates/tfy-cli/tests/product_ux.rs index 0666794..292463f 100644 --- a/crates/tfy-cli/tests/product_ux.rs +++ b/crates/tfy-cli/tests/product_ux.rs @@ -364,13 +364,10 @@ fn smoke_codex_is_checklist_only() { } #[test] -fn setup_named_hosts_emit_truthful_mcp_snippets_without_claiming_savings() { +fn setup_named_hosts_emit_truthful_snippets_without_claiming_savings() { let cases = [ - ("codex", "codex mcp add tfy"), - ("claude-code", "claude mcp add tfy"), - ("cursor", "\"mcpServers\""), - ("opencode", "\"mcp\""), - ("hermes", "mcp_servers:"), + ("codex", "[[hooks.PreToolUse]]"), + ("claude-code", "\"PreToolUse\""), ]; for (host, expected) in cases { let output = Command::new(env!("CARGO_BIN_EXE_tfy")) @@ -396,15 +393,19 @@ fn setup_named_hosts_emit_truthful_mcp_snippets_without_claiming_savings() { } #[test] -fn setup_openclaw_remains_planned_discovery() { +fn setup_cursor_remains_planned_discovery() { let output = Command::new(env!("CARGO_BIN_EXE_tfy")) - .args(["setup", "--ai", "--host", "openclaw", "--dry-run"]) + .args(["setup", "--ai", "--host", "cursor", "--dry-run"]) .output() .unwrap(); - assert!(output.status.success()); - let text = String::from_utf8_lossy(&output.stdout); - assert!(text.contains("planned_discovery"), "{text}"); - assert!(text.contains("no setup/apply/smoke support"), "{text}"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains( + "host 'cursor' is not available in TFY agent mode; supported hosts: codex, claude-code" + ), + "{stderr}" + ); } #[test] @@ -413,61 +414,24 @@ fn setup_cursor_project_apply_is_reversible_and_preserves_existing_config() { let cursor_dir = dir.path().join(".cursor"); std::fs::create_dir_all(&cursor_dir).unwrap(); let config = cursor_dir.join("mcp.json"); - std::fs::write( - &config, - r#"{"mcpServers":{"other":{"command":"other"}},"keep":true}"#, - ) - .unwrap(); - - for _ in 0..2 { - let output = Command::new(env!("CARGO_BIN_EXE_tfy")) - .current_dir(dir.path()) - .args(["setup", "--ai", "--host", "cursor", "--apply", "--project"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr={}", - String::from_utf8_lossy(&output.stderr) - ); - } - assert!(cursor_dir.join("mcp.json.tfy-backup").exists()); - let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&config).unwrap()).unwrap(); - assert_eq!(json["keep"], true); - assert_eq!(json["mcpServers"]["other"]["command"], "other"); - assert_eq!(json["mcpServers"]["tfy"]["command"], "tfy"); - assert_eq!(json["mcpServers"]["tfy"]["tfy_managed"], true); - assert_eq!(json["mcpServers"]["tfy"].as_object().unwrap().len(), 3); - assert_eq!( - std::fs::read_to_string(cursor_dir.join("mcp.json.tfy-backup")).unwrap(), - r#"{"mcpServers":{"other":{"command":"other"}},"keep":true}"# - ); + let original = r#"{"mcpServers":{"other":{"command":"other"}},"keep":true}"#; + std::fs::write(&config, original).unwrap(); - let uninstall = Command::new(env!("CARGO_BIN_EXE_tfy")) + let output = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args([ - "setup", - "--ai", - "--host", - "cursor", - "--uninstall", - "--apply", - "--project", - ]) + .args(["setup", "--ai", "--host", "cursor", "--apply", "--project"]) .output() .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - uninstall.status.success(), - "stderr={}", - String::from_utf8_lossy(&uninstall.stderr) - ); - let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&config).unwrap()).unwrap(); - assert_eq!(json["mcpServers"]["other"]["command"], "other"); - assert!(json["mcpServers"]["tfy"].is_null(), "{json}"); - assert_eq!( - std::fs::read_to_string(cursor_dir.join("mcp.json.tfy-backup")).unwrap(), - r#"{"mcpServers":{"other":{"command":"other"}},"keep":true}"# + stderr.contains( + "host 'cursor' is not available in TFY agent mode; supported hosts: codex, claude-code" + ), + "{stderr}" ); + assert_eq!(std::fs::read_to_string(&config).unwrap(), original); + assert!(!cursor_dir.join("mcp.json.tfy-backup").exists()); } #[test] @@ -485,24 +449,9 @@ fn setup_cursor_project_apply_refuses_to_overwrite_unowned_tfy_entry() { .output() .unwrap(); assert!(!apply.status.success()); + assert!(String::from_utf8_lossy(&apply.stderr).contains("not available in TFY agent mode")); assert_eq!(std::fs::read_to_string(&config).unwrap(), original); assert!(!cursor_dir.join("mcp.json.tfy-backup").exists()); - - let uninstall = Command::new(env!("CARGO_BIN_EXE_tfy")) - .current_dir(dir.path()) - .args([ - "setup", - "--ai", - "--host", - "cursor", - "--uninstall", - "--apply", - "--project", - ]) - .output() - .unwrap(); - assert!(!uninstall.status.success()); - assert_eq!(std::fs::read_to_string(&config).unwrap(), original); } #[test] @@ -540,17 +489,8 @@ fn setup_cursor_project_dry_run_writes_nothing() { ]) .output() .unwrap(); - assert!( - output.status.success(), - "stderr={}", - String::from_utf8_lossy(&output.stderr) - ); - let text = String::from_utf8_lossy(&output.stdout); - assert!( - text.contains("status=dry_run claim_tier=configurable"), - "{text}" - ); - assert!(text.contains("dry_run=true applied=false"), "{text}"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("not available in TFY agent mode")); assert!(!dir.path().join(".cursor").join("mcp.json").exists()); } @@ -570,28 +510,15 @@ fn setup_cursor_project_uninstall_without_existing_config_writes_nothing() { ]) .output() .unwrap(); - assert!( - output.status.success(), - "stderr={}", - String::from_utf8_lossy(&output.stderr) - ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("not available in TFY agent mode")); assert!(!dir.path().join(".cursor").join("mcp.json").exists()); } #[test] fn setup_cursor_project_uninstall_removes_tfy_created_config_without_backup() { let dir = tempfile::tempdir().unwrap(); - let apply = Command::new(env!("CARGO_BIN_EXE_tfy")) - .current_dir(dir.path()) - .args(["setup", "--ai", "--host", "cursor", "--apply", "--project"]) - .output() - .unwrap(); - assert!( - apply.status.success(), - "stderr={}", - String::from_utf8_lossy(&apply.stderr) - ); - let uninstall = Command::new(env!("CARGO_BIN_EXE_tfy")) + let output = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) .args([ "setup", @@ -604,42 +531,47 @@ fn setup_cursor_project_uninstall_removes_tfy_created_config_without_backup() { ]) .output() .unwrap(); - assert!( - uninstall.status.success(), - "stderr={}", - String::from_utf8_lossy(&uninstall.stderr) - ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("not available in TFY agent mode")); assert!(!dir.path().join(".cursor").join("mcp.json").exists()); - assert!(!dir - .path() - .join(".cursor") - .join("mcp.json.tfy-backup") - .exists()); } #[test] fn mcp_install_supports_named_host_dry_runs_and_blocks_non_codex_output() { let output = Command::new(env!("CARGO_BIN_EXE_tfy")) - .args(["mcp", "install", "--target", "hermes", "--dry-run"]) + .args(["mcp", "install", "--target", "claude-code", "--dry-run"]) .output() .unwrap(); assert!(output.status.success()); let text = String::from_utf8_lossy(&output.stdout); - assert!(text.contains("TFY MCP Hermes setup dry-run"), "{text}"); - assert!(text.contains("mcp_servers:"), "{text}"); + assert!(text.contains("TFY MCP Claude Code setup dry-run"), "{text}"); + assert!(text.contains("mcpServers"), "{text}"); assert!( text.contains("Setup success is not token-savings success"), "{text}" ); + let invalid = Command::new(env!("CARGO_BIN_EXE_tfy")) + .args(["mcp", "install", "--target", "cursor", "--dry-run"]) + .output() + .unwrap(); + assert!(!invalid.status.success()); + assert!( + String::from_utf8_lossy(&invalid.stderr).contains( + "target 'cursor' is not available in TFY agent mode; supported hosts: codex, claude-code" + ), + "stderr={}", + String::from_utf8_lossy(&invalid.stderr) + ); + let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cursor.json"); + let path = dir.path().join("claude-code.json"); let blocked = Command::new(env!("CARGO_BIN_EXE_tfy")) .args([ "mcp", "install", "--target", - "cursor", + "claude-code", "--output", path.to_str().unwrap(), ]) @@ -654,6 +586,35 @@ fn mcp_install_supports_named_host_dry_runs_and_blocks_non_codex_output() { ); } +#[test] +fn cursor_doctor_and_smoke_are_unsupported_not_configurable() { + let doctor = Command::new(env!("CARGO_BIN_EXE_tfy")) + .args(["doctor", "--host", "cursor", "--json"]) + .output() + .unwrap(); + assert!(!doctor.status.success()); + assert!( + String::from_utf8_lossy(&doctor.stderr).contains( + "host 'cursor' is not available in TFY agent mode; supported hosts: codex, claude-code" + ), + "stderr={}", + String::from_utf8_lossy(&doctor.stderr) + ); + + let smoke = Command::new(env!("CARGO_BIN_EXE_tfy")) + .args(["smoke", "--host", "cursor", "--json"]) + .output() + .unwrap(); + assert!(!smoke.status.success()); + assert!( + String::from_utf8_lossy(&smoke.stderr).contains( + "host 'cursor' is not available in TFY agent mode; supported hosts: codex, claude-code" + ), + "stderr={}", + String::from_utf8_lossy(&smoke.stderr) + ); +} + #[test] fn status_json_exposes_canonical_named_host_taxonomy() { let output = Command::new(env!("CARGO_BIN_EXE_tfy")) @@ -671,13 +632,14 @@ fn status_json_exposes_canonical_named_host_taxonomy() { }; assert_eq!(find("codex")["status"], "config_snippet_available"); assert_eq!(find("claude-code")["status"], "config_snippet_available"); - assert_eq!(find("cursor")["status"], "config_snippet_available"); - assert_eq!(find("opencode")["status"], "config_snippet_available"); - assert_eq!(find("hermes")["status"], "config_snippet_available"); - assert_eq!(find("openclaw")["status"], "planned_discovery"); + for hidden in ["cursor", "opencode", "hermes", "openclaw"] { + assert!( + hosts.iter().all(|host| host["host"] != hidden), + "unexpected public host {hidden}: {json}" + ); + } assert_eq!(find("codex")["claim_tier"], "configurable"); - assert_eq!(find("cursor")["claim_tier"], "configurable"); - assert_eq!(find("openclaw")["claim_tier"], "planned_discovery"); + assert_eq!(find("claude-code")["claim_tier"], "configurable"); assert!(json["claim_evidence_ladder"] .as_array() .unwrap() @@ -688,7 +650,12 @@ fn status_json_exposes_canonical_named_host_taxonomy() { .as_array() .unwrap() .iter() - .any(|ingress| ingress == "mcp_stdio")); + .any(|ingress| ingress == "official_host_hook")); + assert!(find("claude-code")["supported_ingress"] + .as_array() + .unwrap() + .iter() + .any(|ingress| ingress == "official_host_hook")); assert!(find("codex")["unsupported_ingress"] .as_array() .unwrap() @@ -706,7 +673,7 @@ fn launch_report_keeps_named_host_setup_evidence_below_launch_supported() { let evidence = dir.path().join("host-evidence.json"); std::fs::write( &evidence, - r#"{"hosts":[{"host":"cursor","tfy_version":"0.1.0","setup_verified":true,"real_invocation_verified":true,"setup_artifact":"setup.txt","invocation_artifact":"invoke.txt","overhead_ms":10,"baseline_ms":10}]}"#, + r#"{"hosts":[{"host":"claude-code","tfy_version":"0.1.0","setup_verified":true,"real_invocation_verified":true,"setup_artifact":"setup.txt","invocation_artifact":"invoke.txt","overhead_ms":10,"baseline_ms":10}]}"#, ) .unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_tfy")) @@ -721,13 +688,13 @@ fn launch_report_keeps_named_host_setup_evidence_below_launch_supported() { .unwrap(); assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|host| host["host"] == "cursor") + .find(|host| host["host"] == "claude-code") .unwrap(); - assert_ne!(cursor["status"], "launch_supported", "{json}"); + assert_ne!(claude_code["status"], "launch_supported", "{json}"); assert!( json["blockers"].as_array().unwrap().iter().any(|b| b .as_str() @@ -738,7 +705,7 @@ fn launch_report_keeps_named_host_setup_evidence_below_launch_supported() { } #[test] -fn launch_report_keeps_openclaw_planned_discovery_even_with_host_evidence() { +fn launch_report_keeps_cursor_planned_discovery_even_with_host_evidence() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("setup-proof.txt"), "configured").unwrap(); std::fs::write(dir.path().join("invocation-proof.txt"), "invoked").unwrap(); @@ -747,7 +714,7 @@ fn launch_report_keeps_openclaw_planned_discovery_even_with_host_evidence() { &host_evidence, r#"{ "hosts": [ - {"host":"openclaw","setup_verified":true,"real_invocation_verified":true,"setup_artifact":"setup-proof.txt","invocation_artifact":"invocation-proof.txt","overhead_ms":10,"baseline_ms":10} + {"host":"cursor","setup_verified":true,"real_invocation_verified":true,"setup_artifact":"setup-proof.txt","invocation_artifact":"invocation-proof.txt","overhead_ms":10,"baseline_ms":10} ] }"#, ) @@ -762,24 +729,24 @@ fn launch_report_keeps_openclaw_planned_discovery_even_with_host_evidence() { ]) .output() .unwrap(); + assert!(report.status.success()); + let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); assert!( - report.status.success(), - "stderr={}", - String::from_utf8_lossy(&report.stderr) + json["host_matrix"] + .as_array() + .unwrap() + .iter() + .all(|host| host["host"] != "cursor"), + "{json}" ); - let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let openclaw = json["host_matrix"] - .as_array() - .unwrap() - .iter() - .find(|host| host["host"] == "openclaw") - .unwrap(); - assert_eq!(openclaw["status"], "planned_discovery", "{json}"); - assert_eq!( - openclaw["launch_claim"], - "unsupported/planned until official/current evidence proves a safe route", + assert!( + json["host_evidence"]["named_hosts"]["cursor"].is_null(), "{json}" ); + assert!( + !json.to_string().contains("cursor"), + "unsupported host leaked into public launch report: {json}" + ); } #[test] @@ -789,8 +756,8 @@ fn launch_report_promotes_named_host_only_with_host_bound_evidence() { for file in [ "setup.txt", "invoke.txt", - ".cursor/mcp.json", - "cursor-ledger.jsonl", + "claude_code.json", + "claude-code-ledger.jsonl", "raw.txt", ] { std::fs::write(dir.path().join(file), "evidence").unwrap(); @@ -801,7 +768,7 @@ fn launch_report_promotes_named_host_only_with_host_bound_evidence() { r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", "setup_verified":true, "real_invocation_verified":true, @@ -809,18 +776,18 @@ fn launch_report_promotes_named_host_only_with_host_bound_evidence() { "invocation_artifact":"invoke.txt", "overhead_ms":10, "baseline_ms":10, - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "config_scope":"project", - "config_path":".cursor/mcp.json", + "config_path":"claude_code.json", "route_type":"mcp", - "ledger_artifact":"cursor-ledger.jsonl", + "ledger_artifact":"claude-code-ledger.jsonl", "raw_artifact":"raw.txt", "redacted_public_bytes":200, "model_visible_bytes":100, "savings_result":"positive", "timestamp":"2026-06-09T00:00:00Z", - "smoke_id":"cursor-smoke-1" + "smoke_id":"claude-code-smoke-1" } ] }"#, @@ -842,16 +809,16 @@ fn launch_report_promotes_named_host_only_with_host_bound_evidence() { String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|host| host["host"] == "cursor") + .find(|host| host["host"] == "claude-code") .unwrap(); - assert_eq!(cursor["status"], "launch_supported", "{json}"); - assert_eq!(cursor["claim_tier"], "launch_supported", "{json}"); + assert_eq!(claude_code["status"], "launch_supported", "{json}"); + assert_eq!(claude_code["claim_tier"], "launch_supported", "{json}"); assert_eq!( - json["host_evidence"]["named_hosts"]["cursor"]["host_bound_evidence"], true, + json["host_evidence"]["named_hosts"]["claude-code"]["host_bound_evidence"], true, "{json}" ); } @@ -863,8 +830,8 @@ fn launch_report_keeps_setup_only_named_evidence_from_poisoning_byte_proof() { for file in [ "setup.txt", "invoke.txt", - ".cursor/mcp.json", - "cursor-ledger.jsonl", + "claude_code.json", + "claude-code-ledger.jsonl", "raw.txt", ] { std::fs::write(dir.path().join(file), "evidence").unwrap(); @@ -875,7 +842,7 @@ fn launch_report_keeps_setup_only_named_evidence_from_poisoning_byte_proof() { r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", "setup_verified":true, "real_invocation_verified":true, @@ -894,7 +861,7 @@ fn launch_report_keeps_setup_only_named_evidence_from_poisoning_byte_proof() { r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", "setup_verified":true, "real_invocation_verified":true, @@ -902,17 +869,17 @@ fn launch_report_keeps_setup_only_named_evidence_from_poisoning_byte_proof() { "invocation_artifact":"invoke.txt", "overhead_ms":10, "baseline_ms":10, - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "config_scope":"project", - "config_path":".cursor/mcp.json", + "config_path":"claude_code.json", "route_type":"mcp", - "ledger_artifact":"cursor-ledger.jsonl", + "ledger_artifact":"claude-code-ledger.jsonl", "raw_artifact":"raw.txt", "redacted_public_bytes":200, "model_visible_bytes":100, "timestamp":"2026-06-09T00:00:00Z", - "smoke_id":"cursor-smoke-1" + "smoke_id":"claude-code-smoke-1" } ] }"#, @@ -936,15 +903,15 @@ fn launch_report_keeps_setup_only_named_evidence_from_poisoning_byte_proof() { String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|host| host["host"] == "cursor") + .find(|host| host["host"] == "claude-code") .unwrap(); - assert_eq!(cursor["status"], "launch_supported", "{json}"); + assert_eq!(claude_code["status"], "launch_supported", "{json}"); assert_eq!( - json["host_evidence"]["named_hosts"]["cursor"]["no_negative_savings"], true, + json["host_evidence"]["named_hosts"]["claude-code"]["no_negative_savings"], true, "{json}" ); } @@ -956,8 +923,8 @@ fn launch_report_does_not_promote_named_host_when_bytes_contradict_positive_labe for file in [ "setup.txt", "invoke.txt", - ".cursor/mcp.json", - "cursor-ledger.jsonl", + "claude_code.json", + "claude-code-ledger.jsonl", "raw.txt", ] { std::fs::write(dir.path().join(file), "evidence").unwrap(); @@ -968,7 +935,7 @@ fn launch_report_does_not_promote_named_host_when_bytes_contradict_positive_labe r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", "setup_verified":true, "real_invocation_verified":true, @@ -976,18 +943,18 @@ fn launch_report_does_not_promote_named_host_when_bytes_contradict_positive_labe "invocation_artifact":"invoke.txt", "overhead_ms":10, "baseline_ms":10, - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "config_scope":"project", - "config_path":".cursor/mcp.json", + "config_path":"claude_code.json", "route_type":"mcp", - "ledger_artifact":"cursor-ledger.jsonl", + "ledger_artifact":"claude-code-ledger.jsonl", "raw_artifact":"raw.txt", "redacted_public_bytes":100, "model_visible_bytes":200, "savings_result":"positive", "timestamp":"2026-06-09T00:00:00Z", - "smoke_id":"cursor-smoke-1" + "smoke_id":"claude-code-smoke-1" } ] }"#, @@ -1009,15 +976,15 @@ fn launch_report_does_not_promote_named_host_when_bytes_contradict_positive_labe String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|host| host["host"] == "cursor") + .find(|host| host["host"] == "claude-code") .unwrap(); - assert_ne!(cursor["status"], "launch_supported", "{json}"); + assert_ne!(claude_code["status"], "launch_supported", "{json}"); assert_eq!( - json["host_evidence"]["named_hosts"]["cursor"]["host_bound_evidence"], false, + json["host_evidence"]["named_hosts"]["claude-code"]["host_bound_evidence"], false, "{json}" ); } @@ -1029,8 +996,8 @@ fn launch_report_rejects_positive_label_without_byte_fields() { for file in [ "setup.txt", "invoke.txt", - ".cursor/mcp.json", - "cursor-ledger.jsonl", + "claude_code.json", + "claude-code-ledger.jsonl", "raw.txt", ] { std::fs::write(dir.path().join(file), "evidence").unwrap(); @@ -1041,7 +1008,7 @@ fn launch_report_rejects_positive_label_without_byte_fields() { r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", "setup_verified":true, "real_invocation_verified":true, @@ -1049,16 +1016,16 @@ fn launch_report_rejects_positive_label_without_byte_fields() { "invocation_artifact":"invoke.txt", "overhead_ms":10, "baseline_ms":10, - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "config_scope":"project", - "config_path":".cursor/mcp.json", + "config_path":"claude_code.json", "route_type":"mcp", - "ledger_artifact":"cursor-ledger.jsonl", + "ledger_artifact":"claude-code-ledger.jsonl", "raw_artifact":"raw.txt", "savings_result":"positive", "timestamp":"2026-06-09T00:00:00Z", - "smoke_id":"cursor-smoke-1" + "smoke_id":"claude-code-smoke-1" } ] }"#, @@ -1080,15 +1047,15 @@ fn launch_report_rejects_positive_label_without_byte_fields() { String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|host| host["host"] == "cursor") + .find(|host| host["host"] == "claude-code") .unwrap(); - assert_ne!(cursor["status"], "launch_supported", "{json}"); + assert_ne!(claude_code["status"], "launch_supported", "{json}"); assert_eq!( - json["host_evidence"]["named_hosts"]["cursor"]["host_bound_evidence"], false, + json["host_evidence"]["named_hosts"]["claude-code"]["host_bound_evidence"], false, "{json}" ); } @@ -1100,8 +1067,8 @@ fn launch_report_rejects_substring_positive_savings_labels() { for file in [ "setup.txt", "invoke.txt", - ".cursor/mcp.json", - "cursor-ledger.jsonl", + "claude_code.json", + "claude-code-ledger.jsonl", "raw.txt", ] { std::fs::write(dir.path().join(file), "evidence").unwrap(); @@ -1112,7 +1079,7 @@ fn launch_report_rejects_substring_positive_savings_labels() { r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", "setup_verified":true, "real_invocation_verified":true, @@ -1120,16 +1087,16 @@ fn launch_report_rejects_substring_positive_savings_labels() { "invocation_artifact":"invoke.txt", "overhead_ms":10, "baseline_ms":10, - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "config_scope":"project", - "config_path":".cursor/mcp.json", + "config_path":"claude_code.json", "route_type":"mcp", - "ledger_artifact":"cursor-ledger.jsonl", + "ledger_artifact":"claude-code-ledger.jsonl", "raw_artifact":"raw.txt", "savings_result":"not_positive", "timestamp":"2026-06-09T00:00:00Z", - "smoke_id":"cursor-smoke-1" + "smoke_id":"claude-code-smoke-1" } ] }"#, @@ -1151,15 +1118,15 @@ fn launch_report_rejects_substring_positive_savings_labels() { String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|host| host["host"] == "cursor") + .find(|host| host["host"] == "claude-code") .unwrap(); - assert_ne!(cursor["status"], "launch_supported", "{json}"); + assert_ne!(claude_code["status"], "launch_supported", "{json}"); assert_eq!( - json["host_evidence"]["named_hosts"]["cursor"]["host_bound_evidence"], false, + json["host_evidence"]["named_hosts"]["claude-code"]["host_bound_evidence"], false, "{json}" ); } @@ -1196,7 +1163,7 @@ fn launch_report_caps_named_host_even_with_unrelated_mcp_savings() { &host_evidence, r#"{ "hosts": [ - {"host":"cursor","tfy_version":"0.1.0","setup_verified":true,"real_invocation_verified":true,"setup_artifact":"setup-proof.txt","invocation_artifact":"invocation-proof.txt","overhead_ms":10,"baseline_ms":10} + {"host":"claude-code","tfy_version":"0.1.0","setup_verified":true,"real_invocation_verified":true,"setup_artifact":"setup-proof.txt","invocation_artifact":"invocation-proof.txt","overhead_ms":10,"baseline_ms":10} ] }"#, ) @@ -1215,16 +1182,16 @@ fn launch_report_caps_named_host_even_with_unrelated_mcp_savings() { ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); assert_eq!(json["host_evidence"]["positive_savings"], true, "{json}"); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|host| host["host"] == "cursor") + .find(|host| host["host"] == "claude-code") .unwrap(); - assert_eq!(cursor["status"], "verified_host_invocation", "{json}"); - assert_ne!(cursor["status"], "launch_supported", "{json}"); + assert_eq!(claude_code["status"], "verified_host_invocation", "{json}"); + assert_ne!(claude_code["status"], "launch_supported", "{json}"); assert!( - cursor["evidence_gate"] + claude_code["evidence_gate"] .as_array() .unwrap() .iter() @@ -1624,11 +1591,11 @@ fn launch_report_promotes_adapter_when_gain_ledger_has_command_evidence() { .unwrap() .contains("local smoke verified")); assert_eq!(json["status"], "blocked"); - assert!(json["blockers"] - .as_array() - .unwrap() - .iter() - .any(|b| { b.as_str().unwrap().contains("required v1 host mcp_stdio") })); + assert!(json["blockers"].as_array().unwrap().iter().any(|b| { + b.as_str() + .unwrap() + .contains("required v1 host tfy_agent_adapter") + })); } #[test] @@ -1642,11 +1609,16 @@ fn launch_report_blocks_unverified_codex_and_exposes_v1_host_matrix() { assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(json["status"], "blocked"); - assert!(json["blockers"] - .as_array() - .unwrap() - .iter() - .any(|b| { b.as_str().unwrap().contains("required v1 host mcp_stdio") })); + assert!(json["blockers"].as_array().unwrap().iter().any(|b| { + b.as_str() + .unwrap() + .contains("required v1 host generic_shell") + })); + assert!(json["blockers"].as_array().unwrap().iter().any(|b| { + b.as_str() + .unwrap() + .contains("required v1 host tfy_agent_adapter") + })); let hosts = json["host_matrix"].as_array().unwrap(); assert!(hosts.iter().any(|h| h["host"] == "mcp_stdio")); assert!(hosts.iter().any(|h| h["host"] == "tfy_agent_adapter")); @@ -1654,9 +1626,11 @@ fn launch_report_blocks_unverified_codex_and_exposes_v1_host_matrix() { assert!(hosts.iter().all(|h| h.get("normal_workflow").is_some())); assert!(hosts .iter() - .filter(|h| ["mcp_stdio", "tfy_agent_adapter", "generic_shell"] - .contains(&h["host"].as_str().unwrap())) + .filter(|h| ["tfy_agent_adapter", "generic_shell"].contains(&h["host"].as_str().unwrap())) .all(|h| h["required_for_v1"] == true)); + assert!(hosts + .iter() + .any(|h| h["host"] == "mcp_stdio" && h["required_for_v1"] == false)); assert!(hosts .iter() .any(|h| h["host"] == "codex" && h["status"] == "config_snippet_available")); @@ -1964,9 +1938,9 @@ fn launch_report_promotes_claims_only_from_route_bound_evidence_tiers() { for file in [ "setup-proof.txt", "invocation-proof.txt", - "cursor-config.json", - "cursor-ledger.jsonl", - "cursor-raw.txt", + "claude-code-config.json", + "claude-code-ledger.jsonl", + "claude-code-raw.txt", ] { std::fs::write(dir.path().join(file), "proof").unwrap(); } @@ -1976,20 +1950,20 @@ fn launch_report_promotes_claims_only_from_route_bound_evidence_tiers() { r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "setup_verified":true, "real_invocation_verified":true, "setup_artifact":"setup-proof.txt", "invocation_artifact":"invocation-proof.txt", "config_scope":"project", - "config_path":"cursor-config.json", + "config_path":"claude-code-config.json", "route_type":"mcp_stdio", - "ledger_artifact":"cursor-ledger.jsonl", - "raw_artifact":"cursor-raw.txt", - "smoke_id":"cursor-mcp-smoke", + "ledger_artifact":"claude-code-ledger.jsonl", + "raw_artifact":"claude-code-raw.txt", + "smoke_id":"claude-code-mcp-smoke", "timestamp":"2026-06-09T00:00:00Z", "redacted_public_bytes":1000, "model_visible_bytes":100, @@ -2016,13 +1990,13 @@ fn launch_report_promotes_claims_only_from_route_bound_evidence_tiers() { String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|h| h["host"] == "cursor") + .find(|h| h["host"] == "claude-code") .unwrap(); - assert_eq!(cursor["status"], "launch_supported", "{json}"); + assert_eq!(claude_code["status"], "launch_supported", "{json}"); for tier in [ "config_written", "host_launched", @@ -2032,7 +2006,7 @@ fn launch_report_promotes_claims_only_from_route_bound_evidence_tiers() { "launch_supported", ] { assert!( - cursor["evidence_tiers"] + claude_code["evidence_tiers"] .as_array() .unwrap() .iter() @@ -2040,7 +2014,7 @@ fn launch_report_promotes_claims_only_from_route_bound_evidence_tiers() { "missing {tier}: {json}" ); } - assert_eq!(cursor["next_evidence_tier"], "complete", "{json}"); + assert_eq!(claude_code["next_evidence_tier"], "complete", "{json}"); assert!(json["unsupported_claim_audit"]["rule"] .as_str() .unwrap() @@ -2058,9 +2032,9 @@ fn launch_report_refuses_named_host_unsupported_route_type_even_with_artifacts_a for file in [ "setup-proof.txt", "invocation-proof.txt", - "cursor-config.json", - "cursor-ledger.jsonl", - "cursor-raw.txt", + "claude-code-config.json", + "claude-code-ledger.jsonl", + "claude-code-raw.txt", ] { std::fs::write(dir.path().join(file), "proof").unwrap(); } @@ -2070,20 +2044,20 @@ fn launch_report_refuses_named_host_unsupported_route_type_even_with_artifacts_a r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "setup_verified":true, "real_invocation_verified":true, "setup_artifact":"setup-proof.txt", "invocation_artifact":"invocation-proof.txt", "config_scope":"project", - "config_path":"cursor-config.json", + "config_path":"claude-code-config.json", "route_type":"provider_api_prompt_proxy", - "ledger_artifact":"cursor-ledger.jsonl", - "raw_artifact":"cursor-raw.txt", - "smoke_id":"cursor-provider-proxy-smoke", + "ledger_artifact":"claude-code-ledger.jsonl", + "raw_artifact":"claude-code-raw.txt", + "smoke_id":"claude-code-provider-proxy-smoke", "timestamp":"2026-06-09T00:00:00Z", "redacted_public_bytes":1000, "model_visible_bytes":100, @@ -2110,14 +2084,14 @@ fn launch_report_refuses_named_host_unsupported_route_type_even_with_artifacts_a String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|h| h["host"] == "cursor") + .find(|h| h["host"] == "claude-code") .unwrap(); - assert_ne!(cursor["status"], "launch_supported", "{json}"); - assert!(!cursor["evidence_tiers"] + assert_ne!(claude_code["status"], "launch_supported", "{json}"); + assert!(!claude_code["evidence_tiers"] .as_array() .unwrap() .iter() @@ -2129,15 +2103,143 @@ fn launch_report_refuses_named_host_unsupported_route_type_even_with_artifacts_a .any(|surface| surface == "provider_api_prompt_proxy")); } +#[test] +fn launch_report_promotes_codex_and_claude_code_official_hook_evidence() { + let dir = tempfile::tempdir().unwrap(); + for file in [ + "codex-setup.txt", + "codex-invocation.txt", + "codex-config.toml", + "codex-ledger.jsonl", + "codex-raw.txt", + "claude-setup.txt", + "claude-invocation.txt", + "claude-settings.json", + "claude-ledger.jsonl", + "claude-raw.txt", + ] { + std::fs::write(dir.path().join(file), "proof").unwrap(); + } + let host_evidence = dir.path().join("host-evidence.json"); + std::fs::write( + &host_evidence, + r#"{ + "hosts": [ + { + "host":"codex", + "tfy_version":"0.1.0", + "host_id":"codex", + "host_version":"test", + "setup_verified":true, + "real_invocation_verified":true, + "setup_artifact":"codex-setup.txt", + "invocation_artifact":"codex-invocation.txt", + "config_scope":"project", + "config_path":"codex-config.toml", + "route_type":"official_host_hook", + "ledger_artifact":"codex-ledger.jsonl", + "raw_artifact":"codex-raw.txt", + "smoke_id":"codex-hook-smoke", + "timestamp":"2026-06-09T00:00:00Z", + "redacted_public_bytes":1000, + "model_visible_bytes":100, + "official_docs_backed":true, + "kill_switch_available":true, + "uninstall_available":true, + "overhead_ms":10, + "baseline_ms":10 + }, + { + "host":"claude-code", + "tfy_version":"0.1.0", + "host_id":"claude-code", + "host_version":"test", + "setup_verified":true, + "real_invocation_verified":true, + "setup_artifact":"claude-setup.txt", + "invocation_artifact":"claude-invocation.txt", + "config_scope":"project", + "config_path":"claude-settings.json", + "route_type":"official_host_hook", + "ledger_artifact":"claude-ledger.jsonl", + "raw_artifact":"claude-raw.txt", + "smoke_id":"claude-hook-smoke", + "timestamp":"2026-06-09T00:00:00Z", + "redacted_public_bytes":1000, + "model_visible_bytes":100, + "official_docs_backed":true, + "kill_switch_available":true, + "uninstall_available":true, + "overhead_ms":10, + "baseline_ms":10 + } + ] + }"#, + ) + .unwrap(); + let report = Command::new(env!("CARGO_BIN_EXE_tfy")) + .current_dir(dir.path()) + .args([ + "launch-report", + "--json", + "--host-evidence", + host_evidence.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + report.status.success(), + "stderr={}", + String::from_utf8_lossy(&report.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); + for host_name in ["codex", "claude-code"] { + let host = json["host_matrix"] + .as_array() + .unwrap() + .iter() + .find(|host| host["host"] == host_name) + .unwrap(); + assert_eq!(host["status"], "launch_supported", "{json}"); + let tiers = host["evidence_tiers"].as_array().unwrap(); + assert!( + tiers.iter().any(|tier| tier == "verified_host_hook"), + "{json}" + ); + assert!( + tiers.iter().any(|tier| tier == "launch_supported"), + "{json}" + ); + assert!(host["evidence_gate"] + .as_array() + .unwrap() + .iter() + .any(|gate| gate + .as_str() + .unwrap() + .contains("route_type=official_host_hook"))); + assert_eq!( + json["host_evidence"]["named_hosts"][host_name]["host_bound_evidence"], true, + "{json}" + ); + } + assert!(json["host_evidence"]["evidence_notes"] + .as_array() + .unwrap() + .iter() + .filter_map(serde_json::Value::as_str) + .any(|note| note.contains("codex") && note.contains("hook_authorized=true"))); +} + #[test] fn launch_report_refuses_official_hook_promotion_without_supported_hook_authority() { let dir = tempfile::tempdir().unwrap(); for file in [ "setup-proof.txt", "invocation-proof.txt", - "cursor-config.json", - "cursor-ledger.jsonl", - "cursor-raw.txt", + "claude-code-config.json", + "claude-code-ledger.jsonl", + "claude-code-raw.txt", ] { std::fs::write(dir.path().join(file), "proof").unwrap(); } @@ -2147,24 +2249,24 @@ fn launch_report_refuses_official_hook_promotion_without_supported_hook_authorit r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "setup_verified":true, "real_invocation_verified":true, "setup_artifact":"setup-proof.txt", "invocation_artifact":"invocation-proof.txt", "config_scope":"project", - "config_path":"cursor-config.json", + "config_path":"claude-code-config.json", "route_type":"official_host_hook", - "ledger_artifact":"cursor-ledger.jsonl", - "raw_artifact":"cursor-raw.txt", - "smoke_id":"cursor-hook-smoke", + "ledger_artifact":"claude-code-ledger.jsonl", + "raw_artifact":"claude-code-raw.txt", + "smoke_id":"claude-code-hook-smoke", "timestamp":"2026-06-09T00:00:00Z", "redacted_public_bytes":1000, "model_visible_bytes":100, - "official_docs_backed":true, + "official_docs_backed":false, "kill_switch_available":true, "uninstall_available":true, "overhead_ms":10, @@ -2190,14 +2292,14 @@ fn launch_report_refuses_official_hook_promotion_without_supported_hook_authorit String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|h| h["host"] == "cursor") + .find(|h| h["host"] == "claude-code") .unwrap(); - assert_ne!(cursor["status"], "launch_supported", "{json}"); - assert!(!cursor["evidence_tiers"] + assert_ne!(claude_code["status"], "launch_supported", "{json}"); + assert!(!claude_code["evidence_tiers"] .as_array() .unwrap() .iter() @@ -2415,7 +2517,7 @@ fn launch_report_release_tiers_require_release_evidence_and_keep_ga_blocked_with .as_array() .unwrap() .iter() - .any(|blocker| blocker.as_str().unwrap().contains("no named AI host"))); + .any(|b| b.as_str().unwrap().contains("no named AI host"))); assert_eq!( json["release_tiers"]["public_superiority_claim_ready"]["status"], "blocked" @@ -2484,7 +2586,19 @@ fn lifecycle_project_start_stop_restart_status_truthful() { ); let start_text = String::from_utf8_lossy(&start.stdout); assert!( - start_text.contains("host_route_configured_verification_required"), + start_text.contains("default_agent_routes_configured_verification_required"), + "{start_text}" + ); + assert!( + start_text.contains("Installed TFY agent command wrapper"), + "{start_text}" + ); + assert!( + start_text.contains("Configured Codex project PreToolUse Bash hook route"), + "{start_text}" + ); + assert!( + start_text.contains("Configured Claude Code project PreToolUse Bash hook route"), "{start_text}" ); assert!( @@ -2509,15 +2623,16 @@ fn lifecycle_project_start_stop_restart_status_truthful() { assert_eq!(json["project_lifecycle"]["agent"]["desired"], true); assert_eq!( json["project_lifecycle"]["agent"]["support_status"], - "host_route_configured_verification_required" + "default_agent_routes_configured_verification_required" ); + assert!(dir.path().join(".tfy/agent/tfy-agent-wrapper").exists()); assert!(json["project_lifecycle"]["agent"]["active_routes"].is_null()); assert_eq!( json["project_lifecycle"]["agent"]["intended_routes"] .as_array() .unwrap() .len(), - 3 + 4 ); assert_eq!( json["project_lifecycle"]["agent"]["private_hook_interception"], @@ -3025,10 +3140,7 @@ fn lifecycle_global_start_accepts_host_options_as_default_guidance_only() { json["global_lifecycle"]["agent"]["host_routes"]["claude-code"]["route_state"], "config_snippet_available" ); - assert_eq!( - json["global_lifecycle"]["agent"]["host_routes"]["cursor"]["route_state"], - "config_snippet_available" - ); + assert!(json["global_lifecycle"]["agent"]["host_routes"]["cursor"].is_null()); assert_eq!(json["effective_lifecycle"]["agent"]["active"], false); assert_eq!(json["lifecycle_summary"]["status"], "intent_recorded"); } @@ -3164,7 +3276,7 @@ fn lifecycle_status_reports_effective_state_and_next_actions() { let project_start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) .env("TFY_HOME", home.path()) - .args(["start", "agent", "--host", "cursor", "--apply"]) + .args(["start", "agent", "--host", "codex", "--apply"]) .output() .unwrap(); assert!(project_start.status.success()); @@ -3266,7 +3378,7 @@ fn lifecycle_start_verify_prints_persisted_support_status() { let dir = tempfile::tempdir().unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "agent", "--host", "cursor", "--verify"]) + .args(["start", "agent", "--host", "all", "--verify"]) .output() .unwrap(); assert!( @@ -3354,48 +3466,15 @@ fn lifecycle_start_cursor_apply_caps_at_configured_unverified() { .args(["start", "agent", "--host", "cursor", "--apply"]) .output() .unwrap(); + assert!(!start.status.success()); + let stderr = String::from_utf8_lossy(&start.stderr); assert!( - start.status.success(), - "stderr={}", - String::from_utf8_lossy(&start.stderr) - ); - let text = String::from_utf8_lossy(&start.stdout); - assert!(text.contains("route_state=configured_unverified"), "{text}"); - assert!( - text.contains("Configured Cursor project MCP route: .cursor/mcp.json"), - "{text}" - ); - assert!( - text.contains("Restart/reload Cursor to make the route visible."), - "{text}" - ); - assert!(text.contains("Status: configured, not active."), "{text}"); - assert!( - text.contains("Next: open Cursor and invoke a TFY MCP tool to verify real host routing."), - "{text}" + stderr.contains( + "host 'cursor' is not available in TFY agent mode; supported hosts: codex, claude-code" + ), + "{stderr}" ); - assert!(!text.contains("Cursor is now saving tokens"), "{text}"); - assert!(text.contains("active=false"), "{text}"); - assert!(dir.path().join(".cursor/mcp.json").exists()); - - let status = Command::new(env!("CARGO_BIN_EXE_tfy")) - .current_dir(dir.path()) - .args(["status", "--json"]) - .output() - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); - let cursor = &json["project_lifecycle"]["agent"]["host_routes"]["cursor"]; - assert_eq!(cursor["route_state"], "configured_unverified"); - assert_eq!(cursor["active"], false); - assert_eq!(cursor["route_configured"], true); - assert_eq!(cursor["host_reload_required"], true); - assert_eq!(cursor["host_route_available_after_reload"], false); - assert_eq!(json["project_lifecycle"]["agent"]["active"], false); - assert!(json["minimum_v1_host_matrix"] - .as_array() - .unwrap() - .iter() - .any(|h| h["host"] == "cursor" && h["status"] != "launch_supported")); + assert!(!dir.path().join(".cursor/mcp.json").exists()); } #[test] @@ -3417,17 +3496,17 @@ fn lifecycle_start_host_all_apply_only_uses_safe_writers() { "{text}" ); assert!( - text.contains("host=claude-code route_state=configured_unverified"), + text.contains("host=codex route_state=configured_unverified active=false route_configured=true host_reload_required=true host_approval_required=true"), "{text}" ); assert!( - text.contains("host=cursor route_state=configured_unverified"), + text.contains("host=claude-code route_state=configured_unverified"), "{text}" ); - assert!(text.contains("host=opencode"), "{text}"); - assert!(dir.path().join(".cursor/mcp.json").exists()); + assert!(!text.contains("host=cursor"), "{text}"); + assert!(!dir.path().join(".cursor/mcp.json").exists()); assert!(dir.path().join(".codex/config.toml").exists()); - assert!(dir.path().join(".mcp.json").exists()); + assert!(dir.path().join(".claude/settings.json").exists()); let status = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) @@ -3440,16 +3519,20 @@ fn lifecycle_start_host_all_apply_only_uses_safe_writers() { "configured_unverified" ); assert_eq!( - json["project_lifecycle"]["agent"]["host_routes"]["claude-code"]["route_state"], - "configured_unverified" + json["project_lifecycle"]["agent"]["host_routes"]["codex"]["route_type"], + "official_host_hook" ); assert_eq!( - json["project_lifecycle"]["agent"]["host_routes"]["cursor"]["route_configured"], + json["project_lifecycle"]["agent"]["host_routes"]["codex"]["host_approval_required"], true ); assert_eq!( - json["project_lifecycle"]["agent"]["host_routes"]["cursor"]["host_reload_required"], - true + json["project_lifecycle"]["agent"]["host_routes"]["claude-code"]["route_state"], + "configured_unverified" + ); + assert_eq!( + json["project_lifecycle"]["agent"]["host_routes"]["claude-code"]["route_type"], + "official_host_hook" ); assert_eq!( json["project_lifecycle"]["agent"]["host_routes"]["claude-code"]["active"], @@ -3458,7 +3541,7 @@ fn lifecycle_start_host_all_apply_only_uses_safe_writers() { } #[test] -fn lifecycle_start_agent_auto_configures_codex_by_default() { +fn lifecycle_start_agent_installs_default_agent_routes() { let dir = tempfile::tempdir().unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) @@ -3472,19 +3555,37 @@ fn lifecycle_start_agent_auto_configures_codex_by_default() { ); let text = String::from_utf8_lossy(&start.stdout); assert!( - text.contains("Configured Codex project MCP route: .codex/config.toml"), + text.contains("Installed TFY agent command wrapper: .tfy/agent/tfy-agent-wrapper"), + "{text}" + ); + assert!( + text.contains("Configured Codex project PreToolUse Bash hook route: .codex/config.toml"), + "{text}" + ); + assert!( + text.contains( + "Configured Claude Code project PreToolUse Bash hook route: .claude/settings.json" + ), "{text}" ); assert!( - text.contains("Open/reload Codex in a trusted project"), + text.contains("MCP remains an advanced complementary route"), "{text}" ); assert!(text.contains("Status: configured, not active."), "{text}"); assert!(!text.contains("TFY is active"), "{text}"); assert!(!text.contains("saving tokens"), "{text}"); assert!(dir.path().join(".codex/config.toml").exists()); + assert!(dir.path().join(".claude/settings.json").exists()); assert!(!dir.path().join(".cursor/mcp.json").exists()); assert!(dir.path().join(".tfy/host-config/codex.json").exists()); + assert!(dir + .path() + .join(".tfy/host-config/claude-code.json") + .exists()); + assert!(dir.path().join(".tfy/agent/tfy-agent-wrapper").exists()); + assert!(dir.path().join(".tfy/agent/codex-pre-tool-use").exists()); + assert!(dir.path().join(".tfy/agent/claude-pre-tool-use").exists()); assert!(dir.path().join(".tfy/raw").is_dir()); assert!(dir.path().join(".tfy/state").is_dir()); assert!(dir.path().join(".tfy/adapter").is_dir()); @@ -3497,17 +3598,45 @@ fn lifecycle_start_agent_auto_configures_codex_by_default() { .output() .unwrap(); let json: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); + let routes = json["project_lifecycle"]["agent"]["host_routes"] + .as_object() + .unwrap(); + assert!(routes.contains_key("agent-wrapper"), "{json}"); + assert!(routes.contains_key("codex"), "{json}"); + assert!(routes.contains_key("claude-code"), "{json}"); + assert!(!routes.contains_key("cursor"), "{json}"); + + let wrapper = &json["project_lifecycle"]["agent"]["host_routes"]["agent-wrapper"]; + assert_eq!(wrapper["route_state"], "configured_unverified", "{json}"); + assert_eq!(wrapper["configured"], true, "{json}"); + assert_eq!(wrapper["route_configured"], true, "{json}"); + assert_eq!(wrapper["host_reload_required"], false, "{json}"); + assert_eq!(wrapper["host_approval_required"], false, "{json}"); + assert_eq!( + wrapper["host_route_available_after_reload"], false, + "{json}" + ); + assert_eq!(wrapper["mcp_invocation_observed"], false, "{json}"); + let codex = &json["project_lifecycle"]["agent"]["host_routes"]["codex"]; assert_eq!(codex["route_state"], "configured_unverified", "{json}"); - assert_eq!(codex["configured"], true, "{json}"); + assert_eq!(codex["route_type"], "official_host_hook", "{json}"); assert_eq!(codex["route_configured"], true, "{json}"); + assert_eq!(codex["active"], false, "{json}"); assert_eq!(codex["host_reload_required"], true, "{json}"); - assert_eq!(codex["host_approval_required"], false, "{json}"); - assert_eq!(codex["host_route_available_after_reload"], false, "{json}"); - assert_eq!(codex["mcp_invocation_observed"], false, "{json}"); + assert_eq!(codex["host_approval_required"], true, "{json}"); + + let claude = &json["project_lifecycle"]["agent"]["host_routes"]["claude-code"]; + assert_eq!(claude["route_state"], "configured_unverified", "{json}"); + assert_eq!(claude["route_type"], "official_host_hook", "{json}"); + assert_eq!(claude["route_configured"], true, "{json}"); + assert_eq!(claude["active"], false, "{json}"); + assert_eq!(claude["host_reload_required"], true, "{json}"); + assert_eq!(claude["host_approval_required"], false, "{json}"); + assert_eq!( json["project_lifecycle"]["agent"]["support_status"], - "host_route_configured_verification_required", + "default_agent_routes_configured_verification_required", "{json}" ); assert_eq!( @@ -3531,7 +3660,9 @@ fn lifecycle_start_agent_no_apply_records_intent_without_cursor_config() { ); assert!(!dir.path().join(".cursor/mcp.json").exists()); assert!(!dir.path().join(".codex/config.toml").exists()); + assert!(!dir.path().join(".claude/settings.json").exists()); assert!(!dir.path().join(".mcp.json").exists()); + assert!(!dir.path().join(".tfy/agent/tfy-agent-wrapper").exists()); let status = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) .args(["status", "--agent", "--json"]) @@ -3542,13 +3673,15 @@ fn lifecycle_start_agent_no_apply_records_intent_without_cursor_config() { json["project_lifecycle"]["agent"]["desired"], true, "{json}" ); - assert!( - json["project_lifecycle"]["agent"]["host_routes"] - .as_object() - .unwrap() - .is_empty(), - "{json}" - ); + if let Some(agent) = json["project_lifecycle"]["agent"].as_object() { + assert!( + agent + .get("host_routes") + .and_then(serde_json::Value::as_object) + .is_none_or(serde_json::Map::is_empty), + "{json}" + ); + } assert_eq!( json["effective_lifecycle"]["agent"]["route_configured"], false, "{json}" @@ -3567,18 +3700,57 @@ fn lifecycle_start_agent_host_cursor_applies_without_apply_flag() { .args(["start", "--agent", "--host", "cursor"]) .output() .unwrap(); + assert!(!start.status.success()); + assert!(String::from_utf8_lossy(&start.stderr).contains("not available in TFY agent mode")); + assert!(!dir.path().join(".cursor/mcp.json").exists()); +} + +#[test] +fn lifecycle_start_agent_host_codex_stays_narrow() { + let dir = tempfile::tempdir().unwrap(); + let start = Command::new(env!("CARGO_BIN_EXE_tfy")) + .current_dir(dir.path()) + .args(["start", "--agent", "--host", "codex"]) + .output() + .unwrap(); assert!( start.status.success(), "stderr={}", String::from_utf8_lossy(&start.stderr) ); - assert!(dir.path().join(".cursor/mcp.json").exists()); - let text = String::from_utf8_lossy(&start.stdout); - assert!(text.contains("route_state=configured_unverified"), "{text}"); + assert!(dir.path().join(".codex/config.toml").exists()); + assert!(dir.path().join(".tfy/agent/codex-pre-tool-use").exists()); + assert!(!dir.path().join(".claude/settings.json").exists()); + assert!(!dir.path().join(".tfy/agent/claude-pre-tool-use").exists()); + assert!(!dir.path().join(".cursor/mcp.json").exists()); + assert!(!dir.path().join(".tfy/agent/tfy-agent-wrapper").exists()); + + let status = Command::new(env!("CARGO_BIN_EXE_tfy")) + .current_dir(dir.path()) + .args(["status", "--agent", "--json"]) + .output() + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); + let routes = json["project_lifecycle"]["agent"]["host_routes"] + .as_object() + .unwrap(); + assert!(routes.contains_key("codex"), "{json}"); + assert!(!routes.contains_key("claude-code"), "{json}"); + assert!(!routes.contains_key("agent-wrapper"), "{json}"); + assert!(!routes.contains_key("cursor"), "{json}"); + assert_eq!( + json["project_lifecycle"]["agent"]["host_routes"]["codex"]["route_type"], + "official_host_hook", + "{json}" + ); + assert_eq!( + json["effective_lifecycle"]["agent"]["active"], false, + "{json}" + ); } #[test] -fn lifecycle_start_agent_host_claude_code_applies_project_mcp_with_approval_required() { +fn lifecycle_start_agent_host_claude_code_applies_project_hook_with_verification_required() { let dir = tempfile::tempdir().unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) @@ -3590,7 +3762,26 @@ fn lifecycle_start_agent_host_claude_code_applies_project_mcp_with_approval_requ "stderr={}", String::from_utf8_lossy(&start.stderr) ); - assert!(dir.path().join(".mcp.json").exists()); + assert!(dir.path().join(".claude/settings.json").exists()); + assert!(!dir.path().join(".codex/config.toml").exists()); + assert!(!dir.path().join(".tfy/agent/tfy-agent-wrapper").exists()); + let script = dir.path().join(".tfy/agent/claude-pre-tool-use"); + assert!(script.exists()); + let script_text = std::fs::read_to_string(&script).unwrap(); + assert!(script_text.contains("--host claude-code"), "{script_text}"); + assert!( + script_text.contains(dir.path().join(".tfy/raw").to_str().unwrap()), + "{script_text}" + ); + assert!( + script_text.contains( + dir.path() + .join(".tfy/hook/claude-code-ledger.jsonl") + .to_str() + .unwrap() + ), + "{script_text}" + ); assert!(dir .path() .join(".tfy/host-config/claude-code.json") @@ -3603,18 +3794,38 @@ fn lifecycle_start_agent_host_claude_code_applies_project_mcp_with_approval_requ let json: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); let claude = &json["project_lifecycle"]["agent"]["host_routes"]["claude-code"]; assert_eq!(claude["route_state"], "configured_unverified", "{json}"); + assert_eq!(claude["route_type"], "official_host_hook", "{json}"); assert_eq!(claude["configured"], true, "{json}"); assert_eq!(claude["route_configured"], true, "{json}"); - assert_eq!(claude["host_approval_required"], true, "{json}"); + assert_eq!(claude["host_approval_required"], false, "{json}"); assert_eq!(claude["active"], false, "{json}"); } #[test] -fn lifecycle_start_agent_unsupported_host_is_guidance_only_without_configured_claim() { +fn lifecycle_start_agent_claude_preserves_similar_unowned_hook_without_provenance() { let dir = tempfile::tempdir().unwrap(); + let claude_dir = dir.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + std::fs::write( + claude_dir.join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/user/managed/.tfy/agent/claude-pre-tool-use", + "timeout": 30 + }] + }] + } +} +"#, + ) + .unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent", "--host", "opencode"]) + .args(["start", "--agent", "--host", "claude-code"]) .output() .unwrap(); assert!( @@ -3622,24 +3833,45 @@ fn lifecycle_start_agent_unsupported_host_is_guidance_only_without_configured_cl "stderr={}", String::from_utf8_lossy(&start.stderr) ); + let settings: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(claude_dir.join("settings.json")).unwrap()) + .unwrap(); + let entries = settings["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(entries.len(), 2, "{settings}"); + assert!(settings + .to_string() + .contains("/user/managed/.tfy/agent/claude-pre-tool-use")); +} + +#[test] +fn lifecycle_start_agent_unsupported_host_is_guidance_only_without_configured_claim() { + let dir = tempfile::tempdir().unwrap(); + let start = Command::new(env!("CARGO_BIN_EXE_tfy")) + .current_dir(dir.path()) + .args(["start", "--agent", "--host", "cursor"]) + .output() + .unwrap(); + assert!(!start.status.success()); + assert!(String::from_utf8_lossy(&start.stderr).contains("not available in TFY agent mode")); let status = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) .args(["status", "--agent", "--json"]) .output() .unwrap(); let json: serde_json::Value = serde_json::from_slice(&status.stdout).unwrap(); - let opencode = &json["project_lifecycle"]["agent"]["host_routes"]["opencode"]; - assert_eq!( - opencode["route_state"], "config_snippet_available", - "{json}" - ); - assert_eq!(opencode["configured"], false, "{json}"); - assert_eq!(opencode["route_configured"], false, "{json}"); - assert_eq!(opencode["active"], false, "{json}"); + if let Some(agent) = json["project_lifecycle"]["agent"].as_object() { + assert!( + agent + .get("host_routes") + .and_then(serde_json::Value::as_object) + .is_none_or(serde_json::Map::is_empty), + "{json}" + ); + } } #[test] -fn lifecycle_start_agent_fails_closed_on_unowned_codex_tfy_route() { +fn lifecycle_start_agent_default_fails_closed_on_unowned_codex_route() { let dir = tempfile::tempdir().unwrap(); let codex_dir = dir.path().join(".codex"); std::fs::create_dir_all(&codex_dir).unwrap(); @@ -3656,6 +3888,29 @@ fn lifecycle_start_agent_fails_closed_on_unowned_codex_tfy_route() { assert!(!start.status.success()); let stderr = String::from_utf8_lossy(&start.stderr); assert!(stderr.contains("not TFY-owned"), "{stderr}"); + assert!(!dir.path().join(".claude/settings.json").exists()); + assert!(!dir.path().join(".cursor/mcp.json").exists()); + assert!(!dir.path().join(".mcp.json").exists()); +} + +#[test] +fn lifecycle_start_agent_fails_closed_on_unowned_codex_tfy_route() { + let dir = tempfile::tempdir().unwrap(); + let codex_dir = dir.path().join(".codex"); + std::fs::create_dir_all(&codex_dir).unwrap(); + std::fs::write( + codex_dir.join("config.toml"), + "[mcp_servers.tfy]\ncommand = \"custom\"\n", + ) + .unwrap(); + let start = Command::new(env!("CARGO_BIN_EXE_tfy")) + .current_dir(dir.path()) + .args(["start", "--agent", "--host", "codex"]) + .output() + .unwrap(); + assert!(!start.status.success()); + let stderr = String::from_utf8_lossy(&start.stderr); + assert!(stderr.contains("not TFY-owned"), "{stderr}"); assert!(!dir.path().join(".cursor/mcp.json").exists()); assert!(!dir.path().join(".mcp.json").exists()); } @@ -3668,7 +3923,7 @@ fn lifecycle_start_agent_fails_closed_on_malformed_codex_toml() { std::fs::write(codex_dir.join("config.toml"), "[mcp_servers.tfy\n").unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent"]) + .args(["start", "--agent", "--host", "codex"]) .output() .unwrap(); assert!(!start.status.success()); @@ -3686,7 +3941,7 @@ key ="; std::fs::write(codex_dir.join("config.toml"), original).unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent"]) + .args(["start", "--agent", "--host", "codex"]) .output() .unwrap(); assert!(!start.status.success()); @@ -3711,7 +3966,7 @@ command = "custom" std::fs::write(codex_dir.join("config.toml"), original).unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent"]) + .args(["start", "--agent", "--host", "codex"]) .output() .unwrap(); assert!(!start.status.success()); @@ -3734,7 +3989,7 @@ fn lifecycle_start_agent_fails_closed_on_dotted_codex_tfy_route() { std::fs::write(codex_dir.join("config.toml"), original).unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent"]) + .args(["start", "--agent", "--host", "codex"]) .output() .unwrap(); assert!(!start.status.success()); @@ -3757,7 +4012,7 @@ fn lifecycle_start_agent_fails_closed_on_nested_dotted_codex_tfy_route() { std::fs::write(codex_dir.join("config.toml"), original).unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent"]) + .args(["start", "--agent", "--host", "codex"]) .output() .unwrap(); assert!(!start.status.success()); @@ -3783,7 +4038,7 @@ fn lifecycle_start_agent_fails_closed_on_incomplete_codex_tfy_marker() { .unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent"]) + .args(["start", "--agent", "--host", "codex"]) .output() .unwrap(); assert!(!start.status.success()); @@ -3808,7 +4063,7 @@ fn lifecycle_start_agent_fails_closed_on_orphan_codex_tfy_end_marker() { std::fs::write(codex_dir.join("config.toml"), original).unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "--agent"]) + .args(["start", "--agent", "--host", "codex"]) .output() .unwrap(); assert!(!start.status.success()); @@ -4042,9 +4297,9 @@ fn launch_report_demotes_stale_or_failed_reverify_named_host_evidence() { for file in [ "setup-proof.txt", "invocation-proof.txt", - "cursor-config.json", - "cursor-ledger.jsonl", - "cursor-raw.txt", + "claude-code-config.json", + "claude-code-ledger.jsonl", + "claude-code-raw.txt", ] { std::fs::write(dir.path().join(file), "proof").unwrap(); } @@ -4054,21 +4309,21 @@ fn launch_report_demotes_stale_or_failed_reverify_named_host_evidence() { r#"{ "hosts": [ { - "host":"cursor", + "host":"claude-code", "tfy_version":"0.0.0-stale", "reverify_failed":true, - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "setup_verified":true, "real_invocation_verified":true, "setup_artifact":"setup-proof.txt", "invocation_artifact":"invocation-proof.txt", "config_scope":"project", - "config_path":"cursor-config.json", + "config_path":"claude-code-config.json", "route_type":"mcp_stdio", - "ledger_artifact":"cursor-ledger.jsonl", - "raw_artifact":"cursor-raw.txt", - "smoke_id":"cursor-mcp-smoke", + "ledger_artifact":"claude-code-ledger.jsonl", + "raw_artifact":"claude-code-raw.txt", + "smoke_id":"claude-code-mcp-smoke", "timestamp":"2026-06-09T00:00:00Z", "redacted_public_bytes":1000, "model_visible_bytes":100, @@ -4095,15 +4350,15 @@ fn launch_report_demotes_stale_or_failed_reverify_named_host_evidence() { String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|h| h["host"] == "cursor") + .find(|h| h["host"] == "claude-code") .unwrap(); - assert_ne!(cursor["status"], "launch_supported", "{json}"); + assert_ne!(claude_code["status"], "launch_supported", "{json}"); assert_eq!( - json["host_evidence"]["named_hosts"]["cursor"]["host_bound_evidence"], false, + json["host_evidence"]["named_hosts"]["claude-code"]["host_bound_evidence"], false, "{json}" ); assert!( @@ -4121,7 +4376,7 @@ fn lifecycle_status_exposes_derived_activation_vocabulary() { let dir = tempfile::tempdir().unwrap(); let start = Command::new(env!("CARGO_BIN_EXE_tfy")) .current_dir(dir.path()) - .args(["start", "agent", "--host", "cursor", "--apply"]) + .args(["start", "agent", "--host", "codex", "--apply"]) .output() .unwrap(); assert!(start.status.success()); @@ -4151,9 +4406,9 @@ fn launch_report_accepts_unexpired_unix_evidence_and_rejects_malformed_expiry() for file in [ "setup-proof.txt", "invocation-proof.txt", - "cursor-config.json", - "cursor-ledger.jsonl", - "cursor-raw.txt", + "claude-code-config.json", + "claude-code-ledger.jsonl", + "claude-code-raw.txt", ] { std::fs::write(dir.path().join(file), "proof").unwrap(); } @@ -4173,21 +4428,21 @@ fn launch_report_accepts_unexpired_unix_evidence_and_rejects_malformed_expiry() r#"{{ "hosts": [ {{ - "host":"cursor", + "host":"claude-code", "tfy_version":"0.1.0", "evidence_expires_at":"{expires}", - "host_id":"cursor", + "host_id":"claude-code", "host_version":"test", "setup_verified":true, "real_invocation_verified":true, "setup_artifact":"setup-proof.txt", "invocation_artifact":"invocation-proof.txt", "config_scope":"project", - "config_path":"cursor-config.json", + "config_path":"claude-code-config.json", "route_type":"mcp_stdio", - "ledger_artifact":"cursor-ledger.jsonl", - "raw_artifact":"cursor-raw.txt", - "smoke_id":"cursor-mcp-smoke", + "ledger_artifact":"claude-code-ledger.jsonl", + "raw_artifact":"claude-code-raw.txt", + "smoke_id":"claude-code-mcp-smoke", "timestamp":"unix:{future}", "redacted_public_bytes":1000, "model_visible_bytes":100, @@ -4215,16 +4470,16 @@ fn launch_report_accepts_unexpired_unix_evidence_and_rejects_malformed_expiry() String::from_utf8_lossy(&report.stderr) ); let json: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); - let cursor = json["host_matrix"] + let claude_code = json["host_matrix"] .as_array() .unwrap() .iter() - .find(|h| h["host"] == "cursor") + .find(|h| h["host"] == "claude-code") .unwrap(); if should_launch { - assert_eq!(cursor["status"], "launch_supported", "{json}"); + assert_eq!(claude_code["status"], "launch_supported", "{json}"); } else { - assert_ne!(cursor["status"], "launch_supported", "{json}"); + assert_ne!(claude_code["status"], "launch_supported", "{json}"); assert!( json["host_evidence"]["evidence_notes"] .as_array() @@ -4239,3 +4494,247 @@ fn launch_report_accepts_unexpired_unix_evidence_and_rejects_malformed_expiry() } } } + +#[cfg(unix)] +#[test] +fn live_host_smoke_generates_codex_launch_report_evidence_with_fake_host() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let fake_codex = bin_dir.join("codex"); + std::fs::write( + &fake_codex, + r#"#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "--version" ]; then + echo "codex-cli fake-live-smoke" + exit 0 +fi +payload='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"for i in $(seq 1 80); do echo tfy-live-host-hook-smoke-$i; done"}}' +out=$(printf '%s' "$payload" | ./.tfy/agent/codex-pre-tool-use) +cmd=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"]["updatedInput"]["command"])') +sh -c "$cmd" +"#, + ) + .unwrap(); + std::fs::set_permissions(&fake_codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let smoke = Command::new(env!("CARGO_BIN_EXE_tfy")) + .env("PATH", path) + .args(["smoke", "--host", "codex", "--live", "--json"]) + .output() + .unwrap(); + assert!( + smoke.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&smoke.stdout), + String::from_utf8_lossy(&smoke.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&smoke.stdout).unwrap(); + assert_eq!(json["status"], "pass", "{json}"); + assert_eq!(json["mode"], "host_live", "{json}"); + let host = &json["hosts"][0]; + assert_eq!(host["status"], "pass", "{json}"); + assert_eq!(host["host"], "codex", "{json}"); + let evidence = host["host_evidence"].as_str().unwrap(); + assert!(std::path::Path::new(evidence).is_file(), "{json}"); + + let report = Command::new(env!("CARGO_BIN_EXE_tfy")) + .args(["launch-report", "--json", "--host-evidence", evidence]) + .output() + .unwrap(); + assert!( + report.status.success(), + "stderr={}", + String::from_utf8_lossy(&report.stderr) + ); + let launch: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); + let codex = launch["host_matrix"] + .as_array() + .unwrap() + .iter() + .find(|host| host["host"] == "codex") + .unwrap(); + assert_eq!(codex["status"], "launch_supported", "{launch}"); + assert_eq!( + launch["host_evidence"]["named_hosts"]["codex"]["host_bound_evidence"], true, + "{launch}" + ); +} + +#[cfg(unix)] +#[test] +fn live_host_smoke_rejects_extra_fake_host_hook_command() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let fake_codex = bin_dir.join("codex"); + std::fs::write( + &fake_codex, + r#"#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "--version" ]; then + echo "codex-cli fake-live-smoke" + exit 0 +fi +payload='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"for i in $(seq 1 80); do echo tfy-live-host-hook-smoke-$i; done"}}' +out=$(printf '%s' "$payload" | ./.tfy/agent/codex-pre-tool-use) +cmd=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"]["updatedInput"]["command"])') +sh -c "$cmd" +extra='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"for i in $(seq 1 80); do echo tfy-live-host-hook-extra-$i; done"}}' +out=$(printf '%s' "$extra" | ./.tfy/agent/codex-pre-tool-use) +cmd=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"]["updatedInput"]["command"])') +sh -c "$cmd" +"#, + ) + .unwrap(); + std::fs::set_permissions(&fake_codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let smoke = Command::new(env!("CARGO_BIN_EXE_tfy")) + .env("PATH", path) + .args(["smoke", "--host", "codex", "--live", "--json"]) + .output() + .unwrap(); + assert!( + !smoke.status.success(), + "stdout={}", + String::from_utf8_lossy(&smoke.stdout) + ); + let stderr = String::from_utf8_lossy(&smoke.stderr); + assert!( + stderr.contains("expected exactly one official-host-hook command"), + "{stderr}" + ); +} + +#[cfg(unix)] +#[test] +fn live_host_smoke_rejects_wrong_fake_host_hook_command() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let fake_codex = bin_dir.join("codex"); + std::fs::write( + &fake_codex, + r#"#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "--version" ]; then + echo "codex-cli fake-live-smoke" + exit 0 +fi +payload='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"for i in $(seq 1 80); do echo tfy-live-host-hook-wrong-$i; done"}}' +out=$(printf '%s' "$payload" | ./.tfy/agent/codex-pre-tool-use) +cmd=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"]["updatedInput"]["command"])') +sh -c "$cmd" +"#, + ) + .unwrap(); + std::fs::set_permissions(&fake_codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let smoke = Command::new(env!("CARGO_BIN_EXE_tfy")) + .env("PATH", path) + .args(["smoke", "--host", "codex", "--live", "--json"]) + .output() + .unwrap(); + assert!( + !smoke.status.success(), + "stdout={}", + String::from_utf8_lossy(&smoke.stdout) + ); + let stderr = String::from_utf8_lossy(&smoke.stderr); + assert!( + stderr.contains("official hook command mismatch"), + "{stderr}" + ); +} + +#[cfg(unix)] +#[test] +fn live_host_smoke_generates_claude_code_launch_report_evidence_with_fake_host() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let fake_claude = bin_dir.join("claude"); + std::fs::write( + &fake_claude, + r#"#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "--version" ]; then + echo "2.1.160 fake-live-smoke" + exit 0 +fi +payload='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"for i in $(seq 1 80); do echo tfy-live-host-hook-smoke-$i; done"}}' +out=$(printf '%s' "$payload" | ./.tfy/agent/claude-pre-tool-use) +cmd=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"]["updatedInput"]["command"])') +sh -c "$cmd" +"#, + ) + .unwrap(); + std::fs::set_permissions(&fake_claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let smoke = Command::new(env!("CARGO_BIN_EXE_tfy")) + .env("PATH", path) + .args(["smoke", "--host", "claude-code", "--live", "--json"]) + .output() + .unwrap(); + assert!( + smoke.status.success(), + "stdout={}\nstderr={}", + String::from_utf8_lossy(&smoke.stdout), + String::from_utf8_lossy(&smoke.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&smoke.stdout).unwrap(); + assert_eq!(json["status"], "pass", "{json}"); + assert_eq!(json["mode"], "host_live", "{json}"); + let host = &json["hosts"][0]; + assert_eq!(host["status"], "pass", "{json}"); + assert_eq!(host["host"], "claude-code", "{json}"); + let evidence = host["host_evidence"].as_str().unwrap(); + assert!(std::path::Path::new(evidence).is_file(), "{json}"); + + let report = Command::new(env!("CARGO_BIN_EXE_tfy")) + .args(["launch-report", "--json", "--host-evidence", evidence]) + .output() + .unwrap(); + assert!( + report.status.success(), + "stderr={}", + String::from_utf8_lossy(&report.stderr) + ); + let launch: serde_json::Value = serde_json::from_slice(&report.stdout).unwrap(); + let claude = launch["host_matrix"] + .as_array() + .unwrap() + .iter() + .find(|host| host["host"] == "claude-code") + .unwrap(); + assert_eq!(claude["status"], "launch_supported", "{launch}"); + assert_eq!( + launch["host_evidence"]["named_hosts"]["claude-code"]["host_bound_evidence"], true, + "{launch}" + ); +} diff --git a/crates/tfy-runtime/src/lib.rs b/crates/tfy-runtime/src/lib.rs index 4248c58..9cfde2f 100644 --- a/crates/tfy-runtime/src/lib.rs +++ b/crates/tfy-runtime/src/lib.rs @@ -42,6 +42,7 @@ pub enum OriginKind { #[serde(rename_all = "snake_case")] pub enum OriginHost { Codex, + ClaudeCode, Omx, Generic, Unknown, diff --git a/docs/AGENT_MIDDLEWARE.md b/docs/AGENT_MIDDLEWARE.md index 191aa68..a1588fa 100644 --- a/docs/AGENT_MIDDLEWARE.md +++ b/docs/AGENT_MIDDLEWARE.md @@ -2,9 +2,9 @@ ## Purpose -TFY is intended to sit inside supported AI-agent runtimes as an **I/O middleware**, not merely as a command a human types manually. The Rust CLI remains the debug/protocol surface, and the supported product path is explicit host routing through TFY wrappers, adapters, or MCP tools. Editor auto-integration and provider/API gateway proxying are outside TFY scope, and private/hidden Codex prompt hooks are not claimed. +TFY is intended to sit inside supported AI-agent runtimes as an **I/O middleware**, not merely as a command a human types manually. The Rust CLI remains the debug/protocol surface, and the supported product path is explicit host routing through TFY wrappers/adapters or official Codex/Claude Code PreToolUse Bash hooks, with MCP as an advanced/complementary tool-resource route. Editor auto-integration and provider/API gateway proxying are outside TFY scope, and private/hidden Codex prompt hooks are not claimed. -Current status: the Rust core, CLI primitives, project/global lifecycle intent commands (`tfy start`, `tfy stop`, `tfy status --agent/--human`, `tfy fuckyou`, `tfy global start|stop|fuckyou`) plus `tfy use always|stop|cancel|fuckyou` global aliases, bare-command arrow-key TUI selection, lifecycle target aliases (`agent`/`ai`, `human`, `both`), effective status guidance including `lifecycle_summary`, lifecycle `route_state`/`active` fields, `tfy-runtime` wire contract, Tool/Shell Gateway surfaces, Context Gateway CLI, Output Gateway preview/validate CLI plus proof-gated local single-file selected-scope apply, State Gateway ledger/projection CLI, `tfy adapter` generic-shell command-boundary adapter, configured `tfy agent` AI-runtime wrapper, display-only restore formatter, exact multi-file WorkspaceApplyPlan validate/apply, and `tfy mcp` stdio tool/resource server are implemented. MCP now includes a host-routed agent-native Code I/O workflow: bounded ID-first scope listing, compact selected-scope context with `ApplyProof`, preview-only validation, display-only restoration, WorkspaceApplyPlan validation, and proof-gated apply. Product setup can now generate named-host MCP routing snippets for Codex, Claude Code, Cursor, OpenCode, and Hermes, and can safely apply/uninstall Codex project `.codex/config.toml`, Claude Code project `.mcp.json`, and Cursor project `.cursor/mcp.json` routing with backup/idempotency/provenance; `tfy start --agent` defaults to the Codex writer, prepares raw/ledger directories, and records only `configured_unverified`/`active=false` until lifecycle desire is on and real route evidence exists; `--no-apply` keeps lifecycle intent only. OpenClaw remains planned-discovery until an official/current route is proven. Private/hidden Codex hooks are not claimed, and editor/provider automatic hooks are outside TFY scope. Conservative proof-gated unique-anchor fuzzy workspace mutation is implemented; broader semantic conflict resolution remains fail-closed. +Current status: the Rust core, CLI primitives, project/global lifecycle intent commands (`tfy start`, `tfy stop`, `tfy status --agent/--human`, `tfy fuckyou`, `tfy global start|stop|fuckyou`) plus `tfy use always|stop|cancel|fuckyou` global aliases, bare-command arrow-key TUI selection, lifecycle target aliases (`agent`/`ai`, `human`, `both`), effective status guidance including `lifecycle_summary`, lifecycle `route_state`/`active` fields, `tfy-runtime` wire contract, Tool/Shell Gateway surfaces, Context Gateway CLI, Output Gateway preview/validate CLI plus proof-gated local single-file selected-scope apply, State Gateway ledger/projection CLI, `tfy adapter` generic-shell command-boundary adapter, configured `tfy agent` AI-runtime wrapper, display-only restore formatter, exact multi-file WorkspaceApplyPlan validate/apply, and `tfy mcp` stdio tool/resource server are implemented. MCP now includes a host-routed agent-native Code I/O workflow: bounded ID-first scope listing, compact selected-scope context with `ApplyProof`, preview-only validation, display-only restoration, WorkspaceApplyPlan validation, and proof-gated apply. Product setup can now generate supported-host setup snippets, safely apply/uninstall Codex project `.codex/config.toml` PreToolUse hooks, Claude Code project `.claude/settings.json` PreToolUse hooks, and legacy TFY-owned Claude Code `.mcp.json` cleanup; `tfy start --agent` defaults to creating `.tfy/agent/tfy-agent-wrapper` plus the safe Codex and Claude Code project hook routes, prepares raw/ledger directories, and records only `configured_unverified`/`active=false` until lifecycle desire is on and real route evidence exists; `--no-apply` keeps lifecycle intent only. Private/hidden Codex hooks are not claimed, and editor/provider automatic hooks are outside TFY scope. Conservative proof-gated unique-anchor fuzzy workspace mutation is implemented; broader semantic conflict resolution remains fail-closed. ## Boundary model @@ -12,7 +12,7 @@ The gateway model is the external runtime integration model. It does not replace - Internal spine: representation ladder, token-saving method registry, agent-neutral Rust core. - External boundaries: Tool Gateway, Context Gateway, Output Gateway, State Gateway. -- Runtime connectors: shell/agent wrapper, MCP host routing, and other explicit AI-runtime adapters. Provider/API gateways and editor auto-integration are out of scope; private hidden Codex hooks are not claimed. +- Runtime connectors: shell/agent wrapper, official Codex/Claude Code PreToolUse hook routing, MCP host routing, and other explicit AI-runtime adapters. Provider/API gateways and editor auto-integration are out of scope; private hidden Codex hooks are not claimed. ## Gateway-to-registry crosswalk @@ -158,22 +158,26 @@ An AI agent still intends to run the ordinary command. The host runtime or wrapp Support claim boundary: - `generic-shell` command-boundary interception: implemented and tested. -- Codex/OMX command wrapping: only supported where the host is explicitly configured to call the generic-shell adapter. +- AI-agent command wrapping: supported where the host is explicitly configured to call `.tfy/agent/tfy-agent-wrapper` or the generic-shell adapter. - MCP stdio tool/resource integration: implemented and tested through `tfy mcp serve`, including `tfy_scope_list`, enriched `tfy_context_get`, preview-only `tfy_output_validate`, and proof-gated `tfy_output_apply`. -- Product UX lifecycle: implemented through `tfy init`, `tfy doctor`, `tfy smoke`, and `tfy gain`. These commands make setup, diagnostics, local MCP smoke, and savings reporting easier while keeping the same host-routing boundary. -- Configured AI-agent wrapper: implemented through `tfy agent run`; lifecycle `tfy start --agent` now auto-configures the implemented safe Codex project MCP route unless `--no-apply` is passed; Claude Code and Cursor writers are explicit host/all routes, but it does not prove host invocation or launch support; effective `active=true` is derived only from verified host/route evidence and savings, not config presence. +- Product UX lifecycle: implemented through `tfy init`, `tfy doctor`, `tfy smoke`, and `tfy gain`. These commands make setup, diagnostics, local adapter/agent/MCP smoke, and savings reporting easier while keeping the same host-routing boundary. +- Configured AI-agent wrapper and default official hooks: implemented through `tfy agent run` plus lifecycle `tfy start --agent`, which creates `.tfy/agent/tfy-agent-wrapper` and prepares Codex/Claude Code project hook routes unless `--no-apply` is passed; none of these setup writes prove host invocation or launch support; effective `active=true` is derived only from verified host/route evidence and savings, not config presence. - Human lifecycle: `tfy start --human` records managed-session intent and, on supported platform shells (Linux bash, macOS zsh, Windows PowerShell), interactive project-only runs immediately enter a TFY-managed current-directory-scoped shell session with generated PATH shims or PowerShell proxy functions for safely resolved ordinary external commands. It still keeps `ordinary_terminal_interception=false` because ordinary terminals outside that managed session or trusted marked current directories with the explicit rc/profile hook are not globally intercepted. Supported sessions report `session_wrapper_available=true`, `managed_session_available=true`, `managed_session_scope=current_directory_scoped_tfy_managed_session`, and wrapped-command evidence in the TFY ledger; unsupported platforms report `managed_session_available=false` with `support_status=managed_session_unsupported_platform`. `tfy setup --human` is the short dry-run for the explicit one-time rc/profile hook, and `tfy setup --human --apply` installs that hook for trusted TFY-marked current directories by delegating to the same safety path as `tfy human auto-activate install`; npm install prints only opt-in guidance and never mutates startup files. `tfy human shell --no-auto-intercept` keeps only the managed shell environment without PATH/proxy routing, and `tfy human install --dry-run|--output ` generates the TFY-owned sourceable script. Shell-local functions, aliases, builtins, direct paths, explicit bypass, TFY gateway, and outside-current-directory-scope commands run raw without summary claims; automatic interactive/TUI/stateful subcommand classification is not claimed in v1. Wrapped commands expose TFY ledger evidence and preserve wrapper exit status. - Private Codex hook, editor auto-integration, and provider/API prompt gateway: not claimed; editor/provider are outside TFY scope. -## Adapter v2: MCP/Codex setup foundation +## Agent route foundation -`tfy mcp serve` lets an MCP-aware agent host call TFY at the tool/resource boundary: +Bare `tfy start --agent` prepares the product-facing command routes by writing `.tfy/agent/tfy-agent-wrapper` and the safe project-local Codex plus Claude Code official PreToolUse Bash hook configs. If the user later uses Codex in that project, the Codex hook route is ready; if they later use Claude Code, the Claude Code hook route is ready. Human and agent routes share the same raw-first/no-negative/custom command-summary pipeline; the difference is only where the command boundary is entered. + +Setup remains `configured_unverified` until a real host invocation creates route-bound evidence. `tfy start --agent --host codex` and `tfy start --agent --host claude-code` remain explicit/narrow setup paths. + +MCP remains an advanced/complementary tool-resource route. `tfy mcp serve` lets an MCP-aware agent host call TFY at that boundary: ```bash tfy mcp serve --session --ledger .tfy/mcp/ledger.jsonl --raw-dir .tfy/raw ``` -The host still decides to route through MCP. TFY does not secretly intercept every Codex command. The supported Codex path is: +The host still decides to route through MCP. TFY does not secretly intercept every Codex command. The complementary MCP setup path is: ```bash tfy mcp install --target codex --dry-run @@ -181,18 +185,22 @@ tfy mcp install --target codex --dry-run which prints a concrete `codex mcp add tfy -- tfy mcp serve ...` command and an equivalent TOML snippet. This keeps setup reversible and prevents false claims about private runtime hooks. -The product-facing Codex path is: +The product-facing setup path is: ```bash -tfy init --codex --dry-run -tfy init --codex --project --apply -tfy doctor --codex -tfy smoke --mcp -tfy smoke --codex -tfy setup --ai --codex --dry-run +tfy start --agent +# Prepares .tfy/agent/tfy-agent-wrapper plus Codex and Claude Code project hooks tfy status --json tfy explain -tfy gain # reports no-data until adapter/tfy_tool_run command events exist +tfy gain # reports no-data until adapter/agent/MCP command events exist + +# Optional explicit named-host setup remains available: +tfy start --agent --host codex # .codex/config.toml PreToolUse Bash hook +tfy start --agent --host claude-code # .claude/settings.json PreToolUse Bash hook +tfy smoke --mcp +tfy smoke --codex +tfy smoke --host codex --live --json +tfy smoke --host claude-code --live --json ``` -`tfy init --codex --project --apply` writes only a TFY-owned marker block in `AGENTS.md`. `tfy setup --ai --host --dry-run` and `tfy mcp install --target --dry-run` generate host-specific MCP setup snippets for Codex, Claude Code, Cursor, OpenCode, and Hermes; `tfy setup --ai --host codex --apply --project`, `tfy setup --ai --host claude-code --apply --project`, and `tfy setup --ai --host cursor --apply --project` are safe project writers and preserve unrelated host config with backup, provenance, idempotency, and TFY-only uninstall. Other JSONC/YAML/TOML config writing remains dry-run/manual until a reversible writer exists. Unsupported hosts must not be silently marked configured by `tfy start --agent`. `tfy smoke --codex` and `tfy smoke --host ` remain checklist/evidence-collection surfaces and do not claim that the host invoked TFY; `tfy smoke --mcp` is the automated local proof that TFY's MCP Code I/O path works. `tfy smoke --all` adds local generic-shell and agent-wrapper smoke evidence. `tfy launch-report` exposes the v1 host matrix, uses the canonical statuses `config_snippet_available`, `not_configured`, `applied_unverified`, `verified_local_mcp`, `verified_host_invocation`, `launch_supported`, `unsupported`, and `planned_discovery`; lifecycle status additionally uses `configured_unverified` for safe config writes that have no host invocation evidence, plus claim tiers, and requires route-specific artifact-backed host setup, real-invocation, config path, ledger/raw evidence, no-negative/positive savings, and overhead evidence before named or required routes become `launch_supported`. Setup success is not savings success. `tfy gain` reports command-output savings only after adapter or MCP `tfy_tool_run` command events exist. +`tfy init --codex --project --apply` writes only a TFY-owned marker block in `AGENTS.md`. `tfy setup --ai --host codex --dry-run`, `tfy setup --ai --host claude-code --dry-run`, and matching `tfy mcp install --target ... --dry-run` commands generate supported setup snippets; `tfy setup --ai --host codex --apply --project` and `tfy setup --ai --host claude-code --apply --project` are safe project writers and preserve unrelated host config with backup, provenance, idempotency, and TFY-only uninstall. Codex writes a PreToolUse Bash hook in `.codex/config.toml`, and Claude Code writes a PreToolUse Bash hook in `.claude/settings.json`. Host options outside the supported set must not be silently marked launch-supported by `tfy start --agent`. `tfy smoke --codex` and plain `tfy smoke --host ` remain checklist/evidence-collection surfaces and do not claim that the host invoked TFY; `tfy smoke --host codex --live` and `tfy smoke --host claude-code --live` are explicit opt-in live CLI smokes that create a temporary project, configure the official hook, ask the installed host CLI to run one Bash command, and emit a `host_evidence` JSON file for `tfy launch-report` only if the TFY ledger/raw/no-negative/positive-savings gates pass. `tfy smoke --mcp` is the automated local proof that TFY's MCP Code I/O path works. `tfy smoke --all` adds local generic-shell, agent-wrapper, and MCP smoke evidence. `tfy launch-report` exposes the v1 host matrix, uses the canonical statuses `config_snippet_available`, `not_configured`, `applied_unverified`, `verified_local_mcp`, `verified_local_hook`, `verified_host_invocation`, and `launch_supported`; lifecycle status additionally uses `configured_unverified` for safe config writes that have no host invocation evidence, plus claim tiers, and requires route-specific artifact-backed host setup, real-invocation, config path, ledger/raw evidence, no-negative/positive savings, and overhead evidence before named or required routes become `launch_supported`. Setup success is not savings success. `tfy gain` reports command-output savings only after adapter, agent, hook, or MCP command events exist. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 66e6507..939ac67 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -20,16 +20,16 @@ TFY exposes an agent-neutral protocol so any AI agent, editor, shell wrapper, or - `tfy adapter install --target generic-shell --dry-run` — reversible setup instructions for command-wrapper interception. - `tfy adapter run --session -- ` — supported generic-shell command-boundary adapter. - `tfy adapter report --session ` — session savings/evidence report from adapter ledger. -- `tfy start [agent|ai|human|both] [--agent] [--human] [--host ] [--apply] [--no-apply] [--verify]` / `tfy stop [agent|ai|human|both] [--agent] [--human]` / `tfy fuckyou [agent|ai|human|both] [--agent] [--human] [--yes]` — project lifecycle intent commands backed by `.tfy/lifecycle.json`; `start` also prepares `.tfy/raw` plus `.tfy/state`, `.tfy/adapter`, `.tfy/agent`, and `.tfy/mcp` ledger directories so later raw/log/savings evidence has stable local paths; bare interactive commands open an arrow-key TUI, while piped stdin choices stay script-compatible and missing non-TTY choices fail closed; `stop` preserves raw/shared evidence, while `fuckyou` performs scoped confirmed cleanup and preserves `.tfy/raw` by default. Agent start auto-configuration is safe-writer-only; currently `tfy start --agent`, `tfy start agent`, and `tfy start --agent --host codex` write project `.codex/config.toml` unless `--no-apply` is passed, while `--host claude-code`, `--host cursor`, and `--host all` apply their explicit safe project writers; all record only `configured_unverified`. `active=true` is a derived/effective claim, not a synonym for lifecycle desire or config presence; it requires lifecycle desire plus verified route evidence, raw recovery, no-negative and positive savings, and a supported host/route evidence scope. -- `tfy global start [agent|ai|human|both] [--agent] [--human] [--host ] [--apply] [--no-apply] [--verify]` / `tfy global stop [agent|ai|human|both] [--agent] [--human]` / `tfy global fuckyou [agent|ai|human|both] [--agent] [--human] [--yes]` — user-global lifecycle intent using `$TFY_HOME`, `$XDG_CONFIG_HOME/tfy`, or `~/.tfy`, separate from project state. `tfy use always|stop|cancel|fuckyou` are convenience aliases for the same user-global lifecycle controls (`always` also accepts the historical typo alias `alwais`). P1 global lifecycle records defaults only; global `--host`/`--apply`/`--verify` options are accepted as host-preference guidance but do not mutate project host config, so each project still needs `tfy start --agent` to write Codex/Claude/Cursor route files. +- `tfy start [agent|ai|human|both] [--agent] [--human] [--host ] [--apply] [--no-apply] [--verify]` / `tfy stop [agent|ai|human|both] [--agent] [--human]` / `tfy fuckyou [agent|ai|human|both] [--agent] [--human] [--yes]` — project lifecycle intent commands backed by `.tfy/lifecycle.json`; `start` also prepares `.tfy/raw` plus `.tfy/state`, `.tfy/adapter`, `.tfy/agent`, and `.tfy/mcp` ledger directories so later raw/log/savings evidence has stable local paths; bare interactive commands open an arrow-key TUI, while piped stdin choices stay script-compatible and missing non-TTY choices fail closed; `stop` preserves raw/shared evidence, while `fuckyou` performs scoped confirmed cleanup and preserves `.tfy/raw` by default. Agent start auto-configuration is safe-writer-only; currently `tfy start --agent` and `tfy start agent` create `.tfy/agent/tfy-agent-wrapper` unless `--no-apply` is passed, while explicit `--host codex`, `--host claude-code`, and `--host all` apply their safe project host writers; all record only `configured_unverified`. `active=true` is a derived/effective claim, not a synonym for lifecycle desire or config presence; it requires lifecycle desire plus verified route evidence, raw recovery, no-negative and positive savings, and a supported host/route evidence scope. +- `tfy global start [agent|ai|human|both] [--agent] [--human] [--host ] [--apply] [--no-apply] [--verify]` / `tfy global stop [agent|ai|human|both] [--agent] [--human]` / `tfy global fuckyou [agent|ai|human|both] [--agent] [--human] [--yes]` — user-global lifecycle intent using `$TFY_HOME`, `$XDG_CONFIG_HOME/tfy`, or `~/.tfy`, separate from project state. `tfy use always|stop|cancel|fuckyou` are convenience aliases for the same user-global lifecycle controls (`always` also accepts the historical typo alias `alwais`). P1 global lifecycle records defaults only; global `--host`/`--apply`/`--verify` options are accepted as host-preference guidance but do not mutate project host config, so each project still needs `tfy start --agent` to create its wrapper, or explicit `--host ...` to write Codex/Claude route files. - `tfy status [--agent] [--human] [--json]` — lifecycle/support status with project/global separation, a top-level `lifecycle_summary` for user-facing desired/configured/active/evidence-required state, explicit effective project-over-global desired/configured/active state, desired_source=none in fresh projects, project_parse_error for malformed local lifecycle state, next-action guidance, and truthful route limitations. - `tfy agent capabilities` — reports the configured AI-agent-wrapper-only automatic interception boundary and origin contract. - `tfy agent install --dry-run` — prints a reversible wrapper script without mutating shell startup files. -- `tfy agent run --session -- ` — AI-runtime command wrapper that marks origin as agent runtime while leaving ordinary human terminals untouched. -- `tfy hook capabilities` / `tfy hook install --target --dry-run` / `tfy hook run -- ` — official-host-hook planning surface plus test shim. Hook routes are thin routers into shared gateways and are disabled/planned for real hosts until official docs and host-bound e2e evidence exist. +- `tfy agent run --session -- ` — AI-runtime command wrapper that marks origin as agent runtime while leaving ordinary human terminals untouched. Explicit host setup can additionally write Codex `.codex/config.toml` PreToolUse Bash hooks and Claude Code `.claude/settings.json` PreToolUse Bash hooks. +- `tfy hook capabilities` / `tfy hook install --target --dry-run` / `tfy hook run --host codex|claude-code` — official-host-hook routing surface for Codex and Claude Code plus a test shim. Hook routes are thin routers into shared gateways: PreToolUse rewrites Bash input to a TFY command route, does not execute the pending command inside the hook process, fails closed through `TFY_HOOK_DISABLE=1`, and still requires real host-bound route evidence before launch claims. - `tfy mcp capabilities` — supported MCP tools/resources and support-boundary report. - `tfy mcp serve --session ` — MCP stdio JSON-RPC server; stdout is JSON-RPC only. Exposes Tool/Context/Output/State tools, including host-routed Code I/O tools for bounded scope listing, compact context, preview validation, restore-display, WorkspaceApplyPlan validation, and proof-gated apply. -- `tfy setup --ai --host codex --apply --project` / `--host claude-code --apply --project` / `--host cursor --apply --project` — safe reversible project host writers with backup, idempotency, provenance, and TFY-only uninstall. +- `tfy setup --ai --host codex --apply --project` / `--host claude-code --apply --project` — safe reversible project host writers with backup, idempotency, provenance, and TFY-only uninstall. - `tfy workspace validate --payload plan.json` / `tfy workspace apply --payload plan.json --plan-hash ` — explicit multi-file WorkspaceApplyPlan validation and exact apply for modify/add/delete/rename/move operations, gated by origin/provenance, per-op proof, base hashes, and plan hash. Conservative `modify_fuzzy` is supported only with `allow_fuzzy_apply`, confidence threshold, unique anchor, base hash, preview hash, and per-op proof gates; ambiguous fuzzy edits fail closed. Multiple same-file mutations are rejected until range-aware application exists; callers must merge them into one full-file candidate. - `tfy mcp install --target codex --dry-run` — concrete Codex MCP command/TOML setup snippet without mutating config. @@ -109,7 +109,7 @@ The CLI is both a manual debug surface and the contract used by adapters. Produc Do not overclaim automatic interception: today the Rust CLI/core primitives, `tfy-runtime`, local Tool/Shell/Context/Output-preview-and-proof-gated-apply/State gateway surfaces, generic-shell adapter, configured AI-agent wrapper, MCP stdio tool/resource server, and hook test shim/planning surface exist. MCP supports host-routed Code I/O with snapshot-stable scope ids, compact context, preview validation, restore-display, WorkspaceApplyPlan validation, proof-gated single-file selected-scope apply, and proof-gated exact multi-file workspace apply. Hook routes are optional official-host UX routers into those same gateways; they do not own summarization, redaction, restore, apply, or claim promotion logic. Private/hidden Codex hooks and universal human-shell interception are not claimed. Editor auto-integration and provider/API gateway proxying are out of scope. Conservative fuzzy mutation is supported only through explicit proof-gated WorkspaceApplyPlan operations. -`tfy launch-report` is the product release reducer. Required v1 routes are `generic_shell`, `tfy_agent_adapter`, and `mcp_stdio`; all three must reach `launch_supported` from route-specific smoke/ledger evidence, raw refs, no-negative-savings proof, and `--host-evidence` JSON with existing setup/invocation artifact paths plus measured overhead (`overhead_ms`/`baseline_ms`) or an explicit `overhead_exception` before the report can pass. Local self-smoke evidence alone is intentionally downgraded to `verified_local_mcp`. Named hosts such as Codex, Claude Code, Cursor, OpenCode, and Hermes start at `config_snippet_available`/`configurable`; they promote through the evidence ladder `config_written`, `host_launched`, `verified_host_mcp_invocation` or `verified_host_hook`, `route_evidence_recorded`, `savings_verified`, and finally `launch_supported` only when host-bound evidence includes route type, config scope/path, smoke id, setup/invocation artifacts, ledger/raw artifacts, and no-negative plus positive savings for that specific host. OpenClaw remains `planned_discovery`. Provider/API proxying, editor auto-integration, private Codex hooks, and universal human terminal interception remain `not_supported`/planned unless separate adapters and e2e evidence exist. +`tfy launch-report` is the product release reducer. Required v1 routes are `generic_shell` and `tfy_agent_adapter`; both must reach `launch_supported` from route-specific smoke/ledger evidence, raw refs, no-negative-savings proof, and `--host-evidence` JSON with existing setup/invocation artifact paths plus measured overhead (`overhead_ms`/`baseline_ms`) or an explicit `overhead_exception` before the report can pass. `mcp_stdio` is advanced/complementary and local self-smoke evidence is intentionally downgraded to `verified_local_mcp` or `verified_local_hook` until a real host invocation exists. Codex and Claude Code start at `config_snippet_available`/`configurable`; they promote through the evidence ladder `config_written`, `host_launched`, `verified_host_mcp_invocation` or `verified_host_hook`, `route_evidence_recorded`, `savings_verified`, and finally `launch_supported` only when host-bound evidence includes route type, config scope/path, smoke id, setup/invocation artifacts, ledger/raw artifacts, and no-negative plus positive savings for that specific host. Provider/API proxying, editor auto-integration, private Codex hooks, and universal human terminal interception remain outside supported product claims unless separate adapters and e2e evidence exist. Savings reporting separates exact byte accounting from token estimates. Byte fields are authoritative (`raw_bytes`, `model_bytes`, `saved_bytes`); token fields are a conservative `ceil(bytes/4)` proxy unless a future adapter records tokenizer-specific counts. Raw evidence is local by default under `.tfy/raw` or configured `--raw-dir` plus gateway ledgers; TFY stores it for audit/recovery and does not upload it. @@ -157,10 +157,11 @@ tfy init --uninstall --codex --project --apply tfy doctor --codex tfy smoke --mcp tfy smoke --codex +tfy smoke --host codex --live --json +tfy smoke --host claude-code --live --json tfy setup --ai --codex --dry-run tfy setup --ai --host codex --apply --project tfy setup --ai --host claude-code --apply --project -tfy setup --ai --host cursor --apply --project tfy status --json tfy explain tfy gain # no-data until command-output savings events exist @@ -185,13 +186,13 @@ tfy state-project `tfy init`, `tfy doctor`, `tfy smoke`, and `tfy gain` are product-facing wrappers around the lower-level protocol surfaces: -- Bare interactive `tfy start` / `tfy stop` / `tfy fuckyou` use an arrow-key TUI; non-interactive stdin choices remain available for automation. `tfy start` / `tfy stop` / `tfy fuckyou` manage lifecycle and supported-route configuration, not proof of global interception. Agent lifecycle records supported route intent (`mcp_stdio`, `agent_wrapper`, `generic_shell_adapter`), auto-configures the implemented Codex project route by default, and keeps `active=false`, `private_hook_interception=false`, and `provider_prompt_gateway=false`; named-host launch support still requires launch-report evidence. Human lifecycle records managed-session intent, `route_state=intent_recorded`, `active=false`, and `ordinary_terminal_interception=false`; supported platform shells (Linux bash, macOS zsh, Windows PowerShell) can run `tfy start --human` to create/refresh `.tfy/human/auto-activate.json` plus deterministic shell-specific activation content and then enter a TFY-managed current-directory-scoped session where safely resolved ordinary external commands route through TFY by default and may store combined raw command output before summary selection. Non-interactive plain `tfy start --human` remains lifecycle intent-only; `tfy start --human --auto-activate` is the explicit automation path for marker creation without entering a shell. `tfy setup --human` dry-runs the one-time user rc/profile hook; `tfy setup --human --apply` installs it by delegating to the same safety path as `tfy human auto-activate install --shell --rcfile --apply`, which remains supported. npm install may print this guidance but must not edit startup files. New supported shell sessions that read that hook check only `$PWD/.tfy/human/auto-activate.json`, validate that current-directory marker/script through a pinned absolute TFY executable, export `TFY_HUMAN_ROOT`/`TFY_HUMAN_TFY_BIN`, and source only deterministic TFY-generated content. Shell-local/direct-path/explicit-bypass/TFY-gateway/outside-scope commands run raw without summary/no-negative-savings claims; newly created PATH executables may run raw until the next prompt-time/proxy refresh; automatic interactive/TUI/stateful subcommand classification is not claimed in v1. Unsupported platforms report `managed_session_available=false` with `support_status=managed_session_unsupported_platform`. Ordinary terminals outside managed sessions or trusted marked current directories with the explicit rc/profile hook are not globally intercepted; nested child-shell command boundaries and shell `$?` parity are not claimed unless separately proven. +- Bare interactive `tfy start` / `tfy stop` / `tfy fuckyou` use an arrow-key TUI; non-interactive stdin choices remain available for automation. `tfy start` / `tfy stop` / `tfy fuckyou` manage lifecycle and supported-route configuration, not proof of global interception. Agent lifecycle records supported route intent (`agent_wrapper`, `generic_shell_adapter`, `official_host_hook_when_configured`, `mcp_stdio_complementary`), creates the project agent wrapper by default, and keeps `active=false`, `private_hook_interception=false`, and `provider_prompt_gateway=false`; named-host launch support still requires launch-report evidence. Human lifecycle records managed-session intent, `route_state=intent_recorded`, `active=false`, and `ordinary_terminal_interception=false`; supported platform shells (Linux bash, macOS zsh, Windows PowerShell) can run `tfy start --human` to create/refresh `.tfy/human/auto-activate.json` plus deterministic shell-specific activation content and then enter a TFY-managed current-directory-scoped session where safely resolved ordinary external commands route through TFY by default and may store combined raw command output before summary selection. Non-interactive plain `tfy start --human` remains lifecycle intent-only; `tfy start --human --auto-activate` is the explicit automation path for marker creation without entering a shell. `tfy setup --human` dry-runs the one-time user rc/profile hook; `tfy setup --human --apply` installs it by delegating to the same safety path as `tfy human auto-activate install --shell --rcfile --apply`, which remains supported. npm install may print this guidance but must not edit startup files. New supported shell sessions that read that hook check only `$PWD/.tfy/human/auto-activate.json`, validate that current-directory marker/script through a pinned absolute TFY executable, export `TFY_HUMAN_ROOT`/`TFY_HUMAN_TFY_BIN`, and source only deterministic TFY-generated content. Shell-local/direct-path/explicit-bypass/TFY-gateway/outside-scope commands run raw without summary/no-negative-savings claims; newly created PATH executables may run raw until the next prompt-time/proxy refresh; automatic interactive/TUI/stateful subcommand classification is not claimed in v1. Unsupported platforms report `managed_session_available=false` with `support_status=managed_session_unsupported_platform`. Ordinary terminals outside managed sessions or trusted marked current directories with the explicit rc/profile hook are not globally intercepted; nested child-shell command boundaries and shell `$?` parity are not claimed unless separately proven. ## Adapter v1 protocol `tfy adapter` is the first implemented automatic-interception surface. It is automatic only after a host agent/runtime configures its command execution path to call the adapter wrapper. -`tfy hook` is a dry-run/planning plus test-shim surface for future official host hooks. It fails closed with `TFY_HOOK_DISABLE=1`; real host targets remain `planned_discovery` or `unsupported` until official docs, kill-switch/uninstall posture, and e2e route evidence exist. +`tfy hook` is the official-host-hook router for Codex and Claude Code plus a test-shim equivalence surface. For PreToolUse, it rewrites Bash input to a TFY command route and does not execute the pending command inside the hook process. It fails closed with `TFY_HOOK_DISABLE=1`; setup remains `configured_unverified` until host-bound e2e route evidence proves the host ran the rewritten TFY command and preserved raw-first/no-negative behavior. Supported today: @@ -221,7 +222,7 @@ MCP session resources are session-scoped. `tfy://state/{session}` and `tfy_state `tfy launch-report --json` includes four evidence-gated release tiers: -- `developer_preview_ready` requires verified local build/install evidence, first-success route evidence for `generic_shell`, `tfy_agent_adapter`, and `mcp_stdio`, no-negative plus positive savings, raw lifecycle availability, a benchmark self-manifest, and unsupported claim audit pass. +- `developer_preview_ready` requires verified local build/install evidence, first-success route evidence for `generic_shell` and `tfy_agent_adapter`, no-negative plus positive savings, raw lifecycle availability, a benchmark self-manifest, and unsupported claim audit pass; `mcp_stdio` may strengthen evidence but is not a required command-interception route. - `rc_ready` requires developer preview readiness plus release archive/checksum dry-run, docs/demo/release notes, independent reviews, and PR/CI evidence supplied through `--release-evidence`. - `ga_ready` remains blocked until at least one named AI host has real invocation evidence with host-bound ledger/raw/no-negative/positive-savings proof and reproducible demo evidence. - `public_superiority_claim_ready` remains blocked unless a reviewed external benchmark manifest records baseline, corpus, reproducibility, correctness/no-lost-evidence proof, and overhead comparison. diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index f81b3c9..2c5e35a 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -1,10 +1,10 @@ # TFY Release Readiness -TFY release claims are evidence-gated. Developer Preview and RC are separate gates: Developer Preview proves local supported routes plus installable artifacts, while RC additionally proves review/CI/release closeout. GA and public superiority claims require stricter evidence. +TFY release claims are evidence-gated. TFY is stable-first: stable is the normal product channel, while preview is a pre-release/development validation channel used for large changes before promotion. Developer preview and RC are separate gates: developer preview proves local supported routes plus installable artifacts, while RC additionally proves review/CI/release closeout. GA and public superiority claims require stricter evidence. ## Release tiers -- `developer_preview_ready`: `cargo install`/release build verified, npm preview install smoke passes when the npm channel is in scope, GitHub Release archive/checksum evidence exists, first-success quickstart passes, required routes (`generic_shell`, `tfy_agent_adapter`, `mcp_stdio`) reach `launch_supported`, raw lifecycle commands work, a TFY benchmark manifest exists, and unsupported claim audit passes. +- `developer_preview_ready`: `cargo install`/release build verified, npm preview install smoke passes when the npm channel is in scope, GitHub Release archive/checksum evidence exists, first-success quickstart passes, required command routes (`generic_shell`, `tfy_agent_adapter`) reach `launch_supported`; MCP remains advanced/complementary, raw lifecycle commands work, a TFY benchmark manifest exists, and unsupported claim audit passes. - `rc_ready`: developer preview ready plus archive/checksum dry-run, docs/demo/release notes complete, independent reviews approved, and PR/CI green. - `ga_ready`: RC ready plus at least one named AI host real invocation with route-bound ledger/raw/no-negative/positive-savings evidence and a reproducible named-host demo. - `public_superiority_claim_ready`: GA/RC plus reviewed external benchmark manifest with baseline, corpus, reproducibility, correctness/no-lost-evidence proof, and overhead comparison. @@ -16,6 +16,9 @@ TFY release claims are evidence-gated. Developer Preview and RC are separate gat ./scripts/release-dry-run.sh SMOKE_JSON=.tfy/release/smoke.json tfy smoke --all --json > "$SMOKE_JSON" +# Optional named-host GA evidence, run only when installed/authenticated host CLIs are in scope: +tfy smoke --host codex --live --json > .tfy/release/codex-live-smoke.json +tfy smoke --host claude-code --live --json > .tfy/release/claude-live-smoke.json tfy raw --list --json ARGS=(launch-report --all --release-evidence .tfy/release/release-evidence.json --json) while IFS= read -r item; do diff --git a/docs/TEST_SPEC.md b/docs/TEST_SPEC.md index 2a110d4..fa8833a 100644 --- a/docs/TEST_SPEC.md +++ b/docs/TEST_SPEC.md @@ -202,11 +202,11 @@ Additional MCP hardening tests verify: - `tfy smoke --mcp --json` runs the local MCP Code I/O workflow end to end, records Tool/Context/Output/State evidence, and proves preview validation does not mutate while proof-gated apply does. - `tfy smoke --all --json` runs generic-shell, agent-wrapper, and MCP smokes and emits ledger paths that can be supplied to `tfy launch-report --all --ledger ...`; without separate artifact-backed host setup/real-invocation and overhead evidence these routes are only `verified_local_mcp`. - `tfy smoke --codex` is checklist/report-only and states that no Codex host invocation is claimed. -- `tfy gain` reports `No TFY savings data found yet` on empty ledgers and reports real bytes/tokens from adapter or MCP `tfy_tool_run` `ToolCommandCompleted` command events when present, including `repeat_elided` counts for unchanged repeated output. -- `tfy setup --ai --host --dry-run` and `tfy mcp install --target --dry-run` emit truthful MCP setup snippets for Codex, Claude Code, Cursor, OpenCode, and Hermes, while OpenClaw remains `planned_discovery`. -- `tfy setup --ai --host cursor --apply --project` safely writes project `.cursor/mcp.json`, creates a TFY backup when modifying an existing file, preserves unrelated JSON keys and MCP servers, is idempotent, supports TFY-only uninstall, dry-runs without writing, and fails closed on malformed existing JSON. +- `tfy gain` reports `No TFY savings data found yet` on empty ledgers and reports real bytes/tokens from adapter, agent, or MCP `tfy_tool_run` `ToolCommandCompleted` command events when present, including `repeat_elided` counts for unchanged repeated output. +- `tfy setup --ai --host --dry-run` and `tfy mcp install --target --dry-run` emit truthful setup snippets for Codex and Claude Code. +- Explicit host options outside the supported Codex/Claude Code set fail closed before writing host config and show the supported alternatives only. - `tfy status --json` exposes both canonical status and claim-tier taxonomy for required routes and named hosts. -- `tfy launch-report --json` exposes the `mcp_stdio`, `tfy_agent_adapter`, `generic_shell`, and named-host readiness matrix, blocks release while required v1 hosts (`mcp_stdio`, `tfy_agent_adapter`, `generic_shell`) lack `launch_supported` evidence, promotes local smoke only to `verified_local_mcp`, requires artifact-backed `--host-evidence` JSON plus overhead evidence before `launch_supported`, keeps named hosts non-blocking below launch support without host-bound route/config/ledger/raw/no-negative/positive-savings evidence, lists unsupported provider/editor/private-hook/universal-terminal paths, reports exact byte savings plus conservative token proxy estimates, and includes the seven required launch benchmark scenario names. +- `tfy launch-report --json` exposes the `mcp_stdio`, `tfy_agent_adapter`, `generic_shell`, and supported named-host readiness matrix, blocks release while required v1 command routes (`tfy_agent_adapter`, `generic_shell`) lack `launch_supported` evidence and keeps `mcp_stdio` advanced/complementary, promotes local smoke only to `verified_local_mcp`, requires artifact-backed `--host-evidence` JSON plus overhead evidence before `launch_supported`, keeps Codex and Claude Code non-blocking below launch support without host-bound route/config/ledger/raw/no-negative/positive-savings evidence, lists unsupported provider/editor/private-hook/universal-terminal paths, reports exact byte savings plus conservative token proxy estimates, and includes the seven required launch benchmark scenario names. ## Human `start --human` auto-intercept regression diff --git a/docs/releases/0.1.0-preview.md b/docs/releases/0.1.0-preview.md index 9b0fb70..da0c25d 100644 --- a/docs/releases/0.1.0-preview.md +++ b/docs/releases/0.1.0-preview.md @@ -2,7 +2,7 @@ This preview targets truthful AI-agent I/O token savings at explicit boundaries: CLI tool gateway, generic shell adapter, TFY agent wrapper, MCP stdio, proof-gated code I/O, raw recovery, launch reporting, and release evidence gates. -Supported launch routes for the v1 local gate are `generic_shell`, `tfy_agent_adapter`, and `mcp_stdio`. Named hosts such as Codex, Claude Code, Cursor, OpenCode, and Hermes remain below launch support until real host invocation plus route-bound ledger/raw/no-negative/positive-savings evidence is supplied. +Supported required launch routes for the v1 local gate are `generic_shell` and `tfy_agent_adapter`. MCP stdio remains an implemented advanced/complementary route, not the default command-interception path. Codex and Claude Code remain below launch support until real host invocation plus route-bound ledger/raw/no-negative/positive-savings evidence is supplied. Not supported: provider/API prompt proxying, private Codex hook interception, universal terminal interception, editor auto-hooking, and public external superiority claims without a reviewed benchmark manifest. @@ -13,7 +13,7 @@ The preview can be tested from ordinary project directories through the scoped ` ```sh npm install -g @ium/tfy-cli@preview -tfy start --agent --host codex +tfy start --agent tfy start --human # Cross-platform human managed-session UX: tfy human shell