diff --git a/CHANGELOG.md b/CHANGELOG.md index 4883121f..cde6075e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Bounded agent capability discovery**: the new `capabilities` MCP tool gives + agents a seven-family workflow map instead of making them infer workflow from + every registry entry. Its default compact-receipt summary is bounded to 2 KiB; + one explicitly selected family detail is bounded to 4 KiB. Results are + deterministic, runtime-relative, derived from the capability registry, and + explicitly identify their basis as the registered surface rather than + overstating current authorization. Discovery remains available to unbound + daemon sessions and does not cold-observe repository state. + ### Changed +- **MCP-native bounded output contracts**: all 48 public MCP tools now advertise + object-root `outputSchema` contracts derived from Graft's strict versioned + validators. Successful calls return validated `structuredContent` alongside + equivalent canonical JSON compatibility text, including compact/full receipt + and summary/full diagnostic variants. Discovery keeps exact top-level fields, + scalars, discriminants, schema identity, and receipt posture while shallowly + projecting deep audit structures under a 64 KiB aggregate / 8 KiB per-tool + budget. `returnedBytes` continues to count the canonical compatibility JSON. + The governed exact-edit path preflights its strict domain response before + committing file bytes, so domain-contract drift cannot turn a successful + edit into an ordinary post-write validation failure. + Optional legacy WARP `identityId` data remains valid in precision-match + responses until that compatibility path is explicitly migrated. +- **Summary-first MCP diagnostics**: `doctor` now defaults to a strict bounded + health, workspace, history-readiness, evidence-gap, and next-action summary; + `activity_view` defaults to bounded anchor, count, truncation, group-summary, + and detail-availability facts without embedding event bodies. Both tools + accept `detail: "full"` for their previous exhaustive evidence, sludge scans + force full doctor detail, and CLI peers retain their existing full version-1 + contracts. Long Git refs are explicitly abbreviated in summaries with + `headRefTruncated` while exact commit identity and full-detail refs remain + available. +- **Compact-by-default MCP receipts**: every MCP tool now accepts a common + `receipt: "compact" | "full"` control and defaults to a six-field compact + receipt bounded to 512 encoded bytes. Explicit full mode preserves the prior + audit, budget, and cumulative fields; `stats` remains the cumulative-counter + surface, tripwires remain top-level and immediate, and runtime logs retain the + full internal evidence correlated by compact `receiptId`. MCP output schemas + advance to `2.0.0`, while CLI peers explicitly request full receipts and + preserve their existing `1.0.0` JSON contracts through an explicit nested + projection checked against frozen pre-v2 schema digests. Response accounting + uses the exact UTF-8 bytes of the final encoded response in both in-process + and daemon worker paths. The composed `graft daemon status` reader negotiates + that input control from each daemon tool's advertised schema, requesting full + receipts from v2-aware peers while omitting the unknown field for pre-v2 + daemons during rolling upgrades. +- **Repository observation truth**: ordinary dirty state is now treated as an + initial or unchanged workspace baseline rather than invented movement. + Semantic transition output distinguishes authoritative current state, + observed snapshot deltas, and direct Git transition evidence; live merge, + rebase, and conflict posture remains visible without persisting current state + as a historical transition event. Pre-basis historical events remain + inspectable as `legacy_unclassified` rather than being upgraded to movement. + Initialized reflog entries remain baseline evidence until the record changes; + snapshot deltas cannot inherit stale checkout/reset provenance; and repeated + semantic endpoints receive distinct occurrence identities so causal + `follows` edges cannot collapse into a cycle. + Working-tree snapshots also verify stat-derived `diff-files` candidates + against content-derived evidence, avoiding false dirty state when bytes are + restored inside Git's racy-clean timestamp window. - **CI test feedback**: pull request CI now keeps the release-grade Docker-isolated full test suite on the Node 22 lane while making the Node 20 lane an explicit host-side package compatibility smoke, avoiding a duplicate diff --git a/README.md b/README.md index fcc61d3b..9627e8d5 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,10 @@ Graft sits between the agent and the filesystem and enforces a simple rule: **re - Large file? A structural outline — function names, signatures, line ranges. The agent can drill in with a range read if it needs a specific function body. - Binary, secret, or lockfile? Hard refusal with a machine-readable reason code and a suggested alternative. -Tool responses carry receipts: bytes consumed, bytes avoided, session depth, policy decision. Agents can self-regulate. Operators can audit. +Tool responses carry compact decision receipts by default: a correlation ID, +sequence, reason, latency, and exact encoded response size. Agents that need the +complete audit envelope can request `receipt: "full"`; cumulative session totals +remain available through `stats`. --- @@ -49,11 +52,22 @@ Tool responses carry receipts: bytes consumed, bytes avoided, session depth, pol - **Parser-backed outlines.** Outlines come from Tree-Sitter ASTs, not heuristic line-scanning. Function signatures, class hierarchies, and jump tables are structurally accurate across JavaScript, TypeScript, Rust, Python, Go, GraphQL, JSON, TOML, YAML, Markdown, and more. -- **Machine-readable contracts.** Tool responses carry versioned `_schema` metadata and decision receipts. Agents reason about outcomes without scraping prose. Receipts accumulate cumulative session stats so agents know when they're burning budget. +- **Machine-readable contracts.** Every MCP tool advertises a bounded native + `outputSchema`, and successful calls return validated `structuredContent` + alongside equivalent canonical JSON text for older hosts. Responses also + carry versioned `_schema` metadata and decision receipts, so agents can + reason about outcomes without scraping prose. MCP defaults to a bounded + compact receipt; explicit full receipts and `stats` expose cumulative session + accounting when it is needed. The compact `capabilities` tool is the starting + call for discovering Graft's seven workflow families without asking the agent + to infer workflow from every individual tool definition. - **Structural memory across Git history.** WARP (Structural Worldline Memory) is the current git-warp-backed structural history layer. Query what changed structurally — which symbols were added, removed, or renamed — without dumping source into the agent context. Graft is converging on a `StructuralReadingPort` boundary so Echo can become the primary causal-history substrate after parity is proven. -- **Session governance.** The `GovernorTracker` watches for anti-patterns: runaway tool loops, late-session large reads, edit/bash thrash. Tripwire signals surface in receipts so agents and operators can act before context is exhausted. +- **Session governance.** The `GovernorTracker` watches for anti-patterns: + runaway tool loops, late-session large reads, edit/bash thrash. Tripwire + signals remain immediate top-level response fields even when the receipt is + compact, so agents and operators can act before context is exhausted. - **Industrial-grade daemon.** A same-user local runtime manages multi-repo authorization, persistent monitors, and shared worker pools. Current git-warp contexts stay warm in memory across sessions, while the public contract is moving behind substrate-neutral structural-history ports. @@ -88,6 +102,10 @@ npx @flyingrobots/graft serve Point your MCP client at this process. Graft speaks JSON-RPC over stdin/stdout. The same binary auto-detects non-TTY stdio and enters serve mode automatically — so `npx @flyingrobots/graft` with no arguments works as an MCP server when piped. +Start with `capabilities`. Its default response summarizes the seven registered +workflow families in at most 2 KiB; pass one explicit `family` only when the +agent needs that family's bounded tool names and next-step guidance. + #### Daemon-Backed Stdio A persistent same-user runtime for long-running or multi-repo agent work. Current git-warp contexts stay warm between sessions and persistent monitors can keep structural history current. @@ -100,11 +118,17 @@ npx @flyingrobots/graft daemon npx @flyingrobots/graft serve --runtime daemon ``` -Daemon sessions start unbound. The normal agent flow is: +Daemon sessions start unbound. `capabilities` remains available before binding +and reports the daemon's registered surface without claiming that every tool is +currently authorized. The normal agent flow is: -1. `workspace_open` with the target repo's `cwd`; in daemon mode this authorizes the workspace before opening it. -2. Optionally `workspace_list_opened` to inspect active workspaces -3. Use repository-scoped tools: `safe_read`, `file_outline`, `graft_diff`, etc. +1. Optionally call `capabilities` or + `capabilities({ family: "workspace" })`. +2. Call `workspace_open` with the target repo's `cwd`; in daemon mode this + authorizes the workspace before opening it. +3. Optionally call `workspace_list_opened` to inspect active workspaces. +4. Use repository-scoped tools such as `safe_read`, `file_outline`, and + `graft_diff`. When several agents share one daemon-backed MCP session, repo tools can carry their own explicit route: pass `cwd` to `safe_read`, @@ -115,6 +139,15 @@ changing the active workspace. For explicit control-plane posture, use `workspace_authorize` followed by `workspace_bind`. +MCP diagnostics follow the same bounded-output discipline as file reads. +`doctor` and `activity_view` default to strict summaries below 2 KiB, including +their compact receipts. Pass `detail: "full"` when an agent needs exhaustive +runtime evidence or individual activity items. Doctor's summary treats health +as an evidence posture: `degradedReasons` names unavailable, unknown, or adverse +evidence rather than silently treating missing evidence as healthy. The CLI +doctor and activity commands continue to request full detail internally, so +their existing human and JSON output remains compatible. + See [docs/SETUP.md](./docs/SETUP.md) for client-specific bootstrap and daemon control-plane configuration. ### 3. API — In-Process Integration @@ -130,12 +163,18 @@ const result = await workspace.safeRead({ path: "src/app.ts" }); // result.projection: "content" | "outline" | "refused" | "cache_hit" | "diff" ``` -**In-process tool calls with receipts** (full MCP behavior, no subprocess): +**In-process tool calls with receipts** (MCP behavior, no subprocess): ```ts import { createRepoLocalGraft, callGraftTool } from "@flyingrobots/graft"; const graft = createRepoLocalGraft({ cwd: process.cwd() }); const outline = await callGraftTool(graft, "file_outline", { path: "src/app.ts" }); + +// Audit/debug clients can opt into the complete cumulative envelope. +const audited = await callGraftTool(graft, "file_outline", { + path: "src/app.ts", + receipt: "full", +}); ``` **Editor-native syntax highlighting** (Tree-Sitter WASM, no I/O, viewport-aware): diff --git a/docs/MCP.md b/docs/MCP.md index 615d78c6..b0a6c585 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -55,11 +55,12 @@ Daemon sessions start `unbound`. Once a client is connected to the daemon MCP surface, repository-scoped work normally follows this agent-facing flow: -1. `workspace_open` with the target `cwd` -2. optionally `workspace_list_opened` to inspect opened paths and the - active workspace -3. then call repository-scoped tools such as `safe_read`, `graft_since`, - or `code_show` +1. Optionally call `capabilities` or select its `workspace` family. +2. Call `workspace_open` with the target `cwd`. +3. Optionally call `workspace_list_opened` to inspect opened paths and the + active workspace. +4. Call repository-scoped tools such as `safe_read`, `graft_since`, or + `code_show`. For concurrent multi-repo use inside one daemon-backed MCP session, repo tools that support routing also accept `cwd`: `safe_read`, @@ -71,7 +72,33 @@ mutate the active workspace. `workspace_authorize` and `workspace_bind` remain available as lower-level daemon control-plane tools. +## Capability Discovery + +`capabilities` is the bounded agent-native starting call. With no family it +returns all seven workflow families—`session`, `workspace`, `read`, `code`, +`history`, `review`, and `diagnostic`—with one canonical opening call, one-line +guidance, and a registered-tool count for each. The complete compact response, +including its receipt, is at most 2 KiB and contains no per-tool description +dump. + +Pass one explicit family when deeper discovery is useful: + +```json +{ "family": "read" } +``` + +The family-detail response is at most 4 KiB with a compact receipt and returns +only that family's deterministically ordered names and capability-registry +descriptions. It is available in an unbound daemon session and does not inspect +Git or open WARP. + +Every response states `discoveryBasis: "registered_surface"`. That basis means +the names are installed in the active repo-local or daemon runtime. It is not a +claim that every registered tool is authorized for the current workspace: +binding, per-call routing, and capability policy can still obstruct an action. + ## Key Tool Groups +- **Discovery**: `capabilities` - **Bounded Reads**: `safe_read`, `file_outline`, `read_range`, `changed_since` - **Governed Edits**: `graft_edit` - **Structural History**: `graft_diff`, `graft_since`, `graft_map`, @@ -84,10 +111,52 @@ daemon control-plane tools. ## Current Truth - MCP is the primary agent surface. +- `capabilities` is the compact workflow-discovery surface. Its summary is + bounded to 2 KiB and one selected family detail to 4 KiB with compact + receipts; it reports registration rather than current authorization. - `graft serve` is repo-local stdio; `graft serve --runtime daemon` is the daemon-backed stdio bridge. -- Responses carry versioned `_schema` metadata and `_receipt` decision data. -- `activity_view` provides bounded local `artifact_history` anchored to Git `HEAD`. +- MCP responses carry version-2 `_schema` metadata and a compact `_receipt` by + default. Every tool accepts the common optional input + `receipt: "compact" | "full"`; use `"full"` only when the complete audit and + cumulative-accounting envelope is required. +- Every public tool advertises an MCP-native `outputSchema`. The advertised + schema is a bounded object-root projection of Graft's stricter versioned + validator: it preserves top-level answer fields, scalar types, + discriminants, exact `_schema` identity, and compact/full receipt posture + without recursively dumping every deep audit object into tool discovery. +- Successful calls return the same machine-readable value twice: natively in + `structuredContent` and as canonical JSON `TextContent` for compatibility + with older hosts. Graft validates the value against both its strict contract + and the advertised projection before returning success. `_receipt.returnedBytes` + continues to count only the canonical compatibility JSON, not both equivalent + MCP representations or JSON-RPC framing. +- Compact receipts contain only `mode`, `receiptId`, `seq`, `reason`, + `latencyMs`, and the exact encoded `returnedBytes`. The `receiptId` correlates + with the runtime-observability log; it is not a fetch handle. `stats` is the + explicit cumulative-counter surface. +- Tripwire warnings remain top-level response data in both receipt modes. +- CLI peer commands intentionally request full MCP receipts and project them + into their unchanged version-1 CLI JSON contracts. +- `doctor` and `activity_view` default to strict summary responses. Each default + response, including its compact receipt, is bounded below 2 KiB. Pass + `detail: "full"` on the original MCP call for the exhaustive diagnostic view. +- The doctor summary reports overall evidence posture, active workspace + identity, structural- and local-history readiness, named `degradedReasons`, + and one recommended next action. `unknown` readiness remains unknown; it is + not silently promoted to ready. Consequently, `health: "degraded"` can mean + that evidence is incomplete, not that the runtime itself failed. +- A requested doctor sludge scan is an explicit exhaustive diagnostic and + therefore returns full detail even if `detail: "summary"` is also supplied. +- The activity summary preserves its Git anchor, matching-item counts, + truncation truth, group counts, evidence gaps, and whether item detail is + available, but omits `activeCausalWorkspace` and individual event bodies. + Long refs and narrative fields are bounded; `anchor.headRefTruncated` is true + when the summary abbreviates a ref, while the exact commit SHA remains + present. Use `detail: "full"` to retrieve complete refs and activity items. +- CLI doctor and activity peers explicitly request full detail as well as full + receipts, preserving their existing human rendering and version-1 JSON + contracts while MCP remains summary-first. ## Related docs - [README](../README.md) diff --git a/docs/SETUP.md b/docs/SETUP.md index fe61090a..7604471e 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -120,12 +120,14 @@ rebind, pause, resume, start, or stop daemon resources. Practical daemon-backed MCP first-use sequence: -1. configure your MCP client with `graft serve --runtime daemon` -2. let the bridge auto-start the daemon, or start `npx @flyingrobots/graft daemon` -3. call `workspace_open` with the target `cwd` -4. optionally call `workspace_list_opened` to inspect opened paths and the - active workspace -5. then use repository-scoped tools such as `safe_read` or `graft_map` +1. Configure your MCP client with `graft serve --runtime daemon`. +2. Let the bridge auto-start the daemon, or start + `npx @flyingrobots/graft daemon`. +3. Optionally call `capabilities` or select its `workspace` family. +4. Call `workspace_open` with the target `cwd`. +5. Optionally call `workspace_list_opened` to inspect opened paths and the + active workspace. +6. Use repository-scoped tools such as `safe_read` or `graft_map`. If concurrent agents share one daemon-backed MCP session, prefer explicit `cwd` routes for repo-specific tools that support routing: @@ -169,8 +171,10 @@ runtime: Use repo-local stdio when you want one MCP server bound directly to the current checkout. Use daemon-backed stdio when you want daemon-only capabilities such as shared worker pools, persistent monitors, and -daemon control-plane inspection. Daemon-backed sessions start unbound; -repository-scoped tools normally begin with `workspace_open`. +daemon control-plane inspection. Daemon-backed sessions start unbound. +`capabilities` remains available in that state and reports the registered +surface without claiming current authorization; repository-scoped tools +normally begin with `workspace_open`. ### One-step bootstrap @@ -539,6 +543,7 @@ add to `.claude/settings.local.json`: | Tool | Description | |------|-------------| +| `capabilities` | Bounded agent-native workflow discovery. The default summarizes seven registered tool families; pass one `family` for its opening call, guidance, and tool names. Registration does not imply current workspace authorization. | | `safe_read` | Policy-enforced file read. Returns full content for small files, structural outline with jump table for large files, or refusal with reason code for banned files. Detects re-reads and returns cached outlines or structural diffs. | | `file_outline` | Structural skeleton of a file — function signatures, class shapes, exports. Includes a jump table mapping each symbol to its line range for targeted `read_range` follow-ups. | | `read_range` | Read a bounded range of lines from a file. Maximum 250 lines. Use jump table entries from `file_outline` or `safe_read` to target specific symbols. | @@ -550,29 +555,50 @@ add to `.claude/settings.local.json`: | `code_find` | Search symbols across the project by approximate name or glob pattern, with optional kind/path filter. | | `graft_difficulty` | Refactor difficulty score for a symbol. Combines WARP churn curvature with reference-edge friction and returns the scalar plus the underlying factors. | | `code_refs` | Search import sites, callsites, property access, or literal text references with explicit text-fallback provenance, pattern, and scope. | -| `activity_view` | Bounded between-commit activity for the active workspace, including commit anchor, grouped recent activity, and degraded posture. | -| `doctor` | Runtime health check including layered-worldline repo state and burden summary. Accepts an opt-in `sludge` scan for parser-backed structural smell signals. | +| `activity_view` | Summary-first between-commit activity for the active workspace. The default preserves the commit anchor, counts, truncation truth, group summaries, evidence gaps, and item-detail availability without event bodies; pass `detail: "full"` for individual activity items and active-causal-workspace evidence. | +| `doctor` | Summary-first runtime and evidence-posture check. The default reports health, workspace identity, structural/local-history readiness, named evidence gaps, and one next action; pass `detail: "full"` for exhaustive runtime evidence. An opt-in `sludge` scan forces full detail and adds parser-backed structural smell signals. | +| `set_budget` | Declare a session byte budget. Graft tightens read thresholds as the budget drains, and no single read may consume more than 5% of the remaining budget. | | `stats` | Decision metrics for the current server session, including burden by tool kind. | | `explain` | Human-readable meaning and recommended action for a reason code. | | `run_capture` | Execute a shell command and return the last N lines of output (default 60). This tool is outside graft's bounded-read policy contract, responses include an explicit `policyBoundary` marker, log persistence can be disabled, and persisted output is redacted for obvious secrets by default. | | `state_save` | Save session working state (max 8 KB). Use for session bookmarks: current task, files modified, next planned actions. | | `state_load` | Load previously saved session state. Returns null if no state has been saved. | -MCP responses include versioned `_schema` metadata and `_receipt` -fields. CLI peer commands also return versioned `_schema` metadata; -the declared contracts live in `src/contracts/output-schemas.ts`. -| `doctor` | Runtime health check. Shows project root, parser status, active thresholds, session depth, message count, and a compact burden summary. With `sludge: true`, reports parser-backed structural smell signals. | -| `set_budget` | Declare a session byte budget. Graft tightens read thresholds as the budget drains — no single read may consume more than 5% of remaining budget. Call once at session start. | -| `explain` | Explain a graft reason code. Returns human-readable meaning and recommended next action for any code (e.g., `BINARY`, `BUDGET_CAP`). Case-insensitive. | -| `stats` | Decision metrics for the current session. Total reads, outlines, refusals, cache hits, bytes avoided, and returned-byte burden by tool kind. | - Every MCP tool response includes: + - `_receipt` — runtime decision metadata - `_schema` — versioned output contract metadata -Declared output contracts live in `src/contracts/output-schemas.ts`. -| `graft_since` | Structural changes since a git ref. Shows symbols added, removed, and changed per file — not line hunks. Includes per-file summary lines. Policy-denied files are omitted from `files` and surfaced in `refused`. | -| `graft_map` | Structural map of a directory — all files and their symbols (function signatures, class shapes, exports) in one call. Uses tree-sitter to parse the working tree directly. Policy-denied files are omitted from `files` and surfaced in `refused`. | +CLI peer commands also return versioned `_schema` metadata. Declared output +contracts live in `src/contracts/output-schemas.ts`. + +### Summary-first diagnostics + +Ordinary MCP diagnostics are intentionally small: + +- `doctor({})` returns a strict summary below 2 KiB, including its compact + receipt. It reports `health`, the selected workspace, structural- and + local-history readiness, `degradedReasons`, and one `recommendedNextAction`. +- `activity_view({ limit })` returns a strict summary below 2 KiB, including its + compact receipt. It keeps the Git anchor, matching and returned counts, + `truncated`, group counts, named evidence gaps, and `itemDetailAvailable`, but + omits individual event bodies and `activeCausalWorkspace`. If a long Git ref + is abbreviated to preserve the bound, `anchor.headRefTruncated` is true and + the exact commit SHA remains available. +- Pass `detail: "full"` to either MCP tool for its exhaustive response. A doctor + call with `sludge: true` also selects full detail because the scan is an + explicitly requested diagnostic section. + +Diagnostic health is an evidence posture. `health: "degraded"` may identify +missing, unknown, inactive, or adverse evidence without claiming that the +parser or server failed. In particular, structural-history readiness remains +`unknown` with reason `not_observed` until an authoritative readiness signal has +actually been observed. Graft names that gap instead of guessing. + +The CLI remains exhaustive for compatibility. `graft doctor`, +`graft diag doctor`, and `graft diag activity` request full detail and full +receipts internally, preserving their existing human rendering and version-1 +JSON contracts. ## What the agent sees @@ -635,11 +661,15 @@ tracks cumulative bytes consumed and tightens thresholds as the budget drains. No single read may consume more than 5% of remaining budget. When the budget is exhausted, all reads return outlines. -Budget status appears in every receipt: +Budget status is available in an explicit full receipt or through `stats`: ```json "budget": { "total": 500000, "consumed": 14345, "remaining": 485655, "fraction": 0.029 } ``` +Pass `receipt: "full"` on the original MCP call when the per-call audit +envelope is required. Compact receipts intentionally omit budget and cumulative +session state. + ### Session awareness Graft tracks the session and tightens policy as context pressure @@ -656,14 +686,31 @@ messages, edit-bash loops, runaway tool calls). ### Receipts -Every response includes a `_receipt` block with session ID, -trace ID, sequence number, latency, projection type, bytes -returned, and cumulative counters. This is for usage analysis and -correlation — you can usually ignore it. +Every response includes a `_receipt` block. MCP calls default to the bounded +compact form: + +```json +{ + "mode": "compact", + "receiptId": "trace_xyz", + "seq": 3, + "reason": "CONTENT", + "latencyMs": 12, + "returnedBytes": 912 +} +``` + +Every MCP tool accepts `receipt: "full"` when an audit/debugging client needs +the previous session ID, trace ID, projection, burden, budget, and cumulative +counter fields. `stats` remains the dedicated cumulative-counter tool. CLI peer +commands request full internally, so their version-1 JSON and human output stay +compatible. -If MCP runtime observability is enabled, `traceId` and `seq` line up -with `.graft/logs/mcp-runtime.ndjson`. `doctor` also reports the -current runtime log path and policy. +If MCP runtime observability is enabled, compact `receiptId` (or full +`traceId`) and `seq` line up with `.graft/logs/mcp-runtime.ndjson`. The ID is a +correlation key, not a promise that receipt detail can be fetched later. +`doctor` also reports the current runtime log path and policy. Tripwire warnings +are immediate top-level response fields rather than receipt members. ## Configuration @@ -792,5 +839,7 @@ You can also ask the agent to call `doctor` to verify: Use the doctor tool to check graft's health. ``` -This returns the project root, parser status, active thresholds, -and session depth. +The MCP call returns the bounded evidence summary: health, selected workspace, +history readiness, named evidence gaps, and one recommended next action. Ask for +`detail: "full"` when troubleshooting requires the project root, parser status, +thresholds, session depth, runtime log posture, or other exhaustive evidence. diff --git a/docs/TECHNICAL_TEARDOWN.md b/docs/TECHNICAL_TEARDOWN.md index 6c566098..6b17fa4b 100644 --- a/docs/TECHNICAL_TEARDOWN.md +++ b/docs/TECHNICAL_TEARDOWN.md @@ -127,7 +127,7 @@ Before any code is discussed, establish a shared vocabulary. These terms appear | **Tripwire** | A behavioral signal that fires when an agent is doing something suspicious (reading huge files late in a session, spinning in tool loops, etc.). | | **WARP** | Graft's current git-warp-backed structural worldline memory layer. Git-backed AST outlines are stored per commit to enable symbol-level history queries without dumping source into the agent context. The active architecture is moving this behind `StructuralReadingPort` so Echo can become the primary causal-history substrate after parity is proven. | | **Worldline** | A sequence of structural states a file or symbol passes through across Git commits. The WARP graph stores these worldlines. | -| **Receipt** | A machine-readable metadata envelope attached to every tool call result, carrying latency, byte counts, policy decision, and cumulative session stats. | +| **Receipt** | A machine-readable metadata envelope attached to every tool call result. MCP defaults to a compact decision/correlation receipt; explicit full mode carries the complete audit and cumulative session stats. | | **MCP** | *Model Context Protocol*. An open protocol for tools that AI agents can call. Graft exposes all its capabilities as MCP tools. | | **Daemon** | Graft's persistent same-user background process. It manages authorized workspaces, shared worker pools, persistent monitors, and current git-warp contexts across MCP sessions. | | **Repo-local mode** | A lightweight alternative to the daemon — a per-repo, in-process MCP server that reads one checkout. No persistent process. | @@ -146,7 +146,7 @@ Graft solves this by acting as a **context governor** between the agent and the 1. **Evaluates a policy** — is this file too big? Is the agent's session already deep? 2. **Returns the minimum structurally correct view** — full content for small files; a structural outline for large ones; a hard refusal for binaries, secrets, and lockfiles. -3. **Tracks tool responses** — bytes consumed, files read, symbols seen — and embeds this telemetry in tool receipts so the agent can self-regulate. +3. **Tracks tool responses** — bytes consumed, files read, symbols seen — while emitting a bounded compact receipt by default. Agents request full audit telemetry explicitly or query cumulative state through `stats`. ```mermaid flowchart TD @@ -391,8 +391,9 @@ sequenceDiagram end end - Tool->>Tool: buildReceipt(result, latency, cumulative) - Tool-->>MCP: { ...result, _receipt: McpToolReceipt } + Tool->>Tool: build full internal audit record + Tool->>Tool: project compact (default) or full receipt + Tool-->>MCP: { ...result, _receipt: McpReceipt } MCP-->>Agent: tool_result ``` @@ -440,36 +441,26 @@ Since 87 lines < 150 (the threshold), and the file is not banned: "actual": { "lines": 87, "bytes": 2400 }, "thresholds": { "lines": 150, "bytes": 12288 }, "_receipt": { - "sessionId": "sess_abc123", - "traceId": "trace_xyz", + "mode": "compact", + "receiptId": "trace_xyz", "seq": 3, - "ts": "2025-05-27T14:23:01.042Z", - "tool": "safe_read", - "projection": "content", "reason": "CONTENT", "latencyMs": 12, - "fileBytes": 2400, - "returnedBytes": 2400, - "burden": { "kind": "read", "nonRead": false }, - "cumulative": { - "reads": 3, - "outlines": 1, - "refusals": 0, - "cacheHits": 0, - "bytesReturned": 6821, - "bytesAvoided": 18400, - "nonReadBytesReturned": 0, - "burdenByKind": { "read": 3, "compute": 0, "non_read": 0 } - } + "returnedBytes": 912 } } ``` -Notice `bytesAvoided: 18400` — that's how many bytes Graft saved the agent's context window by returning outlines instead of full content on previous calls. +Pass `receipt: "full"` to retain the session, trace, timestamp, tool, +projection, file-byte, burden, budget, compression, and cumulative fields used +for audit/debugging. The internal full record is always retained for runtime +logging and accounting; compact mode changes only the public projection. ### Receipt Entity Relationship -Every tool call produces one receipt that accumulates session-wide stats: +Every tool call produces one complete internal audit receipt. MCP projects that +record into a compact receipt by default; explicit full mode exposes the +session-wide cumulative relationship shown below: ```mermaid erDiagram @@ -1249,7 +1240,10 @@ flowchart TD J -->|"No"| L["✅ OK to proceed"] ``` -Tripwires are **advisory, not blocking**. They appear in the `_receipt` attached to the tool result. The agent can read them and self-correct. Graft does not forcibly prevent the read — it trusts the agent to respond to the signal. +Tripwires are **advisory, not blocking**. They appear as an immediate top-level +`tripwire` field on the tool result in both receipt modes. The agent can read +them and self-correct. Graft does not forcibly prevent the read — it trusts the +agent to respond to the signal. **Trade-off**: A hard block would be more authoritative but would break agent workflows unexpectedly. An advisory signal is gentler and allows the agent to contextualize (maybe that large read was intentional and justified). @@ -1389,7 +1383,7 @@ OutlineResult { "result": { "content": [{ "type": "text", - "text": "{\"path\":\"src/operations/repo-workspace.ts\",\"projection\":\"outline\",\"reason\":\"OUTLINE\",\"actual\":{\"lines\":612,\"bytes\":24891},\"thresholds\":{\"lines\":150,\"bytes\":12288},\"outline\":[...],\"jumpTable\":[...],\"estimatedBytesAvoided\":24891,\"_receipt\":{\"seq\":8,\"latencyMs\":34,\"returnedBytes\":1240,\"cumulative\":{\"bytesAvoided\":49782,...}}}" + "text": "{\"path\":\"src/operations/repo-workspace.ts\",\"projection\":\"outline\",\"reason\":\"OUTLINE\",\"actual\":{\"lines\":612,\"bytes\":24891},\"thresholds\":{\"lines\":150,\"bytes\":12288},\"outline\":[...],\"jumpTable\":[...],\"estimatedBytesAvoided\":24891,\"_receipt\":{\"mode\":\"compact\",\"receiptId\":\"trace_xyz\",\"seq\":8,\"reason\":\"OUTLINE\",\"latencyMs\":34,\"returnedBytes\":1240}}" }] } } @@ -1871,7 +1865,7 @@ Every design decision is a compromise. Here are Graft's most significant ones, s ### Trade-off 3: Tripwires Are Advisory, Not Blocking -**Decision**: Tripwire signals appear in receipts but do not prevent the tool call from completing. +**Decision**: Tripwire signals appear at the top level of responses but do not prevent the tool call from completing. **Gained**: Agent workflows are never broken unexpectedly. An agent can contextualize and decide the tripwire is a false positive for its specific task. diff --git a/docs/design/SURFACE_agent-working-set-control-plane.md b/docs/design/SURFACE_agent-working-set-control-plane.md new file mode 100644 index 00000000..32307a26 --- /dev/null +++ b/docs/design/SURFACE_agent-working-set-control-plane.md @@ -0,0 +1,630 @@ +--- +title: "Agent working-set control plane" +legend: "SURFACE" +cycle: "agent-native-campaigns" +source_feedback: "docs/feedback/2026-07-14-codex-agent-native-campaigns.md" +external_source_sha256: "d7be1d945730630abbe466b014b6c52121956becaae6d66ee4277aa5a5d86354" +inspected_graft_commit: "5b9c4a866c9938e31cd159c0639ee9dedbaed1ea" +corroborating_local_commit: "f9df3e7ed22bf2a7951f055efb838aac567b52eb" +supersedes_unmerged_commit: "86820e71ae9366ac952077be4ccc3da92fad0839" +--- + +# Agent working-set control plane + +## Sponsors + +- Human: James +- Agent: Codex + +## Hill + +Graft applies its bounded-read discipline to its own control plane: ordinary +agent calls receive compact evidence, routine diagnostics answer the immediate +question before exposing detail, and an initial repository snapshot is never +described as movement that Graft did not observe. + +The first campaign also gives agents one compact capability map for discovering +the intended next Graft call without renaming the existing public tools. Before +that map lands, Graft exposes its already-versioned output contracts through +MCP-native `outputSchema` and `structuredContent` so agents and hosts do not +have to reverse-engineer serialized JSON text. + +## Source and confidence + +The primary feedback was captured outside this repository and is preserved as +an in-repo witness at `docs/feedback/2026-07-14-codex-agent-native-campaigns.md`. +That witness pins the original document by SHA-256 and records the measurements, +recommendations, and routing decisions needed to reproduce this campaign. It +inspected Graft at `5b9c4a86`, whose parent is the `origin/main` branch point +for this cycle (`c3885dab`). The control-plane observations therefore describe +the current main implementation; the only intervening inspected commit was the +unrelated Echo command-kernel transport. + +An independent Claude session recorded the same receipt-overhead finding in +local commit `f9df3e7e`: a 465-byte file produced a 1,461-byte response because +every call repeated cumulative counters. The durable witness includes that +measurement and recommendation, so reviewers do not need the local-only Git +object. This cycle stays rooted at `origin/main` as the operator requested. + +The pushed but unmerged commit `86820e71` is prior art, not a merge base. It is +based on an old main, combines obsolete workspace-onboarding work with an +opt-in compact receipt, and has no completed retro. This packet supersedes that +attempt. Relevant ideas and tests may be ported deliberately; the commit must +not be cherry-picked wholesale. + +## Problem statement + +Five current behaviors make Graft less useful to an agent than its precision +read model suggests: + +1. Every response repeats a full cumulative receipt, including the complete + burden-by-kind map. Small domain payloads can cost less than their metadata. +2. `doctor` and `activity_view` already compute concise summaries, but their + default responses also include exhaustive diagnostic and event state. +3. The first dirty workspace snapshot can be classified as a bulk transition + even though no earlier observation exists, and an unchanged dirty snapshot + can continue to look like movement. +4. The capability registry is authoritative, but agents have no small product + map that presents the intended tool families and opening calls. +5. Graft validates strict, versioned output schemas internally, but MCP tool + discovery advertises only input schemas and successful calls return the + machine-readable result only as serialized text. Hosts cannot use MCP's + native output validation or structured-result path. + +Metadata volume and truth wording are product-correctness concerns for a tool +whose purpose is to conserve context and support lawful decisions. + +## Campaign boundary + +This is a Graft control-plane campaign, not an Echo or git-warp migration slice. +It changes neither structural-history provider nor structural-history facts. + +The wider feedback is routed as follows: + +| Feedback seam | Destination | +| :--- | :--- | +| Compact receipts; summary diagnostics; baseline truth; capability discovery | This campaign | +| MCP-native bounded output schemas and structured results | This campaign | +| Actor bootstrap; one-call orientation; portable checkpoint/resume | Later Graft session-lifecycle campaign | +| Readiness/phase timing; durable job handles | Later Graft durable-operations campaign | +| Write observation; generic evidence references | Later Graft observation/evidence campaign | +| Transactional governed patches | Later Graft governed-mutation campaign | +| Multi-repo goalposts, backlog slices, dependency DAGs, retros, release truth | Method MCP / Method CLI | +| GitHub issue, review, CI, and merge-gate projections | Typed GitHub/Method integration | + +Graft may expose bounded, authorization-filtered repo facts for those consumers, +but it must not absorb METHOD-specific workflow state. This follows the product +boundary in `docs/BEARING.md`. + +## Observation law + +A snapshot supports an endpoint claim; a semantic transition is a stronger +claim about evidence connecting endpoints. Equal snapshots therefore support +neither a movement claim nor a claim that no movement occurred. In this +contract, `semanticTransition: null` means that the available evidence supports +no transition claim. Independent Git transition evidence may still support a +transition when the visible endpoint projection is unchanged. + +This is why observation basis is part of the structured contract and durable +transition identity rather than an inference from summary prose. + +Legacy persisted transition records that predate this field decode as +`legacy_unclassified`. That compatibility posture preserves the record without +inventing either current-state authority or an observed delta. It is valid only +for historical event decoding and is not a live semantic-transition basis. + +## Acceptance criteria + +### Slice 1: baseline observation truth + +- The first snapshot of an ordinary dirty repository may report staged, + unstaged, and untracked baseline facts, but it does not claim that movement + occurred in this Graft session. +- An authoritative first observation may report that a merge, rebase, or + conflict phase is currently active. It uses static current-state wording and + must not say the phase started, emerged, continued, or moved in this session. +- Semantic transition output distinguishes `current_state`, `snapshot_delta`, + and `git_transition_evidence` observation bases. +- Movement wording is reserved for an authoritative delta between observations + or direct Git transition evidence. +- A later snapshot with an unchanged dirty or active-phase status does not + create a new movement merely because that state remains present. +- The reflog entry captured during initialization is baseline evidence. A + same-second timestamp cannot upgrade an unchanged entry into newly observed + movement; direct reflog evidence requires a changed record. +- Durable `snapshot_delta` events do not inherit Git-transition kind, refs, or + checkout-epoch creation from a previously retained checkout/reset. +- Separate occurrences of the same semantic endpoint receive separate durable + event identities while repeated persistence of one observation remains + idempotent. Their `follows` edges remain an acyclic occurrence chain. +- Current workspace facts remain inspectable even when `semanticTransition` is + `null`; suppressing an invented transition must not suppress the baseline. +- Regression tests assert structured truth classes, not incidental prose. + +### Slice 2: compact receipt policy + +- Every MCP tool accepts a common optional `receipt` input with `compact` and + `full` policies. +- `compact` is the MCP default. `full` retains the existing cumulative receipt + fields for explicit audit/debugging. +- Both receipt variants carry an explicit `mode` discriminator. A compact + receipt contains only `mode`, a runtime-log correlation `receiptId`, sequence, + reason, latency, and final encoded response byte count. The response `_schema` + and domain payload continue to identify the tool and projection. +- `receiptId` identifies the matching runtime-observability record. It is not a + promise that the receipt can be fetched later; full detail must be requested + on the original call in this slice. +- Compact receipt metadata is at most 512 encoded bytes. Tests measure encoded + bytes deterministically; they do not assert wall-clock latency. +- This is an intentional breaking MCP-default change for package `0.12.0`. + Every MCP output schema advances from version `1.0.0` to `2.0.0`; CLI output + schema versions remain unchanged because CLI peers explicitly request full + receipts. + +- Slice 1 adds the required live `observationBasis` field while this branch + still advertises the version-1 MCP shape. It is therefore an unreleased + intermediate commit and must not merge or release independently. Slice 2's + version-2 schema transition is the public compatibility boundary for both + changes. +- A no-argument MCP call receives the version-2 compact receipt. Existing MCP + clients that require cumulative receipt fields must update to request + `receipt: "full"` and validate the version-2 full variant. +- `stats` remains the explicit cumulative counter surface. +- Tripwires remain immediate and are never hidden by compact mode. +- Internal metrics, budget accounting, runtime logging, and final + `returnedBytes` semantics remain based on the actual encoded response. +- CLI peer commands explicitly request full receipts so existing CLI JSON and + human rendering contracts do not change in this slice. Diagnostic CLI v1 + schemas and payloads project MCP-v2-only `observationBasis` fields away and + remain structurally identical to `origin/main@c3885dab`. +- The composed `graft daemon status` reader negotiates the `receipt` input + control from each daemon tool's advertised input schema. It requests a full + receipt from v2-aware daemons and omits the unknown field for pre-v2 daemons, + preserving read-only status probes during a rolling CLI/daemon upgrade. + +### Slice 3: summary-first diagnostics + +- `doctor()` defaults to a strict summary that answers: + 1. whether Graft is healthy or degraded; + 2. which workspace is active; + 3. whether persisted structural/local history is ready; + 4. which named evidence gaps degrade confidence; and + 5. the single recommended next action. +- Structural-tool authority is not structural-provider readiness. This slice + reports persisted local-history readiness from owned evidence and reports + structural readiness as `unknown` with reason `not_observed` until a + provider-neutral readiness signal exists. Default doctor must not cold-open + WARP merely to manufacture a readiness answer. +- The default encoded `doctor()` response is below 2 KiB, including its compact + receipt. +- `doctor({ detail: "full" })` preserves the existing exhaustive response. + A sludge scan also returns the full response because the scan result is an + explicitly requested diagnostic section. +- `activity_view()` defaults to its existing bounded summary and window counts, + without embedding the active causal-workspace object or individual activity + items. +- The default encoded `activity_view()` response is below 2 KiB, including its + compact receipt. Its counts describe matching activity, `truncated` remains + authoritative, and it explicitly reports whether full item detail is + available. +- Summary references and narrative fields are byte-bounded. When a Git ref is + abbreviated to preserve the response budget, `headRefTruncated` says so while + the exact commit SHA remains present; full detail preserves the complete ref. +- `activity_view({ detail: "full" })` preserves the existing grouped item view. +- CLI doctor and activity peers explicitly request full detail so their current + presentation and JSON contracts remain stable. +- Summary and full variants are strict, versioned output-schema alternatives; + no untyped optional-property bag is introduced. + +### Slice 4: MCP-native structured output + +- Every registered public MCP tool advertises an `outputSchema` and returns its + successful machine-readable result in `structuredContent`. +- The existing serialized JSON `TextContent` remains present for compatibility. + Parsing that text yields the same JSON value as `structuredContent`; the two + representations cannot drift. +- Graft's strict versioned Zod schema remains the validation authority. The MCP + discovery schema is a deterministic bounded projection of that authority, + not a second hand-maintained contract. Every emitted structured result must + satisfy both the strict schema and the advertised projection. +- `graft_edit` validates its complete domain response against the strict output + contract before writing replacement bytes. A response-contract failure must + leave the target file and session-local structural-edit observations + unchanged; the finalized receipt remains runtime-owned and is published only + after the write succeeds. +- Legacy WARP precision matches may still contain optional `identityId` data. + Strict `code_find` and ambiguous `code_show` output validation preserves that + compatibility field until the legacy graph path is explicitly migrated. +- The discovery projection preserves top-level answer fields, scalar types, + enums and discriminants, `_schema` identity, and compact/full receipt posture. + Deep audit objects are summarized rather than recursively inlined merely + because the internal validator knows their entire shape. +- A baseline measurement over the current 47 tools produces 479,464 bytes of + strict generated output schemas, versus 11,319 bytes of input schemas. + Advertising those strict schemas verbatim is therefore prohibited. The + daemon-mode aggregate encoded `outputSchema` budget is at most 65,536 bytes, + and no individual advertised output schema may exceed 8,192 bytes. +- Compact and full receipts, plus summary and full diagnostic bodies, validate + through the same advertised tool schema. Receipt/detail policy does not + require separate public tool names. +- `returnedBytes` continues to mean the exact canonical JSON payload encoded in + compatibility text. It does not silently change to count JSON-RPC framing or + both equivalent MCP representations. +- Tests use the installed MCP SDK client to prove `tools/list` discovery, + structured-result validation, strict-schema validation, text/structured + equivalence, and schema-size budgets. Playback records real client rendering + separately; this slice does not claim that every host will display or meter + structured results identically. + +### Slice 5: capability discovery + +- A compact `capabilities` MCP tool groups currently registered public tools + into the conceptual families `session`, `workspace`, `read`, `code`, + `history`, `review`, and `diagnostic`. +- The default response is a summary object with `projection: "summary"`, + `reason: "CAPABILITY_SUMMARY"`, `discoveryBasis: "registered_surface"`, the + active `sessionMode`, `registeredToolCount`, and all seven families in the + fixed order above. Each family contains only `family`, `openingCall`, + `guidance`, and `toolCount`; it does not dump tool descriptions or internal + capability fields. +- Supplying one explicit `family` returns `projection: "family_detail"` and + `reason: "CAPABILITY_FAMILY_DETAIL"`. The selected `family`, `openingCall`, + `guidance`, `toolCount`, and `tools` remain flat top-level fields so bounded + MCP discovery preserves their scalar contracts. The tool array contains + canonical names and descriptions derived from the capability registry. + Arbitrary multi-family include lists are out of scope. +- The complete compatibility response, including its compact receipt, is at + most 2,048 UTF-8 bytes for the default summary and at most 4,096 UTF-8 bytes + for every selected family detail. Explicit full audit receipts are not part + of these compact-response bounds. +- Discovery is runtime-relative. Repo-local mode advertises exactly the shared + registry; daemon mode advertises the complete registry. The discovery tool + is daemon-always-available and repo-state-optional, so an unbound daemon can + ask what to do without first binding a workspace or cold-observing Git. +- Existing MCP tool names and CLI commands are not renamed or removed. +- The projection is derived from or checked against the authoritative + capability and runtime registries so it cannot silently advertise a missing, + duplicate, or wrong-runtime tool. Registration is not represented as current + authorization: binding, per-call routing, and capability policy may still + obstruct a registered tool. + +The first family contract is intentionally explicit: + +| Family | Opening call | Guidance | Tool membership | +| --- | --- | --- | --- | +| `session` | `capabilities` | Choose one bounded workflow family, then request only that family's detail. | `capabilities`, `knowledge_map`, `set_budget`, `state_load`, `state_save` | +| `workspace` | `workspace_status` | Inspect workspace and binding posture before routed or daemon work. | `workspace_status`, `causal_attach`, `daemon_monitors`, `daemon_repos`, `daemon_sessions`, `daemon_status`, `monitor_nudge`, `monitor_pause`, `monitor_resume`, `monitor_start`, `monitor_stop`, `workspace_authorizations`, `workspace_authorize`, `workspace_bind`, `workspace_list_opened`, `workspace_open`, `workspace_rebind`, `workspace_revoke` | +| `read` | `safe_read` | Start with a policy-bounded read and drill into ranges only when needed. | `safe_read`, `changed_since`, `file_outline`, `read_range` | +| `code` | `code_find` | Locate a symbol, then focus or inspect references before editing. | `code_find`, `code_refs`, `code_show`, `graft_edit` | +| `history` | `graft_since` | Start from a named base, then deepen with diff, log, blame, or difficulty. | `graft_since`, `graft_blame`, `graft_churn`, `graft_diff`, `graft_difficulty`, `graft_exports`, `graft_log`, `graft_map` | +| `review` | `graft_review` | Review a bounded ref range before focused coverage or dead-symbol checks. | `graft_review`, `graft_dead_symbols`, `graft_test_coverage` | +| `diagnostic` | `doctor` | Start with summary health and request full detail only to investigate. | `doctor`, `activity_view`, `causal_status`, `explain`, `run_capture`, `stats` | + +Within each family the opening call is first and remaining tools use +deterministic code-point order. The fourteen daemon-only workspace tools are +filtered from repo-local family detail and counts. All opening calls remain +registered in both runtimes. The result is 34 registered tools in repo-local +mode and 48 in daemon mode after the new tool is registered. + +## Playback questions + +### Human + +- [x] Does opening a repository with hundreds of pre-existing untracked files + describe a baseline rather than invented movement? +- [x] Can I request the old full receipt and full diagnostic views explicitly? +- [x] Is the default doctor result small enough to use at every session start? +- [x] Can an MCP client discover and validate Graft's output shape without a + Graft-specific schema fetch or reverse-engineering response prose? +- [x] Can I discover the intended Graft workflow without reading the full MCP + registry? + +### Agent + +- [x] Is a normal precision-read receipt below 512 encoded bytes while retaining + a stable evidence identifier and exact returned-byte count? +- [x] Does `stats` still expose cumulative burden after compact calls? +- [x] Does default `doctor` name health, workspace, readiness, degradation, and + exactly one next action in less than 2 KiB? +- [x] Does default `activity_view` omit event bodies while retaining bounded + counts, truncation truth, and group summaries? +- [x] Can `receipt: "full"` and `detail: "full"` reproduce the explicit audit + surfaces needed by compatibility clients? +- [x] Does every successful tool call expose one semantically identical value + through MCP structured content and compatibility JSON text? +- [x] Does native output discovery stay bounded instead of adding the complete + internal audit schema to every tool definition? +- [x] Does capability discovery recommend only tools registered in this + runtime, state that registration basis explicitly, and avoid presenting + registration as authorization? + +## Non-goals + +- No Echo, git-warp, structural schema, or provider-selection changes. +- No METHOD backlog, goalpost, retro, dependency-DAG, merge, or release state in + Graft responses. +- No `session_start`, actor declaration, campaign checkpoint, or cross-session + handoff contract in this campaign. +- No durable public job API, query-performance rewrite, background warming, or + wall-clock latency gate. +- No filesystem watcher, hook installation, generic evidence ledger, or GitHub + projection. +- No transactional multi-file patching. +- No public tool renames. +- No hosted schema registry, external schema URI, or new npm schema subpath in + the first native-output slice. MCP discovery is the first agent-facing + publication surface; exact on-demand distribution is deferred to + `SURFACE_on-demand-exact-mcp-output-contracts.md`. +- No `delta` receipt or `receipt_get` store in the first compact-receipt slice. +- No arbitrary `doctor(include: ...)` projection matrix in the first diagnostic + slice. Focused detail remains available through `causal_status`, + `workspace_status`, `stats`, and explicit `detail: "full"`; selective include + contracts require their own strict-schema design. +- No repository-wide cursor retrofit. List/cursor contracts require a separate + bounded pagination design rather than an incidental schema addition here. + +## Test strategy + +Follow RED-GREEN per slice and keep each regression focused on observable +software behavior: + +1. Temp-repo observation tests establish a dirty baseline, repeat it unchanged, + then make a real change and prove only the final comparison is a transition. +2. Receipt unit and MCP integration tests measure canonical JSON byte lengths, + prove compact default/full opt-in, preserve tripwires, and verify cumulative + stats after compact calls. +3. Doctor and activity tests validate both strict schema variants, encoded size + budgets, default omission of detail, full compatibility, and CLI full-mode + behavior. +4. Native-output tests prove output-schema discovery for every registered tool, + strict and advertised validation, text/structured equivalence, aggregate and + per-tool schema budgets, and pre-write domain validation for the governed + exact-edit path. +5. Capability tests prove deterministic family order, registry membership, and + bounded default output. + +Relevant changed-surface validation is `pnpm lint`, `pnpm typecheck`, targeted +unit/playback tests, and the full test command required by the resulting runtime +surface. No timing assertion may depend on ambient machine speed. + +## Slice playback witnesses + +### Slice 1: baseline observation truth + +- A runtime temp repo opens with one staged path, one unstaged path, and 253 + pre-existing untracked paths. Its first and repeated observations preserve + those workspace facts while returning `semanticTransition: null`; adding the + 254th untracked path produces a `snapshot_delta`. +- Restoring a dirty file to its committed bytes produces and persists an + `unknown` `snapshot_delta` with zero remaining dirty paths, including inside + Git's racy-clean timestamp window. +- First and unchanged merge, rebase, and conflict postures remain + `current_state`. Conflict identity changes produce a delta, while unrelated + workspace churn does not impersonate conflict progress. +- Fresh checkout and reset evidence remain inspectable as + `git_transition_evidence` even when the workspace projection is unchanged. +- Unchanged same-second reflog entries remain baseline evidence. Only a changed + reflog record or independently changed head/ref position supports a new Git + transition. +- A checkout followed by an ordinary edit persists the edit as a pure + `snapshot_delta`, with null Git kind, refs, and created-epoch identity. +- Repeating an A-to-B-to-A workspace sequence produces three distinct event + nodes and a linear acyclic `follows` chain. +- New `current_state` observations are not persisted. Legacy graph records + without a basis decode as `legacy_unclassified`, and observation basis + participates in transition event identity. +- The expanded affected constellation passes 11 files and 121 tests, including + repo-local runtime, production-adapter daemon workers, strict output schemas, + causal ontology, layered worldlines, and durable graph history. Lint, + typecheck, and diff hygiene are separate closure gates. + +The exact Slice 1 commit (`da1956f7`) passes the full suite from a clean detached +temporary worktree: 246 files and 1,856 tests. The operator's unrelated +cool-idea work remained outside the isolated witness and outside the commit. + +### Slice 2: compact receipt policy + +- All registered MCP tools, including tools without domain arguments, advertise + and strictly validate the common optional `receipt` control. Missing policy + selects compact; explicit `full` selects the audit projection; invalid values + fail validation. +- Default compact receipts contain exactly `mode`, `receiptId`, `seq`, `reason`, + `latencyMs`, and `returnedBytes`, remain below 512 canonical UTF-8 bytes, and + stabilize `returnedBytes` against the final encoded response. +- Explicit full receipts preserve the previous audit, projection, burden, + budget, compression, and cumulative fields with a `mode: "full"` + discriminator. The complete internal record remains available to metrics and + runtime observability even when the public projection is compact. +- Compact `receiptId` is the invocation trace identity and correlates with both + started and completed runtime-observability events. It does not introduce a + receipt store or retrieval promise. +- Receipt policy crosses the daemon-worker boundary as a required job field; + in-process and offloaded calls therefore use the same default and projection. +- MCP schemas advance to version `2.0.0` with strict compact/full alternatives. + CLI peers request full, project away the MCP-only discriminator, and continue + validating and emitting their version-`1.0.0` legacy receipt contract. +- Compact calls still contribute their exact encoded bytes to cumulative + accounting. `stats` exposes those totals, while tripwire warnings remain + immediate top-level response fields rather than receipt members. +- Focused receipt, schema, stdio, worker, observability, CLI compatibility, and + playback witnesses pass alongside lint, typecheck, and diff hygiene. The exact + Slice 2 closure commit (`cb1958f1`) passes the Docker-isolated full suite: 246 + files and 1,868 tests. +- The exact-commit suite exposed a rounded-compression-ratio width cycle that + focused tests had not triggered. A follow-up regression now preserves exact + `returnedBytes`; when the optional rounded ratio makes a fixed point + mathematically impossible, the public full receipt omits that derived field + while the complete internal audit receipt retains the ratio. + +### Slice 3: summary-first diagnostics + +- Default `doctor` and `activity_view` responses are strict MCP version-2 + summary variants below 2 KiB, including their compact receipts. Explicit + `detail: "full"` reproduces the exhaustive bodies; sludge scans force the full + doctor variant; CLI peers continue to request full detail and validate their + unchanged version-1 contracts. +- Doctor separates owned local-history readiness from structural readiness. It + reports structural readiness as `unknown` / `not_observed` rather than + cold-opening WARP or turning tool authority into invented provider evidence, + and names degradation through a closed evidence-gap vocabulary. +- Activity summaries preserve anchor identity, returned and matching counts, + truncation truth, group summaries, and item-detail availability while + omitting event bodies and the active causal-workspace audit object. +- An adversarial long-ref witness exposed a real budget violation: a legal + 723-character branch produced a 2,919-byte default response. Summary refs and + narratives are now byte-bounded, `headRefTruncated` makes abbreviation + explicit, the exact commit SHA remains present, and full detail preserves the + complete ref. The same witness now encodes to 1,268 bytes. +- A second aggregate witness exercises all four activity kinds plus every + session tripwire and encodes to 1,879 bytes. The summary budget is therefore + enforced at the complete compatibility-response boundary, not inferred from + one independently bounded field. +- The affected compatibility constellation passes 13 files and 129 tests, + including strict output contracts, MCP discovery, runtime observability, + layered worldlines, per-call routing, CLI rendering, and Git-heavy playback. + Lint, typecheck, and diff hygiene also pass. +- The exact Slice 3 commit (`7dec2a9c`) passes the Docker-isolated full suite + from a clean detached worktree: 247 files and 1,873 tests. The Docker test + image build also passes typecheck, and the runtime suite reports zero + failures. + +### Slice 4: MCP-native structured output + +- Every repo-local and daemon tool advertises an MCP-native object-root + `outputSchema`. The schema is derived from the strict versioned Zod contract, + preserving top-level fields, scalar constraints, discriminants, exact + `_schema` identity, and compact/full receipt posture while projecting deep + objects and array members shallowly. +- The current strict schemas encode to 498,341 bytes across 47 tools, with + `doctor` alone accounting for 67,291 bytes. The actual SDK `tools/list` + discovery projections total 50,093 bytes; the largest is `doctor` at 3,109 + bytes. All roots are objects, below the 65,536-byte aggregate and 8,192-byte + per-tool budgets. +- Root-object projection is a correctness adapter as well as a size bound. The + installed SDK cannot directly register the strict `file_outline` root union: + it omits the output schema during discovery and fails invocation. The derived + projection flattens object-union fields deterministically and marks + variant-only fields optional. +- Successful calls derive `structuredContent` by decoding the finalized + canonical compatibility text, then validate that exact value against both + the strict schema and discovery projection. The representations therefore + cannot drift, and strict output-contract violations fail before success. +- The governed exact-edit path separately preflights its domain response before + the filesystem write. A fault-injection regression proves contract rejection + leaves file bytes unchanged; successful calls still build and publish the + finalized receipt after the write. +- Receipt-boundary regressions preserve optional legacy WARP `identityId` data + for both `code_find` and ambiguous `code_show` results. +- Complete generated-schema digests for `diag_doctor` and `diag_activity` are + frozen to `origin/main@c3885dab`; nested MCP-v2 observation-basis fields are + removed before CLI-v1 validation and rendering. +- `returnedBytes` remains the UTF-8 byte count of canonical compatibility text. + It does not count the equivalent structured representation or protocol + framing. MCP errors remain usable as text-only `isError` results, as the + protocol permits. +- In-process API and read-attribution consumers prefer native structured + content and retain text-only compatibility for older peers. Installed-SDK + tests exercise compact/full receipts, every root-union tool, direct + stdio, daemon HTTP/stdio bridging, and child-worker offload. +- The final affected constellation passes 16 files and 176 tests, including all + 47 emitted tool contracts, real SDK discovery and client validation, + receipt byte accounting, summary/full diagnostics, policy refusals, API + consumers, worker offload, workspace routing, and both stdio runtimes. Lint, + typecheck and diff hygiene also pass. A later full-branch Code Lawyer review + found compatibility and mutation-boundary defects that are recorded and + repaired below. +- The first exact-commit run exposed two stale repository witnesses that focused + tests had missed: an old receipt playback supplied an invalid `safe_read` + body, and the generated backlog dependency graph omitted the newly filed + on-demand-schema idea. The strict runtime validator correctly rejected the + invalid fixture; both witnesses were repaired without weakening the product + contract. +- The exact repair commit (`dccd2d13`) passes a clean detached Docker build, + explicit image typecheck, and the full isolated suite: 249 files and 1,889 + tests with zero failures. The only child change before this witness was the + generated SVG companion to the already-validated dependency-DAG source. That + exact child (`2f6d843a`) changes only the SVG, passes the two relevant files + and 15 tests from a clean detached worktree, and has a clean diff. + +### Slice 5: capability discovery + +- The new `capabilities` tool is registered in both runtimes, remains available + before daemon workspace binding, and does not require repository-state + observation. Repo-local discovery reports 34 registered tools; daemon + discovery reports 48. +- Default discovery returns the seven fixed workflow families without per-tool + descriptions. One explicit family returns only that family's tool names and + capability-registry descriptions, with the opening call first and all + remaining names in deterministic code-point order. +- Every registered MCP tool belongs to exactly one family. The fourteen + daemon-only tools exactly match the daemon registry and are absent from + repo-local detail. Contract tests compare the product taxonomy with the real + shared, daemon-only, and combined runtime registries. +- Every result states `discoveryBasis: "registered_surface"`. Documentation and + tests preserve the distinction between installed registration and current + authorization: workspace binding, explicit routing, and capability policy + may still obstruct a registered tool. +- Complete compact responses measure 1,393 bytes for repo-local summary and + 1,390 bytes for daemon summary. The largest repo-local family detail is 1,233 + bytes; the largest daemon detail is 2,111 bytes. All remain below the 2 KiB + summary and 4 KiB detail contracts. +- With the 48th tool included, strict generated MCP schemas total 511,333 + bytes, with `doctor` largest at 67,291 bytes. Advertised discovery schemas + total 52,446 bytes, with `doctor` largest at 3,114 bytes. `capabilities` + contributes a 12,992-byte strict schema and a 2,118-byte discovery schema; + the advertised surface remains below the 65,536-byte aggregate and + 8,192-byte per-tool budgets. +- The installed MCP SDK proves repo-local summary/detail invocation and unbound + daemon detail invocation, strict output validation, bounded advertised + schemas, and exact equality between structured content and compatibility JSON + text. The affected compatibility sweep passes 43 files and 341 tests. +- The live-worktree full suite passed 250 files and 1,901 tests but correctly + failed its dependency-DAG witness because an unrelated operator-owned + untracked backlog card was present in the Docker build context. That result + is recorded as contaminated rather than called green or repaired by absorbing + the operator's work. +- The exact Slice 5 commit (`8af1aa3a`) passes a clean detached Docker build, + image typecheck, image lint, diff hygiene, and the complete isolated suite: + 251 files and 1,902 tests with zero failures. An early local review missed + issues later found by the complete pre-PR Code Lawyer pass; the independent + affected-test sweep itself reported zero failures. + +## Post-review repair witnesses + +The complete pre-PR self-review found three P1, three P2, and one P4 issue. The +campaign repaired the concrete defects before publication: + +| Finding | Repair commit | Executable witness | +| --- | --- | --- | +| Repeated transition identity could cycle the history graph | `5948e250` | A-to-B-to-A produces distinct occurrence IDs and an acyclic `follows` chain. | +| Snapshot deltas inherited stale Git evidence | `1e577e3a` | Checkout then ordinary edit persists null Git kind, refs, and created epoch. | +| Legacy WARP `identityId` failed strict responses | `3c9b0344` | Receipt-boundary `code_find` and ambiguous `code_show` cases accept the optional field. | +| Same-second baseline reflog looked fresh | `32137c12` | Unchanged baseline entries produce no transition; changed reflogs still do. | +| Diagnostic CLI v1 changed under its old version | `f74d212e` | Full generated schemas match frozen `origin/main@c3885dab` digests and nested payloads project exactly. | +| Edit response validation followed the file write | `4561d5a0` | Injected domain-contract failure leaves file bytes and edit observations unchanged. | +| Public agent-flow Markdown was inconsistent/incomplete | `7f73d011` | README, MCP, and setup flows use consistent steps and publish capability onboarding. | +| New context method was absent from guard fixtures | `34c42b7d` | Unit and playback construction-contract fixtures pass. | +| Daemon status sent v2-only receipt controls to pre-v2 peers | `55d21c14` | Schema-negotiation unit coverage omits the field for old inputs and preserves full receipts for v2; the current-daemon CLI integration passes. | + +At `34c42b7d`, typecheck, lint, branch diff hygiene, and the complete default +suite pass: 253 files and 1,910 tests. Broader two-phase semantics for daemon +control-plane mutations remain explicit follow-up in +`CLEAN_mutating-tools-need-prepared-response-contract.md`; the active campaign +does not represent that future work as already shipped. + +The post-publication `55d21c14` repair separately passes typecheck, lint, diff +hygiene, and its two-file / three-test compatibility constellation. PR CI on +that head remains the full-suite merge gate. + +## Slice and commit plan + +1. `fix(mcp): distinguish baseline state from observed transitions` +2. `feat(mcp): default to compact response receipts` +3. `feat(mcp): make diagnostics summary-first` +4. `feat(mcp): expose bounded structured tool outputs` +5. `feat(mcp): expose compact capability discovery` +6. Retro, drift reconciliation, backlog follow-ups, and validation witness. + +Each implementation slice remains a focused commit. The branch is pushed after +every commit. The local retro is completed, committed, and validated before any +pull request is opened. diff --git a/docs/design/WARP_local-causal-history-graph-schema.md b/docs/design/WARP_local-causal-history-graph-schema.md index 4bef062a..a6ab1299 100644 --- a/docs/design/WARP_local-causal-history-graph-schema.md +++ b/docs/design/WARP_local-causal-history-graph-schema.md @@ -482,6 +482,7 @@ Event kinds: `transition` event required subtype properties: - `semanticKind` - `authority` +- `observationBasis` - `summary` - `phase?` - `transitionKind?` @@ -489,6 +490,14 @@ Event kinds: - `toRef?` - `createdCheckoutEpochId?` +Only new observations based on `snapshot_delta` or +`git_transition_evidence` become durable transition events. A live +`current_state` signal describes an authoritative endpoint posture and is not +persisted as historical movement. Pre-basis transition records decode as +`legacy_unclassified`: they remain inspectable without being silently upgraded +to either current-state authority or observed movement. New writers never emit +that compatibility-only basis. + `handoff` event required subtype properties: - `handoffKind` - `fromActorId?` @@ -643,6 +652,7 @@ Graph-backed mapping: - Becomes: - `semanticKind` - `authority` + - `observationBasis` - `phase` - `transitionKind` - `fromRef` diff --git a/docs/design/versioned-json-output-schemas.md b/docs/design/versioned-json-output-schemas.md index daa94da7..6247aa06 100644 --- a/docs/design/versioned-json-output-schemas.md +++ b/docs/design/versioned-json-output-schemas.md @@ -61,6 +61,20 @@ CLI JSON responses should do the same with: CLI commands should keep human text output by default and add `--json` for machine-readable automation. +## Current compatibility evolution + +This cycle established version `1.0.0` for both output families. The later +agent working-set control-plane campaign intentionally advances every MCP +output schema to `2.0.0` because compact receipts become the MCP default and +the receipt variants gain an explicit `mode` discriminator. CLI output schemas +remain at `1.0.0`: CLI peers request the full MCP variant and remove the +MCP-only discriminator before validating and rendering the legacy CLI shape. + +The current contract and migration requirements are defined in +`docs/design/SURFACE_agent-working-set-control-plane.md`. The version-1 text +above remains the historical design witness for Cycle 0035 rather than a claim +that MCP can never advance. + Tests should validate representative emitted payloads against the declared schemas, not only against ad hoc field assertions. diff --git a/docs/design/warp-lawpack-v0/README.md b/docs/design/warp-lawpack-v0/README.md new file mode 100644 index 00000000..f4f8ae90 --- /dev/null +++ b/docs/design/warp-lawpack-v0/README.md @@ -0,0 +1,50 @@ +# WARP Lawpack v0 — Research Spike + +> **Lowering-validated research spike. Not a production lawpack.** +> Validated through Core and Target IR against Edict `main` (`9f1a11e0358caeb03339f0035333f8a49a2a814a`) **and, separately, against Echo's actual pinned revision** (`c75c3f550d049485ba00eae0dc272c6dd6aca11f`, via a mechanically-derived `intent`-keyword variant of the same source). +> Against the pinned revision, the action was also driven through **real WASM invocation of Echo's checked, unmodified `lowerer.echo-dpo.component.wasm`** — which returned a genuine typed refusal (`UnsupportedSemantics`), not a crash. Bundle assembly, full runtime execution, persistence semantics, and stale-basis enforcement remain unproven. +> Edict `main` and Edict `c75c3f5` are two internally coherent compiler epochs (`action`/`.actions` vs. `intent`/`.intents`) — this is a confirmed breaking compiler-epoch boundary between them, not a defect in either revision on its own terms. Echo's checked, digest-locked provider package was used exactly as-is throughout; nothing in Echo was regenerated or modified. +> **Follow-up finding: the refusal is not about the vocabulary epoch or action shape.** A single-field action with the exact same structural shape as the (accepted) original fixture, differing only in names, is refused identically. The checked lowerer component currently accepts exactly one action identity — the original fixture it was built and reviewed against — not a general class of `target.replace`-shaped actions. See `shape-isolation-evidence.md`. + +## What this is + +A first attempt at expressing a real Graft WARP operation — recording one structural symbol-change fact (added/changed/removed, with a prior-version basis field and a new-signature digest) — as a real `.edict` action, and proving it actually compiles and lowers through Edict's real compiler rather than just looking syntactically plausible. + +This came out of investigating the long-term architecture where Echo hosts Graft's structural-history data model and Graft uses Edict-compiled, lawfully-admitted operations to mutate it (see the Profunctor Plan constitution and the Echo/Edict repos for the surrounding context — not duplicated here). + +## What's in this directory + +| File | What it is | +|---|---| +| `record-symbol-change.edict` | The canonical Edict source (current `action` syntax): one action, `graft.warp@1 / recordSymbolChange`. | +| `record-symbol-change.c75-pinned.edict` | The mechanically-derived `intent`-keyword variant used for the pinned-epoch and shape-isolation runs, checked in for inspection (the harnesses re-derive it from the canonical source at build time; this file is a byte-for-byte record of that derivation's output, SHA-256-verified to match). | +| `authority-facts.json` | Human-readable record of the compiler-context facts used by `harness.rs`. **Documentation only** — the harness builds these facts directly via `CompilerContext` builder calls, it does not load this file. | +| `harness.rs` | The standalone Rust harness that drives the source through `parse_module` → `compile_to_core` → `lower_with_builtin_lowerer`, plus four negative controls. Depends on `edict-syntax` as a local path dependency. | +| `lowering-evidence.md` | The full write-up: narrow claim statement, setup, the dependency-drift finding, positive result, negative-control table, success-criteria checklist, and what remains unproven. | +| `evidence/edict-main-lowering-output.txt` | Full raw stdout from the passing run against Edict `main`. | +| `evidence/echo-pinned-edict-build-failure.txt` | Full raw compiler output from attempting to build the `main`-targeting harness against Echo's pinned Edict revision instead — confirms the epoch boundary, not a claim that Echo's own repository fails to build. | +| `harness-c75-pinned.rs` | The pinned-epoch harness: mechanically derives the `intent`-keyword variant, compiles it under Edict `c75c3f5` (local worktree, not `main`), and drives it through real WASM invocation of Echo's checked, unmodified lowerer component. | +| `pinned-epoch-evidence.md` | The write-up for the pinned-epoch run: the real typed refusal returned by Echo's WASM component, and the cross-version Core/Target-IR structural comparison against the `main` run. | +| `evidence/edict-c75-pinned-run-output.txt` | Full raw stdout from the pinned-epoch run. | +| `harness-c75-shape-isolation.rs` | Three-variant harness isolating whether the checked component's refusal is about epoch, shape, or identity — includes a positive control (the exact original fixture) and a single-field Graft action. | +| `shape-isolation-evidence.md` | The write-up: the refusal is about neither epoch nor shape — the checked component accepts exactly one action identity (the original fixture), full stop. Includes an honestly-recorded harness bug found and fixed mid-experiment. | +| `evidence/edict-c75-shape-isolation-output.txt` | Full raw stdout from the shape-isolation run (post-fix). | +| `teaching-a-new-action-research.md` | Research-only note (no Echo changes made): traces the refusal to its exact source in `echo-edict-provider-lowerer`/`-verifier`'s hardcoded Rust constants, and documents what changing it would actually require. | + +## Reproducing these results + +Each `harness*.rs` file is a snapshot of exactly what was run, not a self-contained crate — there is no checked-in `Cargo.toml`. To rebuild one: create a new binary crate, put the harness as `src/main.rs`, copy `record-symbol-change.edict` alongside it (all three harnesses `include_str!` that file directly), and add `edict-syntax` as a dependency (a local `path` dependency against a checkout of `~/git/edict` is simplest — `main` for `harness.rs`, a worktree pinned at `c75c3f550d049485ba00eae0dc272c6dd6aca11f` for the other two, which also need `edict-provider-schema` and `edict-provider-host-wasmtime` at that same revision). The two `c75`-pinned harnesses additionally `include_bytes!` several of Echo's checked provider-package files via absolute paths under `~/git/echo/schemas/edict-provider/...` — a local Echo checkout at that path is required. All three harnesses were independently rebuilt this way and reproduced byte-for-byte identical output to what's checked into `evidence/` as part of verifying this design packet's accuracy. + +## Status, precisely + +Proven: the action resolves and lowers to real Core IR and real Target IR under **both** Edict `main` and Edict `c75c3f5` (Echo's actual pin), with negative controls confirming the custom facts genuinely participate in compiler resolution. Under the pinned epoch, real invocation of Echo's checked, unmodified WASM lowerer component was performed. The `intents`→`actions` rename is confirmed to be a pure terminology change with no semantic drift. **The component's refusal of new actions is confirmed to be identity-scoped, not epoch- or shape-related** — it currently accepts only the exact original fixture it was built and reviewed against. + +Not proven: bundle assembly, full runtime execution (`TrustedRuntimeHost`/scheduler/receipts), persistence semantics, or stale-basis enforcement. + +**Research-only finding (no Echo changes made):** the checked lowerer/verifier components' narrowness is not a data/configuration limit — it's literal hardcoded Rust `if`/`==` control flow (`echo-edict-provider-lowerer`/`-verifier`'s `src/lib.rs`) recognizing exactly one fixture's coordinate, type shapes, action name, and budget. Teaching it a new action requires real source changes to those crates (either a second hand-written recognizer branch, or genuinely generalizing the lowerer into a real interpreter — neither of which the crate's current design supports without one of those changes), a WASM recompile via the existing `xtask` pipeline, and the same `echo-wesley-gen` regeneration/re-verification as the vocabulary migration. See `teaching-a-new-action-research.md`. + +## Next steps + +Given both findings, neither the Echo `intent`→`action` migration nor "teach the component a new action" is a quick or opportunistic task — both are real, separately-scoped engineering projects that happen to touch the same digest-locked package, and doing them together may make sense precisely because of that overlap. Nothing here recommends starting either yet; this design packet's job was to establish exactly what each would cost before that decision gets made. + +See `lowering-evidence.md`, `pinned-epoch-evidence.md`, `shape-isolation-evidence.md`, and `teaching-a-new-action-research.md` for full detail, in that order. diff --git a/docs/design/warp-lawpack-v0/authority-facts.json b/docs/design/warp-lawpack-v0/authority-facts.json new file mode 100644 index 00000000..17ddf505 --- /dev/null +++ b/docs/design/warp-lawpack-v0/authority-facts.json @@ -0,0 +1,30 @@ +{ + "_note": "This file documents the compiler-context facts used by harness.rs. It is a human-readable record, not a file the harness loads at runtime -- harness.rs constructs these facts directly via edict_syntax::CompilerContext builder calls (with_operation_profile, with_operation_profile_write_classes, with_effect_write_class, with_budget). See lowering-evidence.md for the negative-control results proving these three facts (plus the effect write class) are the ones the compiler actually resolves against.", + "operationProfiles": { + "graft.structuralWrite": "continuum.profile.write/v1" + }, + "operationProfileWriteClasses": { + "graft.structuralWrite": ["Replace"] + }, + "effectWriteClasses": { + "target.replace": "Replace" + }, + "budgets": { + "graft.tinyBudget": { + "maxSteps": 8, + "maxAllocatedBytes": 1024, + "maxOutputBytes": 256 + } + }, + "targetIrLoweringFacts": { + "targetProfileCoordinate": "echo.dpo@1", + "targetProfileDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "_digestNote": "Placeholder digest satisfying Edict's is_sha256_review_digest shape check only (sha256: prefix + 64 hex chars). Not a real content digest of anything. Edict's built-in lowerer refuses an undigested (None) target_profile with TargetLoweringFailureKind::UndigestedTargetProfile -- confirmed during this experiment.", + "targetIrDomain": "echo.span-ir/v1", + "operationProfiles": ["continuum.profile.write/v1"], + "obstructionCoordinates": ["rejected"], + "effectLowerings": [ + { "effect": "target.replace", "targetIntrinsic": "echo.dpo@1.replace" } + ] + } +} diff --git a/docs/design/warp-lawpack-v0/evidence/echo-pinned-edict-build-failure.txt b/docs/design/warp-lawpack-v0/evidence/echo-pinned-edict-build-failure.txt new file mode 100644 index 00000000..08f07281 --- /dev/null +++ b/docs/design/warp-lawpack-v0/evidence/echo-pinned-edict-build-failure.txt @@ -0,0 +1,31 @@ +Context: this is NOT a build failure of the Echo repository itself. Echo's own +test crate (tests/edict-provider-host-v1/) was never rebuilt against this pin +during this experiment. This is the output of building the standalone +`warp-lawpack-experiment` harness (harness.rs in this design packet) when its +Cargo.toml pointed at Echo's actual pinned Edict commit +(c75c3f550d049485ba00eae0dc272c6dd6aca11f) instead of Edict's current main. + +Precise claim: "the current lowering harness does not build against Echo's +pinned Edict revision." Nothing here demonstrates that Echo's own build or +test suite fails at that pin -- that is a separate, not-yet-performed check. + +--- raw compiler output --- + +error[E0609]: no field `actions` on type `TargetIrArtifact` + --> src/main.rs:76:61 + | +76 | println!("action names: {:?}", artifact.actions.keys().collect::>()); + | ^^^^^^^ unknown field + | + = note: available fields are: `domain`, `target_profile`, `source_core_coordinate`, `intents` + +error[E0609]: no field `actions` on type `TargetIrArtifact` + --> src/main.rs:77:53 + | +77 | for (name, action) in &artifact.actions { + | ^^^^^^^ unknown field + | + = note: available fields are: `domain`, `target_profile`, `source_core_coordinate`, `intents` + +For more information about this error, try `rustc --explain E0609`. +error: could not compile `warp-lawpack-experiment` (bin "warp-lawpack-experiment") due to 2 previous errors diff --git a/docs/design/warp-lawpack-v0/evidence/edict-c75-pinned-run-output.txt b/docs/design/warp-lawpack-v0/evidence/edict-c75-pinned-run-output.txt new file mode 100644 index 00000000..371deb5a --- /dev/null +++ b/docs/design/warp-lawpack-v0/evidence/edict-c75-pinned-run-output.txt @@ -0,0 +1,299 @@ + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s + Running `target/debug/warp-lawpack-c75-experiment` +=== PINNED EDICT REVISION: c75c3f550d049485ba00eae0dc272c6dd6aca11f (local worktree) === + +=== STEP 1: mechanical dialect derivation === +Canonical (action) source SHA-256: 9c0dcb789f8c4574caf444666a445ba27fd47d2758d9e4b771ff47b00cb0a6ed +Derived (intent) source SHA-256: 8cc1259a045df7ce3b293ef31197e618416b478ef333ca84142181e3e60ed0bc +Derived source: +package graft.warp@1; + +type SymbolChangeInput = { + symbolId: String, + priorVersionId: String, + changeKind: String, + signatureDigest: String, +}; + +type SymbolReceipt = { + symbolId: String, + newVersionId: String, +}; + +type SymbolChangeOutput = { + symbolId: String, + newVersionId: String, +}; + +intent recordSymbolChange(input: SymbolChangeInput) returns SymbolChangeOutput + profile graft.structuralWrite + basis none + budget <= graft.tinyBudget +{ + let receipt: SymbolReceipt = target.replace(input.symbolId) + else { rejected(reason) => graft.SymbolChangeObstruction.BasisConflict }; + + return { symbolId: input.symbolId, newVersionId: receipt.newVersionId }; +} + + +=== STEP 2: parse + compile_to_core at c75 === +compile_to_core: OK +Core module (debug): +CoreModule { + api_version: "edict.core/v1", + coordinate: "graft.warp@1", + imports: [], + types: { + "SymbolChangeInput": Record { + fields: { + "changeKind": "graft.warp@1.SymbolChangeInput.changeKind", + "priorVersionId": "graft.warp@1.SymbolChangeInput.priorVersionId", + "signatureDigest": "graft.warp@1.SymbolChangeInput.signatureDigest", + "symbolId": "graft.warp@1.SymbolChangeInput.symbolId", + }, + }, + "SymbolChangeInput.changeKind": String { + max: 16, + canonical: "raw-utf8", + }, + "SymbolChangeInput.priorVersionId": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolChangeInput.signatureDigest": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolChangeInput.symbolId": String { + max: 128, + canonical: "raw-utf8", + }, + "SymbolChangeOutput": Record { + fields: { + "newVersionId": "graft.warp@1.SymbolChangeOutput.newVersionId", + "symbolId": "graft.warp@1.SymbolChangeOutput.symbolId", + }, + }, + "SymbolChangeOutput.newVersionId": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolChangeOutput.symbolId": String { + max: 128, + canonical: "raw-utf8", + }, + "SymbolReceipt": Record { + fields: { + "newVersionId": "graft.warp@1.SymbolReceipt.newVersionId", + "symbolId": "graft.warp@1.SymbolReceipt.symbolId", + }, + }, + "SymbolReceipt.newVersionId": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolReceipt.symbolId": String { + max: 128, + canonical: "raw-utf8", + }, + }, + intents: { + "recordSymbolChange": CoreIntent { + input: "graft.warp@1.SymbolChangeInput", + output: "graft.warp@1.SymbolChangeOutput", + required_operation_profile: "continuum.profile.write/v1", + input_constraints: [], + core_evaluation_budget: CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + body: CoreBlock { + locals: [ + LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + LocalRef { + id: "obstruction.0", + alpha_name: "$obstruction0", + ty: "target.replace.rejected", + }, + ], + nodes: [ + Effect { + binding: LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + effect: "target.replace", + input: Field { + base: Local { + reference: LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + }, + field: "symbolId", + }, + obstruction_map: { + "rejected": CoreObstructionArm { + binder: LocalRef { + id: "obstruction.0", + alpha_name: "$obstruction0", + ty: "target.replace.rejected", + }, + value: Call { + callee: "graft.SymbolChangeObstruction.BasisConflict", + type_args: [], + args: [], + }, + }, + }, + }, + ], + result: Record { + fields: { + "newVersionId": Field { + base: Local { + reference: LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + }, + field: "newVersionId", + }, + "symbolId": Field { + base: Local { + reference: LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + }, + field: "symbolId", + }, + }, + }, + }, + }, + }, + required_core_capabilities: [], +} + +=== STEP 3: native oracle lowering (built-in Rust lowerer, c75) === +Native oracle Target IR (debug): +TargetIrArtifact { + domain: "echo.span-ir/v1", + target_profile: ResourceRef { + coordinate: "echo.dpo@1", + digest: Some( + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + ), + }, + source_core_coordinate: "graft.warp@1", + intents: { + "recordSymbolChange": TargetIrIntent { + operation_profile: "continuum.profile.write/v1", + input_constraints: [], + core_evaluation_budget: CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + requirements: [], + steps: [ + TargetIrStep { + id: "recordSymbolChange.step.0", + binding: LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + effect: "target.replace", + target_intrinsic: "echo.dpo@1.replace", + input: Field { + base: Local { + reference: LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + }, + field: "symbolId", + }, + obstruction_failures: [ + "rejected", + ], + obstruction_arms: { + "rejected": CoreObstructionArm { + binder: LocalRef { + id: "obstruction.0", + alpha_name: "$obstruction0", + ty: "target.replace.rejected", + }, + value: Call { + callee: "graft.SymbolChangeObstruction.BasisConflict", + type_args: [], + args: [], + }, + }, + }, + }, + ], + result: Record { + fields: { + "newVersionId": Field { + base: Local { + reference: LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + }, + field: "newVersionId", + }, + "symbolId": Field { + base: Local { + reference: LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + }, + field: "symbolId", + }, + }, + }, + }, + }, +} + +=== STEP 4: REAL WASM lowerer-component invocation (Echo's checked lowerer.echo-dpo.component.wasm, unmodified) === +validate_provider_lowering_request: OK +invoke_lowerer: call succeeded (WASM component executed) +Component returned a REFUSAL (this is the outcome, not a crash): +ProviderRefusal { + kind: UnsupportedSemantics, + subject: Some( + "graft.warp@1", + ), + diagnostics: [ + ProviderDiagnostic { + code: "echo.provider.unsupported-semantics", + severity: Error, + message: "the supplied semantics are outside the exact first Echo lowering closure", + repair: None, + }, + ], +} diff --git a/docs/design/warp-lawpack-v0/evidence/edict-c75-shape-isolation-output.txt b/docs/design/warp-lawpack-v0/evidence/edict-c75-shape-isolation-output.txt new file mode 100644 index 00000000..c1dce5f7 --- /dev/null +++ b/docs/design/warp-lawpack-v0/evidence/edict-c75-shape-isolation-output.txt @@ -0,0 +1,989 @@ +warning: function `native_oracle_target_ir` is never used + --> src/main.rs:383:4 + | +383 | fn native_oracle_target_ir(core: &CoreModule) -> edict_syntax::TargetIrArtifact { + | ^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: `warp-lawpack-c75-experiment` (bin "warp-lawpack-c75-experiment") generated 1 warning + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s + Running `target/debug/warp-lawpack-c75-experiment` +=== PINNED EDICT REVISION: c75c3f550d049485ba00eae0dc272c6dd6aca11f (local worktree) === +Goal: isolate whether Echo's real checked WASM lowerer component's UnsupportedSemantics +refusal (seen for the 4-field recordSymbolChange action) is about action SHAPE or about +something else -- by testing 3 variants under the identical pinned epoch and pipeline. + + +######## VARIANT: 1. ORIGINAL FIXTURE (positive control, unmodified) ######## +Source: +package a.b@1; + +type Input = { id: String, }; + +type Receipt = { id: String, }; + +type Output = { id: String, }; + +intent t(input: Input) returns Output + profile p.effectful + basis none + budget <= p.tiny { + let receipt: Receipt = target.replace(input.id) + else { rejected(reason) => domain.WriteRejected }; + return { id: input.id }; +} + +compile_to_core: OK +validate_provider_lowering_request: OK +RESULT: REAL COMPONENT ACCEPTED AND RETURNED A LOWERING RESPONSE: +ProviderLoweringSuccess { + outputs: [ + ProviderLoweringOutputArtifact { + role: "target-ir.echo-dpo", + kind: TargetIr, + artifact: ProviderArtifact { + domain: "edict.target-ir.artifact/v1", + bytes: [ + 165, + 100, + 107, + 105, + 110, + 100, + 112, + 116, + 97, + 114, + 103, + 101, + 116, + 73, + 114, + 65, + 114, + 116, + 105, + 102, + 97, + 99, + 116, + 102, + 100, + 111, + 109, + 97, + 105, + 110, + 111, + 101, + 99, + 104, + 111, + 46, + 115, + 112, + 97, + 110, + 45, + 105, + 114, + 47, + 118, + 49, + 103, + 105, + 110, + 116, + 101, + 110, + 116, + 115, + 161, + 97, + 116, + 166, + 101, + 115, + 116, + 101, + 112, + 115, + 129, + 167, + 98, + 105, + 100, + 104, + 116, + 46, + 115, + 116, + 101, + 112, + 46, + 48, + 101, + 105, + 110, + 112, + 117, + 116, + 163, + 100, + 98, + 97, + 115, + 101, + 162, + 99, + 114, + 101, + 102, + 163, + 98, + 105, + 100, + 101, + 97, + 114, + 103, + 46, + 48, + 100, + 116, + 121, + 112, + 101, + 107, + 97, + 46, + 98, + 64, + 49, + 46, + 73, + 110, + 112, + 117, + 116, + 105, + 97, + 108, + 112, + 104, + 97, + 78, + 97, + 109, + 101, + 101, + 36, + 97, + 114, + 103, + 48, + 100, + 107, + 105, + 110, + 100, + 101, + 108, + 111, + 99, + 97, + 108, + 100, + 107, + 105, + 110, + 100, + 101, + 102, + 105, + 101, + 108, + 100, + 101, + 102, + 105, + 101, + 108, + 100, + 98, + 105, + 100, + 102, + 101, + 102, + 102, + 101, + 99, + 116, + 110, + 116, + 97, + 114, + 103, + 101, + 116, + 46, + 114, + 101, + 112, + 108, + 97, + 99, + 101, + 103, + 98, + 105, + 110, + 100, + 105, + 110, + 103, + 163, + 98, + 105, + 100, + 103, + 108, + 111, + 99, + 97, + 108, + 46, + 48, + 100, + 116, + 121, + 112, + 101, + 109, + 97, + 46, + 98, + 64, + 49, + 46, + 82, + 101, + 99, + 101, + 105, + 112, + 116, + 105, + 97, + 108, + 112, + 104, + 97, + 78, + 97, + 109, + 101, + 103, + 36, + 108, + 111, + 99, + 97, + 108, + 48, + 111, + 111, + 98, + 115, + 116, + 114, + 117, + 99, + 116, + 105, + 111, + 110, + 65, + 114, + 109, + 115, + 161, + 104, + 114, + 101, + 106, + 101, + 99, + 116, + 101, + 100, + 162, + 101, + 118, + 97, + 108, + 117, + 101, + 164, + 100, + 97, + 114, + 103, + 115, + 128, + 100, + 107, + 105, + 110, + 100, + 100, + 99, + 97, + 108, + 108, + 102, + 99, + 97, + 108, + 108, + 101, + 101, + 116, + 100, + 111, + 109, + 97, + 105, + 110, + 46, + 87, + 114, + 105, + 116, + 101, + 82, + 101, + 106, + 101, + 99, + 116, + 101, + 100, + 104, + 116, + 121, + 112, + 101, + 65, + 114, + 103, + 115, + 128, + 102, + 98, + 105, + 110, + 100, + 101, + 114, + 163, + 98, + 105, + 100, + 109, + 111, + 98, + 115, + 116, + 114, + 117, + 99, + 116, + 105, + 111, + 110, + 46, + 48, + 100, + 116, + 121, + 112, + 101, + 119, + 116, + 97, + 114, + 103, + 101, + 116, + 46, + 114, + 101, + 112, + 108, + 97, + 99, + 101, + 46, + 114, + 101, + 106, + 101, + 99, + 116, + 101, + 100, + 105, + 97, + 108, + 112, + 104, + 97, + 78, + 97, + 109, + 101, + 109, + 36, + 111, + 98, + 115, + 116, + 114, + 117, + 99, + 116, + 105, + 111, + 110, + 48, + 111, + 116, + 97, + 114, + 103, + 101, + 116, + 73, + 110, + 116, + 114, + 105, + 110, + 115, + 105, + 99, + 114, + 101, + 99, + 104, + 111, + 46, + 100, + 112, + 111, + 64, + 49, + 46, + 114, + 101, + 112, + 108, + 97, + 99, + 101, + 115, + 111, + 98, + 115, + 116, + 114, + 117, + 99, + 116, + 105, + 111, + 110, + 70, + 97, + 105, + 108, + 117, + 114, + 101, + 115, + 129, + 104, + 114, + 101, + 106, + 101, + 99, + 116, + 101, + 100, + 102, + 114, + 101, + 115, + 117, + 108, + 116, + 162, + 100, + 107, + 105, + 110, + 100, + 102, + 114, + 101, + 99, + 111, + 114, + 100, + 102, + 102, + 105, + 101, + 108, + 100, + 115, + 161, + 98, + 105, + 100, + 163, + 100, + 98, + 97, + 115, + 101, + 162, + 99, + 114, + 101, + 102, + 163, + 98, + 105, + 100, + 101, + 97, + 114, + 103, + 46, + 48, + 100, + 116, + 121, + 112, + 101, + 107, + 97, + 46, + 98, + 64, + 49, + 46, + 73, + 110, + 112, + 117, + 116, + 105, + 97, + 108, + 112, + 104, + 97, + 78, + 97, + 109, + 101, + 101, + 36, + 97, + 114, + 103, + 48, + 100, + 107, + 105, + 110, + 100, + 101, + 108, + 111, + 99, + 97, + 108, + 100, + 107, + 105, + 110, + 100, + 101, + 102, + 105, + 101, + 108, + 100, + 101, + 102, + 105, + 101, + 108, + 100, + 98, + 105, + 100, + 108, + 114, + 101, + 113, + 117, + 105, + 114, + 101, + 109, + 101, + 110, + 116, + 115, + 128, + 112, + 105, + 110, + 112, + 117, + 116, + 67, + 111, + 110, + 115, + 116, + 114, + 97, + 105, + 110, + 116, + 115, + 128, + 112, + 111, + 112, + 101, + 114, + 97, + 116, + 105, + 111, + 110, + 80, + 114, + 111, + 102, + 105, + 108, + 101, + 120, + 26, + 99, + 111, + 110, + 116, + 105, + 110, + 117, + 117, + 109, + 46, + 112, + 114, + 111, + 102, + 105, + 108, + 101, + 46, + 119, + 114, + 105, + 116, + 101, + 47, + 118, + 49, + 116, + 99, + 111, + 114, + 101, + 69, + 118, + 97, + 108, + 117, + 97, + 116, + 105, + 111, + 110, + 66, + 117, + 100, + 103, + 101, + 116, + 163, + 104, + 109, + 97, + 120, + 83, + 116, + 101, + 112, + 115, + 8, + 110, + 109, + 97, + 120, + 79, + 117, + 116, + 112, + 117, + 116, + 66, + 121, + 116, + 101, + 115, + 25, + 1, + 0, + 113, + 109, + 97, + 120, + 65, + 108, + 108, + 111, + 99, + 97, + 116, + 101, + 100, + 66, + 121, + 116, + 101, + 115, + 25, + 4, + 0, + 109, + 116, + 97, + 114, + 103, + 101, + 116, + 80, + 114, + 111, + 102, + 105, + 108, + 101, + 162, + 98, + 105, + 100, + 106, + 101, + 99, + 104, + 111, + 46, + 100, + 112, + 111, + 64, + 49, + 102, + 100, + 105, + 103, + 101, + 115, + 116, + 130, + 102, + 115, + 104, + 97, + 50, + 53, + 54, + 88, + 32, + 238, + 223, + 123, + 219, + 246, + 254, + 75, + 106, + 64, + 54, + 105, + 95, + 65, + 195, + 220, + 10, + 92, + 105, + 45, + 39, + 226, + 6, + 201, + 212, + 192, + 197, + 234, + 180, + 30, + 47, + 99, + 201, + 116, + 115, + 111, + 117, + 114, + 99, + 101, + 67, + 111, + 114, + 101, + 67, + 111, + 111, + 114, + 100, + 105, + 110, + 97, + 116, + 101, + 101, + 97, + 46, + 98, + 64, + 49, + ], + }, + logical_path: None, + }, + ], + diagnostics: [], +} + + +######## VARIANT: 2. SINGLE-FIELD GRAFT ACTION (shape matches original, names don't) ######## +Source: +package graft.warp.tick@1; + +type TickInput = { symbolId: String, }; + +type TickReceipt = { symbolId: String, }; + +type TickOutput = { symbolId: String, }; + +intent recordTick(input: TickInput) returns TickOutput + profile graft.structuralWrite + basis none + budget <= graft.tinyBudget { + let receipt: TickReceipt = target.replace(input.symbolId) + else { rejected(reason) => graft.TickObstruction.BasisConflict }; + return { symbolId: input.symbolId }; +} + +compile_to_core: OK +validate_provider_lowering_request: OK +RESULT: REAL COMPONENT REFUSED: ProviderRefusal { + kind: UnsupportedSemantics, + subject: Some( + "graft.warp.tick@1", + ), + diagnostics: [ + ProviderDiagnostic { + code: "echo.provider.unsupported-semantics", + severity: Error, + message: "the supplied semantics are outside the exact first Echo lowering closure", + repair: None, + }, + ], +} + + +######## VARIANT: 3. FOUR-FIELD recordSymbolChange (known refused, re-run for comparison) ######## +Source: +package graft.warp@1; + +type SymbolChangeInput = { + symbolId: String, + priorVersionId: String, + changeKind: String, + signatureDigest: String, +}; + +type SymbolReceipt = { + symbolId: String, + newVersionId: String, +}; + +type SymbolChangeOutput = { + symbolId: String, + newVersionId: String, +}; + +intent recordSymbolChange(input: SymbolChangeInput) returns SymbolChangeOutput + profile graft.structuralWrite + basis none + budget <= graft.tinyBudget +{ + let receipt: SymbolReceipt = target.replace(input.symbolId) + else { rejected(reason) => graft.SymbolChangeObstruction.BasisConflict }; + + return { symbolId: input.symbolId, newVersionId: receipt.newVersionId }; +} + +compile_to_core: OK +validate_provider_lowering_request: OK +RESULT: REAL COMPONENT REFUSED: ProviderRefusal { + kind: UnsupportedSemantics, + subject: Some( + "graft.warp@1", + ), + diagnostics: [ + ProviderDiagnostic { + code: "echo.provider.unsupported-semantics", + severity: Error, + message: "the supplied semantics are outside the exact first Echo lowering closure", + repair: None, + }, + ], +} diff --git a/docs/design/warp-lawpack-v0/evidence/edict-main-lowering-output.txt b/docs/design/warp-lawpack-v0/evidence/edict-main-lowering-output.txt new file mode 100644 index 00000000..0cf7cb27 --- /dev/null +++ b/docs/design/warp-lawpack-v0/evidence/edict-main-lowering-output.txt @@ -0,0 +1,224 @@ + Compiling warp-lawpack-experiment v0.0.0 (/private/tmp/claude-501/-Users-james-git-graft/5139853b-54a3-43fa-b022-36039396fc45/scratchpad/warp-lawpack-experiment) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.66s + Running `target/debug/warp-lawpack-experiment` +=== EDICT_REVISION (actual, local path dep to ~/git/edict @ HEAD): 9f1a11e0358caeb03339f0035333f8a49a2a814a === +=== NOTE: Echo's pinned cross-repo test harness is still at c75c3f5 (10 commits behind), which predates the Intent->Action rename === +=== SOURCE SHA256: 9c0dcb789f8c4574caf444666a445ba27fd47d2758d9e4b771ff47b00cb0a6ed === +=== SOURCE === +package graft.warp@1; + +type SymbolChangeInput = { + symbolId: String, + priorVersionId: String, + changeKind: String, + signatureDigest: String, +}; + +type SymbolReceipt = { + symbolId: String, + newVersionId: String, +}; + +type SymbolChangeOutput = { + symbolId: String, + newVersionId: String, +}; + +action recordSymbolChange(input: SymbolChangeInput) returns SymbolChangeOutput + profile graft.structuralWrite + basis none + budget <= graft.tinyBudget +{ + let receipt: SymbolReceipt = target.replace(input.symbolId) + else { rejected(reason) => graft.SymbolChangeObstruction.BasisConflict }; + + return { symbolId: input.symbolId, newVersionId: receipt.newVersionId }; +} + + +=== POSITIVE CASE: full context === +compile_to_core: OK +Core module (debug): CoreModule { + api_version: "edict.core/v1", + coordinate: "graft.warp@1", + imports: [], + types: { + "SymbolChangeInput": Record { + fields: { + "changeKind": "graft.warp@1.SymbolChangeInput.changeKind", + "priorVersionId": "graft.warp@1.SymbolChangeInput.priorVersionId", + "signatureDigest": "graft.warp@1.SymbolChangeInput.signatureDigest", + "symbolId": "graft.warp@1.SymbolChangeInput.symbolId", + }, + }, + "SymbolChangeInput.changeKind": String { + max: 16, + canonical: "raw-utf8", + }, + "SymbolChangeInput.priorVersionId": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolChangeInput.signatureDigest": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolChangeInput.symbolId": String { + max: 128, + canonical: "raw-utf8", + }, + "SymbolChangeOutput": Record { + fields: { + "newVersionId": "graft.warp@1.SymbolChangeOutput.newVersionId", + "symbolId": "graft.warp@1.SymbolChangeOutput.symbolId", + }, + }, + "SymbolChangeOutput.newVersionId": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolChangeOutput.symbolId": String { + max: 128, + canonical: "raw-utf8", + }, + "SymbolReceipt": Record { + fields: { + "newVersionId": "graft.warp@1.SymbolReceipt.newVersionId", + "symbolId": "graft.warp@1.SymbolReceipt.symbolId", + }, + }, + "SymbolReceipt.newVersionId": String { + max: 64, + canonical: "raw-utf8", + }, + "SymbolReceipt.symbolId": String { + max: 128, + canonical: "raw-utf8", + }, + }, + actions: { + "recordSymbolChange": CoreAction { + input: "graft.warp@1.SymbolChangeInput", + output: "graft.warp@1.SymbolChangeOutput", + required_operation_profile: "continuum.profile.write/v1", + input_constraints: [], + core_evaluation_budget: CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + body: CoreBlock { + locals: [ + LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + LocalRef { + id: "obstruction.0", + alpha_name: "$obstruction0", + ty: "target.replace.rejected", + }, + ], + nodes: [ + Effect { + binding: LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + effect: "target.replace", + input: Field { + base: Local { + reference: LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + }, + field: "symbolId", + }, + obstruction_map: { + "rejected": CoreObstructionArm { + binder: LocalRef { + id: "obstruction.0", + alpha_name: "$obstruction0", + ty: "target.replace.rejected", + }, + value: Call { + callee: "graft.SymbolChangeObstruction.BasisConflict", + type_args: [], + args: [], + }, + }, + }, + }, + ], + result: Record { + fields: { + "newVersionId": Field { + base: Local { + reference: LocalRef { + id: "local.0", + alpha_name: "$local0", + ty: "graft.warp@1.SymbolReceipt", + }, + }, + field: "newVersionId", + }, + "symbolId": Field { + base: Local { + reference: LocalRef { + id: "arg.0", + alpha_name: "$arg0", + ty: "graft.warp@1.SymbolChangeInput", + }, + }, + field: "symbolId", + }, + }, + }, + }, + }, + }, + required_core_capabilities: [], +} + +lower_to_target_ir status: Lowered +lower_to_target_ir failures: [] + +=== TARGET IR ARTIFACT === +domain: echo.span-ir/v1 +target_profile: ResourceRef { coordinate: "echo.dpo@1", digest: Some("sha256:0000000000000000000000000000000000000000000000000000000000000000") } +source_core_coordinate: graft.warp@1 +action names: ["recordSymbolChange"] + +--- action `recordSymbolChange` --- +operation_profile: continuum.profile.write/v1 +core_evaluation_budget: CoreBudget { max_steps: 8, max_allocated_bytes: 1024, max_output_bytes: 256 } +steps: 1 + step `recordSymbolChange.step.0`: effect=target.replace target_intrinsic=echo.dpo@1.replace obstruction_arms=["rejected"] +result: Record { fields: {"newVersionId": Field { base: Local { reference: LocalRef { id: "local.0", alpha_name: "$local0", ty: "graft.warp@1.SymbolReceipt" } }, field: "newVersionId" }, "symbolId": Field { base: Local { reference: LocalRef { id: "arg.0", alpha_name: "$arg0", ty: "graft.warp@1.SymbolChangeInput" } }, field: "symbolId" }} } + + +=== NEGATIVE CONTROL 1: missing graft.structuralWrite operation profile === +Expected failure. Diagnostics: + CompilerError { stage: Resolve, kind: MissingContextFact, message: "operation profile `graft.structuralWrite` has no compiler context fact", span: Span { start: 363, end: 735 } } + +=== NEGATIVE CONTROL 2: missing graft.tinyBudget budget === +Expected failure. Diagnostics: + CompilerError { stage: Resolve, kind: MissingContextFact, message: "budget `graft.tinyBudget` has no compiler context fact", span: Span { start: 363, end: 735 } } + +=== NEGATIVE CONTROL 3: missing target.replace effect write class === +Expected failure. Diagnostics: + CompilerError { stage: TypeCheck, kind: MissingContextFact, message: "effect `target.replace` has no compiler context fact", span: Span { start: 520, end: 657 } } + CompilerError { stage: TypeCheck, kind: UnresolvedType, message: "identifier `receipt` has no typed binding", span: Span { start: 710, end: 717 } } + CompilerError { stage: TypeCheck, kind: TypeMismatch, message: "action body must return a value", span: Span { start: 363, end: 735 } } + +=== NEGATIVE CONTROL 4: renamed obstruction identifier (does the compiler care?) === +Compiled successfully with a renamed obstruction identifier -> obstruction names are author-chosen labels, not facts the CompilerContext resolves. diff --git a/docs/design/warp-lawpack-v0/harness-c75-pinned.rs b/docs/design/warp-lawpack-v0/harness-c75-pinned.rs new file mode 100644 index 00000000..9cc4c52a --- /dev/null +++ b/docs/design/warp-lawpack-v0/harness-c75-pinned.rs @@ -0,0 +1,486 @@ +//! Pinned-epoch (Edict c75c3f5) proof for the Graft lawpack spike. +//! +//! Mirrors ~/git/echo/tests/edict-provider-host-v1/tests/host_contract.rs as +//! closely as possible: same manifest/registry/host construction, same +//! checked byte artifacts loaded as-is (no regeneration). Only the compiled +//! source differs (our Graft action instead of the original EFFECTFUL_REPLACE +//! fixture), and only via a mechanical intent<->action keyword transform that +//! is checked, not hand-edited. + +use std::sync::Arc; + +use edict_provider_host_wasmtime::{ + ProviderComponentHost, ProviderHostLimits, ResolvedProviderComponent, +}; +use edict_provider_schema::{ProviderArtifactSchemaRegistry, ResolvedProviderSchemaArtifact}; +use edict_syntax::{ + bind_target_provider_manifest, compile_to_core, decode_canonical_cbor, encode_canonical_cbor, + encode_core_module, lower_with_builtin_lowerer, parse_module, select_provider_component, + validate_provider_lowering_request, BuiltinLowererRequest, BuiltinTargetLowerer, + CanonicalValue, CompilerContext, CoreBudget, CoreModule, ProviderArtifact, + ProviderArtifactBinding, ProviderArtifactKind, ProviderArtifactRef, ProviderArtifactSource, + ProviderBoundArtifact, ProviderDigest, ProviderDigestAlgorithm, ProviderInvocationKind, + ProviderLoweringInvocationContract, ProviderLoweringOutputKind, ProviderLoweringOutputRequest, + ProviderLoweringRequest, ProviderResourceRef, ProviderResponseLimits, ProviderSchemaBinding, + ProviderSchemaFormat, ProviderSemanticInput, ProviderSemanticInputBinding, + ProviderSemanticInputKind, ResourceRef, TargetEffectLowering, TargetIrLoweringFacts, + TargetProviderManifest, WriteClass, AUTHORITY_FACTS_API_VERSION, CORE_DIGEST_FRAME, + CORE_MODULE_DIGEST_DOMAIN, ECHO_DPO_TARGET_PROFILE, ECHO_SPAN_IR_DOMAIN, + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, TARGET_IR_ARTIFACT_DIGEST_DOMAIN, TARGET_PROFILE_API_VERSION, + TARGET_PROVIDER_ABI, TARGET_PROVIDER_MANIFEST_API_VERSION, TARGET_PROVIDER_PROTOCOL_VERSION, +}; +use sha2::{Digest, Sha256}; + +const CANONICAL_ACTION_SOURCE: &str = + include_str!("../record-symbol-change.edict"); + +const LOWERABILITY_DOMAIN: &str = "edict.lowering-requirements/v1"; +const GENERATED_ARTIFACT_DOMAIN: &str = "echo.generated-artifact/v1"; +const LOWERER_ROLE: &str = "lowerer.echo-dpo"; +const SCHEMA_ROLE: &str = "schema.echo-provider-artifacts"; +const TARGET_IR_ROLE: &str = "target-ir.echo-dpo"; + +const SCHEMA_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/schema.echo-provider-artifacts.cddl" +); +const TARGET_PROFILE_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/target-profile.echo-dpo.cbor" +); +const LAWPACK_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/lawpack.echo-dpo.cbor" +); +const TARGET_AUTHORITY_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/authority-facts.echo-dpo.cbor" +); +const LAWPACK_AUTHORITY_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/authority-facts.echo-lawpack.cbor" +); +const LOWERER_COMPONENT_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm" +); + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn raw_sha256(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes)) +} + +fn raw_resource(coordinate: &str, bytes: &[u8]) -> ResourceRef { + ResourceRef { + coordinate: coordinate.to_owned(), + digest: Some(format!("sha256:{}", raw_sha256(bytes))), + } +} + +fn locked_test_resource(coordinate: &str, digit: char) -> ResourceRef { + ResourceRef { + coordinate: coordinate.to_owned(), + digest: Some(format!("sha256:{}", digit.to_string().repeat(64))), + } +} + +fn text(value: &str) -> CanonicalValue { + CanonicalValue::Text(value.to_owned()) +} + +fn provider_digest(domain: &str, canonical_bytes: &[u8]) -> ProviderDigest { + decode_canonical_cbor(canonical_bytes).expect("artifact is canonical CBOR"); + let mut framed = vec![0x83]; + framed.extend( + encode_canonical_cbor(&text(CORE_DIGEST_FRAME)).expect("digest frame tag encodes"), + ); + framed.extend(encode_canonical_cbor(&text(domain)).expect("digest domain encodes")); + framed.extend_from_slice(canonical_bytes); + ProviderDigest { + algorithm: ProviderDigestAlgorithm::Sha256, + bytes: Sha256::digest(framed).to_vec(), + } +} + +fn bound_artifact(coordinate: &str, domain: &str, bytes: &[u8]) -> ProviderBoundArtifact { + ProviderBoundArtifact { + reference: ProviderResourceRef { + coordinate: coordinate.to_owned(), + digest: provider_digest(domain, bytes), + }, + artifact: ProviderArtifact { + domain: domain.to_owned(), + bytes: bytes.to_vec(), + }, + } +} + +fn artifact_binding(bound: &ProviderBoundArtifact) -> ProviderArtifactBinding { + ProviderArtifactBinding { + reference: bound.reference.clone(), + domain: bound.artifact.domain.clone(), + } +} + +fn map(entries: impl IntoIterator) -> CanonicalValue { + CanonicalValue::Map( + entries + .into_iter() + .map(|(key, value)| (text(key), value)) + .collect(), + ) +} + +/// Copied verbatim (structure and field order) from host_contract.rs's +/// `lowerability_bytes()`. These are generic facts about the `target.replace` +/// effect pattern -- not specific to any one action name or type shape -- +/// which is exactly why this can be reused unmodified for a different action. +fn lowerability_bytes() -> Vec { + let value = map([ + ("apiVersion", text(LOWERABILITY_DOMAIN)), + ("operationProfile", text("continuum.profile.write/v1")), + ( + "semanticEffects", + CanonicalValue::Array(vec![map([ + ("coordinate", text("target.replace")), + ("writeClass", text("replace")), + ( + "guardKinds", + CanonicalValue::Array(vec![text("precommit-atomic")]), + ), + ( + "obstructionCoordinates", + CanonicalValue::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValue::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValue::Array(vec![text("target.replace.cost")]), + ), + ])]), + ), + ( + "requiredWriteClasses", + CanonicalValue::Array(vec![text("replace")]), + ), + ( + "guardKinds", + CanonicalValue::Array(vec![text("precommit-atomic")]), + ), + ("atomicity", text("atomic")), + ("postconditionSupport", CanonicalValue::Bool(true)), + ( + "obstructionCoordinates", + CanonicalValue::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValue::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValue::Array(vec![text("target.replace.cost")]), + ), + ("opticContract", text("replace-point")), + ]); + encode_canonical_cbor(&value).expect("lowerability facts encode canonically") +} + +fn semantic_input( + role: &str, + kind: ProviderSemanticInputKind, + coordinate: &str, + domain: &str, + bytes: &[u8], +) -> ProviderSemanticInput { + ProviderSemanticInput { + role: role.to_owned(), + kind, + artifact: bound_artifact(coordinate, domain, bytes), + } +} + +fn echo_manifest(component_bytes: &'static [u8]) -> &'static TargetProviderManifest { + let component = raw_resource("echo.dpo.lowerer/component@1", component_bytes); + let schema = raw_resource("echo.provider-artifacts.cddl@1", SCHEMA_BYTES); + Box::leak(Box::new(TargetProviderManifest { + api_version: TARGET_PROVIDER_MANIFEST_API_VERSION.to_owned(), + provider_abi: TARGET_PROVIDER_ABI.to_owned(), + provider: locked_test_resource("echo.edict-provider-host-witness@1", '1'), + artifacts: vec![ + ProviderArtifactRef { + role: LOWERER_ROLE.to_owned(), + artifact_kind: ProviderArtifactKind::Lowerer, + resource: component.clone(), + source: ProviderArtifactSource::Component { component }, + }, + ProviderArtifactRef { + role: SCHEMA_ROLE.to_owned(), + artifact_kind: ProviderArtifactKind::ArtifactSchema, + resource: schema, + source: ProviderArtifactSource::Generated { + semantic_source: locked_test_resource( + "echo.edict-provider-host-witness.schema-source@1", + '2', + ), + generator: locked_test_resource( + "echo.edict-provider-host-witness.schema-generator@1", + '3', + ), + }, + }, + ], + schema_bindings: [ + (GENERATED_ARTIFACT_DOMAIN, "generated-artifact"), + (AUTHORITY_FACTS_API_VERSION, "authority-facts"), + (CORE_MODULE_DIGEST_DOMAIN, "core-module"), + (PROVIDER_LAWPACK_ARTIFACT_DOMAIN, "lawpack-manifest"), + (LOWERABILITY_DOMAIN, "lowering-requirements"), + (TARGET_IR_ARTIFACT_DIGEST_DOMAIN, "target-ir-artifact"), + (TARGET_PROFILE_API_VERSION, "target-profile-manifest"), + ] + .into_iter() + .map(|(domain, root_rule)| ProviderSchemaBinding { + domain: domain.to_owned(), + schema_role: SCHEMA_ROLE.to_owned(), + format: ProviderSchemaFormat::SelfContainedCddlV1, + root_rule: root_rule.to_owned(), + }) + .collect(), + })) +} + +fn echo_registry( + manifest: &'static TargetProviderManifest, +) -> &'static ProviderArtifactSchemaRegistry { + let proof = bind_target_provider_manifest(manifest).expect("Echo provider manifest validates"); + Box::leak(Box::new( + ProviderArtifactSchemaRegistry::from_manifest( + &proof, + [ResolvedProviderSchemaArtifact { + role: SCHEMA_ROLE.to_owned(), + bytes: Arc::from(SCHEMA_BYTES), + }], + [ + GENERATED_ARTIFACT_DOMAIN, + AUTHORITY_FACTS_API_VERSION, + CORE_MODULE_DIGEST_DOMAIN, + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, + LOWERABILITY_DOMAIN, + TARGET_IR_ARTIFACT_DIGEST_DOMAIN, + TARGET_PROFILE_API_VERSION, + ], + ) + .expect("Echo provider schema registry constructs"), + )) +} + +fn echo_request_from_core_bytes( + core_bytes: &[u8], +) -> (ProviderLoweringInvocationContract, ProviderLoweringRequest) { + let core_artifact = bound_artifact("graft.warp@1", CORE_MODULE_DIGEST_DOMAIN, core_bytes); + let target_profile_artifact = bound_artifact( + ECHO_DPO_TARGET_PROFILE, + TARGET_PROFILE_API_VERSION, + TARGET_PROFILE_BYTES, + ); + let lowerability = lowerability_bytes(); + let semantic_inputs = vec![ + semantic_input( + "authority-facts.echo-dpo", + ProviderSemanticInputKind::AuthorityFacts, + "echo.dpo-authority-facts@1", + AUTHORITY_FACTS_API_VERSION, + TARGET_AUTHORITY_BYTES, + ), + semantic_input( + "authority-facts.echo-lawpack", + ProviderSemanticInputKind::AuthorityFacts, + "echo.dpo-lawpack-authority-facts@1", + AUTHORITY_FACTS_API_VERSION, + LAWPACK_AUTHORITY_BYTES, + ), + semantic_input( + "lawpack.echo-dpo", + ProviderSemanticInputKind::Lawpack, + "echo.dpo-lawpack@1", + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, + LAWPACK_BYTES, + ), + semantic_input( + "lowerability.echo-dpo", + ProviderSemanticInputKind::LowerabilityFacts, + "echo.dpo-lowerability@1", + LOWERABILITY_DOMAIN, + &lowerability, + ), + ]; + let contract = ProviderLoweringInvocationContract { + core: artifact_binding(&core_artifact), + target_profile: artifact_binding(&target_profile_artifact), + semantic_inputs: semantic_inputs + .iter() + .map(|input| ProviderSemanticInputBinding { + role: input.role.clone(), + kind: input.kind.clone(), + artifact: artifact_binding(&input.artifact), + }) + .collect(), + }; + let request = ProviderLoweringRequest { + protocol_version: TARGET_PROVIDER_PROTOCOL_VERSION, + core: core_artifact, + target_profile: target_profile_artifact, + semantic_inputs, + requested_outputs: vec![ProviderLoweringOutputRequest { + role: TARGET_IR_ROLE.to_owned(), + kind: ProviderLoweringOutputKind::TargetIr, + domain: TARGET_IR_ARTIFACT_DIGEST_DOMAIN.to_owned(), + }], + limits: ProviderResponseLimits { + max_output_count: 8, + max_diagnostic_count: 8, + max_total_response_bytes: 64 * 1024, + }, + }; + (contract, request) +} + +fn graft_pinned_context() -> CompilerContext { + CompilerContext::new() + .with_operation_profile("graft.structuralWrite", "continuum.profile.write/v1") + .with_operation_profile_write_classes("graft.structuralWrite", [WriteClass::Replace]) + .with_effect_write_class("target.replace", WriteClass::Replace) + .with_budget( + "graft.tinyBudget", + CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + ) +} + +/// Mechanically derives the c75 (`intent`) dialect from the canonical +/// (`action`) source. Asserts exactly one token changed and nothing else. +fn derive_pinned_dialect_source(canonical: &str) -> String { + let occurrences = canonical.matches("action ").count(); + assert_eq!( + occurrences, 1, + "expected exactly one `action ` keyword occurrence in the canonical source" + ); + let derived = canonical.replacen("action ", "intent ", 1); + assert_ne!(derived, canonical, "the derived source must actually differ"); + assert_eq!( + derived.len(), + canonical.len() - "action".len() + "intent".len(), + "the derived source must differ ONLY by the keyword's length, nothing else" + ); + derived +} + +fn native_oracle_target_ir(core: &CoreModule) -> edict_syntax::TargetIrArtifact { + let facts = TargetIrLoweringFacts { + target_profile: ResourceRef { + coordinate: ECHO_DPO_TARGET_PROFILE.to_owned(), + digest: Some(format!("sha256:{}", "0".repeat(64))), + }, + target_ir_domain: ECHO_SPAN_IR_DOMAIN.to_owned(), + operation_profiles: vec!["continuum.profile.write/v1".to_owned()], + obstruction_coordinates: vec!["rejected".to_owned()], + effect_lowerings: vec![TargetEffectLowering { + effect: "target.replace".to_owned(), + target_intrinsic: "echo.dpo@1.replace".to_owned(), + }], + }; + lower_with_builtin_lowerer(BuiltinTargetLowerer::EchoDpo, BuiltinLowererRequest { + core, + facts: &facts, + }) + .expect("built-in Echo lowerer accepts our target profile coordinate") + .artifact + .expect("native oracle lowers the pinned-epoch Graft action") +} + +fn main() { + println!("=== PINNED EDICT REVISION: c75c3f550d049485ba00eae0dc272c6dd6aca11f (local worktree) ==="); + + println!("\n=== STEP 1: mechanical dialect derivation ==="); + let pinned_source = derive_pinned_dialect_source(CANONICAL_ACTION_SOURCE); + println!("Canonical (action) source SHA-256: {}", raw_sha256(CANONICAL_ACTION_SOURCE.as_bytes())); + println!("Derived (intent) source SHA-256: {}", raw_sha256(pinned_source.as_bytes())); + println!("Derived source:\n{pinned_source}"); + + println!("\n=== STEP 2: parse + compile_to_core at c75 ==="); + let module = parse_module(&pinned_source).expect("derived pinned-dialect source parses under Edict c75"); + let core = compile_to_core(&module, &graft_pinned_context()) + .expect("derived pinned-dialect source compiles to Core under Edict c75"); + println!("compile_to_core: OK"); + println!("Core module (debug):\n{core:#?}"); + + println!("\n=== STEP 3: native oracle lowering (built-in Rust lowerer, c75) ==="); + let oracle = native_oracle_target_ir(&core); + println!("Native oracle Target IR (debug):\n{oracle:#?}"); + + println!("\n=== STEP 4: REAL WASM lowerer-component invocation (Echo's checked lowerer.echo-dpo.component.wasm, unmodified) ==="); + let core_bytes = encode_core_module(&core).expect("Core module encodes canonically"); + let (contract, request) = echo_request_from_core_bytes(&core_bytes); + + let manifest = echo_manifest(LOWERER_COMPONENT_BYTES); + let manifest_proof = Box::leak(Box::new( + bind_target_provider_manifest(manifest).expect("Echo provider manifest validates"), + )); + let selected = select_provider_component(manifest_proof, LOWERER_ROLE, ProviderInvocationKind::Lowering) + .expect("Echo lowerer component selects from the checked manifest"); + let resolved = ResolvedProviderComponent::new(selected, Arc::from(LOWERER_COMPONENT_BYTES)); + let host = ProviderComponentHost::new().expect("host configures"); + let prepared = host.prepare(&resolved).expect("Echo's checked lowerer component prepares"); + let schema = echo_registry(manifest); + let contract = Box::leak(Box::new(contract)); + let request = Box::leak(Box::new(request)); + let validated = validate_provider_lowering_request(schema, contract, request); + + match validated { + Ok(validated) => { + println!("validate_provider_lowering_request: OK"); + let limits = ProviderHostLimits { + max_input_bytes: 1024 * 1024, + max_output_bytes: 3 * 1024 * 1024, + max_diagnostic_bytes: 3 * 1024 * 1024, + max_wasm_memory_bytes: 16 * 1024 * 1024, + max_table_elements: 10_000, + max_instances: 100, + max_memories: 8, + max_tables: 8, + max_wasm_fuel: 50_000_000, + max_hostcall_bytes: 4 * 1024 * 1024, + max_host_diagnostic_bytes: 512, + }; + match host.invoke_lowerer(&prepared, &validated, schema, limits) { + Ok(outcome) => { + println!("invoke_lowerer: call succeeded (WASM component executed)"); + if let Some(refusal) = outcome.refusal() { + println!("Component returned a REFUSAL (this is the outcome, not a crash):"); + println!("{refusal:#?}"); + } else if let Some(response) = outcome.response() { + println!("Component returned a real lowering RESPONSE:"); + println!("{response:#?}"); + } else { + println!("Component returned neither refusal nor response -- unexpected shape"); + } + if let Some(manifest_used) = outcome.manifest() { + println!("Manifest used: {manifest_used:#?}"); + } + } + Err(error) => { + println!("invoke_lowerer FAILED (host-level error, not a component refusal):"); + println!("{error:#?}"); + } + } + } + Err(error) => { + println!("validate_provider_lowering_request FAILED -- did not reach WASM invocation:"); + println!("{error:#?}"); + } + } +} diff --git a/docs/design/warp-lawpack-v0/harness-c75-shape-isolation.rs b/docs/design/warp-lawpack-v0/harness-c75-shape-isolation.rs new file mode 100644 index 00000000..d53ff859 --- /dev/null +++ b/docs/design/warp-lawpack-v0/harness-c75-shape-isolation.rs @@ -0,0 +1,560 @@ +//! Pinned-epoch (Edict c75c3f5) proof for the Graft lawpack spike. +//! +//! Mirrors ~/git/echo/tests/edict-provider-host-v1/tests/host_contract.rs as +//! closely as possible: same manifest/registry/host construction, same +//! checked byte artifacts loaded as-is (no regeneration). Only the compiled +//! source differs (our Graft action instead of the original EFFECTFUL_REPLACE +//! fixture), and only via a mechanical intent<->action keyword transform that +//! is checked, not hand-edited. + +use std::sync::Arc; + +use edict_provider_host_wasmtime::{ + ProviderComponentHost, ProviderHostLimits, ResolvedProviderComponent, +}; +use edict_provider_schema::{ProviderArtifactSchemaRegistry, ResolvedProviderSchemaArtifact}; +use edict_syntax::{ + bind_target_provider_manifest, compile_to_core, decode_canonical_cbor, encode_canonical_cbor, + encode_core_module, lower_with_builtin_lowerer, parse_module, select_provider_component, + validate_provider_lowering_request, BuiltinLowererRequest, BuiltinTargetLowerer, + CanonicalValue, CompilerContext, CoreBudget, CoreModule, ProviderArtifact, + ProviderArtifactBinding, ProviderArtifactKind, ProviderArtifactRef, ProviderArtifactSource, + ProviderBoundArtifact, ProviderDigest, ProviderDigestAlgorithm, ProviderInvocationKind, + ProviderLoweringInvocationContract, ProviderLoweringOutputKind, ProviderLoweringOutputRequest, + ProviderLoweringRequest, ProviderResourceRef, ProviderResponseLimits, ProviderSchemaBinding, + ProviderSchemaFormat, ProviderSemanticInput, ProviderSemanticInputBinding, + ProviderSemanticInputKind, ResourceRef, TargetEffectLowering, TargetIrLoweringFacts, + TargetProviderManifest, WriteClass, AUTHORITY_FACTS_API_VERSION, CORE_DIGEST_FRAME, + CORE_MODULE_DIGEST_DOMAIN, ECHO_DPO_TARGET_PROFILE, ECHO_SPAN_IR_DOMAIN, + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, TARGET_IR_ARTIFACT_DIGEST_DOMAIN, TARGET_PROFILE_API_VERSION, + TARGET_PROVIDER_ABI, TARGET_PROVIDER_MANIFEST_API_VERSION, TARGET_PROVIDER_PROTOCOL_VERSION, +}; +use sha2::{Digest, Sha256}; + +const CANONICAL_ACTION_SOURCE: &str = + include_str!("../record-symbol-change.edict"); + +const LOWERABILITY_DOMAIN: &str = "edict.lowering-requirements/v1"; +const GENERATED_ARTIFACT_DOMAIN: &str = "echo.generated-artifact/v1"; +const LOWERER_ROLE: &str = "lowerer.echo-dpo"; +const SCHEMA_ROLE: &str = "schema.echo-provider-artifacts"; +const TARGET_IR_ROLE: &str = "target-ir.echo-dpo"; + +const SCHEMA_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/schema.echo-provider-artifacts.cddl" +); +const TARGET_PROFILE_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/target-profile.echo-dpo.cbor" +); +const LAWPACK_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/lawpack.echo-dpo.cbor" +); +const TARGET_AUTHORITY_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/authority-facts.echo-dpo.cbor" +); +const LAWPACK_AUTHORITY_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/generated/v1/primary/authority-facts.echo-lawpack.cbor" +); +const LOWERER_COMPONENT_BYTES: &[u8] = include_bytes!( + "/Users/james/git/echo/schemas/edict-provider/components/v1/lowerer.echo-dpo.component.wasm" +); + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn raw_sha256(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes)) +} + +fn raw_resource(coordinate: &str, bytes: &[u8]) -> ResourceRef { + ResourceRef { + coordinate: coordinate.to_owned(), + digest: Some(format!("sha256:{}", raw_sha256(bytes))), + } +} + +fn locked_test_resource(coordinate: &str, digit: char) -> ResourceRef { + ResourceRef { + coordinate: coordinate.to_owned(), + digest: Some(format!("sha256:{}", digit.to_string().repeat(64))), + } +} + +fn text(value: &str) -> CanonicalValue { + CanonicalValue::Text(value.to_owned()) +} + +fn provider_digest(domain: &str, canonical_bytes: &[u8]) -> ProviderDigest { + decode_canonical_cbor(canonical_bytes).expect("artifact is canonical CBOR"); + let mut framed = vec![0x83]; + framed.extend( + encode_canonical_cbor(&text(CORE_DIGEST_FRAME)).expect("digest frame tag encodes"), + ); + framed.extend(encode_canonical_cbor(&text(domain)).expect("digest domain encodes")); + framed.extend_from_slice(canonical_bytes); + ProviderDigest { + algorithm: ProviderDigestAlgorithm::Sha256, + bytes: Sha256::digest(framed).to_vec(), + } +} + +fn bound_artifact(coordinate: &str, domain: &str, bytes: &[u8]) -> ProviderBoundArtifact { + ProviderBoundArtifact { + reference: ProviderResourceRef { + coordinate: coordinate.to_owned(), + digest: provider_digest(domain, bytes), + }, + artifact: ProviderArtifact { + domain: domain.to_owned(), + bytes: bytes.to_vec(), + }, + } +} + +fn artifact_binding(bound: &ProviderBoundArtifact) -> ProviderArtifactBinding { + ProviderArtifactBinding { + reference: bound.reference.clone(), + domain: bound.artifact.domain.clone(), + } +} + +fn map(entries: impl IntoIterator) -> CanonicalValue { + CanonicalValue::Map( + entries + .into_iter() + .map(|(key, value)| (text(key), value)) + .collect(), + ) +} + +/// Copied verbatim (structure and field order) from host_contract.rs's +/// `lowerability_bytes()`. These are generic facts about the `target.replace` +/// effect pattern -- not specific to any one action name or type shape -- +/// which is exactly why this can be reused unmodified for a different action. +fn lowerability_bytes() -> Vec { + let value = map([ + ("apiVersion", text(LOWERABILITY_DOMAIN)), + ("operationProfile", text("continuum.profile.write/v1")), + ( + "semanticEffects", + CanonicalValue::Array(vec![map([ + ("coordinate", text("target.replace")), + ("writeClass", text("replace")), + ( + "guardKinds", + CanonicalValue::Array(vec![text("precommit-atomic")]), + ), + ( + "obstructionCoordinates", + CanonicalValue::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValue::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValue::Array(vec![text("target.replace.cost")]), + ), + ])]), + ), + ( + "requiredWriteClasses", + CanonicalValue::Array(vec![text("replace")]), + ), + ( + "guardKinds", + CanonicalValue::Array(vec![text("precommit-atomic")]), + ), + ("atomicity", text("atomic")), + ("postconditionSupport", CanonicalValue::Bool(true)), + ( + "obstructionCoordinates", + CanonicalValue::Array(vec![text("rejected")]), + ), + ( + "footprintObligations", + CanonicalValue::Array(vec![text("target.replace.footprint")]), + ), + ( + "costObligations", + CanonicalValue::Array(vec![text("target.replace.cost")]), + ), + ("opticContract", text("replace-point")), + ]); + encode_canonical_cbor(&value).expect("lowerability facts encode canonically") +} + +fn semantic_input( + role: &str, + kind: ProviderSemanticInputKind, + coordinate: &str, + domain: &str, + bytes: &[u8], +) -> ProviderSemanticInput { + ProviderSemanticInput { + role: role.to_owned(), + kind, + artifact: bound_artifact(coordinate, domain, bytes), + } +} + +fn echo_manifest(component_bytes: &'static [u8]) -> &'static TargetProviderManifest { + let component = raw_resource("echo.dpo.lowerer/component@1", component_bytes); + let schema = raw_resource("echo.provider-artifacts.cddl@1", SCHEMA_BYTES); + Box::leak(Box::new(TargetProviderManifest { + api_version: TARGET_PROVIDER_MANIFEST_API_VERSION.to_owned(), + provider_abi: TARGET_PROVIDER_ABI.to_owned(), + provider: locked_test_resource("echo.edict-provider-host-witness@1", '1'), + artifacts: vec![ + ProviderArtifactRef { + role: LOWERER_ROLE.to_owned(), + artifact_kind: ProviderArtifactKind::Lowerer, + resource: component.clone(), + source: ProviderArtifactSource::Component { component }, + }, + ProviderArtifactRef { + role: SCHEMA_ROLE.to_owned(), + artifact_kind: ProviderArtifactKind::ArtifactSchema, + resource: schema, + source: ProviderArtifactSource::Generated { + semantic_source: locked_test_resource( + "echo.edict-provider-host-witness.schema-source@1", + '2', + ), + generator: locked_test_resource( + "echo.edict-provider-host-witness.schema-generator@1", + '3', + ), + }, + }, + ], + schema_bindings: [ + (GENERATED_ARTIFACT_DOMAIN, "generated-artifact"), + (AUTHORITY_FACTS_API_VERSION, "authority-facts"), + (CORE_MODULE_DIGEST_DOMAIN, "core-module"), + (PROVIDER_LAWPACK_ARTIFACT_DOMAIN, "lawpack-manifest"), + (LOWERABILITY_DOMAIN, "lowering-requirements"), + (TARGET_IR_ARTIFACT_DIGEST_DOMAIN, "target-ir-artifact"), + (TARGET_PROFILE_API_VERSION, "target-profile-manifest"), + ] + .into_iter() + .map(|(domain, root_rule)| ProviderSchemaBinding { + domain: domain.to_owned(), + schema_role: SCHEMA_ROLE.to_owned(), + format: ProviderSchemaFormat::SelfContainedCddlV1, + root_rule: root_rule.to_owned(), + }) + .collect(), + })) +} + +fn echo_registry( + manifest: &'static TargetProviderManifest, +) -> &'static ProviderArtifactSchemaRegistry { + let proof = bind_target_provider_manifest(manifest).expect("Echo provider manifest validates"); + Box::leak(Box::new( + ProviderArtifactSchemaRegistry::from_manifest( + &proof, + [ResolvedProviderSchemaArtifact { + role: SCHEMA_ROLE.to_owned(), + bytes: Arc::from(SCHEMA_BYTES), + }], + [ + GENERATED_ARTIFACT_DOMAIN, + AUTHORITY_FACTS_API_VERSION, + CORE_MODULE_DIGEST_DOMAIN, + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, + LOWERABILITY_DOMAIN, + TARGET_IR_ARTIFACT_DIGEST_DOMAIN, + TARGET_PROFILE_API_VERSION, + ], + ) + .expect("Echo provider schema registry constructs"), + )) +} + +fn echo_request_from_core_bytes( + core_coordinate: &str, + core_bytes: &[u8], +) -> (ProviderLoweringInvocationContract, ProviderLoweringRequest) { + let core_artifact = bound_artifact(core_coordinate, CORE_MODULE_DIGEST_DOMAIN, core_bytes); + let target_profile_artifact = bound_artifact( + ECHO_DPO_TARGET_PROFILE, + TARGET_PROFILE_API_VERSION, + TARGET_PROFILE_BYTES, + ); + let lowerability = lowerability_bytes(); + let semantic_inputs = vec![ + semantic_input( + "authority-facts.echo-dpo", + ProviderSemanticInputKind::AuthorityFacts, + "echo.dpo-authority-facts@1", + AUTHORITY_FACTS_API_VERSION, + TARGET_AUTHORITY_BYTES, + ), + semantic_input( + "authority-facts.echo-lawpack", + ProviderSemanticInputKind::AuthorityFacts, + "echo.dpo-lawpack-authority-facts@1", + AUTHORITY_FACTS_API_VERSION, + LAWPACK_AUTHORITY_BYTES, + ), + semantic_input( + "lawpack.echo-dpo", + ProviderSemanticInputKind::Lawpack, + "echo.dpo-lawpack@1", + PROVIDER_LAWPACK_ARTIFACT_DOMAIN, + LAWPACK_BYTES, + ), + semantic_input( + "lowerability.echo-dpo", + ProviderSemanticInputKind::LowerabilityFacts, + "echo.dpo-lowerability@1", + LOWERABILITY_DOMAIN, + &lowerability, + ), + ]; + let contract = ProviderLoweringInvocationContract { + core: artifact_binding(&core_artifact), + target_profile: artifact_binding(&target_profile_artifact), + semantic_inputs: semantic_inputs + .iter() + .map(|input| ProviderSemanticInputBinding { + role: input.role.clone(), + kind: input.kind.clone(), + artifact: artifact_binding(&input.artifact), + }) + .collect(), + }; + let request = ProviderLoweringRequest { + protocol_version: TARGET_PROVIDER_PROTOCOL_VERSION, + core: core_artifact, + target_profile: target_profile_artifact, + semantic_inputs, + requested_outputs: vec![ProviderLoweringOutputRequest { + role: TARGET_IR_ROLE.to_owned(), + kind: ProviderLoweringOutputKind::TargetIr, + domain: TARGET_IR_ARTIFACT_DIGEST_DOMAIN.to_owned(), + }], + limits: ProviderResponseLimits { + max_output_count: 8, + max_diagnostic_count: 8, + max_total_response_bytes: 64 * 1024, + }, + }; + (contract, request) +} + +fn graft_pinned_context() -> CompilerContext { + CompilerContext::new() + .with_operation_profile("graft.structuralWrite", "continuum.profile.write/v1") + .with_operation_profile_write_classes("graft.structuralWrite", [WriteClass::Replace]) + .with_effect_write_class("target.replace", WriteClass::Replace) + .with_budget( + "graft.tinyBudget", + CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + ) +} + +/// Mechanically derives the c75 (`intent`) dialect from the canonical +/// (`action`) source. Asserts exactly one token changed and nothing else. +fn derive_pinned_dialect_source(canonical: &str) -> String { + let occurrences = canonical.matches("action ").count(); + assert_eq!( + occurrences, 1, + "expected exactly one `action ` keyword occurrence in the canonical source" + ); + let derived = canonical.replacen("action ", "intent ", 1); + assert_ne!(derived, canonical, "the derived source must actually differ"); + assert_eq!( + derived.len(), + canonical.len() - "action".len() + "intent".len(), + "the derived source must differ ONLY by the keyword's length, nothing else" + ); + derived +} + +fn native_oracle_target_ir(core: &CoreModule) -> edict_syntax::TargetIrArtifact { + let facts = TargetIrLoweringFacts { + target_profile: ResourceRef { + coordinate: ECHO_DPO_TARGET_PROFILE.to_owned(), + digest: Some(format!("sha256:{}", "0".repeat(64))), + }, + target_ir_domain: ECHO_SPAN_IR_DOMAIN.to_owned(), + operation_profiles: vec!["continuum.profile.write/v1".to_owned()], + obstruction_coordinates: vec!["rejected".to_owned()], + effect_lowerings: vec![TargetEffectLowering { + effect: "target.replace".to_owned(), + target_intrinsic: "echo.dpo@1.replace".to_owned(), + }], + }; + lower_with_builtin_lowerer(BuiltinTargetLowerer::EchoDpo, BuiltinLowererRequest { + core, + facts: &facts, + }) + .expect("built-in Echo lowerer accepts our target profile coordinate") + .artifact + .expect("native oracle lowers the pinned-epoch Graft action") +} + +const ORIGINAL_FIXTURE_SOURCE: &str = r#"package a.b@1; + +type Input = { id: String, }; + +type Receipt = { id: String, }; + +type Output = { id: String, }; + +intent t(input: Input) returns Output + profile p.effectful + basis none + budget <= p.tiny { + let receipt: Receipt = target.replace(input.id) + else { rejected(reason) => domain.WriteRejected }; + return { id: input.id }; +} +"#; + +fn original_fixture_context() -> CompilerContext { + CompilerContext::new() + .with_operation_profile("p.effectful", "continuum.profile.write/v1") + .with_operation_profile_write_classes("p.effectful", [WriteClass::Replace]) + .with_effect_write_class("target.replace", WriteClass::Replace) + .with_budget( + "p.tiny", + CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + ) +} + +/// Written in current (`action`) syntax for readability; mechanically +/// derived to `intent` before being compiled at c75, same as the canonical +/// recordSymbolChange source, so no hand-authored `intent` source exists. +const SINGLE_FIELD_GRAFT_SOURCE_ACTION_DIALECT: &str = r#"package graft.warp.tick@1; + +type TickInput = { symbolId: String, }; + +type TickReceipt = { symbolId: String, }; + +type TickOutput = { symbolId: String, }; + +action recordTick(input: TickInput) returns TickOutput + profile graft.structuralWrite + basis none + budget <= graft.tinyBudget { + let receipt: TickReceipt = target.replace(input.symbolId) + else { rejected(reason) => graft.TickObstruction.BasisConflict }; + return { symbolId: input.symbolId }; +} +"#; + +/// Runs one source through parse -> compile_to_core -> real WASM +/// lowerer-component invocation, reusing Echo's checked package unmodified. +/// Returns nothing -- prints a self-contained report for this variant. +fn run_variant(label: &str, source: &str, context: &CompilerContext) { + println!("\n\n######## VARIANT: {label} ########"); + println!("Source:\n{source}"); + + let module = match parse_module(source) { + Ok(module) => module, + Err(error) => { + println!("RESULT: parse_module FAILED: {error:?}"); + return; + } + }; + let core = match compile_to_core(&module, context) { + Ok(core) => core, + Err(errors) => { + println!("RESULT: compile_to_core FAILED: {errors:?}"); + return; + } + }; + println!("compile_to_core: OK"); + + let core_bytes = encode_core_module(&core).expect("Core module encodes canonically"); + let (contract, request) = echo_request_from_core_bytes(&core.coordinate, &core_bytes); + + let manifest = echo_manifest(LOWERER_COMPONENT_BYTES); + let manifest_proof = Box::leak(Box::new( + bind_target_provider_manifest(manifest).expect("Echo provider manifest validates"), + )); + let selected = select_provider_component(manifest_proof, LOWERER_ROLE, ProviderInvocationKind::Lowering) + .expect("Echo lowerer component selects from the checked manifest"); + let resolved = ResolvedProviderComponent::new(selected, Arc::from(LOWERER_COMPONENT_BYTES)); + let host = ProviderComponentHost::new().expect("host configures"); + let prepared = host.prepare(&resolved).expect("Echo's checked lowerer component prepares"); + let schema = echo_registry(manifest); + let contract = Box::leak(Box::new(contract)); + let request = Box::leak(Box::new(request)); + + let validated = match validate_provider_lowering_request(schema, contract, request) { + Ok(validated) => validated, + Err(error) => { + println!("RESULT: validate_provider_lowering_request FAILED (did not reach WASM invocation): {error:?}"); + return; + } + }; + println!("validate_provider_lowering_request: OK"); + + let limits = ProviderHostLimits { + max_input_bytes: 1024 * 1024, + max_output_bytes: 3 * 1024 * 1024, + max_diagnostic_bytes: 3 * 1024 * 1024, + max_wasm_memory_bytes: 16 * 1024 * 1024, + max_table_elements: 10_000, + max_instances: 100, + max_memories: 8, + max_tables: 8, + max_wasm_fuel: 50_000_000, + max_hostcall_bytes: 4 * 1024 * 1024, + max_host_diagnostic_bytes: 512, + }; + match host.invoke_lowerer(&prepared, &validated, schema, limits) { + Ok(outcome) => { + if let Some(refusal) = outcome.refusal() { + println!("RESULT: REAL COMPONENT REFUSED: {refusal:#?}"); + } else if let Some(response) = outcome.response() { + println!("RESULT: REAL COMPONENT ACCEPTED AND RETURNED A LOWERING RESPONSE:"); + println!("{response:#?}"); + } else { + println!("RESULT: component returned neither refusal nor response -- unexpected shape"); + } + } + Err(error) => { + println!("RESULT: invoke_lowerer FAILED (host-level error, not a component refusal): {error:?}"); + } + } +} + +fn main() { + println!("=== PINNED EDICT REVISION: c75c3f550d049485ba00eae0dc272c6dd6aca11f (local worktree) ==="); + println!("Goal: isolate whether Echo's real checked WASM lowerer component's UnsupportedSemantics"); + println!("refusal (seen for the 4-field recordSymbolChange action) is about action SHAPE or about"); + println!("something else -- by testing 3 variants under the identical pinned epoch and pipeline."); + + // Variant 1: the exact original fixture, unmodified. Positive control -- + // this MUST succeed, since it's exactly what the checked component was + // built and conformance-tested against. If this fails, something is wrong + // with THIS harness, not with the hypothesis being tested. + run_variant("1. ORIGINAL FIXTURE (positive control, unmodified)", ORIGINAL_FIXTURE_SOURCE, &original_fixture_context()); + + // Variant 2: a Graft-named action with the SAME single-field shape as the + // original fixture (one String field, same effect/obstruction + // pattern), but different package/type/action/obstruction names. + let single_field_pinned = derive_pinned_dialect_source(SINGLE_FIELD_GRAFT_SOURCE_ACTION_DIALECT); + run_variant("2. SINGLE-FIELD GRAFT ACTION (shape matches original, names don't)", &single_field_pinned, &graft_pinned_context()); + + // Variant 3: the original 4-field recordSymbolChange (already known to be + // refused) -- re-run here for a single consolidated comparison. + let pinned_source = derive_pinned_dialect_source(CANONICAL_ACTION_SOURCE); + run_variant("3. FOUR-FIELD recordSymbolChange (known refused, re-run for comparison)", &pinned_source, &graft_pinned_context()); +} diff --git a/docs/design/warp-lawpack-v0/harness.rs b/docs/design/warp-lawpack-v0/harness.rs new file mode 100644 index 00000000..c322b00c --- /dev/null +++ b/docs/design/warp-lawpack-v0/harness.rs @@ -0,0 +1,178 @@ +use edict_syntax::{ + compile_to_core, lower_with_builtin_lowerer, parse_module, BuiltinLowererRequest, + BuiltinTargetLowerer, CompilerContext, CoreBudget, ResourceRef, TargetEffectLowering, + TargetIrLoweringFacts, WriteClass, ECHO_DPO_TARGET_PROFILE, ECHO_SPAN_IR_DOMAIN, +}; +use sha2::{Digest, Sha256}; + +const GRAFT_SOURCE: &str = include_str!("../record-symbol-change.edict"); + +fn source_sha256() -> String { + let mut hasher = Sha256::new(); + hasher.update(GRAFT_SOURCE.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn full_context() -> CompilerContext { + CompilerContext::new() + .with_operation_profile("graft.structuralWrite", "continuum.profile.write/v1") + .with_operation_profile_write_classes("graft.structuralWrite", [WriteClass::Replace]) + .with_effect_write_class("target.replace", WriteClass::Replace) + .with_budget( + "graft.tinyBudget", + CoreBudget { + max_steps: 8, + max_allocated_bytes: 1024, + max_output_bytes: 256, + }, + ) +} + +fn target_facts() -> TargetIrLoweringFacts { + TargetIrLoweringFacts { + target_profile: ResourceRef { + coordinate: ECHO_DPO_TARGET_PROFILE.to_owned(), + digest: Some(format!("sha256:{}", "0".repeat(64))), + }, + target_ir_domain: ECHO_SPAN_IR_DOMAIN.to_owned(), + operation_profiles: vec!["continuum.profile.write/v1".to_owned()], + obstruction_coordinates: vec!["rejected".to_owned()], + effect_lowerings: vec![TargetEffectLowering { + effect: "target.replace".to_owned(), + target_intrinsic: "echo.dpo@1.replace".to_owned(), + }], + } +} + +fn main() { + println!("=== EDICT_REVISION (actual, local path dep to ~/git/edict @ HEAD): 9f1a11e0358caeb03339f0035333f8a49a2a814a ==="); + println!("=== NOTE: Echo's pinned cross-repo test harness is still at c75c3f5 (10 commits behind), which predates the Intent->Action rename ==="); + println!("=== SOURCE SHA256: {} ===", source_sha256()); + println!("=== SOURCE ===\n{GRAFT_SOURCE}"); + + println!("\n=== POSITIVE CASE: full context ==="); + let module = parse_module(GRAFT_SOURCE).expect("Graft lawpack source parses"); + let core = compile_to_core(&module, &full_context()); + match core { + Ok(core) => { + println!("compile_to_core: OK"); + println!("Core module (debug): {core:#?}"); + + let facts = target_facts(); + let report = + lower_with_builtin_lowerer(BuiltinTargetLowerer::EchoDpo, BuiltinLowererRequest { + core: &core, + facts: &facts, + }) + .expect("built-in Echo lowerer accepts our target profile coordinate"); + + println!("\nlower_to_target_ir status: {:?}", report.status); + println!("lower_to_target_ir failures: {:?}", report.failures); + match report.artifact { + Some(artifact) => { + println!("\n=== TARGET IR ARTIFACT ==="); + println!("domain: {}", artifact.domain); + println!("target_profile: {:?}", artifact.target_profile); + println!("source_core_coordinate: {}", artifact.source_core_coordinate); + println!("action names: {:?}", artifact.actions.keys().collect::>()); + for (name, action) in &artifact.actions { + println!("\n--- action `{name}` ---"); + println!("operation_profile: {}", action.operation_profile); + println!("core_evaluation_budget: {:?}", action.core_evaluation_budget); + println!("steps: {}", action.steps.len()); + for step in &action.steps { + println!( + " step `{}`: effect={} target_intrinsic={} obstruction_arms={:?}", + step.id, + step.effect, + step.target_intrinsic, + step.obstruction_arms.keys().collect::>() + ); + } + println!("result: {:?}", action.result); + } + } + None => println!("NO ARTIFACT PRODUCED (unsupported)"), + } + } + Err(errors) => { + println!("compile_to_core FAILED (unexpected for positive case):"); + for e in &errors { + println!(" {e:?}"); + } + } + } + + println!("\n\n=== NEGATIVE CONTROL 1: missing graft.structuralWrite operation profile ==="); + let ctx = CompilerContext::new() + // .with_operation_profile("graft.structuralWrite", "continuum.profile.write/v1") // REMOVED + .with_operation_profile_write_classes("graft.structuralWrite", [WriteClass::Replace]) + .with_effect_write_class("target.replace", WriteClass::Replace) + .with_budget( + "graft.tinyBudget", + CoreBudget { max_steps: 8, max_allocated_bytes: 1024, max_output_bytes: 256 }, + ); + match compile_to_core(&module, &ctx) { + Ok(_) => println!("UNEXPECTED: compiled successfully without the operation profile fact"), + Err(errors) => { + println!("Expected failure. Diagnostics:"); + for e in &errors { + println!(" {e:?}"); + } + } + } + + println!("\n=== NEGATIVE CONTROL 2: missing graft.tinyBudget budget ==="); + let ctx = CompilerContext::new() + .with_operation_profile("graft.structuralWrite", "continuum.profile.write/v1") + .with_operation_profile_write_classes("graft.structuralWrite", [WriteClass::Replace]) + .with_effect_write_class("target.replace", WriteClass::Replace); + // .with_budget(...) // REMOVED + match compile_to_core(&module, &ctx) { + Ok(_) => println!("UNEXPECTED: compiled successfully without the budget fact"), + Err(errors) => { + println!("Expected failure. Diagnostics:"); + for e in &errors { + println!(" {e:?}"); + } + } + } + + println!("\n=== NEGATIVE CONTROL 3: missing target.replace effect write class ==="); + let ctx = CompilerContext::new() + .with_operation_profile("graft.structuralWrite", "continuum.profile.write/v1") + .with_operation_profile_write_classes("graft.structuralWrite", [WriteClass::Replace]) + // .with_effect_write_class("target.replace", WriteClass::Replace) // REMOVED + .with_budget( + "graft.tinyBudget", + CoreBudget { max_steps: 8, max_allocated_bytes: 1024, max_output_bytes: 256 }, + ); + match compile_to_core(&module, &ctx) { + Ok(_) => println!("UNEXPECTED: compiled successfully without the effect write class fact"), + Err(errors) => { + println!("Expected failure. Diagnostics:"); + for e in &errors { + println!(" {e:?}"); + } + } + } + + println!("\n=== NEGATIVE CONTROL 4: renamed obstruction identifier (does the compiler care?) ==="); + let renamed_source = GRAFT_SOURCE.replace( + "graft.SymbolChangeObstruction.BasisConflict", + "graft.SymbolChangeObstruction.SomethingElseEntirely", + ); + assert_ne!(renamed_source, GRAFT_SOURCE, "replacement must actually change the source"); + let renamed_module = parse_module(&renamed_source).expect("renamed source still parses"); + match compile_to_core(&renamed_module, &full_context()) { + Ok(_) => println!( + "Compiled successfully with a renamed obstruction identifier -> obstruction names are author-chosen labels, not facts the CompilerContext resolves." + ), + Err(errors) => { + println!("Renaming the obstruction identifier caused a failure. Diagnostics:"); + for e in &errors { + println!(" {e:?}"); + } + } + } +} diff --git a/docs/design/warp-lawpack-v0/lowering-evidence.md b/docs/design/warp-lawpack-v0/lowering-evidence.md new file mode 100644 index 00000000..030d8186 --- /dev/null +++ b/docs/design/warp-lawpack-v0/lowering-evidence.md @@ -0,0 +1,72 @@ +# Lowering Evidence: `graft.warp@1 / recordSymbolChange` + +**Claim (narrow, on purpose):** this experiment proves the Graft-shaped action `recordSymbolChange` is resolvable and lowerable through Edict's real Core compiler and its real built-in Echo Target-IR lowerer, targeting `echo.dpo@1`. It does **not** prove stale-basis enforcement (the source uses `basis none`), correct persistence semantics, bundle assembly, or runtime execution against Echo. Those are later rungs, not yet attempted. + +## Setup + +- Harness: standalone Rust binary (`harness.rs` in this directory), depending on `edict-syntax` as a **local path dependency** against a checkout of `~/git/edict` at its current `main`. +- Construction mirrors `~/git/echo/tests/edict-provider-host-v1/tests/package_contract.rs`'s `echo_core_from` / `oracle_target_ir_artifact` functions as closely as possible: same `CompilerContext` builder shape, same `TargetIrLoweringFacts` shape, same `lower_with_builtin_lowerer(BuiltinTargetLowerer::EchoDpo, ...)` call. No authority-facts CBOR files were needed for this rung — those (per reading `package_contract.rs`) are only required for the later provider-lowering-request/verification/bundle-assembly stages, not for `compile_to_core`/`lower_to_target_ir` directly. `authority-facts.json` in this directory documents the facts used; it is not a file the harness reads. + +## Dependency-drift finding (precise phrasing) + +Echo's cross-repo test harness (`tests/edict-provider-host-v1/Cargo.toml`) pins Edict at commit `c75c3f550d049485ba00eae0dc272c6dd6aca11f`. Edict's `main` is currently `9f1a11e0358caeb03339f0035333f8a49a2a814a` — the very next commit, `Refactor: Rename Intent to Action workspace-wide`, which also renamed `TargetIrArtifact.intents` → `TargetIrArtifact.actions`. + +**Precise claim:** *the current lowering harness does not build against Echo's pinned Edict revision* (see `evidence/echo-pinned-edict-build-failure.txt` for the exact compiler error). This experiment did **not** build or test Echo's own repository against either revision — that is a separate, not-yet-performed check, and "Echo's build is broken" would overstate what was actually observed here. + +This harness was built against Edict's current `main` via a local path dependency to get a working result (see `evidence/edict-main-lowering-output.txt` for full raw output). + +## Facts + +- Edict revision used for the passing run: `9f1a11e0358caeb03339f0035333f8a49a2a814a` +- Echo's currently pinned Edict revision (not built/tested in this experiment): `c75c3f550d049485ba00eae0dc272c6dd6aca11f` +- Source SHA-256: `9c0dcb789f8c4574caf444666a445ba27fd47d2758d9e4b771ff47b00cb0a6ed` +- Full raw stdout: `evidence/edict-main-lowering-output.txt` + +## Positive result + +`compile_to_core` — **OK**. Real Core IR produced: alpha-renamed locals (`$arg0`, `$local0`, `$obstruction0`), typed record fields for `SymbolChangeInput`/`SymbolReceipt`/`SymbolChangeOutput`, one `Effect` node (`effect: "target.replace"`) bound to an obstruction arm calling `graft.SymbolChangeObstruction.BasisConflict` as a zero-arg constructor. + +`lower_to_target_ir` (via `lower_with_builtin_lowerer(BuiltinTargetLowerer::EchoDpo, ...)`) — **status: Lowered, zero failures**, once `target_profile.digest` was given a syntactically-valid `sha256:<64 hex>` shape (a `None` digest fails closed with `UndigestedTargetProfile` — Edict correctly refusing an undigested target-profile reference, not a bug in the draft). + +Structural assertions on the resulting `TargetIrArtifact` (checked against the raw output, not just `Result::Ok`): +- `domain: "echo.span-ir/v1"` — correct, matches `ECHO_SPAN_IR_DOMAIN`. +- `action names: ["recordSymbolChange"]` — exactly one action, correctly named, not silently dropped. +- exactly one step: `effect=target.replace`, `target_intrinsic=echo.dpo@1.replace`, `obstruction_arms=["rejected"]`. +- `operation_profile: "continuum.profile.write/v1"` and `core_evaluation_budget: { max_steps: 8, max_allocated_bytes: 1024, max_output_bytes: 256 }` both present and correctly bound. +- `result` record correctly references both `input.symbolId` and `receipt.newVersionId` (the two fields the action's `return` declares). + +## Negative controls (discriminating power confirmed) + +| Removed fact | Result | Diagnostic | +|---|---|---| +| `.with_operation_profile("graft.structuralWrite", ...)` | **Fails, as expected** | `CompilerError { stage: Resolve, kind: MissingContextFact, message: "operation profile \`graft.structuralWrite\` has no compiler context fact" }` | +| `.with_budget("graft.tinyBudget", ...)` | **Fails, as expected** | `CompilerError { stage: Resolve, kind: MissingContextFact, message: "budget \`graft.tinyBudget\` has no compiler context fact" }` | +| `.with_effect_write_class("target.replace", ...)` | **Fails, as expected** | Cascading `TypeCheck` errors: `MissingContextFact` on the effect, then `UnresolvedType` on `receipt`, then `TypeMismatch` ("action body must return a value") | +| Renamed `graft.SymbolChangeObstruction.BasisConflict` → an arbitrary other name | **Compiles successfully** — *this is the informative result, not a bug* | No diagnostic; obstruction identifiers are author-chosen labels carried into Core as a zero-arg `Call`, not resolved against any `CompilerContext` fact the way profile/budget/effect-write-class are | + +All three real facts (`graft.structuralWrite`, `graft.tinyBudget`, `target.replace`'s write class) are genuinely participating in resolution — each produces a specific, correctly-attributed failure when removed, not a generic crash. The obstruction name is confirmed to be free-form and not fact-checked at this compiler stage — a real, useful, structural finding about how the language actually works, not a gap in the experiment. + +## Success criteria checklist + +```text +[x] Exact draft passes compile_to_core +[x] Exact resulting Core module passes lower_with_builtin_lowerer +[x] Target is explicitly echo.dpo@1 +[x] No fixture-specific fallback or source substitution occurs (positive case ran the real, unmodified draft) +[x] Removing a required Graft fact produces a meaningful failure (3 of 3 real facts confirmed discriminating) +[x] Edict commit SHA is recorded +[x] Source hash and exact source text are recorded +[x] Core/Target output is retained (raw output file) and summarized above +``` + +## What this does not prove (restating the boundary on purpose) + +- Nothing about stale-basis enforcement — the draft uses `basis none`; `priorVersionId` is declared but never consumed by the effect. +- Nothing about bundle assembly, WASM component execution, or a real Echo runtime accepting this specific action — that requires the semantic-inputs/authority-facts/bundle-assembly machinery `package_contract.rs` exercises next, which this experiment deliberately stopped short of. +- Nothing about whether `graft.structuralWrite`/`continuum.profile.write/v1` is the *correct* real-world profile choice for a Graft write — that's a design decision, not something this compile step validates. +- Nothing about whether Echo's own repository builds or tests pass at any particular Edict revision — not checked in this experiment. + +## Next steps (not yet started, tracked here so they aren't lost) + +1. Determine the smallest known-good Edict revision to pin: the only functionally-relevant commit between Echo's current pin and Edict's `main` is `main` itself (the rename commit; the other 9 intervening commits are docs-only). Verify Echo's own repository (a) builds and (b) its existing test suite, including the pinned `provider-conformance-v1` fixture, still passes at that revision before recommending Echo's pin move. +2. Only after Echo's pin is settled on a verified-good revision, extend this experiment through provider semantic-inputs, bundle assembly, and WASM component execution (mirroring `package_contract.rs`/`host_contract.rs` fully) — against that same settled revision, not against Edict `main` opportunistically. diff --git a/docs/design/warp-lawpack-v0/pinned-epoch-evidence.md b/docs/design/warp-lawpack-v0/pinned-epoch-evidence.md new file mode 100644 index 00000000..332a8c26 --- /dev/null +++ b/docs/design/warp-lawpack-v0/pinned-epoch-evidence.md @@ -0,0 +1,66 @@ +# Pinned-Epoch Evidence: `recordSymbolChange` Against Edict `c75c3f5` and Echo's Unmodified Checked Package + +**Sequencing note:** this is a follow-on to `lowering-evidence.md`, done *instead of* advancing Echo's pin. Echo's pin (`c75c3f550d049485ba00eae0dc272c6dd6aca11f`) and its digest-locked provider package remain completely untouched — this experiment reused them exactly as checked in. No `echo-wesley-gen` regeneration was run. If/when the Echo compiler-epoch migration happens, it is tracked as a separate, explicitly scoped change (see "Deferred" below), not bundled with this spike. + +## What this experiment did + +1. Mechanically derived a `c75`-compatible (`intent` keyword) variant of the canonical `record-symbol-change.edict` (which uses current Edict's `action` keyword) — a single-token `action `→`intent ` replacement, asserted in code to be exactly one occurrence and to change nothing else in the source (harness-c75-pinned.rs's `derive_pinned_dialect_source`). +2. Compiled the derived source to Core using Edict `c75c3f5` (via a local worktree at that exact commit, not a network fetch — see `evidence/`), with the same Graft-named compiler-context facts as before (`graft.structuralWrite`, `graft.tinyBudget`, `target.replace`'s write class), all resolving to the same canonical identities (`continuum.profile.write/v1`) already proven at current Edict. +3. Lowered that Core to Target IR twice: once via the native built-in Rust lowerer (the "oracle"), and once via **Echo's real, checked, unmodified `lowerer.echo-dpo.component.wasm`**, invoked through `edict_provider_host_wasmtime::ProviderComponentHost` — genuinely executing the WASM component, not simulating it. All checked byte artifacts (`schema.echo-provider-artifacts.cddl`, `target-profile.echo-dpo.cbor`, `lawpack.echo-dpo.cbor`, both `authority-facts.*.cbor` files, and the lowerer component itself) were loaded verbatim from Echo's tree and never modified or regenerated. + +## Result: outcome bucket 2 — real component invocation, real typed refusal + +The pinned-dialect source parses and compiles to Core cleanly (`compile_to_core: OK`). The native oracle lowers it to Target IR successfully. **The real WASM lowerer component executes and returns a genuine, well-formed, typed refusal — not a crash, not a request-validation failure:** + +```text +ProviderRefusal { + kind: UnsupportedSemantics, + subject: Some("graft.warp@1"), + diagnostics: [ + ProviderDiagnostic { + code: "echo.provider.unsupported-semantics", + severity: Error, + message: "the supplied semantics are outside the exact first Echo lowering closure", + repair: None, + }, + ], +} +``` + +This is a real, preserved finding, not something patched around. Interpretation: the checked WASM lowerer component is scoped even more narrowly than the native built-in lowerer — `edict-syntax`'s own `target_ir.rs` documents itself as "narrow v0.9 target slices," and the checked component appears to allowlist something closer to the exact original `EFFECTFUL_REPLACE` fixture shape rather than any structurally-similar `target.replace`-based action. **This means the native oracle and the real WASM component disagree on a novel-but-analogous action shape** — the native lowerer accepts `recordSymbolChange` (4-field input record, different type/obstruction names), the real component refuses it. That is itself worth flagging: Echo PR #677's "prove Edict provider conformance and semantic parity" claim was validated against the *original* fixture; this experiment is the first evidence that parity does not automatically extend to a new, differently-shaped action under the same effect/profile pattern. + +Bundle assembly (`assemble_contract_bundle_from_target_ir`) was not attempted, because it requires a successful lowering *response* to bundle, and the real component did not produce one — a refusal is the correct, honest stopping point per the outcome taxonomy specified for this experiment; forcing a bundle from the oracle's output instead would have proven something about the native lowerer, not about what Echo's real, currently-shipped provider actually does. + +## Cross-version semantic comparison (current-Edict-`main` vs. pinned `c75`) + +Comparing the Core module and native-oracle Target IR from this run against the earlier `lowering-evidence.md` run (current Edict `main`, `action` keyword), after normalizing the one known rename (`intents`/`TargetIrIntent` at `c75` vs. `actions`/`TargetIrAction` at `main`): + +| Structure | `main` (action) | `c75` (intent) | Match? | +|---|---|---|---| +| Action/intent name | `recordSymbolChange` | `recordSymbolChange` | identical | +| Input/output types | `SymbolChangeInput` (4 fields), `SymbolChangeOutput` (2 fields) | identical | identical | +| `required_operation_profile` | `continuum.profile.write/v1` | identical | identical | +| `core_evaluation_budget` | `{max_steps: 8, max_allocated_bytes: 1024, max_output_bytes: 256}` | identical | identical | +| Effect node | `target.replace`, input = `arg.0.symbolId` | identical | identical | +| Obstruction arm | `rejected` → `Call(graft.SymbolChangeObstruction.BasisConflict)` | identical | identical | +| Result record | `{symbolId, newVersionId}` | identical | identical | +| Target IR domain | `echo.span-ir/v1` | identical | identical | +| Target step / intrinsic | `effect=target.replace`, `target_intrinsic=echo.dpo@1.replace` | identical | identical | + +**Conclusion: the `intents`→`actions` rename was genuinely a pure terminology migration at the Core/Target-IR structural level** for this action shape — no semantic content changed alongside the vocabulary. This does not, by itself, explain the real WASM component's refusal (that refusal is about the *shape of the action being lowered*, unrelated to which vocabulary epoch compiled it) — the refusal would very likely also occur if this same 4-field action shape were somehow presented to whatever the checked component's `main`-epoch equivalent would be, since the narrowness appears to be about the lowering closure's supported input shapes, not about `intent` vs. `action` terminology. + +## What's proven, precisely + +- The Graft-shaped action is stable across the one known Edict vocabulary migration — same structure, same semantics, only spelling differs. +- Echo's real, unmodified, checked WASM lowerer component can be genuinely invoked against a novel action without touching any checked artifact. +- That invocation currently refuses our action with a specific, typed, actionable reason (`UnsupportedSemantics`) — a real boundary of what Echo's currently-shipped provider accepts, not a synthetic or hypothetical one. + +## What's still not proven + +- Bundle assembly and full runtime execution (the `TrustedRuntimeHost`/scheduler/receipt path in `warp-core`) — not reached, since the lowering step itself refused. +- Whether a *smaller or differently-shaped* Graft action (closer to the original fixture's exact single-field, single-record shape) would clear the real component's narrower closure — untested, and a plausible, well-scoped next experiment if this rung is worth continuing before the Echo epoch migration happens. +- Anything about stale-basis enforcement or persistence semantics (unchanged from `lowering-evidence.md`). + +## Deferred (not started, and intentionally out of scope for this spike) + +The Echo compiler-epoch migration (`intent`→`action`, rewriting the checked fixture, regenerating the digest-locked provider package via `echo-wesley-gen`, re-verifying via `scripts/verify-edict-provider-host-v1.sh`, running Echo's full test suite) remains a separate, explicitly scoped Echo-side change — not performed here, and not something this design packet's evidence depends on. See `lowering-evidence.md`'s "Next steps" section for the acceptance gate it should carry when someone picks it up. diff --git a/docs/design/warp-lawpack-v0/record-symbol-change.c75-pinned.edict b/docs/design/warp-lawpack-v0/record-symbol-change.c75-pinned.edict new file mode 100644 index 00000000..8075afcf --- /dev/null +++ b/docs/design/warp-lawpack-v0/record-symbol-change.c75-pinned.edict @@ -0,0 +1,29 @@ +package graft.warp@1; + +type SymbolChangeInput = { + symbolId: String, + priorVersionId: String, + changeKind: String, + signatureDigest: String, +}; + +type SymbolReceipt = { + symbolId: String, + newVersionId: String, +}; + +type SymbolChangeOutput = { + symbolId: String, + newVersionId: String, +}; + +intent recordSymbolChange(input: SymbolChangeInput) returns SymbolChangeOutput + profile graft.structuralWrite + basis none + budget <= graft.tinyBudget +{ + let receipt: SymbolReceipt = target.replace(input.symbolId) + else { rejected(reason) => graft.SymbolChangeObstruction.BasisConflict }; + + return { symbolId: input.symbolId, newVersionId: receipt.newVersionId }; +} diff --git a/docs/design/warp-lawpack-v0/record-symbol-change.edict b/docs/design/warp-lawpack-v0/record-symbol-change.edict new file mode 100644 index 00000000..f5120a20 --- /dev/null +++ b/docs/design/warp-lawpack-v0/record-symbol-change.edict @@ -0,0 +1,29 @@ +package graft.warp@1; + +type SymbolChangeInput = { + symbolId: String, + priorVersionId: String, + changeKind: String, + signatureDigest: String, +}; + +type SymbolReceipt = { + symbolId: String, + newVersionId: String, +}; + +type SymbolChangeOutput = { + symbolId: String, + newVersionId: String, +}; + +action recordSymbolChange(input: SymbolChangeInput) returns SymbolChangeOutput + profile graft.structuralWrite + basis none + budget <= graft.tinyBudget +{ + let receipt: SymbolReceipt = target.replace(input.symbolId) + else { rejected(reason) => graft.SymbolChangeObstruction.BasisConflict }; + + return { symbolId: input.symbolId, newVersionId: receipt.newVersionId }; +} diff --git a/docs/design/warp-lawpack-v0/shape-isolation-evidence.md b/docs/design/warp-lawpack-v0/shape-isolation-evidence.md new file mode 100644 index 00000000..beb6fae8 --- /dev/null +++ b/docs/design/warp-lawpack-v0/shape-isolation-evidence.md @@ -0,0 +1,43 @@ +# Shape-Isolation Evidence: Is `UnsupportedSemantics` About the Epoch, the Shape, or Identity? + +**Question this answers:** the previous rung (`pinned-epoch-evidence.md`) found that Echo's real, checked, unmodified WASM lowerer component refuses `recordSymbolChange` with `UnsupportedSemantics`, while the native oracle lowerer accepts it. Before investing in the Echo `intent`→`action` migration, this experiment asks: is that refusal actually about the `intent`/`action` vocabulary epoch, about the *shape* of the action (field count/complexity), or about something else entirely? + +## Method + +Three variants, same pinned epoch (Edict `c75c3f5`), same pipeline, same unmodified checked Echo package, compared directly: + +1. **Positive control** — the exact original fixture (`package a.b@1; ... intent t(...) ...`), completely unmodified. +2. **Single-field Graft action** — `package graft.warp.tick@1; ... intent recordTick(input: TickInput) ...`, matching the original's exact shape (one `String` field, one `target.replace` effect, one obstruction) but with different package/type/action/obstruction names. +3. **Four-field `recordSymbolChange`** — the action from the prior rung, re-run for direct comparison. + +## A bug found and fixed along the way (worth recording honestly) + +The first run of this experiment produced a false result: the positive control (variant 1) was refused with `InvalidSemanticArtifact: "Core coordinate does not equal its bound reference"` — not the expected success. Root cause was a bug in this harness, not a finding about Echo: `echo_request_from_core_bytes` hardcoded the bound-artifact coordinate label as `"graft.warp@1"` regardless of the actual compiled source's real package coordinate, which happened to accidentally match variant 3's package but not variant 1's (`a.b@1`). Fixed by threading the real `core.coordinate` through instead of a hardcoded string. Re-ran all three variants after the fix. Recording this because a wrong "control failed" result, left uninvestigated, would have invalidated the entire experiment. + +## Result (after the fix) + +| Variant | Coordinate | Result | +|---|---|---| +| 1. Original fixture (unmodified) | `a.b@1` | **Accepted** — real lowering response returned | +| 2. Single-field Graft action (same shape, different names) | `graft.warp.tick@1` | **Refused** — `UnsupportedSemantics` | +| 3. Four-field `recordSymbolChange` | `graft.warp@1` | **Refused** — `UnsupportedSemantics` (same as before) | + +Variant 2's refusal is structurally identical to variant 3's — same `kind: UnsupportedSemantics`, same diagnostic code and message, `subject` correctly reflecting each variant's actual submitted coordinate (confirming the check is genuinely identity-aware, not a fixed/hardcoded error unrelated to input). + +## Conclusion + +**The refusal is not about shape, and not about the vocabulary epoch.** A single-field action, structurally identical to the accepted original fixture in every way except its names, is refused exactly like the more complex four-field action. Only the *exact original fixture's identity* (`a.b@1` / `t` / its specific type and obstruction names) is accepted. Echo's own docs' own description of this component as implementing "narrow v0.9 target slices" understates just how narrow: this is not a general-but-limited lowering engine for `target.replace`-shaped actions — **it is, in effect, an allowlist of one specific, exactly-named, already-reviewed source occurrence.** + +## Why this matters for planning + +This reframes the Echo `intent`→`action` migration's expected payoff. Migrating the vocabulary epoch would **not**, by itself, unblock a *new* Graft action executing against Echo's real checked lowerer component — the component doesn't generalize to new action identities at all, regardless of which keyword compiled them. Getting any new action (Graft's or otherwise) through this specific checked component requires the component itself to be extended or regenerated to recognize that new action's identity — which is a materially different, and likely larger, task than a vocabulary rename. This is consistent with `schemas/edict-provider/package/README.md`'s framing of the checked package as "the first exact digest-locked Echo Edict provider distribution" bound to one specific, reviewed fixture (`ORIGIN.toml`: "Materialized reviewed source fixture... whitespace is part of this exact Echo-owned occurrence") — not a general-purpose compiler target yet. + +## What this does not change + +- The Core/Target-IR structural findings from `lowering-evidence.md` and `pinned-epoch-evidence.md` still hold: `recordSymbolChange` compiles and lowers correctly via the native compiler pipeline at both Edict epochs. +- The `intent`↔`action` rename is still confirmed as pure terminology with no semantic drift, for whichever action shape is being compiled. +- Bundle assembly, full runtime execution, persistence semantics, and stale-basis enforcement remain unattempted. + +## Recommended next question, if this line of work continues + +Not "should we migrate Echo's pin" — first: **what would it take to get the checked lowerer component to recognize a second action identity at all**, independent of vocabulary epoch? That's likely the actual gating question for "Echo hosts Graft's data model" being real, and it may turn out to require the same regeneration machinery (`echo-wesley-gen`) the epoch migration would also need — worth learning that once, deliberately, rather than assuming the epoch migration alone gets there. diff --git a/docs/design/warp-lawpack-v0/teaching-a-new-action-research.md b/docs/design/warp-lawpack-v0/teaching-a-new-action-research.md new file mode 100644 index 00000000..c23cc340 --- /dev/null +++ b/docs/design/warp-lawpack-v0/teaching-a-new-action-research.md @@ -0,0 +1,43 @@ +# Research: What Would It Take to Teach Echo's Checked Component a Second Action? + +**Scope: research only, per explicit instruction. Nothing in Echo was modified, built, or regenerated for this note.** This answers the question `shape-isolation-evidence.md` raised: is the checked lowerer component's narrowness fixable by artifact regeneration alone, or does it require real source changes? + +## The mechanism, found directly in source + +`~/git/echo/crates/echo-edict-provider-lowerer/src/lib.rs` (2,044 lines) and its sibling `~/git/echo/crates/echo-edict-provider-verifier/src/lib.rs` (1,862 lines) each hardcode the exact identity of the one fixture they accept, as literal Rust constants: + +```rust +const CORE_COORDINATE: &str = "a.b@1"; +const OPERATION_COORDINATE: &str = "a.b@1.t"; +const OPERATION_INPUT_TYPE: &str = "a.b@1.Input"; +const OPERATION_OUTPUT_TYPE: &str = "a.b@1.Output"; +const OPERATION_PROFILE: &str = "continuum.profile.write/v1"; +``` + +The lowering function checks the submitted Core module against these literally, in sequence — coordinate equality, exact type-shape equality (`expected_core_types()` hand-constructs the precise `Input`/`Output`/`Receipt` record shapes, each a single `String` field named `id`), a hard requirement that the module contain **exactly one** action (`let [(intent_key, intent)] = intents.as_slice() else { return Err(unsupported_semantics(coordinate)); }`), the action name literally equal to `"t"`, and the input/output type names, operation profile, and evaluation budget all checked against further hardcoded expectations. Any mismatch at any of these checks returns `UnsupportedSemantics` — which is exactly the refusal observed for every variant except the byte-for-byte original in `shape-isolation-evidence.md`. + +This is not a data-driven allowlist (e.g., a registered list of known action identities the component reads from its semantic-inputs) — it's Rust `if`/`==` control flow, compiled directly into the WASM component. **The component is source code that recognizes one fixture, not an engine configured with one fixture's data.** + +## What this means concretely + +Teaching this component a second action requires changing `echo-edict-provider-lowerer`'s and `echo-edict-provider-verifier`'s actual Rust source — there is no configuration, generated-artifact, or authority-facts change that adds a new accepted action on its own. Two paths exist: + +1. **Add a second hardcoded branch** (extend the existing pattern: check "is this coordinate `X`? handle it this way. Is it `Y`? handle it that way."). Cheap per-action, but doesn't generalize — every future new action needs its own new hardcoded branch, by hand, forever. This is consistent with the component's own self-description as "narrow v0.9 target slices" that are explicitly *not* meant to be the long-term shape. +2. **Genuinely generalize the lowerer/verifier** to interpret an arbitrary Core module structurally — i.e., port the real algorithm `edict-syntax`'s own native `lower_to_target_ir` already implements (the "oracle" this whole experiment has been comparing against) into these WASM-component crates. This is the architecturally correct fix, but a real rewrite of ~2,000 (lowerer) + ~1,800 (verifier) lines of hand-written recognizer logic into a genuine interpreter — not a quick change. + +Notably, **`echo-edict-provider-lowerer` does not depend on `edict-syntax` at all** (its own `Cargo.toml`: only `echo-edict-canonical`, plus `wit-bindgen` for the `wasm32` target). This looks like a deliberate trust-boundary choice — the untrusted WASM guest doesn't link the full compiler — which means path 2 can't be "just call edict-syntax's function from here"; it requires either re-deriving that logic independently inside the WASM-safe crate, or establishing a new, deliberate way to share the algorithm across the trust boundary without violating whatever isolation reason led to this separation in the first place. That reason isn't stated in the code and would be worth asking about before assuming path 2 is straightforward. + +## Downstream cost, either path + +Whichever path, changing this component's Rust source means: +- Recompiling to WASM via the existing `xtask` pipeline (`xtask/src/provider_lowerer_component.rs`, 2,868 lines as of this writing — a real, already-built tool, not something to write from scratch). +- Regenerating the full digest-locked distribution via `echo-wesley-gen --bin echo-edict-provider-package` (new component bytes → new digests throughout the manifest chain). +- Re-verifying via `scripts/verify-edict-provider-host-v1.sh`. +- Updating or regenerating any of Echo's own tests that assert exact digests against the current component (`RAW_TARGET_IR_SHA256`, `DOMAIN_TARGET_IR_SHA256`, etc. in `host_contract.rs` — these are byte-identity-pinned to the current component's exact output and would need updating once its behavior changes). +- Running Echo's full test suite. + +This is on top of, not instead of, the `intent`→`action` fixture rewrite already scoped in `lowering-evidence.md`'s acceptance gate — the two are separable but likely worth doing together if either is undertaken, since both touch the same digest-locked package. + +## Bottom line + +"Teach the component a new action" is a real, moderate-to-large engineering project — closer in size to "write a second small compiler backend" (path 2) or "hand-maintain a small but growing set of hardcoded recognizers forever" (path 1) than to a configuration change. Neither path is something to start opportunistically; both deserve the same explicit scoping (an Echo issue/branch, a decision on path 1 vs. path 2, a real acceptance gate) already proposed for the vocabulary migration in `lowering-evidence.md`. This research doesn't recommend either path — it establishes, with certainty rather than inference, what each one actually costs. diff --git a/docs/feedback/2026-07-14-codex-agent-native-campaigns.md b/docs/feedback/2026-07-14-codex-agent-native-campaigns.md new file mode 100644 index 00000000..090687d3 --- /dev/null +++ b/docs/feedback/2026-07-14-codex-agent-native-campaigns.md @@ -0,0 +1,136 @@ +--- +title: "Codex feedback: build the agent's working set" +date: "2026-07-14" +author: "Codex" +external_source: "/Users/james/git/blog/Graft/feedback/codex-agent-native-campaigns.md" +external_source_sha256: "d7be1d945730630abbe466b014b6c52121956becaae6d66ee4277aa5a5d86354" +inspected_graft_commit: "5b9c4a866c9938e31cd159c0639ee9dedbaed1ea" +--- + +# Codex feedback: build the agent's working set + +## Witness purpose + +This document preserves the campaign-driving facts from a long-running Codex +session in a location that travels with Graft. The original feedback remains in +the blog checkout named in frontmatter and is pinned by SHA-256. This is a +bounded evidence witness, not a verbatim duplicate of the 645-line source. + +The session involved issue planning, worktrees, RED/GREEN implementation, +GitHub review, CI, merges, cross-session handoffs, and coordination across +Edict, Echo, and Wesley. It inspected Graft at `5b9c4a86` on +`cycle/real-echo-structural-history-provider`. + +## What worked + +- `safe_read -> outline -> read_range` was the right agent protocol. +- An 11,713-byte, 243-line README became a 3,450-byte structural outline with a + useful jump table; the targeted range returned in about 210 ms. +- One-call `workspace_open` had already removed the earlier multi-call daemon + onboarding ramp. +- Explicit `cwd` routing was the right basis for authorized multi-repo work. +- Versioned schemas, reason codes, bounded reads, and projection metadata were + valuable and should be preserved. +- Symbol-oriented history remained Graft's most differentiated capability. + +## Measured control-plane friction + +| Call | Useful subject | Returned response | +| :--- | ---: | ---: | +| `safe_read` of a 666-byte file | 666 bytes | 1,696 bytes | +| `doctor` | routine health summary | 11,093 bytes | +| `activity_view(limit: 12)` | 12 bounded events | 17,292 bytes | +| `workspace_status` | a handful of identifiers | 1,179 bytes | + +Cold structural queries also interrupted the agent loop: + +| Operation | Observed latency | +| :--- | ---: | +| First `workspace_open` for Graft | about 4.5 s | +| `code_find("*receipt*")` | about 16.3 s | +| `code_find("*state*")` | about 8.3 s | +| `code_show(buildReceiptResult)` | about 4.3 s | +| `read_range` | about 0.2 s | + +The exact timings are observations, not deterministic test thresholds. + +## Confirmed truth defects + +- A first observation of 253 pre-existing untracked paths was described as + “Bulk transition movement” even though Graft had no earlier observation from + which to establish movement. +- Attribution remained unknown until a separate actor attachment. +- Historical session multiplicity could be presented as shared-worktree + concurrency even when no live overlapping actor footprint was visible. +- `activity_view` explicitly disclosed that write events were not captured. + +## Recommended product direction + +Make Graft the agent's evidence-linked campaign control plane while keeping +every default response small, fast, deterministic, and progressively +discoverable. + +The source recommended this delivery order: + +1. Compact receipt policy with full receipt retrieval. +2. Summary-first `doctor` and `activity_view`. +3. Readiness and phase timing for potentially slow calls. +4. Correct baseline-versus-transition semantics. +5. Explicit actor bootstrap and live-concurrency semantics. +6. Structured checkpoint/resume. +7. Durable job handles. +8. Multi-repo campaign and evidence projections. +9. Write-event coverage and typed GitHub projections. +10. Capability discovery and transactional governed patches. + +## Product-boundary routing + +Repository direction narrows that proposal: + +- Graft owns bounded repo/worktree facts, receipts, diagnostics, observation + truth, generic evidence references, and provider-neutral readiness. +- Method owns backlog lanes, goalposts, executable slices, blocker DAGs, retros, + merge/release truth, and the “next executable slice” query. +- A typed GitHub/Method integration owns issue, review-thread, CI, and merge-gate + projections. +- Echo and git-warp provider selection does not block the first control-plane + response and truth-correctness campaign. + +## Quantitative milestone targets + +- Warm orientation or resume under one second as an observed product target, + not a machine-dependent unit-test assertion. +- Default control-plane metadata below 20% of ordinary read payload, or below + 512 bytes when the domain payload is tiny. +- Default `doctor()` below 2 KiB and naming one next action. +- First observation never claiming unobserved movement. +- Live concurrency distinct from historical multi-session activity. +- Capability discovery without reading the entire MCP registry. +- List-shaped responses eventually exposing byte limits, truncation, and + cursors through a separately designed pagination contract. + +## Independent corroboration + +A Claude session recorded in local-only commit `f9df3e7e` reached the same +receipt conclusion on 2026-07-10. A 465-byte file produced a 1,461-byte +response, with the receipt accounting for roughly two thirds of the payload. +That agent recommended slim per-call receipts while retaining cumulative +aggregates in `stats` or an explicit verbose/full mode. The same report said +burden accounting itself was valuable; the correction is progressive +disclosure, not removal of evidence. + +This corroborating measurement is embedded here because the local commit is +not part of `origin/main` and may not be available to remote reviewers. + +## First campaign pull + +The first Graft campaign pulled from this witness is defined in +`docs/design/SURFACE_agent-working-set-control-plane.md`: + +1. baseline observation truth; +2. compact-by-default, full-on-request receipts; +3. summary-first doctor and activity views; and +4. compact capability discovery over registered tools. + +Later session lifecycle, durable operation, evidence, integration, and governed +mutation campaigns remain explicitly deferred. diff --git a/docs/invariants/versioned-output-schemas.md b/docs/invariants/versioned-output-schemas.md index 7eb755a1..4b5e57c1 100644 --- a/docs/invariants/versioned-output-schemas.md +++ b/docs/invariants/versioned-output-schemas.md @@ -9,9 +9,22 @@ the shared output-schema registry. This applies to: - MCP tool responses +- MCP `tools/list` discovery contracts and successful `structuredContent` - CLI `--json` responses - shared machine-readable receipt structure +The strict versioned schema registry is the validation authority. MCP discovery +publishes a deterministic bounded projection of that authority because the +protocol requires an object-root output schema and agents should not pay to +receive the complete recursive audit grammar for every tool. The projection +must retain all top-level fields, exact `_schema` identity, scalar and +discriminant constraints, and compact/full receipt posture. + +Successful MCP results carry both native `structuredContent` and canonical JSON +compatibility text. Parsing the text must produce exactly the structured value; +neither representation may be generated independently. Errors may remain +text-only with `isError: true`, as permitted by MCP. + ## If violated Agents and wrappers have to reverse-engineer output shapes from examples @@ -22,6 +35,10 @@ breaking changes slip in without any explicit version boundary. - `src/contracts/output-schemas.ts` declares every machine-readable surface and schema version +- `src/contracts/mcp-discovery-output-schemas.ts` derives bounded MCP discovery + schemas from the strict registry - MCP responses include `_schema` +- every registered MCP tool advertises an object-root `outputSchema` +- successful MCP responses provide equivalent text and `structuredContent` - CLI commands with JSON output include `_schema` - tests validate emitted payloads against the declared schemas diff --git a/docs/method/backlog/bad-code/CLEAN_mcp-output-schema-authority-duplicated-and-drifted.md b/docs/method/backlog/bad-code/CLEAN_mcp-output-schema-authority-duplicated-and-drifted.md new file mode 100644 index 00000000..51da0097 --- /dev/null +++ b/docs/method/backlog/bad-code/CLEAN_mcp-output-schema-authority-duplicated-and-drifted.md @@ -0,0 +1,50 @@ +--- +title: "MCP output-schema authority is duplicated and already drifted" +feature: surface +kind: bad-code +legend: CLEAN +lane: bad-code +priority: 2 +effort: M +status: open +reported: 2026-07-17 +--- + +# MCP output-schema authority is duplicated and already drifted + +## Problem + +`src/contracts/output-schemas.ts` is the live strict runtime authority, but +`src/contracts/output-schema-mcp.ts` still owns a second exhaustive +`McpToolName` body-schema map consumed by CLI rendering and daemon-status +decoding. The intended thin-barrel split was later re-expanded without retiring +the old assembly. + +The copies are no longer equivalent. For example, the canonical `code_find` +contract is an object-root union that includes middleware refusal, while the +split assembly still declares one object shape. Adding an MCP tool currently +requires updating both exhaustive maps merely to typecheck. + +## Risk + +Runtime validation, CLI decoding, generated JSON Schema, and agent-facing MCP +discovery can describe different legal outputs while each local test appears +green. Future contract changes may update only one authority, as already +happened for `code_find`. + +## Desired Outcome + +Restore one executable MCP body-schema authority and make every strict runtime, +CLI, JSON-Schema, and bounded-discovery consumer derive from it. Keep the +refactor behavior-preserving; any intentional contract difference must be +named and tested rather than encoded by file choice. + +## Acceptance Criteria + +- One exhaustive MCP body-schema map is authoritative. +- Runtime and CLI consumers import or derive from that same map. +- Representative union/refusal mutation tests fail if a consumer projects a + stale body contract. +- The old split files are either the real authority again or removed. +- Graveyard and architecture documentation describe the resulting structure + truthfully. diff --git a/docs/method/backlog/bad-code/CLEAN_method-drift-can-silently-pass-with-zero-playback-questions.md b/docs/method/backlog/bad-code/CLEAN_method-drift-can-silently-pass-with-zero-playback-questions.md new file mode 100644 index 00000000..8d057fc2 --- /dev/null +++ b/docs/method/backlog/bad-code/CLEAN_method-drift-can-silently-pass-with-zero-playback-questions.md @@ -0,0 +1,47 @@ +--- +title: "Method drift can silently pass with zero playback questions" +feature: method +kind: bad-code +legend: CLEAN +lane: bad-code +priority: 2 +effort: S +status: open +reported: 2026-07-17 +--- + +# Method drift can silently pass with zero playback questions + +## Problem + +Method's drift check reported a clean result for the active agent-working-set +cycle while also reporting that it scanned zero playback questions. The design +uses `## Playback questions`, but the extractor recognizes only the exact +case-sensitive heading `## Playback Questions`. Graft's `.method.json` also +points the drift scan at `tests/`, while most campaign evidence lives under +`test/unit/` and `test/integration/`. + +## Risk + +A cycle can receive a green-looking drift result without comparing its actual +playback obligations with most of its executable evidence. Operators may then +mistake absence of extracted input for proof of alignment. + +## Desired Outcome + +Drift validation fails closed or reports an explicit degraded result when an +active design yields zero playback questions, and Graft's configured evidence +roots cover its real test layout. + +## Acceptance Criteria + +- An active design with zero extracted playback questions cannot produce an + unqualified clean result. +- Heading recognition is either deliberately normalized or enforced by a + design-packet validator with an actionable error. +- Graft's Method configuration scans `tests/`, `test/unit/`, and + `test/integration/`, or supports an equivalent complete root configuration. +- Regression fixtures cover heading-case drift and a test suite split across + all configured roots. +- Retro witnesses record the number of questions and test descriptions that + actually participated in drift analysis. diff --git a/docs/method/backlog/bad-code/CLEAN_mutating-tools-need-prepared-response-contract.md b/docs/method/backlog/bad-code/CLEAN_mutating-tools-need-prepared-response-contract.md new file mode 100644 index 00000000..98743dd5 --- /dev/null +++ b/docs/method/backlog/bad-code/CLEAN_mutating-tools-need-prepared-response-contract.md @@ -0,0 +1,39 @@ +# Mutating tools need a prepared-response contract + +## Problem + +MCP output validation is part of the success boundary, but several daemon and +control-plane operations compute their result only after changing runtime +state. If a future contract drift rejects that result, the client can receive +an error after an effect has already occurred and may retry unsafely. + +The agent working-set control-plane campaign now protects the non-idempotent +`graft_edit` filesystem path: it validates the complete domain response before +writing file bytes. That repair does not by itself provide two-phase execution +for workspace authorization, binding, monitor lifecycle, causal attachment, or +future multi-object mutations. + +## Required outcome + +- Define a reusable plan/validate/commit result contract for consequential MCP + operations. +- Bind an operation or idempotency identity before effect admission. +- Make retry posture explicit when an external or daemon-owned effect cannot be + rolled back. +- Ensure validation and serialization failures cannot masquerade as proof that + no effect occurred. +- Integrate with the operation journal and generation fencing already planned + by the managed-workspace-store design. + +## Acceptance evidence + +- Fault injection at every post-plan failure boundary. +- Tests for no-effect rejection, committed-and-acknowledged success, and + indeterminate external-effect posture. +- At-least-once retry tests proving operation identity prevents duplicate + consequence. + +## Scope + +This is architectural follow-up for the broader mutating control plane. It does +not reopen the exact-edit preflight repaired in the active campaign. diff --git a/docs/method/backlog/cool-ideas/SURFACE_git-graft-enhance-expanded-git-subcommands.md b/docs/method/backlog/cool-ideas/SURFACE_git-graft-enhance-expanded-git-subcommands.md deleted file mode 100644 index cfb33abf..00000000 --- a/docs/method/backlog/cool-ideas/SURFACE_git-graft-enhance-expanded-git-subcommands.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: git graft enhance expanded git subcommands -feature: surface -kind: trunk -legend: SURFACE -lane: cool-ideas -requirements: - - git graft enhance --since first slice (shipped) -acceptance_criteria: - - "Enhance supports intentionally selected git-adjacent subcommands beyond --since" - - "Each subcommand has a bounded model and renderer" - - "Human output and JSON output are both deterministic" - - "Unsupported git commands fail with clear guidance instead of silently passing through" ---- - -# git graft enhance expanded git subcommands - -Preserve the broader original enhancement vision after the first -`git graft enhance --since` release slice has proven the product shape. - -Candidate future surfaces: - -- `git graft enhance log` -- `git graft enhance diff` -- `git graft enhance show` -- `git graft enhance blame` -- `git graft enhance shortlog` -- `git graft enhance stash` -- `git graft enhance cherry-pick` -- `git graft enhance merge` -- `git graft enhance branch` -- `git graft enhance tag` -- `git graft enhance bisect` - -Each of these should be pulled as its own narrow cycle or grouped only -when the implementation model is genuinely shared. The goal is not to -wrap every Git command by default; the goal is to add structural -annotations where Graft already has truthful, bounded data. - -## Non-goals - -- No catch-all raw Git wrapper. -- No implementation without focused playback questions. -- No command that requires new WARP indexing semantics as hidden scope. diff --git a/docs/method/backlog/cool-ideas/SURFACE_on-demand-exact-mcp-output-contracts.md b/docs/method/backlog/cool-ideas/SURFACE_on-demand-exact-mcp-output-contracts.md new file mode 100644 index 00000000..d378d529 --- /dev/null +++ b/docs/method/backlog/cool-ideas/SURFACE_on-demand-exact-mcp-output-contracts.md @@ -0,0 +1,49 @@ +--- +title: on-demand exact MCP output contracts +feature: surface +kind: trunk +legend: SURFACE +lane: cool-ideas +requirements: + - bounded MCP outputSchema discovery +acceptance_criteria: + - "An agent can request the exact strict versioned contract for one tool without receiving every tool contract" + - "The fetched contract is byte-identical in meaning to Graft's runtime validation authority" + - "Schema identity, version, digest, and compatibility posture are explicit" + - "Ordinary tools/list remains within its aggregate and per-tool byte budgets" +--- + +# On-demand exact MCP output contracts + +Keep ordinary MCP discovery small while allowing an agent, host, or wrapper to +inspect the complete strict contract for one selected tool when it genuinely +needs deep audit fields. + +The first native-output slice projects 47 strict schemas totaling roughly 498 +KiB into about 50 KiB of advertised `outputSchema`. That is the correct default +aperture, but the projection intentionally describes nested objects and array +members shallowly. A schema-aware client may sometimes need the exact compact +receipt, full receipt, summary, full diagnostic, or nested evidence grammar. + +Candidate surface: + +- an MCP resource template such as + `graft://schemas/mcp/{tool}/{version}`; +- canonical JSON Schema generated from the same Zod authority used at runtime; +- a digest over canonical schema bytes so cached copies can be compared; +- a small index resource listing tool, version, digest, and byte size; +- optional package exports for non-MCP in-process consumers, justified + separately from the protocol surface. + +The resource should be preferable to a new tool call because reading a contract +is discovery, not a repository operation. The server must never inline all +exact contracts into `tools/list`, and it must not maintain a second manually +written schema family. + +## Non-goals + +- No external hosted registry as the only source of truth. +- No schema prose generated independently from executable validators. +- No automatic loading of exact schemas into every agent context. +- No weakening of the bounded discovery projection. +- No promise that every MCP host renders or caches schema resources equally. diff --git a/docs/method/backlog/dependency-dag.dot b/docs/method/backlog/dependency-dag.dot index 05032944..878205e5 100644 --- a/docs/method/backlog/dependency-dag.dot +++ b/docs/method/backlog/dependency-dag.dot @@ -48,7 +48,7 @@ digraph backlog { } subgraph cluster_bad_code { - label="bad-code (17)" labeljust=l fontsize=9 fontcolor="#555555" + label="bad-code (20)" labeljust=l fontsize=9 fontcolor="#555555" style=rounded color="#cccccc" bgcolor="#fafafa" bad_code_CLEAN_backlog_dag_unresolved_internal_dependencies [label="CLEAN-backlog-dag-unresolved-internal-dependencies - S" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_canonical_codec_seams_diverge [label="CLEAN-canonical-codec-seams-diverge - M" fillcolor="#F6B3B3" penwidth=2] @@ -58,8 +58,11 @@ digraph backlog { bad_code_CLEAN_descriptor_checker_lacks_schema_validation [label="CLEAN-descriptor-checker-lacks-schema-validation - M" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_echo_witness_test_fixture_duplication [label="CLEAN-echo-witness-test-fixture-duplication - S" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_generated_svg_dag_diff_noise_obscures_review [label="CLEAN-generated-svg-dag-diff-noise-obscures-review - S" fillcolor="#F6B3B3" penwidth=2] + bad_code_CLEAN_mcp_output_schema_authority_duplicated_and_drifted [label="CLEAN-mcp-output-schema-authority-duplicated-and-drifted - M" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_method_close_timeout_produces_no_retro_artifact [label="CLEAN-method-close-timeout-produces-no-retro-artifact - M" fillcolor="#F6B3B3" penwidth=2] + bad_code_CLEAN_method_drift_can_silently_pass_with_zero_playback_questions [label="CLEAN-method-drift-can-silently-pass-with-zero-playback-questions - S" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_method_retro_witness_can_overstate_validation [label="CLEAN-method-retro-witness-can-overstate-validation - M" fillcolor="#F6B3B3" penwidth=2] + bad_code_CLEAN_mutating_tools_need_prepared_response_contract [label="CLEAN-mutating-tools-need-prepared-response-contract" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_pr_review_comments_not_actionability_typed [label="CLEAN-pr-review-comments-not-actionability-typed - M" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_receipt_cumulative_bytes_avoided_stays_zero [label="CLEAN-receipt-cumulative-bytes-avoided-stays-zero - S" fillcolor="#F6B3B3" penwidth=2] bad_code_CLEAN_remaining_structural_warp_reads_bypass_structural_reading_port [label="CLEAN-remaining-structural-warp-reads-bypass-structural-reading-port - M" fillcolor="#F6B3B3" penwidth=2] @@ -118,13 +121,13 @@ digraph backlog { cool_ideas_SURFACE_backlog_sitrep_command [label="SURFACE-backlog-sitrep-command - M" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_bijou_daemon_control_plane_actions [label="SURFACE-bijou-daemon-control-plane-actions - L" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_bijou_daemon_status_live_refresh [label="SURFACE-bijou-daemon-status-live-refresh - M" fillcolor="#D4E8F7" penwidth=1] - cool_ideas_SURFACE_git_graft_enhance_expanded_git_subcommands [label="SURFACE-git-graft-enhance-expanded-git-subcommands" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_graft_review_pr_number_adapter [label="SURFACE-graft-review-pr-number-adapter - M" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_ide_native_graft_integration [label="SURFACE-ide-native-graft-integration - XL" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_init_dry_run [label="SURFACE-init-dry-run - S" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_local_history_dag_render_mode_and_count_legend [label="SURFACE-local-history-dag-render-mode-and-count-legend - S" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_non_codex_instruction_bootstrap_parity [label="SURFACE-non-codex-instruction-bootstrap-parity - M" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_offer_rename_refactor [label="SURFACE-offer-rename-refactor - L" fillcolor="#D4E8F7" penwidth=1] + cool_ideas_SURFACE_on_demand_exact_mcp_output_contracts [label="SURFACE-on-demand-exact-mcp-output-contracts" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_opened_workspace_paths [label="SURFACE-opened-workspace-paths - M" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_pr_merge_gate_sitrep_command [label="SURFACE-pr-merge-gate-sitrep-command - M" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_terminal_activity_browser_tui [label="SURFACE-terminal-activity-browser-tui - L" fillcolor="#D4E8F7" penwidth=1] diff --git a/docs/method/backlog/dependency-dag.svg b/docs/method/backlog/dependency-dag.svg index 26199b27..bd07ee4c 100644 --- a/docs/method/backlog/dependency-dag.svg +++ b/docs/method/backlog/dependency-dag.svg @@ -4,12 +4,12 @@ - + backlog - -Active backlog graph generated from docs/method/backlog/*/*.md + +Active backlog graph generated from docs/method/backlog/*/*.md cluster_up_next @@ -22,28 +22,28 @@ cluster_bad_code - -bad-code (17) + +bad-code (20) cluster_cool_ideas - + Cool Ideas (91) cluster_external - -External blockers + +External blockers cluster_unresolved - -Unresolved internal refs + +Unresolved internal refs cluster_legend - -Legend + +Legend @@ -208,7 +208,7 @@ WARP-symbol-history-timeline - S - + cool_ideas_WARP_temporal_structural_search WARP-temporal-structural-search - M @@ -223,341 +223,359 @@ bad_code_CLEAN_backlog_dag_unresolved_internal_dependencies - -CLEAN-backlog-dag-unresolved-internal-dependencies - S + +CLEAN-backlog-dag-unresolved-internal-dependencies - S bad_code_CLEAN_canonical_codec_seams_diverge - -CLEAN-canonical-codec-seams-diverge - M + +CLEAN-canonical-codec-seams-diverge - M bad_code_CLEAN_code_show_parser_runtime_not_ready - -CLEAN-code-show-parser-runtime-not-ready - S + +CLEAN-code-show-parser-runtime-not-ready - S bad_code_CLEAN_coderabbit_path_filters_skip_method_docs - -CLEAN-coderabbit-path-filters-skip-method-docs - S + +CLEAN-coderabbit-path-filters-skip-method-docs - S bad_code_CLEAN_completed_backlog_card_retirement_is_manual - -CLEAN-completed-backlog-card-retirement-is-manual - M + +CLEAN-completed-backlog-card-retirement-is-manual - M bad_code_CLEAN_descriptor_checker_lacks_schema_validation - -CLEAN-descriptor-checker-lacks-schema-validation - M + +CLEAN-descriptor-checker-lacks-schema-validation - M bad_code_CLEAN_echo_witness_test_fixture_duplication - -CLEAN-echo-witness-test-fixture-duplication - S + +CLEAN-echo-witness-test-fixture-duplication - S bad_code_CLEAN_generated_svg_dag_diff_noise_obscures_review - -CLEAN-generated-svg-dag-diff-noise-obscures-review - S + +CLEAN-generated-svg-dag-diff-noise-obscures-review - S - + +bad_code_CLEAN_mcp_output_schema_authority_duplicated_and_drifted + +CLEAN-mcp-output-schema-authority-duplicated-and-drifted - M + + + bad_code_CLEAN_method_close_timeout_produces_no_retro_artifact - -CLEAN-method-close-timeout-produces-no-retro-artifact - M + +CLEAN-method-close-timeout-produces-no-retro-artifact - M + + + +bad_code_CLEAN_method_drift_can_silently_pass_with_zero_playback_questions + +CLEAN-method-drift-can-silently-pass-with-zero-playback-questions - S - + bad_code_CLEAN_method_retro_witness_can_overstate_validation - -CLEAN-method-retro-witness-can-overstate-validation - M + +CLEAN-method-retro-witness-can-overstate-validation - M + + + +bad_code_CLEAN_mutating_tools_need_prepared_response_contract + +CLEAN-mutating-tools-need-prepared-response-contract - + bad_code_CLEAN_pr_review_comments_not_actionability_typed - -CLEAN-pr-review-comments-not-actionability-typed - M + +CLEAN-pr-review-comments-not-actionability-typed - M - + bad_code_CLEAN_receipt_cumulative_bytes_avoided_stays_zero - -CLEAN-receipt-cumulative-bytes-avoided-stays-zero - S + +CLEAN-receipt-cumulative-bytes-avoided-stays-zero - S - + bad_code_CLEAN_remaining_structural_warp_reads_bypass_structural_reading_port - -CLEAN-remaining-structural-warp-reads-bypass-structural-reading-port - M + +CLEAN-remaining-structural-warp-reads-bypass-structural-reading-port - M - + bad_code_CLEAN_sha256_stable_id_helper_duplication - -CLEAN-sha256-stable-id-helper-duplication - S + +CLEAN-sha256-stable-id-helper-duplication - S - + bad_code_CLEAN_technical_teardown_contract_ledger_can_stale_without_tests - -CLEAN-technical-teardown-contract-ledger-can-stale-without-tests - M + +CLEAN-technical-teardown-contract-ledger-can-stale-without-tests - M - + bad_code_SURFACE_session_global_workspace_activation_races - -SURFACE-session-global-workspace-activation-races + +SURFACE-session-global-workspace-activation-races - + bad_code_WARP_bijou_local_history_stale_after_branch_transition - -WARP-bijou-local-history-stale-after-branch-transition - M + +WARP-bijou-local-history-stale-after-branch-transition - M - + cool_ideas_bounded_neighborhood_for_references bounded-neighborhood-for-references - S - + cool_ideas_CI_001_causal_collapse_visualizer - -CI-001-causal-collapse-visualizer - L + +CI-001-causal-collapse-visualizer - L - + cool_ideas_CI_002_deterministic_scenario_replay CI-002-deterministic-scenario-replay - L - + cool_ideas_CI_003_mcp_native_diff_protocol CI-003-mcp-native-diff-protocol - M - + cool_ideas_CLEAN_CODE_parallel_agent_merge_shared_file_loss CLEAN-CODE-parallel-agent-merge-shared-file-loss - M - + cool_ideas_CLEAN_contract_ledger_schema_and_lint CLEAN-contract-ledger-schema-and-lint - M - + cool_ideas_CLEAN_generated_artifact_review_boundary CLEAN-generated-artifact-review-boundary - S - + cool_ideas_CORE_agent_handoff_protocol CORE-agent-handoff-protocol - M - + cool_ideas_CORE_align_structural_evidence_with_continuum_evidence_posture CORE-align-structural-evidence-with-continuum-evidence-posture - + cool_ideas_CORE_auto_focus CORE-auto-focus - L - + cool_ideas_CORE_backlog_retirement_assistant CORE-backlog-retirement-assistant - M - + cool_ideas_CORE_capture_range CORE-capture-range - S - + cool_ideas_CORE_code_lawyer_local_pr_audit_command CORE-code-lawyer-local-pr-audit-command - L - + cool_ideas_CORE_constructor_in_disguise_lint CORE-constructor-in-disguise-lint - M - + cool_ideas_CORE_context_budget_forecasting CORE-context-budget-forecasting - M - + cool_ideas_CORE_conversation_primer CORE-conversation-primer - M - + cool_ideas_CORE_cross_session_resume CORE-cross-session-resume - S - + cool_ideas_CORE_descriptor_package_registry_for_structural_contracts CORE-descriptor-package-registry-for-structural-contracts - M - + cool_ideas_CORE_echo_capability_gap_ledger CORE-echo-capability-gap-ledger - S - + cool_ideas_CORE_echo_descriptor_to_fake_witness_generator CORE-echo-descriptor-to-fake-witness-generator - M - + cool_ideas_CORE_generated_model_reverse_id_content_binding CORE-generated-model-reverse-id-content-binding - + cool_ideas_CORE_graft_as_teacher CORE-graft-as-teacher - S - + cool_ideas_CORE_graft_teach_learning_receipts CORE-graft-teach-learning-receipts - S - + cool_ideas_CORE_graft_tool_client CORE-graft-tool-client - M - + cool_ideas_CORE_horizon_of_readability CORE-horizon-of-readability - M - + cool_ideas_CORE_lagrangian_policy CORE-lagrangian-policy - XL - + cool_ideas_CORE_migrate_to_slice_first_reads - -CORE-migrate-to-slice-first-reads + +CORE-migrate-to-slice-first-reads - + cool_ideas_CORE_multi_agent_conflict_detection CORE-multi-agent-conflict-detection - L - + cool_ideas_CORE_policy_playground CORE-policy-playground - S - + cool_ideas_CORE_policy_profiles CORE-policy-profiles - M - + cool_ideas_CORE_self_tuning_governor CORE-self-tuning-governor - M - + cool_ideas_CORE_session_knowledge_map CORE-session-knowledge-map - S - + cool_ideas_CORE_speculative_read_cost CORE-speculative-read-cost - S - + cool_ideas_CORE_structural_session_replay CORE-structural-session-replay - M - + cool_ideas_CORE_weslaw_semantic_law_for_structural_history CORE-weslaw-semantic-law-for-structural-history - M - + cool_ideas_CORE_wesley_codec_and_observer_plan_generation_for_structural_history CORE-wesley-codec-and-observer-plan-generation-for-structural-history - M - + cool_ideas_CORE_wire_primitives_into_runtime CORE-wire-primitives-into-runtime - M - + cool_ideas_METHOD_retro_recovery_mode METHOD-retro-recovery-mode - M - + cool_ideas_monitor_tick_ceiling_tracking monitor-tick-ceiling-tracking - S - + cool_ideas_WARP_background_indexing WARP-background-indexing - M @@ -570,13 +588,13 @@ blocked_by/blocking - + cool_ideas_SURFACE_active_causal_workspace_status SURFACE-active-causal-workspace-status - M - + cool_ideas_SURFACE_ide_native_graft_integration SURFACE-ide-native-graft-integration - XL @@ -589,428 +607,428 @@ blocked_by/blocking - + cool_ideas_SURFACE_asap_queue_health_dashboard SURFACE-asap-queue-health-dashboard - M - + cool_ideas_SURFACE_attach_to_existing_causal_session SURFACE-attach-to-existing-causal-session - M - + cool_ideas_SURFACE_backlog_card_lifecycle_linter SURFACE-backlog-card-lifecycle-linter - M - + cool_ideas_SURFACE_backlog_sitrep_command SURFACE-backlog-sitrep-command - M - + cool_ideas_SURFACE_bijou_daemon_control_plane_actions SURFACE-bijou-daemon-control-plane-actions - L - + cool_ideas_SURFACE_bijou_daemon_status_live_refresh SURFACE-bijou-daemon-status-live-refresh - M - - -cool_ideas_SURFACE_git_graft_enhance_expanded_git_subcommands - -SURFACE-git-graft-enhance-expanded-git-subcommands - - + cool_ideas_SURFACE_graft_review_pr_number_adapter - -SURFACE-graft-review-pr-number-adapter - M + +SURFACE-graft-review-pr-number-adapter - M - + cool_ideas_SURFACE_init_dry_run - -SURFACE-init-dry-run - S + +SURFACE-init-dry-run - S - + cool_ideas_SURFACE_local_history_dag_render_mode_and_count_legend - -SURFACE-local-history-dag-render-mode-and-count-legend - S + +SURFACE-local-history-dag-render-mode-and-count-legend - S - + cool_ideas_SURFACE_non_codex_instruction_bootstrap_parity - -SURFACE-non-codex-instruction-bootstrap-parity - M + +SURFACE-non-codex-instruction-bootstrap-parity - M - + cool_ideas_SURFACE_offer_rename_refactor - -SURFACE-offer-rename-refactor - L + +SURFACE-offer-rename-refactor - L + + + +cool_ideas_SURFACE_on_demand_exact_mcp_output_contracts + +SURFACE-on-demand-exact-mcp-output-contracts - + cool_ideas_SURFACE_opened_workspace_paths - -SURFACE-opened-workspace-paths - M + +SURFACE-opened-workspace-paths - M - + cool_ideas_SURFACE_pr_merge_gate_sitrep_command - -SURFACE-pr-merge-gate-sitrep-command - M + +SURFACE-pr-merge-gate-sitrep-command - M - + cool_ideas_SURFACE_terminal_activity_browser_tui - -SURFACE-terminal-activity-browser-tui - L + +SURFACE-terminal-activity-browser-tui - L - + cool_ideas_traverse_plus_query_hydration_helper - -traverse-plus-query-hydration-helper - S + +traverse-plus-query-hydration-helper - S - + cool_ideas_WARP_adaptive_projection_selection - -WARP-adaptive-projection-selection - L + +WARP-adaptive-projection-selection - L - + cool_ideas_WARP_agent_action_provenance - -WARP-agent-action-provenance - XL + +WARP-agent-action-provenance - XL cool_ideas_WARP_agent_action_provenance->cool_ideas_CI_001_causal_collapse_visualizer - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_causal_write_tracking - -WARP-causal-write-tracking - L + +WARP-causal-write-tracking - L cool_ideas_WARP_agent_action_provenance->cool_ideas_WARP_causal_write_tracking - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_intent_and_decision_events - -WARP-intent-and-decision-events - M + +WARP-intent-and-decision-events - M cool_ideas_WARP_agent_action_provenance->cool_ideas_WARP_intent_and_decision_events - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_provenance_dag - -WARP-provenance-dag - L + +WARP-provenance-dag - L cool_ideas_WARP_agent_action_provenance->cool_ideas_WARP_provenance_dag - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_auto_breaking_change_detection - -WARP-auto-breaking-change-detection - L + +WARP-auto-breaking-change-detection - L - + cool_ideas_WARP_budget_elasticity - -WARP-budget-elasticity - M + +WARP-budget-elasticity - M - + cool_ideas_WARP_causal_blame_for_staged_artifacts - -WARP-causal-blame-for-staged-artifacts - L + +WARP-causal-blame-for-staged-artifacts - L - + cool_ideas_WARP_codebase_entropy_trajectory - -WARP-codebase-entropy-trajectory - M + +WARP-codebase-entropy-trajectory - M - + cool_ideas_WARP_counterfactual_refactoring - -WARP-counterfactual-refactoring - XL + +WARP-counterfactual-refactoring - XL cool_ideas_WARP_codebase_entropy_trajectory->cool_ideas_WARP_counterfactual_refactoring - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_codebase_signature - -WARP-codebase-signature - L + +WARP-codebase-signature - L - + cool_ideas_WARP_structural_impact_prediction - -WARP-structural-impact-prediction - XL + +WARP-structural-impact-prediction - XL cool_ideas_WARP_counterfactual_refactoring->cool_ideas_WARP_structural_impact_prediction - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_degeneracy_warning - -WARP-degeneracy-warning - M + +WARP-degeneracy-warning - M - + cool_ideas_WARP_drift_sentinel - -WARP-drift-sentinel - M + +WARP-drift-sentinel - M - + cool_ideas_WARP_footprint_parallelism - -WARP-footprint-parallelism - XL + +WARP-footprint-parallelism - XL - + cool_ideas_WARP_graft_pack - -WARP-graft-pack - M + +WARP-graft-pack - M - + cool_ideas_WARP_grouped_aggregate_queries - -WARP-grouped-aggregate-queries - M + +WARP-grouped-aggregate-queries - M - + cool_ideas_WARP_intentional_degeneracy_privacy - -WARP-intentional-degeneracy-privacy - M + +WARP-intentional-degeneracy-privacy - M - + cool_ideas_WARP_minimum_viable_context - -WARP-minimum-viable-context - M + +WARP-minimum-viable-context - M - + cool_ideas_WARP_outline_diff_commit_trailer - -WARP-outline-diff-commit-trailer - S + +WARP-outline-diff-commit-trailer - S - + cool_ideas_WARP_projection_safety_classes - -WARP-projection-safety-classes - M + +WARP-projection-safety-classes - M cool_ideas_WARP_provenance_dag->cool_ideas_WARP_causal_blame_for_staged_artifacts - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_reasoning_trace_replay - -WARP-reasoning-trace-replay - M + +WARP-reasoning-trace-replay - M cool_ideas_WARP_provenance_dag->cool_ideas_WARP_reasoning_trace_replay - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_rulial_heat_map - -WARP-rulial-heat-map - L + +WARP-rulial-heat-map - L - + cool_ideas_WARP_semantic_drift_in_sessions - -WARP-semantic-drift-in-sessions - M + +WARP-semantic-drift-in-sessions - M - + cool_ideas_WARP_semantic_merge_conflict_prediction - -WARP-semantic-merge-conflict-prediction - L + +WARP-semantic-merge-conflict-prediction - L - + cool_ideas_WARP_session_filtration - -WARP-session-filtration - L + +WARP-session-filtration - L - + cool_ideas_WARP_shadow_structural_workspaces - -WARP-shadow-structural-workspaces - XL + +WARP-shadow-structural-workspaces - XL - + cool_ideas_WARP_speculative_merge - -WARP-speculative-merge - XL + +WARP-speculative-merge - XL - + cool_ideas_WARP_stale_docs_checker - -WARP-stale-docs-checker - M + +WARP-stale-docs-checker - M cool_ideas_WARP_stale_docs_checker->cool_ideas_WARP_drift_sentinel - - -blocked_by/blocking + + +blocked_by/blocking - + cool_ideas_WARP_structural_drift_detection - -WARP-structural-drift-detection - M + +WARP-structural-drift-detection - M - + cool_ideas_WARP_symbol_heatmap - -WARP-symbol-heatmap - M + +WARP-symbol-heatmap - M - + cool_ideas_WARP_technical_debt_curvature - -WARP-technical-debt-curvature - L + +WARP-technical-debt-curvature - L - + external_git_warp_observer_geometry_ladder__Rung_2_4_ - -git-warp observer geometry ladder (Rung 2-4) + +git-warp observer geometry ladder (Rung 2-4) external_git_warp_observer_geometry_ladder__Rung_2_4_->cool_ideas_CORE_migrate_to_slice_first_reads - - -blocked_by_external + + +blocked_by_external - + unresolved_CLEAN_CODE_export_diff_semver_signature_as_patch - -missing: CLEAN_CODE_export-diff-semver-signature-as-patch + +missing: CLEAN_CODE_export-diff-semver-signature-as-patch unresolved_CLEAN_CODE_export_diff_semver_signature_as_patch->cool_ideas_WARP_auto_breaking_change_detection - - -blocked_by + + +blocked_by unresolved_CLEAN_CODE_export_diff_semver_signature_as_patch->cool_ideas_WARP_semantic_merge_conflict_prediction - - -blocked_by + + +blocked_by - + leg_v08 - -v0.8.0 + +v0.8.0 - + leg_v07 - -v0.7.0 + +v0.7.0 - + leg_bad - -bad-code + +bad-code - + leg_idea - -cool-ideas + +cool-ideas - + leg_external - -external blocker + +external blocker - + leg_unresolved - -unresolved ref + +unresolved ref diff --git a/docs/method/retro/SURFACE_agent-working-set-control-plane/SURFACE_agent-working-set-control-plane.md b/docs/method/retro/SURFACE_agent-working-set-control-plane/SURFACE_agent-working-set-control-plane.md new file mode 100644 index 00000000..2074ddea --- /dev/null +++ b/docs/method/retro/SURFACE_agent-working-set-control-plane/SURFACE_agent-working-set-control-plane.md @@ -0,0 +1,121 @@ +--- +title: "SURFACE: Agent working-set control plane" +cycle: "SURFACE_agent-working-set-control-plane" +design_doc: "docs/design/SURFACE_agent-working-set-control-plane.md" +outcome: hill-met +drift_check: manual +--- + +# SURFACE: Agent working-set control plane Retro + +## Status + +Implementation, agent playback, human playback, drift reconciliation, and +exact-commit validation are complete. A complete pre-PR Code Lawyer pass then +found seven issues; every concrete branch defect was repaired and the final +runtime head passed 253 files and 1,910 tests. The operator approved the four +remaining human playback claims and explicitly authorized repair and push. +After publication, Codex found one additional rolling-upgrade compatibility +defect in the composed daemon-status reader. `55d21c14` repairs it through +advertised-input negotiation; focused compatibility tests, typecheck, lint, +and diff hygiene pass, while current-head PR CI remains the merge gate. + +## Summary + +This campaign turned Graft's high-volume MCP surface into a bounded, +schema-published agent control plane through five independently committed +slices: + +1. first observations now describe baseline state instead of inventing motion; +2. MCP receipts default to a sub-512-byte compact projection while explicit + full receipts preserve the audit surface; +3. `doctor` and `activity_view` default to sub-2-KiB summaries with explicit + full detail; +4. every successful public MCP tool publishes bounded native output discovery + and returns equivalent validated structured content plus compatibility text; +5. `capabilities` gives agents a bounded seven-family workflow map before they + spend context on the complete tool registry. + +The campaign preserved full audit access, cumulative accounting, existing tool +names, CLI compatibility, receiver-side authorization checks, and text-only MCP +error compatibility. It did not add session orchestration, filesystem watching, +history transport, transactional patches, or a hosted schema registry. + +## Playback Witness + +- [verification.md](witness/verification.md) + +## Manual Drift Reconciliation + +The implementation, strict schemas, advertised schemas, public documentation, +capability matrix, affected tests, full exact-commit suites, and non-goals were +compared manually with the design packet. No undocumented product-surface drift +remains after the repair addendum. All eight agent playback questions now have +executable evidence. + +Method's automated drift command was also run, but its apparent clean result was +rejected as evidence because it scanned zero playback questions and only the +configured `tests/` root. The parser requires a differently cased heading and +does not see most unit and integration evidence. That defect is recorded in +`CLEAN_method-drift-can-silently-pass-with-zero-playback-questions.md`. + +## What surprised us? + +- Existing dirty files are state, not movement. The first slice exposed how + easily an observer can manufacture causal transitions by confusing baseline + acquisition with comparison. +- Compact receipt byte accounting encountered a real fixed-point edge: a + rounded derived compression ratio can change the very payload length it is + supposed to describe. +- Legal long Git refs broke the first diagnostic budget despite individually + bounded fields, reinforcing that only the complete encoded response is the + product boundary. +- The installed MCP SDK could not directly advertise a strict object-root union + for `file_outline`; bounded discovery projection was required for correctness + as well as context economy. +- Graft already had two exhaustive MCP output-schema authorities, and they had + drifted. Slice 5 wired the new contract through both but filed the duplication + as debt instead of hiding a campaign-expanding refactor. +- Endpoint-shaped event identity was insufficient for causal history: an + A-to-B-to-A sequence could reuse a node and create a cycle. Durable history + requires occurrence identity as well as semantic identity. +- Requesting a full MCP response did not by itself preserve CLI-v1 body shape. + Compatibility requires an explicit projection plus frozen structural + witnesses, not an unchanged version string. +- Strict response validation belongs before an irreversible edit. Graft now + preflights the exact-edit body; broader daemon mutation planning remains a + separate transactional design problem. + +## What would we do differently? + +- Use the installed MCP SDK and complete-response byte measurements from the + first RED witness, not after pure schema tests pass. +- Validate every implementation commit from a clean exact checkout before + interpreting a live-worktree full-suite failure. +- Make output-schema authority singular before expanding the public tool set. +- Bound auxiliary-agent review calls with a short timeout and immediately fall + back to local review; this closeout lost substantial wall-clock time waiting + on a stalled review call even though the implementation evidence was ready. +- End delegated review work at the first missed progress deadline. The repair + cycle again lost wall-clock time to a hung agent-launch call even while five + completed repairs had already reached the remote branch. + +## Follow-up Items + +- `CLEAN_mcp-output-schema-authority-duplicated-and-drifted.md`: restore one + executable MCP output-schema authority. +- `CLEAN_method-drift-can-silently-pass-with-zero-playback-questions.md`: make + zero-input drift checks explicit and scan Graft's real evidence roots. +- `SURFACE_on-demand-exact-mcp-output-contracts.md`: add exact on-demand schema + distribution only if clients need more than bounded native discovery. +- `CLEAN_mutating-tools-need-prepared-response-contract.md`: generalize the + exact-edit preflight into plan/validate/commit and idempotency semantics for + consequential daemon and foreign effects. + +## Automation Limitations + +The normal Method close path was not used. It would rerun and overwrite witness +capture in the operator's intentionally dirty worktree, where an unrelated +untracked backlog card changes the dependency-DAG input. The manual packet +therefore separates focused local results, contaminated live-worktree results, +clean exact-commit evidence, drift limitations, and final human attestation. diff --git a/docs/method/retro/SURFACE_agent-working-set-control-plane/witness/verification.md b/docs/method/retro/SURFACE_agent-working-set-control-plane/witness/verification.md new file mode 100644 index 00000000..5348128c --- /dev/null +++ b/docs/method/retro/SURFACE_agent-working-set-control-plane/witness/verification.md @@ -0,0 +1,204 @@ +# Verification: Agent working-set control plane + +## Evidence Posture + +This witness distinguishes exact committed evidence from local focused checks +and from known-contaminated live-worktree output. It does not treat an unrun, +interrupted, or zero-input check as green. + +## Exact Slice Witnesses + +| Slice | Exact commit | Clean isolated result | +| --- | --- | --- | +| Baseline observation truth | `da1956f7` | 246 files, 1,856 tests passed | +| Compact receipt policy | `cb1958f1` | 246 files, 1,868 tests passed | +| Summary-first diagnostics | `7dec2a9c` | 247 files, 1,873 tests passed | +| MCP-native structured output | `dccd2d13` | 249 files, 1,889 tests passed | +| Capability discovery | `8af1aa3a` | 251 files, 1,902 tests passed | + +The generated dependency-DAG SVG child `2f6d843a` separately passed its two +relevant files and 15 tests from a clean detached worktree. + +## Code Lawyer Repair Commits + +| Severity | Finding | Commit | Focused evidence | +| --- | --- | --- | --- | +| P1 | Repeated transition occurrences reused event identity | `5948e250` | Distinct A-to-B-to-A IDs; linear acyclic graph. | +| P1 | Snapshot deltas inherited stale Git transition evidence | `1e577e3a` | Checkout then edit persists null Git kind/refs/created epoch. | +| P2 | Legacy WARP `identityId` failed strict validation | `3c9b0344` | `code_find` and ambiguous `code_show` receipt regressions. | +| P2 | Same-second baseline reflog appeared fresh | `32137c12` | Unchanged baseline returns null; fresh reflog remains visible. | +| P1 | Diagnostic CLI JSON changed beneath version 1 | `f74d212e` | Complete schema digests match `origin/main@c3885dab`; nested payload projection passes. | +| P2 | Exact-edit validation occurred after file mutation | `4561d5a0` | Injected body-contract failure leaves file bytes unchanged. | +| P4 | Agent onboarding Markdown was inconsistent | `7f73d011` | README/MCP/SETUP flows aligned; `capabilities` added to setup reference. | +| Test repair | Context guard fixtures omitted the new method | `34c42b7d` | 22 focused unit/playback tests pass. | +| P2 (PR review) | Daemon status sent the v2-only receipt control to pre-v2 daemon tools | `55d21c14` | Old advertised schemas omit the field, v2 schemas request full receipts, and the current-daemon CLI integration passes. | + +History-focused validation passed four files and 46 tests. Schema/CLI focused +validation passed receipt, projection, rendering, frozen-schema, and real-peer +witnesses. Mutation-boundary validation passed five files and 47 tests before +the construction-contract fixture repair. + +## Final Combined-Head Validation + +The clean linked repair worktree at `34c42b7d` produced: + +```text +pnpm typecheck +PASS + +pnpm lint +PASS + +git diff --check origin/main...HEAD +PASS + +pnpm exec vitest run --maxWorkers 2 +Test Files 253 passed (253) +Tests 1910 passed (1910) +PASS +``` + +The first combined run exposed six stale context-guard fixtures and one +resource-pressure timeout in the dead-symbol playback. The fixtures were +repaired in `34c42b7d`; the dead-symbol playback passed immediately in the +focused diagnostic run and the unchanged default full-suite rerun then passed. +The only final diagnostic was the pre-existing Node `DEP0205 +module.register()` deprecation warning. + +## Slice 5 Exact-Commit Validation + +The following commands ran from a clean detached worktree at `8af1aa3a` and a +Docker image built from that exact tree: + +```text +docker build --target test -t graft-test:slice5-8af1aa3a . +PASS + +docker run --rm --network none graft-test:slice5-8af1aa3a pnpm typecheck +PASS + +docker run --rm --network none graft-test:slice5-8af1aa3a \ + pnpm exec vitest run --maxWorkers 2 +Test Files 251 passed (251) +Tests 1902 passed (1902) + +docker run --rm --network none graft-test:slice5-8af1aa3a pnpm lint +PASS + +git diff --check +PASS + +git status --porcelain +clean +``` + +The Docker build also ran the package build successfully. + +## Affected Compatibility Sweep + +An independent affected-surface sweep passed 43 files and 341 tests: + +- contracts, schema discovery, API/library surface, capability matrix, and + release gates: 14 files / 79 tests; +- repo-local MCP invocation, receipts, burden, metrics, and diagnostics: + 12 files / 158 tests; +- daemon, worker, workspace, and routing compatibility with one worker: + 17 files / 104 tests. + +The only diagnostic was Node's pre-existing `DEP0205 module.register()` +deprecation warning. + +## Post-Publication Review Validation + +Codex reviewed PR #233 at `5a5a4c22` and found that the composed +`graft daemon status` reader unconditionally sent `receipt: "full"`. A CLI from +this campaign could therefore fail against a still-running pre-v2 daemon whose +strict tool inputs did not recognize that field. + +Repair commit `55d21c14` lists the daemon's advertised tools once and includes +the full-receipt control only when the selected tool advertises it. The repair +passed: + +```text +pnpm typecheck +PASS + +pnpm lint +PASS + +git diff --check +PASS + +pnpm exec vitest run \ + test/unit/cli/daemon-status.test.ts \ + test/integration/mcp/daemon-status-cli.test.ts +Test Files 2 passed (2) +Tests 3 passed (3) +``` + +The 253-file / 1,910-test exact-head witness above predates this repair. The +focused evidence proves the changed compatibility seam; green PR CI on the +current head remains required before merge readiness is claimed. + +## Capability and Schema Measurements + +| Measurement | Result | Contract | +| --- | ---: | ---: | +| Repo-local registered tools | 34 | exact runtime registry | +| Daemon registered tools | 48 | exact combined registry | +| Repo-local summary response | 1,393 bytes | at most 2,048 | +| Daemon summary response | 1,390 bytes | at most 2,048 | +| Largest repo-local family detail | 1,233 bytes | at most 4,096 | +| Largest daemon family detail | 2,111 bytes | at most 4,096 | +| Strict schemas, aggregate | 511,333 bytes | measurement only | +| Largest strict schema (`doctor`) | 67,291 bytes | measurement only | +| Advertised schemas, aggregate | 52,446 bytes | at most 65,536 | +| Largest advertised schema (`doctor`) | 3,114 bytes | at most 8,192 | +| `capabilities` strict schema | 12,992 bytes | measurement only | +| `capabilities` advertised schema | 2,118 bytes | at most 8,192 | + +Installed-SDK tests prove repo-local and unbound-daemon discovery, strict and +advertised-schema validation, compact/full receipt compatibility, and exact +semantic equality between `structuredContent` and canonical JSON text. + +## Contaminated Live-Worktree Check + +The live-worktree full suite reported 250 passing files and 1,901 passing tests +with one failure. The dependency-DAG test saw the operator's unrelated untracked +`WARP_recoverable-workspace-content-history.md` card in the Docker context while +the committed graph correctly excluded it. No product code was changed to make +that contaminated result pass. The clean exact-commit suite above is the +authoritative result, and its dependency-DAG tests pass. + +## Review + +- The early Slice 5 review was insufficient and missed actionable defects. +- The complete pre-PR Code Lawyer pass found three P1, three P2, and one P4 + issue. All concrete active-branch defects were repaired before PR creation. +- The broad mutation-atomicity question was narrowed honestly: `graft_edit` + preflights its domain body before writing, while general daemon + plan/validate/commit semantics are filed as explicit architectural debt. +- Two delegated review/repair launches stalled. Their elapsed time is not + counted as review evidence; completed findings, commits, tests, and final + local validation are the evidence. +- Third-party PR-head review remains a later merge gate and is not claimed here. + +## Drift + +Manual design/code/test/documentation/non-goal reconciliation found no product +drift. Method's automated output said it scanned one active cycle, zero playback +questions, and 312 test descriptions. That result is not accepted as substantive +evidence; the zero-question and incomplete-root defects are filed separately. + +## Human Playback + +The operator directed the branch to proceed to pull request after receiving the +four remaining playback claims. That instruction is recorded as approval of: + +- explicit full receipt and diagnostic retrieval; +- default doctor usability and size; +- client-native output discovery and validation; +- bounded workflow discovery without reading the full MCP registry. + +All human and agent playback questions are now attested or evidenced, and the +retro outcome is `hill-met`. diff --git a/docs/plans/graft-source-architecture-and-managed-workspace-convergence.md b/docs/plans/graft-source-architecture-and-managed-workspace-convergence.md new file mode 100644 index 00000000..a987c700 --- /dev/null +++ b/docs/plans/graft-source-architecture-and-managed-workspace-convergence.md @@ -0,0 +1,1412 @@ +--- +title: "Graft source architecture and managed workspace convergence plan" +status: "proposed" +generated: "2026-07-18" +source_revision: "d1f5f14d" +source_branch: "cycle/agent-native-campaigns" +--- + +# Graft Source Architecture and Managed Workspace Convergence Plan + +## 1. Purpose + +This is the umbrella execution plan for two changes that must converge without +being confused: + +1. Graft should stop placing Graft-owned state in every target repository and + should make daemon-managed, multi-workspace operation the normal agent path. +2. MCP should become a thin agent-facing primary adapter instead of the de + facto home of workspace, history, routing, worker, cache, metrics, and WARP + application behavior. + +The intended user experience is simple: + +```text +An agent starts in an authorized working directory. +The agent calls a Graft tool. +Graft identifies and routes the workspace automatically. +Graft stores its own state under Graft's home, not in the target repository. +The tool succeeds without workspace_open or workspace_bind ceremony. +If authority is missing, Graft returns an exact repair instead of guessing. +Durable history remains an explicit, separately governed capability. +``` + +This plan integrates the source audit at `d1f5f14d` with existing design work. +It does not silently supersede the normative contracts in: + +- [Daemon-first Graft-managed workspace store](../design/SURFACE_graft-managed-workspace-store.md) +- [Managed workspace store roadmap](../design/SURFACE_graft-managed-workspace-store-roadmap.md) +- [Hexagonal architecture convergence plan](../design/CORE_hexagonal-architecture-convergence-plan.md) +- [Primary adapters thin use-case extraction](../design/CORE_primary-adapters-thin-use-case-extraction.md) +- [Composition roots for CLI, MCP, daemon, and hooks](../design/CORE_composition-roots-for-cli-mcp-daemon-and-hooks.md) +- [WARP port and adapter boundary](../design/CORE_warp-port-and-adapter-boundary.md) + +When this plan proposes a change to one of those documents, it labels the +change explicitly. Each implementation cycle still requires its own packet in +`docs/design/` under the repository's METHOD rules. This document coordinates +the campaign; it is not a substitute for PR-sized design packets. + +## 2. Truth Posture + +### 2.1 Verified current facts + +At the source revision named above: + +- `src/mcp/` contains approximately 19,102 lines of TypeScript. +- `src/operations/`, `src/ports/`, and `src/adapters/` together contain + approximately 10,395 lines. +- API and CLI each import MCP modules in seven source locations. +- `ToolContext` exposes 42 fields or methods. +- The repository-tool worker supplies 19 unsupported context methods. +- `workspace-router.ts` is approximately 1,000 lines. +- persisted local-history implementation under `src/mcp/` is approximately + 3,127 lines. +- only five MCP modules, totaling 1,276 lines, directly import the MCP SDK. +- a static import-graph audit found no runtime import cycles and three + type-only strongly connected components. +- plain `graft serve` selects repo-local operation. +- repo-local operation defaults to `/.graft` and ensures that + directory is excluded from Git. +- daemon-managed workspace observation exists, but it occurs as a side effect + of explicit daemon authorization. +- an unbound daemon tool call still instructs the caller to invoke + `workspace_bind`. +- daemon bootstrap suppresses directory and socket `chmod` failures while + daemon status unconditionally reports `sameUserOnly: true`. +- the MCP output-schema authority is duplicated and is already known to have + drifted for `code_find`. +- `pnpm lint` passed during the audit. +- the targeted architecture, registry, and path-boundary tests passed 29/29. + +### 2.2 Interpretation + +Graft is not architecturally unstructured. It has real contracts, ports, +secondary adapters, strict TypeScript, substantial runtime schemas, and +thoughtful workspace identity and confinement logic. + +The primary problem is ownership: + +> The application has been decomposed substantially, but much of that +> decomposition occurred inside the MCP namespace. + +The second problem is product-path divergence: + +> The daemon-managed model expresses the desired future, while the default +> agent path still uses the repo-local model that creates the unwanted state. + +Approximate planning posture, not a coverage measurement: + +| Campaign | Estimate | Confidence | +|---|---:|---| +| Strict hexagonal convergence | 45% | Medium | +| Managed-store critical path | G0 complete; G1 partial | High | +| Authorized resource router | Not started as the new model | High | +| No-pollution multi-workspace reads | Not yet demonstrated | High | +| No-open/no-bind agent experience | About 20% | Medium | + +## 3. Problem Statement + +### 3.1 Repository pollution + +The normal MCP launch path stores Graft-owned material at +`/.graft`. Graft then mutates repository exclusion state to hide +its own storage. This creates several product failures: + +- every repository acquires tool-specific material; +- agents and humans encounter `.graft` state unrelated to the product source; +- linked worktrees and sibling repositories have fragmented state; +- cleanup and retention become repository lifecycle problems; +- Graft must alter Git exclusion configuration merely to operate; +- a workspace tool is made to look like repository content. + +Target repository mutation may remain an explicit compatibility or portable +history feature. It must not remain the default cost of current-state reads. + +### 3.2 Agent-visible workspace ceremony + +The current daemon interface conflates four different concepts: + +| Concept | Question | Desired behavior | +|---|---|---| +| Identification | What workspace does this coordinate name? | Automatic | +| Authorization | May this client access it? | Host/operator granted; fail closed | +| Activation | Which workspace should this call use? | Automatic per invocation | +| Tracking consent | May Graft retain durable history? | Explicit and separately governed | + +An agent should not need to call `workspace_open`, `workspace_authorize`, and +`workspace_bind` merely to read a file in the working directory the host +already granted to it. + +Conversely, an arbitrary `cwd` string must never create authority. Automatic +routing is not automatic authorization. + +### 3.3 Primary-adapter ownership inversion + +The architecture declares API, CLI, and MCP to be peer primary adapters over +one application core. Current source still contains paths of the form: + +```text +CLI or API + -> MCP server/tool/context machinery + -> product behavior +``` + +The target direction is: + +```text +API ----\ +CLI -----+-> application use cases -> ports -> secondary adapters +MCP ----/ +``` + +MCP may expose an explicitly named compatibility bridge through the public API. +It must not remain the internal application bus. + +### 3.4 Mixed workspace-registry ownership + +`src/mcp/workspace-registry.ts` currently combines: + +- persisted record types; +- handwritten runtime validation; +- canonical identity encoding and hashing; +- pure incarnation decision rules; +- Node filesystem access; +- permission and path checks; +- lock acquisition and stale-lock reclamation; +- quarantine; +- atomic-file-shaped persistence; +- the `observeGitWorkspace` application use case. + +Moving the file unchanged to a generic `src/core/` directory would only rename +the ambiguity. The responsibilities must be separated. + +### 3.5 Security posture can outrun enforcement + +The daemon currently suppresses permission-tightening failures and then reports +`sameUserOnly: true`. Because daemon sessions do not independently authenticate +an operating-system principal in the examined request path, socket access +control is load-bearing. + +Security claims must be derived from verified platform evidence, not from the +configuration Graft attempted to establish. + +### 3.6 Application interfaces are too broad + +`ToolContext` is a transport-shaped service locator. It includes repository +reads, WARP, local history, daemon control, monitor lifecycle, workspace +authorization, receipts, caches, metrics, policy, and host capabilities. + +The worker's need to stub 19 methods as unsupported is direct evidence that the +interface violates capability minimization and interface segregation. + +### 3.7 Schema authority is duplicated + +Compact discovery, full schema publication, runtime validation, CLI decoding, +and rendering must derive from one executable output contract. Two exhaustive +maps create agent-visible drift while local tests can remain green. + +### 3.8 WARP remains ambient + +Several MCP tools and workspace components receive or open a concrete +`WarpApp`. The current `StructuralHistoryPort` identifies providers but does not +yet carry the operational history surface needed to remove concrete WARP from +the application. + +This must be corrected without coupling the workspace campaign to git-warp v19 +or prematurely claiming Echo parity. + +## 4. Campaign Objectives + +### 4.1 Primary objectives + +1. Default current-state Graft operation does not create or modify state in the + target repository. +2. An agent can call repository tools from an authorized working directory + without first calling an agent-visible open, bind, or authorize tool. +3. Authorization remains explicit, inspectable, revocable, and independent of + path identification. +4. Durable history remains explicit, scope-bounded, and independent of ordinary + current-state reads. +5. Workspace contracts, use cases, persistence capabilities, and Node storage + live in the correct architectural layers. +6. API, CLI, and MCP invoke the same application services. +7. MCP handlers validate protocol input, invoke one use case, and project + protocol output; they do not own business flow. +8. Daemon security posture reports only verified guarantees. +9. Runtime, CLI, compact discovery, and full schema publication derive from one + output-schema authority. +10. git-warp and future Echo providers enter through application-owned ports. + +### 4.2 Secondary objectives + +- Preserve current public behavior during extraction whenever behavior is not + intentionally changed by a separately reviewed slice. +- Preserve explicit repo-local mode as a compatibility and recovery path. +- Make migration and rollback safe across rolling CLI/daemon versions. +- Improve source navigability for humans and agents. +- Replace directory-level architectural aspiration with executable dependency + rules. +- Make denial actionable: name the missing grant and the lawful repair without + leaking unrelated registry state. + +### 4.3 Non-goals + +- Rewriting the entire repository in one campaign. +- Moving files solely to improve directory aesthetics. +- Creating a generic `src/core/` junk drawer. +- Replacing git-warp with Echo in the workspace-store campaign. +- Waiting for durable history, document projection, or every lifecycle command + before proving no-pollution current-state reads. +- Treating process readability as client authorization. +- Automatically importing, deleting, or rewriting existing repo-local `.graft` + history. +- Automatically removing old `.git/info/exclude` entries. +- Giving every agent registry-administration authority. +- Freezing speculative TypeScript interface sketches in this plan as public + contracts before their PR-sized design packets and fixtures exist. +- Testing Markdown wording as a product invariant. + +## 5. Product Laws + +1. **MCP is a transport, not the application.** +2. **A path hint is not a capability.** `cwd` helps resolve a target; it does + not grant access to that target. +3. **Identification, authorization, activation, and tracking consent are + separate decisions.** +4. **Current-state reads do not require durable history.** +5. **Default reads do not mutate the target repository.** +6. **Security posture is observed, not asserted.** A failed permission or ACL + check fails closed or produces a truthfully weaker posture. +7. **One request has one explicit route context.** No repository-scoped tool + silently depends on mutable global active-workspace state. +8. **One executable schema authority describes each output.** Compact and full + projections may differ in detail but not in legal meaning. +9. **Application services depend on ports, never concrete host adapters.** +10. **Concrete WARP and Echo clients stay behind secondary adapters.** +11. **Foreign or legacy history is not silently reclassified as native + evidence.** +12. **Denied access does not disclose the registry.** Repair information is + bounded to what the caller may know. +13. **No state migration is implicit merely because the default runtime + changes.** +14. **Every rollout has a reversible compatibility posture.** +15. **Every PR delivers one reviewable behavioral or architectural claim.** + +## 6. Target Product Behavior + +### 6.1 Normal agent path + +The desired normal path is: + +```text +MCP client launches Graft from /work/repo-a + -> bridge records a host-provided session root/grant + -> Graft connects to or starts the local daemon + -> agent calls safe_read({ path: "src/a.ts" }) + -> route resolver uses the session default coordinate + -> authorization evaluator verifies the coordinate is inside the grant + -> workspace identity is resolved automatically + -> workspace observation is created/refreshed under GRAFT_HOME + -> safe-read use case runs + -> MCP projects compact or full output plus a scoped receipt +``` + +The agent does not call `workspace_open` or `workspace_bind`. + +### 6.2 Cross-repository path + +When the host grants two sibling repositories, one daemon session can route +both: + +```text +authorized roots: + /work/server + /work/sdk + +safe_read server/src/a.ts +safe_read sdk/src/b.ts +``` + +Both reads succeed, neither repository receives `.graft`, and a third +unauthorized sibling is refused without being enumerated. + +### 6.3 Missing authority + +If the requested coordinate is not authorized, Graft returns a structured +obstruction similar to: + +```json +{ + "ok": false, + "errorCode": "WORKSPACE_NOT_AUTHORIZED", + "resource": { + "kind": "requested-coordinate" + }, + "repair": { + "kind": "request-host-grant", + "requestedScope": "opaque-or-redacted" + } +} +``` + +The exact public contract belongs in the G2 router packet. The invariant is +that an agent receives a lawful repair, not instructions to manufacture its own +authority by calling another tool. + +### 6.4 Durable tracking + +The first successful read may establish or refresh an observation record. It +must not silently enable durable structural history. + +Durable tracking requires a separate plan and consent/authority flow that +names: + +- tracking scope; +- provider; +- physical storage; +- retention; +- maintenance authority; +- expected cost; +- target-repository mutation posture; +- pause, revocation, export, and deletion behavior. + +### 6.5 Explicit repo-local compatibility mode + +Repo-local mode remains available during and after rollout: + +```text +graft serve --runtime repo-local +``` + +Its behavior must be explicit in receipts and diagnostics. It may continue to +use `/.graft` for compatibility, but it must never be selected +silently after the daemon-backed default has shipped unless the documented +fallback policy permits it. + +## 7. Target Architecture + +### 7.1 Layer responsibilities + +| Layer | Owns | Must not own | +|---|---|---| +| Contracts | Runtime schemas, command/result DTOs, value models, error vocabulary | Host I/O, MCP, CLI, concrete WARP | +| Operations | Use cases, orchestration, policy decisions, pure workspace decisions | Node APIs, MCP results, concrete adapters | +| Ports | Capabilities required by operations | Application orchestration, transport output | +| Secondary adapters | Node filesystem, Git, process, registry persistence, git-warp/Echo implementations | Product decisions, MCP schemas | +| Composition roots | Construction and dependency wiring | Reusable business logic | +| API/CLI/MCP | Edge validation, use-case invocation, edge-specific projection | Shared business flow | + +### 7.2 Proposed source topology + +Names are proposed and may be refined in the slice packet, but responsibility +must not be recombined: + +```text +src/ + contracts/ + workspaces/ + registry.ts + routes.ts + authorization.ts + receipts.ts + operations/ + workspaces/ + derive-workspace-identity.ts + decide-incarnation.ts + observe-git-workspace.ts + resolve-workspace-route.ts + explain-workspace-obstruction.ts + reads/ + safe-read.ts + read-range.ts + file-outline.ts + changed-since.ts + ports/ + workspace-registry-store.ts + workspace-authorization.ts + workspace-opener.ts + structural-history.ts + adapters/ + node-workspace-registry-store.ts + node-workspace-opener.ts + git-workspace-identity.ts + git-warp-structural-history.ts + echo-structural-history.ts + composition/ + repo-local.ts + daemon.ts + mcp/ + server.ts + stdio-server.ts + daemon-session-host.ts + daemon-stdio-bridge.ts + tools/ + safe-read.ts + workspace-status.ts + ... +``` + +If a new `src/composition/` directory is not adopted, equivalent thin assembly +may remain at entrypoint modules. The important rule is that construction is +isolated and behavior lives below it. + +### 7.3 Workspace registry seam + +The registry must be split into four independently testable parts. + +#### Contracts and pure identity + +Own: + +- versioned records; +- runtime schemas; +- typed IDs; +- canonical identity preimages; +- deterministic ID derivation; +- pure incarnation transition decisions. + +Do not own filesystem paths, locks, or MCP types. + +The canonical codec must not be imported from an Echo-named integration +module. It should be a foundational canonical-encoding facility or an explicit +pure dependency whose domain is identity, not execution. + +#### Registry port + +Illustrative, not frozen: + +```ts +interface WorkspaceRegistryStore { + loadInstallation(): Promise; + createInstallation(input: CreateInstallationRecord): Promise; + readWorkspace(id: WorkspaceId): Promise; + observeWorkspace(input: WorkspaceObservationCommit): Promise; + quarantine(input: RegistryQuarantineRequest): Promise; +} +``` + +The port must express generations or another concurrency primitive. It must not +pretend two independently published files form one transaction unless the +adapter can prove that property. + +#### Observation use case + +Own: + +- resolving existing records through the port; +- deciding reuse, suspect, replacement, or quarantine posture; +- constructing the next records; +- invoking one atomic publication operation; +- returning an application result independent of MCP receipts. + +#### Node adapter + +Own: + +- path layout; +- `lstat`, ownership, permission, and symlink enforcement; +- locks, leases, owner tokens, or generation fencing; +- atomic replacement and fsync policy; +- crash recovery; +- quarantine movement; +- JSON or future storage representation. + +### 7.4 State topology + +The conceptual managed layout is: + +```text +GRAFT_HOME/ + installation.json + daemon/ + runtime and transport state + workspaces/ + ws_/ + metadata + incarnations/ + wi_/ + metadata + derived-cache/ + history-bindings/ + receipts-or-references/ +``` + +The exact physical layout remains governed by the managed-store contract and +G1 design packets. + +A single global `workspaces.json` should not be the only source of truth. It +would create a contention and corruption domain. A derived index is acceptable +if individual versioned records remain authoritative and recoverable. + +### 7.5 Per-invocation route context + +Illustrative, not frozen: + +```ts +interface WorkspaceRouteRequest { + sessionId: string; + coordinate?: string; + workspaceHandle?: string; + operation: WorkspaceOperationKind; +} + +interface WorkspaceExecutionContext { + resourceScope: ResourceScope; + projectRoot?: string; + workspaceId?: string; + incarnationId?: string; + visibilityContext: VisibilityContext; + capabilityProfile: WorkspaceCapabilityProfile; + storagePosture: "managed" | "repo-local" | "memory" | "none"; +} +``` + +The route resolver must not mutate a session-global active workspace merely to +service one call. A session default coordinate may exist for clients that do +not supply per-call coordinates, but the resolved execution context is owned by +the invocation. + +### 7.6 Agent working directory transport + +MCP does not automatically make an agent's current working directory a trusted +per-call protocol field. Graft therefore needs an explicit integration rule: + +- a repo-local stdio server may use its launch directory as the host-provided + session root; +- a daemon stdio bridge may transmit its launch directory and configured + allowed roots as authenticated initialization metadata; +- clients capable of sending per-call coordinates may do so as route hints; +- a route hint is accepted only when it falls within a current grant; +- the daemon process's own working directory is never used as ambient + authority; +- integrations unable to communicate a trustworthy grant use a sandbox-local + reader, configured allow-roots, or an opened-handle flow. + +The G2 design packet must freeze how bridge initialization metadata is bound to +the authenticated daemon session. + +### 7.7 Read-family application service + +The first extracted application service should cover: + +- `safe_read`; +- `read_range`; +- `file_outline`; +- `changed_since` where its history requirement is accurately represented. + +It should receive narrow dependencies such as: + +```ts +interface GovernedReadDependencies { + opener: AuthorizedWorkspaceOpener; + filesystem: FileSystem; + parser: StructuralParser; + policy: ReadPolicy; + observations: ObservationStore; +} +``` + +It should return an application result. MCP receipts, CLI rendering, and API +types are projections over that result. + +### 7.8 Worker boundary + +Worker execution should no longer reconstruct all of `ToolContext` and call an +MCP handler. It should accept a versioned application command envelope: + +```text +parent adapter + -> validate edge input + -> resolve authorized execution context + -> submit versioned application command +worker + -> execute use case with exact dependencies + -> return application result and internal evidence +parent adapter + -> update metrics/cache state + -> project MCP/API/CLI result +``` + +Unsupported capabilities should be unrepresentable, not methods that fail only +when called. + +### 7.9 WARP and structural-history boundary + +The application-owned structural-history port must eventually cover the +operations required by local-history and structural tools. It must preserve +Graft's semantic distinctions rather than becoming a generic graph abstraction. + +Rules: + +- concrete `WarpApp` types stay in the git-warp adapter; +- Echo client types stay in the Echo adapter; +- provider evidence labels remain explicit; +- current-state reads do not open WARP unless the use case requires history; +- git-warp v19 work is integrated after the port shape is stable enough to + absorb it; +- Echo is not called native until the existing Echo integration gate is met. + +## 8. Security and Authority Model + +### 8.1 Daemon bootstrap truth + +Before daemon-first can become default, startup must: + +1. resolve the intended Graft home and socket path; +2. `lstat` every security-relevant parent under Graft control; +3. reject symlinks and unexpected file types; +4. establish restrictive permissions or platform ACLs; +5. verify the resulting ownership and access posture; +6. refuse startup when a required guarantee cannot be established; +7. derive reported posture from verified facts; +8. distinguish unsupported platform guarantees from verified guarantees. + +`sameUserOnly` may be `true` only when Graft possesses evidence for that exact +transport instance. + +### 8.2 Session authentication and grants + +The daemon must bind each request to an authenticated session and current +grants. A session identifier used for request routing is not by itself proof of +authorization. + +Grants must support: + +- opaque identifiers in receipts; +- resource scope; +- allowed operations; +- epoch or generation; +- expiry; +- revocation; +- least-privilege disclosure; +- host/operator provenance. + +### 8.3 Route resolution + +Route resolution must: + +- normalize the requested coordinate without widening it; +- verify it against the current grant before repository discovery can leak + information; +- use race-safe beneath-root traversal for supported platforms; +- refuse special files and unsupported path guarantees; +- distinguish workspace, opened root, scoped directory, and object-only + results; +- return `unknown` scope relations as denial, not permission; +- avoid inventing workspace identity for object-only reads. + +### 8.4 Registry disclosure + +Ordinary agent sessions must not receive a global workspace list. Status and +explanation surfaces are filtered to resources intersecting current authority. +Registry administration remains a trusted local operator capability. + +### 8.5 Cache and derived artifacts + +Derived artifacts inherit the visibility, policy, incarnation, and truth +constraints of their inputs. A cache hit from a broader historical grant is not +safe merely because the current requested path is authorized. + +## 9. Execution Plan + +Each slice below is intended to fit one reviewable PR. Refactoring, public +behavior changes, and state migrations should remain separate unless the slice +cannot be truthful without combining them. + +### Phase 0 — Baseline and security correction + +#### S0.1 — Verified daemon-home posture + +**Result:** daemon startup verifies directory type, ownership, permissions, and +platform support. + +**Acceptance:** + +- symlinked daemon home is refused; +- non-owned or unverifiable security-sensitive paths fail closed according to + documented platform posture; +- `chmod` failure cannot be ignored; +- status reflects observed posture; +- no normal repository behavior changes. + +#### S0.2 — Verified socket or named-pipe posture + +**Result:** socket permissions or named-pipe ACLs are established and verified. + +**Acceptance:** + +- Unix socket mode is re-read after creation; +- unsupported ACL guarantees are explicit; +- `sameUserOnly` is never unconditional; +- initialization without the required transport security is refused or routed + through an independently authenticated mode. + +#### S0.3 — Threat and regression matrix update + +**Result:** the managed-store threat matrix names the enforcement point and +tests for every daemon bootstrap claim. + +**Dependencies:** S0.1 and S0.2. + +### Phase 1 — Workspace registry architectural extraction + +#### S1.1 — Registry contracts and executable schemas + +Move versioned workspace, incarnation, installation, retention, and storage +records to contracts. Replace permissive handwritten checks with one runtime +schema authority. Reject negative budgets and TTLs. + +No persistence or behavior change belongs in this slice. + +#### S1.2 — Canonical workspace identity module + +Extract deterministic encoding, typed IDs, checked decoding, remote +sanitization, and pure incarnation decisions. Remove the registry's dependency +on an Echo-named CBOR module. + +Golden identity vectors must remain byte-for-byte stable unless a separately +versioned migration is approved. + +#### S1.3 — Registry persistence port + +Define exact load, observe/commit, quarantine, generation, and diagnostic +capabilities. The port must allow tests to model conflict, interruption, and +corruption without Node filesystem I/O. + +#### S1.4 — Node registry adapter + +Move path layout, confinement, permissions, locks, persistence, fsync, and +quarantine behind the port. + +Acceptance includes: + +- generation conflict tests; +- concurrent observer tests longer than the former one-second wait; +- stale-owner fencing rather than age-only lock deletion; +- interruption between write, fsync, rename, and parent fsync; +- workspace/incarnation publication consistency; +- malformed and unknown-major quarantine/refusal; +- restart recovery. + +#### S1.5 — Observe-workspace use case + +Move reconciliation and incarnation policy into an application service using +the registry port. Return application results independent of MCP. + +#### S1.6 — Daemon composition injection + +Construct the Node adapter at the daemon composition root and inject the +observation use case into the control plane. Leave a compatibility re-export at +the old MCP module only while imports migrate. + +#### S1.7 — Remove the compatibility re-export + +Delete the old MCP registry module after API, CLI, MCP, tests, and daemon code +depend on the application seam. + +### Phase 2 — Read use cases and worker decoupling + +#### S2.1 — `safe_read` application use case + +Extract governed current-state read behavior with exact ports and an +application result. Prove direct API and MCP parity. + +#### S2.2 — `read_range` application use case + +Use the same opener, snapshot identity, policy, and observation semantics as +`safe_read`. Do not create a second cache or path-resolution path. + +#### S2.3 — `file_outline` application use case + +Add parser-backed projection through an explicit parser capability. Preserve +unstable-read detection and output semantics. + +#### S2.4 — Remaining read-family extraction + +Extract `changed_since` and current-snapshot map behavior, explicitly naming +where committed or structural history becomes required. + +#### S2.5 — Versioned worker command envelope + +Make worker execution consume application commands rather than MCP tool +definitions. Remove unsupported `ToolContext` methods from the worker. + +#### S2.6 — Decompose `ToolContext` + +Replace the global service locator with exact handler/use-case dependencies. +Retain receipt and footprint projection as edge concerns. + +### Phase 3 — Authorized per-call workspace routing + +This phase corresponds to the secure G2 router, not merely a friendlier wrapper +over existing global binding. + +#### S3.1 — Session-root and grant bootstrap + +Bind stdio launch coordinates/configured roots to the daemon session using +authenticated initialization metadata. Freeze rolling-version behavior. + +#### S3.2 — Resource route contracts + +Implement workspace-view, opened-root, scoped-directory, and object-only route +results plus conservative scope algebra and failure taxonomy. + +#### S3.3 — Per-invocation route resolver + +Resolve each repository operation against its coordinate and current grants. +Do not mutate session-global active workspace state as part of resolution. + +#### S3.4 — Automatic authorized observation + +After a successfully authorized resolution, create or refresh the managed +workspace observation automatically. Observation must not imply history +tracking. + +#### S3.5 — Repair-shaped authorization obstruction + +Replace instructions to call `workspace_bind` with a structured host-grant +repair. Preserve explicit operator authorization tools for integrations that +need them. + +#### S3.6 — Multi-repository session proof + +One session reads two authorized sibling repositories and refuses a third. No +target is mutated. Every receipt names the resource scope and authorizing grant +without leaking unrelated paths. + +### Phase 4 — No-pollution current-state rollout + +The existing roadmap places all daemon-first rollout at G12 after later history +and lifecycle goalposts. This plan proposes splitting that rollout: + +- **G12a:** daemon-first current-state reads after G1-G3 security and parity + gates; +- **G12b:** durable-history and lifecycle rollout after G4-G8. + +This is a proposed roadmap amendment. It follows the existing product law that +current-state reads do not require durable history. + +#### S4.1 — Opt-in daemon-backed normal-read path + +Run the new path behind an explicit feature/runtime choice. Gather parity, +latency, denial, restart, and fallback evidence. + +#### S4.2 — Shadow comparison + +Where safe and bounded, compare repo-local and daemon-backed application +results without publishing duplicate user output or mutating target state. + +#### S4.3 — Default `graft serve` flip + +Make daemon-backed managed current-state reads the default only after the G1-G3 +exit gates pass. Preserve explicit `--runtime repo-local`. + +#### S4.4 — Target-repository mutation audit + +Prove the default path does not: + +- create `/.graft`; +- alter `.git/info/exclude`; +- alter `.gitignore`; +- initialize git-warp; +- enable durable tracking; +- change worktree contents or Git metadata. + +#### S4.5 — Fallback and rollback contract + +Define when daemon unavailability may use an in-process memory/none-storage +fallback and when it must refuse. Never silently fall back to repo-local +persistence. Add a documented operator switch to restore the previous runtime +while diagnosing regressions. + +### Phase 5 — One agent-facing schema authority + +#### S5.1 — Select the canonical MCP body-schema assembly + +Choose one exhaustive map as authority and freeze representative union, +refusal, obstruction, and compact/full vectors. + +#### S5.2 — Derive every consumer + +Make runtime validation, CLI decoding, rendering, generated JSON Schema, and +capability discovery derive from the same authority. + +#### S5.3 — Delete the duplicate map + +Remove the second exhaustive assembly and add mutation tests proving consumers +cannot project a stale legal-output shape. + +#### S5.4 — Publish compact/full relationship metadata + +Make it explicit which projection is compact, which is full, and which fields +may be omitted without changing semantic legality. Receipts should name the +projection and schema version. + +### Phase 6 — Router, history, monitor, and metrics extraction + +#### S6.1 — Workspace router contracts and resolver + +Separate route models, resolution, execution-context construction, and +session/default-coordinate state. + +#### S6.2 — Persisted local-history application capability + +Move policy, projection, and history orchestration out of MCP. MCP retains only +tool translation. + +#### S6.3 — Monitor application capability + +Replace hardcoded Node Git/path/WARP construction in monitor jobs with explicit +ports and application commands. + +#### S6.4 — Metrics, cache, and causal-context ownership + +Classify each generic support module by responsibility and migrate it without +creating another miscellaneous directory. Compatibility re-exports are +acceptable temporarily. + +#### S6.5 — Thin workspace router facade removal + +Once all consumers use the extracted services, retire the old router facade and +its type-only dependency knot. + +### Phase 7 — Structural-history provider boundary + +#### S7.1 — Operational `StructuralHistoryPort` + +Extend the port from provider description/status to the exact operations used +by Graft. Preserve semantic evidence labels and bounded query behavior. + +#### S7.2 — git-warp adapter + +Move concrete `WarpApp` pooling, opening, reads, and writes behind the adapter. +Coordinate implementation details with git-warp v19 after its interface is +stable enough to consume. + +#### S7.3 — Tool migration + +Migrate structural log, blame, churn, refactor difficulty, precision, code +find/show, and persisted local history away from `ToolContext.getWarp()`. + +#### S7.4 — Echo parity adapter + +Implement only after the existing Echo integration gate is satisfied. Compare +generated-model results and evidence posture before normal operation stops +opening git-warp. + +#### S7.5 — Dependency enforcement + +Forbid concrete `@git-stunts/git-warp` imports outside the adapter and approved +composition/testing modules. + +### Phase 8 — Guardrails and source-topology closure + +#### S8.1 — Primary-adapter peer-import rules + +Prevent API, CLI, MCP, and hooks from importing peer adapters except named +compatibility or composition modules. + +#### S8.2 — MCP host-import allowlist + +After extraction, permit Node host imports only in protocol transport, +composition, and explicitly justified edge modules. + +#### S8.3 — Real architecture tests + +Replace Markdown-string assertions with executable import rules, API parity, +security, and behavior tests. Documentation remains checked through links, +lint, review, and source-truth audits. + +#### S8.4 — Architecture and topology truth update + +Update `ARCHITECTURE.md`, `docs/repo-topology.md`, `BEARING.md`, and the relevant +retros only when code and tests earn the claim. + +## 10. Dependency Order + +```text +S0 daemon permission truth + -> S1 registry extraction and durable store correctness + -> S3 authenticated per-call router + -> S4 daemon-first current-state default + +S1 registry extraction + -> S3 automatic observation + -> later managed history and lifecycle work + +S2 read use cases + -> S3 routed execution + -> S4 cross-surface default rollout + +S5 schema authority + -> may run after S0 in parallel + -> must finish before claiming stable compact/full agent contracts + +S6 application extraction + -> follows proven read/router seams incrementally + +S7 WARP/Echo boundary + -> follows application-owned port definition + -> does not block S4 current-state rollout + +S8 guardrail closure + -> lands incrementally after each clean territory exists +``` + +The narrowest critical path to the user-visible win is: + +```text +verified daemon security + -> registry port/adapter/use case + -> read application service + -> authenticated per-call cwd routing + -> two-repository no-pollution proof + -> daemon-backed current-state default +``` + +## 11. Verification Strategy + +### 11.1 Unit tests + +- canonical workspace and incarnation identity vectors; +- checked typed-ID decoding; +- remote sanitization; +- incarnation transition decisions; +- negative and overflow retention rejection; +- generation conflict behavior; +- route scope algebra including `unknown`; +- authorization versus identification separation; +- application use cases with fake ports; +- compact/full schema derivation; +- truthful daemon posture derivation. + +### 11.2 Security and adversarial tests + +- symlinked Graft home; +- non-directory socket parent; +- permission-tightening failure; +- wrong owner where detectable; +- stale socket with and without active listener; +- unsupported named-pipe ACL guarantee; +- guessed workspace/view IDs; +- unauthorized parent and sibling discovery; +- symlink and mount traversal races; +- special files; +- expired and revoked grants; +- stale session initialization metadata; +- broader cached artifact requested under narrower authority. + +### 11.3 Crash and concurrency tests + +- two concurrent first-start installation writers; +- two observers for one workspace; +- observation longer than the former lock timeout; +- expired lock with a live owner; +- crash before temporary-file fsync; +- crash after file fsync but before rename; +- crash after rename but before parent fsync; +- crash between workspace and incarnation publication; +- daemon restart during registry migration; +- old and new daemon/CLI rolling-version combinations. + +### 11.4 Cross-surface parity tests + +For each extracted use case: + +- direct API invocation; +- CLI invocation where supported; +- MCP compact output; +- MCP full output; +- daemon worker execution; +- repo-local compatibility execution. + +Tests assert shared behavior and structured contracts, not incidental prose. + +### 11.5 Golden multi-workspace scenario + +Fixture: + +```text +root/ + server/ authorized Git repo + sdk/ authorized Git repo + private/ unauthorized Git repo +``` + +Required evidence: + +1. one daemon-backed MCP session starts from `server`; +2. `safe_read` succeeds in `server`; +3. `safe_read` succeeds in `sdk` after an authorized route hint; +4. `read_range` and `file_outline` use the same route semantics; +5. access to `private` is refused without enumeration leakage; +6. no target contains `.graft`; +7. no target Git exclusion file changes; +8. restart preserves managed identity without granting new authority; +9. revocation immediately prevents the next call; +10. receipts identify runtime, scope, grant, workspace/incarnation when + applicable, storage posture, and whether durable history was used. + +### 11.6 Performance tests + +Measure rather than assume: + +- cold daemon startup and connect time; +- warm reconnect time; +- workspace route resolution latency; +- registry observation latency; +- cache hit/miss latency; +- two-, ten-, and hundred-workspace registry behavior; +- lock contention; +- worker command-envelope overhead; +- compact versus full response bytes. + +Performance must not justify weakening authority or truth posture. + +## 12. Receipt and Observability Contract + +Every daemon-routed operation should make the following facts available in its +full receipt, subject to disclosure policy: + +- runtime kind and version; +- transport kind; +- verified transport security posture; +- session identity or opaque session reference; +- opaque authorizing grant ID and epoch; +- resource-scope kind; +- workspace, view, and incarnation IDs when applicable; +- route coordinate source: launch default, explicit hint, handle, or operator + selection; +- storage posture: managed, repo-local, memory, or none; +- observation/cache/history participation; +- durable-history binding and coverage only when used; +- policy/schema/projection versions; +- fallback posture; +- residual uncertainty or refusal reason. + +Compact output may omit most receipt details, but it must carry enough identity +to retrieve or request the governed full explanation where authorized. + +Metrics should distinguish: + +- automatic route success; +- missing grant; +- identification failure; +- registry observation failure; +- daemon unavailable; +- explicit repo-local use; +- memory fallback; +- unexpected target mutation attempt; +- schema projection mismatch. + +Metrics must not expose unauthorized paths or content. + +## 13. Migration and Compatibility + +### 13.1 Existing repo-local `.graft` + +Do not silently move or delete it. + +Classify content as: + +- reconstructible derived cache; +- portable durable history; +- receipts or audit material; +- unknown/unsupported legacy state. + +Derived cache may be rebuilt. Durable history requires an explicit import or +binding operation with a plan, evidence posture, and rollback. Unknown state is +preserved and reported. + +### 13.2 Existing Git exclusions + +The daemon-first default stops adding new exclusions. It does not automatically +remove old `.git/info/exclude` or `.gitignore` entries because those may have +been intentionally retained or edited by the user. + +`graft doctor` may report stale Graft-authored exclusions and offer a reviewed +repair plan later. + +### 13.3 Rolling daemon and CLI versions + +Initialization metadata, worker command envelopes, registry records, and +receipts are versioned. New clients must fail clearly or negotiate a bounded +compatibility path with older daemons. Unknown major persisted records fail +closed. + +### 13.4 Rollback + +The operator can restore explicit repo-local operation without changing target +history. Rollback must not reinterpret managed observations as repo-local +history or copy state implicitly. + +### 13.5 Public API + +Direct application services become the preferred in-process API. An MCP-style +tool bridge may remain as an explicitly named compatibility surface, but API +callers should not need MCP receipts or server construction to use governed +reads. + +## 14. Risk Register + +| Risk | Consequence | Mitigation | +|---|---|---| +| Daemon-default ships before transport security is proven | Cross-user local disclosure or false trust claim | S0 hard gate; fail closed | +| Automatic cwd is mistaken for authority | Path escalation | Bind route hints to authenticated grants | +| Session-global active workspace survives | Cross-repo confusion and concurrency races | Per-invocation execution context | +| Registry extraction changes identity bytes | Orphaned observations/history | Golden vectors and versioned migration | +| Two-file publication remains non-transactional | Inconsistent workspace/incarnation records | One commit primitive, generations, crash tests | +| Single global manifest becomes a hotspot | Contention and large corruption domain | Per-record authority; derived index | +| Default flip silently falls back to repo-local | Repository pollution returns invisibly | Explicit fallback posture; never persist silently | +| Schema consolidation breaks CLI renderers | Agent/operator incompatibility | Cross-surface vectors and rolling-version tests | +| WARP refactor races git-warp v19 | Repeated churn | Stabilize application port first; adapter later | +| Echo is introduced before parity | Unsupported native-evidence claim | Preserve integration gate and evidence labels | +| Architecture effort becomes one giant PR | Review failure and hidden regressions | One claim/state migration per slice | +| Registry/status leaks workspace existence | Cross-session metadata disclosure | Capability-filtered projections and adversarial tests | +| Windows security semantics are assumed | False same-user guarantee | Platform-specific ACL adapter and truthful unsupported posture | +| Full history becomes prerequisite for reads | Delayed product value and unnecessary storage | Keep current-state critical path independent | + +## 15. Success Metrics and Definition of Done + +### 15.1 User-visible completion + +- Plain `graft serve` uses the proven daemon-managed current-state path by + default. +- Starting and using Graft in a clean repository creates no `.graft` path in + that repository. +- Default operation makes no Git exclusion change. +- An agent can use the common read tools from its authorized launch coordinate + without `workspace_open`, `workspace_authorize`, or `workspace_bind`. +- The same session can use two authorized repositories. +- Unauthorized coordinates fail closed with a bounded repair. +- Durable history is neither initialized nor implied by an ordinary read. +- Explicit repo-local mode remains available and visible. + +### 15.2 Architecture completion + +- Workspace registry contracts, use case, port, and Node adapter live in their + respective layers. +- API, CLI, and MCP call shared use cases. +- Repository workers execute application commands rather than MCP handlers. +- `ToolContext` no longer acts as a cross-product service locator. +- workspace routing and persisted local history are not implemented under MCP. +- concrete `WarpApp` is confined to approved adapter/composition/test modules. +- primary-adapter peer imports are mechanically guarded. +- MCP host imports are limited to named transport/composition exceptions. +- documentation claims match executable source truth. + +### 15.3 Security completion + +- daemon home and transport restrictions are verified, not attempted silently; +- `sameUserOnly` is derived from evidence; +- every request is bound to an authenticated session and current grant; +- guessed IDs confer no authority; +- unauthorized registry entries are not enumerable; +- route traversal meets the declared platform security floor or refuses; +- revocation affects the next operation; +- cache/history artifacts do not cross visibility contexts without proven safe + filtering. + +### 15.4 Agent-DX completion + +- one executable output-schema authority exists; +- compact and full projections are both published and related explicitly; +- output validation, discovery, CLI decoding, and rendering cannot drift by + choosing different schema maps; +- authorization failures tell the client what class of host repair is needed; +- receipts explain runtime, route, basis, authority, and storage without + overwhelming compact output. + +## 16. Review Gates + +No slice advances solely because files moved or prose was updated. + +Every implementation PR must include: + +1. a PR-sized `docs/design/` packet; +2. explicit hill, acceptance criteria, non-goals, and playback questions; +3. RED evidence tied to a Graft behavior or invariant; +4. implementation and focused tests; +5. architecture/dependency checks appropriate to the moved boundary; +6. documentation and receipt/schema updates where behavior changed; +7. a completed local retro before PR creation; +8. clean CI and completed third-party review under `CODE_STANDARDS.md`; +9. a rollback or compatibility statement; +10. no unrelated refactor bundled into the slice. + +Tests must assert behavior, authority, structured contracts, and import rules. +They must not merely assert Markdown headings or incidental wording. + +## 17. Open Decisions + +These questions require a PR-sized decision because their answers change +implementation: + +1. **Session grant source:** What exact metadata can each supported MCP host + provide about launch roots or filesystem grants, and how is that metadata + authenticated to the daemon? +2. **Default coordinate:** For clients without a per-call coordinate, is the + stdio bridge's launch directory the sole default, or can the host update it + during a session? +3. **G12 split:** Approve or reject the proposed G12a current-state rollout + after G3 and G12b durable-history rollout after G8. +4. **Registry transaction model:** Directory generation swap, journaled records, + embedded database, or another mechanism? +5. **Lock model:** Owner token and fencing, advisory OS lock, lease heartbeat, or + storage-engine transaction? +6. **Canonical identity codec home:** Foundational pure module or explicit codec + port? +7. **Composition topology:** New `src/composition/` directory or thin assembly + retained in entrypoint homes? +8. **Fallback:** Which current-state operations may use memory/none storage when + the daemon is unavailable, and which must refuse? +9. **Explicit workspace tools:** Do `workspace_open` and `workspace_bind` remain + operator/debugging surfaces, become deprecated aliases, or disappear after + automatic routing? +10. **Legacy history import:** What exact evidence label and consent flow apply + when importing an existing repo-local git-warp store? +11. **Windows support floor:** Which named-pipe ACL guarantees can Graft verify + in the supported Node runtime? +12. **Path privacy:** Which route and repair fields may expose canonical paths to + ordinary agent sessions? +13. **Public API stability:** Which extracted read-service types become stable + package API versus internal application contracts? +14. **Metrics retention:** How can routing and denial telemetry remain useful + without becoming a local workspace-surveillance ledger? + +## 18. Recommended Immediate Queue + +The next five design/implementation cycles should be: + +1. **Verified daemon security posture** — correct the false `sameUserOnly` + possibility and create the platform test matrix. +2. **Registry contracts and pure identity extraction** — behavior-preserving, + with golden identity vectors. +3. **Registry port and crash-consistent Node adapter** — generations, fencing, + fsync, and interruption tests. +4. **`observeGitWorkspace` application use case and injected composition** — + remove registry behavior from MCP. +5. **`safe_read` application service** — the first thin-adapter and future + per-call-routing seam. + +Schema-authority consolidation may proceed in parallel after cycle 1 because it +touches a different primary risk and directly improves agent DX. + +Do not begin the default runtime flip until cycles 1-5 are complete and the G2 +session-grant packet has frozen its authority model. + +## 19. Final Campaign Narrative + +Graft should behave like a local causal development service, not a collection +of repository-resident MCP state. The daemon owns managed observations and +derived state under Graft's home. The host grants authority; the invocation +selects a coordinate; the router identifies and activates the lawful workspace +automatically; the application use case performs the work; and MCP projects the +result for the agent. Durable history is a separate governed capability. The +source architecture should tell the same story: contracts define meaning, +operations own decisions, ports name required capabilities, secondary adapters +touch the host and causal substrate, composition roots wire the system, and API, +CLI, and MCP remain thin peers. The migration succeeds when the most common +agent read requires no workspace ceremony, leaves no debris in the target +repository, carries truthful authority and storage receipts, and can be invoked +through any primary surface without MCP owning the behavior. diff --git a/docs/three-surface-capability-matrix.md b/docs/three-surface-capability-matrix.md index 0a3077c6..5aae2b75 100644 --- a/docs/three-surface-capability-matrix.md +++ b/docs/three-surface-capability-matrix.md @@ -18,11 +18,11 @@ changes, this matrix must be refreshed before release. - `6` CLI-only capabilities - `23` API + CLI + MCP capabilities -- `24` API + MCP capabilities +- `25` API + MCP capabilities - `1` API-only capability - `22` direct CLI/MCP peer capabilities - `1` composed CLI operator/lifecycle capability -- `24` intentionally API + MCP-only agent/control-plane capabilities +- `25` intentionally API + MCP-only agent/control-plane capabilities API exposure kinds: @@ -56,6 +56,7 @@ composing existing tools, it belongs in this matrix as | Capability | API | CLI | MCP | API exposure | CLI/MCP posture | CLI path | MCP tool | |---|---|---|---|---|---|---|---| +| `capabilities` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `capabilities` | | `init` | No | Yes | No | `-` | `cli_only` | `init` | `-` | | `index` | No | Yes | No | `-` | `cli_only` | `index` | `-` | | `migrate_local_history` | No | Yes | No | `-` | `cli_only` | `migrate local-history` | `-` | diff --git a/src/api/tool-bridge.ts b/src/api/tool-bridge.ts index a339c47d..7311a6b1 100644 --- a/src/api/tool-bridge.ts +++ b/src/api/tool-bridge.ts @@ -1,10 +1,13 @@ import type { McpToolName } from "../contracts/capabilities.js"; -import type { JsonObject } from "../contracts/json-object.js"; +import { parseJsonObject, type JsonObject } from "../contracts/json-object.js"; import { parseJsonTextObject } from "../adapters/json-text-decoder.js"; import { getMcpOutputSchema, type McpOutputFor } from "../contracts/output-schemas.js"; import type { GraftServer, McpToolResult } from "../mcp/server.js"; export function parseGraftToolPayload(result: McpToolResult): JsonObject { + if (result.structuredContent !== undefined) { + return parseJsonObject(result.structuredContent, "Graft tool structured result"); + } const payload = result.content.find((item) => item.type === "text"); if (payload === undefined) { throw new Error("Graft tool result did not contain a text payload"); diff --git a/src/cli/daemon-status.ts b/src/cli/daemon-status.ts index 7a9e4760..1a5c51da 100644 --- a/src/cli/daemon-status.ts +++ b/src/cli/daemon-status.ts @@ -13,6 +13,23 @@ export interface ReadDaemonStatusOptions { readonly socketPath?: string | undefined; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function buildDaemonStatusProbeArguments( + inputSchema: unknown, + args: Readonly> = {}, +): Record { + const properties = isRecord(inputSchema) && isRecord(inputSchema["properties"]) + ? inputSchema["properties"] + : null; + + return properties !== null && Object.hasOwn(properties, "receipt") + ? { ...args, receipt: "full" } + : { ...args }; +} + function readToolText(result: unknown): string { const content = (result as { readonly content?: unknown }).content; if (!Array.isArray(content)) { @@ -63,6 +80,10 @@ export async function readDaemonStatusSnapshot( await client.connect(transport as unknown as Parameters[0]); try { + const listedTools = await client.listTools(); + const inputSchemas = new Map( + listedTools.tools.map((tool) => [tool.name, tool.inputSchema] as const), + ); const call = async ( tool: string, schema: z.ZodType, @@ -70,7 +91,7 @@ export async function readDaemonStatusSnapshot( ): Promise => { const result = await client.callTool({ name: tool, - arguments: args, + arguments: buildDaemonStatusProbeArguments(inputSchemas.get(tool), args), }); return parseToolBody(schema, result); }; diff --git a/src/cli/peer-command.ts b/src/cli/peer-command.ts index 81209edb..5099b3f3 100644 --- a/src/cli/peer-command.ts +++ b/src/cli/peer-command.ts @@ -33,13 +33,44 @@ export function writeLine(writer: Writer, line = ""): void { writer.write(`${line}\n`); } +function removeMcpV2OnlyCliFields(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(removeMcpV2OnlyCliFields); + } + if (value === null || typeof value !== "object") { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "observationBasis") + .map(([key, item]) => [key, removeMcpV2OnlyCliFields(item)]), + ); +} + +export function projectMcpV2PeerPayloadToCliV1(data: JsonObject): JsonObject { + const receipt = data["_receipt"]; + if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt)) { + throw new Error("CLI peer response did not contain a full MCP receipt"); + } + const { mode, ...legacyReceipt } = receipt as Record; + if (mode !== "full") { + throw new Error("CLI peer commands require an explicit full MCP receipt"); + } + const projected = removeMcpV2OnlyCliFields(data) as JsonObject; + return { + ...projected, + _receipt: legacyReceipt, + }; +} + export function emitPeerCommand( command: CliCommandName, data: JsonObject, json: boolean, writer: Writer, ): void { - const { _schema: _mcpSchema, ...rest } = data; + const projected = projectMcpV2PeerPayloadToCliV1(data); + const { _schema: _mcpSchema, ...rest } = projected; const validated = validateCliOutput(command, attachCliSchemaMeta(command, rest)); if (json) { writer.write(`${codec.encode(validated)}\n`); @@ -81,5 +112,10 @@ export async function invokePeerCommand( projectRoot: cwd, graftDir: path.join(cwd, ".graft"), }); - return parseToolResult(await server.callTool(tool, args)); + const requiresFullDetail = tool === "doctor" || tool === "activity_view"; + return parseToolResult(await server.callTool(tool, { + ...args, + ...(requiresFullDetail ? { detail: "full" } : {}), + receipt: "full", + })); } diff --git a/src/contracts/capabilities.ts b/src/contracts/capabilities.ts index f9f6161e..df16bd9a 100644 --- a/src/contracts/capabilities.ts +++ b/src/contracts/capabilities.ts @@ -1,4 +1,5 @@ export const MCP_TOOL_NAMES = [ + "capabilities", "safe_read", "graft_edit", "file_outline", @@ -155,6 +156,12 @@ function defineCapability(seed: CapabilitySeed): CapabilityDefinition { } export const CAPABILITY_REGISTRY: readonly CapabilityDefinition[] = [ + defineCapability({ + id: "capabilities", + description: "Bounded MCP tool-family discovery", + mcpTool: "capabilities", + cliMcpParity: "mcp_only", + }), defineCapability({ id: "init", description: "Initialize graft in a repo", diff --git a/src/contracts/causal-ontology.ts b/src/contracts/causal-ontology.ts index 810bea7d..dda5619a 100644 --- a/src/contracts/causal-ontology.ts +++ b/src/contracts/causal-ontology.ts @@ -328,6 +328,20 @@ export const SEMANTIC_TRANSITION_AUTHORITIES = [ export const semanticTransitionAuthoritySchema = z.enum(SEMANTIC_TRANSITION_AUTHORITIES); export type SemanticTransitionAuthority = z.infer; +export const SEMANTIC_TRANSITION_OBSERVATION_BASES = [ + "current_state", + "snapshot_delta", + "git_transition_evidence", + "legacy_unclassified", +] as const; + +export const semanticTransitionObservationBasisSchema = z.enum( + SEMANTIC_TRANSITION_OBSERVATION_BASES, +); +export type SemanticTransitionObservationBasis = z.infer< + typeof semanticTransitionObservationBasisSchema +>; + export const REPO_CONCURRENCY_POSTURES = [ "exclusive", "shared_repo_only", @@ -469,6 +483,9 @@ const commitEventPayloadSchema = z.object({ const transitionEventPayloadSchema = z.object({ semanticKind: semanticTransitionKindSchema, authority: semanticTransitionAuthoritySchema, + // Transition events written before observation basis existed cannot be + // upgraded honestly to either endpoint state or observed movement. + observationBasis: semanticTransitionObservationBasisSchema.default("legacy_unclassified"), phase: semanticTransitionPhaseSchema.nullable(), summary: z.string().min(1), transitionKind: transitionKindSchema.nullable(), @@ -514,6 +531,12 @@ export const transitionEventSchema = causalEventCommonSchema.extend({ payload: transitionEventPayloadSchema, }).strict(); +/** Frozen transition-event shape used by CLI JSON v1 projections. */ +export const legacyCliV1TransitionEventSchema = causalEventCommonSchema.extend({ + eventKind: z.literal("transition"), + payload: transitionEventPayloadSchema.omit({ observationBasis: true }), +}).strict(); + export const handoffEventSchema = causalEventCommonSchema.extend({ eventKind: z.literal("handoff"), payload: handoffEventPayloadSchema, diff --git a/src/contracts/diagnostic-evidence-gap.ts b/src/contracts/diagnostic-evidence-gap.ts new file mode 100644 index 00000000..fbfbd0f3 --- /dev/null +++ b/src/contracts/diagnostic-evidence-gap.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +/** Closed vocabulary for evidence gaps surfaced by summary-first diagnostics. */ +export const DIAGNOSTIC_EVIDENCE_GAPS = [ + "structural_history_readiness_unknown", + "workspace_unbound", + "local_history_unavailable", + "local_history_inactive", + "target_repo_hooks_absent", + "target_repo_hooks_unrecognized", + "local_edit_watchers_absent", + "shared_repo_only", + "shared_worktree", + "overlapping_actors", + "divergent_checkout", + "repo_concurrency_unknown", +] as const; + +export type DiagnosticEvidenceGap = (typeof DIAGNOSTIC_EVIDENCE_GAPS)[number]; + +export const diagnosticEvidenceGapSchema = z.enum(DIAGNOSTIC_EVIDENCE_GAPS); diff --git a/src/contracts/diagnostic-summary-bounds.ts b/src/contracts/diagnostic-summary-bounds.ts new file mode 100644 index 00000000..1dc18ba4 --- /dev/null +++ b/src/contracts/diagnostic-summary-bounds.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +/** UTF-8 byte budgets for bounded activity-view summary strings. */ +export const ACTIVITY_SUMMARY_BOUNDS = { + headRef: 20, + headline: 104, + anchor: 96, + workspace: 48, + group: 40, +} as const; + +/** Strict string contract whose semantic bound is encoded UTF-8 bytes. */ +export function utf8ByteBoundedStringSchema(maxBytes: number) { + return z.string().max(maxBytes).refine( + (value) => Buffer.byteLength(value, "utf8") <= maxBytes, + { message: `must encode to at most ${String(maxBytes)} UTF-8 bytes` }, + ); +} diff --git a/src/contracts/mcp-capability-discovery.ts b/src/contracts/mcp-capability-discovery.ts new file mode 100644 index 00000000..fb1be3ef --- /dev/null +++ b/src/contracts/mcp-capability-discovery.ts @@ -0,0 +1,317 @@ +import { z } from "zod"; + +import { + CAPABILITY_REGISTRY, + MCP_TOOL_NAMES, +} from "./capabilities.js"; +import type { McpToolName } from "./capabilities.js"; + +/** Fixed conceptual order for the bounded MCP capability map. */ +export const MCP_CAPABILITY_FAMILIES = [ + "session", + "workspace", + "read", + "code", + "history", + "review", + "diagnostic", +] as const; + +export type McpCapabilityFamily = typeof MCP_CAPABILITY_FAMILIES[number]; +export type McpCapabilitySessionMode = "repo_local" | "daemon"; + +export const MCP_CAPABILITY_DISCOVERY_BASIS = "registered_surface" as const; +export const MCP_CAPABILITY_SUMMARY_REASON = "CAPABILITY_SUMMARY" as const; +export const MCP_CAPABILITY_FAMILY_DETAIL_REASON = "CAPABILITY_FAMILY_DETAIL" as const; +export const MCP_CAPABILITY_SUMMARY_MAX_BYTES = 2_048; +export const MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES = 4_096; + +/** Tools registered only by the daemon control plane. */ +export const MCP_DAEMON_ONLY_TOOLS = [ + "daemon_repos", + "daemon_status", + "daemon_sessions", + "daemon_monitors", + "monitor_start", + "monitor_pause", + "monitor_resume", + "monitor_nudge", + "monitor_stop", + "workspace_authorize", + "workspace_authorizations", + "workspace_revoke", + "workspace_bind", + "workspace_rebind", +] as const satisfies readonly McpToolName[]; + +interface McpCapabilityFamilyDefinition { + readonly family: McpCapabilityFamily; + readonly openingCall: McpToolName; + readonly guidance: string; + readonly tools: readonly McpToolName[]; +} + +/** + * Explicit product taxonomy for registered MCP tools. + * + * The runtime registries remain authoritative for registration. Contract tests + * compare this taxonomy with those registries so a tool cannot silently become + * undiscoverable or appear in more than one family. + */ +export const MCP_CAPABILITY_FAMILY_DEFINITIONS = [ + { + family: "session", + openingCall: "capabilities", + guidance: "Choose one bounded workflow family, then request only that family's detail.", + tools: [ + "capabilities", + "state_save", + "state_load", + "set_budget", + "knowledge_map", + ], + }, + { + family: "workspace", + openingCall: "workspace_status", + guidance: "Inspect workspace and binding posture before routed or daemon work.", + tools: [ + "daemon_repos", + "daemon_status", + "daemon_sessions", + "daemon_monitors", + "monitor_start", + "monitor_pause", + "monitor_resume", + "monitor_nudge", + "monitor_stop", + "workspace_authorize", + "workspace_authorizations", + "workspace_revoke", + "workspace_open", + "workspace_list_opened", + "workspace_bind", + "workspace_status", + "workspace_rebind", + "causal_attach", + ], + }, + { + family: "read", + openingCall: "safe_read", + guidance: "Start with a policy-bounded read and drill into ranges only when needed.", + tools: [ + "safe_read", + "file_outline", + "read_range", + "changed_since", + ], + }, + { + family: "code", + openingCall: "code_find", + guidance: "Locate a symbol, then focus or inspect references before editing.", + tools: [ + "graft_edit", + "code_show", + "code_find", + "code_refs", + ], + }, + { + family: "history", + openingCall: "graft_since", + guidance: "Start from a named base, then deepen with diff, log, blame, or difficulty.", + tools: [ + "graft_diff", + "graft_since", + "graft_map", + "graft_churn", + "graft_exports", + "graft_log", + "graft_blame", + "graft_difficulty", + ], + }, + { + family: "review", + openingCall: "graft_review", + guidance: "Review a bounded ref range before focused coverage or dead-symbol checks.", + tools: [ + "graft_review", + "graft_test_coverage", + "graft_dead_symbols", + ], + }, + { + family: "diagnostic", + openingCall: "doctor", + guidance: "Start with summary health and request full detail only to investigate.", + tools: [ + "activity_view", + "causal_status", + "run_capture", + "explain", + "doctor", + "stats", + ], + }, +] as const satisfies readonly McpCapabilityFamilyDefinition[]; + +export const mcpCapabilityFamilySchema = z.enum(MCP_CAPABILITY_FAMILIES); + +const mcpCapabilitySessionModeSchema = z.enum(["repo_local", "daemon"]); + +const mcpCapabilityFamilySummarySchema = z.object({ + family: mcpCapabilityFamilySchema, + openingCall: z.enum(MCP_TOOL_NAMES), + guidance: z.string().min(1), + toolCount: z.number().int().nonnegative(), +}).strict(); + +const mcpCapabilityToolSchema = z.object({ + name: z.enum(MCP_TOOL_NAMES), + description: z.string().min(1), +}).strict(); + +export const mcpCapabilitySummaryBodySchema = z.object({ + projection: z.literal("summary"), + reason: z.literal(MCP_CAPABILITY_SUMMARY_REASON), + discoveryBasis: z.literal(MCP_CAPABILITY_DISCOVERY_BASIS), + sessionMode: mcpCapabilitySessionModeSchema, + registeredToolCount: z.number().int().nonnegative(), + families: z.array(mcpCapabilityFamilySummarySchema).length(MCP_CAPABILITY_FAMILIES.length), +}).strict(); + +export const mcpCapabilityFamilyDetailBodySchema = z.object({ + projection: z.literal("family_detail"), + reason: z.literal(MCP_CAPABILITY_FAMILY_DETAIL_REASON), + discoveryBasis: z.literal(MCP_CAPABILITY_DISCOVERY_BASIS), + sessionMode: mcpCapabilitySessionModeSchema, + registeredToolCount: z.number().int().nonnegative(), + family: mcpCapabilityFamilySchema, + openingCall: z.enum(MCP_TOOL_NAMES), + guidance: z.string().min(1), + toolCount: z.number().int().nonnegative(), + tools: z.array(mcpCapabilityToolSchema), +}).strict(); + +/** Shared strict body contract for both MCP output-schema registries. */ +export const mcpCapabilityDiscoveryBodySchema = z.discriminatedUnion("projection", [ + mcpCapabilitySummaryBodySchema, + mcpCapabilityFamilyDetailBodySchema, +]); + +export type McpCapabilitySummaryBody = z.infer; +export type McpCapabilityFamilyDetailBody = z.infer; +export type McpCapabilityDiscoveryBody = z.infer; + +export interface BuildMcpCapabilityDiscoveryInput { + readonly sessionMode: McpCapabilitySessionMode; + readonly family?: McpCapabilityFamily | undefined; +} + +function compareCodePoints(left: string, right: string): number { + const leftPoints = Array.from(left, (value) => value.codePointAt(0) ?? 0); + const rightPoints = Array.from(right, (value) => value.codePointAt(0) ?? 0); + const commonLength = Math.min(leftPoints.length, rightPoints.length); + + for (let index = 0; index < commonLength; index += 1) { + const difference = (leftPoints[index] ?? 0) - (rightPoints[index] ?? 0); + if (difference !== 0) return difference; + } + + return leftPoints.length - rightPoints.length; +} + +const daemonOnlyToolSet = new Set(MCP_DAEMON_ONLY_TOOLS); + +function isRegisteredInMode(tool: McpToolName, sessionMode: McpCapabilitySessionMode): boolean { + return sessionMode === "daemon" || !daemonOnlyToolSet.has(tool); +} + +function registeredTools( + definition: McpCapabilityFamilyDefinition, + sessionMode: McpCapabilitySessionMode, +): readonly McpToolName[] { + return definition.tools.filter((tool) => isRegisteredInMode(tool, sessionMode)); +} + +function orderedTools( + definition: McpCapabilityFamilyDefinition, + sessionMode: McpCapabilitySessionMode, +): readonly McpToolName[] { + const available = registeredTools(definition, sessionMode); + if (!available.includes(definition.openingCall)) { + throw new Error( + `Capability-family opening call ${definition.openingCall} is not registered in ${sessionMode} mode.`, + ); + } + const remainder = available + .filter((tool) => tool !== definition.openingCall) + .sort(compareCodePoints); + return [definition.openingCall, ...remainder]; +} + +function descriptionFor(tool: McpToolName): string { + const matches = CAPABILITY_REGISTRY.filter((capability) => capability.mcpTool === tool); + const [match] = matches; + if (matches.length !== 1 || match === undefined) { + throw new Error(`Expected exactly one capability-registry description for MCP tool ${tool}.`); + } + return match.description; +} + +function registeredToolCount(sessionMode: McpCapabilitySessionMode): number { + return MCP_CAPABILITY_FAMILY_DEFINITIONS.reduce( + (count, definition) => count + registeredTools(definition, sessionMode).length, + 0, + ); +} + +/** Build the bounded summary or one explicitly selected family detail. */ +export function buildMcpCapabilityDiscovery( + input: BuildMcpCapabilityDiscoveryInput, +): McpCapabilityDiscoveryBody { + const total = registeredToolCount(input.sessionMode); + + if (input.family === undefined) { + return { + projection: "summary", + reason: MCP_CAPABILITY_SUMMARY_REASON, + discoveryBasis: MCP_CAPABILITY_DISCOVERY_BASIS, + sessionMode: input.sessionMode, + registeredToolCount: total, + families: MCP_CAPABILITY_FAMILY_DEFINITIONS.map((definition) => ({ + family: definition.family, + openingCall: definition.openingCall, + guidance: definition.guidance, + toolCount: registeredTools(definition, input.sessionMode).length, + })), + }; + } + + const definition = MCP_CAPABILITY_FAMILY_DEFINITIONS.find( + (candidate) => candidate.family === input.family, + ); + if (definition === undefined) { + throw new Error(`Unknown MCP capability family: ${input.family}`); + } + const tools = orderedTools(definition, input.sessionMode); + + return { + projection: "family_detail", + reason: MCP_CAPABILITY_FAMILY_DETAIL_REASON, + discoveryBasis: MCP_CAPABILITY_DISCOVERY_BASIS, + sessionMode: input.sessionMode, + registeredToolCount: total, + family: definition.family, + openingCall: definition.openingCall, + guidance: definition.guidance, + toolCount: tools.length, + tools: tools.map((name) => ({ + name, + description: descriptionFor(name), + })), + }; +} diff --git a/src/contracts/mcp-discovery-output-schemas.ts b/src/contracts/mcp-discovery-output-schemas.ts new file mode 100644 index 00000000..b19b8c6f --- /dev/null +++ b/src/contracts/mcp-discovery-output-schemas.ts @@ -0,0 +1,217 @@ +import { z } from "zod"; +import { + MCP_TOOL_NAMES, + type McpToolName, +} from "./capabilities.js"; +import { MCP_OUTPUT_SCHEMAS } from "./output-schemas.js"; + +/** Maximum aggregate size of Graft's advertised MCP output schemas. */ +export const MCP_DISCOVERY_OUTPUT_SCHEMA_MAX_TOTAL_BYTES = 65_536; + +/** Maximum size of one advertised MCP output schema. */ +export const MCP_DISCOVERY_OUTPUT_SCHEMA_MAX_TOOL_BYTES = 8_192; + +/** + * MCP discovery requires an object-root output schema. Graft's canonical + * schemas are intentionally stricter and may use object unions, so discovery + * receives a deterministic, bounded projection of those contracts. + */ +type DiscoveryShape = Record; + +export type McpDiscoveryOutputSchema = z.ZodObject; + +interface ProjectedField { + readonly schema: z.ZodType; + readonly optional: boolean; +} + +const receiptPostureSchema = z.looseObject({ + mode: z.enum(["compact", "full"]), +}); + +function compareCodePoints(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function stableJson(value: unknown): string { + if (value === undefined) { + throw new Error("MCP discovery schema contained a non-JSON value"); + } + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableJson(entry)).join(",")}]`; + } + const entries = Object.entries(value as Record) + .sort(([left], [right]) => compareCodePoints(left, right)); + return `{${entries.map(([key, entry]) => { + return `${JSON.stringify(key)}:${stableJson(entry)}`; + }).join(",")}}`; +} + +function schemaFingerprint(schema: z.ZodType): string { + return stableJson(z.toJSONSchema(schema)); +} + +function mergeProjectedFields(fields: readonly ProjectedField[]): ProjectedField { + if (fields.length === 0) { + throw new Error("Cannot merge an empty MCP discovery field set"); + } + + const uniqueSchemas = new Map(); + for (const field of fields) { + uniqueSchemas.set(schemaFingerprint(field.schema), field.schema); + } + const schemas = [...uniqueSchemas.entries()] + .sort(([left], [right]) => compareCodePoints(left, right)) + .map(([, schema]) => schema); + const first = schemas[0]; + if (first === undefined) { + throw new Error("Cannot merge an empty MCP discovery schema set"); + } + const second = schemas[1]; + const schema = second === undefined + ? first + : z.union([first, second, ...schemas.slice(2)]); + + return { + schema, + optional: fields.some((field) => field.optional), + }; +} + +function projectField( + fieldName: string, + schema: z.ZodType, + activeSchemas = new Set(), +): ProjectedField { + if (activeSchemas.has(schema)) { + throw new Error(`Recursive top-level MCP output field is unsupported: ${fieldName}`); + } + activeSchemas.add(schema); + try { + if (schema instanceof z.ZodOptional) { + const projected = projectField( + fieldName, + schema.unwrap() as z.ZodType, + activeSchemas, + ); + return { schema: projected.schema, optional: true }; + } + if (schema instanceof z.ZodNullable) { + const projected = projectField( + fieldName, + schema.unwrap() as z.ZodType, + activeSchemas, + ); + return { + schema: projected.schema.nullable(), + optional: projected.optional, + }; + } + if (schema instanceof z.ZodLazy) { + return projectField(fieldName, schema.unwrap() as z.ZodType, activeSchemas); + } + + // Schema identity is the versioned link back to the exact contract. + if (fieldName === "_schema") { + return { schema, optional: false }; + } + // Receipt internals remain available in the canonical schema. Discovery + // advertises only the two public projection modes to preserve its budget. + if (fieldName === "_receipt") { + return { schema: receiptPostureSchema, optional: false }; + } + + if (schema instanceof z.ZodUnion) { + return mergeProjectedFields( + schema.options.map((option) => { + return projectField(fieldName, option as z.ZodType, activeSchemas); + }), + ); + } + if (schema instanceof z.ZodObject) { + return { schema: z.looseObject({}), optional: false }; + } + if (schema instanceof z.ZodArray) { + return { schema: z.array(z.unknown()), optional: false }; + } + if (schema instanceof z.ZodRecord) { + return { schema: z.record(z.string(), z.unknown()), optional: false }; + } + if ( + schema instanceof z.ZodString + || schema instanceof z.ZodNumber + || schema instanceof z.ZodBoolean + || schema instanceof z.ZodEnum + || schema instanceof z.ZodLiteral + ) { + return { schema, optional: false }; + } + + throw new Error( + `Unsupported top-level MCP output field schema for ${fieldName}: ${schema.constructor.name}`, + ); + } finally { + activeSchemas.delete(schema); + } +} + +function rootObjectVariants(tool: McpToolName, schema: z.ZodType): readonly z.ZodObject[] { + if (schema instanceof z.ZodObject) { + return [schema]; + } + if (schema instanceof z.ZodUnion) { + return schema.options.map((option) => { + if (!(option instanceof z.ZodObject)) { + throw new Error(`MCP output schema ${tool} has a non-object union variant`); + } + return option; + }); + } + throw new Error(`MCP output schema ${tool} must be an object or an object union`); +} + +/** @internal Exported so contract tests can prove construction-order invariance. */ +export function buildMcpDiscoveryOutputSchema( + tool: McpToolName, + canonicalSchema: z.ZodType, +): McpDiscoveryOutputSchema { + const variants = rootObjectVariants(tool, canonicalSchema); + const fieldNames = [...new Set(variants.flatMap((variant) => Object.keys(variant.shape)))] + .sort(compareCodePoints); + const shape: DiscoveryShape = {}; + + for (const fieldName of fieldNames) { + const presentFields = variants.flatMap((variant) => { + const variantShape = variant.shape as Record; + if (!Object.hasOwn(variantShape, fieldName)) { + return []; + } + const fieldSchema: unknown = variantShape[fieldName]; + if (fieldSchema === undefined) { + throw new Error(`MCP output schema ${tool} has an undefined field: ${fieldName}`); + } + return [projectField(fieldName, fieldSchema as z.ZodType)]; + }); + const projected = mergeProjectedFields(presentFields); + const optional = presentFields.length < variants.length || projected.optional; + shape[fieldName] = optional ? projected.schema.optional() : projected.schema; + } + + return z.object(shape).strict(); +} + +export const MCP_DISCOVERY_OUTPUT_SCHEMAS = Object.freeze(Object.fromEntries( + MCP_TOOL_NAMES.map((tool) => [ + tool, + buildMcpDiscoveryOutputSchema(tool, MCP_OUTPUT_SCHEMAS[tool]), + ]), +) as Record); + +export function getMcpDiscoveryOutputSchema( + tool: McpToolName, +): McpDiscoveryOutputSchema { + return MCP_DISCOVERY_OUTPUT_SCHEMAS[tool]; +} diff --git a/src/contracts/output-schema-cli.ts b/src/contracts/output-schema-cli.ts index 670b939e..087d43fe 100644 --- a/src/contracts/output-schema-cli.ts +++ b/src/contracts/output-schema-cli.ts @@ -1,10 +1,24 @@ import { z } from "zod"; import type { CliCommandName } from "./capabilities.js"; -import { activityViewSchema, cliFragmentSchemas, localHistoryDagEdgeSchema, localHistoryDagNodeSchema } from "./output-schema-fragments.js"; -import { mcpOutputBodySchemas } from "./output-schema-mcp.js"; +import { legacyCliV1TransitionEventSchema } from "./causal-ontology.js"; +import { + cliFragmentSchemas, + legacyCliV1ActivityViewFullSchema, + legacyCliV1PersistedLocalHistorySummarySchema, + legacyCliV1RepoSemanticTransitionSchema, + localHistoryDagEdgeSchema, + localHistoryDagNodeSchema, +} from "./output-schema-fragments.js"; +import { doctorFullSchema, mcpOutputBodySchemas } from "./output-schema-mcp.js"; const { initActionSchema, hooksConfigSchema, suggestedMcpServerSchema } = cliFragmentSchemas; +const legacyCliV1DoctorFullSchema = doctorFullSchema.extend({ + latestTransitionEvent: legacyCliV1TransitionEventSchema.nullable(), + semanticTransition: legacyCliV1RepoSemanticTransitionSchema.nullable(), + persistedLocalHistory: legacyCliV1PersistedLocalHistorySummarySchema, +}).strict(); + const gitGraftEnhanceBodySchema = z.object({ range: z.object({ since: z.string(), @@ -100,8 +114,8 @@ export const cliOutputBodySchemas = { struct_map: mcpOutputBodySchemas.graft_map, symbol_show: mcpOutputBodySchemas.code_show, symbol_find: mcpOutputBodySchemas.code_find, - diag_doctor: mcpOutputBodySchemas.doctor, - diag_activity: activityViewSchema, + diag_doctor: legacyCliV1DoctorFullSchema, + diag_activity: legacyCliV1ActivityViewFullSchema, diag_local_history_dag: z.object({ cwd: z.string(), repoId: z.string(), diff --git a/src/contracts/output-schema-fragments.ts b/src/contracts/output-schema-fragments.ts index 739b7965..0eadfde6 100644 --- a/src/contracts/output-schema-fragments.ts +++ b/src/contracts/output-schema-fragments.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import { + ACTIVITY_SUMMARY_BOUNDS, + utf8ByteBoundedStringSchema, +} from "./diagnostic-summary-bounds.js"; import { attributionSummarySchema, attributionConfidenceSchema, @@ -9,6 +13,7 @@ import { repoConcurrencyPostureSchema, stageEventSchema, transitionEventSchema, + legacyCliV1TransitionEventSchema, stagedTargetSchema, } from "./causal-ontology.js"; import { causalSurfaceNextActionSchema } from "./causal-surface-next-action.js"; @@ -142,7 +147,8 @@ export const sludgeReportSchema = z.object({ summary: z.string(), }).strict(); -export const receiptSchema = z.object({ +export const fullReceiptSchema = z.object({ + mode: z.literal("full"), sessionId: z.string(), traceId: z.string(), seq: z.number().int().positive(), @@ -171,6 +177,22 @@ export const receiptSchema = z.object({ compressionRatio: z.number().nullable().optional(), }).strict(); +export const compactReceiptSchema = z.object({ + mode: z.literal("compact"), + receiptId: z.string(), + seq: z.number().int().positive(), + reason: z.string(), + latencyMs: z.number().int().nonnegative(), + returnedBytes: z.number().int().nonnegative(), +}).strict(); + +export const receiptSchema = z.discriminatedUnion("mode", [ + compactReceiptSchema, + fullReceiptSchema, +]); + +export const legacyCliReceiptSchema = fullReceiptSchema.omit({ mode: true }); + export const runtimeObservabilitySchema = z.object({ enabled: z.boolean(), logPath: z.string(), @@ -251,7 +273,7 @@ export const activityViewItemSchema = z.union([ transitionEventSchema, ]); -const activityViewGroupSchema = z.object({ +export const activityViewFullGroupSchema = z.object({ groupKind: z.enum(["transition", "stage", "continuity", "read"]), label: z.string(), summary: z.string(), @@ -259,13 +281,36 @@ const activityViewGroupSchema = z.object({ items: z.array(activityViewItemSchema), }).strict(); -const activityViewSummarySchema = z.object({ +const legacyCliV1ActivityViewItemSchema = z.union([ + activityViewContinuityItemSchema, + readEventSchema, + stageEventSchema, + legacyCliV1TransitionEventSchema, +]); + +const legacyCliV1ActivityViewFullGroupSchema = activityViewFullGroupSchema.extend({ + items: z.array(legacyCliV1ActivityViewItemSchema), +}).strict(); + +export const activityViewSummaryGroupSchema = z.object({ + groupKind: z.enum(["transition", "stage", "continuity", "read"]), + summary: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.group), + count: z.number().int().positive(), +}).strict(); + +const activityViewFullNarrativeSchema = z.object({ headline: z.string(), anchor: z.string(), workspace: z.string(), groups: z.array(z.string()), }).strict(); +const activityViewSummaryNarrativeSchema = z.object({ + headline: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.headline), + anchor: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.anchor), + workspace: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.workspace), +}).strict(); + export const persistedLocalHistorySummarySchema = z.discriminatedUnion("availability", [ z.object({ availability: z.literal("none"), @@ -319,6 +364,16 @@ export const persistedLocalHistorySummarySchema = z.discriminatedUnion("availabi }).strict(), ]); +export const legacyCliV1PersistedLocalHistorySummarySchema = z.discriminatedUnion( + "availability", + [ + persistedLocalHistorySummarySchema.options[0], + persistedLocalHistorySummarySchema.options[1].extend({ + latestTransitionEvent: legacyCliV1TransitionEventSchema.nullable(), + }).strict(), + ], +); + export const repoConcurrencySummarySchema = z.object({ posture: repoConcurrencyPostureSchema, authority: repoConcurrencyAuthoritySchema, @@ -428,6 +483,11 @@ export const repoSemanticTransitionSchema = z.object({ "authoritative_git_state", "repo_snapshot", ]), + observationBasis: z.enum([ + "current_state", + "snapshot_delta", + "git_transition_evidence", + ]), phase: z.enum([ "started", "conflicted", @@ -451,6 +511,10 @@ export const repoSemanticTransitionSchema = z.object({ }).strict(), }).strict(); +export const legacyCliV1RepoSemanticTransitionSchema = repoSemanticTransitionSchema.omit({ + observationBasis: true, +}); + export const workspaceOverlaySummarySchema = z.object({ dirty: z.literal(true), totalPaths: z.number().int().nonnegative(), @@ -585,10 +649,17 @@ export const causalAttachSchema = workspaceStatusSchema.extend({ error: z.string().optional(), }).strict(); -export const activityViewSchema = workspaceStatusSchema.extend({ +export const diagnosticWorkspaceSummarySchema = z.object({ + sessionMode: z.enum(["repo_local", "daemon"]), + bindState: z.enum(["bound", "unbound"]), + repoId: z.string().nullable(), + worktreeId: z.string().nullable(), +}).strict(); + +export const activityViewFullSchema = workspaceStatusSchema.extend({ truthClass: z.literal("artifact_history"), anchor: activityViewAnchorSchema, - summary: activityViewSummarySchema, + summary: activityViewFullNarrativeSchema, activeCausalWorkspace: z.object({ causalContext: runtimeCausalContextSchema, attribution: attributionSummarySchema, @@ -608,7 +679,7 @@ export const activityViewSchema = workspaceStatusSchema.extend({ totalMatchingItems: z.number().int().nonnegative(), truncated: z.boolean(), missingSignalKinds: z.array(z.string()), - groups: z.array(activityViewGroupSchema), + groups: z.array(activityViewFullGroupSchema), }).strict(), degradedReasons: z.array(z.string()), nextAction: z.union([ @@ -617,6 +688,78 @@ export const activityViewSchema = workspaceStatusSchema.extend({ ]), }).strict(); +export const legacyCliV1ActivityViewFullSchema = workspaceStatusSchema.extend({ + truthClass: z.literal("artifact_history"), + anchor: activityViewAnchorSchema, + summary: activityViewFullNarrativeSchema, + activeCausalWorkspace: z.object({ + causalContext: runtimeCausalContextSchema, + attribution: attributionSummarySchema, + repoConcurrency: repoConcurrencySummarySchema.nullable(), + checkoutEpoch: z.number().int().nonnegative(), + lastTransition: repoTransitionSchema.nullable(), + semanticTransition: legacyCliV1RepoSemanticTransitionSchema.nullable(), + workspaceOverlayId: z.string().nullable(), + workspaceOverlay: workspaceOverlaySummarySchema.nullable(), + workspaceOverlayFooting: workspaceOverlayFootingSchema.nullable(), + stagedTarget: runtimeStagedTargetSchema, + }).nullable(), + activityWindow: z.object({ + historyPath: z.string().nullable(), + limit: z.number().int().positive(), + returned: z.number().int().nonnegative(), + totalMatchingItems: z.number().int().nonnegative(), + truncated: z.boolean(), + missingSignalKinds: z.array(z.string()), + groups: z.array(legacyCliV1ActivityViewFullGroupSchema), + }).strict(), + degradedReasons: z.array(z.string()), + nextAction: z.union([ + causalSurfaceNextActionSchema, + z.literal("bind_workspace_to_begin_local_history"), + ]), +}).strict(); + +export const activityViewSummarySchema = z.object({ + workspace: diagnosticWorkspaceSummarySchema, + truthClass: z.literal("artifact_history"), + anchor: z.discriminatedUnion("posture", [ + z.object({ + posture: z.literal("head_commit"), + headRef: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.headRef).nullable(), + headRefTruncated: z.boolean(), + headSha: z.string(), + }).strict(), + z.object({ + posture: z.literal("unknown"), + headRef: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.headRef).nullable(), + headRefTruncated: z.boolean(), + headSha: z.string().nullable(), + reason: z.enum(["workspace_unbound", "missing_head_commit"]), + }).strict(), + ]), + summary: activityViewSummaryNarrativeSchema, + activityWindow: z.object({ + limit: z.number().int().positive(), + returned: z.number().int().nonnegative(), + totalMatchingItems: z.number().int().nonnegative(), + truncated: z.boolean(), + missingSignalKinds: z.array(z.string()), + itemDetailAvailable: z.boolean(), + groups: z.array(activityViewSummaryGroupSchema), + }).strict(), + degradedReasons: z.array(z.string()), + nextAction: z.union([ + causalSurfaceNextActionSchema, + z.literal("bind_workspace_to_begin_local_history"), + ]), +}).strict(); + +export const activityViewSchema = z.union([ + activityViewSummarySchema, + activityViewFullSchema, +]); + export const authorizedWorkspaceSchema = z.object({ repoId: z.string(), worktreeId: z.string(), @@ -889,6 +1032,7 @@ export const mcpFragmentSchemas = { sludgeReportSchema, burdenSummarySchema, receiptSchema, + legacyCliReceiptSchema, runtimeObservabilitySchema, runtimeCausalContextSchema, runtimeStagedTargetSchema, @@ -899,6 +1043,9 @@ export const mcpFragmentSchemas = { codeRefsProvenanceSchema, structuralRefusalSchema, worldlineLayerSchema, + diagnosticWorkspaceSummarySchema, + activityViewSummarySchema, + activityViewFullSchema, activityViewSchema, causalStatusSchema, causalAttachSchema, diff --git a/src/contracts/output-schema-mcp.ts b/src/contracts/output-schema-mcp.ts index 131aefbd..f42d9c00 100644 --- a/src/contracts/output-schema-mcp.ts +++ b/src/contracts/output-schema-mcp.ts @@ -9,6 +9,9 @@ import { mcpFragmentSchemas, } from "./output-schema-fragments.js"; import type { McpToolName } from "./capabilities.js"; +import { mcpCapabilityDiscoveryBodySchema } from "./mcp-capability-discovery.js"; +import { causalSurfaceNextActionSchema } from "./causal-surface-next-action.js"; +import { diagnosticEvidenceGapSchema } from "./diagnostic-evidence-gap.js"; const { actualSchema, @@ -34,6 +37,7 @@ const { codeRefsProvenanceSchema, structuralRefusalSchema, worldlineLayerSchema, + diagnosticWorkspaceSummarySchema, activityViewSchema, causalStatusSchema, causalAttachSchema, @@ -85,7 +89,51 @@ const graftEditDriftWarningSchema = z.object({ }).strict(), }).strict(); +export const doctorSummarySchema = z.object({ + health: z.enum(["healthy", "degraded"]), + workspace: diagnosticWorkspaceSummarySchema, + history: z.object({ + structural: z.object({ + readiness: z.literal("unknown"), + reason: z.literal("not_observed"), + }).strict(), + local: z.object({ + readiness: z.enum(["ready", "degraded", "unavailable"]), + active: z.boolean(), + }).strict(), + }).strict(), + degradedReasons: z.array(diagnosticEvidenceGapSchema), + recommendedNextAction: causalSurfaceNextActionSchema, +}).strict(); + +export const doctorFullSchema = z.object({ + projectRoot: z.string(), + parserHealthy: z.boolean(), + thresholds: thresholdsSchema, + sessionDepth: z.enum(["early", "mid", "late"]), + totalMessages: z.number().int().nonnegative(), + burdenSummary: burdenSummarySchema, + runtimeObservability: runtimeObservabilitySchema, + causalContext: runtimeCausalContextSchema, + latestReadEvent: readEventSchema.nullable(), + latestStageEvent: stageEventSchema.nullable(), + latestTransitionEvent: transitionEventSchema.nullable(), + repoConcurrency: repoConcurrencySummarySchema.nullable(), + checkoutEpoch: z.number().int().nonnegative(), + lastTransition: repoTransitionSchema.nullable(), + semanticTransition: repoSemanticTransitionSchema.nullable(), + workspaceOverlayId: z.string().nullable(), + workspaceOverlay: workspaceOverlaySummarySchema.nullable(), + workspaceOverlayFooting: workspaceOverlayFootingSchema, + stagedTarget: runtimeStagedTargetSchema, + attribution: attributionSummarySchema, + persistedLocalHistory: persistedLocalHistorySummarySchema, + recommendedNextAction: causalSurfaceNextActionSchema, + sludge: sludgeReportSchema.optional(), +}).strict(); + export const mcpOutputBodySchemas = { + capabilities: mcpCapabilityDiscoveryBodySchema, safe_read: z.object({ path: z.string(), projection: z.enum(["content", "outline", "refused", "error", "cache_hit", "diff"]), @@ -293,31 +341,7 @@ export const mcpOutputBodySchemas = { error: z.string().optional(), knownCodes: z.string().optional(), }).strict(), - doctor: z.object({ - projectRoot: z.string(), - parserHealthy: z.boolean(), - thresholds: thresholdsSchema, - sessionDepth: z.enum(["early", "mid", "late"]), - totalMessages: z.number().int().nonnegative(), - burdenSummary: burdenSummarySchema, - runtimeObservability: runtimeObservabilitySchema, - causalContext: runtimeCausalContextSchema, - latestReadEvent: readEventSchema.nullable(), - latestStageEvent: stageEventSchema.nullable(), - latestTransitionEvent: transitionEventSchema.nullable(), - repoConcurrency: repoConcurrencySummarySchema.nullable(), - checkoutEpoch: z.number().int().nonnegative(), - lastTransition: repoTransitionSchema.nullable(), - semanticTransition: repoSemanticTransitionSchema.nullable(), - workspaceOverlayId: z.string().nullable(), - workspaceOverlay: workspaceOverlaySummarySchema.nullable(), - workspaceOverlayFooting: workspaceOverlayFootingSchema, - stagedTarget: runtimeStagedTargetSchema, - attribution: attributionSummarySchema, - persistedLocalHistory: persistedLocalHistorySummarySchema, - recommendedNextAction: z.string(), - sludge: sludgeReportSchema.optional(), - }).strict(), + doctor: z.union([doctorSummarySchema, doctorFullSchema]), stats: z.object({ totalReads: z.number().int().nonnegative(), totalOutlines: z.number().int().nonnegative(), diff --git a/src/contracts/output-schema-meta.ts b/src/contracts/output-schema-meta.ts index d4c0254f..2f4d6069 100644 --- a/src/contracts/output-schema-meta.ts +++ b/src/contracts/output-schema-meta.ts @@ -6,24 +6,27 @@ import { type McpToolName, } from "./capabilities.js"; -export const OUTPUT_SCHEMA_VERSION = "1.0.0" as const; +export const MCP_OUTPUT_SCHEMA_VERSION = "2.0.0" as const; +export const CLI_OUTPUT_SCHEMA_VERSION = "1.0.0" as const; +/** @deprecated Use the surface-specific schema version constants. */ +export const OUTPUT_SCHEMA_VERSION = MCP_OUTPUT_SCHEMA_VERSION; export interface OutputSchemaMeta { readonly id: string; - readonly version: typeof OUTPUT_SCHEMA_VERSION; + readonly version: typeof MCP_OUTPUT_SCHEMA_VERSION | typeof CLI_OUTPUT_SCHEMA_VERSION; } export const mcpOutputSchemaMeta = Object.freeze(Object.fromEntries( MCP_TOOL_NAMES.map((tool) => [tool, Object.freeze({ id: `graft.mcp.${tool}`, - version: OUTPUT_SCHEMA_VERSION, + version: MCP_OUTPUT_SCHEMA_VERSION, })]), ) as Record); export const cliOutputSchemaMeta = Object.freeze(Object.fromEntries( CLI_COMMAND_NAMES.map((command) => [command, Object.freeze({ id: `graft.cli.${command}`, - version: OUTPUT_SCHEMA_VERSION, + version: CLI_OUTPUT_SCHEMA_VERSION, })]), ) as Record); diff --git a/src/contracts/output-schemas.ts b/src/contracts/output-schemas.ts index d6dc057d..fb636ecd 100644 --- a/src/contracts/output-schemas.ts +++ b/src/contracts/output-schemas.ts @@ -6,6 +6,7 @@ import { MCP_TOOL_NAMES, type McpToolName, } from "./capabilities.js"; +import { mcpCapabilityDiscoveryBodySchema } from "./mcp-capability-discovery.js"; import { attributionSummarySchema, attributionConfidenceSchema, @@ -16,31 +17,41 @@ import { repoConcurrencyPostureSchema, stageEventSchema, transitionEventSchema, + legacyCliV1TransitionEventSchema, stagedTargetSchema, } from "./causal-ontology.js"; import { causalSurfaceNextActionSchema } from "./causal-surface-next-action.js"; +import { diagnosticEvidenceGapSchema } from "./diagnostic-evidence-gap.js"; +import { parseJsonObject, type JsonObject } from "./json-object.js"; +import { + ACTIVITY_SUMMARY_BOUNDS, + utf8ByteBoundedStringSchema, +} from "./diagnostic-summary-bounds.js"; export { CLI_COMMAND_NAMES, MCP_TOOL_NAMES }; export type { CliCommandName, McpToolName } from "./capabilities.js"; -export const OUTPUT_SCHEMA_VERSION = "1.0.0" as const; +export const MCP_OUTPUT_SCHEMA_VERSION = "2.0.0" as const; +export const CLI_OUTPUT_SCHEMA_VERSION = "1.0.0" as const; +/** @deprecated Use the surface-specific schema version constants. */ +export const OUTPUT_SCHEMA_VERSION = MCP_OUTPUT_SCHEMA_VERSION; export interface OutputSchemaMeta { readonly id: string; - readonly version: typeof OUTPUT_SCHEMA_VERSION; + readonly version: typeof MCP_OUTPUT_SCHEMA_VERSION | typeof CLI_OUTPUT_SCHEMA_VERSION; } const mcpOutputSchemaMeta = Object.freeze(Object.fromEntries( MCP_TOOL_NAMES.map((tool) => [tool, Object.freeze({ id: `graft.mcp.${tool}`, - version: OUTPUT_SCHEMA_VERSION, + version: MCP_OUTPUT_SCHEMA_VERSION, })]), ) as Record); const cliOutputSchemaMeta = Object.freeze(Object.fromEntries( CLI_COMMAND_NAMES.map((command) => [command, Object.freeze({ id: `graft.cli.${command}`, - version: OUTPUT_SCHEMA_VERSION, + version: CLI_OUTPUT_SCHEMA_VERSION, })]), ) as Record); @@ -209,7 +220,8 @@ const sludgeReportSchema = z.object({ summary: z.string(), }).strict(); -const receiptSchema = z.object({ +const fullReceiptSchema = z.object({ + mode: z.literal("full"), sessionId: z.string(), traceId: z.string(), seq: z.number().int().positive(), @@ -238,6 +250,22 @@ const receiptSchema = z.object({ compressionRatio: z.number().nullable().optional(), }).strict(); +const compactReceiptSchema = z.object({ + mode: z.literal("compact"), + receiptId: z.string(), + seq: z.number().int().positive(), + reason: z.string(), + latencyMs: z.number().int().nonnegative(), + returnedBytes: z.number().int().nonnegative(), +}).strict(); + +const receiptSchema = z.discriminatedUnion("mode", [ + compactReceiptSchema, + fullReceiptSchema, +]); + +const legacyCliReceiptSchema = fullReceiptSchema.omit({ mode: true }); + const runtimeObservabilitySchema = z.object({ enabled: z.boolean(), logPath: z.string(), @@ -372,7 +400,7 @@ const activityViewItemSchema = z.union([ transitionEventSchema, ]); -const activityViewGroupSchema = z.object({ +const activityViewFullGroupSchema = z.object({ groupKind: z.enum(["transition", "stage", "continuity", "read"]), label: z.string(), summary: z.string(), @@ -380,13 +408,36 @@ const activityViewGroupSchema = z.object({ items: z.array(activityViewItemSchema), }).strict(); -const activityViewSummarySchema = z.object({ +const legacyCliV1ActivityViewItemSchema = z.union([ + activityViewContinuityItemSchema, + readEventSchema, + stageEventSchema, + legacyCliV1TransitionEventSchema, +]); + +const legacyCliV1ActivityViewFullGroupSchema = activityViewFullGroupSchema.extend({ + items: z.array(legacyCliV1ActivityViewItemSchema), +}).strict(); + +const activityViewSummaryGroupSchema = z.object({ + groupKind: z.enum(["transition", "stage", "continuity", "read"]), + summary: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.group), + count: z.number().int().positive(), +}).strict(); + +const activityViewFullNarrativeSchema = z.object({ headline: z.string(), anchor: z.string(), workspace: z.string(), groups: z.array(z.string()), }).strict(); +const activityViewSummaryNarrativeSchema = z.object({ + headline: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.headline), + anchor: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.anchor), + workspace: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.workspace), +}).strict(); + const persistedLocalHistorySummarySchema = z.discriminatedUnion("availability", [ z.object({ availability: z.literal("none"), @@ -440,6 +491,13 @@ const persistedLocalHistorySummarySchema = z.discriminatedUnion("availability", }).strict(), ]); +const legacyCliV1PersistedLocalHistorySummarySchema = z.discriminatedUnion("availability", [ + persistedLocalHistorySummarySchema.options[0], + persistedLocalHistorySummarySchema.options[1].extend({ + latestTransitionEvent: legacyCliV1TransitionEventSchema.nullable(), + }).strict(), +]); + const repoConcurrencySummarySchema = z.object({ posture: repoConcurrencyPostureSchema, authority: repoConcurrencyAuthoritySchema, @@ -459,6 +517,7 @@ const precisionSymbolMatchSchema = z.object({ name: z.string(), kind: z.string(), path: z.string(), + identityId: z.string().optional(), signature: z.string().optional(), exported: z.boolean(), startLine: z.number().int().positive().optional(), @@ -531,6 +590,11 @@ const repoSemanticTransitionSchema = z.object({ "authoritative_git_state", "repo_snapshot", ]), + observationBasis: z.enum([ + "current_state", + "snapshot_delta", + "git_transition_evidence", + ]), phase: z.enum([ "started", "conflicted", @@ -554,6 +618,10 @@ const repoSemanticTransitionSchema = z.object({ }).strict(), }).strict(); +const legacyCliV1RepoSemanticTransitionSchema = repoSemanticTransitionSchema.omit({ + observationBasis: true, +}); + const workspaceOverlaySummarySchema = z.object({ dirty: z.literal(true), totalPaths: z.number().int().nonnegative(), @@ -648,6 +716,13 @@ const workspaceStatusSchema = z.object({ capabilityProfile: workspaceCapabilityProfileSchema.nullable(), }).strict(); +const diagnosticWorkspaceSummarySchema = z.object({ + sessionMode: z.enum(["repo_local", "daemon"]), + bindState: z.enum(["bound", "unbound"]), + repoId: z.string().nullable(), + worktreeId: z.string().nullable(), +}).strict(); + const workspaceActionSchema = workspaceStatusSchema.extend({ ok: z.boolean(), action: z.enum(["bind", "rebind"]), @@ -688,10 +763,10 @@ const causalAttachSchema = workspaceStatusSchema.extend({ error: z.string().optional(), }).strict(); -const activityViewSchema = workspaceStatusSchema.extend({ +const activityViewFullSchema = workspaceStatusSchema.extend({ truthClass: z.literal("artifact_history"), anchor: activityViewAnchorSchema, - summary: activityViewSummarySchema, + summary: activityViewFullNarrativeSchema, activeCausalWorkspace: z.object({ causalContext: runtimeCausalContextSchema, attribution: attributionSummarySchema, @@ -711,7 +786,7 @@ const activityViewSchema = workspaceStatusSchema.extend({ totalMatchingItems: z.number().int().nonnegative(), truncated: z.boolean(), missingSignalKinds: z.array(z.string()), - groups: z.array(activityViewGroupSchema), + groups: z.array(activityViewFullGroupSchema), }).strict(), degradedReasons: z.array(z.string()), nextAction: z.union([ @@ -720,6 +795,78 @@ const activityViewSchema = workspaceStatusSchema.extend({ ]), }).strict(); +const legacyCliV1ActivityViewFullSchema = workspaceStatusSchema.extend({ + truthClass: z.literal("artifact_history"), + anchor: activityViewAnchorSchema, + summary: activityViewFullNarrativeSchema, + activeCausalWorkspace: z.object({ + causalContext: runtimeCausalContextSchema, + attribution: attributionSummarySchema, + repoConcurrency: repoConcurrencySummarySchema.nullable(), + checkoutEpoch: z.number().int().nonnegative(), + lastTransition: repoTransitionSchema.nullable(), + semanticTransition: legacyCliV1RepoSemanticTransitionSchema.nullable(), + workspaceOverlayId: z.string().nullable(), + workspaceOverlay: workspaceOverlaySummarySchema.nullable(), + workspaceOverlayFooting: workspaceOverlayFootingSchema.nullable(), + stagedTarget: runtimeStagedTargetSchema, + }).nullable(), + activityWindow: z.object({ + historyPath: z.string().nullable(), + limit: z.number().int().positive(), + returned: z.number().int().nonnegative(), + totalMatchingItems: z.number().int().nonnegative(), + truncated: z.boolean(), + missingSignalKinds: z.array(z.string()), + groups: z.array(legacyCliV1ActivityViewFullGroupSchema), + }).strict(), + degradedReasons: z.array(z.string()), + nextAction: z.union([ + causalSurfaceNextActionSchema, + z.literal("bind_workspace_to_begin_local_history"), + ]), +}).strict(); + +const activityViewSummarySchema = z.object({ + workspace: diagnosticWorkspaceSummarySchema, + truthClass: z.literal("artifact_history"), + anchor: z.discriminatedUnion("posture", [ + z.object({ + posture: z.literal("head_commit"), + headRef: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.headRef).nullable(), + headRefTruncated: z.boolean(), + headSha: z.string(), + }).strict(), + z.object({ + posture: z.literal("unknown"), + headRef: utf8ByteBoundedStringSchema(ACTIVITY_SUMMARY_BOUNDS.headRef).nullable(), + headRefTruncated: z.boolean(), + headSha: z.string().nullable(), + reason: z.enum(["workspace_unbound", "missing_head_commit"]), + }).strict(), + ]), + summary: activityViewSummaryNarrativeSchema, + activityWindow: z.object({ + limit: z.number().int().positive(), + returned: z.number().int().nonnegative(), + totalMatchingItems: z.number().int().nonnegative(), + truncated: z.boolean(), + missingSignalKinds: z.array(z.string()), + itemDetailAvailable: z.boolean(), + groups: z.array(activityViewSummaryGroupSchema), + }).strict(), + degradedReasons: z.array(z.string()), + nextAction: z.union([ + causalSurfaceNextActionSchema, + z.literal("bind_workspace_to_begin_local_history"), + ]), +}).strict(); + +const activityViewSchema = z.union([ + activityViewSummarySchema, + activityViewFullSchema, +]); + const authorizedWorkspaceSchema = z.object({ repoId: z.string(), worktreeId: z.string(), @@ -957,12 +1104,62 @@ function withCliPeerCommon( ): z.ZodType { return extendWithCommonFields(schema, { _schema: schemaMetaLiteral(cliOutputSchemaMeta[command]), - _receipt: receiptSchema, + _receipt: legacyCliReceiptSchema, tripwire: z.array(tripwireSchema).optional(), }); } +const doctorSummarySchema = z.object({ + health: z.enum(["healthy", "degraded"]), + workspace: diagnosticWorkspaceSummarySchema, + history: z.object({ + structural: z.object({ + readiness: z.literal("unknown"), + reason: z.literal("not_observed"), + }).strict(), + local: z.object({ + readiness: z.enum(["ready", "degraded", "unavailable"]), + active: z.boolean(), + }).strict(), + }).strict(), + degradedReasons: z.array(diagnosticEvidenceGapSchema), + recommendedNextAction: causalSurfaceNextActionSchema, +}).strict(); + +const doctorFullSchema = z.object({ + projectRoot: z.string(), + parserHealthy: z.boolean(), + thresholds: thresholdsSchema, + sessionDepth: z.enum(["early", "mid", "late"]), + totalMessages: z.number().int().nonnegative(), + burdenSummary: burdenSummarySchema, + runtimeObservability: runtimeObservabilitySchema, + causalContext: runtimeCausalContextSchema, + latestReadEvent: readEventSchema.nullable(), + latestStageEvent: stageEventSchema.nullable(), + latestTransitionEvent: transitionEventSchema.nullable(), + repoConcurrency: repoConcurrencySummarySchema.nullable(), + checkoutEpoch: z.number().int().nonnegative(), + lastTransition: repoTransitionSchema.nullable(), + semanticTransition: repoSemanticTransitionSchema.nullable(), + workspaceOverlayId: z.string().nullable(), + workspaceOverlay: workspaceOverlaySummarySchema.nullable(), + workspaceOverlayFooting: workspaceOverlayFootingSchema, + stagedTarget: runtimeStagedTargetSchema, + attribution: attributionSummarySchema, + persistedLocalHistory: persistedLocalHistorySummarySchema, + recommendedNextAction: causalSurfaceNextActionSchema, + sludge: sludgeReportSchema.optional(), +}).strict(); + +const legacyCliV1DoctorFullSchema = doctorFullSchema.extend({ + latestTransitionEvent: legacyCliV1TransitionEventSchema.nullable(), + semanticTransition: legacyCliV1RepoSemanticTransitionSchema.nullable(), + persistedLocalHistory: legacyCliV1PersistedLocalHistorySummarySchema, +}).strict(); + const mcpOutputBodySchemas: Record = { + capabilities: mcpCapabilityDiscoveryBodySchema, safe_read: z.object({ path: z.string(), projection: z.enum(["content", "outline", "refused", "error", "cache_hit", "diff"]), @@ -1078,20 +1275,25 @@ const mcpOutputBodySchemas: Record = { next: z.array(z.string()).optional(), actual: actualSchema.optional(), }).strict(), - code_find: z.object({ - query: z.string(), - kind: z.string().nullable(), - matches: z.array(precisionSymbolMatchSchema).optional(), - total: z.number().int().nonnegative().optional(), - path: z.string().optional(), - projection: z.literal("refused").optional(), - reason: z.string().optional(), - reasonDetail: z.string().optional(), - next: z.array(z.string()).optional(), - actual: actualSchema.optional(), - source: z.enum(["warp", "live"]), - layer: worldlineLayerSchema, - }).strict(), + code_find: z.union([ + z.object({ + query: z.string(), + kind: z.string().nullable(), + matches: z.array(precisionSymbolMatchSchema).optional(), + total: z.number().int().nonnegative().optional(), + path: z.string().optional(), + projection: z.literal("refused").optional(), + reason: z.string().optional(), + reasonDetail: z.string().optional(), + next: z.array(z.string()).optional(), + actual: actualSchema.optional(), + source: z.enum(["warp", "live"]), + layer: worldlineLayerSchema, + }).strict(), + structuralRefusalSchema.extend({ + projection: z.literal("refused"), + }).strict(), + ]), code_refs: z.object({ query: z.string(), mode: z.enum(["text", "import", "call", "property"]), @@ -1174,31 +1376,7 @@ const mcpOutputBodySchemas: Record = { error: z.string().optional(), knownCodes: z.string().optional(), }).strict(), - doctor: z.object({ - projectRoot: z.string(), - parserHealthy: z.boolean(), - thresholds: thresholdsSchema, - sessionDepth: z.enum(["early", "mid", "late"]), - totalMessages: z.number().int().nonnegative(), - burdenSummary: burdenSummarySchema, - runtimeObservability: runtimeObservabilitySchema, - causalContext: runtimeCausalContextSchema, - latestReadEvent: readEventSchema.nullable(), - latestStageEvent: stageEventSchema.nullable(), - latestTransitionEvent: transitionEventSchema.nullable(), - repoConcurrency: repoConcurrencySummarySchema.nullable(), - checkoutEpoch: z.number().int().nonnegative(), - lastTransition: repoTransitionSchema.nullable(), - semanticTransition: repoSemanticTransitionSchema.nullable(), - workspaceOverlayId: z.string().nullable(), - workspaceOverlay: workspaceOverlaySummarySchema.nullable(), - workspaceOverlayFooting: workspaceOverlayFootingSchema, - stagedTarget: runtimeStagedTargetSchema, - attribution: attributionSummarySchema, - persistedLocalHistory: persistedLocalHistorySummarySchema, - recommendedNextAction: causalSurfaceNextActionSchema, - sludge: sludgeReportSchema.optional(), - }).strict(), + doctor: z.union([doctorSummarySchema, doctorFullSchema]), stats: z.object({ totalReads: z.number().int().nonnegative(), totalOutlines: z.number().int().nonnegative(), @@ -1410,7 +1588,25 @@ const mcpOutputBodySchemas: Record = { }).strict(), }; +/** + * Validate one tool's domain response before a consequential side effect. + * + * Receipt and schema metadata are runtime-owned and are validated when the + * finalized MCP result is built. This preflight keeps domain-contract drift + * from being discovered only after a mutating tool has committed its effect. + */ +export function validateMcpOutputBody( + tool: McpToolName, + data: JsonObject, +): JsonObject { + return parseJsonObject( + mcpOutputBodySchemas[tool].parse(data), + `MCP ${tool} output body`, + ); +} + export const MCP_OUTPUT_SCHEMAS: Record = { + capabilities: withMcpCommon("capabilities", mcpOutputBodySchemas.capabilities), safe_read: withMcpCommon("safe_read", mcpOutputBodySchemas.safe_read), graft_edit: withMcpCommon("graft_edit", mcpOutputBodySchemas.graft_edit), file_outline: withMcpCommon("file_outline", mcpOutputBodySchemas.file_outline), @@ -1539,8 +1735,8 @@ export const CLI_OUTPUT_SCHEMAS: Record = { struct_review: withCliPeerCommon("struct_review", mcpOutputBodySchemas.graft_review), struct_test_coverage: withCliPeerCommon("struct_test_coverage", mcpOutputBodySchemas.graft_test_coverage), struct_dead_symbols: withCliPeerCommon("struct_dead_symbols", mcpOutputBodySchemas.graft_dead_symbols), - diag_doctor: withCliPeerCommon("diag_doctor", mcpOutputBodySchemas.doctor), - diag_activity: withCliPeerCommon("diag_activity", mcpOutputBodySchemas.activity_view), + diag_doctor: withCliPeerCommon("diag_doctor", legacyCliV1DoctorFullSchema), + diag_activity: withCliPeerCommon("diag_activity", legacyCliV1ActivityViewFullSchema), diag_explain: withCliPeerCommon("diag_explain", mcpOutputBodySchemas.explain), diag_stats: withCliPeerCommon("diag_stats", mcpOutputBodySchemas.stats), diag_capture: withCliPeerCommon("diag_capture", mcpOutputBodySchemas.run_capture), @@ -1634,7 +1830,11 @@ export type McpOutputFor = z.output<(typeof MCP_OUTPUT_SC /** Inferred output type for a given CLI command name. */ export type CliOutputFor = z.output<(typeof CLI_OUTPUT_SCHEMAS)[K]>; -export const DIAG_ACTIVITY_CLI_SCHEMA = activityViewSchema.extend({ _schema: z.unknown().optional(), _receipt: z.unknown().optional(), tripwire: z.unknown().optional() }).strict(); +export const DIAG_ACTIVITY_CLI_SCHEMA = legacyCliV1ActivityViewFullSchema.extend({ + _schema: z.unknown().optional(), + _receipt: z.unknown().optional(), + tripwire: z.unknown().optional(), +}).strict(); export const RECEIPT_SCHEMA = receiptSchema; export const RECEIPT_JSON_SCHEMA = z.toJSONSchema(receiptSchema); diff --git a/src/mcp/burden.ts b/src/mcp/burden.ts index c5f67b4e..5a00f438 100644 --- a/src/mcp/burden.ts +++ b/src/mcp/burden.ts @@ -14,6 +14,7 @@ export type BurdenByKind = Record; const ZERO_BUCKET: BurdenBucket = Object.freeze({ calls: 0, bytesReturned: 0 }); const TOOL_BURDEN_KIND: Record = { + capabilities: "diagnostic", safe_read: "read", graft_edit: "state", file_outline: "read", diff --git a/src/mcp/context.ts b/src/mcp/context.ts index 350346b1..d3024463 100644 --- a/src/mcp/context.ts +++ b/src/mcp/context.ts @@ -77,6 +77,7 @@ export interface ToolContext { readonly git: GitClient; readonly runCapture: RunCaptureConfig; readonly observability: RuntimeObservabilityState; + validateResponse(tool: McpToolName, data: Record): void; respond(tool: McpToolName, data: Record): McpToolResult; recordFootprint(entry: { readonly paths?: readonly string[]; @@ -146,6 +147,7 @@ export function assertToolContext(value: unknown): asserts value is ToolContext } const methods = [ + "validateResponse", "respond", "resolvePath", "getWarp", diff --git a/src/mcp/persisted-local-history-graph.ts b/src/mcp/persisted-local-history-graph.ts index 90f2f3cb..6184cec4 100644 --- a/src/mcp/persisted-local-history-graph.ts +++ b/src/mcp/persisted-local-history-graph.ts @@ -573,6 +573,7 @@ export async function writeCausalEventToGraph( accumulator.ensureNode(eventNode, { semanticKind: input.event.payload.semanticKind, authority: input.event.payload.authority, + observationBasis: input.event.payload.observationBasis, phase: input.event.payload.phase, summary: input.event.payload.summary, transitionKind: input.event.payload.transitionKind, diff --git a/src/mcp/persisted-local-history-policy.ts b/src/mcp/persisted-local-history-policy.ts index 34056e6a..82978de0 100644 --- a/src/mcp/persisted-local-history-policy.ts +++ b/src/mcp/persisted-local-history-policy.ts @@ -445,8 +445,10 @@ export function createTransitionEvent(input: { readonly transition: RepoObservation["lastTransition"]; readonly attribution: AttributionSummary; }): Extract { - const transition = input.transition; const semanticTransition = input.semanticTransition; + const transition = semanticTransition.observationBasis === "git_transition_evidence" + ? input.transition + : null; const footprint = { paths: [], symbols: [], @@ -458,10 +460,12 @@ export function createTransitionEvent(input: { "event", JSON.stringify({ eventKind: "transition", + observedAt: input.current.observedAt, checkoutEpochId: input.current.checkoutEpochId, workspaceOverlayId: input.current.workspaceOverlayId, semanticKind: semanticTransition.kind, authority: semanticTransition.authority, + observationBasis: semanticTransition.observationBasis, phase: semanticTransition.phase, summary: semanticTransition.summary, transitionKind: transition?.kind ?? null, @@ -490,6 +494,7 @@ export function createTransitionEvent(input: { payload: { semanticKind: semanticTransition.kind, authority: semanticTransition.authority, + observationBasis: semanticTransition.observationBasis, phase: semanticTransition.phase, summary: semanticTransition.summary, transitionKind: transition?.kind ?? null, diff --git a/src/mcp/persisted-local-history.ts b/src/mcp/persisted-local-history.ts index 54330734..fc98d71e 100644 --- a/src/mcp/persisted-local-history.ts +++ b/src/mcp/persisted-local-history.ts @@ -377,6 +377,7 @@ const LOCAL_HISTORY_GRAPH_EXPOSE = [ "evidenceKind", "fromRef", "occurredAt", + "observationBasis", "path", "paths", "phase", @@ -753,6 +754,7 @@ function readTransitionEventFromGraph( payload: { semanticKind: asString(node.props["semanticKind"]), authority: asString(node.props["authority"]), + observationBasis: asString(node.props["observationBasis"]), phase: asNullableString(node.props["phase"]) ?? null, summary: asString(node.props["summary"]), transitionKind: asNullableString(node.props["transitionKind"]) ?? null, @@ -1493,6 +1495,9 @@ export class PersistedLocalHistoryStore { readonly attribution: AttributionSummary; readonly graph?: PersistedLocalHistoryGraphContext | null; }): Promise { + if (input.semanticTransition.observationBasis === "current_state") { + return; + } const continuityKey = buildContinuityKey(input.current.repoId, input.current.worktreeId); const state = await this.loadWritableState( input.graph, diff --git a/src/mcp/receipt.ts b/src/mcp/receipt.ts index 217a1af8..d68fbd60 100644 --- a/src/mcp/receipt.ts +++ b/src/mcp/receipt.ts @@ -6,7 +6,14 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { MetricsSnapshot } from "./metrics.js"; import type { Tripwire } from "../session/types.js"; import type { JsonCodec } from "../ports/codec.js"; -import { attachMcpSchemaMeta, type McpToolName } from "../contracts/output-schemas.js"; +import { + attachMcpSchemaMeta, + getMcpOutputSchema, + type McpToolName, +} from "../contracts/output-schemas.js"; +import { getMcpDiscoveryOutputSchema } from "../contracts/mcp-discovery-output-schemas.js"; +import { parseJsonObject } from "../contracts/json-object.js"; +import type { ReceiptMode } from "./tool-input-controls.js"; import { burdenKindForTool, isNonReadBurdenKind, @@ -42,6 +49,10 @@ export interface ReceiptBurden { } export interface McpToolReceipt { + /** Requested public projection. Internal accounting fields remain available in both modes. */ + readonly mode: ReceiptMode; + /** Correlates this receipt with the matching completed runtime-observability record. */ + readonly receiptId: string; readonly sessionId: string; readonly traceId: string; readonly seq: number; @@ -67,10 +78,14 @@ export interface ReceiptDeps { readonly tripwires: Tripwire[]; readonly codec: JsonCodec; readonly budget?: ReceiptBudget | null; + /** Direct builder callers retain the historical full projection by default. */ + readonly receiptMode?: ReceiptMode | undefined; } /** Mutable draft used internally during the size-stabilization loop. */ interface ReceiptDraft { + mode: ReceiptMode; + receiptId: string; sessionId: string; traceId: string; seq: number; @@ -96,6 +111,39 @@ interface ReceiptDraft { compressionRatio?: number | null; } +interface CompactReceiptDraft { + mode: "compact"; + receiptId: string; + seq: number; + reason: string; + latencyMs: number; + returnedBytes: number; +} + +type FullReceiptDraft = Omit; + +const COMPACT_RECEIPT_MAX_BYTES = 512; +const COMPACT_REASON_MAX_BYTES = 256; + +function boundedCompactReason(reason: string): string { + if (Buffer.byteLength(reason, "utf8") <= COMPACT_REASON_MAX_BYTES) { + return reason; + } + const suffix = "…"; + const contentBudget = COMPACT_REASON_MAX_BYTES - Buffer.byteLength(suffix, "utf8"); + let bounded = ""; + let bytes = 0; + for (const character of reason) { + const characterBytes = Buffer.byteLength(character, "utf8"); + if (bytes + characterBytes > contentBudget) { + break; + } + bounded += character; + bytes += characterBytes; + } + return `${bounded}${suffix}`; +} + function extractProjection(data: Record): string { if (typeof data["projection"] === "string") return data["projection"]; return "none"; @@ -122,6 +170,22 @@ function freezeReceipt(draft: ReceiptDraft): McpToolReceipt { return draft as McpToolReceipt; } +function compactReceiptDraft(full: ReceiptDraft): CompactReceiptDraft { + return { + mode: "compact", + receiptId: full.receiptId, + seq: full.seq, + reason: boundedCompactReason(full.reason), + latencyMs: full.latencyMs, + returnedBytes: full.returnedBytes, + }; +} + +function fullReceiptDraft(internal: ReceiptDraft): FullReceiptDraft { + const { receiptId: _receiptId, ...output } = internal; + return output; +} + /** * Build a tool response with an attached receipt. * Returns the finalized MCP result and the byte count of the serialized text @@ -133,8 +197,11 @@ export function buildReceiptResult( deps: ReceiptDeps, ): { result: McpToolResult; textBytes: number; receipt: McpToolReceipt } { const burdenKind = burdenKindForTool(tool); + const receiptMode = deps.receiptMode ?? "full"; const draft: ReceiptDraft = { + mode: receiptMode, + receiptId: deps.traceId, sessionId: deps.sessionId, traceId: deps.traceId, seq: deps.seq, @@ -165,36 +232,96 @@ export function buildReceiptResult( draft.budget = deps.budget; } + const outputReceipt: FullReceiptDraft | CompactReceiptDraft = receiptMode === "compact" + ? compactReceiptDraft(draft) + : fullReceiptDraft(draft); const fullData: Record & { tripwire?: Tripwire[] } = attachMcpSchemaMeta(tool, { ...data, - _receipt: draft, + _receipt: outputReceipt, }); if (deps.tripwires.length > 0) { fullData.tripwire = deps.tripwires; } - // Stabilize self-referential size fields (use UTF-8 byte length, not char count) - let prev = 0; - let text = ""; - for (let i = 0; i < 5; i++) { - text = deps.codec.encode(fullData); - const byteLen = Buffer.byteLength(text, "utf8"); - if (byteLen === prev) break; - prev = byteLen; + // Stabilize self-referential size fields (use UTF-8 byte length, not char count). + // Equality means the encoded text already contains the byte count it reports. + // + // A rounded compressionRatio can make the JSON-width function non-monotonic: + // for example, alternating between 13.86 and 13.842 changes the response by + // one byte and can create a two-state cycle. The field is already optional, + // so an impossible fixed point falls back to exact byte accounting without + // projecting the derived ratio. The complete internal receipt still records + // the ratio once the final byte count is known. + const maxStabilizationPasses = 32; + const updateAccounting = (byteLen: number, exposeCompressionRatio: boolean): void => { draft.returnedBytes = byteLen; + outputReceipt.returnedBytes = byteLen; const burdenByKind = projectBurdenByKind(deps.metrics.burdenByKind, tool, byteLen); draft.compressionRatio = draft.fileBytes !== null && draft.fileBytes > 0 ? Math.round((byteLen / draft.fileBytes) * 1000) / 1000 : null; + if (outputReceipt.mode === "full") { + if (exposeCompressionRatio) { + outputReceipt.compressionRatio = draft.compressionRatio; + } else { + delete outputReceipt.compressionRatio; + } + } draft.cumulative.bytesReturned = deps.metrics.bytesReturned + byteLen; draft.cumulative.nonReadBytesReturned = totalNonReadBytesReturned(burdenByKind); draft.cumulative.burdenByKind = burdenByKind; + }; + const stabilize = (exposeCompressionRatio: boolean): string | null => { + for (let i = 0; i < maxStabilizationPasses; i++) { + const candidate = deps.codec.encode(fullData); + const byteLen = Buffer.byteLength(candidate, "utf8"); + if (byteLen === outputReceipt.returnedBytes) { + return candidate; + } + updateAccounting(byteLen, exposeCompressionRatio); + } + return null; + }; + + let text = stabilize(true); + if (text === null && outputReceipt.mode === "full") { + delete outputReceipt.compressionRatio; + draft.returnedBytes = 0; + outputReceipt.returnedBytes = 0; + draft.cumulative.bytesReturned = 0; + draft.cumulative.nonReadBytesReturned = totalNonReadBytesReturned( + deps.metrics.burdenByKind, + ); + draft.cumulative.burdenByKind = deps.metrics.burdenByKind; + text = stabilize(false); + } + if (text === null) { + throw new Error("MCP receipt byte accounting did not converge"); + } + const exposesCompressionRatio = outputReceipt.mode === "full" + && outputReceipt.compressionRatio !== undefined; + updateAccounting(Buffer.byteLength(text, "utf8"), exposesCompressionRatio); + if ( + outputReceipt.mode === "compact" + && Buffer.byteLength(deps.codec.encode(outputReceipt), "utf8") > COMPACT_RECEIPT_MAX_BYTES + ) { + throw new Error("Compact MCP receipt exceeded its 512-byte contract"); } + Object.freeze(outputReceipt); const receipt = freezeReceipt(draft); + const structuredContent = parseJsonObject( + deps.codec.decode(text), + `MCP ${tool} output`, + ); + getMcpOutputSchema(tool).parse(structuredContent); + getMcpDiscoveryOutputSchema(tool).parse(structuredContent); return { - result: { content: [{ type: "text", text }] }, + result: { + content: [{ type: "text", text }], + structuredContent, + }, textBytes: Buffer.byteLength(text, "utf8"), receipt, }; diff --git a/src/mcp/repo-state-git.ts b/src/mcp/repo-state-git.ts index b70c8abf..576ae2f4 100644 --- a/src/mcp/repo-state-git.ts +++ b/src/mcp/repo-state-git.ts @@ -81,8 +81,27 @@ function countStatusLines(statusLines: readonly string[]): { } async function countUnmergedPaths(gitClient: GitClient, cwd: string): Promise { - const paths = await readGitLines(gitClient, ["diff", "--name-only", "--diff-filter=U", "--"], cwd); - return new Set(paths.filter((value) => value.length > 0)).size; + const entries = await readGitLines(gitClient, ["ls-files", "--unmerged", "--"], cwd); + return new Set( + entries + .map((entry) => entry.split("\t").at(-1) ?? "") + .filter((value) => value.length > 0), + ).size; +} + +function verifiedWorktreeStatusLines( + statusLines: readonly string[], + numstatLines: readonly string[], +): readonly string[] { + const verifiedPaths = new Set( + numstatLines + .map((line) => line.split("\t").at(-1) ?? "") + .filter((value) => value.length > 0), + ); + return statusLines.filter((line) => { + const filePath = line.split("\t").at(-1) ?? ""; + return verifiedPaths.has(filePath); + }); } function mergeStatusLines( @@ -221,9 +240,22 @@ export async function isAncestor( export async function captureSnapshot(cwd: string, fs: FileSystem, gitClient: GitClient): Promise { const headSha = await readGit(gitClient, ["rev-parse", "HEAD"], cwd); + const worktreeStatusLines = await readGitLines( + gitClient, + ["diff-files", "--find-renames", "--name-status", "--"], + cwd, + ); + // `diff-files --name-status` can report a false dirty entry when a file is + // modified and restored inside Git's racy-clean timestamp window because it + // trusts cached index stat data. Numstat forces content inspection and still + // includes content, deletion, type, binary, submodule, and mode-only changes. + const verifiedWorktreeLines = verifiedWorktreeStatusLines( + worktreeStatusLines, + await readGitLines(gitClient, ["diff-files", "--numstat", "--"], cwd), + ); const statusLines = mergeStatusLines( headSha === null ? [] : await readGitLines(gitClient, ["diff-index", "--cached", "--find-renames", "--name-status", headSha, "--"], cwd), - await readGitLines(gitClient, ["diff-files", "--find-renames", "--name-status", "--"], cwd), + verifiedWorktreeLines, await readGitLines(gitClient, ["ls-files", "--others", "--exclude-standard"], cwd), ); const counts = countStatusLines(statusLines); diff --git a/src/mcp/repo-state-observation.ts b/src/mcp/repo-state-observation.ts index 50033290..a58618aa 100644 --- a/src/mcp/repo-state-observation.ts +++ b/src/mcp/repo-state-observation.ts @@ -55,6 +55,50 @@ function buildSemanticTransitionEvidence( }; } +function statusLinesMatch( + previous: readonly string[], + current: readonly string[], +): boolean { + return previous.length === current.length + && previous.every((line, index) => line === current[index]); +} + +const UNMERGED_STATUS_CODES = new Set([ + "DD", + "AU", + "UD", + "UA", + "DU", + "AA", + "UU", + // Graft merges `diff-index` and `diff-files` plumbing rows. An unmerged + // index row (`U`) plus a modified worktree row (`M`) therefore appears as + // `UM`, even though porcelain status would normally render `UU`. + "UM", +]); + +function unmergedStatusLines(snapshot: RepoSnapshot): readonly string[] { + return snapshot.statusLines.filter((line) => UNMERGED_STATUS_CODES.has(line.slice(0, 2))); +} + +function mergeStateChanged(previous: RepoSnapshot | null, current: RepoSnapshot): boolean { + return previous !== null && ( + previous.mergeInProgress !== current.mergeInProgress + || previous.unmergedPaths !== current.unmergedPaths + || !statusLinesMatch(unmergedStatusLines(previous), unmergedStatusLines(current)) + ); +} + +function rebaseStateChanged(previous: RepoSnapshot | null, current: RepoSnapshot): boolean { + return previous !== null && ( + previous.rebase.inProgress !== current.rebase.inProgress + || previous.rebase.step !== current.rebase.step + || previous.rebase.total !== current.rebase.total + || previous.unmergedPaths !== current.unmergedPaths + || !statusLinesMatch(unmergedStatusLines(previous), unmergedStatusLines(current)) + ); +} + export function buildSemanticTransition( previous: RepoSnapshot | null, current: RepoSnapshot, @@ -63,9 +107,13 @@ export function buildSemanticTransition( const evidence = buildSemanticTransitionEvidence(current, lastTransition); if (current.mergeInProgress) { + const observationBasis = lastTransition?.kind === "merge" + ? "git_transition_evidence" + : (mergeStateChanged(previous, current) ? "snapshot_delta" : "current_state"); return { kind: "merge_phase", authority: "authoritative_git_state", + observationBasis, phase: current.unmergedPaths > 0 ? "conflicted" : "resolved_waiting_commit", summary: buildSemanticTransitionSummary({ kind: "merge_phase", @@ -77,12 +125,19 @@ export function buildSemanticTransition( } if (current.rebase.inProgress) { - const phase: RepoSemanticTransitionPhase = current.unmergedPaths > 0 + const stateChanged = rebaseStateChanged(previous, current); + const observationBasis = lastTransition?.kind === "rebase" + ? "git_transition_evidence" + : (stateChanged ? "snapshot_delta" : "current_state"); + const phase: RepoSemanticTransitionPhase | null = current.unmergedPaths > 0 ? "conflicted" - : ((current.rebase.step ?? 1) > 1 ? "continued" : "started"); + : (observationBasis === "current_state" + ? null + : (previous?.rebase.inProgress === true ? "continued" : "started")); return { kind: "rebase_phase", authority: "authoritative_git_state", + observationBasis, phase, summary: buildSemanticTransitionSummary({ kind: "rebase_phase", @@ -93,11 +148,17 @@ export function buildSemanticTransition( }; } - const unmergedChanged = previous !== null && previous.unmergedPaths !== current.unmergedPaths; - if (current.unmergedPaths > 0 || unmergedChanged) { + const conflictStateChanged = previous !== null + && (previous.unmergedPaths > 0 || current.unmergedPaths > 0) + && ( + previous.unmergedPaths !== current.unmergedPaths + || !statusLinesMatch(unmergedStatusLines(previous), unmergedStatusLines(current)) + ); + if (current.unmergedPaths > 0 || conflictStateChanged) { return { kind: "conflict_resolution", authority: "authoritative_git_state", + observationBasis: conflictStateChanged ? "snapshot_delta" : "current_state", phase: null, summary: buildSemanticTransitionSummary({ kind: "conflict_resolution", @@ -113,6 +174,7 @@ export function buildSemanticTransition( return { kind: "merge_phase", authority: "repo_snapshot", + observationBasis: "git_transition_evidence", phase: "completed_or_cleared", summary: buildSemanticTransitionSummary({ kind: "merge_phase", @@ -127,6 +189,7 @@ export function buildSemanticTransition( return { kind: "rebase_phase", authority: "repo_snapshot", + observationBasis: "git_transition_evidence", phase: "completed_or_cleared", summary: buildSemanticTransitionSummary({ kind: "rebase_phase", @@ -137,13 +200,14 @@ export function buildSemanticTransition( }; } - if (current.statusLines.length >= 8) { + if (lastTransition !== null) { return { - kind: "bulk_transition", + kind: "unknown", authority: "repo_snapshot", + observationBasis: "git_transition_evidence", phase: null, summary: buildSemanticTransitionSummary({ - kind: "bulk_transition", + kind: "unknown", evidence, phase: null, }), @@ -151,13 +215,20 @@ export function buildSemanticTransition( }; } - if (current.stagedPaths > 0) { + // Null means this observation supports no transition claim. It does not prove + // that no unobserved transition occurred between equal endpoint snapshots. + if (previous === null || statusLinesMatch(previous.statusLines, current.statusLines)) { + return null; + } + + if (current.statusLines.length >= 8) { return { - kind: "index_update", + kind: "bulk_transition", authority: "repo_snapshot", + observationBasis: "snapshot_delta", phase: null, summary: buildSemanticTransitionSummary({ - kind: "index_update", + kind: "bulk_transition", evidence, phase: null, }), @@ -165,13 +236,14 @@ export function buildSemanticTransition( }; } - if (current.statusLines.length > 0) { + if (current.stagedPaths > 0) { return { - kind: "unknown", + kind: "index_update", authority: "repo_snapshot", + observationBasis: "snapshot_delta", phase: null, summary: buildSemanticTransitionSummary({ - kind: "unknown", + kind: "index_update", evidence, phase: null, }), @@ -179,7 +251,18 @@ export function buildSemanticTransition( }; } - return null; + return { + kind: "unknown", + authority: "repo_snapshot", + observationBasis: "snapshot_delta", + phase: null, + summary: buildSemanticTransitionSummary({ + kind: "unknown", + evidence, + phase: null, + }), + evidence, + }; } export function buildObservation( diff --git a/src/mcp/repo-state-transition.ts b/src/mcp/repo-state-transition.ts index 116c0a2d..dccd0393 100644 --- a/src/mcp/repo-state-transition.ts +++ b/src/mcp/repo-state-transition.ts @@ -16,16 +16,13 @@ function parseCheckoutTarget(value: string): string | null { function parseTransitionFromReflog( previous: RepoSnapshot, current: RepoSnapshot, - bootstrapTimestampSec: number, - allowBootstrap: boolean, ): RepoTransition | null { const entry = current.headReflog; if (entry === null) { return null; } const hasFreshReflog = previous.headReflog?.raw !== entry.raw; - const bootstrapEligible = allowBootstrap && entry.timestampSec !== null && entry.timestampSec >= bootstrapTimestampSec; - if (!hasFreshReflog && !bootstrapEligible) { + if (!hasFreshReflog) { return null; } @@ -93,10 +90,8 @@ export async function detectTransition( cwd: string, previous: RepoSnapshot, current: RepoSnapshot, - bootstrapTimestampSec: number, - allowBootstrap: boolean, ): Promise { - const reflogTransition = parseTransitionFromReflog(previous, current, bootstrapTimestampSec, allowBootstrap); + const reflogTransition = parseTransitionFromReflog(previous, current); if (reflogTransition !== null) { return reflogTransition; } diff --git a/src/mcp/repo-state-types.ts b/src/mcp/repo-state-types.ts index 9b8c5259..563c64a3 100644 --- a/src/mcp/repo-state-types.ts +++ b/src/mcp/repo-state-types.ts @@ -10,6 +10,10 @@ export type RepoSemanticTransitionKind = export type RepoSemanticTransitionAuthority = | "authoritative_git_state" | "repo_snapshot"; +export type RepoSemanticTransitionObservationBasis = + | "current_state" + | "snapshot_delta" + | "git_transition_evidence"; export type RepoSemanticTransitionPhase = | "started" | "conflicted" @@ -31,6 +35,7 @@ export interface RepoTransition { export interface RepoSemanticTransition { readonly kind: RepoSemanticTransitionKind; readonly authority: RepoSemanticTransitionAuthority; + readonly observationBasis: RepoSemanticTransitionObservationBasis; readonly phase: RepoSemanticTransitionPhase | null; readonly summary: string; readonly evidence: { diff --git a/src/mcp/repo-state.ts b/src/mcp/repo-state.ts index 270664c1..1b41afb6 100644 --- a/src/mcp/repo-state.ts +++ b/src/mcp/repo-state.ts @@ -11,6 +11,7 @@ export type { RepoTransitionKind, RepoSemanticTransitionKind, RepoSemanticTransitionAuthority, + RepoSemanticTransitionObservationBasis, RepoSemanticTransitionPhase, RepoTransition, RepoSemanticTransition, @@ -40,9 +41,6 @@ export class RepoStateTracker { statusLines: [], }; private initialization: Promise | null = null; - private readonly startedAtSec = Math.floor(Date.now() / 1000); - private hasObservedTransition = false; - /** Pending observe promise used for debounce coalescing. */ private pendingObserve: Promise | null = null; /** Timestamp of the last completed observation (ms since epoch). */ @@ -125,12 +123,9 @@ export class RepoStateTracker { this.cwd, previousSnapshot, nextSnapshot, - this.startedAtSec, - !this.hasObservedTransition, ); if (transition !== null) { this.checkoutEpoch++; - this.hasObservedTransition = true; } this.snapshot = nextSnapshot; diff --git a/src/mcp/repo-tool-job.ts b/src/mcp/repo-tool-job.ts index 67f970c4..3aa52aa0 100644 --- a/src/mcp/repo-tool-job.ts +++ b/src/mcp/repo-tool-job.ts @@ -6,6 +6,7 @@ import type { ToolDefinition, ToolHandler } from "./context.js"; import type { MetricsDelta, MetricsSnapshot } from "./metrics.js"; import type { ObservationSnapshot } from "./cache.js"; import type { RepoObservation } from "./repo-state.js"; +import type { ReceiptMode } from "./tool-input-controls.js"; import type { WorkspaceCapabilityProfile } from "./workspace-router.js"; import { graftDiffTool } from "./tools/graft-diff.js"; import { sinceTool } from "./tools/since.js"; @@ -69,6 +70,8 @@ export interface RepoToolWorkerJob { readonly repoState: RepoObservation; readonly governorSnapshot: GovernorTrackerSnapshot; readonly metricsSnapshot: MetricsSnapshot; + /** Public receipt projection selected by the parent invocation. */ + readonly receiptMode: ReceiptMode; readonly cacheSnapshots?: Readonly>; } diff --git a/src/mcp/repo-tool-worker-context.ts b/src/mcp/repo-tool-worker-context.ts index 7f9d38cd..ea0aba99 100644 --- a/src/mcp/repo-tool-worker-context.ts +++ b/src/mcp/repo-tool-worker-context.ts @@ -18,6 +18,7 @@ import { resolveRunCaptureConfig } from "./run-capture-config.js"; import { resolveRuntimeObservabilityState } from "./runtime-observability.js"; import { buildRuntimeCausalContext } from "./runtime-causal-context.js"; import type { RepoToolWorkerJob, RepoToolWorkerResult } from "./repo-tool-job.js"; +import { validateMcpOutputBody } from "../contracts/output-schemas.js"; function unsupported(name: string): never { throw new Error(`${name} is not available in repo-tool worker mode`); @@ -95,6 +96,9 @@ export function buildRepoToolWorkerContext( git: nodeGit, runCapture: resolveRunCaptureConfig({}), observability: resolveRuntimeObservabilityState({ graftDir: job.graftDir }), + validateResponse(tool, data) { + validateMcpOutputBody(tool, data); + }, respond(tool, data) { const tripwires = governor.checkTripwires(); const built = buildReceiptResult(tool, data, { @@ -106,6 +110,7 @@ export function buildRepoToolWorkerContext( metrics: metrics.snapshot(), tripwires, budget: governor.getBudget(), + receiptMode: job.receiptMode, }); response = { result: built.result, diff --git a/src/mcp/runtime-observability.ts b/src/mcp/runtime-observability.ts index 0c6af992..e3acc7b0 100644 --- a/src/mcp/runtime-observability.ts +++ b/src/mcp/runtime-observability.ts @@ -95,6 +95,8 @@ export interface RuntimeToolCallCompletedEvent { readonly event: "tool_call_completed"; readonly sessionId: string; readonly traceId: string; + /** Public compact-receipt correlation identifier. Equal to traceId in v2. */ + readonly receiptId: string; readonly seq: number; readonly tool: string; readonly latencyMs: number; diff --git a/src/mcp/semantic-transition-summary.ts b/src/mcp/semantic-transition-summary.ts index 365bce00..d7db8ee3 100644 --- a/src/mcp/semantic-transition-summary.ts +++ b/src/mcp/semantic-transition-summary.ts @@ -114,7 +114,9 @@ export function buildSemanticTransitionSummary(input: { case "resolved_waiting_commit": return "Rebase conflicts are cleared and the rebase is waiting for the next step."; default: - return "Rebase state is active."; + return evidence.rebaseStep !== null && evidence.rebaseTotalSteps !== null + ? `Rebase is active at step ${String(evidence.rebaseStep)} of ${String(evidence.rebaseTotalSteps)}.` + : "Rebase state is active."; } case "conflict_resolution": return buildConflictResolutionSummary(evidence, previousUnmergedPaths); diff --git a/src/mcp/server-context.ts b/src/mcp/server-context.ts index 8a53b107..7e447575 100644 --- a/src/mcp/server-context.ts +++ b/src/mcp/server-context.ts @@ -38,6 +38,7 @@ export interface ToolContextDeps { readonly monitorRuntime: PersistentMonitorRuntime | null; readonly daemonRuntime: () => DaemonRuntimeDescriptor; readonly getActiveExecutionContext: () => WorkspaceExecutionContext | null; + readonly validateResponse: (tool: ToolDefinition["name"], data: JsonObject) => void; readonly respond: (tool: ToolDefinition["name"], data: JsonObject) => McpToolResult; readonly recordFootprint: (entry: { readonly paths?: readonly string[]; @@ -61,6 +62,7 @@ export function buildToolContext(deps: ToolContextDeps): ToolContext { monitorRuntime, daemonRuntime, getActiveExecutionContext, + validateResponse, respond, } = deps; @@ -83,6 +85,7 @@ export function buildToolContext(deps: ToolContextDeps): ToolContext { get metrics() { return getActiveExecutionContext()?.metrics ?? workspaceRouter.metrics; }, + validateResponse, respond, recordFootprint: deps.recordFootprint, resolvePath(relative: string): string { diff --git a/src/mcp/server-invocation.ts b/src/mcp/server-invocation.ts index 78baf7a3..1b508fd5 100644 --- a/src/mcp/server-invocation.ts +++ b/src/mcp/server-invocation.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import * as crypto from "node:crypto"; import { z } from "zod"; import type { JsonObject } from "../contracts/json-object.js"; +import { validateMcpOutputBody } from "../contracts/output-schemas.js"; import type { CanonicalJsonCodec } from "../adapters/canonical-json.js"; import { buildReceiptResult } from "./receipt.js"; import type { ObservationSnapshot } from "./cache.js"; @@ -33,6 +34,11 @@ import { resolveDaemonOffloadedRepoTool, workspaceRoutedRepoTools, } from "./server-tool-access.js"; +import { + readReceiptMode, + stripCommonToolInputs, + type ReceiptMode, +} from "./tool-input-controls.js"; /** Mutable footprint accumulator for the current tool invocation. */ export interface FootprintAccumulator { @@ -45,6 +51,7 @@ export interface FootprintAccumulator { export interface InvocationStore { readonly traceId: string; readonly startedAtMs: number; + readonly receiptMode: ReceiptMode; readonly footprint: FootprintAccumulator; response?: { readonly receipt: McpToolReceipt; readonly tripwireSignals: readonly string[] }; } @@ -53,6 +60,7 @@ interface InvocationEnvelope { readonly traceId: string; readonly startedAtMs: number; readonly argKeys: readonly string[]; + readonly receiptMode: ReceiptMode; } interface InvocationExecutionPlan { @@ -81,6 +89,8 @@ export interface InvocationEngine { /** Monotonically increasing sequence counter (mutable). */ getSeq(): number; incrementSeq(): number; + /** Validate a domain response before a consequential side effect. */ + validateResponse: (tool: ToolDefinition["name"], data: JsonObject) => void; /** Build the respond() function suitable for ToolContext. */ respond: (tool: ToolDefinition["name"], data: JsonObject) => McpToolResult; /** Get the active execution context from AsyncLocalStorage. */ @@ -192,6 +202,7 @@ export function createInvocationEngine(deps: InvocationEngineDeps): InvocationEn metrics: metrics.snapshot(), tripwires, budget: governor.getBudget(), + receiptMode: invocation.receiptMode, }); invocation.response = { receipt, @@ -202,11 +213,16 @@ export function createInvocationEngine(deps: InvocationEngineDeps): InvocationEn return result; } + function validateResponse(tool: ToolDefinition["name"], data: JsonObject): void { + validateMcpOutputBody(tool, data); + } + function openInvocation(args: JsonObject): InvocationEnvelope { return { traceId: crypto.randomUUID(), startedAtMs: Date.now(), argKeys: sanitizeArgKeys(args), + receiptMode: readReceiptMode(args), }; } @@ -214,6 +230,7 @@ export function createInvocationEngine(deps: InvocationEngineDeps): InvocationEn return { traceId: envelope.traceId, startedAtMs: envelope.startedAtMs, + receiptMode: envelope.receiptMode, footprint: { paths: new Set(), symbols: new Set(), regions: [] }, }; } @@ -359,6 +376,7 @@ export function createInvocationEngine(deps: InvocationEngineDeps): InvocationEn repoState: input.execution.repoState.getState(), governorSnapshot: input.execution.governor.snapshot(), metricsSnapshot: input.execution.metrics.snapshot(), + receiptMode: input.envelope.receiptMode, ...(cacheSnapshots !== undefined ? { cacheSnapshots } : {}), }); @@ -427,6 +445,7 @@ export function createInvocationEngine(deps: InvocationEngineDeps): InvocationEn event: "tool_call_completed", sessionId, traceId: input.envelope.traceId, + receiptId: input.invocation.response.receipt.receiptId, seq: input.invocation.response.receipt.seq, tool: input.name, latencyMs: input.invocation.response.receipt.latencyMs, @@ -518,7 +537,7 @@ export function createInvocationEngine(deps: InvocationEngineDeps): InvocationEn }; try { - const parsed = decodeInvocationArgs(args, schema); + const parsed = stripCommonToolInputs(decodeInvocationArgs(args, schema)); authorizeInvocation(name, parsed); execution = (await planInvocationExecution(name, parsed)).execution; recordInvocation(); @@ -549,6 +568,7 @@ export function createInvocationEngine(deps: InvocationEngineDeps): InvocationEn invocationStorage, getSeq: () => seq, incrementSeq: () => ++seq, + validateResponse, respond, getActiveExecutionContext, invokeTool, diff --git a/src/mcp/server-tool-access.ts b/src/mcp/server-tool-access.ts index 880c2ca6..c7e5cddf 100644 --- a/src/mcp/server-tool-access.ts +++ b/src/mcp/server-tool-access.ts @@ -1,5 +1,5 @@ import type { McpToolResult } from "./receipt.js"; -import type { JsonObject } from "../contracts/json-object.js"; +import { tryParseJsonObject, type JsonObject } from "../contracts/json-object.js"; import { parseJsonTextObject } from "../adapters/json-text-decoder.js"; import { evaluateMcpPolicy } from "./policy.js"; import { RefusedResult } from "../policy/types.js"; @@ -12,6 +12,7 @@ import { import type { ToolContext, ToolDefinition, ToolHandler } from "./context.js"; const daemonAlwaysAvailableTools = new Set([ + "capabilities", "daemon_repos", "daemon_status", "daemon_sessions", @@ -34,6 +35,7 @@ const daemonAlwaysAvailableTools = new Set([ ]); export const repoStateOptionalTools = new Set([ + "capabilities", "daemon_repos", "daemon_status", "daemon_sessions", @@ -116,6 +118,9 @@ export function resolveDaemonOffloadedRepoTool( } export function parseToolPayload(result: McpToolResult): JsonObject | null { + if (result.structuredContent !== undefined) { + return tryParseJsonObject(result.structuredContent); + } const textBlock = result.content.find((entry) => entry.type === "text"); if (textBlock === undefined) { return null; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b41615bb..eabf6a34 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -37,6 +37,8 @@ import { ALL_TOOL_REGISTRY, TOOL_REGISTRY } from "./tool-registry.js"; import { wrapWithPolicyCheck } from "./server-tool-access.js"; import { buildToolContext } from "./server-context.js"; import { createInvocationEngine, recordFootprint } from "./server-invocation.js"; +import { COMMON_TOOL_INPUT_SCHEMA, toolInputSchema } from "./tool-input-controls.js"; +import { getMcpDiscoveryOutputSchema } from "../contracts/mcp-discovery-output-schemas.js"; export type { McpToolResult, ToolHandler, ToolContext }; export { ALL_TOOL_REGISTRY, TOOL_REGISTRY } from "./tool-registry.js"; @@ -44,7 +46,10 @@ export { ALL_TOOL_REGISTRY, TOOL_REGISTRY } from "./tool-registry.js"; export interface GraftServer { getRegisteredTools(): string[]; callTool(name: string, args: JsonObject): Promise; + /** @internal Deterministic governor test support; not an MCP capability. */ injectSessionMessages(count: number): void; + /** @internal Deterministic governor test support; not an MCP capability. */ + injectSessionToolCalls(toolNames: readonly string[]): void; getWorkspaceStatus(): import("./workspace-router.js").WorkspaceStatus; getRuntimeCausalContext(): import("./runtime-causal-context.js").RuntimeCausalContext | null; getMcpServer(): McpServer; @@ -250,21 +255,18 @@ function registerGraftToolSurface(input: { const handler = def.policyCheck === true ? wrapWithPolicyCheck(def.name, rawHandler) : rawHandler; handlers.set(def.name, handler); - if (def.schema !== undefined) { - const zodSchema = z.object(def.schema).strict(); - schemas.set(def.name, zodSchema); - input.mcpServer.registerTool( - def.name, - { description: def.description, inputSchema: def.schema }, - async (args) => input.engine.invokeTool(def.name, handler, args, input.ctx, zodSchema), - ); - } else { - input.mcpServer.registerTool( - def.name, - { description: def.description }, - async () => input.engine.invokeTool(def.name, handler, {}, input.ctx), - ); - } + const inputSchema = { ...(def.schema ?? {}), ...COMMON_TOOL_INPUT_SCHEMA }; + const zodSchema = toolInputSchema(def.schema); + schemas.set(def.name, zodSchema); + input.mcpServer.registerTool( + def.name, + { + description: def.description, + inputSchema, + outputSchema: getMcpDiscoveryOutputSchema(def.name), + }, + async (args) => input.engine.invokeTool(def.name, handler, args, input.ctx, zodSchema), + ); } return { handlers, schemas }; @@ -294,6 +296,11 @@ function createGraftServerSurface(input: { input.workspaceRouter.governor.recordMessage(); } }, + injectSessionToolCalls(toolNames: readonly string[]): void { + for (const toolName of toolNames) { + input.workspaceRouter.governor.recordToolCall(toolName); + } + }, getWorkspaceStatus() { return input.workspaceRouter.getStatus(); }, @@ -384,6 +391,7 @@ export function createGraftServer(options: CreateGraftServerOptions = {}): Graft monitorRuntime: daemon.monitorRuntime, daemonRuntime: daemon.runtime, getActiveExecutionContext: engine.getActiveExecutionContext, + validateResponse: engine.validateResponse, respond: engine.respond, recordFootprint: (entry) => { recordFootprint(engine.invocationStorage, entry); }, }); diff --git a/src/mcp/tool-input-controls.ts b/src/mcp/tool-input-controls.ts new file mode 100644 index 00000000..d71658e2 --- /dev/null +++ b/src/mcp/tool-input-controls.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import type { JsonObject } from "../contracts/json-object.js"; + +/** Public receipt projection requested for an MCP invocation. */ +export type ReceiptMode = "compact" | "full"; + +export const DEFAULT_RECEIPT_MODE: ReceiptMode = "compact"; + +/** Input controls shared by every public MCP tool. */ +export const COMMON_TOOL_INPUT_SCHEMA = Object.freeze({ + receipt: z.enum(["compact", "full"]).optional(), +}); + +/** Build the strict public input schema for one tool plus common controls. */ +export function toolInputSchema( + toolSchema: Readonly> | undefined, +): z.ZodObject { + return z.object({ + ...(toolSchema ?? {}), + ...COMMON_TOOL_INPUT_SCHEMA, + }).strict(); +} + +/** Resolve the requested mode; the composed input schema validates the value separately. */ +export function readReceiptMode(args: Readonly>): ReceiptMode { + return args["receipt"] === "full" ? "full" : DEFAULT_RECEIPT_MODE; +} + +/** Remove transport controls before domain handlers and worker jobs run. */ +export function stripCommonToolInputs(args: JsonObject): JsonObject { + const { receipt: _receipt, ...domainArgs } = args; + return domainArgs; +} diff --git a/src/mcp/tool-registry.ts b/src/mcp/tool-registry.ts index ba27ad2d..45dd87e5 100644 --- a/src/mcp/tool-registry.ts +++ b/src/mcp/tool-registry.ts @@ -1,5 +1,6 @@ import type { ToolDefinition } from "./context.js"; +import { capabilitiesTool } from "./tools/capabilities.js"; import { safeReadTool } from "./tools/safe-read.js"; import { graftEditTool } from "./tools/graft-edit.js"; import { fileOutlineTool } from "./tools/file-outline.js"; @@ -48,6 +49,7 @@ import { workspaceStatusTool } from "./tools/workspace-status.js"; import { workspaceRebindTool } from "./tools/workspace-rebind.js"; export const TOOL_REGISTRY: readonly ToolDefinition[] = [ + capabilitiesTool, safeReadTool, graftEditTool, fileOutlineTool, diff --git a/src/mcp/tools/activity-view.ts b/src/mcp/tools/activity-view.ts index edeba3e4..83598efe 100644 --- a/src/mcp/tools/activity-view.ts +++ b/src/mcp/tools/activity-view.ts @@ -3,8 +3,12 @@ import { buildRuntimeStagedTarget } from "../runtime-staged-target.js"; import { deriveCausalSurfaceNextAction } from "../semantic-transition-guidance.js"; import type { PersistedLocalActivityItem } from "../persisted-local-history.js"; import type { ToolDefinition, ToolHandler } from "../context.js"; +import type { WorkspaceStatus } from "../workspace-router.js"; +import { ACTIVITY_SUMMARY_BOUNDS } from "../../contracts/diagnostic-summary-bounds.js"; +import { diagnosticDetailSchema, readDiagnosticDetail } from "./diagnostic-detail.js"; const limitSchema = z.number().int().positive().max(50).optional(); +const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); type ActivityGroupKind = "transition" | "stage" | "continuity" | "read"; @@ -77,6 +81,70 @@ function countText(count: number): string { return String(count); } +function utf8Prefix(value: string, maxBytes: number): string { + let bounded = ""; + let usedBytes = 0; + for (const { segment: character } of graphemeSegmenter.segment(value)) { + const characterBytes = Buffer.byteLength(character, "utf8"); + if (usedBytes + characterBytes > maxBytes) { + break; + } + bounded += character; + usedBytes += characterBytes; + } + return bounded; +} + +function utf8Suffix(value: string, maxBytes: number): string { + const segments = Array.from( + graphemeSegmenter.segment(value), + ({ segment }) => segment, + ); + let bounded = ""; + let usedBytes = 0; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const character = segments[index]; + if (character === undefined) { + continue; + } + const characterBytes = Buffer.byteLength(character, "utf8"); + if (usedBytes + characterBytes > maxBytes) { + break; + } + bounded = `${character}${bounded}`; + usedBytes += characterBytes; + } + return bounded; +} + +function boundUtf8(value: string, maxBytes: number): { value: string; truncated: boolean } { + if (Buffer.byteLength(value, "utf8") <= maxBytes) { + return { value, truncated: false }; + } + + const marker = "…"; + const contentBudget = maxBytes - Buffer.byteLength(marker, "utf8"); + return { + value: `${utf8Prefix(value, contentBudget)}${marker}`, + truncated: true, + }; +} + +function abbreviateUtf8(value: string, maxBytes: number): { value: string; truncated: boolean } { + if (Buffer.byteLength(value, "utf8") <= maxBytes) { + return { value, truncated: false }; + } + + const marker = "…"; + const contentBudget = maxBytes - Buffer.byteLength(marker, "utf8"); + const prefixBudget = Math.floor(contentBudget / 2); + const suffixBudget = contentBudget - prefixBudget; + return { + value: `${utf8Prefix(value, prefixBudget)}${marker}${utf8Suffix(value, suffixBudget)}`, + truncated: true, + }; +} + function shortSha(sha: string | null): string { return sha === null ? "unknown" : sha.slice(0, 7); } @@ -199,19 +267,68 @@ function buildHeadline( return truncated ? `${headline} Results are truncated to the requested window.` : headline; } +function workspaceSummary(status: WorkspaceStatus) { + return { + sessionMode: status.sessionMode, + bindState: status.bindState, + repoId: status.repoId, + worktreeId: status.worktreeId, + }; +} + +function summaryAnchor(anchor: ReturnType) { + if (anchor.headRef === null) { + return { ...anchor, headRefTruncated: false }; + } + const bounded = abbreviateUtf8(anchor.headRef, ACTIVITY_SUMMARY_BOUNDS.headRef); + return { + ...anchor, + headRef: bounded.value, + headRefTruncated: bounded.truncated, + }; +} + export const activityViewTool: ToolDefinition = { name: "activity_view", description: "Inspect recent bounded local artifact history for the active workspace, anchored to the current commit when possible.", schema: { limit: limitSchema, + detail: diagnosticDetailSchema, }, createHandler(): ToolHandler { return async (args, ctx) => { const limit = limitSchema.parse(args["limit"]) ?? 20; + const detail = readDiagnosticDetail(args); const workspaceStatus = ctx.getWorkspaceStatus(); if (workspaceStatus.bindState === "unbound") { + if (detail === "summary") { + return ctx.respond("activity_view", { + workspace: workspaceSummary(workspaceStatus), + truthClass: "artifact_history", + anchor: summaryAnchor(buildAnchor(false, null, null)), + summary: { + headline: boundUtf8( + buildHeadline(0, false), + ACTIVITY_SUMMARY_BOUNDS.headline, + ).value, + anchor: "No bound workspace; Git anchor unavailable.", + workspace: "Bind a workspace to begin local history.", + }, + activityWindow: { + limit, + returned: 0, + totalMatchingItems: 0, + truncated: false, + missingSignalKinds: ["write_events_not_captured"], + itemDetailAvailable: false, + groups: [], + }, + degradedReasons: ["workspace_unbound", "anchor_unknown"], + nextAction: "bind_workspace_to_begin_local_history", + }); + } return ctx.respond("activity_view", { ...workspaceStatus, truthClass: "artifact_history", @@ -280,6 +397,50 @@ export const activityViewTool: ToolDefinition = { : []), ]); + if (detail === "summary") { + const boundedAnchor = summaryAnchor(anchor); + return ctx.respond("activity_view", { + workspace: workspaceSummary(workspaceStatus), + truthClass: "artifact_history", + anchor: boundedAnchor, + summary: { + headline: boundUtf8( + buildHeadline(activityWindow.items.length, activityWindow.truncated), + ACTIVITY_SUMMARY_BOUNDS.headline, + ).value, + anchor: boundUtf8( + describeAnchor(boundedAnchor), + ACTIVITY_SUMMARY_BOUNDS.anchor, + ).value, + workspace: boundUtf8( + describeWorkspace( + repoConcurrency === null + ? null + : { posture: repoConcurrency.posture, summary: repoConcurrency.summary }, + repoState.semanticTransition?.summary ?? null, + stagedTarget, + ), + ACTIVITY_SUMMARY_BOUNDS.workspace, + ).value, + }, + activityWindow: { + limit: activityWindow.limit, + returned: activityWindow.items.length, + totalMatchingItems: activityWindow.totalMatchingItems, + truncated: activityWindow.truncated, + missingSignalKinds: ["write_events_not_captured"], + itemDetailAvailable: activityWindow.items.length > 0, + groups: groups.map((group) => ({ + groupKind: group.groupKind, + summary: boundUtf8(group.summary, ACTIVITY_SUMMARY_BOUNDS.group).value, + count: group.count, + })), + }, + degradedReasons, + nextAction, + }); + } + return ctx.respond("activity_view", { ...workspaceStatus, truthClass: "artifact_history", diff --git a/src/mcp/tools/capabilities.ts b/src/mcp/tools/capabilities.ts new file mode 100644 index 00000000..8059879f --- /dev/null +++ b/src/mcp/tools/capabilities.ts @@ -0,0 +1,27 @@ +import { + buildMcpCapabilityDiscovery, + mcpCapabilityFamilySchema, + type McpCapabilityFamily, +} from "../../contracts/mcp-capability-discovery.js"; +import type { ToolDefinition, ToolHandler } from "../context.js"; + +export const capabilitiesTool: ToolDefinition = { + name: "capabilities", + description: + "Discover Graft's registered MCP surface as bounded workflow families. " + + "Select one family for bounded tool names and guidance.", + schema: { + family: mcpCapabilityFamilySchema.optional(), + }, + createHandler(): ToolHandler { + return (args, ctx) => { + const family = args["family"] as McpCapabilityFamily | undefined; + const sessionMode = ctx.getWorkspaceStatus().sessionMode; + const response = buildMcpCapabilityDiscovery({ + sessionMode, + ...(family === undefined ? {} : { family }), + }); + return ctx.respond("capabilities", response); + }; + }, +}; diff --git a/src/mcp/tools/diagnostic-detail.ts b/src/mcp/tools/diagnostic-detail.ts new file mode 100644 index 00000000..5231ba2f --- /dev/null +++ b/src/mcp/tools/diagnostic-detail.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export type DiagnosticDetail = "summary" | "full"; + +export const diagnosticDetailSchema = z.enum(["summary", "full"]).optional(); + +/** Resolve detail after strict tool-input validation. Explicit scans force full evidence. */ +export function readDiagnosticDetail( + args: Readonly>, + forceFull = false, +): DiagnosticDetail { + if (forceFull || args["detail"] === "full") { + return "full"; + } + return "summary"; +} diff --git a/src/mcp/tools/diagnostic-models.ts b/src/mcp/tools/diagnostic-models.ts index b3250d28..f2f2abb6 100644 --- a/src/mcp/tools/diagnostic-models.ts +++ b/src/mcp/tools/diagnostic-models.ts @@ -13,6 +13,9 @@ import type { import type { RuntimeWorkspaceOverlayFooting } from "../runtime-workspace-overlay.js"; import type { CausalSurfaceNextAction } from "../../contracts/causal-surface-next-action.js"; import type { SludgeReport } from "../../operations/sludge-detector.js"; +import type { DiagnosticEvidenceGap } from "../../contracts/diagnostic-evidence-gap.js"; + +export type { DiagnosticEvidenceGap } from "../../contracts/diagnostic-evidence-gap.js"; export interface SetBudgetResponse { readonly budget: { @@ -50,7 +53,29 @@ export interface DoctorBurdenSummary { readonly topCalls: number; } -export interface DoctorResponse { +export interface DoctorSummaryResponse { + readonly health: "healthy" | "degraded"; + readonly workspace: { + readonly sessionMode: "repo_local" | "daemon"; + readonly bindState: "bound" | "unbound"; + readonly repoId: string | null; + readonly worktreeId: string | null; + }; + readonly history: { + readonly structural: { + readonly readiness: "unknown"; + readonly reason: "not_observed"; + }; + readonly local: { + readonly readiness: "ready" | "degraded" | "unavailable"; + readonly active: boolean; + }; + }; + readonly degradedReasons: readonly DiagnosticEvidenceGap[]; + readonly recommendedNextAction: CausalSurfaceNextAction; +} + +export interface DoctorFullResponse { readonly projectRoot: string; readonly parserHealthy: boolean; readonly thresholds: { readonly lines: number; readonly bytes: number }; @@ -76,6 +101,8 @@ export interface DoctorResponse { readonly sludge?: SludgeReport | undefined; } +export type DoctorResponse = DoctorSummaryResponse | DoctorFullResponse; + export interface RunCapturePolicyBoundary { readonly kind: "shell_escape_hatch"; readonly boundedReadContract: false; diff --git a/src/mcp/tools/doctor.ts b/src/mcp/tools/doctor.ts index 440d9047..a5910f04 100644 --- a/src/mcp/tools/doctor.ts +++ b/src/mcp/tools/doctor.ts @@ -4,23 +4,74 @@ import { buildRuntimeStagedTarget } from "../runtime-staged-target.js"; import { deriveCausalSurfaceNextAction } from "../semantic-transition-guidance.js"; import type { ToolDefinition, ToolHandler } from "../context.js"; import { toJsonObject } from "../../operations/result-dto.js"; -import type { DoctorResponse } from "./diagnostic-models.js"; +import type { + DiagnosticEvidenceGap, + DoctorFullResponse, + DoctorSummaryResponse, +} from "./diagnostic-models.js"; import { detectSludge } from "../../operations/sludge-detector.js"; +import type { PersistedLocalHistorySummary, RepoConcurrencySummary } from "../persisted-local-history.js"; +import type { WorkspaceStatus } from "../workspace-router.js"; +import { diagnosticDetailSchema, readDiagnosticDetail } from "./diagnostic-detail.js"; import { z } from "zod"; +function workspaceSummary(status: WorkspaceStatus): DoctorSummaryResponse["workspace"] { + return { + sessionMode: status.sessionMode, + bindState: status.bindState, + repoId: status.repoId, + worktreeId: status.worktreeId, + }; +} + +function localHistorySummary( + status: WorkspaceStatus, + history: PersistedLocalHistorySummary, +): DoctorSummaryResponse["history"]["local"] { + if (status.bindState === "unbound") { + return { readiness: "unavailable", active: false }; + } + if (history.availability === "none") { + return { readiness: "ready", active: false }; + } + return history.active + ? { readiness: "ready", active: true } + : { readiness: "degraded", active: false }; +} + +function concurrencyGap( + concurrency: RepoConcurrencySummary | null, +): DiagnosticEvidenceGap | null { + switch (concurrency?.posture) { + case undefined: + case "unknown": + return "repo_concurrency_unknown"; + case "exclusive": + return null; + case "shared_repo_only": + case "shared_worktree": + case "overlapping_actors": + case "divergent_checkout": + return concurrency.posture; + } +} + export const doctorTool: ToolDefinition = { name: "doctor", description: - "Runtime health check. Shows project root, parser status, active " + - "thresholds, session depth, message count, and burden summary.", + "Return bounded runtime health, workspace, history-readiness, evidence-gap, " + + "and next-action guidance. Request detail='full' for exhaustive evidence.", schema: { sludge: z.boolean().optional(), path: z.string().optional(), + detail: diagnosticDetailSchema, }, createHandler(): ToolHandler { return async (args, ctx) => { const pathArg = typeof args["path"] === "string" ? args["path"] : undefined; - const sludge = args["sludge"] === true + const sludgeRequested = args["sludge"] === true; + const detail = readDiagnosticDetail(args, sludgeRequested); + const sludge = sludgeRequested ? await detectSludge({ cwd: ctx.projectRoot, fs: ctx.fs, @@ -30,10 +81,8 @@ export const doctorTool: ToolDefinition = { }) : undefined; const repoState = ctx.getRepoState(); - const causalContext = ctx.getCausalContext(); + const status = ctx.getWorkspaceStatus(); const workspaceOverlayFooting = await ctx.getWorkspaceOverlayFooting(); - const metrics = ctx.metrics.snapshot(); - const topBurden = topBurdenKind(metrics.burdenByKind); const persistedLocalHistory = await ctx.getPersistedLocalHistorySummary(); const repoConcurrency = await ctx.getRepoConcurrencySummary(); const recommendedNextAction = deriveCausalSurfaceNextAction( @@ -41,7 +90,45 @@ export const doctorTool: ToolDefinition = { repoState.semanticTransition, repoConcurrency, ); - const response: DoctorResponse = { + + if (detail === "summary") { + const localHistory = localHistorySummary(status, persistedLocalHistory); + const repoConcurrencyGap = concurrencyGap(repoConcurrency); + const degradedReasons: DiagnosticEvidenceGap[] = [ + "structural_history_readiness_unknown", + ...(status.bindState === "unbound" ? ["workspace_unbound" as const] : []), + ...(localHistory.readiness === "unavailable" + ? ["local_history_unavailable" as const] + : localHistory.readiness === "degraded" + ? ["local_history_inactive" as const] + : []), + ...(workspaceOverlayFooting === null + ? [] + : [workspaceOverlayFooting.degradedReason]), + ...(repoConcurrencyGap === null + ? [] + : [repoConcurrencyGap]), + ]; + const response: DoctorSummaryResponse = { + health: degradedReasons.length === 0 ? "healthy" : "degraded", + workspace: workspaceSummary(status), + history: { + structural: { + readiness: "unknown", + reason: "not_observed", + }, + local: localHistory, + }, + degradedReasons: [...new Set(degradedReasons)], + recommendedNextAction, + }; + return ctx.respond("doctor", toJsonObject(response)); + } + + const causalContext = ctx.getCausalContext(); + const metrics = ctx.metrics.snapshot(); + const topBurden = topBurdenKind(metrics.burdenByKind); + const response: DoctorFullResponse = { projectRoot: ctx.projectRoot, parserHealthy: true, thresholds: { lines: STATIC_THRESHOLDS.lines, bytes: STATIC_THRESHOLDS.bytes }, @@ -67,7 +154,7 @@ export const doctorTool: ToolDefinition = { workspaceOverlay: repoState.workspaceOverlay, workspaceOverlayFooting, stagedTarget: buildRuntimeStagedTarget( - ctx.getWorkspaceStatus(), + status, causalContext, repoState, persistedLocalHistory.attribution, diff --git a/src/mcp/tools/graft-edit.ts b/src/mcp/tools/graft-edit.ts index 498572f1..75eb2289 100644 --- a/src/mcp/tools/graft-edit.ts +++ b/src/mcp/tools/graft-edit.ts @@ -245,9 +245,6 @@ export const graftEditTool: ToolDefinition = { const nextContent = content.replace(oldString, newString); const changed = nextContent !== content; - if (changed) { - await ctx.fs.writeFile(filePath, nextContent, "utf-8"); - } const observation = classifyStructuralEdit({ path: filePath, @@ -255,9 +252,6 @@ export const graftEditTool: ToolDefinition = { newString, }); const driftWarnings = buildDriftWarnings(observation, structuralEditObservations); - if (observation !== null) { - structuralEditObservations.push(observation); - } const response: GraftEditResponse = { path: filePath, @@ -270,6 +264,13 @@ export const graftEditTool: ToolDefinition = { actual, ...(driftWarnings.length > 0 ? { driftWarnings } : {}), }; + ctx.validateResponse("graft_edit", { ...response }); + if (changed) { + await ctx.fs.writeFile(filePath, nextContent, "utf-8"); + } + if (observation !== null) { + structuralEditObservations.push(observation); + } return ctx.respond("graft_edit", { ...response }); }; }, diff --git a/test/helpers/mcp.ts b/test/helpers/mcp.ts index 6d05b3b4..3671f6dc 100644 --- a/test/helpers/mcp.ts +++ b/test/helpers/mcp.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { deepStrictEqual } from "node:assert/strict"; import { createGraftServer } from "../../src/mcp/server.js"; import type { CreateGraftServerOptions, GraftServer } from "../../src/mcp/server.js"; import type { RunCaptureConfig } from "../../src/mcp/run-capture-config.js"; @@ -26,7 +27,15 @@ export function extractText(result: unknown): string { } export function parse(result: unknown): Record { - return JSON.parse(extractText(result)) as Record; + const parsedText = JSON.parse(extractText(result)) as Record; + const structuredContent = (result as { + structuredContent?: Record; + }).structuredContent; + if (structuredContent === undefined) { + throw new Error("No structured content in successful Graft MCP result"); + } + deepStrictEqual(structuredContent, parsedText); + return structuredContent; } export interface IsolatedServer { diff --git a/test/integration/mcp/daemon-bridge.test.ts b/test/integration/mcp/daemon-bridge.test.ts index 7f51cb89..1cb2161b 100644 --- a/test/integration/mcp/daemon-bridge.test.ts +++ b/test/integration/mcp/daemon-bridge.test.ts @@ -4,13 +4,41 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { ALL_TOOL_REGISTRY } from "../../../src/mcp/server.js"; +import { MCP_OUTPUT_SCHEMAS } from "../../../src/contracts/output-schemas.js"; +import { parseJsonObject } from "../../../src/contracts/json-object.js"; +import { MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES } from "../../../src/contracts/mcp-capability-discovery.js"; import { startDaemonServer, type GraftDaemonServer } from "../../../src/mcp/daemon-server.js"; import { cleanupTestRepo, createTestRepo, git } from "../../helpers/git.js"; import { extractText, harnessPath } from "../../helpers/mcp.js"; +type SdkToolResult = Awaited>; +type SdkToolList = Awaited>["tools"]; + +function structuredSafeRead(result: SdkToolResult): Record { + expect(result.isError).not.toBe(true); + const textPayload = JSON.parse(extractText(result)) as unknown; + expect(result.structuredContent).toBeDefined(); + const structured = parseJsonObject(result.structuredContent, "daemon safe_read structured content"); + expect(structured).toEqual(textPayload); + expect(() => MCP_OUTPUT_SCHEMAS.safe_read.parse(structured)).not.toThrow(); + return structured; +} + +function structuredCapabilities(result: SdkToolResult): Record { + expect(result.isError).not.toBe(true); + const textPayload = JSON.parse(extractText(result)) as unknown; + expect(result.structuredContent).toBeDefined(); + const structured = parseJsonObject(result.structuredContent, "daemon capabilities structured content"); + expect(structured).toEqual(textPayload); + expect(() => MCP_OUTPUT_SCHEMAS.capabilities.parse(structured)).not.toThrow(); + return structured; +} + describe("integration: daemon-backed MCP bridge over stdio", () => { let client: Client; let transport: StdioClientTransport; + let listedTools: SdkToolList; let daemon: GraftDaemonServer; let daemonRoot: string; let repoDir: string; @@ -45,6 +73,8 @@ describe("integration: daemon-backed MCP bridge over stdio", () => { }); client = new Client({ name: "graft-daemon-bridge-test", version: "0.0.0" }); await client.connect(transport); + // Output validators are cached by the SDK only after tools/list. + listedTools = (await client.listTools()).tools; }); afterAll(async () => { @@ -54,6 +84,45 @@ describe("integration: daemon-backed MCP bridge over stdio", () => { cleanupTestRepo(repoDir); }); + it("advertises bounded root-object output schemas for every daemon tool", () => { + const names = listedTools.map((tool) => tool.name); + expect(new Set(names)).toEqual(new Set(ALL_TOOL_REGISTRY.map((tool) => tool.name))); + expect(names).toHaveLength(ALL_TOOL_REGISTRY.length); + + const schemas = listedTools.map((tool) => { + expect(tool.outputSchema).toEqual(expect.objectContaining({ type: "object" })); + if (tool.outputSchema === undefined) { + throw new Error(`daemon tool ${tool.name} did not advertise outputSchema`); + } + expect(Buffer.byteLength(JSON.stringify(tool.outputSchema), "utf8")).toBeLessThanOrEqual(8_192); + return tool.outputSchema; + }); + expect(Buffer.byteLength(JSON.stringify(schemas), "utf8")).toBeLessThanOrEqual(65_536); + }); + + it("discovers the registered daemon surface before workspace binding", async () => { + const result = await client.callTool({ + name: "capabilities", + arguments: { family: "workspace" }, + }); + const payload = structuredCapabilities(result); + + expect(Buffer.byteLength(extractText(result), "utf8")).toBeLessThanOrEqual( + MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES, + ); + expect(payload).toMatchObject({ + projection: "family_detail", + discoveryBasis: "registered_surface", + sessionMode: "daemon", + registeredToolCount: ALL_TOOL_REGISTRY.length, + family: "workspace", + openingCall: "workspace_status", + }); + const toolNames = (payload["tools"] as { name: string }[]).map((entry) => entry.name); + expect(toolNames).toContain("daemon_status"); + expect(toolNames).toContain("workspace_authorize"); + }); + it("proxies daemon-only workspace binding flow through stdio", async () => { const workspaceStatus = JSON.parse(extractText(await client.callTool({ name: "workspace_status", @@ -74,11 +143,11 @@ describe("integration: daemon-backed MCP bridge over stdio", () => { expect(bind.ok).toBe(true); expect(bind.bindState).toBe("bound"); - const safeRead = JSON.parse(extractText(await client.callTool({ + const safeRead = structuredSafeRead(await client.callTool({ name: "safe_read", arguments: { path: "app.ts" }, - }))) as { projection: string; content: string }; - expect(safeRead.projection).toBe("content"); - expect(safeRead.content).toContain("greet"); + })); + expect(safeRead["projection"]).toBe("content"); + expect(safeRead["content"]).toContain("greet"); }); }); diff --git a/test/integration/mcp/daemon-server.test.ts b/test/integration/mcp/daemon-server.test.ts index 08c3a468..07c5cdaa 100644 --- a/test/integration/mcp/daemon-server.test.ts +++ b/test/integration/mcp/daemon-server.test.ts @@ -311,15 +311,16 @@ describe("mcp: daemon transport and lifecycle", () => { expect(daemonStatus.workers.completedTasks).toBeGreaterThanOrEqual(2); }); - it("offloads dirty precision lookups through child-process workers", async () => { + it("offloads dirty precision lookups and recognizes restored worktree bytes", async () => { const repoDir = createTestRepo("graft-daemon-precision-live-"); repos.push(repoDir); - fs.writeFileSync(path.join(repoDir, "app.ts"), [ + const committedSource = [ "export function greet(name: string): string {", " return `hello ${name}`;", "}", "", - ].join("\n")); + ].join("\n"); + fs.writeFileSync(path.join(repoDir, "app.ts"), committedSource); git(repoDir, "add -A"); git(repoDir, "commit -m init"); fs.writeFileSync(path.join(repoDir, "app.ts"), [ @@ -379,12 +380,25 @@ describe("mcp: daemon transport and lifecycle", () => { expect(show.layer).toBe("workspace_overlay"); expect(show.content).toContain("return `hey ${name}`;"); + fs.writeFileSync(path.join(repoDir, "app.ts"), committedSource); + const restored = await callTool<{ total: number; layer: string }>( + socketPath, + sessionId, + "code_find", + { query: "greet" }, + 44, + ); + expect(restored).toMatchObject({ + total: 1, + layer: "ref_view", + }); + const daemonStatus = await callTool<{ workers: { completedTasks: number; mode: string } }>( socketPath, sessionId, "daemon_status", {}, - 44, + 45, ); expect(daemonStatus.workers.mode).toBe("child_processes"); expect(daemonStatus.workers.completedTasks).toBeGreaterThanOrEqual(2); diff --git a/test/integration/mcp/server.test.ts b/test/integration/mcp/server.test.ts index 160562e3..bdf0e234 100644 --- a/test/integration/mcp/server.test.ts +++ b/test/integration/mcp/server.test.ts @@ -2,6 +2,15 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { TOOL_REGISTRY } from "../../../src/mcp/server.js"; +import { + MCP_OUTPUT_SCHEMAS, + type McpToolName, +} from "../../../src/contracts/output-schemas.js"; +import { parseJsonObject } from "../../../src/contracts/json-object.js"; +import { + MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES, + MCP_CAPABILITY_SUMMARY_MAX_BYTES, +} from "../../../src/contracts/mcp-capability-discovery.js"; import * as fs from "node:fs"; import * as path from "node:path"; import { extractText, harnessPath } from "../../helpers/mcp.js"; @@ -83,6 +92,22 @@ const BINARY_PNG = Buffer.from( "base64", ); +type SdkToolResult = Awaited>; +type SdkToolList = Awaited>["tools"]; + +function structuredPayload( + result: SdkToolResult, + tool: McpToolName, +): Record { + expect(result.isError).not.toBe(true); + const textPayload = JSON.parse(extractText(result)) as unknown; + expect(result.structuredContent).toBeDefined(); + const structured = parseJsonObject(result.structuredContent, `MCP tool ${tool} structured content`); + expect(structured).toEqual(textPayload); + expect(() => MCP_OUTPUT_SCHEMAS[tool].parse(structured)).not.toThrow(); + return structured; +} + /** * Integration tests: spawn the actual MCP server as a subprocess, * connect via stdio, and call tools through the MCP protocol. @@ -93,6 +118,7 @@ const BINARY_PNG = Buffer.from( describe("integration: MCP server over stdio", { timeout: 60_000 }, () => { let client: Client; let transport: StdioClientTransport; + let listedTools: SdkToolList; let projectRoot: string; let graftDir: string; @@ -125,6 +151,9 @@ describe("integration: MCP server over stdio", { timeout: 60_000 }, () => { }); client = new Client({ name: "graft-test", version: "0.0.0" }); await client.connect(transport); + // listTools seeds the SDK client's output-schema validator cache. Calls + // below therefore exercise client-side validation as well as the server. + listedTools = (await client.listTools()).tools; }); afterAll(async () => { @@ -132,21 +161,79 @@ describe("integration: MCP server over stdio", { timeout: 60_000 }, () => { cleanupTestRepo(projectRoot); }); - it("lists all registered tools", async () => { - const tools = await client.listTools(); - const names = tools.tools.map((t) => t.name); + it("lists all registered tools", () => { + const names = listedTools.map((t) => t.name); for (const def of TOOL_REGISTRY) { expect(names).toContain(def.name); } expect(names).toHaveLength(TOOL_REGISTRY.length); }); - it("tools have JSON Schema input definitions", async () => { - const tools = await client.listTools(); - const safeRead = tools.tools.find((t) => t.name === "safe_read"); - expect(safeRead).toBeDefined(); - expect(safeRead!.inputSchema).toBeDefined(); - expect(safeRead!.inputSchema.properties).toHaveProperty("path"); + it("tools have JSON Schema input definitions", () => { + for (const tool of listedTools) { + const properties = tool.inputSchema.properties as Record; + expect(properties).toHaveProperty("receipt"); + expect(properties["receipt"]?.enum).toEqual(["compact", "full"]); + expect(tool.outputSchema).toEqual(expect.objectContaining({ type: "object" })); + } + const safeRead = listedTools.find((tool) => tool.name === "safe_read"); + expect(safeRead?.inputSchema.properties).toHaveProperty("path"); + for (const toolName of ["doctor", "activity_view"]) { + const tool = listedTools.find((candidate) => candidate.name === toolName); + const detail = tool?.inputSchema.properties?.["detail"] as { enum?: string[] } | undefined; + expect(detail?.enum).toEqual(["summary", "full"]); + } + }); + + it("discovers bounded repo-local workflow families", async () => { + const summaryResult = await client.callTool({ + name: "capabilities", + arguments: {}, + }); + const summaryText = extractText(summaryResult); + const summary = structuredPayload(summaryResult, "capabilities"); + expect(Buffer.byteLength(summaryText, "utf8")).toBeLessThanOrEqual( + MCP_CAPABILITY_SUMMARY_MAX_BYTES, + ); + expect(summary).toMatchObject({ + projection: "summary", + reason: "CAPABILITY_SUMMARY", + discoveryBasis: "registered_surface", + sessionMode: "repo_local", + registeredToolCount: TOOL_REGISTRY.length, + }); + expect((summary["families"] as { family: string }[]).map((entry) => entry.family)).toEqual([ + "session", + "workspace", + "read", + "code", + "history", + "review", + "diagnostic", + ]); + + const detailResult = await client.callTool({ + name: "capabilities", + arguments: { family: "workspace" }, + }); + const detailText = extractText(detailResult); + const detail = structuredPayload(detailResult, "capabilities"); + expect(Buffer.byteLength(detailText, "utf8")).toBeLessThanOrEqual( + MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES, + ); + expect(detail).toMatchObject({ + projection: "family_detail", + reason: "CAPABILITY_FAMILY_DETAIL", + discoveryBasis: "registered_surface", + sessionMode: "repo_local", + family: "workspace", + openingCall: "workspace_status", + }); + const toolNames = (detail["tools"] as { name: string }[]).map((entry) => entry.name); + expect(toolNames).toContain("workspace_open"); + expect(toolNames).not.toContain("daemon_status"); }); it("safe_read returns content for small files", { timeout: 60_000 }, async () => { @@ -154,9 +241,24 @@ describe("integration: MCP server over stdio", { timeout: 60_000 }, () => { name: "safe_read", arguments: { path: "test/fixtures/small.ts" }, }); - const parsed = JSON.parse(extractText(result)) as Record; + const parsed = structuredPayload(result, "safe_read"); expect(parsed["projection"]).toBe("content"); expect(parsed["content"]).toContain("greet"); + expect(parsed["_schema"]).toEqual({ id: "graft.mcp.safe_read", version: "2.0.0" }); + expect(parsed["_receipt"]).toMatchObject({ mode: "compact", reason: "CONTENT" }); + }); + + it("safe_read returns the full receipt only when requested", { timeout: 60_000 }, async () => { + const result = await client.callTool({ + name: "safe_read", + arguments: { path: "test/fixtures/small.ts", receipt: "full" }, + }); + const parsed = structuredPayload(result, "safe_read"); + expect(parsed["_receipt"]).toMatchObject({ + mode: "full", + tool: "safe_read", + cumulative: expect.any(Object), + }); }); it("safe_read returns outline for large files", { timeout: 60_000 }, async () => { @@ -184,7 +286,7 @@ describe("integration: MCP server over stdio", { timeout: 60_000 }, () => { name: "file_outline", arguments: { path: "test/fixtures/medium.ts" }, }); - const parsed = JSON.parse(extractText(result)) as Record; + const parsed = structuredPayload(result, "file_outline"); expect(parsed["outline"]).toBeDefined(); expect(parsed["jumpTable"]).toBeDefined(); }); @@ -200,14 +302,51 @@ describe("integration: MCP server over stdio", { timeout: 60_000 }, () => { expect(parsed["endLine"]).toBe(5); }); - it("doctor returns health check", { timeout: 60_000 }, async () => { - const result = await client.callTool({ + it("doctor defaults to summary and preserves explicit full detail", { timeout: 60_000 }, async () => { + const summaryResult = await client.callTool({ name: "doctor", arguments: {}, }); - const parsed = JSON.parse(extractText(result)) as Record; - expect(parsed["projectRoot"]).toBe(projectRoot); - expect(parsed["parserHealthy"]).toBe(true); + const summary = structuredPayload(summaryResult, "doctor"); + expect(summary["health"]).toBe("degraded"); + expect(summary["workspace"]).toEqual(expect.objectContaining({ bindState: "bound" })); + + const fullResult = await client.callTool({ + name: "doctor", + arguments: { detail: "full" }, + }); + const full = structuredPayload(fullResult, "doctor"); + expect(full["projectRoot"]).toBe(projectRoot); + expect(full["parserHealthy"]).toBe(true); + }); + + it("activity_view validates both summary and full structured variants", { timeout: 60_000 }, async () => { + const summaryResult = await client.callTool({ + name: "activity_view", + arguments: {}, + }); + const summary = structuredPayload(summaryResult, "activity_view"); + expect(summary["truthClass"]).toBe("artifact_history"); + expect(summary).not.toHaveProperty("activeCausalWorkspace"); + + const fullResult = await client.callTool({ + name: "activity_view", + arguments: { detail: "full" }, + }); + const full = structuredPayload(fullResult, "activity_view"); + expect(full["truthClass"]).toBe("artifact_history"); + expect(full).toHaveProperty("activeCausalWorkspace"); + }); + + it("keeps MCP tool errors usable without structured content", async () => { + const result = await client.callTool({ + name: "doctor", + arguments: { detail: "everything" }, + }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + expect(extractText(result).length).toBeGreaterThan(0); }); it("stats returns metrics summary", { timeout: 60_000 }, async () => { diff --git a/test/unit/api/tool-bridge.test.ts b/test/unit/api/tool-bridge.test.ts index 7f773299..158e40bd 100644 --- a/test/unit/api/tool-bridge.test.ts +++ b/test/unit/api/tool-bridge.test.ts @@ -8,6 +8,16 @@ function createTextResult(text: string): McpToolResult { }; } +function createStructuredResult( + structuredContent: Record, + text = JSON.stringify(structuredContent), +): McpToolResult { + return { + content: [{ type: "text", text }], + structuredContent, + }; +} + describe("api tool bridge", () => { it("parses object-shaped tool payloads", () => { const parsed = parseGraftToolPayload(createTextResult('{"ok":true,"count":1}')); @@ -15,6 +25,26 @@ describe("api tool bridge", () => { expect(parsed).toEqual({ ok: true, count: 1 }); }); + it("prefers native structured content over compatibility text", () => { + const parsed = parseGraftToolPayload(createStructuredResult( + { source: "structured" }, + '{"source":"text"}', + )); + + expect(parsed).toEqual({ source: "structured" }); + }); + + it("rejects malformed native structured content instead of hiding it with text", () => { + const result = { + content: [{ type: "text" as const, text: '{"ok":true}' }], + structuredContent: ["not", "an", "object"], + } as unknown as McpToolResult; + + expect(() => parseGraftToolPayload(result)).toThrow( + "Graft tool structured result was not a JSON object", + ); + }); + it("rejects non-object JSON payloads", () => { expect(() => parseGraftToolPayload(createTextResult('["not","an","object"]'))).toThrow( "Graft tool result was not a JSON object", diff --git a/test/unit/cli/daemon-status.test.ts b/test/unit/cli/daemon-status.test.ts new file mode 100644 index 00000000..aaf50be4 --- /dev/null +++ b/test/unit/cli/daemon-status.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildDaemonStatusProbeArguments } from "../../../src/cli/daemon-status.js"; + +describe("cli: daemon status probe compatibility", () => { + it("omits the v2 receipt control when an older daemon does not advertise it", () => { + expect(buildDaemonStatusProbeArguments( + { + type: "object", + properties: { + cwd: { type: "string" }, + }, + }, + { cwd: "/repo" }, + )).toEqual({ cwd: "/repo" }); + }); + + it("requests a full receipt when the daemon advertises the v2 control", () => { + expect(buildDaemonStatusProbeArguments( + { + type: "object", + properties: { + cwd: { type: "string" }, + receipt: { type: "string", enum: ["compact", "full"] }, + }, + }, + { cwd: "/repo" }, + )).toEqual({ cwd: "/repo", receipt: "full" }); + }); +}); diff --git a/test/unit/cli/peer-command.test.ts b/test/unit/cli/peer-command.test.ts new file mode 100644 index 00000000..4173f22d --- /dev/null +++ b/test/unit/cli/peer-command.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { projectMcpV2PeerPayloadToCliV1 } from "../../../src/cli/peer-command.js"; + +describe("CLI peer payload projection", () => { + it("removes MCP v2-only observation evidence from the frozen CLI v1 shape", () => { + const projected = projectMcpV2PeerPayloadToCliV1({ + semanticTransition: { + kind: "unknown", + authority: "repo_snapshot", + observationBasis: "snapshot_delta", + phase: null, + }, + activityWindow: { + groups: [{ + items: [{ + eventKind: "transition", + payload: { + semanticKind: "unknown", + authority: "repo_snapshot", + observationBasis: "git_transition_evidence", + }, + }], + }], + }, + _receipt: { + mode: "full", + traceId: "trace-1", + cumulative: { reads: 1 }, + }, + }); + + expect(projected).toEqual({ + semanticTransition: { + kind: "unknown", + authority: "repo_snapshot", + phase: null, + }, + activityWindow: { + groups: [{ + items: [{ + eventKind: "transition", + payload: { + semanticKind: "unknown", + authority: "repo_snapshot", + }, + }], + }], + }, + _receipt: { + traceId: "trace-1", + cumulative: { reads: 1 }, + }, + }); + }); +}); diff --git a/test/unit/contracts/capabilities.test.ts b/test/unit/contracts/capabilities.test.ts index 69c2eec5..af179344 100644 --- a/test/unit/contracts/capabilities.test.ts +++ b/test/unit/contracts/capabilities.test.ts @@ -10,6 +10,10 @@ describe("capability registry", () => { it("treats API, CLI, and MCP as explicit entrypoint surfaces", () => { const byId = new Map(CAPABILITY_REGISTRY.map((capability) => [capability.id, capability])); + expect(byId.get("capabilities")?.surfaces).toEqual(["api", "mcp"]); + expect(byId.get("capabilities")?.apiExposure).toBe("tool_bridge"); + expect(byId.get("capabilities")?.cliMcpParity).toBe("mcp_only"); + expect(byId.get("safe_read")?.surfaces).toEqual(["api", "cli", "mcp"]); expect(byId.get("safe_read")?.apiExposure).toBe("repo_workspace"); expect(byId.get("safe_read")?.cliMcpParity).toBe("peer"); @@ -73,13 +77,23 @@ describe("capability registry", () => { expect(baseline).toEqual({ cliOnly: 6, apiCliMcp: 23, - apiMcp: 24, + apiMcp: 25, apiOnly: 1, directCliMcpPeers: 22, composedCliOperators: 1, - intentionallyApiMcpOnly: 24, + intentionallyApiMcpOnly: 25, }); expect(rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: "capabilities", + api: "Yes", + cli: "No", + mcp: "Yes", + apiExposure: "tool_bridge", + cliMcpParity: "mcp_only", + cliPath: "-", + mcpTool: "capabilities", + }), expect.objectContaining({ id: "safe_read", api: "Yes", diff --git a/test/unit/contracts/causal-ontology.test.ts b/test/unit/contracts/causal-ontology.test.ts index 1645c6f9..5f1cd6e0 100644 --- a/test/unit/contracts/causal-ontology.test.ts +++ b/test/unit/contracts/causal-ontology.test.ts @@ -244,6 +244,7 @@ describe("contracts: causal ontology", () => { payload: { semanticKind: "merge_phase", authority: "authoritative_git_state", + observationBasis: "git_transition_evidence", phase: "conflicted", summary: "Merge is in conflict across 2 path(s).", transitionKind: "merge", @@ -256,6 +257,23 @@ describe("contracts: causal ontology", () => { expect(readEvent.eventKind).toBe("read"); expect(stageEvent.eventKind).toBe("stage"); expect(transitionEvent.eventKind).toBe("transition"); + const legacyPayload: Record = { ...transitionEvent.payload }; + delete legacyPayload["observationBasis"]; + const legacyTransitionEvent = causalEventSchema.parse(JSON.parse(JSON.stringify({ + ...transitionEvent, + payload: legacyPayload, + }))); + expect(legacyTransitionEvent.eventKind).toBe("transition"); + if (legacyTransitionEvent.eventKind === "transition") { + expect(legacyTransitionEvent.payload.observationBasis).toBe("legacy_unclassified"); + } + expect(() => causalEventSchema.parse({ + ...transitionEvent, + payload: { + ...transitionEvent.payload, + observationBasis: "unsupported_basis", + }, + })).toThrow(); }); it("keeps attribution confidence bounded by evidence strength", () => { diff --git a/test/unit/contracts/mcp-capability-discovery.test.ts b/test/unit/contracts/mcp-capability-discovery.test.ts new file mode 100644 index 00000000..f9bdfccc --- /dev/null +++ b/test/unit/contracts/mcp-capability-discovery.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; + +import { CanonicalJsonCodec } from "../../../src/adapters/canonical-json.js"; +import { + CAPABILITY_REGISTRY, + MCP_TOOL_NAMES, +} from "../../../src/contracts/capabilities.js"; +import { + buildMcpCapabilityDiscovery, + MCP_CAPABILITY_FAMILIES, + MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES, + MCP_CAPABILITY_FAMILY_DEFINITIONS, + MCP_CAPABILITY_SUMMARY_MAX_BYTES, + MCP_DAEMON_ONLY_TOOLS, + mcpCapabilityDiscoveryBodySchema, + type McpCapabilityDiscoveryBody, + type McpCapabilityFamily, +} from "../../../src/contracts/mcp-capability-discovery.js"; +import { emptyBurdenByKind } from "../../../src/mcp/burden.js"; +import { MetricsSnapshot } from "../../../src/mcp/metrics.js"; +import { buildReceiptResult } from "../../../src/mcp/receipt.js"; +import { + ALL_TOOL_REGISTRY, + DAEMON_TOOL_REGISTRY, + TOOL_REGISTRY, +} from "../../../src/mcp/tool-registry.js"; + +function codePointOrder(left: string, right: string): number { + const leftPoints = Array.from(left, (value) => value.codePointAt(0) ?? 0); + const rightPoints = Array.from(right, (value) => value.codePointAt(0) ?? 0); + const commonLength = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < commonLength; index += 1) { + const difference = (leftPoints[index] ?? 0) - (rightPoints[index] ?? 0); + if (difference !== 0) return difference; + } + return leftPoints.length - rightPoints.length; +} + +function sorted(values: readonly string[]): readonly string[] { + return [...values].sort(codePointOrder); +} + +function encodedBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} + +const codec = new CanonicalJsonCodec(); + +function completeCompactResponseBytes(body: McpCapabilityDiscoveryBody): number { + return buildReceiptResult("capabilities", body, { + sessionId: "capability-contract-session", + traceId: "capability-contract-receipt", + seq: 1, + latencyMs: 0, + metrics: new MetricsSnapshot({ + reads: 0, + outlines: 0, + refusals: 0, + cacheHits: 0, + bytesReturned: 0, + bytesAvoided: 0, + burdenByKind: emptyBurdenByKind(), + }), + tripwires: [], + codec, + receiptMode: "compact", + }).textBytes; +} + +describe("MCP capability discovery contract", () => { + it("assigns every registered MCP tool to exactly one family", () => { + const familyTools = MCP_CAPABILITY_FAMILY_DEFINITIONS.flatMap((definition) => definition.tools); + + expect(new Set(familyTools).size).toBe(familyTools.length); + expect(sorted(familyTools)).toEqual(sorted(MCP_TOOL_NAMES)); + expect(sorted(ALL_TOOL_REGISTRY.map((tool) => tool.name))).toEqual(sorted(MCP_TOOL_NAMES)); + }); + + it("keeps runtime filtering checked against the authoritative tool registries", () => { + expect(sorted(MCP_DAEMON_ONLY_TOOLS)).toEqual( + sorted(DAEMON_TOOL_REGISTRY.map((tool) => tool.name)), + ); + + const repoSummary = buildMcpCapabilityDiscovery({ sessionMode: "repo_local" }); + const daemonSummary = buildMcpCapabilityDiscovery({ sessionMode: "daemon" }); + expect(repoSummary.projection).toBe("summary"); + expect(daemonSummary.projection).toBe("summary"); + if (repoSummary.projection !== "summary" || daemonSummary.projection !== "summary") return; + + expect(repoSummary.registeredToolCount).toBe(34); + expect(repoSummary.registeredToolCount).toBe(TOOL_REGISTRY.length); + expect(daemonSummary.registeredToolCount).toBe(48); + expect(daemonSummary.registeredToolCount).toBe(ALL_TOOL_REGISTRY.length); + expect(repoSummary.families.map((family) => family.toolCount)).toEqual([5, 4, 4, 4, 8, 3, 6]); + expect(daemonSummary.families.map((family) => family.toolCount)).toEqual([5, 18, 4, 4, 8, 3, 6]); + + const repoToolNames = new Set(TOOL_REGISTRY.map((tool) => tool.name)); + for (const definition of MCP_CAPABILITY_FAMILY_DEFINITIONS) { + expect(repoToolNames.has(definition.openingCall)).toBe(true); + } + }); + + it("derives family-detail descriptions from the capability registry", () => { + const descriptions = new Map( + CAPABILITY_REGISTRY.flatMap((capability) => ( + capability.mcpTool === undefined + ? [] + : [[capability.mcpTool, capability.description] as const] + )), + ); + + expect(descriptions.size).toBe(MCP_TOOL_NAMES.length); + for (const family of MCP_CAPABILITY_FAMILIES) { + const detail = buildMcpCapabilityDiscovery({ sessionMode: "daemon", family }); + expect(detail.projection).toBe("family_detail"); + if (detail.projection !== "family_detail") continue; + for (const tool of detail.tools) { + expect(tool.description).toBe(descriptions.get(tool.name)); + } + } + }); + + it("returns the fixed summary order and opening calls without description sludge", () => { + const summary = buildMcpCapabilityDiscovery({ sessionMode: "repo_local" }); + + expect(summary).toMatchObject({ + projection: "summary", + reason: "CAPABILITY_SUMMARY", + discoveryBasis: "registered_surface", + sessionMode: "repo_local", + registeredToolCount: 34, + }); + expect(summary.projection).toBe("summary"); + if (summary.projection !== "summary") return; + + expect(summary.families.map((entry) => entry.family)).toEqual(MCP_CAPABILITY_FAMILIES); + expect(summary.families.map((entry) => entry.openingCall)).toEqual([ + "capabilities", + "workspace_status", + "safe_read", + "code_find", + "graft_since", + "graft_review", + "doctor", + ]); + for (const entry of summary.families) { + expect(Object.keys(entry).sort()).toEqual([ + "family", + "guidance", + "openingCall", + "toolCount", + ]); + } + expect(mcpCapabilityDiscoveryBodySchema.parse(summary)).toEqual(summary); + }); + + it("returns only the selected family with opening-call-first deterministic ordering", () => { + const repoWorkspace = buildMcpCapabilityDiscovery({ + sessionMode: "repo_local", + family: "workspace", + }); + const daemonWorkspace = buildMcpCapabilityDiscovery({ + sessionMode: "daemon", + family: "workspace", + }); + + expect(repoWorkspace).toMatchObject({ + projection: "family_detail", + reason: "CAPABILITY_FAMILY_DETAIL", + discoveryBasis: "registered_surface", + sessionMode: "repo_local", + registeredToolCount: 34, + family: "workspace", + openingCall: "workspace_status", + toolCount: 4, + }); + expect(repoWorkspace.projection).toBe("family_detail"); + expect(daemonWorkspace.projection).toBe("family_detail"); + if (repoWorkspace.projection !== "family_detail" || daemonWorkspace.projection !== "family_detail") { + return; + } + + expect(repoWorkspace.tools.map((tool) => tool.name)).toEqual([ + "workspace_status", + "causal_attach", + "workspace_list_opened", + "workspace_open", + ]); + expect(daemonWorkspace.tools[0]?.name).toBe("workspace_status"); + expect(daemonWorkspace.tools.slice(1).map((tool) => tool.name)).toEqual( + sorted(daemonWorkspace.tools.slice(1).map((tool) => tool.name)), + ); + expect(daemonWorkspace.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining([...MCP_DAEMON_ONLY_TOOLS]), + ); + + for (const family of MCP_CAPABILITY_FAMILIES) { + const detail = buildMcpCapabilityDiscovery({ sessionMode: "daemon", family }); + expect(detail.projection).toBe("family_detail"); + if (detail.projection !== "family_detail") continue; + expect(detail.tools[0]?.name).toBe(detail.openingCall); + expect(detail.tools.slice(1).map((tool) => tool.name)).toEqual( + sorted(detail.tools.slice(1).map((tool) => tool.name)), + ); + expect(detail.toolCount).toBe(detail.tools.length); + expect(mcpCapabilityDiscoveryBodySchema.parse(detail)).toEqual(detail); + } + }); + + it("keeps complete canonical compact responses within their byte budgets", () => { + for (const sessionMode of ["repo_local", "daemon"] as const) { + const summary = buildMcpCapabilityDiscovery({ sessionMode }); + expect(encodedBytes(summary)).toBeLessThanOrEqual(MCP_CAPABILITY_SUMMARY_MAX_BYTES); + expect(completeCompactResponseBytes(summary)).toBeLessThanOrEqual( + MCP_CAPABILITY_SUMMARY_MAX_BYTES, + ); + + for (const family of MCP_CAPABILITY_FAMILIES) { + const detail = buildMcpCapabilityDiscovery({ sessionMode, family }); + expect(encodedBytes(detail)).toBeLessThanOrEqual(MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES); + expect(completeCompactResponseBytes(detail)).toBeLessThanOrEqual( + MCP_CAPABILITY_FAMILY_DETAIL_MAX_BYTES, + ); + } + } + }); + + it("keeps the shared body schema strict across both projections", () => { + const summary = buildMcpCapabilityDiscovery({ sessionMode: "daemon" }); + const detail = buildMcpCapabilityDiscovery({ + sessionMode: "daemon", + family: "code" as McpCapabilityFamily, + }); + + expect(mcpCapabilityDiscoveryBodySchema.safeParse({ ...summary, unexpected: true }).success).toBe(false); + expect(mcpCapabilityDiscoveryBodySchema.safeParse({ ...detail, families: [] }).success).toBe(false); + }); +}); diff --git a/test/unit/contracts/mcp-discovery-output-schemas.test.ts b/test/unit/contracts/mcp-discovery-output-schemas.test.ts new file mode 100644 index 00000000..38d4dbad --- /dev/null +++ b/test/unit/contracts/mcp-discovery-output-schemas.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { MCP_TOOL_NAMES } from "../../../src/contracts/capabilities.js"; +import { + MCP_DISCOVERY_OUTPUT_SCHEMA_MAX_TOOL_BYTES, + MCP_DISCOVERY_OUTPUT_SCHEMA_MAX_TOTAL_BYTES, + MCP_DISCOVERY_OUTPUT_SCHEMAS, + buildMcpDiscoveryOutputSchema, + getMcpDiscoveryOutputSchema, +} from "../../../src/contracts/mcp-discovery-output-schemas.js"; +import { MCP_OUTPUT_SCHEMAS } from "../../../src/contracts/output-schemas.js"; + +function canonicalRootVariants(schema: z.ZodType): readonly z.ZodObject[] { + if (schema instanceof z.ZodObject) { + return [schema]; + } + if (schema instanceof z.ZodUnion) { + return schema.options.map((option) => { + if (!(option instanceof z.ZodObject)) { + throw new Error("expected an object-root canonical schema"); + } + return option; + }); + } + throw new Error("expected an object-root canonical schema"); +} + +function schemaBytes(schema: z.ZodType): number { + return Buffer.byteLength(JSON.stringify(z.toJSONSchema(schema)), "utf8"); +} + +function common(tool: string, receiptMode: "compact" | "full" = "compact") { + return { + _schema: { id: `graft.mcp.${tool}`, version: "2.0.0" }, + _receipt: { mode: receiptMode }, + }; +} + +describe("bounded MCP discovery output schemas", () => { + it("projects every canonical tool contract to a strict object root", () => { + expect(Object.keys(MCP_DISCOVERY_OUTPUT_SCHEMAS).sort()).toEqual( + [...MCP_TOOL_NAMES].sort(), + ); + for (const tool of MCP_TOOL_NAMES) { + const schema = getMcpDiscoveryOutputSchema(tool); + expect(schema).toBeInstanceOf(z.ZodObject); + const jsonSchema = z.toJSONSchema(schema); + expect(jsonSchema.type).toBe("object"); + expect(jsonSchema.additionalProperties).toBe(false); + } + }); + + it("preserves every top-level property in deterministic code-point order", () => { + for (const tool of MCP_TOOL_NAMES) { + const canonicalFields = new Set( + canonicalRootVariants(MCP_OUTPUT_SCHEMAS[tool]) + .flatMap((variant) => Object.keys(variant.shape)), + ); + const discoveryFields = Object.keys(getMcpDiscoveryOutputSchema(tool).shape); + expect(discoveryFields).toEqual([...discoveryFields].sort()); + expect(new Set(discoveryFields)).toEqual(canonicalFields); + } + }); + + it("is invariant to canonical root-union construction order", () => { + const canonical = MCP_OUTPUT_SCHEMAS.file_outline; + if (!(canonical instanceof z.ZodUnion)) { + throw new Error("file_outline must remain an object-root union fixture"); + } + const [first, second] = canonical.options; + if (first === undefined || second === undefined) { + throw new Error("file_outline union fixture requires two variants"); + } + const reversed = z.union([second, first]); + + const forwardJson = JSON.stringify(z.toJSONSchema( + buildMcpDiscoveryOutputSchema("file_outline", canonical), + )); + const reversedJson = JSON.stringify(z.toJSONSchema( + buildMcpDiscoveryOutputSchema("file_outline", reversed), + )); + expect(reversedJson).toBe(forwardJson); + }); + + it("stays within the aggregate and per-tool discovery budgets", () => { + let totalBytes = 0; + for (const tool of MCP_TOOL_NAMES) { + const bytes = schemaBytes(getMcpDiscoveryOutputSchema(tool)); + expect(bytes, `${tool} discovery schema bytes`).toBeLessThanOrEqual( + MCP_DISCOVERY_OUTPUT_SCHEMA_MAX_TOOL_BYTES, + ); + totalBytes += bytes; + } + expect(totalBytes).toBeLessThanOrEqual(MCP_DISCOVERY_OUTPUT_SCHEMA_MAX_TOTAL_BYTES); + }); + + it("keeps exact schema identity while exposing both receipt modes", () => { + const schema = getMcpDiscoveryOutputSchema("safe_read"); + const payload = { + path: "src/index.ts", + projection: "content", + reason: "CONTENT", + ...common("safe_read"), + }; + + expect(() => schema.parse(payload)).not.toThrow(); + expect(() => schema.parse({ + ...payload, + _receipt: { mode: "full", cumulative: { reads: 1 } }, + })).not.toThrow(); + expect(() => schema.parse({ + ...payload, + _schema: { id: "graft.mcp.stats", version: "2.0.0" }, + })).toThrow(); + expect(() => schema.parse({ + ...payload, + _schema: { ...payload._schema, future: true }, + })).toThrow(); + expect(() => schema.parse({ ...payload, _receipt: { mode: "verbose" } })).toThrow(); + expect(() => schema.parse({ ...payload, _receipt: {} })).toThrow(); + }); + + it("flattens canonical union roots without making variant-only fields required", () => { + const fileOutline = getMcpDiscoveryOutputSchema("file_outline"); + expect(() => fileOutline.parse({ + path: "src/index.ts", + outline: [{ future: "shape" }], + jumpTable: [], + ...common("file_outline"), + })).not.toThrow(); + expect(() => fileOutline.parse({ + path: "src/index.ts", + projection: "refused", + reason: "policy", + ...common("file_outline", "full"), + })).not.toThrow(); + + const doctor = getMcpDiscoveryOutputSchema("doctor"); + expect(() => doctor.parse({ + recommendedNextAction: "bind_workspace_to_begin_local_history", + ...common("doctor"), + })).not.toThrow(); + expect(doctor.shape["health"]).toBeInstanceOf(z.ZodOptional); + expect(doctor.shape["projectRoot"]).toBeInstanceOf(z.ZodOptional); + + const activity = getMcpDiscoveryOutputSchema("activity_view"); + expect(activity).toBeInstanceOf(z.ZodObject); + expect(activity.shape["workspace"]).toBeInstanceOf(z.ZodOptional); + expect(activity.shape["sessionMode"]).toBeInstanceOf(z.ZodOptional); + }); + + it("preserves top-level scalar contracts while leaving deep payloads shallow", () => { + const safeRead = getMcpDiscoveryOutputSchema("safe_read"); + const payload = { + path: "src/index.ts", + projection: "content", + reason: "CONTENT", + actual: { deliberately: "shallow" }, + outline: [42, "future-entry"], + ...common("safe_read"), + }; + + expect(() => safeRead.parse(payload)).not.toThrow(); + expect(() => safeRead.parse({ ...payload, path: 42 })).toThrow(); + expect(() => safeRead.parse({ ...payload, projection: "unknown" })).toThrow(); + expect(() => safeRead.parse({ ...payload, unexpectedTopLevel: true })).toThrow(); + }); +}); diff --git a/test/unit/contracts/output-schemas.test.ts b/test/unit/contracts/output-schemas.test.ts index 28d3591d..a7f2cbbf 100644 --- a/test/unit/contracts/output-schemas.test.ts +++ b/test/unit/contracts/output-schemas.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; +import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { ALL_TOOL_REGISTRY, createGraftServer } from "../../../src/mcp/server.js"; @@ -8,8 +9,11 @@ import { MCP_TOOL_NAMES, MCP_OUTPUT_SCHEMAS, attachCliSchemaMeta, + getCliOutputSchemaMeta, getCliOutputJsonSchema, + getMcpOutputSchemaMeta, getMcpOutputJsonSchema, + validateMcpOutputBody, validateCliOutput, } from "../../../src/contracts/output-schemas.js"; import { runCli } from "../../../src/cli/main.js"; @@ -21,6 +25,15 @@ import { writeLegacyLocalHistoryArtifact } from "../../helpers/legacy-local-hist import { createServerInRepo, parse } from "../../helpers/mcp.js"; const CLI_PEER_SCHEMA_TIMEOUT_MS = 120_000; +// Frozen from origin/main@c3885dab before MCP v2 added observationBasis. +const ORIGIN_MAIN_CLI_V1_SCHEMA_DIGESTS = { + diag_doctor: "9540b569945677f16ca90c3f87e041975ab27e218da2241c28e4a908b4072db1", + diag_activity: "cfd20f0c83ff2217b40822569de28a2acd703c3706f58649e0102abb1aa232dd", +} as const; + +function schemaDigest(schema: unknown): string { + return createHash("sha256").update(JSON.stringify(schema)).digest("hex"); +} function createDaemonServer(graftDir: string) { return createGraftServer({ @@ -61,6 +74,77 @@ describe("contracts: output schemas", () => { } }); + it("advances every MCP contract to v2 while preserving CLI contracts at v1", () => { + for (const tool of MCP_TOOL_NAMES) { + expect(getMcpOutputSchemaMeta(tool).version).toBe("2.0.0"); + } + for (const command of CLI_COMMAND_NAMES) { + expect(getCliOutputSchemaMeta(command).version).toBe("1.0.0"); + } + }); + + it("preserves the complete origin/main v1 diagnostic CLI schemas", () => { + for (const [command, expectedDigest] of Object.entries(ORIGIN_MAIN_CLI_V1_SCHEMA_DIGESTS)) { + expect(schemaDigest(getCliOutputJsonSchema(command as "diag_doctor" | "diag_activity"))) + .toBe(expectedDigest); + } + }); + + it("enforces strict compact and full receipt alternatives", async () => { + const repoDir = createTestRepo("graft-output-schema-receipts-"); + cleanups.push(repoDir); + fs.writeFileSync(path.join(repoDir, "app.ts"), "export const ready = true;\n"); + git(repoDir, "add -A"); + git(repoDir, "commit -m init"); + const server = createServerInRepo(repoDir); + + const compact = parse(await server.callTool("safe_read", { path: "app.ts" })); + const full = parse(await server.callTool("safe_read", { + path: "app.ts", + receipt: "full", + })); + + expect(() => MCP_OUTPUT_SCHEMAS.safe_read.parse(compact)).not.toThrow(); + expect(() => MCP_OUTPUT_SCHEMAS.safe_read.parse(full)).not.toThrow(); + + const compactReceipt = compact["_receipt"] as Record; + expect(() => MCP_OUTPUT_SCHEMAS.safe_read.parse({ + ...compact, + _receipt: { ...compactReceipt, cumulative: {} }, + })).toThrow(); + + const fullReceipt = full["_receipt"] as Record; + const { mode: _mode, ...missingMode } = fullReceipt; + expect(() => MCP_OUTPUT_SCHEMAS.safe_read.parse({ + ...full, + _receipt: missingMode, + })).toThrow(); + const { cumulative: _cumulative, ...missingCumulative } = fullReceipt; + expect(() => MCP_OUTPUT_SCHEMAS.safe_read.parse({ + ...full, + _receipt: missingCumulative, + })).toThrow(); + }); + + it("preflights governed edit domain responses without runtime metadata", () => { + const response = { + path: "/repo/app.ts", + operation: "replace", + projection: "edited", + status: "edited", + changed: true, + matches: 1, + replacements: 1, + actual: { lines: 2, bytes: 26 }, + }; + + expect(validateMcpOutputBody("graft_edit", response)).toEqual(response); + expect(() => validateMcpOutputBody("graft_edit", { + ...response, + unsupported: true, + })).toThrow(); + }); + it("preserves concrete CLI output types through the helper stack", () => { const payload = validateCliOutput("diag_local_history_dag", attachCliSchemaMeta("diag_local_history_dag", { cwd: "/tmp/example", @@ -176,6 +260,7 @@ describe("contracts: output schemas", () => { git(repoDir, "checkout -b feature/output-schema-attach"); const outputs = { + capabilities: parse(await server.callTool("capabilities", {})), safe_read: parse(await server.callTool("safe_read", { path: "app.ts" })), graft_edit: parse(await server.callTool("graft_edit", { path: "edit-target.ts", @@ -384,6 +469,12 @@ describe("contracts: output schemas", () => { for (const command of CLI_COMMAND_NAMES.filter((name) => !["init", "index", "migrate_local_history"].includes(name))) { expect(() => CLI_OUTPUT_SCHEMAS[command].parse(outputs[command as keyof typeof outputs])).not.toThrow(); } + + const cliReadReceipt = outputs.read_safe["_receipt"] as Record; + expect(outputs.read_safe["_schema"]).toEqual({ id: "graft.cli.read_safe", version: "1.0.0" }); + expect(cliReadReceipt["mode"]).toBeUndefined(); + expect(cliReadReceipt["receiptId"]).toBeUndefined(); + expect(cliReadReceipt["cumulative"]).toBeDefined(); }); it("validates local-history migration JSON output against the declared CLI schema", { timeout: 15_000 }, async () => { diff --git a/test/unit/library/index.test.ts b/test/unit/library/index.test.ts index 2580ca16..21f24da7 100644 --- a/test/unit/library/index.test.ts +++ b/test/unit/library/index.test.ts @@ -47,10 +47,18 @@ describe("public library API", () => { expect(result["_schema"]).toEqual( expect.objectContaining({ id: "graft.mcp.safe_read" }), ); + + const capabilities = await callGraftTool(graft, "capabilities", {}) as Record; + expect(capabilities).toMatchObject({ + projection: "summary", + discoveryBasis: "registered_surface", + sessionMode: "repo_local", + }); }); it("exports stable top-level metadata and capability names", () => { expect(GRAFT_VERSION).toBe(packageJson.version); + expect(MCP_TOOL_NAMES).toContain("capabilities"); expect(MCP_TOOL_NAMES).toContain("safe_read"); expect(MCP_TOOL_NAMES).toContain("doctor"); }); diff --git a/test/unit/mcp/capabilities.test.ts b/test/unit/mcp/capabilities.test.ts new file mode 100644 index 00000000..8813751a --- /dev/null +++ b/test/unit/mcp/capabilities.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; +import { capabilitiesTool } from "../../../src/mcp/tools/capabilities.js"; +import type { ToolContext } from "../../../src/mcp/context.js"; +import { repoStateOptionalTools } from "../../../src/mcp/server-tool-access.js"; + +interface CapturedResponse { + readonly tool: string; + readonly data: Record; +} + +function createContext(sessionMode: "repo_local" | "daemon"): { + readonly ctx: ToolContext; + readonly responses: CapturedResponse[]; +} { + const responses: CapturedResponse[] = []; + const ctx = { + getWorkspaceStatus: () => ({ sessionMode }), + respond: (tool: string, data: Record) => { + responses.push({ tool, data }); + return { + content: [{ type: "text" as const, text: JSON.stringify(data) }], + }; + }, + } as unknown as ToolContext; + return { ctx, responses }; +} + +describe("mcp: capabilities", () => { + it("returns the bounded registered-family summary for the active runtime", async () => { + const { ctx, responses } = createContext("repo_local"); + + await capabilitiesTool.createHandler()({}, ctx); + + expect(responses).toHaveLength(1); + expect(responses[0]!.tool).toBe("capabilities"); + expect(responses[0]!.data).toMatchObject({ + projection: "summary", + reason: "CAPABILITY_SUMMARY", + discoveryBasis: "registered_surface", + sessionMode: "repo_local", + registeredToolCount: 34, + }); + + const families = responses[0]!.data["families"] as Record[]; + expect(families.map((family) => family["family"])).toEqual([ + "session", + "workspace", + "read", + "code", + "history", + "review", + "diagnostic", + ]); + expect(families).toEqual(expect.arrayContaining([ + expect.objectContaining({ + family: "read", + openingCall: "safe_read", + toolCount: 4, + }), + ])); + expect(families.every((family) => !("tools" in family))).toBe(true); + }); + + it("returns only the explicitly selected family detail", async () => { + const { ctx, responses } = createContext("daemon"); + + await capabilitiesTool.createHandler()({ family: "read" }, ctx); + + expect(responses).toHaveLength(1); + expect(responses[0]!.tool).toBe("capabilities"); + expect(responses[0]!.data).toMatchObject({ + projection: "family_detail", + reason: "CAPABILITY_FAMILY_DETAIL", + discoveryBasis: "registered_surface", + sessionMode: "daemon", + registeredToolCount: 48, + family: "read", + openingCall: "safe_read", + toolCount: 4, + }); + expect(responses[0]!.data).not.toHaveProperty("families"); + + const tools = responses[0]!.data["tools"] as Record[]; + expect(tools.map((tool) => tool["name"])).toEqual([ + "safe_read", + "changed_since", + "file_outline", + "read_range", + ]); + expect(tools.every((tool) => typeof tool["description"] === "string")).toBe(true); + }); + + it("publishes one optional strict family selector", () => { + const familySchema = capabilitiesTool.schema?.["family"]; + expect(familySchema).toBeDefined(); + expect(familySchema!.parse(undefined)).toBeUndefined(); + expect(familySchema!.parse("history")).toBe("history"); + expect(() => familySchema!.parse("everything")).toThrow(ZodError); + }); + + it("does not require repository-state observation for discovery", () => { + expect(repoStateOptionalTools.has("capabilities")).toBe(true); + }); +}); diff --git a/test/unit/mcp/changed.test.ts b/test/unit/mcp/changed.test.ts index 4def5012..595cb61c 100644 --- a/test/unit/mcp/changed.test.ts +++ b/test/unit/mcp/changed.test.ts @@ -169,7 +169,10 @@ describe("mcp: changed-since-last-read", () => { it("receipt includes diff projection on changed reads", async () => { await server.callTool("safe_read", { path: testFile }); fs.writeFileSync(testFile, 'export function hello(): string {\n return "hi";\n}\nexport function extra(): void {}\n'); - const result = parse(await server.callTool("safe_read", { path: testFile })); + const result = parse(await server.callTool("safe_read", { + path: testFile, + receipt: "full", + })); const receipt = result["_receipt"] as { projection: string; reason: string }; expect(receipt.projection).toBe("diff"); expect(receipt.reason).toBe("CHANGED_SINCE_LAST_READ"); diff --git a/test/unit/mcp/context-guard.test.ts b/test/unit/mcp/context-guard.test.ts index 50f206e3..c8904229 100644 --- a/test/unit/mcp/context-guard.test.ts +++ b/test/unit/mcp/context-guard.test.ts @@ -23,6 +23,7 @@ function minimalContext(): Record { git: { run: () => Promise.resolve({}) }, runCapture: {}, observability: {}, + validateResponse: () => undefined, respond: () => ({ content: [] }), resolvePath: (r: string) => r, getWarp: () => Promise.resolve({}), diff --git a/test/unit/mcp/daemon-worker-pool.test.ts b/test/unit/mcp/daemon-worker-pool.test.ts index 0c0ce34e..e1497121 100644 --- a/test/unit/mcp/daemon-worker-pool.test.ts +++ b/test/unit/mcp/daemon-worker-pool.test.ts @@ -129,6 +129,7 @@ describe("mcp: daemon worker pool", () => { seq: 1, startedAtMs: Date.now(), tool: "graft_map", + receiptMode: "compact", args: {}, projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), @@ -193,6 +194,7 @@ describe("mcp: daemon worker pool", () => { workspaceSliceId: "slice:test", traceId: "trace:test", tool: "safe_read" as const, + receiptMode: "compact" as const, args: { path: "app.ts" }, projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), @@ -217,8 +219,13 @@ describe("mcp: daemon worker pool", () => { if (firstContent?.type !== "text") { throw new Error("expected text content"); } - const firstPayload = JSON.parse(firstContent.text) as { projection: string }; + const firstPayload = JSON.parse(firstContent.text) as { + projection: string; + _receipt: { mode: string; receiptId: string }; + }; expect(firstPayload.projection).toBe("content"); + expect(firstPayload._receipt.mode).toBe("compact"); + expect(firstPayload._receipt.receiptId).toBe("trace:test"); expect(first.cacheUpdates).toHaveLength(1); expect(first.cacheUpdates[0]?.observation).not.toBeNull(); expect(governorSnapshot).toEqual(governorSnapshotBefore); @@ -228,6 +235,7 @@ describe("mcp: daemon worker pool", () => { ...jobBase, seq: 2, startedAtMs: Date.now(), + receiptMode: "full", cacheSnapshots: { [first.cacheUpdates[0]!.path]: first.cacheUpdates[0]!.observation!, }, @@ -237,9 +245,15 @@ describe("mcp: daemon worker pool", () => { if (secondContent?.type !== "text") { throw new Error("expected text content"); } - const secondPayload = JSON.parse(secondContent.text) as { projection: string; reason: string }; + const secondPayload = JSON.parse(secondContent.text) as { + projection: string; + reason: string; + _receipt: { mode: string; cumulative: Record }; + }; expect(secondPayload.projection).toBe("cache_hit"); expect(secondPayload.reason).toBe("REREAD_UNCHANGED"); + expect(secondPayload._receipt.mode).toBe("full"); + expect(secondPayload._receipt.cumulative).toBeDefined(); }); it("refuses absolute paths outside the repo in the offloaded read worker context", async () => { @@ -271,6 +285,7 @@ describe("mcp: daemon worker pool", () => { seq: 1, startedAtMs: Date.now(), tool: "safe_read", + receiptMode: "compact", args: { path: outsideFile }, projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), @@ -320,6 +335,7 @@ describe("mcp: daemon worker pool", () => { seq: 1, startedAtMs: Date.now(), tool: "code_find_live", + receiptMode: "compact", args: { query: "greet" }, projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), diff --git a/test/unit/mcp/diagnostic-summary.test.ts b/test/unit/mcp/diagnostic-summary.test.ts new file mode 100644 index 00000000..933fb3bb --- /dev/null +++ b/test/unit/mcp/diagnostic-summary.test.ts @@ -0,0 +1,339 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ZodError } from "zod"; +import { MCP_OUTPUT_SCHEMAS } from "../../../src/contracts/output-schemas.js"; +import type { McpToolResult } from "../../../src/mcp/server.js"; +import { createIsolatedServer, parse } from "../../helpers/mcp.js"; +import { cleanupTestRepo, createTestRepo, git } from "../../helpers/git.js"; + +const cleanups: (() => void)[] = []; + +afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()!(); + } +}); + +function textOf(result: McpToolResult): string { + const content = result.content.find((item) => item.type === "text"); + if (content?.type !== "text") { + throw new Error("expected MCP text content"); + } + return content.text; +} + +function containsKey(value: unknown, key: string): boolean { + if (Array.isArray(value)) { + return value.some((entry) => containsKey(entry, key)); + } + if (value === null || typeof value !== "object") { + return false; + } + const record = value as Record; + return Object.hasOwn(record, key) + || Object.values(record).some((entry) => containsKey(entry, key)); +} + +function createLongPathRepo(): string { + const repoDir = createTestRepo(`graft-diagnostic-${"x".repeat(96)}-`); + cleanups.push(() => { + cleanupTestRepo(repoDir); + }); + fs.writeFileSync(path.join(repoDir, "app.ts"), "export const value = 0;\n"); + git(repoDir, "add -A"); + git(repoDir, "commit -m init"); + return repoDir; +} + +describe("mcp: summary-first diagnostics", () => { + it("returns a bounded doctor summary with explicit readiness uncertainty", async () => { + const repoDir = createLongPathRepo(); + const isolated = createIsolatedServer({ + projectRoot: repoDir, + graftDir: path.join(repoDir, ".graft"), + }); + cleanups.push(() => { + isolated.cleanup(); + }); + + const result = await isolated.server.callTool("doctor", {}); + const parsed = parse(result); + + expect(Buffer.byteLength(textOf(result), "utf8")).toBeLessThan(2_048); + expect(parsed["_receipt"]).toMatchObject({ mode: "compact" }); + expect(parsed["health"]).toBe("degraded"); + expect(parsed["workspace"]).toEqual(expect.objectContaining({ + sessionMode: "repo_local", + bindState: "bound", + repoId: expect.any(String), + worktreeId: expect.any(String), + })); + expect(parsed["history"]).toEqual({ + structural: { + readiness: "unknown", + reason: "not_observed", + }, + local: { + readiness: "ready", + active: true, + }, + }); + expect(parsed["degradedReasons"]).toEqual(expect.arrayContaining([ + "structural_history_readiness_unknown", + "target_repo_hooks_absent", + ])); + expect(parsed["recommendedNextAction"]).toBe("continue_active_causal_workspace"); + + expect(() => MCP_OUTPUT_SCHEMAS.doctor.parse(parsed)).not.toThrow(); + + const summaryWithProjectRoot = { + ...parsed, + projectRoot: repoDir, + }; + expect(() => MCP_OUTPUT_SCHEMAS.doctor.parse(summaryWithProjectRoot)).toThrow(ZodError); + + const summaryWithSludge = { + ...parsed, + sludge: {}, + }; + expect(() => MCP_OUTPUT_SCHEMAS.doctor.parse(summaryWithSludge)).toThrow(ZodError); + + for (const fullOnlyField of [ + "projectRoot", + "thresholds", + "burdenSummary", + "runtimeObservability", + "causalContext", + "workspaceOverlayFooting", + "persistedLocalHistory", + ]) { + expect(parsed).not.toHaveProperty(fullOnlyField); + } + }); + + it("restores exhaustive doctor evidence only for full detail or a sludge scan", async () => { + const repoDir = createLongPathRepo(); + fs.mkdirSync(path.join(repoDir, "src"), { recursive: true }); + fs.writeFileSync(path.join(repoDir, "src", "sloppy.ts"), [ + "/** @typedef {{ name: string }} UserShape */", + "/** @type {UserShape} */", + "const first = {};", + "/** @type {UserShape} */", + "const second = {};", + "/** @type {UserShape} */", + "const third = {};", + "type UserShape = { name: string };", + "", + ].join("\n")); + const isolated = createIsolatedServer({ + projectRoot: repoDir, + graftDir: path.join(repoDir, ".graft"), + }); + cleanups.push(() => { + isolated.cleanup(); + }); + + const full = parse(await isolated.server.callTool("doctor", { detail: "full" })); + expect(full["projectRoot"]).toBe(repoDir); + expect(full["runtimeObservability"]).toBeDefined(); + expect(full["persistedLocalHistory"]).toBeDefined(); + expect(full).not.toHaveProperty("health"); + expect(() => MCP_OUTPUT_SCHEMAS.doctor.parse(full)).not.toThrow(); + + const fullMissingParserHealth = structuredClone(full); + delete fullMissingParserHealth["parserHealthy"]; + expect(() => MCP_OUTPUT_SCHEMAS.doctor.parse(fullMissingParserHealth)).toThrow(ZodError); + + const sludge = parse(await isolated.server.callTool("doctor", { + detail: "summary", + sludge: true, + path: "src", + })); + expect(sludge["projectRoot"]).toBe(repoDir); + expect(sludge["sludge"]).toBeDefined(); + expect(sludge).not.toHaveProperty("health"); + expect(() => MCP_OUTPUT_SCHEMAS.doctor.parse(sludge)).not.toThrow(); + }); + + it("returns a bounded activity summary without event bodies and preserves window truth", async () => { + const repoDir = createLongPathRepo(); + const longRefSegment = "x".repeat(180); + const longRef = [ + longRefSegment, + longRefSegment, + longRefSegment, + longRefSegment, + ].join("/"); + git(repoDir, `checkout -b ${longRef}`); + const isolated = createIsolatedServer({ + projectRoot: repoDir, + graftDir: path.join(repoDir, ".graft"), + }); + cleanups.push(() => { + isolated.cleanup(); + }); + + for (let index = 1; index <= 3; index += 1) { + fs.writeFileSync( + path.join(repoDir, "app.ts"), + `export const value = ${String(index)};\n`, + ); + await isolated.server.callTool("safe_read", { path: "app.ts" }); + } + + const summaryResult = await isolated.server.callTool("activity_view", { limit: 2 }); + const summary = parse(summaryResult); + const summaryWindow = summary["activityWindow"] as { + returned: number; + totalMatchingItems: number; + truncated: boolean; + itemDetailAvailable: boolean; + groups: { groupKind: string; count: number; summary: string }[]; + }; + + expect(Buffer.byteLength(textOf(summaryResult), "utf8")).toBeLessThan(2_048); + expect(summary["_receipt"]).toMatchObject({ mode: "compact" }); + expect(summary["truthClass"]).toBe("artifact_history"); + expect(summary["anchor"]).toEqual(expect.objectContaining({ + posture: "head_commit", + headRef: expect.stringContaining("…"), + headRefTruncated: true, + })); + expect(summary).not.toHaveProperty("activeCausalWorkspace"); + expect(containsKey(summary, "items")).toBe(false); + expect(summaryWindow.returned).toBe(2); + expect(summaryWindow.totalMatchingItems).toBeGreaterThan(2); + expect(summaryWindow.truncated).toBe(true); + expect(summaryWindow.itemDetailAvailable).toBe(true); + expect(summaryWindow.groups.reduce((total, group) => total + group.count, 0)).toBe( + summaryWindow.returned, + ); + expect(summaryWindow.groups).toEqual(expect.arrayContaining([ + expect.objectContaining({ + groupKind: "read", + summary: expect.stringContaining("reads across"), + }), + ])); + expect(() => MCP_OUTPUT_SCHEMAS.activity_view.parse(summary)).not.toThrow(); + + const summaryWithOversizedUtf8 = structuredClone(summary); + const summaryGroups = (summaryWithOversizedUtf8["activityWindow"] as { + groups: { summary: string }[]; + }).groups; + expect(summaryGroups.length).toBeGreaterThan(0); + summaryGroups[0]!.summary = "😀".repeat(60); + expect(() => MCP_OUTPUT_SCHEMAS.activity_view.parse(summaryWithOversizedUtf8)).toThrow( + ZodError, + ); + + const full = parse(await isolated.server.callTool("activity_view", { + detail: "full", + limit: 2, + })); + const fullWindow = full["activityWindow"] as { + returned: number; + totalMatchingItems: number; + truncated: boolean; + groups: { items: Record[] }[]; + }; + expect(full["activeCausalWorkspace"]).toBeDefined(); + expect((full["anchor"] as { headRef: string }).headRef.length).toBeGreaterThan(64); + expect(full["anchor"]).not.toHaveProperty("headRefTruncated"); + expect(containsKey(full, "items")).toBe(true); + expect(fullWindow.returned).toBe(summaryWindow.returned); + expect(fullWindow.totalMatchingItems).toBe(summaryWindow.totalMatchingItems); + expect(fullWindow.truncated).toBe(summaryWindow.truncated); + expect(() => MCP_OUTPUT_SCHEMAS.activity_view.parse(full)).not.toThrow(); + + const summaryWithActiveWorkspace = { + ...summary, + activeCausalWorkspace: full["activeCausalWorkspace"], + }; + expect(() => MCP_OUTPUT_SCHEMAS.activity_view.parse(summaryWithActiveWorkspace)).toThrow( + ZodError, + ); + + const fullMissingGroupItems = structuredClone(full); + const groups = (fullMissingGroupItems["activityWindow"] as { + groups: Record[]; + }).groups; + expect(groups.length).toBeGreaterThan(0); + delete groups[0]!["items"]; + expect(() => MCP_OUTPUT_SCHEMAS.activity_view.parse(fullMissingGroupItems)).toThrow( + ZodError, + ); + + }); + + it("bounds a four-kind activity summary including a late-session tripwire", { + timeout: 20_000, + }, async () => { + const repoDir = createLongPathRepo(); + const longRefSegment = "x".repeat(180); + const longRef = [ + longRefSegment, + longRefSegment, + longRefSegment, + longRefSegment, + ].join("/"); + git(repoDir, `checkout -b ${longRef}`); + const isolated = createIsolatedServer({ + projectRoot: repoDir, + graftDir: path.join(repoDir, ".graft"), + }); + cleanups.push(() => { + isolated.cleanup(); + }); + + git(repoDir, "checkout -b aggregate-bound"); + await isolated.server.callTool("doctor", { detail: "full" }); + git(repoDir, `checkout ${longRef}`); + await isolated.server.callTool("doctor", { detail: "full" }); + await isolated.server.callTool("safe_read", { path: "app.ts" }); + fs.writeFileSync(path.join(repoDir, "staged.ts"), "export const staged = true;\n"); + git(repoDir, "add staged.ts"); + await isolated.server.callTool("doctor", { detail: "full" }); + isolated.server.injectSessionMessages(501); + isolated.server.injectSessionToolCalls([ + ...Array.from({ length: 81 }, () => "injected"), + ...Array.from({ length: 31 }, () => ["Edit", "Bash"]).flat(), + ]); + + const result = await isolated.server.callTool("activity_view", { limit: 50 }); + const parsed = parse(result); + const groups = (parsed["activityWindow"] as { + groups: { groupKind: string }[]; + }).groups; + expect(groups.map((group) => group.groupKind)).toEqual([ + "transition", + "stage", + "continuity", + "read", + ]); + expect(parsed["tripwire"]).toEqual(expect.arrayContaining([ + expect.objectContaining({ signal: "SESSION_LONG" }), + expect.objectContaining({ signal: "EDIT_BASH_LOOP" }), + expect.objectContaining({ signal: "RUNAWAY_TOOLS" }), + ])); + expect(Buffer.byteLength(textOf(result), "utf8")).toBeLessThan(2_048); + }); + + it("rejects unsupported diagnostic detail policies", async () => { + const repoDir = createLongPathRepo(); + const isolated = createIsolatedServer({ + projectRoot: repoDir, + graftDir: path.join(repoDir, ".graft"), + }); + cleanups.push(() => { + isolated.cleanup(); + }); + + await expect( + isolated.server.callTool("doctor", { detail: "everything" }), + ).rejects.toBeInstanceOf(ZodError); + await expect( + isolated.server.callTool("activity_view", { detail: "everything" }), + ).rejects.toBeInstanceOf(ZodError); + }); +}); diff --git a/test/unit/mcp/graft-edit.test.ts b/test/unit/mcp/graft-edit.test.ts index 2e627eef..b55976d5 100644 --- a/test/unit/mcp/graft-edit.test.ts +++ b/test/unit/mcp/graft-edit.test.ts @@ -8,6 +8,9 @@ import { MCP_OUTPUT_SCHEMAS } from "../../../src/contracts/output-schemas.js"; import type { ToolDefinition } from "../../../src/mcp/context.js"; import { createTestRepo } from "../../helpers/git.js"; import { createServerInRepo, parse } from "../../helpers/mcp.js"; +import { nodeFs } from "../../../src/adapters/node-fs.js"; +import { Metrics } from "../../../src/mcp/metrics.js"; +import { GovernorTracker } from "../../../src/session/tracker.js"; const cleanups: string[] = []; @@ -53,6 +56,7 @@ async function callGraftEdit( interface RuntimeEvent { readonly event: string; readonly traceId?: string; + readonly receiptId?: string; readonly tool?: string; readonly footprint?: { readonly paths?: readonly string[]; @@ -126,6 +130,40 @@ describe("mcp: graft_edit RED contract", () => { expect(readRepoFile(repoDir, "src/app.ts")).toBe("export const greeting = 'goodbye';\n"); }); + it("validates the edited response before committing file bytes", async () => { + const repoDir = createRepo({ + "src/app.ts": "export const greeting = 'hello';\n", + }); + const handler = graftEditDefinition().createHandler(); + const context = { + projectRoot: repoDir, + graftignorePatterns: [], + governor: new GovernorTracker(), + metrics: new Metrics(), + fs: nodeFs, + resolvePath(relativePath: string): string { + return path.join(repoDir, relativePath); + }, + recordFootprint(): void { + return undefined; + }, + validateResponse(): never { + throw new Error("injected output-contract failure"); + }, + respond(): never { + throw new Error("respond must not run after a failed preflight"); + }, + } as unknown as Parameters[1]; + + await expect(handler({ + path: "src/app.ts", + old_string: "hello", + new_string: "goodbye", + }, context)).rejects.toThrow("injected output-contract failure"); + + expect(readRepoFile(repoDir, "src/app.ts")).toBe("export const greeting = 'hello';\n"); + }); + it("refuses a missing old_string without changing the file", async () => { const repoDir = createRepo({ "src/app.ts": "export const greeting = 'hello';\n", @@ -288,12 +326,12 @@ describe("mcp: graft_edit RED contract", () => { old_string: "hello", new_string: "goodbye", }); - const receipt = result["_receipt"] as { traceId: string }; + const receipt = result["_receipt"] as { receiptId: string }; const events = readRuntimeLog(repoDir); const completed = events.find((event) => event.event === "tool_call_completed" && event.tool === "graft_edit" && - event.traceId === receipt.traceId + event.receiptId === receipt.receiptId ); expect(completed?.footprint?.paths).toEqual([path.join(repoDir, "src/app.ts")]); diff --git a/test/unit/mcp/layered-worldline.test.ts b/test/unit/mcp/layered-worldline.test.ts index 972207ad..651106c3 100644 --- a/test/unit/mcp/layered-worldline.test.ts +++ b/test/unit/mcp/layered-worldline.test.ts @@ -139,10 +139,10 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { git(tmpDir, "commit -m feature"); const server = createServerInRepo(tmpDir); - const first = parse(await server.callTool("doctor", {})); + const first = parse(await server.callTool("doctor", { detail: "full" })); git(tmpDir, `checkout -q ${baseBranch}`); - const second = parse(await server.callTool("doctor", {})); + const second = parse(await server.callTool("doctor", { detail: "full" })); expect(first["checkoutEpoch"]).toBeDefined(); expect(second["checkoutEpoch"]).toBeDefined(); @@ -153,9 +153,17 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { fromRef?: string; toRef?: string; }; + const semanticTransition = second["semanticTransition"] as { + kind?: string; + observationBasis?: string; + } | null; expect(transition.kind).toBe("checkout"); expect(transition.fromRef).toBe("feature"); expect(transition.toRef).toBe(baseBranch); + expect(semanticTransition).toEqual(expect.objectContaining({ + kind: "unknown", + observationBasis: "git_transition_evidence", + })); } finally { cleanupTestRepo(tmpDir); } @@ -196,7 +204,7 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { ); const server = createServerInRepo(tmpDir); - const result = parse(await server.callTool("doctor", {})); + const result = parse(await server.callTool("doctor", { detail: "full" })); const overlay = result["workspaceOverlay"] as { actorGuess?: string; confidence?: string; @@ -227,7 +235,7 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { ); const server = createServerInRepo(tmpDir); - const result = parse(await server.callTool("doctor", {})); + const result = parse(await server.callTool("doctor", { detail: "full" })); const overlay = result["workspaceOverlay"] as { totalPaths?: number; stagedPaths?: number; @@ -255,10 +263,10 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { git(tmpDir, "commit -m v2"); const server = createServerInRepo(tmpDir); - const before = parse(await server.callTool("doctor", {})); + const before = parse(await server.callTool("doctor", { detail: "full" })); git(tmpDir, `checkout -q ${c1}`); - const after = parse(await server.callTool("doctor", {})); + const after = parse(await server.callTool("doctor", { detail: "full" })); expect(before["checkoutEpoch"]).toBeDefined(); expect(after["checkoutEpoch"]).toBeDefined(); @@ -285,9 +293,10 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { git(tmpDir, "commit -m base"); const server = createServerInRepo(tmpDir); + await server.callTool("doctor", { detail: "full" }); git(tmpDir, "checkout -q -b feature/rebase-ui"); - const doctor = parse(await server.callTool("doctor", {})); + const doctor = parse(await server.callTool("doctor", { detail: "full" })); const transition = doctor["lastTransition"] as { kind?: string; toRef?: string | null } | undefined; expect(transition).toBeDefined(); @@ -317,12 +326,21 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { const c2 = git(tmpDir, "rev-parse HEAD"); const server = createServerInRepo(tmpDir); + await server.callTool("doctor", { detail: "full" }); git(tmpDir, "reset -q --hard HEAD~1"); - const doctor = parse(await server.callTool("doctor", {})); + const doctor = parse(await server.callTool("doctor", { detail: "full" })); const transition = doctor["lastTransition"] as { kind?: string } | undefined; + const semanticTransition = doctor["semanticTransition"] as { + kind?: string; + observationBasis?: string; + } | null; expect(transition).toBeDefined(); expect(transition?.kind).toBe("reset"); + expect(semanticTransition).toEqual(expect.objectContaining({ + kind: "unknown", + observationBasis: "git_transition_evidence", + })); const historical = parse(await server.callTool("code_show", { symbol: "keep", @@ -353,9 +371,10 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { git(tmpDir, "commit -m mainline"); const server = createServerInRepo(tmpDir); + await server.callTool("doctor", { detail: "full" }); git(tmpDir, "merge -q --no-ff feature -m merge-feature"); - const doctor = parse(await server.callTool("doctor", {})); + const doctor = parse(await server.callTool("doctor", { detail: "full" })); const transition = doctor["lastTransition"] as { kind?: string; fromRef?: string; @@ -365,6 +384,7 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { kind?: string; phase?: string | null; authority?: string; + observationBasis?: string; } | null; expect(transition).toBeDefined(); expect(transition?.kind).toBe("merge"); @@ -373,6 +393,7 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { expect(semanticTransition?.kind).toBe("merge_phase"); expect(semanticTransition?.phase).toBe("completed_or_cleared"); expect(semanticTransition?.authority).toBe("repo_snapshot"); + expect(semanticTransition?.observationBasis).toBe("git_transition_evidence"); } finally { cleanupTestRepo(tmpDir); } @@ -398,20 +419,23 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { git(tmpDir, "checkout -q feature"); const server = createServerInRepo(tmpDir); + await server.callTool("doctor", { detail: "full" }); git(tmpDir, `rebase -q ${baseBranch}`); - const doctor = parse(await server.callTool("doctor", {})); + const doctor = parse(await server.callTool("doctor", { detail: "full" })); const transition = doctor["lastTransition"] as { kind?: string } | undefined; const semanticTransition = doctor["semanticTransition"] as { kind?: string; phase?: string | null; authority?: string; + observationBasis?: string; } | null; expect(transition).toBeDefined(); expect(transition?.kind).toBe("rebase"); expect(semanticTransition?.kind).toBe("rebase_phase"); expect(semanticTransition?.phase).toBe("completed_or_cleared"); expect(semanticTransition?.authority).toBe("repo_snapshot"); + expect(semanticTransition?.observationBasis).toBe("git_transition_evidence"); const refView = parse(await server.callTool("graft_since", { base: baseBranch, @@ -439,11 +463,12 @@ describe("mcp: layered worldline model", { timeout: 15000 }, () => { git(tmpDir, "commit -m feature"); const server = createServerInRepo(tmpDir); + await server.callTool("doctor", { detail: "full" }); const epochs: unknown[] = []; for (const target of [baseBranch, "feature", baseBranch, "feature", baseBranch]) { git(tmpDir, `checkout -q ${target}`); - const doctor = parse(await server.callTool("doctor", {})); + const doctor = parse(await server.callTool("doctor", { detail: "full" })); epochs.push(doctor["checkoutEpoch"]); const transition = doctor["lastTransition"] as { kind?: string } | undefined; expect(transition).toBeDefined(); diff --git a/test/unit/mcp/per-call-workspace-route.test.ts b/test/unit/mcp/per-call-workspace-route.test.ts index b74a580a..997177a7 100644 --- a/test/unit/mcp/per-call-workspace-route.test.ts +++ b/test/unit/mcp/per-call-workspace-route.test.ts @@ -90,14 +90,18 @@ describe("mcp: per-call workspace route", () => { projection: string; path: string; content: string; + _receipt: { mode: string; cumulative: Record }; }>("safe_read", { cwd: repoDir, path: "app.ts", + receipt: "full", }); expect(routedRead.projection).toBe("content"); expect(routedRead.path).toBe(path.join(repoDir, "app.ts")); expect(routedRead.content).toBe("export const repo = 'routed';\n"); + expect(routedRead._receipt.mode).toBe("full"); + expect(routedRead._receipt.cumulative).toBeDefined(); const status = await session.callToolJson<{ bindState: string; @@ -169,12 +173,16 @@ describe("mcp: per-call workspace route", () => { activate: true, }); - const before = await session.callToolJson<{ totalMessages: number }>("doctor", {}); + const before = await session.callToolJson<{ totalMessages: number }>("doctor", { + detail: "full", + }); await session.callToolJson("safe_read", { cwd: repoA, path: "app.ts", }); - const after = await session.callToolJson<{ totalMessages: number }>("doctor", {}); + const after = await session.callToolJson<{ totalMessages: number }>("doctor", { + detail: "full", + }); expect(after.totalMessages).toBe(before.totalMessages + 1); }); diff --git a/test/unit/mcp/persisted-local-history.test.ts b/test/unit/mcp/persisted-local-history.test.ts index 975bd54a..f6fbcdd5 100644 --- a/test/unit/mcp/persisted-local-history.test.ts +++ b/test/unit/mcp/persisted-local-history.test.ts @@ -6,6 +6,7 @@ import { PersistedLocalHistoryStore, buildContinuityKey, } from "../../../src/mcp/persisted-local-history.js"; +import { createTransitionEvent } from "../../../src/mcp/persisted-local-history-policy.js"; import { createCausalContext } from "../../helpers/causal-context.js"; import { FakePersistedLocalHistoryWarp, @@ -670,6 +671,7 @@ describe("mcp: persisted local history", () => { const semanticTransition = { kind: "merge_phase" as const, authority: "repo_snapshot" as const, + observationBasis: "git_transition_evidence" as const, phase: "completed_or_cleared" as const, summary: "Merge transition completed or merge state has cleared.", evidence: { @@ -698,6 +700,75 @@ describe("mcp: persisted local history", () => { }, }; + const snapshotDeltaEvent = createTransitionEvent({ + current: { + ...current, + observedAt: "2026-04-10T01:10:01.000Z", + }, + semanticTransition: { + ...semanticTransition, + kind: "unknown", + observationBasis: "snapshot_delta", + phase: null, + summary: "Workspace contents changed between observations.", + evidence: { + ...semanticTransition.evidence, + lastTransitionKind: null, + reflogSubject: null, + }, + }, + // RepoObservation intentionally retains the last known Git transition + // for status output. That historical status must not be attached to a + // later snapshot-delta occurrence. + transition, + attribution, + }); + expect(snapshotDeltaEvent.payload).toEqual(expect.objectContaining({ + observationBasis: "snapshot_delta", + transitionKind: null, + fromRef: null, + toRef: null, + createdCheckoutEpochId: null, + })); + + expect(createTransitionEvent({ + current, + semanticTransition, + transition, + attribution, + }).eventId).not.toBe(createTransitionEvent({ + current, + semanticTransition: { + ...semanticTransition, + observationBasis: "snapshot_delta", + }, + transition, + attribution, + }).eventId); + + await store.noteSemanticTransitionObservation({ + current, + semanticTransition: { + ...semanticTransition, + observationBasis: "current_state", + }, + transition: null, + attribution, + graph: harness.graph, + }); + const baselineSummary = await store.summarize( + harness.status({ + repoId: current.repoId, + worktreeId: current.worktreeId, + }), + harness.causal(current), + harness.graph, + ); + expect(baselineSummary.availability).toBe("present"); + if (baselineSummary.availability === "present") { + expect(baselineSummary.latestTransitionEvent).toBeNull(); + } + await store.noteSemanticTransitionObservation({ current, semanticTransition, @@ -728,14 +799,143 @@ describe("mcp: persisted local history", () => { } expect(summary.latestTransitionEvent?.eventKind).toBe("transition"); expect(summary.latestTransitionEvent?.payload.semanticKind).toBe("merge_phase"); + expect(summary.latestTransitionEvent?.payload.observationBasis).toBe( + "git_transition_evidence", + ); expect(summary.latestTransitionEvent?.payload.transitionKind).toBe("merge"); expect(summary.latestTransitionEvent?.payload.phase).toBe("completed_or_cleared"); expect(summary.latestTransitionEvent?.attribution.actor.actorKind).toBe("git"); - expect(harness.warp.findNodes((props) => + const transitionNodes = harness.warp.findNodes((props) => props["entityKind"] === "local_history_event" && props["eventKind"] === "transition" - )).toHaveLength(1); + ); + expect(transitionNodes).toHaveLength(1); + + const legacyTransitionNode = transitionNodes[0]; + expect(legacyTransitionNode).toBeDefined(); + if (legacyTransitionNode === undefined) { + return; + } + delete legacyTransitionNode[1]["observationBasis"]; + const legacySummary = await store.summarize( + harness.status({ + repoId: current.repoId, + worktreeId: current.worktreeId, + }), + harness.causal(current), + harness.graph, + ); + expect(legacySummary.latestTransitionEvent?.payload.observationBasis).toBe( + "legacy_unclassified", + ); + }); + + it("records repeated equal semantic transitions as distinct ordered occurrences", async () => { + const graftTmp = createTestDir("graft-history-"); + cleanups.push(graftTmp); + const graftDir = graftTmp.path; + + const store = new PersistedLocalHistoryStore({ + fs: nodeFs, + codec: new CanonicalJsonCodec(), + graftDir, + }); + const harness = createGraphHarness(graftDir); + const first = context({ + workspaceOverlayId: "overlay:dirty-a", + observedAt: "2026-04-10T01:10:00.000Z", + }); + await store.noteBinding({ current: first, currentGraph: harness.graph }); + const baseline = await store.summarize( + harness.status(), + harness.causal(first), + harness.graph, + ); + const semanticTransition = { + kind: "unknown" as const, + authority: "repo_snapshot" as const, + observationBasis: "snapshot_delta" as const, + phase: null, + summary: "Workspace contents changed between observations.", + evidence: { + totalPaths: 1, + stagedPaths: 0, + changedPaths: 1, + untrackedPaths: 0, + unmergedPaths: 0, + mergeInProgress: false, + rebaseInProgress: false, + rebaseStep: null, + rebaseTotalSteps: null, + lastTransitionKind: null, + reflogSubject: null, + }, + }; + const middleTransition = { + ...semanticTransition, + kind: "index_update" as const, + summary: "The Git index changed between observations.", + }; + const middle = context({ + workspaceOverlayId: null, + observedAt: "2026-04-10T01:11:00.000Z", + }); + const repeated = context({ + workspaceOverlayId: "overlay:dirty-a", + observedAt: "2026-04-10T01:12:00.000Z", + }); + + await store.noteSemanticTransitionObservation({ + current: first, + semanticTransition, + transition: null, + attribution: baseline.attribution, + graph: harness.graph, + }); + await store.noteSemanticTransitionObservation({ + current: middle, + semanticTransition: middleTransition, + transition: null, + attribution: baseline.attribution, + graph: harness.graph, + }); + await store.noteSemanticTransitionObservation({ + current: repeated, + semanticTransition, + transition: null, + attribution: baseline.attribution, + graph: harness.graph, + }); + // Re-persisting the same observation remains idempotent. + await store.noteSemanticTransitionObservation({ + current: repeated, + semanticTransition, + transition: null, + attribution: baseline.attribution, + graph: harness.graph, + }); + + const transitionNodes = harness.warp.findNodes((props) => + props["entityKind"] === "local_history_event" && + props["eventKind"] === "transition" + ); + expect(transitionNodes).toHaveLength(3); + + const eventIdsByTime = new Map(transitionNodes.map(([nodeId, props]) => [ + props["occurredAt"], + nodeId, + ])); + const firstNode = eventIdsByTime.get(first.observedAt); + const middleNode = eventIdsByTime.get(middle.observedAt); + const repeatedNode = eventIdsByTime.get(repeated.observedAt); + expect(firstNode).toBeDefined(); + expect(middleNode).toBeDefined(); + expect(repeatedNode).toBeDefined(); + expect(new Set([firstNode, middleNode, repeatedNode])).toHaveLength(3); + expect(harness.warp.hasEdge(middleNode!, firstNode!, "follows")).toBe(true); + expect(harness.warp.hasEdge(repeatedNode!, middleNode!, "follows")).toBe(true); + expect(harness.warp.hasEdge(firstNode!, repeatedNode!, "follows")).toBe(false); }); it("summarizes shared repo posture when another worktree is active on the same checkout", async () => { diff --git a/test/unit/mcp/project-root-resolution.test.ts b/test/unit/mcp/project-root-resolution.test.ts index 7ee5419b..5acf0c8f 100644 --- a/test/unit/mcp/project-root-resolution.test.ts +++ b/test/unit/mcp/project-root-resolution.test.ts @@ -32,7 +32,7 @@ describe("project root resolution", () => { env: { GRAFT_PROJECT_ROOT: envRoot }, }); - const result = await server.callTool("doctor", {}); + const result = await server.callTool("doctor", { detail: "full" }); expect(parse(result)["projectRoot"]).toBe(explicit); }); @@ -44,7 +44,7 @@ describe("project root resolution", () => { env: { GRAFT_PROJECT_ROOT: envRoot }, }); - const result = await server.callTool("doctor", {}); + const result = await server.callTool("doctor", { detail: "full" }); expect(parse(result)["projectRoot"]).toBe(envRoot); }); diff --git a/test/unit/mcp/receipt-builder.test.ts b/test/unit/mcp/receipt-builder.test.ts index 724204be..70777e05 100644 --- a/test/unit/mcp/receipt-builder.test.ts +++ b/test/unit/mcp/receipt-builder.test.ts @@ -3,6 +3,9 @@ import { buildReceiptResult } from "../../../src/mcp/receipt.js"; import { MetricsSnapshot } from "../../../src/mcp/metrics.js"; import { CanonicalJsonCodec } from "../../../src/adapters/canonical-json.js"; import { emptyBurdenByKind } from "../../../src/mcp/burden.js"; +import { getMcpOutputSchema } from "../../../src/contracts/output-schemas.js"; +import { getMcpDiscoveryOutputSchema } from "../../../src/contracts/mcp-discovery-output-schemas.js"; +import { PrecisionSymbolMatch } from "../../../src/mcp/tools/precision.js"; const codec = new CanonicalJsonCodec(); @@ -18,9 +21,222 @@ function emptyMetrics(): MetricsSnapshot { }); } +function payloadOfText(result: ReturnType["result"]): string { + const content = result.content[0]; + if (content?.type !== "text") { + throw new Error("expected a text MCP response"); + } + return content.text; +} + +function payloadOf(result: ReturnType["result"]): Record { + return JSON.parse(payloadOfText(result)) as Record; +} + +function safeReadBody( + overrides: Record = {}, +): Record { + return { + path: "app.ts", + projection: "content", + reason: "CONTENT", + ...overrides, + }; +} + +function statsBody(): Record { + return { + totalReads: 0, + totalOutlines: 0, + totalRefusals: 0, + totalCacheHits: 0, + totalBytesReturned: 0, + totalBytesAvoidedByCache: 0, + totalNonReadBytesReturned: 0, + burdenByKind: emptyBurdenByKind(), + }; +} + +function runCaptureBody(): Record { + return { + output: "", + totalLines: 0, + tailedLines: 0, + truncated: false, + policyBoundary: { + kind: "shell_escape_hatch", + boundedReadContract: false, + policyEnforced: false, + }, + }; +} + describe("buildReceiptResult (unit)", () => { + it("accepts supported WARP identity ids in precision-search responses", () => { + const legacyWarpMatch = new PrecisionSymbolMatch({ + name: "handle", + kind: "function", + path: "src/handler.ts", + identityId: "sid:legacy-handler", + signature: "handle(): void", + exported: true, + startLine: 3, + endLine: 5, + }); + const receiptDeps = { + sessionId: "s1", + traceId: "t1", + seq: 1, + latencyMs: 1, + metrics: emptyMetrics(), + tripwires: [], + codec, + }; + + const find = buildReceiptResult("code_find", { + query: "handle", + kind: null, + matches: [legacyWarpMatch], + total: 1, + source: "warp", + layer: "ref_view", + }, receiptDeps); + const show = buildReceiptResult("code_show", { + symbol: "handle", + ambiguous: true, + matches: [legacyWarpMatch, new PrecisionSymbolMatch({ + name: "handle", + kind: "function", + path: "src/other-handler.ts", + identityId: "sid:legacy-other-handler", + exported: true, + startLine: 8, + endLine: 10, + })], + source: "warp", + layer: "ref_view", + }, receiptDeps); + + expect( + ((payloadOf(find.result)["matches"] as Record[])[0])?.["identityId"], + ).toBe("sid:legacy-handler"); + expect( + ((payloadOf(show.result)["matches"] as Record[])[1])?.["identityId"], + ).toBe("sid:legacy-other-handler"); + }); + + it("projects a compact wire receipt without weakening the internal audit receipt", () => { + const built = buildReceiptResult("safe_read", safeReadBody({ + actual: { bytes: 1234, lines: 50 }, + }), { + sessionId: "s1", + traceId: "t1", + seq: 1, + latencyMs: 10, + metrics: emptyMetrics(), + tripwires: [], + codec, + receiptMode: "compact", + }); + const publicReceipt = payloadOf(built.result)["_receipt"] as Record; + + expect(Object.keys(publicReceipt).sort()).toEqual([ + "latencyMs", + "mode", + "reason", + "receiptId", + "returnedBytes", + "seq", + ]); + expect(publicReceipt).toMatchObject({ + mode: "compact", + receiptId: "t1", + reason: "CONTENT", + returnedBytes: built.textBytes, + }); + expect(Buffer.byteLength(codec.encode(publicReceipt), "utf8")).toBeLessThanOrEqual(512); + expect(built.receipt.mode).toBe("compact"); + expect(built.receipt.traceId).toBe("t1"); + expect(built.receipt.fileBytes).toBe(1234); + expect(built.receipt.cumulative).toBeDefined(); + expect(built.result.structuredContent).toEqual(payloadOf(built.result)); + expect(() => getMcpOutputSchema("safe_read").parse( + built.result.structuredContent, + )).not.toThrow(); + expect(() => getMcpDiscoveryOutputSchema("safe_read").parse( + built.result.structuredContent, + )).not.toThrow(); + }); + + it("bounds long multibyte compact reasons while preserving full internal evidence", () => { + const reason = "agent-observed-" + "🧶".repeat(200); + const built = buildReceiptResult("safe_read", safeReadBody({ + reason, + }), { + sessionId: "s1", + traceId: "t1", + seq: 1, + latencyMs: 10, + metrics: emptyMetrics(), + tripwires: [], + codec, + receiptMode: "compact", + }); + const publicReceipt = payloadOf(built.result)["_receipt"] as { reason: string }; + + expect(Buffer.byteLength(codec.encode(publicReceipt), "utf8")).toBeLessThanOrEqual(512); + expect(Buffer.byteLength(publicReceipt.reason, "utf8")).toBeLessThanOrEqual(256); + expect(publicReceipt.reason).toMatch(/…$/u); + expect(built.receipt.reason).toBe(reason); + }); + + it("projects the legacy audit fields with a full v2 discriminator", () => { + const built = buildReceiptResult("stats", statsBody(), { + sessionId: "s1", + traceId: "t1", + seq: 1, + latencyMs: 1, + metrics: emptyMetrics(), + tripwires: [], + codec, + receiptMode: "full", + }); + const publicReceipt = payloadOf(built.result)["_receipt"] as Record; + + expect(publicReceipt["mode"]).toBe("full"); + expect(publicReceipt["traceId"]).toBe("t1"); + expect(publicReceipt["receiptId"]).toBeUndefined(); + expect(publicReceipt["cumulative"]).toBeDefined(); + expect(publicReceipt["returnedBytes"]).toBe(built.textBytes); + }); + + it("keeps exact full-response bytes when a rounded ratio has no fixed point", () => { + const built = buildReceiptResult("safe_read", safeReadBody({ + projection: "diff", + reason: "CHANGED_SINCE_LAST_READ", + actual: { bytes: 57, lines: 3 }, + content: "", + }), { + sessionId: "s1", + traceId: "t1", + seq: 1, + latencyMs: 175, + metrics: emptyMetrics(), + tripwires: [], + codec, + receiptMode: "full", + }); + const publicReceipt = payloadOf(built.result)["_receipt"] as Record; + + expect(publicReceipt["returnedBytes"]).toBe(built.textBytes); + expect(publicReceipt["compressionRatio"]).toBeUndefined(); + expect(built.receipt.compressionRatio).toBe( + Math.round((built.textBytes / 57) * 1000) / 1000, + ); + }); + it("produces a frozen receipt", () => { - const { receipt } = buildReceiptResult("safe_read", {}, { + const { receipt } = buildReceiptResult("safe_read", safeReadBody(), { sessionId: "s1", traceId: "t1", seq: 1, @@ -35,7 +251,10 @@ describe("buildReceiptResult (unit)", () => { }); it("extracts projection from data safely", () => { - const { receipt } = buildReceiptResult("graft_diff", { projection: "diff", reason: "FULL" }, { + const { receipt } = buildReceiptResult("safe_read", safeReadBody({ + projection: "diff", + reason: "FULL", + }), { sessionId: "s1", traceId: "t1", seq: 1, @@ -49,7 +268,7 @@ describe("buildReceiptResult (unit)", () => { }); it("defaults projection and reason when absent", () => { - const { receipt } = buildReceiptResult("stats", {}, { + const { receipt } = buildReceiptResult("stats", statsBody(), { sessionId: "s1", traceId: "t1", seq: 1, @@ -62,8 +281,8 @@ describe("buildReceiptResult (unit)", () => { expect(receipt.reason).toBe("none"); }); - it("handles non-string projection gracefully", () => { - const { receipt } = buildReceiptResult("stats", { projection: 42 }, { + it("rejects a strict output-contract violation before returning success", () => { + expect(() => buildReceiptResult("safe_read", safeReadBody({ path: 42 }), { sessionId: "s1", traceId: "t1", seq: 1, @@ -71,12 +290,13 @@ describe("buildReceiptResult (unit)", () => { metrics: emptyMetrics(), tripwires: [], codec, - }); - expect(receipt.projection).toBe("none"); + })).toThrow(); }); it("extracts fileBytes from data.actual.bytes", () => { - const { receipt } = buildReceiptResult("safe_read", { actual: { bytes: 1234, lines: 50 } }, { + const { receipt } = buildReceiptResult("safe_read", safeReadBody({ + actual: { bytes: 1234, lines: 50 }, + }), { sessionId: "s1", traceId: "t1", seq: 1, @@ -89,7 +309,7 @@ describe("buildReceiptResult (unit)", () => { }); it("sets fileBytes to null when actual is absent", () => { - const { receipt } = buildReceiptResult("stats", {}, { + const { receipt } = buildReceiptResult("stats", statsBody(), { sessionId: "s1", traceId: "t1", seq: 1, @@ -103,7 +323,7 @@ describe("buildReceiptResult (unit)", () => { it("attaches budget when provided", () => { const budget = { total: 100000, consumed: 5000, remaining: 95000, fraction: 0.05 }; - const { receipt } = buildReceiptResult("safe_read", {}, { + const { receipt } = buildReceiptResult("safe_read", safeReadBody(), { sessionId: "s1", traceId: "t1", seq: 1, @@ -117,7 +337,7 @@ describe("buildReceiptResult (unit)", () => { }); it("stabilizes returnedBytes to match textBytes", () => { - const { receipt, textBytes } = buildReceiptResult("safe_read", {}, { + const { receipt, textBytes, result } = buildReceiptResult("safe_read", safeReadBody(), { sessionId: "s1", traceId: "t1", seq: 1, @@ -127,17 +347,18 @@ describe("buildReceiptResult (unit)", () => { codec, }); expect(receipt.returnedBytes).toBe(textBytes); + expect(textBytes).toBe(Buffer.byteLength(payloadOfText(result), "utf8")); }); it("classifies burden kind correctly", () => { - const { receipt: readReceipt } = buildReceiptResult("safe_read", {}, { + const { receipt: readReceipt } = buildReceiptResult("safe_read", safeReadBody(), { sessionId: "s1", traceId: "t1", seq: 1, latencyMs: 1, metrics: emptyMetrics(), tripwires: [], codec, }); expect(readReceipt.burden.kind).toBe("read"); expect(readReceipt.burden.nonRead).toBe(false); - const { receipt: shellReceipt } = buildReceiptResult("run_capture", {}, { + const { receipt: shellReceipt } = buildReceiptResult("run_capture", runCaptureBody(), { sessionId: "s1", traceId: "t1", seq: 1, latencyMs: 1, metrics: emptyMetrics(), tripwires: [], codec, }); diff --git a/test/unit/mcp/receipt.test.ts b/test/unit/mcp/receipt.test.ts index 39dbe962..3cd39240 100644 --- a/test/unit/mcp/receipt.test.ts +++ b/test/unit/mcp/receipt.test.ts @@ -3,6 +3,7 @@ import type { GraftServer } from "../../../src/mcp/server.js"; import { createFixtureWorkspace, createIsolatedServer, extractText, parse } from "../../helpers/mcp.js"; interface Receipt { + mode: "full"; sessionId: string; traceId: string; seq: number; @@ -32,6 +33,15 @@ interface Receipt { }; } +interface CompactReceipt { + mode: "compact"; + receiptId: string; + seq: number; + reason: string; + latencyMs: number; + returnedBytes: number; +} + const SMALL_TS = "fixtures/small.ts"; const BANNED_IMAGE = "fixtures/ban-targets/image.png"; @@ -92,10 +102,74 @@ describe("mcp: receipt mode", () => { expect(result["_receipt"]).toBeDefined(); }); + it("defaults ordinary MCP calls to the bounded compact receipt", async () => { + const server = createServer(); + const raw = await server.callTool("safe_read", { path: SMALL_TS }); + const result = parse(raw); + const receipt = result["_receipt"] as CompactReceipt; + + expect(Object.keys(receipt).sort()).toEqual([ + "latencyMs", + "mode", + "reason", + "receiptId", + "returnedBytes", + "seq", + ]); + expect(receipt.mode).toBe("compact"); + expect(receipt.receiptId.length).toBeGreaterThan(0); + expect(receipt.reason).toBe("CONTENT"); + expect(receipt.returnedBytes).toBe(Buffer.byteLength(extractText(raw), "utf8")); + expect(Buffer.byteLength(JSON.stringify(receipt), "utf8")).toBeLessThanOrEqual(512); + }); + + it("returns the cumulative audit receipt only when full mode is explicit", async () => { + const server = createServer(); + const result = parse(await server.callTool("safe_read", { + path: SMALL_TS, + receipt: "full", + })); + const receipt = result["_receipt"] as Receipt; + + expect(receipt.mode).toBe("full"); + expect(receipt.traceId.length).toBeGreaterThan(0); + expect(receipt.tool).toBe("safe_read"); + expect(receipt.cumulative).toBeDefined(); + expect(receipt.burden).toEqual({ kind: "read", nonRead: false }); + }); + + it("accepts full receipt control on tools with no domain arguments", async () => { + const server = createServer(); + const result = parse(await server.callTool("stats", { receipt: "full" })); + expect((result["_receipt"] as Receipt).mode).toBe("full"); + }); + + it("rejects unknown receipt policies as input validation failures", async () => { + const server = createServer(); + await expect(server.callTool("stats", { receipt: "everything" })).rejects.toThrow(); + }); + + it("keeps cumulative stats authoritative after compact calls", async () => { + const server = createServer(); + const first = parse(await server.callTool("safe_read", { path: SMALL_TS })); + const second = parse(await server.callTool("safe_read", { path: SMALL_TS })); + expect((first["_receipt"] as CompactReceipt).mode).toBe("compact"); + expect((second["_receipt"] as CompactReceipt).mode).toBe("compact"); + + const stats = parse(await server.callTool("stats", {})); + expect(stats["totalReads"]).toBe(1); + expect(stats["totalCacheHits"]).toBe(1); + expect(stats["totalBytesReturned"]).toBeGreaterThan(0); + expect(stats["burdenByKind"]).toMatchObject({ + read: { calls: 2 }, + }); + }); + it("receipt has correct shape", async () => { const server = createServer(); const result = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const receipt = result["_receipt"] as Receipt; expect(typeof receipt.sessionId).toBe("string"); @@ -123,9 +197,11 @@ describe("mcp: receipt mode", () => { const server = createServer(); const r1 = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const r2 = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const receipt1 = r1["_receipt"] as Receipt; const receipt2 = r2["_receipt"] as Receipt; @@ -136,9 +212,11 @@ describe("mcp: receipt mode", () => { const server = createServer(); const r1 = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const r2 = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const receipt1 = r1["_receipt"] as Receipt; const receipt2 = r2["_receipt"] as Receipt; @@ -162,15 +240,16 @@ describe("mcp: receipt mode", () => { path: SMALL_TS, })); const r3 = parse(await server.callTool("doctor", {})); - expect((r1["_receipt"] as Receipt).seq).toBe(1); - expect((r2["_receipt"] as Receipt).seq).toBe(2); - expect((r3["_receipt"] as Receipt).seq).toBe(3); + expect((r1["_receipt"] as CompactReceipt).seq).toBe(1); + expect((r2["_receipt"] as CompactReceipt).seq).toBe(2); + expect((r3["_receipt"] as CompactReceipt).seq).toBe(3); }); it("receipt includes fileBytes for file operations", async () => { const server = createServer(); const result = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const receipt = result["_receipt"] as Receipt; expect(receipt.fileBytes).toBeGreaterThan(0); @@ -178,7 +257,7 @@ describe("mcp: receipt mode", () => { it("receipt has null fileBytes for non-file operations", async () => { const server = createServer(); - const result = parse(await server.callTool("doctor", {})); + const result = parse(await server.callTool("doctor", { receipt: "full" })); const receipt = result["_receipt"] as Receipt; expect(receipt.fileBytes).toBeNull(); expect(receipt.burden.kind).toBe("diagnostic"); @@ -189,11 +268,12 @@ describe("mcp: receipt mode", () => { const server = createServer(); // First read — content - await server.callTool("safe_read", { path: SMALL_TS }); + await server.callTool("safe_read", { path: SMALL_TS, receipt: "full" }); // Second read — cache hit const r2 = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const receipt = r2["_receipt"] as Receipt; expect(receipt.cumulative.reads).toBe(1); @@ -207,6 +287,7 @@ describe("mcp: receipt mode", () => { const server = createServer(); const result = parse(await server.callTool("safe_read", { path: BANNED_IMAGE, + receipt: "full", })); const receipt = result["_receipt"] as Receipt; expect(receipt.projection).toBe("refused"); @@ -215,9 +296,10 @@ describe("mcp: receipt mode", () => { it("receipt on cache hit shows cache_hit projection", async () => { const server = createServer(); - await server.callTool("safe_read", { path: SMALL_TS }); + await server.callTool("safe_read", { path: SMALL_TS, receipt: "full" }); const r2 = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const receipt = r2["_receipt"] as Receipt; expect(receipt.projection).toBe("cache_hit"); @@ -228,6 +310,7 @@ describe("mcp: receipt mode", () => { const server = createServer(); const result = parse(await server.callTool("safe_read", { path: SMALL_TS, + receipt: "full", })); const receipt = result["_receipt"] as Receipt & { compressionRatio: number | null }; expect(receipt.compressionRatio).not.toBeNull(); @@ -240,7 +323,7 @@ describe("mcp: receipt mode", () => { it("compressionRatio is null for non-file operations", async () => { const server = createServer(); - const result = parse(await server.callTool("doctor", {})); + const result = parse(await server.callTool("doctor", { receipt: "full" })); const receipt = result["_receipt"] as Receipt & { compressionRatio: number | null }; expect(receipt.compressionRatio).toBeNull(); }); @@ -251,9 +334,8 @@ describe("mcp: receipt mode", () => { path: SMALL_TS, }); const text = extractText(raw); - const receipt = (parse(raw))["_receipt"] as Receipt; - // returnedBytes should be close to the text length - expect(receipt.returnedBytes).toBe(text.length); + const receipt = (parse(raw))["_receipt"] as CompactReceipt; + expect(receipt.returnedBytes).toBe(Buffer.byteLength(text, "utf8")); }); it("tracks non-read burden by tool kind in receipts", async () => { @@ -262,6 +344,7 @@ describe("mcp: receipt mode", () => { const result = parse(await server.callTool("run_capture", { command: "printf 'alpha'", tail: 1, + receipt: "full", })); const receipt = result["_receipt"] as Receipt; diff --git a/test/unit/mcp/repo-state-observation.test.ts b/test/unit/mcp/repo-state-observation.test.ts new file mode 100644 index 00000000..2d86bdb0 --- /dev/null +++ b/test/unit/mcp/repo-state-observation.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { buildSemanticTransition } from "../../../src/mcp/repo-state-observation.js"; +import type { RepoSnapshot, RepoTransition } from "../../../src/mcp/repo-state-types.js"; + +function snapshot(overrides: Partial = {}): RepoSnapshot { + return { + headRef: "main", + headSha: "a".repeat(40), + parentShas: ["b".repeat(40)], + observedAt: "2026-07-15T00:00:00.000Z", + statusLines: [], + dirty: false, + stagedPaths: 0, + changedPaths: 0, + untrackedPaths: 0, + unmergedPaths: 0, + mergeInProgress: false, + rebase: { + inProgress: false, + step: null, + total: null, + }, + headReflog: null, + ...overrides, + }; +} + +function transition(kind: RepoTransition["kind"]): RepoTransition { + return { + kind, + fromRef: "feature", + toRef: "main", + fromCommit: "b".repeat(40), + toCommit: "a".repeat(40), + evidence: { + reflogSubject: `${kind}: observed by git`, + }, + }; +} + +describe("repo semantic transition observation", () => { + it("keeps an ordinary dirty first snapshot as baseline state", () => { + const current = snapshot({ + statusLines: Array.from({ length: 253 }, (_, index) => `?? file-${String(index)}.ts`), + dirty: true, + untrackedPaths: 253, + }); + + expect(buildSemanticTransition(null, current, null)).toBeNull(); + }); + + it("does not repeat an unchanged dirty snapshot as movement", () => { + const previous = snapshot({ + statusLines: [" M app.ts"], + dirty: true, + changedPaths: 1, + }); + const current = snapshot({ + ...previous, + observedAt: "2026-07-15T00:00:01.000Z", + }); + + expect(buildSemanticTransition(previous, current, null)).toBeNull(); + }); + + it("classifies a changed dirty snapshot as snapshot-delta movement", () => { + const previous = snapshot({ + statusLines: Array.from({ length: 7 }, (_, index) => ` M file-${String(index)}.ts`), + dirty: true, + changedPaths: 7, + }); + const current = snapshot({ + statusLines: Array.from({ length: 8 }, (_, index) => ` M file-${String(index)}.ts`), + dirty: true, + changedPaths: 8, + }); + + expect(buildSemanticTransition(previous, current, null)).toEqual(expect.objectContaining({ + kind: "bulk_transition", + authority: "repo_snapshot", + observationBasis: "snapshot_delta", + })); + }); + + it("classifies clearing the final dirty path as an observed snapshot delta", () => { + const previous = snapshot({ + statusLines: [" M app.ts"], + dirty: true, + changedPaths: 1, + }); + + expect(buildSemanticTransition(previous, snapshot(), null)).toEqual( + expect.objectContaining({ + kind: "unknown", + authority: "repo_snapshot", + observationBasis: "snapshot_delta", + }), + ); + }); + + it("reports authoritative first and unchanged active phases as current state", () => { + const activeMerge = snapshot({ + statusLines: ["UU app.ts"], + dirty: true, + unmergedPaths: 1, + mergeInProgress: true, + }); + const activeRebase = snapshot({ + statusLines: [" M app.ts"], + dirty: true, + changedPaths: 1, + rebase: { + inProgress: true, + step: 2, + total: 4, + }, + }); + + expect(buildSemanticTransition(null, activeMerge, null)).toEqual(expect.objectContaining({ + kind: "merge_phase", + authority: "authoritative_git_state", + observationBasis: "current_state", + phase: "conflicted", + })); + expect(buildSemanticTransition(activeRebase, { + ...activeRebase, + observedAt: "2026-07-15T00:00:01.000Z", + }, null)).toEqual(expect.objectContaining({ + kind: "rebase_phase", + authority: "authoritative_git_state", + observationBasis: "current_state", + phase: null, + })); + }); + + it("distinguishes Git transition evidence from snapshot deltas", () => { + const previous = snapshot({ mergeInProgress: true }); + const current = snapshot(); + + expect(buildSemanticTransition(previous, current, transition("merge"))).toEqual( + expect.objectContaining({ + kind: "merge_phase", + observationBasis: "git_transition_evidence", + phase: "completed_or_cleared", + }), + ); + }); + + it.each(["checkout", "reset"] as const)( + "retains direct %s evidence when the workspace projection is unchanged", + (kind) => { + const previous = snapshot(); + + expect(buildSemanticTransition(previous, { + ...previous, + observedAt: "2026-07-15T00:00:01.000Z", + }, transition(kind))).toEqual(expect.objectContaining({ + kind: "unknown", + authority: "repo_snapshot", + observationBasis: "git_transition_evidence", + })); + }, + ); + + it("treats a changed conflict path set as a snapshot delta even when the count is stable", () => { + const previous = snapshot({ + statusLines: ["UM first.ts"], + dirty: true, + unmergedPaths: 1, + }); + const current = snapshot({ + statusLines: ["UM second.ts"], + dirty: true, + unmergedPaths: 1, + }); + + expect(buildSemanticTransition(previous, current, null)).toEqual( + expect.objectContaining({ + kind: "conflict_resolution", + observationBasis: "snapshot_delta", + }), + ); + }); + + it("does not treat unrelated workspace churn as conflict movement", () => { + const previous = snapshot({ + statusLines: ["UM conflict.ts"], + dirty: true, + unmergedPaths: 1, + }); + const current = snapshot({ + statusLines: ["UM conflict.ts", "?? notes.txt"], + dirty: true, + untrackedPaths: 1, + unmergedPaths: 1, + }); + + expect(buildSemanticTransition(previous, current, null)).toEqual( + expect.objectContaining({ + kind: "conflict_resolution", + observationBasis: "current_state", + }), + ); + }); + + it.each(["merge", "rebase"] as const)( + "does not treat unrelated workspace churn as active %s movement", + (kind) => { + const active = kind === "merge" + ? { mergeInProgress: true } + : { + rebase: { + inProgress: true, + step: 2, + total: 4, + }, + }; + const previous = snapshot({ + ...active, + statusLines: ["UM conflict.ts"], + dirty: true, + unmergedPaths: 1, + }); + const current = snapshot({ + ...active, + statusLines: ["UM conflict.ts", "?? notes.txt"], + dirty: true, + untrackedPaths: 1, + unmergedPaths: 1, + }); + + expect(buildSemanticTransition(previous, current, null)).toEqual( + expect.objectContaining({ + kind: kind === "merge" ? "merge_phase" : "rebase_phase", + observationBasis: "current_state", + }), + ); + }, + ); +}); diff --git a/test/unit/mcp/repo-state-transition.test.ts b/test/unit/mcp/repo-state-transition.test.ts new file mode 100644 index 00000000..b4c9db51 --- /dev/null +++ b/test/unit/mcp/repo-state-transition.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { GitClient } from "../../../src/ports/git.js"; +import { detectTransition } from "../../../src/mcp/repo-state-transition.js"; +import type { HeadReflogEntry, RepoSnapshot } from "../../../src/mcp/repo-state-types.js"; + +const unusedGitClient: GitClient = { + run() { + throw new Error("same-position reflog tests must not query ancestry"); + }, +}; + +function reflog(overrides: Partial = {}): HeadReflogEntry { + return { + raw: `${"a".repeat(40)} ${"a".repeat(40)} Test 1784340000 -0700\tcheckout: moving from feature to main`, + previousSha: "a".repeat(40), + nextSha: "a".repeat(40), + timestampSec: 1_784_340_000, + subject: "checkout: moving from feature to main", + ...overrides, + }; +} + +function snapshot(headReflog: HeadReflogEntry | null): RepoSnapshot { + return { + headRef: "main", + headSha: "a".repeat(40), + parentShas: ["b".repeat(40)], + observedAt: "2026-07-17T20:00:00.000Z", + statusLines: [], + dirty: false, + stagedPaths: 0, + changedPaths: 0, + untrackedPaths: 0, + unmergedPaths: 0, + mergeInProgress: false, + rebase: { + inProgress: false, + step: null, + total: null, + }, + headReflog, + }; +} + +describe("repo transition evidence freshness", () => { + it("does not replay an unchanged same-second reflog entry from the baseline", async () => { + const baseline = snapshot(reflog()); + const repeated = { + ...baseline, + observedAt: "2026-07-17T20:00:00.500Z", + }; + + await expect(detectTransition( + unusedGitClient, + "/repo", + baseline, + repeated, + )).resolves.toBeNull(); + }); + + it("accepts a changed reflog entry as fresh direct Git evidence", async () => { + const baseline = snapshot(reflog()); + const changed = snapshot(reflog({ + raw: `${"a".repeat(40)} ${"a".repeat(40)} Test 1784340000 -0700\tcheckout: moving from main to feature`, + subject: "checkout: moving from main to feature", + })); + + await expect(detectTransition( + unusedGitClient, + "/repo", + baseline, + changed, + )).resolves.toEqual(expect.objectContaining({ + kind: "checkout", + fromRef: "main", + toRef: "feature", + })); + }); +}); diff --git a/test/unit/mcp/runtime-observability.test.ts b/test/unit/mcp/runtime-observability.test.ts index af5242f9..bb649663 100644 --- a/test/unit/mcp/runtime-observability.test.ts +++ b/test/unit/mcp/runtime-observability.test.ts @@ -10,6 +10,7 @@ interface RuntimeEvent { readonly event: string; readonly sessionId: string; readonly traceId?: string; + readonly receiptId?: string; readonly seq?: number; readonly tool?: string; readonly projection?: string; @@ -61,13 +62,12 @@ describe("mcp: runtime observability", () => { path: testFile, })); const receipt = result["_receipt"] as { - sessionId: string; - traceId: string; + mode: "compact"; + receiptId: string; seq: number; - projection: string; reason: string; }; - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); const runtime = doctor["runtimeObservability"] as { logPath: string }; const latestReadEvent = doctor["latestReadEvent"] as { eventKind: string; @@ -78,18 +78,21 @@ describe("mcp: runtime observability", () => { const events = readRuntimeLog(runtime.logPath); expect(events[0]?.event).toBe("session_started"); - expect(events[0]?.sessionId).toBe(receipt.sessionId); + expect(receipt.mode).toBe("compact"); - const started = events.find((event) => event.event === "tool_call_started" && event.traceId === receipt.traceId); + const started = events.find((event) => event.event === "tool_call_started" && event.traceId === receipt.receiptId); expect(started).toBeDefined(); expect(started?.tool).toBe("safe_read"); expect(started?.argKeys).toEqual(["path"]); - const completed = events.find((event) => event.event === "tool_call_completed" && event.traceId === receipt.traceId); + const completed = events.find((event) => + event.event === "tool_call_completed" && event.receiptId === receipt.receiptId + ); expect(completed).toBeDefined(); - expect(completed?.sessionId).toBe(receipt.sessionId); + expect(completed?.traceId).toBe(receipt.receiptId); + expect(completed?.sessionId).toBe(events[0]?.sessionId); expect(completed?.seq).toBe(receipt.seq); - expect(completed?.projection).toBe(receipt.projection); + expect(completed?.projection).toBe("content"); expect(completed?.reason).toBe(receipt.reason); expect(completed?.burdenKind).toBe("read"); expect(completed?.nonReadBurden).toBe(false); @@ -133,7 +136,7 @@ describe("mcp: runtime observability", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "graft-rt-obs-doctor-")); const isolated = createIsolatedServer({ projectRoot: tmpDir }); try { - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); const runtime = doctor["runtimeObservability"] as { enabled: boolean; logPath: string; @@ -291,7 +294,7 @@ describe("mcp: runtime observability", () => { } }); - it("surfaces a full-file runtime staged target for staged rename selections", async () => { + it("surfaces a staged rename baseline without inventing movement", async () => { const repoDir = createTestRepo("graft-runtime-staged-target-rename-"); try { fs.writeFileSync(path.join(repoDir, "app.ts"), "export const ready = true;\n"); @@ -305,7 +308,7 @@ describe("mcp: runtime observability", () => { graftDir: path.join(repoDir, ".graft"), }); try { - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); const stagedTarget = doctor["stagedTarget"] as { availability: string; reason?: string; @@ -371,16 +374,9 @@ describe("mcp: runtime observability", () => { ).toBeGreaterThan(0); expect(workspaceOverlayFooting.hookBootstrap.posture).toBe("absent"); expect(workspaceOverlayFooting.hookBootstrap.supportsCheckoutBoundaries).toBe(false); - expect(semanticTransition?.kind).toBe("index_update"); - expect(semanticTransition?.authority).toBe("repo_snapshot"); - expect(semanticTransition?.phase ?? null).toBeNull(); - expect(semanticTransition?.evidence.stagedPaths).toBeGreaterThan(0); - expect(semanticTransition?.evidence.totalPaths).toBeGreaterThan(0); + expect(semanticTransition).toBeNull(); expect(recommendedNextAction).toBe("continue_active_causal_workspace"); - expect(latestTransitionEvent?.eventKind).toBe("transition"); - expect(latestTransitionEvent?.payload.semanticKind).toBe("index_update"); - expect(latestTransitionEvent?.payload.transitionKind).toBeNull(); - expect(latestTransitionEvent?.attribution.actor.actorKind).toBe("unknown"); + expect(latestTransitionEvent).toBeNull(); expect(["full_file", "ambiguous"]).toContain(stagedTarget.availability); expect(stagedTarget.attribution?.actor.actorKind).toBe("unknown"); expect(stagedTarget.attribution?.confidence).toBe("unknown"); @@ -402,10 +398,10 @@ describe("mcp: runtime observability", () => { expect(persistedLocalHistory.latestStageEvent?.payload.targetId).toBe( stagedTarget.target?.targetId, ); - expect(persistedLocalHistory.latestTransitionEvent?.payload.semanticKind).toBe("index_update"); + expect(persistedLocalHistory.latestTransitionEvent).toBeNull(); } else { expect(persistedLocalHistory.latestStageEvent).toBeNull(); - expect(persistedLocalHistory.latestTransitionEvent?.payload.semanticKind).toBe("index_update"); + expect(persistedLocalHistory.latestTransitionEvent).toBeNull(); } } finally { isolated.cleanup(); @@ -415,38 +411,169 @@ describe("mcp: runtime observability", () => { } }); - it("surfaces bulk-transition guidance when many paths move together", async () => { - const repoDir = createTestRepo("graft-runtime-bulk-transition-"); + it("reports a hundreds-path dirty baseline and unchanged repeat before a real snapshot delta", async () => { + const repoDir = createTestRepo("graft-runtime-baseline-delta-"); try { - for (let index = 0; index < 8; index += 1) { + fs.writeFileSync(path.join(repoDir, "app.ts"), "export const value = 1;\n"); + git(repoDir, "add -A"); + git(repoDir, "commit -m init"); + fs.writeFileSync(path.join(repoDir, "app.ts"), "export const value = 2;\n"); + fs.writeFileSync(path.join(repoDir, "staged.ts"), "export const staged = true;\n"); + git(repoDir, "add staged.ts"); + for (let index = 0; index < 253; index += 1) { fs.writeFileSync( - path.join(repoDir, `file-${String(index)}.ts`), - `export const value${String(index)} = ${String(index)};\n`, + path.join(repoDir, `scratch-${String(index)}.ts`), + `export const scratch${String(index)} = true;\n`, ); } + + const isolated = createIsolatedServer({ + projectRoot: repoDir, + graftDir: path.join(repoDir, ".graft"), + }); + try { + const first = parse(await isolated.server.callTool("causal_status", {})); + const repeated = parse(await isolated.server.callTool("causal_status", {})); + const firstWorkspace = first["activeCausalWorkspace"] as { + workspaceOverlay: { + dirty: boolean; + stagedPaths: number; + changedPaths: number; + untrackedPaths: number; + } | null; + semanticTransition: unknown; + } | null; + const repeatedWorkspace = repeated["activeCausalWorkspace"] as { + workspaceOverlay: { dirty: boolean } | null; + semanticTransition: unknown; + } | null; + + expect(firstWorkspace?.workspaceOverlay).toEqual(expect.objectContaining({ + dirty: true, + stagedPaths: 1, + changedPaths: 1, + untrackedPaths: 253, + })); + expect(firstWorkspace?.semanticTransition).toBeNull(); + expect(repeatedWorkspace?.workspaceOverlay?.dirty).toBe(true); + expect(repeatedWorkspace?.semanticTransition).toBeNull(); + + fs.writeFileSync(path.join(repoDir, "scratch-253.ts"), "export const next = true;\n"); + const changed = parse(await isolated.server.callTool("causal_status", {})); + const changedWorkspace = changed["activeCausalWorkspace"] as { + semanticTransition: { + kind: string; + observationBasis: string; + } | null; + } | null; + + expect(changedWorkspace?.semanticTransition).toEqual(expect.objectContaining({ + kind: "bulk_transition", + observationBasis: "snapshot_delta", + })); + } finally { + isolated.cleanup(); + } + } finally { + cleanupTestRepo(repoDir); + } + }); + + it("reports and persists the observed delta when the workspace becomes clean", async () => { + const repoDir = createTestRepo("graft-runtime-clean-delta-"); + try { + const original = "export const value = 1;\n"; + fs.writeFileSync(path.join(repoDir, "app.ts"), original); git(repoDir, "add -A"); git(repoDir, "commit -m init"); + const isolated = createIsolatedServer({ + projectRoot: repoDir, + graftDir: path.join(repoDir, ".graft"), + }); + try { + await isolated.server.callTool("causal_status", {}); + fs.writeFileSync( + path.join(repoDir, "app.ts"), + "export const value = 2;\nexport const changed = true;\n", + ); + await isolated.server.callTool("causal_status", {}); + fs.writeFileSync(path.join(repoDir, "app.ts"), original); + + const cleaned = parse(await isolated.server.callTool("causal_status", {})); + const activeCausalWorkspace = cleaned["activeCausalWorkspace"] as { + workspaceOverlay: unknown; + semanticTransition: { + kind: string; + observationBasis: string; + evidence: { totalPaths: number }; + } | null; + } | null; + const persistedLocalHistory = cleaned["persistedLocalHistory"] as { + latestTransitionEvent: { + payload: { observationBasis: string }; + } | null; + }; + + expect(activeCausalWorkspace?.workspaceOverlay).toBeNull(); + expect(activeCausalWorkspace?.semanticTransition).toEqual(expect.objectContaining({ + kind: "unknown", + observationBasis: "snapshot_delta", + evidence: expect.objectContaining({ totalPaths: 0 }), + })); + expect(persistedLocalHistory.latestTransitionEvent?.payload.observationBasis).toBe( + "snapshot_delta", + ); + } finally { + isolated.cleanup(); + } + } finally { + cleanupTestRepo(repoDir); + } + }); + + it("surfaces bulk-transition guidance when many paths move together", async () => { + const repoDir = createTestRepo("graft-runtime-bulk-transition-"); + try { for (let index = 0; index < 8; index += 1) { fs.writeFileSync( path.join(repoDir, `file-${String(index)}.ts`), - `export const value${String(index)} = ${String(index + 1)};\n`, + `export const value${String(index)} = ${String(index)};\n`, ); } + git(repoDir, "add -A"); + git(repoDir, "commit -m init"); const isolated = createIsolatedServer({ projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), }); try { + await isolated.server.callTool("causal_status", {}); + for (let index = 0; index < 8; index += 1) { + fs.writeFileSync( + path.join(repoDir, `file-${String(index)}.ts`), + `export const value${String(index)} = ${String(index + 1)};\n`, + ); + } + const status = parse(await isolated.server.callTool("causal_status", {})); const activeCausalWorkspace = status["activeCausalWorkspace"] as { - semanticTransition: { kind: string; authority: string; phase: string | null; summary: string } | null; + semanticTransition: { + kind: string; + authority: string; + observationBasis: string; + phase: string | null; + evidence: { totalPaths: number; changedPaths: number }; + } | null; } | null; expect(activeCausalWorkspace?.semanticTransition?.kind).toBe("bulk_transition"); expect(activeCausalWorkspace?.semanticTransition?.authority).toBe("repo_snapshot"); - expect(activeCausalWorkspace?.semanticTransition?.summary).toContain("Bulk edit sweep spans"); + expect(activeCausalWorkspace?.semanticTransition?.observationBasis).toBe("snapshot_delta"); + expect(activeCausalWorkspace?.semanticTransition?.evidence).toEqual( + expect.objectContaining({ totalPaths: 8, changedPaths: 8 }), + ); expect(status["nextAction"]).toBe("inspect_bulk_transition_scope_before_continuing"); } finally { isolated.cleanup(); @@ -469,7 +596,10 @@ describe("mcp: runtime observability", () => { }); try { await isolated.server.callTool("safe_read", { path: "app.ts" }); - const activityView = parse(await isolated.server.callTool("activity_view", { limit: 5 })); + const activityView = parse(await isolated.server.callTool("activity_view", { + detail: "full", + limit: 5, + })); const anchor = activityView["anchor"] as { posture: string; @@ -533,27 +663,37 @@ describe("mcp: runtime observability", () => { git(repoDir, "add -A"); git(repoDir, "commit -m init"); - for (let index = 0; index < 8; index += 1) { - fs.writeFileSync( - path.join(repoDir, `file-${String(index)}.ts`), - `export const value${String(index)} = ${String(index + 10)};\n`, - ); - } - git(repoDir, "add -A"); - const isolated = createIsolatedServer({ projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), }); try { + await isolated.server.callTool("causal_status", {}); + for (let index = 0; index < 8; index += 1) { + fs.writeFileSync( + path.join(repoDir, `file-${String(index)}.ts`), + `export const value${String(index)} = ${String(index + 10)};\n`, + ); + } + git(repoDir, "add -A"); + const status = parse(await isolated.server.callTool("causal_status", {})); const activeCausalWorkspace = status["activeCausalWorkspace"] as { - semanticTransition: { kind: string; authority: string; phase: string | null; summary: string } | null; + semanticTransition: { + kind: string; + authority: string; + observationBasis: string; + phase: string | null; + evidence: { totalPaths: number; stagedPaths: number }; + } | null; } | null; expect(activeCausalWorkspace?.semanticTransition?.kind).toBe("bulk_transition"); expect(activeCausalWorkspace?.semanticTransition?.authority).toBe("repo_snapshot"); - expect(activeCausalWorkspace?.semanticTransition?.summary).toContain("Bulk staging spans"); + expect(activeCausalWorkspace?.semanticTransition?.observationBasis).toBe("snapshot_delta"); + expect(activeCausalWorkspace?.semanticTransition?.evidence).toEqual( + expect.objectContaining({ totalPaths: 8, stagedPaths: 8 }), + ); expect(status["nextAction"]).toBe("inspect_bulk_transition_scope_before_continuing"); } finally { isolated.cleanup(); @@ -590,17 +730,22 @@ describe("mcp: runtime observability", () => { const status = parse(await isolated.server.callTool("causal_status", {})); const activeCausalWorkspace = status["activeCausalWorkspace"] as { - semanticTransition: { kind: string; authority: string; phase: string | null } | null; + semanticTransition: { + kind: string; + authority: string; + observationBasis: string; + phase: string | null; + } | null; } | null; - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); expect(activeCausalWorkspace?.semanticTransition?.kind).toBe("merge_phase"); expect(activeCausalWorkspace?.semanticTransition?.authority).toBe("authoritative_git_state"); - expect(["conflicted", "resolved_waiting_commit"]).toContain( - activeCausalWorkspace?.semanticTransition?.phase ?? null, - ); + expect(activeCausalWorkspace?.semanticTransition?.observationBasis).toBe("current_state"); + expect(activeCausalWorkspace?.semanticTransition?.phase).toBe("conflicted"); expect(status["nextAction"]).toBe("complete_merge_phase_before_continuing"); expect(doctor["recommendedNextAction"]).toBe("complete_merge_phase_before_continuing"); + expect(doctor["latestTransitionEvent"]).toBeNull(); } finally { isolated.cleanup(); } @@ -634,21 +779,35 @@ describe("mcp: runtime observability", () => { graftDir: path.join(repoDir, ".graft"), }); try { + await isolated.server.callTool("doctor", { detail: "full" }); expect(() => git(repoDir, `rebase ${baseBranch}`)).toThrow(); const status = parse(await isolated.server.callTool("causal_status", {})); const activeCausalWorkspace = status["activeCausalWorkspace"] as { - semanticTransition: { kind: string; authority: string; phase: string | null } | null; + semanticTransition: { + kind: string; + authority: string; + observationBasis: string; + phase: string | null; + } | null; } | null; - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); expect(activeCausalWorkspace?.semanticTransition?.kind).toBe("rebase_phase"); expect(activeCausalWorkspace?.semanticTransition?.authority).toBe("authoritative_git_state"); - expect(["conflicted", "continued", "started"]).toContain( - activeCausalWorkspace?.semanticTransition?.phase ?? null, + expect(activeCausalWorkspace?.semanticTransition?.observationBasis).toBe( + "git_transition_evidence", ); + expect(activeCausalWorkspace?.semanticTransition?.phase).toBe("conflicted"); expect(status["nextAction"]).toBe("continue_rebase_phase_before_continuing"); expect(doctor["recommendedNextAction"]).toBe("continue_rebase_phase_before_continuing"); + expect(doctor["latestTransitionEvent"]).toEqual(expect.objectContaining({ + eventKind: "transition", + payload: expect.objectContaining({ + semanticKind: "rebase_phase", + observationBasis: "git_transition_evidence", + }), + })); } finally { isolated.cleanup(); } @@ -669,7 +828,7 @@ describe("mcp: runtime observability", () => { graftDir: path.join(repoDir, ".graft"), }); try { - const first = parse(await isolated.server.callTool("doctor", {})); + const first = parse(await isolated.server.callTool("doctor", { detail: "full" })); const firstHistory = first["persistedLocalHistory"] as { availability: string; totalContinuityRecords: number; @@ -682,7 +841,7 @@ describe("mcp: runtime observability", () => { git(repoDir, "checkout -b feature/history"); - const second = parse(await isolated.server.callTool("doctor", {})); + const second = parse(await isolated.server.callTool("doctor", { detail: "full" })); const secondHistory = second["persistedLocalHistory"] as { availability: string; active: boolean; @@ -736,6 +895,34 @@ describe("mcp: runtime observability", () => { expect(secondHistory.nextAction).toBe("review_transition_boundary_before_continuing"); expect(secondCausal.checkoutEpochId).not.toBe(firstCausal.checkoutEpochId); expect(secondCausal.strandId).not.toBe(firstCausal.strandId); + + fs.writeFileSync( + path.join(repoDir, "app.ts"), + "export const ready = true;\nexport const editedAfterCheckout = true;\n", + ); + const third = parse(await isolated.server.callTool("doctor", { detail: "full" })); + const thirdSemanticTransition = third["semanticTransition"] as { + observationBasis: string; + } | null; + const thirdHistory = third["persistedLocalHistory"] as { + latestTransitionEvent: { + payload: { + observationBasis: string; + transitionKind: string | null; + fromRef: string | null; + toRef: string | null; + createdCheckoutEpochId: string | null; + }; + } | null; + }; + expect(thirdSemanticTransition?.observationBasis).toBe("snapshot_delta"); + expect(thirdHistory.latestTransitionEvent?.payload).toEqual(expect.objectContaining({ + observationBasis: "snapshot_delta", + transitionKind: null, + fromRef: null, + toRef: null, + createdCheckoutEpochId: null, + })); } finally { isolated.cleanup(); } @@ -762,14 +949,14 @@ describe("mcp: runtime observability", () => { graftDir: path.join(repoDir, ".graft"), }); try { - const first = parse(await isolated.server.callTool("doctor", {})); + const first = parse(await isolated.server.callTool("doctor", { detail: "full" })); const firstCausal = first["causalContext"] as { checkoutEpochId: string; }; git(repoDir, "checkout -b feature/hooked-history"); - const second = parse(await isolated.server.callTool("doctor", {})); + const second = parse(await isolated.server.callTool("doctor", { detail: "full" })); const secondHistory = second["persistedLocalHistory"] as { availability: string; continuityConfidence: string; @@ -829,7 +1016,7 @@ describe("mcp: runtime observability", () => { const safeRead = parse(await isolated.server.callTool("safe_read", { path: "app.ts" })); expect(safeRead["projection"]).toBe("content"); - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); expect(doctor["workspaceOverlay"]).toBeNull(); const excludePath = path.join(repoDir, ".git", "info", "exclude"); @@ -861,7 +1048,7 @@ describe("mcp: runtime observability", () => { graftDir: path.join(repoDir, ".graft"), }); try { - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); const workspaceOverlayFooting = doctor["workspaceOverlayFooting"] as { observationMode: string; lineagePosture: string; @@ -927,7 +1114,7 @@ describe("mcp: runtime observability", () => { graftDir: path.join(repoDir, ".graft"), }); try { - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); const workspaceOverlayFooting = doctor["workspaceOverlayFooting"] as { observationMode: string; lineagePosture: string; diff --git a/test/unit/mcp/semantic-transition-guidance.test.ts b/test/unit/mcp/semantic-transition-guidance.test.ts index 3c3a56d7..6d04f95d 100644 --- a/test/unit/mcp/semantic-transition-guidance.test.ts +++ b/test/unit/mcp/semantic-transition-guidance.test.ts @@ -48,6 +48,7 @@ describe("mcp: semantic transition guidance", () => { { kind: "merge_phase", authority: "authoritative_git_state", + observationBasis: "current_state", phase: "conflicted", summary: "Merge is waiting on conflict resolution.", evidence: { diff --git a/test/unit/mcp/server-tool-access.test.ts b/test/unit/mcp/server-tool-access.test.ts new file mode 100644 index 00000000..d70b06b9 --- /dev/null +++ b/test/unit/mcp/server-tool-access.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { McpToolResult } from "../../../src/mcp/receipt.js"; +import { parseToolPayload } from "../../../src/mcp/server-tool-access.js"; + +function textResult(text: string): McpToolResult { + return { content: [{ type: "text", text }] }; +} + +describe("MCP server tool payload parsing", () => { + it("prefers native structured content", () => { + const result: McpToolResult = { + content: [{ type: "text", text: '{"source":"text"}' }], + structuredContent: { source: "structured" }, + }; + + expect(parseToolPayload(result)).toEqual({ source: "structured" }); + }); + + it("retains compatibility with legacy text-only results", () => { + expect(parseToolPayload(textResult('{"source":"text"}'))).toEqual({ + source: "text", + }); + }); + + it("fails closed when present structured content is malformed", () => { + const result = { + content: [{ type: "text" as const, text: '{"source":"text"}' }], + structuredContent: ["not", "an", "object"], + } as unknown as McpToolResult; + + expect(parseToolPayload(result)).toBeNull(); + }); + + it("returns null for malformed legacy text", () => { + expect(parseToolPayload(textResult("not-json"))).toBeNull(); + }); +}); diff --git a/test/unit/mcp/tools.test.ts b/test/unit/mcp/tools.test.ts index 24443d01..e2e1dc1f 100644 --- a/test/unit/mcp/tools.test.ts +++ b/test/unit/mcp/tools.test.ts @@ -305,7 +305,7 @@ describe("mcp: tool handlers", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "graft-mcp-tools-doctor-")); cleanups.push(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); const server = createServerForProjectRoot(tmpDir); - const result = await server.callTool("doctor", {}); + const result = await server.callTool("doctor", { detail: "full" }); const parsed = parse(result); expect(parsed["projectRoot"]).toBeDefined(); expect(parsed["parserHealthy"]).toBeDefined(); @@ -443,7 +443,7 @@ describe("mcp: tool handlers", () => { const server = createServerForProjectRoot(repoDir); await server.callTool("safe_read", { path: "app.ts" }); - const parsed = parse(await server.callTool("activity_view", { limit: 5 })); + const parsed = parse(await server.callTool("activity_view", { detail: "full", limit: 5 })); expect(parsed["bindState"]).toBe("bound"); expect(parsed["truthClass"]).toBe("artifact_history"); expect(parsed["nextAction"]).toBe("continue_active_causal_workspace"); @@ -533,7 +533,12 @@ describe("mcp: tool handlers", () => { latestReadEvent: null; latestStageEvent: null; latestTransitionEvent: null; - semanticTransition: { kind: string; authority: string; phase: string | null } | null; + semanticTransition: { + kind: string; + authority: string; + observationBasis: string; + phase: string | null; + } | null; workspaceOverlayFooting: { observationMode: string; lineagePosture: string; @@ -566,7 +571,12 @@ describe("mcp: tool handlers", () => { expect(activeCausalWorkspace.attribution.actor.actorKind).toBe("agent"); expect(activeCausalWorkspace.latestReadEvent).toBeNull(); expect(activeCausalWorkspace.latestStageEvent).toBeNull(); - expect(activeCausalWorkspace.semanticTransition).toBeNull(); + expect(activeCausalWorkspace.semanticTransition).toEqual(expect.objectContaining({ + kind: "unknown", + authority: "repo_snapshot", + observationBasis: "git_transition_evidence", + phase: null, + })); expect(activeCausalWorkspace.workspaceOverlayFooting.observationMode).toBe("inferred_between_tool_calls"); expect(activeCausalWorkspace.workspaceOverlayFooting.lineagePosture).toBe("forked_after_transition"); expect(activeCausalWorkspace.workspaceOverlayFooting.boundaryAuthority).toBe("repo_snapshot"); @@ -592,7 +602,7 @@ describe("mcp: tool handlers", () => { const server = createServer(); await server.callTool("run_capture", { command: "printf 'alpha'", tail: 1 }); - const doctor = parse(await server.callTool("doctor", {})); + const doctor = parse(await server.callTool("doctor", { detail: "full" })); const burdenSummary = doctor["burdenSummary"] as { topKind: string | null; totalBytesReturned: number; @@ -622,7 +632,7 @@ describe("mcp: context budget", () => { it("budget appears in receipt after set_budget", async () => { const server = createServer(); await server.callTool("set_budget", { bytes: 100000 }); - const result = await server.callTool("safe_read", { path: SMALL_TS }); + const result = await server.callTool("safe_read", { path: SMALL_TS, receipt: "full" }); const parsed = parse(result); const receipt = parsed["_receipt"] as { budget?: { total: number; remaining: number } }; expect(receipt.budget).toBeDefined(); @@ -634,7 +644,7 @@ describe("mcp: context budget", () => { const server = createServer(); // Set a very tight budget — 5% of 1000 = 50 bytes max per read await server.callTool("set_budget", { bytes: 1000 }); - const result = await server.callTool("safe_read", { path: MEDIUM_TS }); + const result = await server.callTool("safe_read", { path: MEDIUM_TS, receipt: "full" }); const parsed = parse(result); // medium.ts should get an outline due to tight budget cap expect(["outline", "content"]).toContain(parsed["projection"]); @@ -644,7 +654,7 @@ describe("mcp: context budget", () => { it("no budget in receipt when budget not set", async () => { const server = createServer(); - const result = await server.callTool("safe_read", { path: SMALL_TS }); + const result = await server.callTool("safe_read", { path: SMALL_TS, receipt: "full" }); const parsed = parse(result); const receipt = parsed["_receipt"] as Record; expect(receipt["budget"]).toBeUndefined(); @@ -754,7 +764,7 @@ describe("mcp: session tracking", () => { for (let i = 0; i < 5; i++) { await server.callTool("safe_read", { path: SMALL_TS }); } - const result = await server.callTool("doctor", {}); + const result = await server.callTool("doctor", { detail: "full" }); const parsed = parse(result); expect(parsed["sessionDepth"]).toBeDefined(); }); @@ -768,5 +778,7 @@ describe("mcp: session tracking", () => { }); const parsed = parse(result); expect(parsed["tripwire"]).toBeDefined(); + expect(parsed["_receipt"]).toMatchObject({ mode: "compact" }); + expect((parsed["_receipt"] as Record)["cumulative"]).toBeUndefined(); }); }); diff --git a/tests/playback/0058-system-wide-resource-pressure-and-fairness.test.ts b/tests/playback/0058-system-wide-resource-pressure-and-fairness.test.ts index f98f67d4..d361e860 100644 --- a/tests/playback/0058-system-wide-resource-pressure-and-fairness.test.ts +++ b/tests/playback/0058-system-wide-resource-pressure-and-fairness.test.ts @@ -254,6 +254,7 @@ describe("0058 playback: system-wide resource pressure and fairness", () => { seq: 1, startedAtMs: Date.now(), tool: "safe_read", + receiptMode: "compact", args: { path: "app.ts" }, projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), diff --git a/tests/playback/0078-three-surface-capability-baseline-and-parity-matrix.test.ts b/tests/playback/0078-three-surface-capability-baseline-and-parity-matrix.test.ts index abcaca11..ff8f9a61 100644 --- a/tests/playback/0078-three-surface-capability-baseline-and-parity-matrix.test.ts +++ b/tests/playback/0078-three-surface-capability-baseline-and-parity-matrix.test.ts @@ -43,17 +43,17 @@ describe("0078 three-surface capability baseline and parity matrix", () => { expect(CAPABILITY_REGISTRY.filter((capability) => capability.surfaces.join("+") === "cli")).toHaveLength(6); expect(CAPABILITY_REGISTRY.filter((capability) => capability.surfaces.join("+") === "api+cli+mcp")).toHaveLength(23); - expect(CAPABILITY_REGISTRY.filter((capability) => capability.surfaces.join("+") === "api+mcp")).toHaveLength(24); + expect(CAPABILITY_REGISTRY.filter((capability) => capability.surfaces.join("+") === "api+mcp")).toHaveLength(25); expect(CAPABILITY_REGISTRY.filter((capability) => capability.surfaces.join("+") === "api")).toHaveLength(1); expect(CAPABILITY_REGISTRY.filter((capability) => capability.surfaces.join("+") === "mcp")).toHaveLength(0); expect(content).toContain("- `6` CLI-only capabilities"); expect(content).toContain("- `23` API + CLI + MCP capabilities"); - expect(content).toContain("- `24` API + MCP capabilities"); + expect(content).toContain("- `25` API + MCP capabilities"); expect(content).toContain("- `1` API-only capability"); expect(content).toContain("- `22` direct CLI/MCP peer capabilities"); expect(content).toContain("- `1` composed CLI operator/lifecycle capability"); - expect(content).toContain("- `24` intentionally API + MCP-only agent/control-plane capabilities"); + expect(content).toContain("- `25` intentionally API + MCP-only agent/control-plane capabilities"); }); it("Does the capability registry explicitly model all three entry points?", () => { diff --git a/tests/playback/0088-target-repo-git-hook-bootstrap.test.ts b/tests/playback/0088-target-repo-git-hook-bootstrap.test.ts index 9cb270e1..8038e38e 100644 --- a/tests/playback/0088-target-repo-git-hook-bootstrap.test.ts +++ b/tests/playback/0088-target-repo-git-hook-bootstrap.test.ts @@ -194,7 +194,7 @@ describe("0088 target-repo git hook bootstrap", () => { isolated.cleanup(); }); - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); const footing = doctor["workspaceOverlayFooting"] as { observationMode: string; degradedReason: string; @@ -244,7 +244,7 @@ describe("0088 target-repo git hook bootstrap", () => { isolated.cleanup(); }); - const doctor = parse(await isolated.server.callTool("doctor", {})); + const doctor = parse(await isolated.server.callTool("doctor", { detail: "full" })); const footing = doctor["workspaceOverlayFooting"] as { observationMode: string; boundaryAuthority: string; diff --git a/tests/playback/CORE_migrate-path-ops-to-port.test.ts b/tests/playback/CORE_migrate-path-ops-to-port.test.ts index ab7645d9..27d147f9 100644 --- a/tests/playback/CORE_migrate-path-ops-to-port.test.ts +++ b/tests/playback/CORE_migrate-path-ops-to-port.test.ts @@ -75,6 +75,7 @@ async function expectWorkerRefusesOutsidePath(repoDir: string, outsideFile: stri seq: 1, startedAtMs: Date.now(), tool: "safe_read", + receiptMode: "compact", args: { path: outsideFile }, projectRoot: repoDir, graftDir: path.join(repoDir, ".graft"), diff --git a/tests/playback/CORE_v060-bad-code-burndown.test.ts b/tests/playback/CORE_v060-bad-code-burndown.test.ts index 9deefa91..80afd512 100644 --- a/tests/playback/CORE_v060-bad-code-burndown.test.ts +++ b/tests/playback/CORE_v060-bad-code-burndown.test.ts @@ -83,6 +83,7 @@ describe("CORE_v060-bad-code-burndown", () => { governor: {}, cache: {}, metrics: {}, + validateResponse: () => undefined, respond: () => ({ content: [] }), resolvePath: (r: string) => r, getWarp: () => Promise.resolve({}), @@ -194,7 +195,11 @@ describe("CORE_v060-bad-code-burndown", () => { burdenByKind: freezeBurdenByKind(emptyBurdenByKind()), }); const codec = { encode: (v: unknown) => JSON.stringify(v), decode: (s: string) => JSON.parse(s) as unknown }; - const result = buildReceiptResult("safe_read", { projection: "content" }, { + const result = buildReceiptResult("safe_read", { + path: "README.md", + projection: "content", + reason: "receipt immutability fixture", + }, { sessionId: "test-session", traceId: "test-trace", seq: 1, diff --git a/tests/playback/SURFACE_agent-dx-governed-edit.test.ts b/tests/playback/SURFACE_agent-dx-governed-edit.test.ts index 14c160ae..63352213 100644 --- a/tests/playback/SURFACE_agent-dx-governed-edit.test.ts +++ b/tests/playback/SURFACE_agent-dx-governed-edit.test.ts @@ -300,12 +300,13 @@ describe("SURFACE_agent-dx-governed-edit playback", () => { old_string: "'old'", new_string: "'new'", }); - const receipt = result["_receipt"] as { traceId: string }; + const receipt = result["_receipt"] as { receiptId: string }; const runtimeLog = fs.readFileSync(path.join(repoDir, ".graft", "logs", "mcp-runtime.ndjson"), "utf-8"); const events = runtimeLog.trim().split("\n").map((line) => JSON.parse(line) as { event: string; tool?: string; traceId?: string; + receiptId?: string; footprint?: { paths?: string[] }; provenance?: unknown; causalWriteEvent?: unknown; @@ -313,7 +314,7 @@ describe("SURFACE_agent-dx-governed-edit playback", () => { const completed = events.find((event) => event.event === "tool_call_completed" && event.tool === "graft_edit" && - event.traceId === receipt.traceId + event.receiptId === receipt.receiptId ); expect(completed?.footprint?.paths).toEqual([path.join(repoDir, "src/app.ts")]); diff --git a/tests/playback/SURFACE_capability-matrix-truth.test.ts b/tests/playback/SURFACE_capability-matrix-truth.test.ts index cf3e9c7e..d08a6142 100644 --- a/tests/playback/SURFACE_capability-matrix-truth.test.ts +++ b/tests/playback/SURFACE_capability-matrix-truth.test.ts @@ -59,7 +59,7 @@ describe("SURFACE capability matrix truth playback", () => { it("Do tests prove the intentionally API+MCP-only tools were not converted into direct CLI peers?", () => { const intentionallyApiMcpOnly = CAPABILITY_REGISTRY.filter((capability) => capability.cliMcpParity === "mcp_only"); - expect(intentionallyApiMcpOnly).toHaveLength(24); + expect(intentionallyApiMcpOnly).toHaveLength(25); expect(intentionallyApiMcpOnly.every((capability) => capability.cliCommand === undefined)).toBe(true); expect(intentionallyApiMcpOnly.every((capability) => capability.cliPath === undefined)).toBe(true); expect(CAPABILITY_REGISTRY.find((capability) => capability.id === "daemon_status")?.cliCommand).toBeUndefined(); diff --git a/tests/playback/SURFACE_governed-write-tools.test.ts b/tests/playback/SURFACE_governed-write-tools.test.ts index 94908076..38e223f4 100644 --- a/tests/playback/SURFACE_governed-write-tools.test.ts +++ b/tests/playback/SURFACE_governed-write-tools.test.ts @@ -72,11 +72,11 @@ describe("SURFACE_governed-write-tools scope playback", () => { old_string: "'old'", new_string: "'new'", })); - const receipt = result["_receipt"] as { traceId: string }; + const receipt = result["_receipt"] as { receiptId: string }; const completed = readRuntimeEvents(repoDir).find((event) => event["event"] === "tool_call_completed" && event["tool"] === "graft_edit" && - event["traceId"] === receipt.traceId + event["receiptId"] === receipt.receiptId ); expect(completed?.["footprint"]).toMatchObject({ @@ -101,6 +101,7 @@ describe("SURFACE_governed-write-tools scope playback", () => { path: "src/app.ts", start: 1, end: 2, + receipt: "full", })); const receipt = result["_receipt"] as Record; @@ -159,8 +160,9 @@ describe("SURFACE_governed-write-tools scope playback", () => { direction: "removed", }, }], - _schema: { id: "graft.mcp.graft_edit", version: "1.0.0" }, + _schema: { id: "graft.mcp.graft_edit", version: "2.0.0" }, _receipt: { + mode: "full", sessionId: "session", traceId: "trace", seq: 1,