From 89a7641b9048f252a65e81b4a86452dfcbd3f729 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 16 May 2026 13:04:35 -0700 Subject: [PATCH 01/15] feat: add structural history schema authority --- CHANGELOG.md | 7 + ...tural-history-schema-and-echo-migration.md | 16 + ...EAN_wesley-cli-not-hermetic-in-graft-ci.md | 59 ++++ docs/method/backlog/dependency-dag.dot | 3 +- docs/method/backlog/dependency-dag.svg | 210 +++++++------ eslint.config.js | 1 + package.json | 1 + schemas/graft-structural-history.graphql | 282 +++++++++++++++++ .../graft-structural-history.manifest.json | 34 ++ ...eck-structural-history-schema-artifacts.ts | 163 ++++++++++ src/generated/graft-structural-history.ts | 291 ++++++++++++++++++ .../graft-structural-history-schema.test.ts | 74 +++++ 12 files changed, 1038 insertions(+), 103 deletions(-) create mode 100644 docs/method/backlog/bad-code/CLEAN_wesley-cli-not-hermetic-in-graft-ci.md create mode 100644 schemas/graft-structural-history.graphql create mode 100644 schemas/graft-structural-history.manifest.json create mode 100644 scripts/check-structural-history-schema-artifacts.ts create mode 100644 src/generated/graft-structural-history.ts create mode 100644 test/unit/contracts/graft-structural-history-schema.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cb6b3c..a030ba4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Structural history schema authority**: Graft now carries a canonical + `schemas/graft-structural-history.graphql` schema with Wesley-generated + TypeScript contracts and a deterministic artifact drift check, establishing + the schema-first Echo migration boundary without changing Echo or Wesley. + ### Changed - **Structural reading boundary**: `graft_review` impact counts and diff --git a/docs/design/CORE_structural-history-schema-and-echo-migration.md b/docs/design/CORE_structural-history-schema-and-echo-migration.md index c6eea70e..c87fec05 100644 --- a/docs/design/CORE_structural-history-schema-and-echo-migration.md +++ b/docs/design/CORE_structural-history-schema-and-echo-migration.md @@ -320,3 +320,19 @@ The implementation phase should add deterministic tests in this order: Tests must assert observable behavior. They should not depend on wall-clock timing, unseeded randomness, stdout/stderr text, or source-code introspection as proof. + +## Implementation Status + +- `schemas/graft-structural-history.graphql` is the first Graft-owned canonical + structural history schema. +- `src/generated/graft-structural-history.ts` is generated by the existing + Wesley CLI TypeScript emitter from that schema. +- `schemas/graft-structural-history.manifest.json` records the schema source + hash, Wesley L1 registry hash, generated artifact hash, required structural + types, required read operations, and required evidence labels. +- `scripts/check-structural-history-schema-artifacts.ts` and + `test/unit/contracts/graft-structural-history-schema.test.ts` provide the + first deterministic drift check for the schema/generated artifact pair. +- This slice does not wire Echo runtime reads yet. It establishes schema + authority first so later Echo import and parity work has a canonical Graft + model to target. diff --git a/docs/method/backlog/bad-code/CLEAN_wesley-cli-not-hermetic-in-graft-ci.md b/docs/method/backlog/bad-code/CLEAN_wesley-cli-not-hermetic-in-graft-ci.md new file mode 100644 index 00000000..23c8721f --- /dev/null +++ b/docs/method/backlog/bad-code/CLEAN_wesley-cli-not-hermetic-in-graft-ci.md @@ -0,0 +1,59 @@ +--- +title: "Wesley CLI is not hermetic in Graft CI" +feature: core +kind: bad-code +legend: CLEAN +lane: bad-code +priority: 2 +effort: M +status: open +reported: 2026-05-16 +--- + +# Wesley CLI is not hermetic in Graft CI + +## Problem + +Graft now carries `schemas/graft-structural-history.graphql` and a +Wesley-generated TypeScript artifact, but Graft does not yet have a hermetic +Wesley CLI dependency in its Node/Docker CI environment. + +The current drift check verifies: + +- the committed schema source hash +- the committed generated TypeScript hash +- the recorded Wesley L1 registry hash +- required generated structural types +- required generated query operation constants +- required evidence labels + +That catches stale edits to either committed file, but it does not regenerate +the TypeScript artifact from the GraphQL schema during CI. + +## Risk + +A future change could update the manifest and generated artifact manually +without actually running the Wesley compiler. That would preserve local tests +while weakening the "GraphQL is the single source of truth" promise. + +## Desired Outcome + +Make Wesley generation hermetic for Graft validation without changing Echo or +Wesley semantics. + +Acceptable paths include: + +- install and cache `wesley-cli` in CI and the Docker test image +- consume a published binary or package wrapper once one exists +- add a Graft-local validation adapter that invokes an explicit `WESLEY_BIN` + only when configured, while CI config supplies that binary + +## Acceptance Criteria + +- Graft CI can run a regenerate-and-diff check for + `schemas/graft-structural-history.graphql`. +- The check fails if `src/generated/graft-structural-history.ts` is not exactly + what the configured Wesley CLI emits. +- The check records the Wesley CLI version used for generation. +- The check does not require modifying Echo. +- The check does not require modifying Wesley compiler semantics. diff --git a/docs/method/backlog/dependency-dag.dot b/docs/method/backlog/dependency-dag.dot index 78c1c52e..1aa85acf 100644 --- a/docs/method/backlog/dependency-dag.dot +++ b/docs/method/backlog/dependency-dag.dot @@ -48,9 +48,10 @@ digraph backlog { } subgraph cluster_bad_code { - label="bad-code (1)" labeljust=l fontsize=9 fontcolor="#555555" + label="bad-code (2)" labeljust=l fontsize=9 fontcolor="#555555" style=rounded color="#cccccc" bgcolor="#fafafa" 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] + bad_code_CLEAN_wesley_cli_not_hermetic_in_graft_ci [label="CLEAN-wesley-cli-not-hermetic-in-graft-ci - M" fillcolor="#F6B3B3" penwidth=2] } subgraph cluster_cool_ideas { diff --git a/docs/method/backlog/dependency-dag.svg b/docs/method/backlog/dependency-dag.svg index 60bdf346..0c47802c 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,8 +22,8 @@ cluster_bad_code - -bad-code (1) + +bad-code (2) cluster_cool_ideas @@ -42,8 +42,8 @@ cluster_legend - -Legend + +Legend @@ -208,7 +208,7 @@ WARP-symbol-history-timeline - S - + cool_ideas_WARP_temporal_structural_search WARP-temporal-structural-search - M @@ -226,170 +226,176 @@ CLEAN-remaining-structural-warp-reads-bypass-structural-reading-port - M - + +bad_code_CLEAN_wesley_cli_not_hermetic_in_graft_ci + +CLEAN-wesley-cli-not-hermetic-in-graft-ci - 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 - + 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_CORE_agent_handoff_protocol CORE-agent-handoff-protocol - M - + cool_ideas_CORE_auto_focus CORE-auto-focus - L - + cool_ideas_CORE_capture_range CORE-capture-range - S - + 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_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 - + 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_wire_primitives_into_runtime CORE-wire-primitives-into-runtime - M - + cool_ideas_monitor_tick_ceiling_tracking monitor-tick-ceiling-tracking - S - + cool_ideas_WARP_background_indexing WARP-background-indexing - M @@ -402,13 +408,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 @@ -421,79 +427,79 @@ blocked_by/blocking - + cool_ideas_SURFACE_attach_to_existing_causal_session SURFACE-attach-to-existing-causal-session - 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 - + cool_ideas_SURFACE_init_dry_run 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 - + cool_ideas_SURFACE_non_codex_instruction_bootstrap_parity SURFACE-non-codex-instruction-bootstrap-parity - M - + cool_ideas_SURFACE_offer_rename_refactor SURFACE-offer-rename-refactor - L - + cool_ideas_SURFACE_terminal_activity_browser_tui SURFACE-terminal-activity-browser-tui - L - + cool_ideas_traverse_plus_query_hydration_helper traverse-plus-query-hydration-helper - S - + cool_ideas_WARP_adaptive_projection_selection WARP-adaptive-projection-selection - L - + cool_ideas_WARP_agent_action_provenance WARP-agent-action-provenance - XL @@ -506,7 +512,7 @@ blocked_by/blocking - + cool_ideas_WARP_causal_write_tracking WARP-causal-write-tracking - L @@ -519,7 +525,7 @@ blocked_by/blocking - + cool_ideas_WARP_intent_and_decision_events WARP-intent-and-decision-events - M @@ -532,7 +538,7 @@ blocked_by/blocking - + cool_ideas_WARP_provenance_dag WARP-provenance-dag - L @@ -545,31 +551,31 @@ blocked_by/blocking - + cool_ideas_WARP_auto_breaking_change_detection WARP-auto-breaking-change-detection - L - + cool_ideas_WARP_budget_elasticity WARP-budget-elasticity - M - + cool_ideas_WARP_causal_blame_for_staged_artifacts WARP-causal-blame-for-staged-artifacts - L - + cool_ideas_WARP_codebase_entropy_trajectory WARP-codebase-entropy-trajectory - M - + cool_ideas_WARP_counterfactual_refactoring WARP-counterfactual-refactoring - XL @@ -582,13 +588,13 @@ blocked_by/blocking - + cool_ideas_WARP_codebase_signature WARP-codebase-signature - L - + cool_ideas_WARP_structural_impact_prediction WARP-structural-impact-prediction - XL @@ -601,55 +607,55 @@ blocked_by/blocking - + cool_ideas_WARP_degeneracy_warning WARP-degeneracy-warning - M - + cool_ideas_WARP_drift_sentinel WARP-drift-sentinel - M - + cool_ideas_WARP_footprint_parallelism WARP-footprint-parallelism - XL - + cool_ideas_WARP_graft_pack WARP-graft-pack - M - + cool_ideas_WARP_grouped_aggregate_queries WARP-grouped-aggregate-queries - M - + cool_ideas_WARP_intentional_degeneracy_privacy WARP-intentional-degeneracy-privacy - M - + cool_ideas_WARP_minimum_viable_context WARP-minimum-viable-context - M - + cool_ideas_WARP_outline_diff_commit_trailer WARP-outline-diff-commit-trailer - S - + cool_ideas_WARP_projection_safety_classes WARP-projection-safety-classes - M @@ -662,7 +668,7 @@ blocked_by/blocking - + cool_ideas_WARP_reasoning_trace_replay WARP-reasoning-trace-replay - M @@ -675,43 +681,43 @@ blocked_by/blocking - + cool_ideas_WARP_rulial_heat_map WARP-rulial-heat-map - L - + cool_ideas_WARP_semantic_drift_in_sessions WARP-semantic-drift-in-sessions - M - + cool_ideas_WARP_semantic_merge_conflict_prediction WARP-semantic-merge-conflict-prediction - L - + cool_ideas_WARP_session_filtration WARP-session-filtration - L - + cool_ideas_WARP_shadow_structural_workspaces WARP-shadow-structural-workspaces - XL - + cool_ideas_WARP_speculative_merge WARP-speculative-merge - XL - + cool_ideas_WARP_stale_docs_checker WARP-stale-docs-checker - M @@ -724,25 +730,25 @@ blocked_by/blocking - + cool_ideas_WARP_structural_drift_detection WARP-structural-drift-detection - M - + cool_ideas_WARP_symbol_heatmap WARP-symbol-heatmap - M - + cool_ideas_WARP_technical_debt_curvature WARP-technical-debt-curvature - L - + external_git_warp_observer_geometry_ladder__Rung_2_4_ git-warp observer geometry ladder (Rung 2-4) @@ -755,7 +761,7 @@ blocked_by_external - + unresolved_CLEAN_CODE_export_diff_semver_signature_as_patch missing: CLEAN_CODE_export-diff-semver-signature-as-patch @@ -775,44 +781,44 @@ 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/eslint.config.js b/eslint.config.js index c26478cc..cf764d4b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -251,6 +251,7 @@ export default tseslint.config( "dist/", "coverage/", "node_modules/", + "src/generated/", ".graft/", ".claude/", ".obsidian/", diff --git a/package.json b/package.json index 6cbe8d8c..50ad1527 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "test:watch": "vitest", "lint": "eslint .", "typecheck": "tsc --noEmit", + "schema:structural-history:check": "tsx scripts/check-structural-history-schema-artifacts.ts", "pack:check": "pnpm pack --dry-run", "guard:agent-worktrees": "tsx scripts/check-agent-worktree-hygiene.ts", "security:check": "tsx scripts/check-release-security.ts", diff --git a/schemas/graft-structural-history.graphql b/schemas/graft-structural-history.graphql new file mode 100644 index 00000000..7f287754 --- /dev/null +++ b/schemas/graft-structural-history.graphql @@ -0,0 +1,282 @@ +# Graft Structural History Schema +# Version: 0.1.0 +# +# Graft-authored canonical structural history facts. +# +# This schema is the source of truth for Graft structural semantics. Wesley +# compiles derived artifacts from it. Echo stores and executes the resulting +# contracts. git-warp may provide imported or fallback-translated evidence, but +# git-warp concepts do not become canonical unless they are named here as Graft +# facts. + +scalar Hash +scalar Json + +enum StructuralBasisKind { + GIT_COMMIT + GIT_REF + ECHO_HEAD + LIVE_FRONTIER + IMPORT_BATCH +} + +enum StructuralReadingKind { + SYMBOL_REFERENCE_COUNT + DEAD_SYMBOLS + SYMBOL_HISTORY + REFACTOR_DIFFICULTY + STRUCTURAL_LOG + STRUCTURAL_CHURN + PRECISION_CODE_LOOKUP +} + +enum StructuralReadingFreshness { + CURRENT + STALE + INCOMPARABLE + UNKNOWN + LIVE_FRONTIER +} + +enum StructuralReadingResidualPosture { + COMPLETE + PARTIAL + PLURAL + BUDGET_LIMITED + RIGHTS_LIMITED + UNAVAILABLE + OBSTRUCTED + DEGRADED +} + +enum StructuralEvidenceKind { + ECHO_NATIVE + GIT_WARP_IMPORTED + FALLBACK_TRANSLATED +} + +enum StructuralSubstrateKind { + ECHO + GIT_WARP +} + +enum SourceSpanEncoding { + UTF8_BYTE_RANGE + UTF16_CODE_UNIT_RANGE + LINE_COLUMN +} + +enum SymbolVisibility { + LOCAL + EXPORTED + UNKNOWN +} + +enum SymbolRelationKind { + IMPORTS + EXPORTS + REFERENCES + CALLS + DEFINES + REMOVES +} + +enum MigrationParityStatus { + NOT_CHECKED + MATCHED + MISMATCHED + PARTIAL +} + +type StructuralRepository + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 512) + @wes_version(major: 0, minor: 1) +{ + repositoryId: ID! @wes_stateField(key: true) + rootKey: String! @wes_stateField + defaultBranch: String @wes_stateField + summary: String! @wes_stateField +} + +type StructuralBasis + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 513) + @wes_version(major: 0, minor: 1) +{ + basisId: ID! @wes_stateField(key: true) + repositoryId: ID! @wes_stateField + basisKind: StructuralBasisKind! @wes_stateField + commitId: String @wes_stateField + refName: String @wes_stateField + echoHeadId: String @wes_stateField + importBatchId: ID @wes_stateField + summary: String! @wes_stateField +} + +type StructuralFileVersion + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 514) + @wes_version(major: 0, minor: 1) +{ + fileVersionId: ID! @wes_stateField(key: true) + repositoryId: ID! @wes_stateField + basisId: ID! @wes_stateField + path: String! @wes_stateField + contentDigest: Hash! @wes_stateField + language: String @wes_stateField + summary: String! @wes_stateField +} + +type StructuralSourceSpan + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 515) + @wes_version(major: 0, minor: 1) +{ + spanId: ID! @wes_stateField(key: true) + fileVersionId: ID! @wes_stateField + encoding: SourceSpanEncoding! @wes_stateField + startOffset: Int! @wes_stateField @wes_constraint(min: 0) + endOffset: Int! @wes_stateField @wes_constraint(min: 0) + startLine: Int @wes_stateField @wes_constraint(min: 1) + endLine: Int @wes_stateField @wes_constraint(min: 1) + summary: String! @wes_stateField +} + +type StructuralParserRun + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 516) + @wes_version(major: 0, minor: 1) +{ + parserRunId: ID! @wes_stateField(key: true) + fileVersionId: ID! @wes_stateField + parserName: String! @wes_stateField + parserVersion: String @wes_stateField + diagnosticCount: Int! @wes_stateField @wes_constraint(min: 0) + summary: String! @wes_stateField +} + +type StructuralSymbol + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 517) + @wes_version(major: 0, minor: 1) +{ + symbolId: ID! @wes_stateField(key: true) + repositoryId: ID! @wes_stateField + basisId: ID! @wes_stateField + fileVersionId: ID! @wes_stateField + parserRunId: ID @wes_stateField + name: String! @wes_stateField + kind: String! @wes_stateField + visibility: SymbolVisibility! @wes_stateField + declarationSpanId: ID @wes_stateField + exported: Boolean! @wes_stateField + summary: String! @wes_stateField +} + +type StructuralSymbolRelation + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 518) + @wes_version(major: 0, minor: 1) +{ + relationId: ID! @wes_stateField(key: true) + repositoryId: ID! @wes_stateField + basisId: ID! @wes_stateField + relationKind: SymbolRelationKind! @wes_stateField + fromSymbolId: ID @wes_stateField + toSymbolId: ID @wes_stateField + fileVersionId: ID @wes_stateField + spanId: ID @wes_stateField + summary: String! @wes_stateField +} + +type StructuralReadingEvidence + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 519) + @wes_version(major: 0, minor: 1) +{ + evidenceId: ID! @wes_stateField(key: true) + evidenceKind: StructuralEvidenceKind! @wes_stateField + substrate: StructuralSubstrateKind! @wes_stateField + basisId: ID! @wes_stateField + sourceRef: String @wes_stateField + migrationBatchId: ID @wes_stateField + nativeContinuumWitness: Boolean! @wes_stateField + parity: MigrationParityStatus! @wes_stateField + summary: String! @wes_stateField +} + +type StructuralReading + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 520) + @wes_version(major: 0, minor: 1) +{ + readingId: ID! @wes_stateField(key: true) + repositoryId: ID! @wes_stateField + basisId: ID! @wes_stateField + evidenceId: ID! @wes_stateField + readingKind: StructuralReadingKind! @wes_stateField + freshness: StructuralReadingFreshness! @wes_stateField + residualPosture: StructuralReadingResidualPosture! @wes_stateField + payloadDigest: Hash! @wes_stateField + payloadJson: Json @wes_stateField + summary: String! @wes_stateField +} + +type GitWarpImportBatch + @wes_codec(format: "cbor", canonical: true) + @wes_registry(id: 521) + @wes_version(major: 0, minor: 1) +{ + importBatchId: ID! @wes_stateField(key: true) + repositoryId: ID! @wes_stateField + sourceRef: String! @wes_stateField + importedBasisId: ID! @wes_stateField + parity: MigrationParityStatus! @wes_stateField + importedReadingCount: Int! @wes_stateField @wes_constraint(min: 0) + summary: String! @wes_stateField +} + +type Query { + structuralRepositories(repositoryId: ID): [StructuralRepository!]! + @wes_op(name: "structuralRepositories", readonly: true) + @wes_footprint(reads: ["StructuralRepository"]) + + structuralBases(repositoryId: ID!, basisKind: StructuralBasisKind): [StructuralBasis!]! + @wes_op(name: "structuralBases", readonly: true) + @wes_footprint(reads: ["StructuralBasis"]) + + structuralFileVersions(basisId: ID!, path: String): [StructuralFileVersion!]! + @wes_op(name: "structuralFileVersions", readonly: true) + @wes_footprint(reads: ["StructuralFileVersion"]) + + structuralSymbols(basisId: ID!, fileVersionId: ID, name: String): [StructuralSymbol!]! + @wes_op(name: "structuralSymbols", readonly: true) + @wes_footprint(reads: ["StructuralSymbol"]) + + structuralSymbolRelations(basisId: ID!, symbolId: ID, relationKind: SymbolRelationKind): [StructuralSymbolRelation!]! + @wes_op(name: "structuralSymbolRelations", readonly: true) + @wes_footprint(reads: ["StructuralSymbolRelation"]) + + structuralReadings(basisId: ID!, readingKind: StructuralReadingKind): [StructuralReading!]! + @wes_op(name: "structuralReadings", readonly: true) + @wes_footprint(reads: ["StructuralReading", "StructuralReadingEvidence"]) + + structuralReadingEvidence(evidenceId: ID, basisId: ID): [StructuralReadingEvidence!]! + @wes_op(name: "structuralReadingEvidence", readonly: true) + @wes_footprint(reads: ["StructuralReadingEvidence"]) + + gitWarpImportBatches(repositoryId: ID!, parity: MigrationParityStatus): [GitWarpImportBatch!]! + @wes_op(name: "gitWarpImportBatches", readonly: true) + @wes_footprint(reads: ["GitWarpImportBatch"]) +} + +type GraftStructuralHistoryInvariants + @wes_invariant( + name: "fallback_translated_is_not_native_continuum", + expr: "forall e in StructuralReadingEvidence: e.evidenceKind == FALLBACK_TRANSLATED implies e.nativeContinuumWitness == false", + severity: "error" + ) +{ + _placeholder: Boolean +} diff --git a/schemas/graft-structural-history.manifest.json b/schemas/graft-structural-history.manifest.json new file mode 100644 index 00000000..f48632c9 --- /dev/null +++ b/schemas/graft-structural-history.manifest.json @@ -0,0 +1,34 @@ +{ + "schemaPath": "schemas/graft-structural-history.graphql", + "generatedTypesPath": "src/generated/graft-structural-history.ts", + "schemaSourceSha256": "588d2bd5b873387e9443d94e557423e8c82d824b69eb78efe91c279585412d81", + "generatedTypesSha256": "79737d3c089067685d553f9526c8eb442cb7a20c099a49a771162c2be29559cc", + "wesleyCliVersion": "0.0.4", + "wesleyL1RegistryHash": "0f6d6d2109142a0cd33ee8db9ebc28f1718e0d1ec2863ec4837048a1340bff61", + "requiredTypes": [ + "StructuralRepository", + "StructuralBasis", + "StructuralFileVersion", + "StructuralParserRun", + "StructuralSymbol", + "StructuralSymbolRelation", + "StructuralReadingEvidence", + "StructuralReading", + "GitWarpImportBatch" + ], + "requiredOperationConstants": [ + "queryStructuralRepositoriesOperation", + "queryStructuralBasesOperation", + "queryStructuralFileVersionsOperation", + "queryStructuralSymbolsOperation", + "queryStructuralSymbolRelationsOperation", + "queryStructuralReadingsOperation", + "queryStructuralReadingEvidenceOperation", + "queryGitWarpImportBatchesOperation" + ], + "requiredEvidenceLabels": [ + "ECHO_NATIVE", + "GIT_WARP_IMPORTED", + "FALLBACK_TRANSLATED" + ] +} diff --git a/scripts/check-structural-history-schema-artifacts.ts b/scripts/check-structural-history-schema-artifacts.ts new file mode 100644 index 00000000..885b4cb7 --- /dev/null +++ b/scripts/check-structural-history-schema-artifacts.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env tsx +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_WORKSPACE_ROOT = path.resolve(SCRIPT_DIR, ".."); +const DEFAULT_MANIFEST_PATH = "schemas/graft-structural-history.manifest.json"; +const GENERATED_HEADER = "/* @generated by Wesley. Do not edit. */"; + +export interface StructuralHistorySchemaManifest { + readonly schemaPath: string; + readonly generatedTypesPath: string; + readonly schemaSourceSha256: string; + readonly generatedTypesSha256: string; + readonly wesleyCliVersion: string; + readonly wesleyL1RegistryHash: string; + readonly requiredTypes: readonly string[]; + readonly requiredOperationConstants: readonly string[]; + readonly requiredEvidenceLabels: readonly string[]; +} + +export interface StructuralHistorySchemaArtifactCheck { + readonly manifest: StructuralHistorySchemaManifest; + readonly violations: readonly string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Expected manifest field ${key} to be a non-empty string.`); + } + + return value; +} + +function readStringArray(record: Record, key: string): readonly string[] { + const value = record[key]; + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + throw new Error(`Expected manifest field ${key} to be a string array.`); + } + + return value; +} + +export function readStructuralHistorySchemaManifest( + workspaceRoot = DEFAULT_WORKSPACE_ROOT, + manifestPath = DEFAULT_MANIFEST_PATH, +): StructuralHistorySchemaManifest { + const manifestText = readFileSync(path.join(workspaceRoot, manifestPath), "utf8"); + const parsed = JSON.parse(manifestText) as unknown; + if (!isRecord(parsed)) { + throw new Error("Structural history schema manifest must be a JSON object."); + } + + return { + schemaPath: readString(parsed, "schemaPath"), + generatedTypesPath: readString(parsed, "generatedTypesPath"), + schemaSourceSha256: readString(parsed, "schemaSourceSha256"), + generatedTypesSha256: readString(parsed, "generatedTypesSha256"), + wesleyCliVersion: readString(parsed, "wesleyCliVersion"), + wesleyL1RegistryHash: readString(parsed, "wesleyL1RegistryHash"), + requiredTypes: readStringArray(parsed, "requiredTypes"), + requiredOperationConstants: readStringArray(parsed, "requiredOperationConstants"), + requiredEvidenceLabels: readStringArray(parsed, "requiredEvidenceLabels"), + }; +} + +function sha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function readWorkspaceFile(workspaceRoot: string, relativePath: string): string { + return readFileSync(path.join(workspaceRoot, relativePath), "utf8"); +} + +function typePattern(typeName: string): RegExp { + return new RegExp(`export (interface|type) ${typeName}\\b`, "u"); +} + +export function checkStructuralHistorySchemaArtifacts( + workspaceRoot = DEFAULT_WORKSPACE_ROOT, +): StructuralHistorySchemaArtifactCheck { + const manifest = readStructuralHistorySchemaManifest(workspaceRoot); + const schemaText = readWorkspaceFile(workspaceRoot, manifest.schemaPath); + const generatedText = readWorkspaceFile(workspaceRoot, manifest.generatedTypesPath); + const violations: string[] = []; + + const schemaHash = sha256(schemaText); + if (schemaHash !== manifest.schemaSourceSha256) { + violations.push( + `${manifest.schemaPath} sha256 ${schemaHash} does not match manifest ${manifest.schemaSourceSha256}.`, + ); + } + + const generatedHash = sha256(generatedText); + if (generatedHash !== manifest.generatedTypesSha256) { + violations.push( + `${manifest.generatedTypesPath} sha256 ${generatedHash} does not match manifest ${manifest.generatedTypesSha256}.`, + ); + } + + if (!generatedText.startsWith(GENERATED_HEADER)) { + violations.push(`${manifest.generatedTypesPath} is missing the Wesley generated-file header.`); + } + + if (manifest.wesleyCliVersion !== "0.0.4") { + violations.push(`Unexpected Wesley CLI version ${manifest.wesleyCliVersion}; expected 0.0.4.`); + } + + for (const typeName of manifest.requiredTypes) { + if (!typePattern(typeName).test(generatedText)) { + violations.push(`${manifest.generatedTypesPath} is missing generated type ${typeName}.`); + } + } + + for (const operationName of manifest.requiredOperationConstants) { + if (!generatedText.includes(`export const ${operationName} = {`)) { + violations.push(`${manifest.generatedTypesPath} is missing operation constant ${operationName}.`); + } + } + + for (const evidenceLabel of manifest.requiredEvidenceLabels) { + if (!schemaText.includes(evidenceLabel) || !generatedText.includes(evidenceLabel)) { + violations.push(`Evidence label ${evidenceLabel} must exist in schema and generated types.`); + } + } + + return { + manifest, + violations, + }; +} + +function isMainModule(): boolean { + const invokedPath = process.argv[1]; + return invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).href; +} + +if (isMainModule()) { + const result = checkStructuralHistorySchemaArtifacts(); + if (result.violations.length > 0) { + for (const violation of result.violations) { + console.error(violation); + } + process.exitCode = 1; + } else { + console.log( + [ + "structural history schema artifacts verified", + `schema=${result.manifest.schemaPath}`, + `generated=${result.manifest.generatedTypesPath}`, + `wesley=${result.manifest.wesleyCliVersion}`, + `l1=${result.manifest.wesleyL1RegistryHash}`, + ].join(" "), + ); + } +} diff --git a/src/generated/graft-structural-history.ts b/src/generated/graft-structural-history.ts new file mode 100644 index 00000000..f0e368ce --- /dev/null +++ b/src/generated/graft-structural-history.ts @@ -0,0 +1,291 @@ +/* @generated by Wesley. Do not edit. */ + +export interface GitWarpImportBatch { + importBatchId: string; + repositoryId: string; + sourceRef: string; + importedBasisId: string; + parity: MigrationParityStatus; + importedReadingCount: number; + summary: string; +} + +export interface GraftStructuralHistoryInvariants { + _placeholder: boolean | null; +} + +export type Hash = unknown; + +export type Json = unknown; + +export type MigrationParityStatus = "NOT_CHECKED" | "MATCHED" | "MISMATCHED" | "PARTIAL"; + +export type SourceSpanEncoding = "UTF8_BYTE_RANGE" | "UTF16_CODE_UNIT_RANGE" | "LINE_COLUMN"; + +export interface StructuralBasis { + basisId: string; + repositoryId: string; + basisKind: StructuralBasisKind; + commitId: string | null; + refName: string | null; + echoHeadId: string | null; + importBatchId: string | null; + summary: string; +} + +export type StructuralBasisKind = "GIT_COMMIT" | "GIT_REF" | "ECHO_HEAD" | "LIVE_FRONTIER" | "IMPORT_BATCH"; + +export type StructuralEvidenceKind = "ECHO_NATIVE" | "GIT_WARP_IMPORTED" | "FALLBACK_TRANSLATED"; + +export interface StructuralFileVersion { + fileVersionId: string; + repositoryId: string; + basisId: string; + path: string; + contentDigest: Hash; + language: string | null; + summary: string; +} + +export interface StructuralParserRun { + parserRunId: string; + fileVersionId: string; + parserName: string; + parserVersion: string | null; + diagnosticCount: number; + summary: string; +} + +export interface StructuralReading { + readingId: string; + repositoryId: string; + basisId: string; + evidenceId: string; + readingKind: StructuralReadingKind; + freshness: StructuralReadingFreshness; + residualPosture: StructuralReadingResidualPosture; + payloadDigest: Hash; + payloadJson: Json | null; + summary: string; +} + +export interface StructuralReadingEvidence { + evidenceId: string; + evidenceKind: StructuralEvidenceKind; + substrate: StructuralSubstrateKind; + basisId: string; + sourceRef: string | null; + migrationBatchId: string | null; + nativeContinuumWitness: boolean; + parity: MigrationParityStatus; + summary: string; +} + +export type StructuralReadingFreshness = "CURRENT" | "STALE" | "INCOMPARABLE" | "UNKNOWN" | "LIVE_FRONTIER"; + +export type StructuralReadingKind = "SYMBOL_REFERENCE_COUNT" | "DEAD_SYMBOLS" | "SYMBOL_HISTORY" | "REFACTOR_DIFFICULTY" | "STRUCTURAL_LOG" | "STRUCTURAL_CHURN" | "PRECISION_CODE_LOOKUP"; + +export type StructuralReadingResidualPosture = "COMPLETE" | "PARTIAL" | "PLURAL" | "BUDGET_LIMITED" | "RIGHTS_LIMITED" | "UNAVAILABLE" | "OBSTRUCTED" | "DEGRADED"; + +export interface StructuralRepository { + repositoryId: string; + rootKey: string; + defaultBranch: string | null; + summary: string; +} + +export interface StructuralSourceSpan { + spanId: string; + fileVersionId: string; + encoding: SourceSpanEncoding; + startOffset: number; + endOffset: number; + startLine: number | null; + endLine: number | null; + summary: string; +} + +export type StructuralSubstrateKind = "ECHO" | "GIT_WARP"; + +export interface StructuralSymbol { + symbolId: string; + repositoryId: string; + basisId: string; + fileVersionId: string; + parserRunId: string | null; + name: string; + kind: string; + visibility: SymbolVisibility; + declarationSpanId: string | null; + exported: boolean; + summary: string; +} + +export interface StructuralSymbolRelation { + relationId: string; + repositoryId: string; + basisId: string; + relationKind: SymbolRelationKind; + fromSymbolId: string | null; + toSymbolId: string | null; + fileVersionId: string | null; + spanId: string | null; + summary: string; +} + +export type SymbolRelationKind = "IMPORTS" | "EXPORTS" | "REFERENCES" | "CALLS" | "DEFINES" | "REMOVES"; + +export type SymbolVisibility = "LOCAL" | "EXPORTED" | "UNKNOWN"; + +export interface QueryStructuralRepositoriesRequest { + repositoryId?: string | null; +} + +export type QueryStructuralRepositoriesResponse = StructuralRepository[]; + +export const queryStructuralRepositoriesOperation = { + operationType: "QUERY", + fieldName: "structuralRepositories", + directives: {"wes_op":{"name":"structuralRepositories","readonly":true},"wes_footprint":{"reads":["StructuralRepository"]}}, +} as const; + +export type QueryStructuralRepositoriesOperation = { + request: QueryStructuralRepositoriesRequest; + response: QueryStructuralRepositoriesResponse; + metadata: typeof queryStructuralRepositoriesOperation; +}; + +export interface QueryStructuralBasesRequest { + repositoryId: string; + basisKind?: StructuralBasisKind | null; +} + +export type QueryStructuralBasesResponse = StructuralBasis[]; + +export const queryStructuralBasesOperation = { + operationType: "QUERY", + fieldName: "structuralBases", + directives: {"wes_op":{"name":"structuralBases","readonly":true},"wes_footprint":{"reads":["StructuralBasis"]}}, +} as const; + +export type QueryStructuralBasesOperation = { + request: QueryStructuralBasesRequest; + response: QueryStructuralBasesResponse; + metadata: typeof queryStructuralBasesOperation; +}; + +export interface QueryStructuralFileVersionsRequest { + basisId: string; + path?: string | null; +} + +export type QueryStructuralFileVersionsResponse = StructuralFileVersion[]; + +export const queryStructuralFileVersionsOperation = { + operationType: "QUERY", + fieldName: "structuralFileVersions", + directives: {"wes_op":{"name":"structuralFileVersions","readonly":true},"wes_footprint":{"reads":["StructuralFileVersion"]}}, +} as const; + +export type QueryStructuralFileVersionsOperation = { + request: QueryStructuralFileVersionsRequest; + response: QueryStructuralFileVersionsResponse; + metadata: typeof queryStructuralFileVersionsOperation; +}; + +export interface QueryStructuralSymbolsRequest { + basisId: string; + fileVersionId?: string | null; + name?: string | null; +} + +export type QueryStructuralSymbolsResponse = StructuralSymbol[]; + +export const queryStructuralSymbolsOperation = { + operationType: "QUERY", + fieldName: "structuralSymbols", + directives: {"wes_op":{"name":"structuralSymbols","readonly":true},"wes_footprint":{"reads":["StructuralSymbol"]}}, +} as const; + +export type QueryStructuralSymbolsOperation = { + request: QueryStructuralSymbolsRequest; + response: QueryStructuralSymbolsResponse; + metadata: typeof queryStructuralSymbolsOperation; +}; + +export interface QueryStructuralSymbolRelationsRequest { + basisId: string; + symbolId?: string | null; + relationKind?: SymbolRelationKind | null; +} + +export type QueryStructuralSymbolRelationsResponse = StructuralSymbolRelation[]; + +export const queryStructuralSymbolRelationsOperation = { + operationType: "QUERY", + fieldName: "structuralSymbolRelations", + directives: {"wes_op":{"name":"structuralSymbolRelations","readonly":true},"wes_footprint":{"reads":["StructuralSymbolRelation"]}}, +} as const; + +export type QueryStructuralSymbolRelationsOperation = { + request: QueryStructuralSymbolRelationsRequest; + response: QueryStructuralSymbolRelationsResponse; + metadata: typeof queryStructuralSymbolRelationsOperation; +}; + +export interface QueryStructuralReadingsRequest { + basisId: string; + readingKind?: StructuralReadingKind | null; +} + +export type QueryStructuralReadingsResponse = StructuralReading[]; + +export const queryStructuralReadingsOperation = { + operationType: "QUERY", + fieldName: "structuralReadings", + directives: {"wes_op":{"name":"structuralReadings","readonly":true},"wes_footprint":{"reads":["StructuralReading","StructuralReadingEvidence"]}}, +} as const; + +export type QueryStructuralReadingsOperation = { + request: QueryStructuralReadingsRequest; + response: QueryStructuralReadingsResponse; + metadata: typeof queryStructuralReadingsOperation; +}; + +export interface QueryStructuralReadingEvidenceRequest { + evidenceId?: string | null; + basisId?: string | null; +} + +export type QueryStructuralReadingEvidenceResponse = StructuralReadingEvidence[]; + +export const queryStructuralReadingEvidenceOperation = { + operationType: "QUERY", + fieldName: "structuralReadingEvidence", + directives: {"wes_op":{"name":"structuralReadingEvidence","readonly":true},"wes_footprint":{"reads":["StructuralReadingEvidence"]}}, +} as const; + +export type QueryStructuralReadingEvidenceOperation = { + request: QueryStructuralReadingEvidenceRequest; + response: QueryStructuralReadingEvidenceResponse; + metadata: typeof queryStructuralReadingEvidenceOperation; +}; + +export interface QueryGitWarpImportBatchesRequest { + repositoryId: string; + parity?: MigrationParityStatus | null; +} + +export type QueryGitWarpImportBatchesResponse = GitWarpImportBatch[]; + +export const queryGitWarpImportBatchesOperation = { + operationType: "QUERY", + fieldName: "gitWarpImportBatches", + directives: {"wes_op":{"name":"gitWarpImportBatches","readonly":true},"wes_footprint":{"reads":["GitWarpImportBatch"]}}, +} as const; + +export type QueryGitWarpImportBatchesOperation = { + request: QueryGitWarpImportBatchesRequest; + response: QueryGitWarpImportBatchesResponse; + metadata: typeof queryGitWarpImportBatchesOperation; +}; diff --git a/test/unit/contracts/graft-structural-history-schema.test.ts b/test/unit/contracts/graft-structural-history-schema.test.ts new file mode 100644 index 00000000..f3ee2fca --- /dev/null +++ b/test/unit/contracts/graft-structural-history-schema.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + checkStructuralHistorySchemaArtifacts, + readStructuralHistorySchemaManifest, +} from "../../../scripts/check-structural-history-schema-artifacts.js"; +import { + queryGitWarpImportBatchesOperation, + queryStructuralReadingEvidenceOperation, + queryStructuralReadingsOperation, + type StructuralEvidenceKind, + type StructuralReading, + type StructuralReadingEvidence, +} from "../../../src/generated/graft-structural-history.js"; + +describe("Graft structural history schema authority", () => { + it("keeps the GraphQL schema and Wesley-generated TypeScript artifact in lockstep", () => { + expect(checkStructuralHistorySchemaArtifacts().violations).toEqual([]); + }); + + it("names the evidence states needed for Echo migration without changing Echo or Wesley", () => { + const manifest = readStructuralHistorySchemaManifest(); + + expect(manifest.requiredEvidenceLabels).toEqual([ + "ECHO_NATIVE", + "GIT_WARP_IMPORTED", + "FALLBACK_TRANSLATED", + ]); + expect(manifest.wesleyCliVersion).toBe("0.0.4"); + }); + + it("exposes structural reading and import operations from the generated contract", () => { + expect(queryStructuralReadingsOperation.directives.wes_footprint.reads).toEqual([ + "StructuralReading", + "StructuralReadingEvidence", + ]); + expect(queryStructuralReadingEvidenceOperation.directives.wes_footprint.reads).toEqual([ + "StructuralReadingEvidence", + ]); + expect(queryGitWarpImportBatchesOperation.directives.wes_footprint.reads).toEqual([ + "GitWarpImportBatch", + ]); + }); + + it("keeps generated structural reading values behaviorally typed", () => { + const evidenceKind: StructuralEvidenceKind = "FALLBACK_TRANSLATED"; + const evidence: StructuralReadingEvidence = { + evidenceId: "evidence:1", + evidenceKind, + substrate: "GIT_WARP", + basisId: "basis:head", + sourceRef: "HEAD", + migrationBatchId: null, + nativeContinuumWitness: false, + parity: "NOT_CHECKED", + summary: "fallback git-warp compatibility evidence", + }; + const reading: StructuralReading = { + readingId: "reading:1", + repositoryId: "repo:fixture", + basisId: evidence.basisId, + evidenceId: evidence.evidenceId, + readingKind: "SYMBOL_REFERENCE_COUNT", + freshness: "CURRENT", + residualPosture: "COMPLETE", + payloadDigest: "hash:payload", + payloadJson: { referenceCount: 2 }, + summary: "symbol reference count", + }; + + expect(evidence.nativeContinuumWitness).toBe(false); + expect(reading.evidenceId).toBe(evidence.evidenceId); + expect(reading.readingKind).toBe("SYMBOL_REFERENCE_COUNT"); + }); +}); From 6b70305ff466f921e7919185ed12d6ef3e9304a7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 25 May 2026 19:34:03 -0700 Subject: [PATCH 02/15] docs: add opened workspace paths proposal --- docs/design/SURFACE_opened-workspace-paths.md | 346 ++++++++++++++++ .../SURFACE_opened-workspace-paths.md | 90 +++++ docs/method/backlog/dependency-dag.dot | 3 +- docs/method/backlog/dependency-dag.svg | 378 +++++++++--------- 4 files changed, 630 insertions(+), 187 deletions(-) create mode 100644 docs/design/SURFACE_opened-workspace-paths.md create mode 100644 docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md diff --git a/docs/design/SURFACE_opened-workspace-paths.md b/docs/design/SURFACE_opened-workspace-paths.md new file mode 100644 index 00000000..4eb14b40 --- /dev/null +++ b/docs/design/SURFACE_opened-workspace-paths.md @@ -0,0 +1,346 @@ +--- +title: "Opened workspace paths" +legend: "SURFACE" +source_backlog: "docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md" +status: proposal +--- + +# Opened workspace paths + +Source backlog item: +`docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md` + +Legend: SURFACE + +## Sponsors + +- Human: James +- Agent: Codex + +## Hill + +An agent can start Graft MCP from one repository, later open another git +worktree path in the same MCP service, and make that path the active +workspace for existing repo-scoped tools without restarting the server +or adding per-tool `cwd` routing. + +The model is: + +- many opened workspaces +- one active workspace per MCP session +- explicit activation when the active workspace changes + +## Playback Questions + +### Human + +- [ ] Can I tell an agent to work in another repo and have it open that + path in the existing Graft MCP session? +- [ ] Can I inspect which paths are opened and which one is active? +- [ ] Does switching repos feel like opening another workspace, not + editing low-level daemon authorization state by hand? + +### Agent + +- [ ] Can a repo-local MCP server open a second git worktree and run + `safe_read`, `graft_map`, and `code_find` against it without process + restart? +- [ ] Does activation reset session-local cache, budget, saved state, + metrics, and repo-state tracking instead of bleeding state across + worktrees? +- [ ] Does daemon mode reuse the existing authorization registry and + capability profile rather than creating a parallel allowlist? +- [ ] Do repo-scoped tools keep their current repo-relative schemas? + +## Accessibility and Assistive Reading + +- Linear truth / reduced-complexity posture: one list of opened + workspaces, one active workspace, and explicit activation events. +- Non-visual or alternate-reading expectations: all state is returned + as structured JSON through MCP surfaces; no operator should need to + infer the active workspace from logs. + +## Localization and Directionality + +- Locale / wording / formatting assumptions: no locale-sensitive + behavior is in scope. +- Logical direction / layout assumptions: none beyond canonical + absolute paths and stable JSON field names. + +## Agent Inspectability and Explainability + +- What must be explicit and deterministic for agents: opened workspace + records, active workspace identity, resolved repo/worktree ids, + capability profile, whether activation created a fresh session slice, + and why open/activation failed. +- What must be attributable, evidenced, or governed: workspace opening + and activation decisions, server-resolved git identity, and daemon + authorization/capability posture. + +## Non-goals + +- [ ] Do not add `cwd` or workspace routing envelopes to every + repo-scoped MCP tool. +- [ ] Do not make unauthorized repos discoverable through daemon + filtering. +- [ ] Do not expose raw receipts, cache content, saved state, runtime + log payloads, or shell output through opened-workspace inspection. +- [ ] Do not make repo-local opened workspaces persistent global daemon + state. +- [ ] Do not change Git history or worktree contents as part of opening + a workspace. + +## Current Repo Truth + +The substrate already exists in pieces: + +- `WorkspaceRouter` owns one active binding and has `bind` / + `rebind`, fresh session-local slices, workspace status, and + server-side identity resolution. +- `resolveWorkspaceRequest` resolves canonical repo/worktree identity + from git and canonicalizes path aliases. +- `DaemonControlPlane` persists daemon-authorized workspaces keyed by + resolved `worktreeId`. +- `workspace_authorize`, `workspace_bind`, `workspace_rebind`, + `workspace_authorizations`, and `workspace_revoke` exist as daemon + control-plane tools. +- `daemon_repos` already projects authorized repos without exposing + session-local receipts, cache, saved state, or shell output. + +The missing surface is ergonomic and repo-local: a server started in +repo A cannot explicitly open repo B and make repo B active without +starting another MCP server or using daemon-specific control-plane +vocabulary. + +## Product Model + +Use opened workspaces plus one active workspace. + +Opened workspace: + +- a resolved git worktree path Graft can use in this MCP process or + daemon session +- identified by `repoId`, `worktreeId`, `worktreeRoot`, and + `gitCommonDir` +- carries a capability profile +- has a source such as `startup`, `session_opened`, or + `daemon_authorized` + +Active workspace: + +- the single workspace against which current repo-scoped tools operate +- changes only through explicit open/activation flow +- owns the current session-local slice + +This preserves the existing repo-scoped tool contract: paths passed to +tools such as `safe_read`, `graft_map`, and `code_find` remain +repo-relative to the active workspace. + +## Proposed MCP Surface + +### `workspace_open` + +Input: + +```json +{ + "cwd": "/path/to/repo-or-subdir", + "activate": true, + "runCapture": false +} +``` + +Rules: + +- `cwd` is required and is the main path hint. +- `activate` defaults to `true`. +- `runCapture` is daemon capability posture and remains default-denied + unless explicitly enabled. +- Client-supplied repo/worktree ids are not accepted as authority. +- Non-git paths fail with the existing `NOT_A_GIT_REPO` style error. + +Result: + +- `ok` +- `changed` +- opened workspace record +- active workspace status +- `freshSessionSlice` +- optional `errorCode` / `error` + +Daemon mode implementation: + +- resolve `cwd` +- call or share the `DaemonControlPlane.authorizeWorkspace` path +- activate through existing bind/rebind semantics + +Repo-local mode implementation: + +- resolve `cwd` +- store the opened record in memory only +- activate through existing bind/rebind semantics + +### `workspace_opened` + +Returns: + +- `workspaces` +- `activeWorktreeId` +- `sessionMode` + +Each workspace record should include: + +- `repoId` +- `worktreeId` +- `worktreeRoot` +- `gitCommonDir` +- `source` +- `active` +- `capabilityProfile` +- `openedAt` +- `lastActivatedAt` +- daemon-only active-session counts where available + +Daemon mode may project the existing authorization registry plus active +binding state. Repo-local mode should project only the startup workspace +and paths opened by this process. + +### `workspace_activate` + +Potential follow-up surface. + +Input: + +```json +{ + "cwd": "/path/to/already-opened-repo" +} +``` + +or: + +```json +{ + "worktreeId": "worktree:..." +} +``` + +Rules: + +- target must already be opened or authorized +- activation starts a fresh session-local slice when changing + worktrees +- result mirrors `workspace_open` activation fields + +## Semantics By Runtime + +### Repo-local MCP + +`graft serve` remains simple: + +- startup repo is opened and active +- `workspace_open` can add another git worktree to the process-local + opened set +- opening another path does not persist a global authorization registry +- existing tools operate against whichever workspace is active + +This solves the immediate user story: an agent can start from repo A, +then later open repo B in the same Graft MCP service. + +### Daemon-backed MCP + +Daemon mode keeps its current trust model: + +- opened workspaces are backed by the daemon authorization registry +- authorization persists under the daemon graft directory +- `workspace_open` is a friendly wrapper over + `workspace_authorize` plus `workspace_bind` / `workspace_rebind` +- `run_capture` remains denied unless the authorized workspace enables + it + +The low-level daemon tools remain available for operator control-plane +work. Agents should normally use `workspace_open` when they only need +to open and optionally activate a path. + +## Security And Privacy Boundary + +- Resolve all workspace identity server-side from git. +- Canonicalize paths through the existing workspace resolver. +- Do not use client-supplied ids as authority. +- Do not expose unauthorized daemon repos through filters or error + detail. +- Do not expose another session's raw receipts, cache content, saved + state, runtime-log payloads, or shell output. +- Keep repo-local opened workspaces process-local. +- Keep daemon authorization daemon-owned. + +## Implementation Plan + +1. Add opened-workspace model types: + - `OpenedWorkspaceRecord` + - `WorkspaceOpenRequest` + - `WorkspaceOpenResult` + - `WorkspaceOpenedResult` + - optionally `WorkspaceActivateRequest` +2. Extend `WorkspaceRouter`: + - seed the opened set with the repo-local startup binding + - add `openWorkspace` + - add `listOpenedWorkspaces` + - optionally add `activateWorkspace` + - reuse existing bind/rebind internals so fresh-slice behavior stays + centralized +3. Extend daemon control-plane projection: + - reuse authorized workspace records as opened records in daemon + mode + - preserve capability profile and active-session counts +4. Add MCP tools: + - `workspace_open` + - `workspace_opened` + - optionally `workspace_activate` +5. Register `workspace_opened` and `workspace_open` in both repo-local + and daemon tool registries. +6. Add output schemas and capability metadata. +7. Update MCP/setup docs to position `workspace_open` as the normal + agent-facing path and `workspace_authorize` / `workspace_bind` as + lower-level daemon control-plane tools. + +## Test Strategy + +Focused tests: + +- repo-local server starts in repo A, opens repo B, and `safe_read` + reads from repo B after activation +- `workspace_open({ activate: false })` adds repo B without changing + the active workspace +- activation resets `state_save` / `state_load` +- two worktrees of the same repo share `repoId` but have distinct + `worktreeId` +- a non-git path returns `NOT_A_GIT_REPO` +- path aliases resolve to one canonical worktree identity +- daemon `workspace_open` updates the existing authorization registry +- `workspace_opened` output validates against the MCP output schema + +Playback tests should cover the human story end to end: start Graft +from one repo, open another repo path, inspect the opened set, and use +an existing repo-scoped tool against the new active workspace. + +## Suggested First Slice + +Ship the narrow version first: + +- `workspace_open` +- `workspace_opened` +- repo-local availability for `workspace_status` +- `workspace_open({ activate: true })` as the common switch path + +Defer standalone `workspace_activate` until there is evidence that +agents need a separate "open but do not activate, then activate later" +workflow often enough to justify another tool. + +## Open Questions + +- Should `activate` default to `true`? Recommendation: yes. +- Should the list tool be named `workspace_opened` or + `workspace_list`? Recommendation: `workspace_opened`. +- Should repo-local opened workspaces ever persist beyond the process? + Recommendation: no; persistence belongs to daemon authorization. diff --git a/docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md b/docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md new file mode 100644 index 00000000..4dff2b2b --- /dev/null +++ b/docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md @@ -0,0 +1,90 @@ +--- +title: "Opened workspace paths" +feature: mcp +kind: trunk +legend: SURFACE +lane: cool-ideas +effort: M +requirements: + - "Workspace bind and routing surface (shipped)" + - "Daemon control plane authorization registry (shipped)" + - "Daemon-backed stdio MCP runtime (shipped)" +acceptance_criteria: + - "A repo-local MCP session can open a second git worktree path without restarting the server" + - "The newly opened path can become the active workspace for existing repo-scoped tools" + - "Repo-scoped tools keep repo-relative schemas and do not each grow their own cwd parameter" + - "Activation starts a fresh session-local slice so cache, budget, saved state, and repo-state tracking do not bleed across worktrees" + - "Daemon mode reuses the existing authorization registry and capability profile instead of inventing a second permission model" + - "Opened workspaces are inspectable through a structured MCP surface" +--- + +# Opened workspace paths + +Agents sometimes start the Graft MCP service from one repository, then +later need to work in another repository path. The agent can edit that +other repo through the host environment, but the original Graft MCP +server remains pinned to the startup repo. The useful product behavior +is to let the session explicitly open another git worktree path and +optionally make it active. + +This should be modeled as opened workspaces plus one active workspace: + +- an opened workspace is a git worktree path Graft has resolved and is + allowed to use in this MCP process or daemon session +- the active workspace is the single workspace that repo-scoped MCP + tools currently operate against +- activation switches the active workspace through the same fresh-slice + boundary as `workspace_rebind` + +Do not add `cwd` to every repo-scoped tool. That would spread routing, +authorization, cache, receipt, budget, and causal-workspace semantics +across the whole tool surface. + +## Proposed surface + +- `workspace_open` + - input: `cwd`, optional `activate`, optional daemon capability + posture such as `runCapture` + - resolves git identity server-side + - adds the worktree to the opened set + - activates it by default +- `workspace_opened` + - lists opened/authorized workspaces and marks the active one +- `workspace_activate` + - switches to an already-opened workspace by `cwd` or `worktreeId` + - starts a fresh session-local slice + +The first implementation slice can be narrower: ship `workspace_open` +and `workspace_opened`, with `workspace_open({ activate: true })` as +the common switch path. A standalone `workspace_activate` can follow if +the split proves useful in practice. + +## Semantics + +Repo-local mode: + +- seed the opened set with the startup repository +- `workspace_open` adds another resolved git worktree to the current + process only +- opened workspaces are not persisted globally + +Daemon mode: + +- `workspace_open` is an ergonomic wrapper around the existing + `workspace_authorize` plus `workspace_bind` or `workspace_rebind` + flow +- authorization remains persisted under the daemon graft directory +- capability posture remains attached to the authorized workspace + +Both modes: + +- server-side git resolution is authoritative +- symlink and path alias canonicalization follows existing workspace + resolution +- activation starts a fresh session-local slice when the active + worktree changes +- existing repo-scoped tools continue to use the active workspace + +## Related design packet + +- `docs/design/SURFACE_opened-workspace-paths.md` diff --git a/docs/method/backlog/dependency-dag.dot b/docs/method/backlog/dependency-dag.dot index 1aa85acf..3e6a8cbf 100644 --- a/docs/method/backlog/dependency-dag.dot +++ b/docs/method/backlog/dependency-dag.dot @@ -55,7 +55,7 @@ digraph backlog { } subgraph cluster_cool_ideas { - label="Cool Ideas (74)" labeljust=l fontsize=9 fontcolor="#555555" + label="Cool Ideas (75)" labeljust=l fontsize=9 fontcolor="#555555" style=rounded color="#cccccc" bgcolor="#fafafa" cool_ideas_bounded_neighborhood_for_references [label="bounded-neighborhood-for-references - S" fillcolor="#D4E8F7" penwidth=1] cool_ideas_CI_001_causal_collapse_visualizer [label="CI-001-causal-collapse-visualizer - L" fillcolor="#D4E8F7" penwidth=1] @@ -95,6 +95,7 @@ digraph backlog { 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_opened_workspace_paths [label="SURFACE-opened-workspace-paths - M" fillcolor="#D4E8F7" penwidth=1] cool_ideas_SURFACE_terminal_activity_browser_tui [label="SURFACE-terminal-activity-browser-tui - L" fillcolor="#D4E8F7" penwidth=1] cool_ideas_traverse_plus_query_hydration_helper [label="traverse-plus-query-hydration-helper - S" fillcolor="#D4E8F7" penwidth=1] cool_ideas_WARP_adaptive_projection_selection [label="WARP-adaptive-projection-selection - L" fillcolor="#D4E8F7" penwidth=1] diff --git a/docs/method/backlog/dependency-dag.svg b/docs/method/backlog/dependency-dag.svg index 0c47802c..094f5296 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 (2) + +bad-code (2) cluster_cool_ideas - -Cool Ideas (74) + +Cool Ideas (75) 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,14 +223,14 @@ 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_wesley_cli_not_hermetic_in_graft_ci - -CLEAN-wesley-cli-not-hermetic-in-graft-ci - M + +CLEAN-wesley-cli-not-hermetic-in-graft-ci - M @@ -241,8 +241,8 @@ cool_ideas_CI_001_causal_collapse_visualizer - -CI-001-causal-collapse-visualizer - L + +CI-001-causal-collapse-visualizer - L @@ -337,8 +337,8 @@ cool_ideas_CORE_migrate_to_slice_first_reads - -CORE-migrate-to-slice-first-reads + +CORE-migrate-to-slice-first-reads @@ -395,7 +395,7 @@ monitor-tick-ceiling-tracking - S - + cool_ideas_WARP_background_indexing WARP-background-indexing - M @@ -480,345 +480,351 @@ SURFACE-offer-rename-refactor - L - + +cool_ideas_SURFACE_opened_workspace_paths + +SURFACE-opened-workspace-paths - 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 From 82602442e48c981991b3329564f3228bf4eae1e4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 25 May 2026 19:44:38 -0700 Subject: [PATCH 03/15] test: specify opened workspace path behavior --- test/unit/mcp/opened-workspaces.test.ts | 172 ++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 test/unit/mcp/opened-workspaces.test.ts diff --git a/test/unit/mcp/opened-workspaces.test.ts b/test/unit/mcp/opened-workspaces.test.ts new file mode 100644 index 00000000..ec0cbe22 --- /dev/null +++ b/test/unit/mcp/opened-workspaces.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import { createServerInRepo, parse } from "../../helpers/mcp.js"; +import { cleanupTestRepo, createCommittedTestRepo } from "../../helpers/git.js"; + +const cleanups: (() => void)[] = []; + +afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()!(); + } +}); + +function createRepo(prefix: string, content: string): string { + const repoDir = createCommittedTestRepo(prefix, { + "app.ts": content, + }); + cleanups.push(() => { + cleanupTestRepo(repoDir); + }); + return repoDir; +} + +interface ListedOpenedWorkspace { + readonly worktreeRoot: string; + readonly active: boolean; + readonly source: "startup" | "session_opened" | "daemon_authorized"; +} + +interface WorkspaceListOpenedPayload { + readonly sessionMode: "repo_local" | "daemon"; + readonly activeWorktreeId: string | null; + readonly workspaces: readonly ListedOpenedWorkspace[]; +} + +async function listOpened(server: ReturnType): Promise { + return parse(await server.callTool("workspace_list_opened", {})) as unknown as WorkspaceListOpenedPayload; +} + +describe("mcp: opened workspace paths", () => { + it("records an opened workspace in the opened-workspace list", async () => { + const initialRepo = createRepo("graft-opened-initial-", "export const repo = 'initial';\n"); + const nextRepo = createRepo("graft-opened-next-", "export const repo = 'next';\n"); + const server = createServerInRepo(initialRepo); + + const opened = parse(await server.callTool("workspace_open", { + cwd: nextRepo, + activate: false, + })); + expect(opened).toEqual(expect.objectContaining({ + ok: true, + changed: true, + freshSessionSlice: false, + })); + + const listed = await listOpened(server); + expect(listed.sessionMode).toBe("repo_local"); + expect(listed.workspaces).toEqual(expect.arrayContaining([ + expect.objectContaining({ + worktreeRoot: fs.realpathSync(initialRepo), + active: true, + source: "startup", + }), + expect.objectContaining({ + worktreeRoot: fs.realpathSync(nextRepo), + active: false, + source: "session_opened", + }), + ])); + }); + + it("opens with activate=true and makes the opened workspace active", async () => { + const initialRepo = createRepo("graft-opened-activate-initial-", "export const repo = 'initial';\n"); + const nextRepo = createRepo("graft-opened-activate-next-", "export const repo = 'next';\n"); + const server = createServerInRepo(initialRepo); + + const opened = parse(await server.callTool("workspace_open", { + cwd: nextRepo, + activate: true, + })); + expect(opened).toEqual(expect.objectContaining({ + ok: true, + freshSessionSlice: true, + worktreeRoot: fs.realpathSync(nextRepo), + })); + + const read = parse(await server.callTool("safe_read", { path: "app.ts" })); + expect(read["content"]).toBe("export const repo = 'next';\n"); + + const listed = await listOpened(server); + expect(listed.workspaces).toEqual(expect.arrayContaining([ + expect.objectContaining({ + worktreeRoot: fs.realpathSync(initialRepo), + active: false, + }), + expect.objectContaining({ + worktreeRoot: fs.realpathSync(nextRepo), + active: true, + }), + ])); + }); + + it("opens with activate omitted and treats activation as the default", async () => { + const initialRepo = createRepo("graft-opened-default-initial-", "export const repo = 'initial';\n"); + const nextRepo = createRepo("graft-opened-default-next-", "export const repo = 'next';\n"); + const server = createServerInRepo(initialRepo); + + const opened = parse(await server.callTool("workspace_open", { cwd: nextRepo })); + expect(opened).toEqual(expect.objectContaining({ + ok: true, + freshSessionSlice: true, + worktreeRoot: fs.realpathSync(nextRepo), + })); + + const read = parse(await server.callTool("safe_read", { path: "app.ts" })); + expect(read["content"]).toBe("export const repo = 'next';\n"); + }); + + it("opens with activate=false without changing the active workspace", async () => { + const initialRepo = createRepo("graft-opened-inactive-initial-", "export const repo = 'initial';\n"); + const nextRepo = createRepo("graft-opened-inactive-next-", "export const repo = 'next';\n"); + const server = createServerInRepo(initialRepo); + + const opened = parse(await server.callTool("workspace_open", { + cwd: nextRepo, + activate: false, + })); + expect(opened).toEqual(expect.objectContaining({ + ok: true, + freshSessionSlice: false, + worktreeRoot: fs.realpathSync(initialRepo), + })); + + const read = parse(await server.callTool("safe_read", { path: "app.ts" })); + expect(read["content"]).toBe("export const repo = 'initial';\n"); + + const listed = await listOpened(server); + expect(listed.workspaces).toEqual(expect.arrayContaining([ + expect.objectContaining({ + worktreeRoot: fs.realpathSync(nextRepo), + active: false, + }), + ])); + }); + + it("keeps repo-local opened workspaces process-local across server sessions", async () => { + const initialRepo = createRepo("graft-opened-local-initial-", "export const repo = 'initial';\n"); + const nextRepo = createRepo("graft-opened-local-next-", "export const repo = 'next';\n"); + const firstServer = createServerInRepo(initialRepo); + + await firstServer.callTool("workspace_open", { + cwd: nextRepo, + activate: false, + }); + const firstList = await listOpened(firstServer); + expect(firstList.workspaces).toEqual(expect.arrayContaining([ + expect.objectContaining({ + worktreeRoot: fs.realpathSync(nextRepo), + }), + ])); + + const secondServer = createServerInRepo(initialRepo); + const secondList = await listOpened(secondServer); + expect(secondList.workspaces).toEqual([ + expect.objectContaining({ + worktreeRoot: fs.realpathSync(initialRepo), + active: true, + source: "startup", + }), + ]); + }); +}); From 0df836347250a44bf03aaba7d1a817d96037b06a Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 25 May 2026 23:38:25 -0700 Subject: [PATCH 04/15] feat: add opened workspace path tools --- docs/design/SURFACE_opened-workspace-paths.md | 23 +-- .../SURFACE_opened-workspace-paths.md | 4 +- docs/three-surface-capability-matrix.md | 6 +- src/contracts/capabilities.ts | 14 ++ src/contracts/output-schema-fragments.ts | 30 +++ src/contracts/output-schema-mcp.ts | 4 + src/contracts/output-schemas.ts | 32 +++ src/mcp/burden.ts | 2 + src/mcp/context.ts | 5 + src/mcp/repo-tool-worker-context.ts | 6 + src/mcp/server-context.ts | 6 + src/mcp/server-tool-access.ts | 4 + src/mcp/tool-registry.ts | 4 + src/mcp/tools/workspace-list-opened.ts | 12 ++ src/mcp/tools/workspace-open.ts | 22 +++ src/mcp/workspace-router-model.ts | 32 +++ src/mcp/workspace-router.ts | 182 ++++++++++++++++++ test/unit/contracts/capabilities.test.ts | 4 +- test/unit/contracts/output-schemas.test.ts | 4 + test/unit/mcp/opened-workspaces.test.ts | 5 +- ...ability-baseline-and-parity-matrix.test.ts | 6 +- .../SURFACE_capability-matrix-truth.test.ts | 2 +- 22 files changed, 386 insertions(+), 23 deletions(-) create mode 100644 src/mcp/tools/workspace-list-opened.ts create mode 100644 src/mcp/tools/workspace-open.ts diff --git a/docs/design/SURFACE_opened-workspace-paths.md b/docs/design/SURFACE_opened-workspace-paths.md index 4eb14b40..3cb6b75f 100644 --- a/docs/design/SURFACE_opened-workspace-paths.md +++ b/docs/design/SURFACE_opened-workspace-paths.md @@ -180,7 +180,7 @@ Repo-local mode implementation: - store the opened record in memory only - activate through existing bind/rebind semantics -### `workspace_opened` +### `workspace_list_opened` Returns: @@ -295,9 +295,9 @@ to open and optionally activate a path. - preserve capability profile and active-session counts 4. Add MCP tools: - `workspace_open` - - `workspace_opened` + - `workspace_list_opened` - optionally `workspace_activate` -5. Register `workspace_opened` and `workspace_open` in both repo-local +5. Register `workspace_list_opened` and `workspace_open` in both repo-local and daemon tool registries. 6. Add output schemas and capability metadata. 7. Update MCP/setup docs to position `workspace_open` as the normal @@ -318,7 +318,7 @@ Focused tests: - a non-git path returns `NOT_A_GIT_REPO` - path aliases resolve to one canonical worktree identity - daemon `workspace_open` updates the existing authorization registry -- `workspace_opened` output validates against the MCP output schema +- `workspace_list_opened` output validates against the MCP output schema Playback tests should cover the human story end to end: start Graft from one repo, open another repo path, inspect the opened set, and use @@ -329,7 +329,7 @@ an existing repo-scoped tool against the new active workspace. Ship the narrow version first: - `workspace_open` -- `workspace_opened` +- `workspace_list_opened` - repo-local availability for `workspace_status` - `workspace_open({ activate: true })` as the common switch path @@ -337,10 +337,11 @@ Defer standalone `workspace_activate` until there is evidence that agents need a separate "open but do not activate, then activate later" workflow often enough to justify another tool. -## Open Questions +## Product Decisions -- Should `activate` default to `true`? Recommendation: yes. -- Should the list tool be named `workspace_opened` or - `workspace_list`? Recommendation: `workspace_opened`. -- Should repo-local opened workspaces ever persist beyond the process? - Recommendation: no; persistence belongs to daemon authorization. +- `activate` defaults to `true`. +- Should the list tool be named `workspace_opened`, + `workspace_list`, or `workspace_list_opened`? Decision: + `workspace_list_opened`. +- Repo-local opened workspaces stay process-local for the first cut; + persistence belongs to daemon authorization. diff --git a/docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md b/docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md index 4dff2b2b..b3e24ee5 100644 --- a/docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md +++ b/docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md @@ -48,14 +48,14 @@ across the whole tool surface. - resolves git identity server-side - adds the worktree to the opened set - activates it by default -- `workspace_opened` +- `workspace_list_opened` - lists opened/authorized workspaces and marks the active one - `workspace_activate` - switches to an already-opened workspace by `cwd` or `worktreeId` - starts a fresh session-local slice The first implementation slice can be narrower: ship `workspace_open` -and `workspace_opened`, with `workspace_open({ activate: true })` as +and `workspace_list_opened`, with `workspace_open({ activate: true })` as the common switch path. A standalone `workspace_activate` can follow if the split proves useful in practice. diff --git a/docs/three-surface-capability-matrix.md b/docs/three-surface-capability-matrix.md index 584b08a9..0a3077c6 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 -- `22` API + MCP capabilities +- `24` API + MCP capabilities - `1` API-only capability - `22` direct CLI/MCP peer capabilities - `1` composed CLI operator/lifecycle capability -- `22` intentionally API + MCP-only agent/control-plane capabilities +- `24` intentionally API + MCP-only agent/control-plane capabilities API exposure kinds: @@ -92,6 +92,8 @@ composing existing tools, it belongs in this matrix as | `workspace_authorize` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `workspace_authorize` | | `workspace_authorizations` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `workspace_authorizations` | | `workspace_revoke` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `workspace_revoke` | +| `workspace_open` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `workspace_open` | +| `workspace_list_opened` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `workspace_list_opened` | | `workspace_bind` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `workspace_bind` | | `workspace_status` | Yes | No | Yes | `tool_bridge` | `mcp_only` | `-` | `workspace_status` | | `activity_view` | Yes | Yes | Yes | `tool_bridge` | `peer` | `diag activity` | `activity_view` | diff --git a/src/contracts/capabilities.ts b/src/contracts/capabilities.ts index d715c368..f9f6161e 100644 --- a/src/contracts/capabilities.ts +++ b/src/contracts/capabilities.ts @@ -22,6 +22,8 @@ export const MCP_TOOL_NAMES = [ "workspace_authorize", "workspace_authorizations", "workspace_revoke", + "workspace_open", + "workspace_list_opened", "workspace_bind", "workspace_status", "activity_view", @@ -336,6 +338,18 @@ export const CAPABILITY_REGISTRY: readonly CapabilityDefinition[] = [ mcpTool: "workspace_revoke", cliMcpParity: "mcp_only", }), + defineCapability({ + id: "workspace_open", + description: "Open a git worktree path in this MCP session", + mcpTool: "workspace_open", + cliMcpParity: "mcp_only", + }), + defineCapability({ + id: "workspace_list_opened", + description: "List workspaces opened in this MCP session", + mcpTool: "workspace_list_opened", + cliMcpParity: "mcp_only", + }), defineCapability({ id: "workspace_bind", description: "Bind a daemon session to a workspace", diff --git a/src/contracts/output-schema-fragments.ts b/src/contracts/output-schema-fragments.ts index 49ce8ffb..c60eb071 100644 --- a/src/contracts/output-schema-fragments.ts +++ b/src/contracts/output-schema-fragments.ts @@ -647,6 +647,34 @@ export const workspaceRevokeSchema = z.object({ error: z.string().optional(), }).strict(); +const openedWorkspaceSchema = z.object({ + repoId: z.string(), + worktreeId: z.string(), + worktreeRoot: z.string(), + gitCommonDir: z.string(), + source: z.enum(["startup", "session_opened", "daemon_authorized"]), + active: z.boolean(), + capabilityProfile: workspaceCapabilityProfileSchema, + openedAt: z.string(), + lastActivatedAt: z.string().nullable(), + activeSessions: z.number().int().nonnegative().optional(), +}).strict(); + +export const workspaceOpenSchema = workspaceStatusSchema.extend({ + ok: z.boolean(), + changed: z.boolean(), + freshSessionSlice: z.boolean(), + openedWorkspace: openedWorkspaceSchema.optional(), + errorCode: z.string().optional(), + error: z.string().optional(), +}).strict(); + +export const workspaceListOpenedSchema = z.object({ + sessionMode: z.enum(["repo_local", "daemon"]), + activeWorktreeId: z.string().nullable(), + workspaces: z.array(openedWorkspaceSchema), +}).strict(); + export const daemonSessionSchema = z.object({ sessionId: z.string(), sessionMode: z.literal("daemon"), @@ -864,6 +892,8 @@ export const mcpFragmentSchemas = { causalAttachSchema, workspaceAuthorizeSchema, workspaceRevokeSchema, + workspaceOpenSchema, + workspaceListOpenedSchema, workspaceStatusSchema, workspaceActionSchema, workspaceOverlaySummarySchema, diff --git a/src/contracts/output-schema-mcp.ts b/src/contracts/output-schema-mcp.ts index 187fd1dc..b0e60f6d 100644 --- a/src/contracts/output-schema-mcp.ts +++ b/src/contracts/output-schema-mcp.ts @@ -39,6 +39,8 @@ const { causalAttachSchema, workspaceAuthorizeSchema, workspaceRevokeSchema, + workspaceOpenSchema, + workspaceListOpenedSchema, workspaceStatusSchema, workspaceActionSchema, workspaceOverlaySummarySchema, @@ -253,6 +255,8 @@ export const mcpOutputBodySchemas = { workspaces: z.array(authorizedWorkspaceSchema), }).strict(), workspace_revoke: workspaceRevokeSchema, + workspace_open: workspaceOpenSchema, + workspace_list_opened: workspaceListOpenedSchema, workspace_bind: workspaceActionSchema.extend({ action: z.literal("bind") }).strict(), workspace_status: workspaceStatusSchema, activity_view: activityViewSchema, diff --git a/src/contracts/output-schemas.ts b/src/contracts/output-schemas.ts index e2e1547f..957b40bd 100644 --- a/src/contracts/output-schemas.ts +++ b/src/contracts/output-schemas.ts @@ -750,6 +750,34 @@ const workspaceRevokeSchema = z.object({ error: z.string().optional(), }).strict(); +const openedWorkspaceSchema = z.object({ + repoId: z.string(), + worktreeId: z.string(), + worktreeRoot: z.string(), + gitCommonDir: z.string(), + source: z.enum(["startup", "session_opened", "daemon_authorized"]), + active: z.boolean(), + capabilityProfile: workspaceCapabilityProfileSchema, + openedAt: z.string(), + lastActivatedAt: z.string().nullable(), + activeSessions: z.number().int().nonnegative().optional(), +}).strict(); + +const workspaceOpenSchema = workspaceStatusSchema.extend({ + ok: z.boolean(), + changed: z.boolean(), + freshSessionSlice: z.boolean(), + openedWorkspace: openedWorkspaceSchema.optional(), + errorCode: z.string().optional(), + error: z.string().optional(), +}).strict(); + +const workspaceListOpenedSchema = z.object({ + sessionMode: z.enum(["repo_local", "daemon"]), + activeWorktreeId: z.string().nullable(), + workspaces: z.array(openedWorkspaceSchema), +}).strict(); + const daemonSessionSchema = z.object({ sessionId: z.string(), sessionMode: z.literal("daemon"), @@ -1092,6 +1120,8 @@ const mcpOutputBodySchemas: Record = { workspaces: z.array(authorizedWorkspaceSchema), }).strict(), workspace_revoke: workspaceRevokeSchema, + workspace_open: workspaceOpenSchema, + workspace_list_opened: workspaceListOpenedSchema, workspace_bind: workspaceActionSchema.extend({ action: z.literal("bind"), }).strict(), @@ -1392,6 +1422,8 @@ export const MCP_OUTPUT_SCHEMAS: Record = { mcpOutputBodySchemas.workspace_authorizations, ), workspace_revoke: withMcpCommon("workspace_revoke", mcpOutputBodySchemas.workspace_revoke), + workspace_open: withMcpCommon("workspace_open", mcpOutputBodySchemas.workspace_open), + workspace_list_opened: withMcpCommon("workspace_list_opened", mcpOutputBodySchemas.workspace_list_opened), workspace_bind: withMcpCommon("workspace_bind", mcpOutputBodySchemas.workspace_bind), workspace_status: withMcpCommon("workspace_status", mcpOutputBodySchemas.workspace_status), activity_view: withMcpCommon("activity_view", mcpOutputBodySchemas.activity_view), diff --git a/src/mcp/burden.ts b/src/mcp/burden.ts index 7a55ef51..c5f67b4e 100644 --- a/src/mcp/burden.ts +++ b/src/mcp/burden.ts @@ -37,6 +37,8 @@ const TOOL_BURDEN_KIND: Record = { workspace_authorize: "diagnostic", workspace_authorizations: "diagnostic", workspace_revoke: "diagnostic", + workspace_open: "diagnostic", + workspace_list_opened: "diagnostic", workspace_bind: "diagnostic", workspace_status: "diagnostic", causal_status: "diagnostic", diff --git a/src/mcp/context.ts b/src/mcp/context.ts index b1d01c8e..d447ebcc 100644 --- a/src/mcp/context.ts +++ b/src/mcp/context.ts @@ -46,6 +46,9 @@ import type { WorkspaceActionResult, CausalAttachResult, WorkspaceBindRequest, + WorkspaceListOpenedResult, + WorkspaceOpenRequest, + WorkspaceOpenResult, WorkspaceStatus, } from "./workspace-router.js"; @@ -91,6 +94,8 @@ export interface ToolContext { getRepoConcurrencySummary(): Promise; declareCausalAttach(request: PersistedLocalHistoryAttachDeclaration): Promise; getWorkspaceStatus(): WorkspaceStatus; + openWorkspace(request: WorkspaceOpenRequest): Promise; + listOpenedWorkspaces(): Promise; bindWorkspace(request: WorkspaceBindRequest, actionName: string): Promise; rebindWorkspace(request: WorkspaceBindRequest, actionName: string): Promise; getDaemonStatus(): Promise; diff --git a/src/mcp/repo-tool-worker-context.ts b/src/mcp/repo-tool-worker-context.ts index ce3d3712..7f9d38cd 100644 --- a/src/mcp/repo-tool-worker-context.ts +++ b/src/mcp/repo-tool-worker-context.ts @@ -165,6 +165,12 @@ export function buildRepoToolWorkerContext( getWorkspaceStatus() { return workerStatus(job); }, + openWorkspace() { + return unsupported("openWorkspace"); + }, + listOpenedWorkspaces() { + return unsupported("listOpenedWorkspaces"); + }, bindWorkspace() { return unsupported("bindWorkspace"); }, diff --git a/src/mcp/server-context.ts b/src/mcp/server-context.ts index df68fb76..8a53b107 100644 --- a/src/mcp/server-context.ts +++ b/src/mcp/server-context.ts @@ -147,6 +147,12 @@ export function buildToolContext(deps: ToolContextDeps): ToolContext { getWorkspaceStatus() { return getActiveExecutionContext()?.status ?? workspaceRouter.getStatus(); }, + openWorkspace(request) { + return workspaceRouter.openWorkspace(request); + }, + listOpenedWorkspaces() { + return Promise.resolve(workspaceRouter.listOpenedWorkspaces()); + }, bindWorkspace(request, actionName) { return workspaceRouter.bind(request, actionName); }, diff --git a/src/mcp/server-tool-access.ts b/src/mcp/server-tool-access.ts index 793cfcd7..4a621402 100644 --- a/src/mcp/server-tool-access.ts +++ b/src/mcp/server-tool-access.ts @@ -24,6 +24,8 @@ const daemonAlwaysAvailableTools = new Set([ "workspace_authorize", "workspace_authorizations", "workspace_revoke", + "workspace_open", + "workspace_list_opened", "workspace_bind", "workspace_status", "causal_status", @@ -44,6 +46,8 @@ export const repoStateOptionalTools = new Set([ "workspace_authorize", "workspace_authorizations", "workspace_revoke", + "workspace_open", + "workspace_list_opened", "workspace_bind", "workspace_status", "workspace_rebind", diff --git a/src/mcp/tool-registry.ts b/src/mcp/tool-registry.ts index 45ed054b..c266cd78 100644 --- a/src/mcp/tool-registry.ts +++ b/src/mcp/tool-registry.ts @@ -41,6 +41,8 @@ import { causalAttachTool } from "./tools/causal-attach.js"; import { workspaceAuthorizeTool } from "./tools/workspace-authorize.js"; import { workspaceAuthorizationsTool } from "./tools/workspace-authorizations.js"; import { workspaceBindTool } from "./tools/workspace-bind.js"; +import { workspaceListOpenedTool } from "./tools/workspace-list-opened.js"; +import { workspaceOpenTool } from "./tools/workspace-open.js"; import { workspaceRevokeTool } from "./tools/workspace-revoke.js"; import { workspaceStatusTool } from "./tools/workspace-status.js"; import { workspaceRebindTool } from "./tools/workspace-rebind.js"; @@ -59,6 +61,8 @@ export const TOOL_REGISTRY: readonly ToolDefinition[] = [ activityViewTool, causalStatusTool, causalAttachTool, + workspaceOpenTool, + workspaceListOpenedTool, statsTool, explainTool, setBudgetTool, diff --git a/src/mcp/tools/workspace-list-opened.ts b/src/mcp/tools/workspace-list-opened.ts new file mode 100644 index 00000000..2582aba4 --- /dev/null +++ b/src/mcp/tools/workspace-list-opened.ts @@ -0,0 +1,12 @@ +import type { ToolDefinition, ToolHandler } from "../context.js"; + +export const workspaceListOpenedTool: ToolDefinition = { + name: "workspace_list_opened", + description: + "List workspaces opened in this MCP session and identify the active workspace.", + createHandler(): ToolHandler { + return async (_args, ctx) => { + return ctx.respond("workspace_list_opened", { ...await ctx.listOpenedWorkspaces() }); + }; + }, +}; diff --git a/src/mcp/tools/workspace-open.ts b/src/mcp/tools/workspace-open.ts new file mode 100644 index 00000000..ede9b0cf --- /dev/null +++ b/src/mcp/tools/workspace-open.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import type { ToolDefinition, ToolHandler } from "../context.js"; + +export const workspaceOpenTool: ToolDefinition = { + name: "workspace_open", + description: + "Open a git worktree path in this MCP session and optionally make it the active workspace.", + schema: { + cwd: z.string(), + activate: z.boolean().optional(), + runCapture: z.boolean().optional(), + }, + createHandler(): ToolHandler { + return async (args, ctx) => { + return ctx.respond("workspace_open", { ...await ctx.openWorkspace({ + cwd: args["cwd"] as string, + activate: args["activate"] as boolean | undefined, + runCapture: args["runCapture"] as boolean | undefined, + }) }); + }; + }, +}; diff --git a/src/mcp/workspace-router-model.ts b/src/mcp/workspace-router-model.ts index a8f36885..511c09c2 100644 --- a/src/mcp/workspace-router-model.ts +++ b/src/mcp/workspace-router-model.ts @@ -38,6 +38,38 @@ export interface WorkspaceActionResult extends WorkspaceStatus { readonly error?: string; } +export type OpenedWorkspaceSource = "startup" | "session_opened" | "daemon_authorized"; + +export interface OpenedWorkspaceView extends ResolvedWorkspace { + readonly source: OpenedWorkspaceSource; + readonly active: boolean; + readonly capabilityProfile: WorkspaceCapabilityProfile; + readonly openedAt: string; + readonly lastActivatedAt: string | null; + readonly activeSessions?: number | undefined; +} + +export interface WorkspaceOpenRequest { + readonly cwd: string; + readonly activate?: boolean | undefined; + readonly runCapture?: boolean | undefined; +} + +export interface WorkspaceOpenResult extends WorkspaceStatus { + readonly ok: boolean; + readonly changed: boolean; + readonly freshSessionSlice: boolean; + readonly openedWorkspace?: OpenedWorkspaceView | undefined; + readonly errorCode?: string; + readonly error?: string; +} + +export interface WorkspaceListOpenedResult { + readonly sessionMode: WorkspaceMode; + readonly activeWorktreeId: string | null; + readonly workspaces: readonly OpenedWorkspaceView[]; +} + export interface CausalAttachResult extends WorkspaceStatus { readonly ok: boolean; readonly action: "attach"; diff --git a/src/mcp/workspace-router.ts b/src/mcp/workspace-router.ts index 1ac60c8d..90231545 100644 --- a/src/mcp/workspace-router.ts +++ b/src/mcp/workspace-router.ts @@ -23,6 +23,8 @@ import { DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, WorkspaceBindingRequiredError, type CausalAttachResult, + type OpenedWorkspaceSource, + type OpenedWorkspaceView, type ResolvedWorkspace, type WorkspaceActionResult, type WorkspaceAuthorizationPolicy, @@ -30,7 +32,10 @@ import { type WorkspaceBindRequest, type WorkspaceCapabilityProfile, type WorkspaceExecutionContext, + type WorkspaceListOpenedResult, type WorkspaceMode, + type WorkspaceOpenRequest, + type WorkspaceOpenResult, type WorkspaceSharedAttachPolicy, type WorkspaceStatus, } from "./workspace-router-model.js"; @@ -62,6 +67,8 @@ export { WorkspaceBindingRequiredError, WorkspaceCapabilityDeniedError, type CausalAttachResult, + type OpenedWorkspaceSource, + type OpenedWorkspaceView, type ResolvedWorkspace, type WorkspaceActionResult, type WorkspaceAuthorizationPolicy, @@ -69,7 +76,10 @@ export { type WorkspaceBindRequest, type WorkspaceCapabilityProfile, type WorkspaceExecutionContext, + type WorkspaceListOpenedResult, type WorkspaceMode, + type WorkspaceOpenRequest, + type WorkspaceOpenResult, type WorkspaceSharedAttachPolicy, type WorkspaceStatus, } from "./workspace-router-model.js"; @@ -90,12 +100,20 @@ interface WorkspaceRouterOptions { readonly persistedLocalHistoryGraph?: boolean; } +interface OpenedWorkspaceRecord extends ResolvedWorkspace { + readonly capabilityProfile: WorkspaceCapabilityProfile; + readonly source: OpenedWorkspaceSource; + readonly openedAt: string; + readonly lastActivatedAt: string | null; +} + export class WorkspaceRouter { private bindingCounter = 0; private sliceIdCounter = 0; private currentSlice: WorkspaceSlice; private currentBinding: BoundWorkspace | null = null; private initialization: Promise | null = null; + private readonly openedWorkspaces = new Map(); constructor(private readonly options: WorkspaceRouterOptions) { const initialProjectRoot = options.mode === "repo_local" ? options.projectRoot : undefined; @@ -157,6 +175,7 @@ export class WorkspaceRouter { currentGraph: await this.buildPersistedLocalHistoryGraphContext(currentBinding), }); this.currentBinding = currentBinding; + this.noteOpenedWorkspace(initialWorkspace, DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE, "startup", true); })(); await this.initialization; @@ -236,6 +255,104 @@ export class WorkspaceRouter { return boundWorkspaceStatus(this.options.mode, this.currentBinding); } + listOpenedWorkspaces(): WorkspaceListOpenedResult { + const activeWorktreeId = this.currentBinding?.worktreeId ?? null; + return { + sessionMode: this.options.mode, + activeWorktreeId, + workspaces: [...this.openedWorkspaces.values()] + .map((record) => this.toOpenedWorkspaceView(record, activeWorktreeId)) + .sort((left, right) => left.worktreeRoot.localeCompare(right.worktreeRoot)), + }; + } + + async openWorkspace(request: WorkspaceOpenRequest): Promise { + const resolved = await resolveWorkspaceRequest(this.options.git, request); + if ("code" in resolved) { + return { + ok: false, + changed: false, + freshSessionSlice: false, + ...this.getStatus(), + errorCode: resolved.code, + error: resolved.message, + }; + } + + const capabilityProfile = this.options.mode === "repo_local" + ? DEFAULT_REPO_LOCAL_CAPABILITY_PROFILE + : (await this.options.authorizationPolicy?.getCapabilityProfile(resolved)) ?? null; + if (capabilityProfile === null) { + return { + ok: false, + changed: false, + freshSessionSlice: false, + ...this.getStatus(), + errorCode: "WORKSPACE_NOT_AUTHORIZED", + error: `Workspace ${resolved.worktreeRoot} is not authorized for daemon binding. Call workspace_authorize first.`, + }; + } + + const activate = request.activate ?? true; + const source: OpenedWorkspaceSource = this.options.mode === "daemon" ? "daemon_authorized" : "session_opened"; + const changed = this.noteOpenedWorkspace(resolved, capabilityProfile, source, false); + + if (!activate) { + return { + ok: true, + changed, + freshSessionSlice: false, + ...this.getStatus(), + openedWorkspace: this.toOpenedWorkspaceView( + this.requireOpenedWorkspace(resolved.worktreeId), + this.currentBinding?.worktreeId ?? null, + ), + }; + } + + if (this.currentBinding?.worktreeId === resolved.worktreeId) { + this.noteWorkspaceActivated(resolved.worktreeId); + return { + ok: true, + changed, + freshSessionSlice: false, + ...this.getStatus(), + openedWorkspace: this.toOpenedWorkspaceView( + this.requireOpenedWorkspace(resolved.worktreeId), + this.currentBinding.worktreeId, + ), + }; + } + + const action: WorkspaceBindAction = this.currentBinding === null ? "bind" : "rebind"; + const result = await this.bindInternal(action, request, "workspace_open", { + openedSource: source, + }); + return { + ok: result.ok, + changed, + freshSessionSlice: result.freshSessionSlice, + sessionMode: result.sessionMode, + bindState: result.bindState, + repoId: result.repoId, + worktreeId: result.worktreeId, + worktreeRoot: result.worktreeRoot, + gitCommonDir: result.gitCommonDir, + graftDir: result.graftDir, + capabilityProfile: result.capabilityProfile, + ...(result.ok + ? { + openedWorkspace: this.toOpenedWorkspaceView( + this.requireOpenedWorkspace(resolved.worktreeId), + result.worktreeId, + ), + } + : {}), + ...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}), + ...(result.error !== undefined ? { error: result.error } : {}), + }; + } + async getPersistedLocalHistorySummary(): Promise { const binding = this.currentBinding; if (binding?.slice.repoState === null || binding === null) { @@ -511,6 +628,7 @@ export class WorkspaceRouter { action: WorkspaceBindAction, request: WorkspaceBindRequest, actionName: string, + options: { readonly openedSource?: OpenedWorkspaceSource | undefined } = {}, ): Promise { const resolved = await resolveWorkspaceRequest(this.options.git, request); if ("code" in resolved) { @@ -564,6 +682,12 @@ export class WorkspaceRouter { } this.currentBinding = nextBinding; this.currentSlice = nextBinding.slice; + this.noteOpenedWorkspace( + resolved, + capabilityProfile, + options.openedSource ?? (this.options.mode === "daemon" ? "daemon_authorized" : "session_opened"), + true, + ); return { ok: true, @@ -615,6 +739,64 @@ export class WorkspaceRouter { return repoState; } + private noteOpenedWorkspace( + resolved: ResolvedWorkspace, + capabilityProfile: WorkspaceCapabilityProfile, + source: OpenedWorkspaceSource, + activated: boolean, + ): boolean { + const now = new Date().toISOString(); + const current = this.openedWorkspaces.get(resolved.worktreeId); + const changed = current?.repoId !== resolved.repoId + || current.worktreeRoot !== resolved.worktreeRoot + || current.gitCommonDir !== resolved.gitCommonDir + || current.source !== source; + this.openedWorkspaces.set(resolved.worktreeId, { + ...resolved, + capabilityProfile, + source: current?.source === "startup" ? current.source : source, + openedAt: current?.openedAt ?? now, + lastActivatedAt: activated ? now : (current?.lastActivatedAt ?? null), + }); + return changed; + } + + private noteWorkspaceActivated(worktreeId: string): void { + const current = this.openedWorkspaces.get(worktreeId); + if (current === undefined) { + return; + } + this.openedWorkspaces.set(worktreeId, { + ...current, + lastActivatedAt: new Date().toISOString(), + }); + } + + private requireOpenedWorkspace(worktreeId: string): OpenedWorkspaceRecord { + const workspace = this.openedWorkspaces.get(worktreeId); + if (workspace === undefined) { + throw new Error(`Opened workspace missing for worktree ${worktreeId}`); + } + return workspace; + } + + private toOpenedWorkspaceView( + record: OpenedWorkspaceRecord, + activeWorktreeId: string | null, + ): OpenedWorkspaceView { + return { + repoId: record.repoId, + worktreeId: record.worktreeId, + worktreeRoot: record.worktreeRoot, + gitCommonDir: record.gitCommonDir, + capabilityProfile: { ...record.capabilityProfile }, + source: record.source, + active: activeWorktreeId === record.worktreeId, + openedAt: record.openedAt, + lastActivatedAt: record.lastActivatedAt, + }; + } + private buildCausalContext( binding: BoundWorkspace, observation: { readonly checkoutEpoch: number }, diff --git a/test/unit/contracts/capabilities.test.ts b/test/unit/contracts/capabilities.test.ts index d74a3e48..69c2eec5 100644 --- a/test/unit/contracts/capabilities.test.ts +++ b/test/unit/contracts/capabilities.test.ts @@ -73,11 +73,11 @@ describe("capability registry", () => { expect(baseline).toEqual({ cliOnly: 6, apiCliMcp: 23, - apiMcp: 22, + apiMcp: 24, apiOnly: 1, directCliMcpPeers: 22, composedCliOperators: 1, - intentionallyApiMcpOnly: 22, + intentionallyApiMcpOnly: 24, }); expect(rows).toEqual(expect.arrayContaining([ expect.objectContaining({ diff --git a/test/unit/contracts/output-schemas.test.ts b/test/unit/contracts/output-schemas.test.ts index a0871f48..5ac700da 100644 --- a/test/unit/contracts/output-schemas.test.ts +++ b/test/unit/contracts/output-schemas.test.ts @@ -169,6 +169,8 @@ describe("contracts: output schemas", () => { const daemonBind = parse(await daemonServer.callTool("workspace_bind", { cwd: repoDir })); const daemonRebind = parse(await daemonServer.callTool("workspace_rebind", { cwd: repoDir })); const daemonRevoke = parse(await daemonServer.callTool("workspace_revoke", { cwd: repoDir })); + const workspaceOpen = parse(await server.callTool("workspace_open", { cwd: repoDir, activate: false })); + const workspaceListOpened = parse(await server.callTool("workspace_list_opened", {})); git(repoDir, "checkout -b feature/output-schema-attach"); const outputs = { @@ -199,6 +201,8 @@ describe("contracts: output schemas", () => { workspace_authorize: daemonAuthorize, workspace_authorizations: daemonAuthorizations, workspace_revoke: daemonRevoke, + workspace_open: workspaceOpen, + workspace_list_opened: workspaceListOpened, workspace_bind: daemonBind, workspace_status: daemonStatus, activity_view: parse(await server.callTool("activity_view", {})), diff --git a/test/unit/mcp/opened-workspaces.test.ts b/test/unit/mcp/opened-workspaces.test.ts index ec0cbe22..b0a4ab6e 100644 --- a/test/unit/mcp/opened-workspaces.test.ts +++ b/test/unit/mcp/opened-workspaces.test.ts @@ -15,10 +15,11 @@ function createRepo(prefix: string, content: string): string { const repoDir = createCommittedTestRepo(prefix, { "app.ts": content, }); + const realRepoDir = fs.realpathSync(repoDir); cleanups.push(() => { - cleanupTestRepo(repoDir); + cleanupTestRepo(realRepoDir); }); - return repoDir; + return realRepoDir; } interface ListedOpenedWorkspace { 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 1005f830..abcaca11 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(22); + expect(CAPABILITY_REGISTRY.filter((capability) => capability.surfaces.join("+") === "api+mcp")).toHaveLength(24); 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("- `22` API + MCP capabilities"); + expect(content).toContain("- `24` 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("- `22` intentionally API + MCP-only agent/control-plane capabilities"); + expect(content).toContain("- `24` intentionally API + MCP-only agent/control-plane capabilities"); }); it("Does the capability registry explicitly model all three entry points?", () => { diff --git a/tests/playback/SURFACE_capability-matrix-truth.test.ts b/tests/playback/SURFACE_capability-matrix-truth.test.ts index b5161a90..cf3e9c7e 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(22); + expect(intentionallyApiMcpOnly).toHaveLength(24); 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(); From 1a96483bf1ebf5c1746bdf1bc45f5bd094a25af7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 02:11:23 -0700 Subject: [PATCH 05/15] feat: finish opened workspace path slice --- docs/design/SURFACE_opened-workspace-paths.md | 40 +- .../SURFACE_opened-workspace-paths.md | 29 ++ .../witness/verification.md | 375 ++++++++++++++++++ src/mcp/tool-registry.ts | 2 +- src/mcp/tools/workspace-open.ts | 18 +- src/mcp/workspace-router.ts | 22 +- .../SURFACE_opened-workspace-paths.test.ts | 225 +++++++++++ 7 files changed, 696 insertions(+), 15 deletions(-) create mode 100644 docs/method/retro/SURFACE_opened-workspace-paths/SURFACE_opened-workspace-paths.md create mode 100644 docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md create mode 100644 tests/playback/SURFACE_opened-workspace-paths.test.ts diff --git a/docs/design/SURFACE_opened-workspace-paths.md b/docs/design/SURFACE_opened-workspace-paths.md index 3cb6b75f..d92d666d 100644 --- a/docs/design/SURFACE_opened-workspace-paths.md +++ b/docs/design/SURFACE_opened-workspace-paths.md @@ -1,8 +1,9 @@ --- title: "Opened workspace paths" legend: "SURFACE" +cycle: "SURFACE_opened-workspace-paths" source_backlog: "docs/method/backlog/cool-ideas/SURFACE_opened-workspace-paths.md" -status: proposal +status: completed --- # Opened workspace paths @@ -34,23 +35,23 @@ The model is: ### Human -- [ ] Can I tell an agent to work in another repo and have it open that +- [x] Can I tell an agent to work in another repo and have it open that path in the existing Graft MCP session? -- [ ] Can I inspect which paths are opened and which one is active? -- [ ] Does switching repos feel like opening another workspace, not +- [x] Can I inspect which paths are opened and which one is active? +- [x] Does switching repos feel like opening another workspace, not editing low-level daemon authorization state by hand? ### Agent -- [ ] Can a repo-local MCP server open a second git worktree and run +- [x] Can a repo-local MCP server open a second git worktree and run `safe_read`, `graft_map`, and `code_find` against it without process restart? -- [ ] Does activation reset session-local cache, budget, saved state, +- [x] Does activation reset session-local cache, budget, saved state, metrics, and repo-state tracking instead of bleeding state across worktrees? -- [ ] Does daemon mode reuse the existing authorization registry and +- [x] Does daemon mode reuse the existing authorization registry and capability profile rather than creating a parallel allowlist? -- [ ] Do repo-scoped tools keep their current repo-relative schemas? +- [x] Do repo-scoped tools keep their current repo-relative schemas? ## Accessibility and Assistive Reading @@ -280,7 +281,7 @@ to open and optionally activate a path. - `OpenedWorkspaceRecord` - `WorkspaceOpenRequest` - `WorkspaceOpenResult` - - `WorkspaceOpenedResult` + - `WorkspaceListOpenedResult` - optionally `WorkspaceActivateRequest` 2. Extend `WorkspaceRouter`: - seed the opened set with the repo-local startup binding @@ -337,6 +338,27 @@ Defer standalone `workspace_activate` until there is evidence that agents need a separate "open but do not activate, then activate later" workflow often enough to justify another tool. +## Landed First Slice + +The first slice is implemented and witnessed by +`tests/playback/SURFACE_opened-workspace-paths.test.ts`. + +Shipped behavior: + +- `workspace_open` is available in repo-local and daemon-backed MCP + sessions. +- `activate` defaults to `true`; `activate: false` records without + changing the active workspace. +- repo-local opened workspaces are process-local and seeded with the + startup workspace. +- `workspace_list_opened` returns the opened set and active worktree id. +- `workspace_status` is available in repo-local sessions. +- daemon `workspace_open` writes through the existing authorization + registry, including capability posture such as `runCapture`, then + uses the existing bind/rebind activation path. +- existing repo-scoped tools keep repo-relative input schemas and run + against the active workspace. + ## Product Decisions - `activate` defaults to `true`. diff --git a/docs/method/retro/SURFACE_opened-workspace-paths/SURFACE_opened-workspace-paths.md b/docs/method/retro/SURFACE_opened-workspace-paths/SURFACE_opened-workspace-paths.md new file mode 100644 index 00000000..c8e7b52e --- /dev/null +++ b/docs/method/retro/SURFACE_opened-workspace-paths/SURFACE_opened-workspace-paths.md @@ -0,0 +1,29 @@ +--- +title: "Opened workspace paths" +cycle: "SURFACE_opened-workspace-paths" +design_doc: "docs/design/SURFACE_opened-workspace-paths.md" +outcome: hill-met +drift_check: yes +--- + +# Opened workspace paths Retro + +## Summary + +Opened workspace paths shipped as a first-class MCP surface. Repo-local sessions can open another git worktree path, list opened workspaces, inspect workspace status, and activate the opened path without restarting or adding cwd envelopes to repo-scoped tools. Daemon workspace_open now writes through the existing authorization registry and preserves capability posture before binding. + +## Playback Witness + +Artifacts under `docs/method/retro/SURFACE_opened-workspace-paths/witness`. + +## What surprised you? + +Nothing unexpected. + +## What would you do differently? + +No changes to approach. + +## Follow-up items + +- None. diff --git a/docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md b/docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md new file mode 100644 index 00000000..eb70924a --- /dev/null +++ b/docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md @@ -0,0 +1,375 @@ +--- +title: "Verification Witness for Cycle SURFACE_opened-workspace-paths" +--- + +# Verification Witness for Cycle SURFACE_opened-workspace-paths + +This witness proves that `Opened workspace paths` now carries the required +behavior and adheres to the repo invariants. + +## Test Results + +```text + +> @flyingrobots/graft@0.8.0 test +> tsx scripts/run-isolated-tests.ts + + + RUN v4.1.2 /app + + ✓ test/unit/parser/outline.test.ts (26 tests) 90ms + ✓ test/unit/mcp/persisted-local-history.test.ts (13 tests) 767ms + ✓ retains full read-event history in the WARP graph 706ms + ✓ test/unit/mcp/runtime-observability.test.ts (14 tests) 2205ms + ✓ test/unit/cli/init.test.ts (29 tests) 132ms + ✓ test/unit/mcp/tools.test.ts (33 tests) 3227ms + ✓ test/unit/cli/main.test.ts (20 tests) 1440ms + ✓ runs symbol difficulty through the grouped CLI surface 414ms + ✓ test/unit/mcp/precision.test.ts (18 tests) 2200ms + ✓ test/unit/mcp/layered-worldline.test.ts (14 tests) 1884ms + ✓ labels historical symbol reads as commit_worldline 468ms + ✓ test/unit/warp/lsp-semantic-enrichment.test.ts (13 tests) 1104ms + ✓ test/integration/mcp/daemon-server.test.ts (4 tests) 2660ms + ✓ preserves safe_read cache behavior across off-process daemon execution 698ms + ✓ offloads dirty precision lookups through child-process workers 638ms + ✓ persists repo-scoped monitor lifecycle across daemon restart 1237ms + ✓ test/unit/operations/export-surface-diff.test.ts (13 tests) 784ms + ✓ test/unit/mcp/persisted-local-history-graph.test.ts (6 tests) 50ms + ✓ test/unit/operations/structural-review.test.ts (11 tests) 830ms + ✓ test/unit/parser/outline-audit.test.ts (42 tests) 10ms + ✓ tests/playback/0058-system-wide-resource-pressure-and-fairness.test.ts (8 tests) 973ms + ✓ Does the daemon keep session state authoritative in-process while workers execute against immutable snapshots and return deltas? 612ms + ✓ test/unit/contracts/output-schemas.test.ts (8 tests) 8908ms + ✓ validates representative MCP tool outputs against the declared schemas 1718ms + ✓ validates representative CLI peer outputs against the declared schemas 7004ms + ✓ tests/playback/SURFACE_agent-dx-governed-edit.test.ts (12 tests) 697ms + ✓ Does it refuse outside-repo, ignored, generated, lockfile, binary, minified, build-output, and likely-secret paths? 306ms + ✓ test/unit/contracts/causal-ontology.test.ts (6 tests) 31ms + ✓ test/unit/operations/structural-test-coverage-map.test.ts (5 tests) 205ms + ✓ test/unit/library/structured-buffer.test.ts (7 tests) 66ms + ✓ tests/playback/CORE_v060-bad-code-burndown.test.ts (13 tests) 61ms + ✓ test/unit/mcp/daemon-worker-pool.test.ts (7 tests) 3117ms + ✓ runs monitor tick work on a child-process worker and reports worker counts 688ms + ✓ runs an offloaded repo tool on a child-process worker 627ms + ✓ Does the daemon keep session state authoritative in-process while workers execute against immutable snapshots and return deltas? 605ms + ✓ refuses absolute paths outside the repo in the offloaded read worker context 572ms + ✓ runs dirty code_find through the live worker path 600ms + ✓ test/unit/mcp/workspace-binding.test.ts (11 tests) 1082ms + ✓ rebinds across worktrees of the same repo without carrying session-local state 396ms + ✓ test/unit/parser/diff.test.ts (18 tests) 58ms + ✓ test/unit/mcp/graft-edit.test.ts (11 tests) 451ms + ✓ test/unit/operations/graft-diff.test.ts (12 tests) 695ms + ✓ test/unit/mcp/receipt.test.ts (19 tests) 2372ms + ✓ test/unit/warp/ast-import-resolver.test.ts (10 tests) 989ms + ✓ tests/playback/0088-target-repo-git-hook-bootstrap.test.ts (6 tests) 485ms + ✓ test/unit/mcp/changed.test.ts (14 tests) 2214ms + ✓ test/unit/mcp/tool-call-footprint.test.ts (17 tests) 32ms + ✓ tests/playback/CORE_migrate-path-ops-to-port.test.ts (7 tests) 838ms + ✓ In temp repos only, does `safe_read` refuse or fail clearly for an absolute path outside the repo root on every runtime surface? 776ms + ✓ test/unit/policy/cross-surface-parity.test.ts (6 tests) 1181ms + ✓ keeps governed-read behavior honest across hooks and safe_read 378ms + ✓ tests/playback/SURFACE_governed-write-tools.test.ts (9 tests) 208ms + ✓ tests/playback/SURFACE_opened-workspace-paths.test.ts (7 tests) 1339ms + ✓ Can a repo-local MCP server open a second git worktree and run `safe_read`, `graft_map`, and `code_find` against it without process restart? 443ms + ✓ test/unit/warp/symbol-timeline.test.ts (7 tests) 1011ms + ✓ test/unit/mcp/structural-policy.test.ts (8 tests) 965ms + ✓ test/unit/parser/value-objects.test.ts (33 tests) 32ms + ✓ test/unit/mcp/daemon-multi-session.test.ts (3 tests) 1328ms + ✓ shares daemon-wide workspace authorization and bound session state across sessions on the same repo 668ms + ✓ surfaces shared-worktree posture and explicit handoff for two daemon sessions on one worktree 395ms + ✓ test/unit/mcp/code-refs.test.ts (6 tests) 450ms + ✓ test/unit/mcp/runtime-workspace-overlay.test.ts (5 tests) 134ms + ✓ test/unit/mcp/graft-edit-drift-warning.test.ts (8 tests) 538ms + ✓ test/unit/operations/structural-blame.test.ts (5 tests) 944ms + ✓ detects last signature change across commits 323ms + ✓ test/unit/mcp/cache.test.ts (15 tests) 2406ms + ✓ test/unit/operations/diff-identity.test.ts (8 tests) 27ms + ✓ test/unit/git/diff.test.ts (17 tests) 396ms + ✓ tests/playback/0061-provenance-attribution-instrumentation.test.ts (15 tests) 29ms + ✓ test/unit/operations/safe-read.test.ts (16 tests) 79ms + ✓ test/integration/mcp/server.test.ts (9 tests) 1480ms + ✓ test/unit/guards/stream-boundary.test.ts (28 tests) 31ms + ✓ test/unit/adapters/repo-paths-invariants.test.ts (25 tests) 41ms + ✓ test/unit/warp/structural-queries.test.ts (5 tests) 985ms + ✓ returns removed symbols when a function is deleted 319ms + ✓ test/unit/mcp/daemon-job-scheduler.test.ts (4 tests) 40ms + ✓ test/unit/cli/daemon-status-model.test.ts (2 tests) 33ms + ✓ test/unit/warp/stale-docs.test.ts (13 tests) 728ms + ✓ test/unit/mcp/repo-concurrency.test.ts (6 tests) 26ms + ✓ tests/playback/CORE_v080-scope-formation.test.ts (6 tests) 28ms + ✓ test/unit/cli/git-graft-enhance-model.test.ts (4 tests) 38ms + ✓ test/unit/metrics/metrics.test.ts (14 tests) 28ms + ✓ tests/playback/CORE_test-runner-docker-daemon-hard-failure.test.ts (9 tests) 28ms + ✓ test/unit/mcp/opened-workspaces.test.ts (5 tests) 724ms + ✓ test/unit/warp/structural-reading-adapter.test.ts (4 tests) 30ms + ✓ tests/playback/0081-composition-roots-for-cli-mcp-daemon-and-hooks.test.ts (5 tests) 28ms + ✓ test/unit/mcp/map-truncation.test.ts (5 tests) 956ms + ✓ test/unit/warp/since.test.ts (3 tests) 864ms + ✓ detects removed symbols between two commits 340ms + ✓ test/unit/warp/drift-sentinel.test.ts (5 tests) 970ms + ✓ test/unit/hooks/pretooluse-read.test.ts (13 tests) 37ms + ✓ test/unit/warp/index-head.test.ts (5 tests) 751ms + ✓ test/unit/mcp/persistent-monitor.test.ts (2 tests) 583ms + ✓ Do background monitors run through the same pressure and fairness scheduler as foreground repo work? 377ms + ✓ tests/playback/0063-richer-semantic-transitions.test.ts (11 tests) 29ms + ✓ tests/playback/0059-graph-ontology-and-causal-collapse-model.test.ts (10 tests) 27ms + ✓ tests/playback/0075-hexagonal-architecture-convergence-plan.test.ts (8 tests) 28ms + ✓ tests/playback/0076-hex-layer-map-and-dependency-guardrails.test.ts (9 tests) 2910ms + ✓ Do contracts and pure helpers reject imports from ports, application modules, secondary adapters, primary adapters, and host libraries? 2499ms + ✓ test/unit/warp/warp-structural-churn.test.ts (6 tests) 824ms + ✓ tests/playback/SURFACE_bijou-daemon-status-first-slice.test.ts (5 tests) 36ms + ✓ tests/playback/0074-local-causal-history-graph-schema.test.ts (9 tests) 29ms + ✓ test/unit/warp/dead-symbols.test.ts (5 tests) 1485ms + ✓ detects a symbol removed and not re-added 341ms + ✓ excludes symbols that were removed then re-added 380ms + ✓ respects maxCommits to limit search depth 311ms + ✓ test/unit/contracts/capabilities.test.ts (4 tests) 33ms + ✓ test/unit/session/tripwires.test.ts (15 tests) 32ms + ✓ tests/playback/CORE_git-graft-enhance.test.ts (6 tests) 1251ms + ✓ Can I run git-graft enhance --since HEAD~1 in a temp repo and see a concise structural review summary? 798ms + ✓ Can I run git-graft enhance --since HEAD~1 --json in a temp repo and get schema-validated JSON for the same facts? 428ms + ✓ test/unit/mcp/runtime-staged-target.test.ts (3 tests) 28ms + ✓ tests/playback/CORE_v070-structural-history.test.ts (11 tests) 28ms + ✓ test/unit/warp/context.test.ts (8 tests) 30ms + ✓ test/unit/cli/doctor-posture.test.ts (7 tests) 761ms + ✓ tests/playback/0064-same-repo-concurrent-agent-model.test.ts (10 tests) 29ms + ✓ test/unit/scripts/docker-autostart.test.ts (6 tests) 27ms + ✓ test/integration/safe-read.test.ts (9 tests) 76ms + ✓ test/unit/mcp/path-resolver.test.ts (14 tests) 34ms + ✓ test/unit/parser/lang.test.ts (15 tests) 28ms + ✓ tests/playback/0060-persisted-sub-commit-local-history.test.ts (9 tests) 29ms + ✓ test/unit/hooks/posttooluse-read.test.ts (9 tests) 101ms + ✓ test/unit/release/docker-test-isolation.test.ts (6 tests) 29ms + ✓ test/unit/hooks/shared.test.ts (17 tests) 28ms + ✓ test/unit/cli/local-history-dag-model.test.ts (3 tests) 46ms + ✓ test/unit/metrics/logging.test.ts (7 tests) 48ms + ✓ test/unit/warp/warp-structural-log.test.ts (6 tests) 945ms + ✓ respects limit parameter 513ms + ✓ tests/playback/0062-reactive-workspace-overlay.test.ts (9 tests) 28ms + ✓ test/unit/warp/refactor-difficulty.test.ts (4 tests) 1057ms + ✓ combines aggregate churn curvature with reference friction 563ms + ✓ test/unit/warp/warp-reference-count.test.ts (5 tests) 906ms + ✓ distinguishes same-named symbols in different files 330ms + ✓ test/unit/mcp/knowledge-map.test.ts (7 tests) 1274ms + ✓ tracks multiple files 309ms + ✓ tests/playback/0078-three-surface-capability-baseline-and-parity-matrix.test.ts (7 tests) 32ms + ✓ test/unit/operations/knowledge-map.test.ts (5 tests) 38ms + ✓ tests/playback/CORE_v060-code-review-fixes.test.ts (9 tests) 30ms + ✓ test/unit/mcp/secret-scrub.test.ts (13 tests) 30ms + ✓ test/unit/policy/bans.test.ts (43 tests) 33ms + ✓ test/integration/cli/git-graft-enhance-cli.test.ts (3 tests) 2586ms + ✓ renders a human review summary for enhance --since in a temp repo 745ms + ✓ emits schema-validated JSON for enhance --since in a temp repo 489ms + ✓ supports Git external-command invocation through git graft in a temp repo 1328ms + ✓ test/unit/warp/references-for-symbol.test.ts (6 tests) 1083ms + ✓ finds multiple referencing files 366ms + ✓ tests/playback/0065-between-commit-activity-view.test.ts (10 tests) 28ms + ✓ test/unit/mcp/receipt-builder.test.ts (9 tests) 41ms + ✓ test/unit/mcp/monitor-tick-ceiling.test.ts (6 tests) 625ms + ✓ tests/playback/0077-primary-adapters-thin-use-case-extraction.test.ts (5 tests) 286ms + ✓ tests/playback/CORE_pr-review-structural-summary.test.ts (2 tests) 524ms + ✓ test/unit/warp/directory.test.ts (3 tests) 526ms + ✓ tests/playback/0082-runtime-validated-command-and-context-models.test.ts (3 tests) 27ms + ✓ tests/playback/0080-warp-port-and-adapter-boundary.test.ts (8 tests) 48ms + ✓ test/unit/release/three-surface-capability-posture.test.ts (4 tests) 28ms + ✓ tests/playback/CORE_graft-doctor.test.ts (6 tests) 769ms + ✓ test/unit/mcp/daemon-repos.test.ts (2 tests) 301ms + ✓ test/unit/library/index.test.ts (5 tests) 1005ms + ✓ keeps sync projection bundles non-throwing before parser warmup 774ms + ✓ test/unit/operations/conversation-primer.test.ts (6 tests) 136ms + ✓ test/unit/helpers/git.test.ts (6 tests) 94ms + ✓ tests/playback/CORE_structural-test-coverage-map.test.ts (2 tests) 344ms + ✓ tests/playback/SURFACE_capability-matrix-truth.test.ts (6 tests) 28ms + ✓ test/unit/adapters/canonical-json.test.ts (17 tests) 30ms + ✓ test/unit/mcp/run-capture.test.ts (5 tests) 471ms + ✓ tests/playback/0089-logical-warp-writer-lanes.test.ts (3 tests) 39ms + ✓ test/unit/operations/sludge-detector.test.ts (3 tests) 49ms + ✓ test/unit/cli/command-parser.test.ts (8 tests) 30ms + ✓ tests/playback/0083-public-api-contract-and-stability-policy.test.ts (4 tests) 27ms + ✓ test/unit/operations/cross-session-resume.test.ts (5 tests) 154ms + ✓ test/unit/operations/review-cooldown-status.test.ts (6 tests) 28ms + ✓ tests/playback/BADCODE_repo-path-resolver-symlink-parent-write-escape.test.ts (4 tests) 30ms + ✓ test/unit/mcp/worktree-identity-canonicalization.test.ts (5 tests) 71ms + ✓ tests/playback/0085-projection-bundle-over-buffer-head-for-jedit.test.ts (4 tests) 54ms + ✓ test/unit/warp/warp-structural-blame.test.ts (4 tests) 795ms + ✓ tracks signature changes in blame history 308ms + ✓ test/unit/ports/filesystem-contract.test.ts (10 tests) 35ms + ✓ tests/playback/0084-projection-basis-and-head-identity-for-jedit-warm-truth.test.ts (4 tests) 55ms + ✓ test/unit/warp/structural-drift-detection.test.ts (6 tests) 27ms + ✓ test/unit/warp/full-ast.test.ts (1 test) 148ms + ✓ test/unit/mcp/semantic-transition-guidance.test.ts (5 tests) 26ms + ✓ test/unit/ports/guards.test.ts (11 tests) 27ms + ✓ tests/playback/SURFACE_review-cooldown-status.test.ts (2 tests) 62ms + ✓ test/unit/release/path-ops-boundary-allowlist.test.ts (2 tests) 34ms + ✓ test/unit/mcp/runtime-causal-context.test.ts (5 tests) 28ms + ✓ test/integration/mcp/daemon-bridge.test.ts (1 test) 657ms + ✓ test/unit/policy/budget.test.ts (7 tests) 29ms + ✓ tests/playback/CORE_rewrite-structural-blame-to-use-warp-worldline-provenance.test.ts (5 tests) 27ms + ✓ test/unit/adapters/rotating-ndjson-log.test.ts (3 tests) 41ms + ✓ test/unit/mcp/typed-seams.test.ts (8 tests) 28ms + ✓ test/unit/operations/file-outline.test.ts (7 tests) 61ms + ✓ tests/playback/0079-repo-topology-for-api-cli-and-mcp-primary-adapters.test.ts (6 tests) 27ms + ✓ test/unit/cli/git-graft-enhance-render.test.ts (3 tests) 27ms + ✓ test/unit/ports/structural-reading.test.ts (2 tests) 25ms + ✓ test/unit/warp/outline-diff-trailer.test.ts (6 tests) 27ms + ✓ tests/playback/0090-symbol-identity-and-rename-continuity.test.ts (3 tests) 44ms + ✓ test/unit/policy/thresholds.test.ts (10 tests) 29ms + ✓ tests/playback/WARP_symbol-history-timeline.test.ts (1 test) 818ms + ✓ Can I read a human symbol timeline from indexed WARP history? 796ms + ✓ test/unit/mcp/warp-pool.test.ts (3 tests) 26ms + ✓ test/unit/method/backlog-dependency-dag.test.ts (2 tests) 40ms + ✓ test/unit/contracts/graft-structural-history-schema.test.ts (4 tests) 28ms + ✓ test/unit/cli/daemon-status-render.test.ts (2 tests) 22ms + ✓ test/unit/policy/session-depth.test.ts (7 tests) 29ms + ✓ test/unit/warp/traverse-hydrate.test.ts (2 tests) 204ms + ✓ test/unit/cli/index-cmd.test.ts (3 tests) 144ms + ✓ test/unit/mcp/background-indexing.test.ts (2 tests) 2678ms + ✓ monitor nudge triggers an immediate tick that indexes 2468ms + ✓ test/unit/helpers/mcp.test.ts (2 tests) 211ms + ✓ test/unit/operations/projection-safety.test.ts (11 tests) 29ms + ✓ tests/playback/0086-release-gate-for-three-surface-capability-posture.test.ts (3 tests) 27ms + ✓ test/unit/operations/deterministic-replay.test.ts (6 tests) 29ms + ✓ test/unit/operations/agent-handoff.test.ts (4 tests) 27ms + ✓ test/unit/mcp/workspace-read-observation.test.ts (4 tests) 29ms + ✓ tests/playback/WARP_dead-symbol-detection.test.ts (1 test) 942ms + ✓ Can I list symbols removed from indexed history and not re-added? 920ms + ✓ test/unit/operations/state.test.ts (5 tests) 30ms + ✓ test/unit/operations/session-filtration.test.ts (8 tests) 26ms + ✓ test/unit/git/agent-worktree-hygiene.test.ts (4 tests) 87ms + ✓ test/integration/mcp/daemon-status-cli.test.ts (1 test) 148ms + ✓ test/unit/mcp/semantic-transition-summary.test.ts (2 tests) 27ms + ✓ test/unit/operations/semantic-drift.test.ts (4 tests) 27ms + ✓ test/unit/mcp/path-boundary-runtime.test.ts (3 tests) 241ms + ✓ tests/method/0067-async-git-client-via-plumbing.test.ts (2 tests) 72ms + ✓ test/unit/operations/footprint-parallelism.test.ts (6 tests) 27ms + ✓ test/unit/mcp/context-guard.test.ts (6 tests) 27ms + ✓ test/unit/release/package-library-surface.test.ts (6 tests) 28ms + ✓ test/unit/adapters/node-paths.test.ts (14 tests) 29ms + ✓ test/unit/cli/structural-blame-render.test.ts (2 tests) 33ms + ✓ test/unit/library/repo-workspace.test.ts (2 tests) 84ms + ✓ test/unit/mcp/project-root-resolution.test.ts (3 tests) 129ms + ✓ test/unit/operations/session-replay.test.ts (5 tests) 26ms + ✓ test/unit/cli/structural-test-coverage-render.test.ts (1 test) 30ms + ✓ test/unit/release/security-gate.test.ts (2 tests) 26ms + ✓ test/unit/release/v080-witness.test.ts (3 tests) 25ms + ✓ test/unit/operations/read-range.test.ts (6 tests) 26ms + ✓ test/unit/operations/capture-range.test.ts (5 tests) 26ms + ✓ test/unit/operations/teaching-hints.test.ts (5 tests) 26ms + ✓ test/unit/mcp/structural-review-cold-warp.test.ts (1 test) 172ms + ✓ tests/playback/0092-daemon-session-directory-cleanup.test.ts (3 tests) 49ms + ✓ test/unit/warp/sym-id-codec.test.ts (5 tests) 28ms + ✓ test/unit/cli/structural-review-render.test.ts (1 test) 32ms + ✓ test/unit/git/version-guard.test.ts (4 tests) 26ms + ✓ tests/playback/0093-structural-queries-use-query-builder.test.ts (4 tests) 26ms + ✓ test/unit/mcp/precision-warp-slice-first.test.ts (1 test) 141ms + ✓ test/unit/policy/graftignore.test.ts (5 tests) 28ms + ✓ test/unit/ports/warp-plumbing-conformance.test.ts (6 tests) 26ms + ✓ test/unit/mcp/daemon-stdio-bridge.test.ts (3 tests) 32ms + ✓ test/unit/operations/horizon-of-readability.test.ts (4 tests) 25ms + ✓ test/unit/operations/adaptive-projection.test.ts (5 tests) 26ms + ✓ test/unit/session/tripwire-value-object.test.ts (7 tests) 27ms + ✓ test/unit/warp/writer-id.test.ts (5 tests) 26ms + ✓ test/unit/scripts/isolated-test-args.test.ts (4 tests) 26ms + ✓ test/unit/ports/codec-contract.test.ts (7 tests) 27ms + ✓ test/unit/release/agent-worktree-hygiene-gate.test.ts (1 test) 26ms + ✓ test/unit/api/tool-bridge.test.ts (3 tests) 36ms + ✓ test/unit/warp/open.test.ts (2 tests) 130ms + ✓ test/unit/release/package-files-exist.test.ts (1 test) 25ms + ✓ test/unit/adapters/node-git.test.ts (1 test) 46ms + ✓ test/unit/release/code-standards.test.ts (1 test) 25ms + ✓ tests/playback/0094-references-no-getEdges.test.ts (3 tests) 25ms + ✓ test/unit/cli/dead-symbols-render.test.ts (1 test) 28ms + ✓ test/unit/parser/extractor-common.test.ts (1 test) 25ms + ✓ test/unit/release/package-docs.test.ts (1 test) 24ms + ✓ test/unit/cli/activity-render.test.ts (1 test) 36ms + ✓ tests/method/0069-graft-map-bounded-overview.test.ts (2 tests) 227ms + ✓ test/unit/version.test.ts (1 test) 26ms + + Test Files 223 passed (223) + Tests 1620 passed (1620) + Start at 09:01:12 + Duration 76.22s (transform 2.85s, setup 5.51s, import 35.00s, tests 92.14s, environment 15ms) + +#0 building with "desktop-linux" instance using docker driver + +#1 [internal] load build definition from Dockerfile +#1 transferring dockerfile: 1.27kB done +#1 DONE 0.0s + +#2 [internal] load metadata for docker.io/library/node:22-alpine +#2 DONE 0.4s + +#3 [internal] load .dockerignore +#3 transferring context: 97B done +#3 DONE 0.0s + +#4 [deps 1/6] FROM docker.io/library/node:22-alpine@sha256:968df39aedcea65eeb078fb336ed7191baf48f972b4479711397108be0966920 +#4 DONE 0.0s + +#5 [internal] load build context +#5 transferring context: 139.11kB 0.1s done +#5 DONE 0.1s + +#6 [deps 6/6] RUN pnpm install --frozen-lockfile --prod=false +#6 CACHED + +#7 [deps 2/6] WORKDIR /app +#7 CACHED + +#8 [deps 4/6] RUN corepack enable && corepack prepare pnpm@10.30.0 --activate +#8 CACHED + +#9 [deps 5/6] COPY package.json pnpm-lock.yaml ./ +#9 CACHED + +#10 [deps 3/6] RUN apk add --no-cache git +#10 CACHED + +#11 [build 1/3] WORKDIR /app +#11 CACHED + +#12 [build 2/3] COPY . . +#12 DONE 0.1s + +#13 [build 3/3] RUN pnpm build +#13 0.291 +#13 0.291 > @flyingrobots/graft@0.8.0 build /app +#13 0.291 > tsc -p tsconfig.build.json +#13 0.291 +#13 DONE 5.9s + +#14 exporting to image +#14 exporting layers 0.1s done +#14 writing image sha256:5761bdf82a353626cfda44d1bdd540a239df1f73cef2c74e5b5279dea79a1508 done +#14 naming to docker.io/library/graft-test:local done +#14 DONE 0.1s + +View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/j6ysw3r2il9750vpn3hvconzj + +``` + +## Drift Results + +```text +No playback-question drift found. +Scanned 1 active cycle, 0 playback questions, 306 test descriptions. +Search basis: normalized match, semantic normalization, or high-confidence token similarity in tests/**/*.test.* and tests/**/*.spec.* descriptions. + +``` + +## Automated Capture + +- [x] Test command succeeded: `npm test`. +- [x] Drift check passed: `method drift SURFACE_opened-workspace-paths`. + +## Human Verification + +To reproduce this verification independently from the workspace root: + +```sh +npm test +method drift SURFACE_opened-workspace-paths +``` + +Expected: the recorded test command exits successfully. +Expected: the recorded drift command exits 0. diff --git a/src/mcp/tool-registry.ts b/src/mcp/tool-registry.ts index c266cd78..ba27ad2d 100644 --- a/src/mcp/tool-registry.ts +++ b/src/mcp/tool-registry.ts @@ -63,6 +63,7 @@ export const TOOL_REGISTRY: readonly ToolDefinition[] = [ causalAttachTool, workspaceOpenTool, workspaceListOpenedTool, + workspaceStatusTool, statsTool, explainTool, setBudgetTool, @@ -96,7 +97,6 @@ export const DAEMON_TOOL_REGISTRY: readonly ToolDefinition[] = [ workspaceAuthorizationsTool, workspaceRevokeTool, workspaceBindTool, - workspaceStatusTool, workspaceRebindTool, ]; diff --git a/src/mcp/tools/workspace-open.ts b/src/mcp/tools/workspace-open.ts index ede9b0cf..9baf6726 100644 --- a/src/mcp/tools/workspace-open.ts +++ b/src/mcp/tools/workspace-open.ts @@ -12,11 +12,25 @@ export const workspaceOpenTool: ToolDefinition = { }, createHandler(): ToolHandler { return async (args, ctx) => { - return ctx.respond("workspace_open", { ...await ctx.openWorkspace({ + const request = { cwd: args["cwd"] as string, activate: args["activate"] as boolean | undefined, runCapture: args["runCapture"] as boolean | undefined, - }) }); + }; + if (ctx.getWorkspaceStatus().sessionMode === "daemon") { + const authorization = await ctx.authorizeWorkspace(request); + if (!authorization.ok) { + return ctx.respond("workspace_open", { + ok: false, + changed: false, + freshSessionSlice: false, + ...ctx.getWorkspaceStatus(), + errorCode: authorization.errorCode, + error: authorization.error, + }); + } + } + return ctx.respond("workspace_open", { ...await ctx.openWorkspace(request) }); }; }, }; diff --git a/src/mcp/workspace-router.ts b/src/mcp/workspace-router.ts index 90231545..9ff1f14a 100644 --- a/src/mcp/workspace-router.ts +++ b/src/mcp/workspace-router.ts @@ -107,6 +107,17 @@ interface OpenedWorkspaceRecord extends ResolvedWorkspace { readonly lastActivatedAt: string | null; } +function workspaceCapabilityProfilesEqual( + left: WorkspaceCapabilityProfile, + right: WorkspaceCapabilityProfile, +): boolean { + return left.boundedReads === right.boundedReads + && left.structuralTools === right.structuralTools + && left.precisionTools === right.precisionTools + && left.stateBookmarks === right.stateBookmarks + && left.runCapture === right.runCapture; +} + export class WorkspaceRouter { private bindingCounter = 0; private sliceIdCounter = 0; @@ -310,7 +321,10 @@ export class WorkspaceRouter { }; } - if (this.currentBinding?.worktreeId === resolved.worktreeId) { + if ( + this.currentBinding?.worktreeId === resolved.worktreeId + && workspaceCapabilityProfilesEqual(this.currentBinding.capabilityProfile, capabilityProfile) + ) { this.noteWorkspaceActivated(resolved.worktreeId); return { ok: true, @@ -747,14 +761,16 @@ export class WorkspaceRouter { ): boolean { const now = new Date().toISOString(); const current = this.openedWorkspaces.get(resolved.worktreeId); + const nextSource = current?.source === "startup" ? current.source : source; const changed = current?.repoId !== resolved.repoId || current.worktreeRoot !== resolved.worktreeRoot || current.gitCommonDir !== resolved.gitCommonDir - || current.source !== source; + || current.source !== nextSource + || !workspaceCapabilityProfilesEqual(current.capabilityProfile, capabilityProfile); this.openedWorkspaces.set(resolved.worktreeId, { ...resolved, capabilityProfile, - source: current?.source === "startup" ? current.source : source, + source: nextSource, openedAt: current?.openedAt ?? now, lastActivatedAt: activated ? now : (current?.lastActivatedAt ?? null), }); diff --git a/tests/playback/SURFACE_opened-workspace-paths.test.ts b/tests/playback/SURFACE_opened-workspace-paths.test.ts new file mode 100644 index 00000000..b5a0df3c --- /dev/null +++ b/tests/playback/SURFACE_opened-workspace-paths.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "node:fs"; +import { cleanupTestRepo, createCommittedTestRepo } from "../../test/helpers/git.js"; +import { createManagedDaemonServer, createServerInRepo, parse } from "../../test/helpers/mcp.js"; + +const cleanups: (() => void)[] = []; + +afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()!(); + } +}); + +function createRepo(prefix: string, marker: string): string { + const repoDir = createCommittedTestRepo(prefix, { + "app.ts": [ + `export const workspaceMarker = "${marker}";`, + `export function ${marker}Thing() {`, + " return workspaceMarker;", + "}", + "", + ].join("\n"), + }); + const realRepoDir = fs.realpathSync.native(repoDir); + cleanups.push(() => { + cleanupTestRepo(realRepoDir); + }); + return realRepoDir; +} + +interface OpenedWorkspace { + readonly worktreeRoot: string; + readonly source: "startup" | "session_opened" | "daemon_authorized"; + readonly active: boolean; + readonly capabilityProfile: { readonly runCapture: boolean }; +} + +interface OpenedWorkspaceList { + readonly activeWorktreeId: string | null; + readonly workspaces: readonly OpenedWorkspace[]; +} + +function workspaceFor(list: OpenedWorkspaceList, worktreeRoot: string): OpenedWorkspace { + const workspace = list.workspaces.find((candidate) => candidate.worktreeRoot === worktreeRoot); + if (workspace === undefined) { + throw new Error(`Missing opened workspace for ${worktreeRoot}`); + } + return workspace; +} + +describe("SURFACE opened workspace paths playback", () => { + it("Can I tell an agent to work in another repo and have it open that path in the existing Graft MCP session?", async () => { + const initialRepo = createRepo("graft-playback-open-initial-", "alpha"); + const nextRepo = createRepo("graft-playback-open-next-", "beta"); + const server = createServerInRepo(initialRepo); + + const opened = parse(await server.callTool("workspace_open", { cwd: nextRepo })); + expect(opened).toEqual(expect.objectContaining({ + ok: true, + freshSessionSlice: true, + worktreeRoot: nextRepo, + })); + + const read = parse(await server.callTool("safe_read", { path: "app.ts" })); + expect(read["content"]).toContain("betaThing"); + }); + + it("Can I inspect which paths are opened and which one is active?", async () => { + const initialRepo = createRepo("graft-playback-list-initial-", "alpha"); + const nextRepo = createRepo("graft-playback-list-next-", "beta"); + const server = createServerInRepo(initialRepo); + + const opened = parse(await server.callTool("workspace_open", { cwd: nextRepo })); + const listed = parse(await server.callTool("workspace_list_opened", {})) as unknown as OpenedWorkspaceList; + + expect(listed.activeWorktreeId).toBe(opened["worktreeId"]); + expect(workspaceFor(listed, initialRepo)).toEqual(expect.objectContaining({ + source: "startup", + active: false, + })); + expect(workspaceFor(listed, nextRepo)).toEqual(expect.objectContaining({ + source: "session_opened", + active: true, + })); + }); + + it("Does switching repos feel like opening another workspace, not editing low-level daemon authorization state by hand?", async () => { + const initialRepo = createRepo("graft-playback-ergonomic-initial-", "alpha"); + const nextRepo = createRepo("graft-playback-ergonomic-next-", "beta"); + const server = createServerInRepo(initialRepo); + + expect(server.getRegisteredTools()).toContain("workspace_open"); + expect(server.getRegisteredTools()).toContain("workspace_list_opened"); + expect(server.getRegisteredTools()).toContain("workspace_status"); + expect(server.getRegisteredTools()).not.toContain("workspace_authorize"); + expect(server.getRegisteredTools()).not.toContain("workspace_bind"); + + const inactiveOpen = parse(await server.callTool("workspace_open", { + cwd: nextRepo, + activate: false, + })); + expect(inactiveOpen).toEqual(expect.objectContaining({ + ok: true, + freshSessionSlice: false, + worktreeRoot: initialRepo, + })); + + const activatedOpen = parse(await server.callTool("workspace_open", { cwd: nextRepo })); + expect(activatedOpen).toEqual(expect.objectContaining({ + ok: true, + freshSessionSlice: true, + worktreeRoot: nextRepo, + })); + }); + + it("Can a repo-local MCP server open a second git worktree and run `safe_read`, `graft_map`, and `code_find` against it without process restart?", async () => { + const initialRepo = createRepo("graft-playback-tools-initial-", "alpha"); + const nextRepo = createRepo("graft-playback-tools-next-", "beta"); + const server = createServerInRepo(initialRepo); + + await server.callTool("workspace_open", { cwd: nextRepo }); + + const read = parse(await server.callTool("safe_read", { path: "app.ts" })); + expect(read["content"]).toContain("betaThing"); + + const map = parse(await server.callTool("graft_map", {})) as { + files?: { path: string; symbols: { name: string }[] }[]; + }; + expect(map.files?.flatMap((file) => file.symbols.map((symbol) => symbol.name))).toContain("betaThing"); + + const find = parse(await server.callTool("code_find", { query: "betaThing" })) as { + total?: number; + matches?: { path: string; name: string }[]; + }; + expect(find.total).toBeGreaterThan(0); + expect(find.matches).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: "app.ts", name: "betaThing" }), + ])); + }); + + it("Does activation reset session-local cache, budget, saved state, metrics, and repo-state tracking instead of bleeding state across worktrees?", async () => { + const initialRepo = createRepo("graft-playback-reset-initial-", "alpha"); + const nextRepo = createRepo("graft-playback-reset-next-", "beta"); + const server = createServerInRepo(initialRepo); + + await server.callTool("state_save", { content: "alpha state" }); + await server.callTool("set_budget", { bytes: 100_000 }); + const before = parse(await server.callTool("state_load", {})); + expect(before["content"]).toBe("alpha state"); + + const opened = parse(await server.callTool("workspace_open", { cwd: nextRepo })); + expect(opened["freshSessionSlice"]).toBe(true); + + const after = parse(await server.callTool("state_load", {})); + expect(after["content"]).toBeNull(); + + const read = parse(await server.callTool("safe_read", { path: "app.ts" })); + const receipt = read["_receipt"] as Record; + expect(receipt["budget"]).toBeUndefined(); + expect(read["content"]).toContain("betaThing"); + }); + + it("Does daemon mode reuse the existing authorization registry and capability profile rather than creating a parallel allowlist?", async () => { + const repoDir = createRepo("graft-playback-daemon-open-", "daemon"); + const server = createManagedDaemonServer(cleanups); + + const initialOpen = parse(await server.callTool("workspace_open", { + cwd: repoDir, + })); + expect(initialOpen).toEqual(expect.objectContaining({ + ok: true, + capabilityProfile: expect.objectContaining({ runCapture: false }), + })); + + const opened = parse(await server.callTool("workspace_open", { + cwd: repoDir, + runCapture: true, + })); + expect(opened).toEqual(expect.objectContaining({ + ok: true, + freshSessionSlice: true, + worktreeRoot: repoDir, + capabilityProfile: expect.objectContaining({ runCapture: true }), + })); + expect(opened["openedWorkspace"]).toEqual(expect.objectContaining({ + source: "daemon_authorized", + capabilityProfile: expect.objectContaining({ runCapture: true }), + })); + + const authorizations = parse(await server.callTool("workspace_authorizations", {})) as { + workspaces?: OpenedWorkspace[]; + }; + expect(authorizations.workspaces).toEqual(expect.arrayContaining([ + expect.objectContaining({ + worktreeRoot: repoDir, + capabilityProfile: expect.objectContaining({ runCapture: true }), + }), + ])); + + const capture = parse(await server.callTool("run_capture", { + command: "printf 'daemon-ok'", + tail: 1, + })); + expect(capture["output"]).toContain("daemon-ok"); + }); + + it("Do repo-scoped tools keep their current repo-relative schemas?", async () => { + const initialRepo = createRepo("graft-playback-relative-initial-", "alpha"); + const nextRepo = createRepo("graft-playback-relative-next-", "beta"); + const server = createServerInRepo(initialRepo); + + await server.callTool("workspace_open", { cwd: nextRepo }); + + const read = parse(await server.callTool("safe_read", { path: "app.ts" })); + const find = parse(await server.callTool("code_find", { query: "betaThing" })) as { + matches?: { path: string; name: string }[]; + }; + + expect(read["path"]).toBe(`${nextRepo}/app.ts`); + expect(read["content"]).toContain("betaThing"); + expect(find.matches).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: "app.ts", name: "betaThing" }), + ])); + }); +}); From b128a370cf31e77a82466d3b9d3f63c5c032a69f Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 02:25:16 -0700 Subject: [PATCH 06/15] docs(backlog): record stale local history bug --- ...l-history-stale-after-branch-transition.md | 172 ++++++++++++++ docs/method/backlog/dependency-dag.dot | 3 +- docs/method/backlog/dependency-dag.svg | 212 +++++++++--------- 3 files changed, 283 insertions(+), 104 deletions(-) create mode 100644 docs/method/backlog/bad-code/WARP_bijou-local-history-stale-after-branch-transition.md diff --git a/docs/method/backlog/bad-code/WARP_bijou-local-history-stale-after-branch-transition.md b/docs/method/backlog/bad-code/WARP_bijou-local-history-stale-after-branch-transition.md new file mode 100644 index 00000000..37b0a3c7 --- /dev/null +++ b/docs/method/backlog/bad-code/WARP_bijou-local-history-stale-after-branch-transition.md @@ -0,0 +1,172 @@ +--- +title: "Bijou local history stays stale after branch transition" +feature: warp +kind: bad-code +legend: WARP +lane: bad-code +priority: 1 +effort: M +status: open +reported: 2026-05-26 +example_repo: "/Users/james/git/bijou" +--- + +# Bijou local history stays stale after branch transition + +## Problem + +Graft's repo-local causal and structural-history surfaces can present stale or +incorrect state after a target repository has moved through a PR merge, +fast-forward, checkout, and branch rename sequence. + +The concrete witness is the Bijou repo at `/Users/james/git/bijou`. + +On 2026-05-26, Git reported Bijou was clean on +`cycle/dx-035-input-action-system` at `247f1e95`, equal to both `main` and +`origin/main`. That merge commit includes PR #117, whose local commit +`4372170c` added `packages/bijou-tui/src/input-map.ts` and the AppFrame +double-Tab footer toggle. + +Graft, however, still surfaced older local-history truth: + +- `state_load` returned a DF-071 checkpoint from + `cycle/df-071-dogfood-block-authored-surfaces`, saying that three DF-071 + commits remained. +- `graft_log` did not expose PR #117 or the `input-map.ts` structural addition; + a path-scoped log for `packages/bijou-tui/src/input-map.ts` reported no + structural changes in the recent commit window. +- `causal_status` reported an active `rebase_phase` even though Git had no + active rebase metadata and `git status` was clean. +- `graft_diff main..HEAD` and `graft_since 247f1e95..HEAD` correctly reported + an empty current-branch diff, so ref-view truth and local-history truth were + disagreeing. + +This is a recovery hazard. An agent trying to resume work could believe an +old cycle is still active, miss the most recent shipped commit, or stop because +Graft says a rebase is in progress. + +## Snapshot + +Captured from `/Users/james/git/bijou` on 2026-05-26. + +Git current state: + +```text +$ git status --short --branch +## cycle/dx-035-input-action-system + +$ git rev-parse --short HEAD main origin/main +247f1e95 +247f1e95 +247f1e95 +``` + +Recent Git movement: + +```text +247f1e95 HEAD@{2026-05-25 13:52:06 -0700}: Branch: renamed refs/heads/cycle/df-073-dialog-theme-backgrounds to refs/heads/cycle/dx-035-input-action-system +247f1e95 HEAD@{2026-05-25 13:50:58 -0700}: Branch: renamed refs/heads/cycle/dx-035-input-action-system to refs/heads/cycle/df-073-dialog-theme-backgrounds +247f1e95 HEAD@{2026-05-25 13:48:57 -0700}: checkout: moving from main to cycle/dx-035-input-action-system +247f1e95 HEAD@{2026-05-25 13:48:57 -0700}: pull --ff-only origin main: Fast-forward +097a59fc HEAD@{2026-05-25 13:48:55 -0700}: checkout: moving from cycle/df-072-dogfood-block-preview-polish to main +4372170c HEAD@{2026-05-25 11:12:34 -0700}: commit: feat(tui): add semantic input gestures for footer toggle +9d4d42f6 HEAD@{2026-05-25 08:17:21 -0700}: commit: fix(dogfood): flatten standard block previews +``` + +PR #117 structural witness from Git: + +```text +$ git show --name-status --oneline 4372170c -- \ + packages/bijou-tui/src/input-map.ts \ + packages/bijou-tui/src/input-map.test.ts \ + packages/bijou-tui/src/app-frame.ts \ + docs/CHANGELOG.md + +4372170c feat(tui): add semantic input gestures for footer toggle +M docs/CHANGELOG.md +M packages/bijou-tui/src/app-frame.ts +A packages/bijou-tui/src/input-map.test.ts +A packages/bijou-tui/src/input-map.ts +``` + +No Git rebase state exists: + +```text +$ test -d .git/rebase-merge; echo rebase_merge=$? +rebase_merge=1 +$ test -d .git/rebase-apply; echo rebase_apply=$? +rebase_apply=1 +$ find .git -maxdepth 2 \( -name rebase-merge -o -name rebase-apply -o -name MERGE_HEAD -o -name CHERRY_PICK_HEAD \) -print +# no output +``` + +Graft observations from the same repo-local MCP session: + +```text +state_load.content: +Bijou DF-071 branch checkpoint: cwd /Users/james/git/bijou, branch +cycle/df-071-dogfood-block-authored-surfaces, clean worktree after 12 commits +over origin/main. ... Need 3 more commits to reach 15 slices ... + +causal_status.semanticTransition: +kind: rebase_phase +summary: Rebase started and is now inspectable as active repo state. + +graft_diff(base: "main", head: "HEAD"): +files: [] + +graft_since(base: "247f1e95", head: "HEAD"): +summary: +0 added, -0 removed, ~0 changed across 0 files + +graft_log(path: "packages/bijou-tui/src/input-map.ts"): +recent entries reported "No structural changes" and did not include +4372170c or merge commit 247f1e95. +``` + +The raw local-history artifacts are still available in the example repo: + +- `/Users/james/git/bijou/.graft/state.md` +- `/Users/james/git/bijou/.graft/logs/mcp-runtime.ndjson` +- `/Users/james/git/bijou/.graft/local-history/continuity:932eca833966d22f.json` + +## Risk + +Graft is specifically used during context recovery. If recovery surfaces mix +current ref truth with stale local-history truth, agents will make bad branch, +cycle, and safety decisions. + +The false rebase signal is especially risky in repositories that prohibit +rebases. A user or agent may stop work unnecessarily, or worse, try to "fix" a +nonexistent rebase state. + +## Desired Outcome + +Graft should make current Git authority and persisted local-history authority +agree, or loudly mark the persisted side stale. + +The minimum acceptable behavior is: + +- `state_load` includes the branch, HEAD, and timestamp of the saved state and + labels it stale when the current repo branch or HEAD differs. +- `causal_status` only reports an active rebase when Git metadata such as + `rebase-merge` or `rebase-apply` exists. +- `graft_log` and path-scoped structural history either include recent commits + after PR merge/branch transitions or report that the structural index is + stale relative to current Git HEAD. +- Ref-view tools such as `graft_diff` and local-history tools do not silently + disagree without an explicit diagnostic. + +## Acceptance Criteria + +- Add a regression fixture based on the Bijou snapshot above or an equivalent + minimal repo-local history fixture. +- A branch rename after a fast-forwarded merge does not leave `state_load` + looking authoritative for an older branch. +- A clean repository with no rebase metadata cannot produce + `semanticTransition.kind = "rebase_phase"`. +- Path-scoped `graft_log` can see a recently added TypeScript file after a PR + merge, or it emits a machine-readable stale-index reason. +- `causal_status.nextAction` does not instruct the caller to continue a rebase + unless Git confirms one is active. +- The diagnostic includes enough evidence for an agent to prefer Git current + state over stale local-history state when they conflict. diff --git a/docs/method/backlog/dependency-dag.dot b/docs/method/backlog/dependency-dag.dot index 3e6a8cbf..37b33353 100644 --- a/docs/method/backlog/dependency-dag.dot +++ b/docs/method/backlog/dependency-dag.dot @@ -48,10 +48,11 @@ digraph backlog { } subgraph cluster_bad_code { - label="bad-code (2)" labeljust=l fontsize=9 fontcolor="#555555" + label="bad-code (3)" labeljust=l fontsize=9 fontcolor="#555555" style=rounded color="#cccccc" bgcolor="#fafafa" 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] bad_code_CLEAN_wesley_cli_not_hermetic_in_graft_ci [label="CLEAN-wesley-cli-not-hermetic-in-graft-ci - M" fillcolor="#F6B3B3" penwidth=2] + bad_code_WARP_bijou_local_history_stale_after_branch_transition [label="WARP-bijou-local-history-stale-after-branch-transition - M" fillcolor="#F6B3B3" penwidth=2] } subgraph cluster_cool_ideas { diff --git a/docs/method/backlog/dependency-dag.svg b/docs/method/backlog/dependency-dag.svg index 094f5296..267e6a96 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,8 +22,8 @@ cluster_bad_code - -bad-code (2) + +bad-code (3) cluster_cool_ideas @@ -42,8 +42,8 @@ cluster_legend - -Legend + +Legend @@ -208,7 +208,7 @@ WARP-symbol-history-timeline - S - + cool_ideas_WARP_temporal_structural_search WARP-temporal-structural-search - M @@ -232,170 +232,176 @@ CLEAN-wesley-cli-not-hermetic-in-graft-ci - M - + +bad_code_WARP_bijou_local_history_stale_after_branch_transition + +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 - + 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_CORE_agent_handoff_protocol CORE-agent-handoff-protocol - M - + cool_ideas_CORE_auto_focus CORE-auto-focus - L - + cool_ideas_CORE_capture_range CORE-capture-range - S - + 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_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 - + 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_wire_primitives_into_runtime CORE-wire-primitives-into-runtime - M - + cool_ideas_monitor_tick_ceiling_tracking monitor-tick-ceiling-tracking - S - + cool_ideas_WARP_background_indexing WARP-background-indexing - M @@ -408,13 +414,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 @@ -427,85 +433,85 @@ blocked_by/blocking - + cool_ideas_SURFACE_attach_to_existing_causal_session SURFACE-attach-to-existing-causal-session - 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 - + cool_ideas_SURFACE_init_dry_run 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 - + cool_ideas_SURFACE_non_codex_instruction_bootstrap_parity SURFACE-non-codex-instruction-bootstrap-parity - M - + cool_ideas_SURFACE_offer_rename_refactor SURFACE-offer-rename-refactor - L - + cool_ideas_SURFACE_opened_workspace_paths SURFACE-opened-workspace-paths - M - + cool_ideas_SURFACE_terminal_activity_browser_tui SURFACE-terminal-activity-browser-tui - L - + cool_ideas_traverse_plus_query_hydration_helper traverse-plus-query-hydration-helper - S - + cool_ideas_WARP_adaptive_projection_selection WARP-adaptive-projection-selection - L - + cool_ideas_WARP_agent_action_provenance WARP-agent-action-provenance - XL @@ -518,7 +524,7 @@ blocked_by/blocking - + cool_ideas_WARP_causal_write_tracking WARP-causal-write-tracking - L @@ -531,7 +537,7 @@ blocked_by/blocking - + cool_ideas_WARP_intent_and_decision_events WARP-intent-and-decision-events - M @@ -544,7 +550,7 @@ blocked_by/blocking - + cool_ideas_WARP_provenance_dag WARP-provenance-dag - L @@ -557,31 +563,31 @@ blocked_by/blocking - + cool_ideas_WARP_auto_breaking_change_detection WARP-auto-breaking-change-detection - L - + cool_ideas_WARP_budget_elasticity WARP-budget-elasticity - M - + cool_ideas_WARP_causal_blame_for_staged_artifacts WARP-causal-blame-for-staged-artifacts - L - + cool_ideas_WARP_codebase_entropy_trajectory WARP-codebase-entropy-trajectory - M - + cool_ideas_WARP_counterfactual_refactoring WARP-counterfactual-refactoring - XL @@ -594,13 +600,13 @@ blocked_by/blocking - + cool_ideas_WARP_codebase_signature WARP-codebase-signature - L - + cool_ideas_WARP_structural_impact_prediction WARP-structural-impact-prediction - XL @@ -613,55 +619,55 @@ blocked_by/blocking - + cool_ideas_WARP_degeneracy_warning WARP-degeneracy-warning - M - + cool_ideas_WARP_drift_sentinel WARP-drift-sentinel - M - + cool_ideas_WARP_footprint_parallelism WARP-footprint-parallelism - XL - + cool_ideas_WARP_graft_pack WARP-graft-pack - M - + cool_ideas_WARP_grouped_aggregate_queries WARP-grouped-aggregate-queries - M - + cool_ideas_WARP_intentional_degeneracy_privacy WARP-intentional-degeneracy-privacy - M - + cool_ideas_WARP_minimum_viable_context WARP-minimum-viable-context - M - + cool_ideas_WARP_outline_diff_commit_trailer WARP-outline-diff-commit-trailer - S - + cool_ideas_WARP_projection_safety_classes WARP-projection-safety-classes - M @@ -674,7 +680,7 @@ blocked_by/blocking - + cool_ideas_WARP_reasoning_trace_replay WARP-reasoning-trace-replay - M @@ -687,43 +693,43 @@ blocked_by/blocking - + cool_ideas_WARP_rulial_heat_map WARP-rulial-heat-map - L - + cool_ideas_WARP_semantic_drift_in_sessions WARP-semantic-drift-in-sessions - M - + cool_ideas_WARP_semantic_merge_conflict_prediction WARP-semantic-merge-conflict-prediction - L - + cool_ideas_WARP_session_filtration WARP-session-filtration - L - + cool_ideas_WARP_shadow_structural_workspaces WARP-shadow-structural-workspaces - XL - + cool_ideas_WARP_speculative_merge WARP-speculative-merge - XL - + cool_ideas_WARP_stale_docs_checker WARP-stale-docs-checker - M @@ -736,25 +742,25 @@ blocked_by/blocking - + cool_ideas_WARP_structural_drift_detection WARP-structural-drift-detection - M - + cool_ideas_WARP_symbol_heatmap WARP-symbol-heatmap - M - + cool_ideas_WARP_technical_debt_curvature WARP-technical-debt-curvature - L - + external_git_warp_observer_geometry_ladder__Rung_2_4_ git-warp observer geometry ladder (Rung 2-4) @@ -767,7 +773,7 @@ blocked_by_external - + unresolved_CLEAN_CODE_export_diff_semver_signature_as_patch missing: CLEAN_CODE_export-diff-semver-signature-as-patch @@ -787,44 +793,44 @@ 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 From 7ba63ada6a7a0118ab445a0b63dcb86d74077530 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 14:29:59 -0700 Subject: [PATCH 07/15] fix: patch audited transitive dependencies --- package.json | 4 +++- pnpm-lock.yaml | 20 +++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 50ad1527..dfad55ba 100644 --- a/package.json +++ b/package.json @@ -112,10 +112,12 @@ "onlyBuiltDependencies": [], "overrides": { "vite": "^8.0.5", + "brace-expansion": "5.0.6", "hono": "4.12.18", "fast-uri": "3.1.2", "ip-address": "10.2.0", - "postcss": "8.5.12" + "postcss": "8.5.12", + "qs": "6.15.2" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95c2695f..8e447141 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,10 +6,12 @@ settings: overrides: vite: ^8.0.5 + brace-expansion: 5.0.6 hono: 4.12.18 fast-uri: 3.1.2 ip-address: 10.2.0 postcss: 8.5.12 + qs: 6.15.2 importers: @@ -728,8 +730,8 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} bytes@3.1.2: @@ -1449,8 +1451,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} range-parser@1.2.1: @@ -2386,7 +2388,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.0 + qs: 6.15.2 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -2403,7 +2405,7 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@5.0.5: + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2674,7 +2676,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.0 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -2966,7 +2968,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.5 + brace-expansion: 5.0.6 minipass-collect@2.0.1: dependencies: @@ -3126,7 +3128,7 @@ snapshots: punycode@2.3.1: {} - qs@6.15.0: + qs@6.15.2: dependencies: side-channel: 1.1.0 From 61747c5ed748ade9ceb25b4f06d720654f899507 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 15:34:21 -0700 Subject: [PATCH 08/15] fix: resolve self-review findings --- CHANGELOG.md | 9 +++ GUIDE.md | 4 +- README.md | 10 +++- docs/MCP.md | 15 +++-- docs/SETUP.md | 18 ++++-- docs/design/SURFACE_opened-workspace-paths.md | 4 +- .../witness/verification.md | 26 +++++---- .../graft-structural-history.manifest.json | 1 + ...eck-structural-history-schema-artifacts.ts | 28 +++++++++ src/mcp/workspace-router.ts | 3 + test/unit/mcp/workspace-binding.test.ts | 58 ++++++++++++++++++- .../SURFACE_opened-workspace-paths.test.ts | 48 ++++++++++++++- 12 files changed, 191 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a030ba4a..ea9d95cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `schemas/graft-structural-history.graphql` schema with Wesley-generated TypeScript contracts and a deterministic artifact drift check, establishing the schema-first Echo migration boundary without changing Echo or Wesley. +- **Opened workspace paths**: MCP sessions can now call `workspace_open` to + open another git worktree path, activate it by default, and inspect opened + paths through `workspace_list_opened` without adding per-tool `cwd` routing. ### Changed @@ -27,6 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). report a partial residual posture when committed import-scan fallback evidence is unavailable after a zero-count WARP graph reading. +### Security + +- **Dependency audit posture**: Patched audited transitive dependencies through + pnpm overrides for `brace-expansion` and `qs`, preserving a clean release + security gate. + ## [0.8.0] - 2026-05-13 ### Added diff --git a/GUIDE.md b/GUIDE.md index 24dfa335..0a73c414 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -18,7 +18,7 @@ Manually enforce policies or inspect structural history from your terminal. ### 3. Shared Daemon Mode Run the central execution authority for multi-repo work and persistent monitors. - **Run**: `npx @flyingrobots/graft daemon` -- **Read**: [docs/SETUP.md](./docs/SETUP.md) (Daemon bootstrap and `workspace_authorize` / `workspace_bind`) +- **Read**: [docs/SETUP.md](./docs/SETUP.md) (Daemon bootstrap and `workspace_open`) - **Read**: [Architecture](./ARCHITECTURE.md) (Daemon section) ## Big Picture: System Orchestration @@ -33,7 +33,7 @@ Graft is a tiered governor. It manages the context burden across three layers: - [ ] **I am setting up a new project**: Start with `README.md` Quick Start. - [ ] **I am configuring Claude Code**: Use `npx graft init --write-claude-hooks`. -- [ ] **I am using daemon mode in a generic MCP client**: Read [docs/SETUP.md](./docs/SETUP.md) and expect explicit `workspace_authorize` then `workspace_bind`. +- [ ] **I am using daemon mode in a generic MCP client**: Read [docs/SETUP.md](./docs/SETUP.md) and expect `workspace_open` as the normal agent-facing path. - [ ] **I am debugging a structural diff**: Use `npx graft struct diff --json`. - [ ] **I am contributing to Graft**: Read `METHOD.md` and `docs/BEARING.md`. diff --git a/README.md b/README.md index ed667520..0bed98aa 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,16 @@ npx @flyingrobots/graft daemon ``` Daemon sessions start `unbound`. If your client connects through the -daemon instead of repo-local stdio, the first repo-scoped flow is: +daemon instead of repo-local stdio, the normal agent-facing flow is: -1. `workspace_authorize` for the target repo/worktree -2. `workspace_bind` for the active daemon session +1. `workspace_open` with the target repo/worktree `cwd` +2. optionally `workspace_list_opened` to inspect opened paths and the + active workspace 3. then use repository-scoped tools such as `safe_read` +Use `workspace_authorize` and `workspace_bind` only when you need the +lower-level daemon control plane directly. + Use [docs/SETUP.md](./docs/SETUP.md) for the exact client bootstrap and daemon control-plane posture. diff --git a/docs/MCP.md b/docs/MCP.md index 181e5ddb..80929854 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -52,14 +52,18 @@ npx @flyingrobots/graft daemon ``` Daemon sessions start `unbound`. Once a client is connected to the -daemon MCP surface, repository-scoped work follows this control-plane -flow: +daemon MCP surface, repository-scoped work normally follows this +agent-facing flow: -1. `workspace_authorize` with the target `cwd` -2. `workspace_bind` with the target `cwd` +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` +`workspace_authorize` and `workspace_bind` remain available as lower-level +daemon control-plane tools. + ## Key Tool Groups - **Bounded Reads**: `safe_read`, `file_outline`, `read_range`, `changed_since` - **Governed Edits**: `graft_edit` @@ -68,7 +72,8 @@ flow: - **Structural Metrics**: `graft_churn`, `graft_difficulty` - **Precision**: `code_show`, `code_find`, `code_refs` - **Activity & Footing**: `activity_view`, `causal_status`, `causal_attach`, `doctor` -- **Daemon Control Plane**: `workspace_authorizations`, `workspace_authorize`, `workspace_bind`, `workspace_status`, `workspace_rebind`, `workspace_revoke`, `daemon_status`, `daemon_repos`, `daemon_sessions`, `daemon_monitors`, `monitor_*` +- **Workspace Routing**: `workspace_open`, `workspace_list_opened`, `workspace_status` +- **Daemon Control Plane**: `workspace_authorizations`, `workspace_authorize`, `workspace_bind`, `workspace_rebind`, `workspace_revoke`, `daemon_status`, `daemon_repos`, `daemon_sessions`, `daemon_monitors`, `monitor_*` ## Current Truth - MCP is the primary agent surface. diff --git a/docs/SETUP.md b/docs/SETUP.md index 941b274e..477607ad 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -98,6 +98,8 @@ Daemon control-plane inspection now exists through MCP tools: - `monitor_stop` - `workspace_authorizations` - `workspace_authorize` +- `workspace_open` +- `workspace_list_opened` - `workspace_bind` - `workspace_status` - `workspace_rebind` @@ -120,10 +122,14 @@ 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_authorize` with the target `cwd` -4. call `workspace_bind` with the target `cwd` +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` +Use `workspace_authorize` plus `workspace_bind` when you need direct +operator control over daemon authorization and binding state. + ### MCP runtime selection Repo-local stdio is the default and remains the simplest path: @@ -157,8 +163,7 @@ 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 require `workspace_authorize` and -`workspace_bind`. +repository-scoped tools normally begin with `workspace_open`. ### One-step bootstrap @@ -399,8 +404,9 @@ If your client doesn't support `npx`, install globally and use: This section shows the repo-local stdio path. If you connect through `graft daemon` instead, the MCP session starts `unbound` and needs -`workspace_authorize` plus `workspace_bind` before repository-scoped -tool calls. +`workspace_open` before repository-scoped tool calls. The lower-level +`workspace_authorize` and `workspace_bind` tools remain available for +operator control-plane workflows. ## Claude Code Hooks diff --git a/docs/design/SURFACE_opened-workspace-paths.md b/docs/design/SURFACE_opened-workspace-paths.md index d92d666d..83b70421 100644 --- a/docs/design/SURFACE_opened-workspace-paths.md +++ b/docs/design/SURFACE_opened-workspace-paths.md @@ -362,8 +362,6 @@ Shipped behavior: ## Product Decisions - `activate` defaults to `true`. -- Should the list tool be named `workspace_opened`, - `workspace_list`, or `workspace_list_opened`? Decision: - `workspace_list_opened`. +- List tool name: `workspace_list_opened`. - Repo-local opened workspaces stay process-local for the first cut; persistence belongs to daemon authorization. diff --git a/docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md b/docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md index eb70924a..4901d31e 100644 --- a/docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md +++ b/docs/method/retro/SURFACE_opened-workspace-paths/witness/verification.md @@ -4,8 +4,9 @@ title: "Verification Witness for Cycle SURFACE_opened-workspace-paths" # Verification Witness for Cycle SURFACE_opened-workspace-paths -This witness proves that `Opened workspace paths` now carries the required -behavior and adheres to the repo invariants. +This witness records the test evidence for `Opened workspace paths`. The drift +section below records that the captured drift output did not witness playback +questions and must not be treated as complete drift evidence. ## Test Results @@ -332,10 +333,10 @@ behavior and adheres to the repo invariants. #12 DONE 0.1s #13 [build 3/3] RUN pnpm build -#13 0.291 +#13 0.291 #13 0.291 > @flyingrobots/graft@0.8.0 build /app #13 0.291 > tsc -p tsconfig.build.json -#13 0.291 +#13 0.291 #13 DONE 5.9s #14 exporting to image @@ -351,25 +352,26 @@ View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux ## Drift Results ```text -No playback-question drift found. -Scanned 1 active cycle, 0 playback questions, 306 test descriptions. -Search basis: normalized match, semantic normalization, or high-confidence token similarity in tests/**/*.test.* and tests/**/*.spec.* descriptions. +Captured drift output was incomplete: it scanned 0 playback questions for +SURFACE_opened-workspace-paths. Treat the playback test file above as the +behavioral witness and do not treat this drift capture as question coverage. ``` ## Automated Capture -- [x] Test command succeeded: `npm test`. -- [x] Drift check passed: `method drift SURFACE_opened-workspace-paths`. +- [x] Test command succeeded: `pnpm test`. +- [ ] Drift capture did not witness playback questions; captured output + reported 0 scanned questions. ## Human Verification To reproduce this verification independently from the workspace root: ```sh -npm test -method drift SURFACE_opened-workspace-paths +pnpm test ``` Expected: the recorded test command exits successfully. -Expected: the recorded drift command exits 0. +Expected: drift coverage must be recaptured with non-zero playback-question +coverage before this witness is treated as complete drift evidence. diff --git a/schemas/graft-structural-history.manifest.json b/schemas/graft-structural-history.manifest.json index f48632c9..340709e3 100644 --- a/schemas/graft-structural-history.manifest.json +++ b/schemas/graft-structural-history.manifest.json @@ -9,6 +9,7 @@ "StructuralRepository", "StructuralBasis", "StructuralFileVersion", + "StructuralSourceSpan", "StructuralParserRun", "StructuralSymbol", "StructuralSymbolRelation", diff --git a/scripts/check-structural-history-schema-artifacts.ts b/scripts/check-structural-history-schema-artifacts.ts index 885b4cb7..a232418b 100644 --- a/scripts/check-structural-history-schema-artifacts.ts +++ b/scripts/check-structural-history-schema-artifacts.ts @@ -83,6 +83,27 @@ function typePattern(typeName: string): RegExp { return new RegExp(`export (interface|type) ${typeName}\\b`, "u"); } +function schemaRegistryTypes(schemaText: string): readonly string[] { + const registryTypes: string[] = []; + let currentType: string | null = null; + for (const line of schemaText.split(/\r?\n/u)) { + const typeMatch = /^type\s+([A-Za-z_][A-Za-z0-9_]*)\b/u.exec(line); + if (typeMatch !== null) { + currentType = typeMatch[1] ?? null; + continue; + } + if (currentType !== null && line.includes("@wes_registry")) { + registryTypes.push(currentType); + currentType = null; + continue; + } + if (currentType !== null && line.trim() === "{") { + currentType = null; + } + } + return registryTypes; +} + export function checkStructuralHistorySchemaArtifacts( workspaceRoot = DEFAULT_WORKSPACE_ROOT, ): StructuralHistorySchemaArtifactCheck { @@ -119,6 +140,13 @@ export function checkStructuralHistorySchemaArtifacts( } } + const requiredTypeNames = new Set(manifest.requiredTypes); + for (const typeName of schemaRegistryTypes(schemaText)) { + if (!requiredTypeNames.has(typeName)) { + violations.push(`${manifest.schemaPath} registered type ${typeName} must be listed in manifest requiredTypes.`); + } + } + for (const operationName of manifest.requiredOperationConstants) { if (!generatedText.includes(`export const ${operationName} = {`)) { violations.push(`${manifest.generatedTypesPath} is missing operation constant ${operationName}.`); diff --git a/src/mcp/workspace-router.ts b/src/mcp/workspace-router.ts index 9ff1f14a..bbd634d3 100644 --- a/src/mcp/workspace-router.ts +++ b/src/mcp/workspace-router.ts @@ -111,10 +111,13 @@ function workspaceCapabilityProfilesEqual( left: WorkspaceCapabilityProfile, right: WorkspaceCapabilityProfile, ): boolean { + const leftRuntimeLogs: string = left.runtimeLogs; + const rightRuntimeLogs: string = right.runtimeLogs; return left.boundedReads === right.boundedReads && left.structuralTools === right.structuralTools && left.precisionTools === right.precisionTools && left.stateBookmarks === right.stateBookmarks + && leftRuntimeLogs === rightRuntimeLogs && left.runCapture === right.runCapture; } diff --git a/test/unit/mcp/workspace-binding.test.ts b/test/unit/mcp/workspace-binding.test.ts index 4e6baf20..7587fe87 100644 --- a/test/unit/mcp/workspace-binding.test.ts +++ b/test/unit/mcp/workspace-binding.test.ts @@ -6,7 +6,11 @@ import { CanonicalJsonCodec } from "../../../src/adapters/canonical-json.js"; import { nodeFs } from "../../../src/adapters/node-fs.js"; import { nodeGit } from "../../../src/adapters/node-git.js"; import { PersistedLocalHistoryStore } from "../../../src/mcp/persisted-local-history.js"; -import { DEFAULT_DAEMON_CAPABILITY_PROFILE, WorkspaceRouter } from "../../../src/mcp/workspace-router.js"; +import { + DEFAULT_DAEMON_CAPABILITY_PROFILE, + type WorkspaceCapabilityProfile, + WorkspaceRouter, +} from "../../../src/mcp/workspace-router.js"; import type { FileSystem } from "../../../src/ports/filesystem.js"; import { createManagedDaemonServer, parse } from "../../helpers/mcp.js"; import { cleanupTestRepo, createCommittedTestRepo, git } from "../../helpers/git.js"; @@ -110,6 +114,58 @@ describe("mcp: daemon workspace binding", () => { expect(safeRead["projection"]).toBe("content"); }); + it("treats runtime-log posture changes as workspace capability changes", async () => { + const repoDir = createCommittedRepo(); + const graftDir = fs.mkdtempSync(path.join(os.tmpdir(), "graft-bind-runtime-logs-")); + cleanups.push(() => { + fs.rmSync(graftDir, { recursive: true, force: true }); + }); + const futureRuntimeLogsProfile = { + ...DEFAULT_DAEMON_CAPABILITY_PROFILE, + runtimeLogs: "future_runtime_log_posture", + } as unknown as WorkspaceCapabilityProfile; + let capabilityProfile: WorkspaceCapabilityProfile = DEFAULT_DAEMON_CAPABILITY_PROFILE; + const router = new WorkspaceRouter({ + mode: "daemon", + fs: nodeFs, + git: nodeGit, + graftDir, + warpPool: { + getOrOpen(): Promise { + return Promise.reject(new Error("unused in workspace binding test")); + }, + size(): number { + return 0; + }, + }, + transportSessionId: "transport:test", + authorizationPolicy: { + getCapabilityProfile() { + return Promise.resolve(capabilityProfile); + }, + noteBound(): Promise { + return Promise.resolve(); + }, + }, + persistedLocalHistory: new PersistedLocalHistoryStore({ + fs: nodeFs, + codec: new CanonicalJsonCodec(), + graftDir, + }), + }); + + const first = await router.openWorkspace({ cwd: repoDir }); + expect(first.ok).toBe(true); + expect(first.freshSessionSlice).toBe(true); + + capabilityProfile = futureRuntimeLogsProfile; + const second = await router.openWorkspace({ cwd: repoDir }); + + expect(second.ok).toBe(true); + expect(second.freshSessionSlice).toBe(true); + expect(second.capabilityProfile?.runtimeLogs).toBe("future_runtime_log_posture"); + }); + it("Does workspace binding load graftignore without sync filesystem reads?", async () => { const repoDir = createCommittedRepo(); fs.writeFileSync(path.join(repoDir, ".graftignore"), "ignored.ts\n"); diff --git a/tests/playback/SURFACE_opened-workspace-paths.test.ts b/tests/playback/SURFACE_opened-workspace-paths.test.ts index b5a0df3c..799dbb84 100644 --- a/tests/playback/SURFACE_opened-workspace-paths.test.ts +++ b/tests/playback/SURFACE_opened-workspace-paths.test.ts @@ -6,8 +6,18 @@ import { createManagedDaemonServer, createServerInRepo, parse } from "../../test const cleanups: (() => void)[] = []; afterEach(() => { + const errors: unknown[] = []; while (cleanups.length > 0) { - cleanups.pop()!(); + const cleanup = cleanups.pop(); + if (cleanup === undefined) continue; + try { + cleanup(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, "Playback cleanup failure"); } }); @@ -40,6 +50,21 @@ interface OpenedWorkspaceList { readonly workspaces: readonly OpenedWorkspace[]; } +interface StatsPayload { + readonly totalReads: number; + readonly totalCacheHits: number; +} + +interface CausalStatusPayload { + readonly worktreeRoot: string | null; + readonly activeCausalWorkspace: { + readonly causalContext: { + readonly workspaceSliceId: string; + readonly checkoutEpochId: string; + }; + } | null; +} + function workspaceFor(list: OpenedWorkspaceList, worktreeRoot: string): OpenedWorkspace { const workspace = list.workspaces.find((candidate) => candidate.worktreeRoot === worktreeRoot); if (workspace === undefined) { @@ -145,17 +170,38 @@ describe("SURFACE opened workspace paths playback", () => { await server.callTool("state_save", { content: "alpha state" }); await server.callTool("set_budget", { bytes: 100_000 }); + const initialRead = parse(await server.callTool("safe_read", { path: "app.ts" })); + expect(initialRead["content"]).toContain("alphaThing"); + const cachedRead = parse(await server.callTool("safe_read", { path: "app.ts" })); + expect(cachedRead["projection"]).toBe("cache_hit"); const before = parse(await server.callTool("state_load", {})); expect(before["content"]).toBe("alpha state"); + const beforeStats = parse(await server.callTool("stats", {})) as unknown as StatsPayload; + expect(beforeStats.totalReads).toBeGreaterThan(0); + expect(beforeStats.totalCacheHits).toBeGreaterThan(0); + const beforeCausal = parse(await server.callTool("causal_status", {})) as unknown as CausalStatusPayload; + const beforeCausalContext = beforeCausal.activeCausalWorkspace?.causalContext; + expect(beforeCausal.worktreeRoot).toBe(initialRepo); + expect(beforeCausalContext).toBeDefined(); const opened = parse(await server.callTool("workspace_open", { cwd: nextRepo })); expect(opened["freshSessionSlice"]).toBe(true); const after = parse(await server.callTool("state_load", {})); expect(after["content"]).toBeNull(); + const afterStats = parse(await server.callTool("stats", {})) as unknown as StatsPayload; + expect(afterStats.totalReads).toBe(0); + expect(afterStats.totalCacheHits).toBe(0); + const afterCausal = parse(await server.callTool("causal_status", {})) as unknown as CausalStatusPayload; + const afterCausalContext = afterCausal.activeCausalWorkspace?.causalContext; + expect(afterCausal.worktreeRoot).toBe(nextRepo); + expect(afterCausalContext).toBeDefined(); + expect(afterCausalContext?.workspaceSliceId).not.toBe(beforeCausalContext?.workspaceSliceId); + expect(afterCausalContext?.checkoutEpochId).not.toBe(beforeCausalContext?.checkoutEpochId); const read = parse(await server.callTool("safe_read", { path: "app.ts" })); const receipt = read["_receipt"] as Record; + expect(read["projection"]).not.toBe("cache_hit"); expect(receipt["budget"]).toBeUndefined(); expect(read["content"]).toContain("betaThing"); }); From 8bd29c815304aaecdba6485e5123f8736b954dfd Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 16:47:17 -0700 Subject: [PATCH 09/15] fix: validate workspace methods in tool context guard --- src/mcp/context.ts | 2 ++ test/unit/mcp/context-guard.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/mcp/context.ts b/src/mcp/context.ts index d447ebcc..350346b1 100644 --- a/src/mcp/context.ts +++ b/src/mcp/context.ts @@ -153,6 +153,8 @@ export function assertToolContext(value: unknown): asserts value is ToolContext "getRepoState", "getCausalContext", "getWorkspaceStatus", + "openWorkspace", + "listOpenedWorkspaces", ] as const; for (const method of methods) { if (obj[method] === undefined || obj[method] === null) { diff --git a/test/unit/mcp/context-guard.test.ts b/test/unit/mcp/context-guard.test.ts index 5c64dbed..50f206e3 100644 --- a/test/unit/mcp/context-guard.test.ts +++ b/test/unit/mcp/context-guard.test.ts @@ -30,6 +30,8 @@ function minimalContext(): Record { getRepoState: () => ({}), getCausalContext: () => ({}), getWorkspaceStatus: () => ({}), + openWorkspace: () => Promise.resolve({}), + listOpenedWorkspaces: () => Promise.resolve({}), }; } @@ -60,6 +62,18 @@ describe("assertToolContext", () => { expect(() => { assertToolContext(ctx); }).toThrow("missing method: respond"); }); + it("rejects missing workspace open method", () => { + const ctx = minimalContext(); + delete ctx["openWorkspace"]; + expect(() => { assertToolContext(ctx); }).toThrow("missing method: openWorkspace"); + }); + + it("rejects missing opened workspace list method", () => { + const ctx = minimalContext(); + delete ctx["listOpenedWorkspaces"]; + expect(() => { assertToolContext(ctx); }).toThrow("missing method: listOpenedWorkspaces"); + }); + it("rejects non-function method", () => { const ctx = minimalContext(); ctx["respond"] = "not a function"; From 43b4884b4bbe16b24c3ca10dde28c5cede491fc4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 16:48:45 -0700 Subject: [PATCH 10/15] fix: escape manifest type names in schema checker --- ...eck-structural-history-schema-artifacts.ts | 6 +- .../graft-structural-history-schema.test.ts | 57 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/scripts/check-structural-history-schema-artifacts.ts b/scripts/check-structural-history-schema-artifacts.ts index a232418b..d0ab277d 100644 --- a/scripts/check-structural-history-schema-artifacts.ts +++ b/scripts/check-structural-history-schema-artifacts.ts @@ -79,8 +79,12 @@ function readWorkspaceFile(workspaceRoot: string, relativePath: string): string return readFileSync(path.join(workspaceRoot, relativePath), "utf8"); } +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + function typePattern(typeName: string): RegExp { - return new RegExp(`export (interface|type) ${typeName}\\b`, "u"); + return new RegExp(`export (interface|type) ${escapeRegexLiteral(typeName)}\\b`, "u"); } function schemaRegistryTypes(schemaText: string): readonly string[] { diff --git a/test/unit/contracts/graft-structural-history-schema.test.ts b/test/unit/contracts/graft-structural-history-schema.test.ts index f3ee2fca..c11b15a7 100644 --- a/test/unit/contracts/graft-structural-history-schema.test.ts +++ b/test/unit/contracts/graft-structural-history-schema.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { checkStructuralHistorySchemaArtifacts, readStructuralHistorySchemaManifest, @@ -12,6 +16,44 @@ import { type StructuralReadingEvidence, } from "../../../src/generated/graft-structural-history.js"; +function sha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function writeSchemaArtifactFixture(options: { + readonly manifestOverrides?: Record; + readonly schemaText?: string; + readonly generatedText?: string; +} = {}): string { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graft-schema-artifacts-")); + const schemaPath = "schemas/schema.graphql"; + const generatedTypesPath = "src/generated.ts"; + const schemaText = options.schemaText ?? ""; + const generatedText = options.generatedText ?? "/* @generated by Wesley. Do not edit. */\n"; + + fs.mkdirSync(path.join(workspaceRoot, "schemas"), { recursive: true }); + fs.mkdirSync(path.join(workspaceRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(workspaceRoot, schemaPath), schemaText); + fs.writeFileSync(path.join(workspaceRoot, generatedTypesPath), generatedText); + fs.writeFileSync( + path.join(workspaceRoot, "schemas/graft-structural-history.manifest.json"), + `${JSON.stringify({ + schemaPath, + generatedTypesPath, + schemaSourceSha256: sha256(schemaText), + generatedTypesSha256: sha256(generatedText), + wesleyCliVersion: "0.0.4", + wesleyL1RegistryHash: "0f6d6d2109142a0cd33ee8db9ebc28f1718e0d1ec2863ec4837048a1340bff61", + requiredTypes: [], + requiredOperationConstants: [], + requiredEvidenceLabels: [], + ...options.manifestOverrides, + }, null, 2)}\n`, + ); + + return workspaceRoot; +} + describe("Graft structural history schema authority", () => { it("keeps the GraphQL schema and Wesley-generated TypeScript artifact in lockstep", () => { expect(checkStructuralHistorySchemaArtifacts().violations).toEqual([]); @@ -71,4 +113,19 @@ describe("Graft structural history schema authority", () => { expect(reading.evidenceId).toBe(evidence.evidenceId); expect(reading.readingKind).toBe("SYMBOL_REFERENCE_COUNT"); }); + + it("reports malformed manifest type names as drift violations without throwing", () => { + const workspaceRoot = writeSchemaArtifactFixture({ + manifestOverrides: { + requiredTypes: ["Structural(Symbol"], + }, + }); + try { + expect(checkStructuralHistorySchemaArtifacts(workspaceRoot).violations).toContain( + "src/generated.ts is missing generated type Structural(Symbol.", + ); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); }); From bfe8fa6fa80378bfe0a1abd0b5a91c9d0b3a29a4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 16:49:56 -0700 Subject: [PATCH 11/15] fix: centralize Wesley CLI version authority --- scripts/check-structural-history-schema-artifacts.ts | 7 +++++-- .../unit/contracts/graft-structural-history-schema.test.ts | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/check-structural-history-schema-artifacts.ts b/scripts/check-structural-history-schema-artifacts.ts index d0ab277d..44887375 100644 --- a/scripts/check-structural-history-schema-artifacts.ts +++ b/scripts/check-structural-history-schema-artifacts.ts @@ -8,6 +8,7 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_WORKSPACE_ROOT = path.resolve(SCRIPT_DIR, ".."); const DEFAULT_MANIFEST_PATH = "schemas/graft-structural-history.manifest.json"; const GENERATED_HEADER = "/* @generated by Wesley. Do not edit. */"; +export const EXPECTED_WESLEY_CLI_VERSION = "0.0.4"; export interface StructuralHistorySchemaManifest { readonly schemaPath: string; @@ -134,8 +135,10 @@ export function checkStructuralHistorySchemaArtifacts( violations.push(`${manifest.generatedTypesPath} is missing the Wesley generated-file header.`); } - if (manifest.wesleyCliVersion !== "0.0.4") { - violations.push(`Unexpected Wesley CLI version ${manifest.wesleyCliVersion}; expected 0.0.4.`); + if (manifest.wesleyCliVersion !== EXPECTED_WESLEY_CLI_VERSION) { + violations.push( + `Unexpected Wesley CLI version ${manifest.wesleyCliVersion}; expected ${EXPECTED_WESLEY_CLI_VERSION}.`, + ); } for (const typeName of manifest.requiredTypes) { diff --git a/test/unit/contracts/graft-structural-history-schema.test.ts b/test/unit/contracts/graft-structural-history-schema.test.ts index c11b15a7..3c8ff870 100644 --- a/test/unit/contracts/graft-structural-history-schema.test.ts +++ b/test/unit/contracts/graft-structural-history-schema.test.ts @@ -5,6 +5,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { checkStructuralHistorySchemaArtifacts, + EXPECTED_WESLEY_CLI_VERSION, readStructuralHistorySchemaManifest, } from "../../../scripts/check-structural-history-schema-artifacts.js"; import { @@ -42,7 +43,7 @@ function writeSchemaArtifactFixture(options: { generatedTypesPath, schemaSourceSha256: sha256(schemaText), generatedTypesSha256: sha256(generatedText), - wesleyCliVersion: "0.0.4", + wesleyCliVersion: EXPECTED_WESLEY_CLI_VERSION, wesleyL1RegistryHash: "0f6d6d2109142a0cd33ee8db9ebc28f1718e0d1ec2863ec4837048a1340bff61", requiredTypes: [], requiredOperationConstants: [], @@ -67,7 +68,7 @@ describe("Graft structural history schema authority", () => { "GIT_WARP_IMPORTED", "FALLBACK_TRANSLATED", ]); - expect(manifest.wesleyCliVersion).toBe("0.0.4"); + expect(manifest.wesleyCliVersion).toBe(EXPECTED_WESLEY_CLI_VERSION); }); it("exposes structural reading and import operations from the generated contract", () => { From 786e46048fad3b0115f8a21a8d6554abca3113a6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 16:51:12 -0700 Subject: [PATCH 12/15] fix: validate Wesley L1 registry hash --- ...heck-structural-history-schema-artifacts.ts | 7 +++++++ .../graft-structural-history-schema.test.ts | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/check-structural-history-schema-artifacts.ts b/scripts/check-structural-history-schema-artifacts.ts index 44887375..e27789fa 100644 --- a/scripts/check-structural-history-schema-artifacts.ts +++ b/scripts/check-structural-history-schema-artifacts.ts @@ -9,6 +9,7 @@ const DEFAULT_WORKSPACE_ROOT = path.resolve(SCRIPT_DIR, ".."); const DEFAULT_MANIFEST_PATH = "schemas/graft-structural-history.manifest.json"; const GENERATED_HEADER = "/* @generated by Wesley. Do not edit. */"; export const EXPECTED_WESLEY_CLI_VERSION = "0.0.4"; +export const EXPECTED_WESLEY_L1_REGISTRY_HASH = "0f6d6d2109142a0cd33ee8db9ebc28f1718e0d1ec2863ec4837048a1340bff61"; export interface StructuralHistorySchemaManifest { readonly schemaPath: string; @@ -141,6 +142,12 @@ export function checkStructuralHistorySchemaArtifacts( ); } + if (manifest.wesleyL1RegistryHash !== EXPECTED_WESLEY_L1_REGISTRY_HASH) { + violations.push( + `Unexpected Wesley L1 registry hash ${manifest.wesleyL1RegistryHash}; expected ${EXPECTED_WESLEY_L1_REGISTRY_HASH}.`, + ); + } + for (const typeName of manifest.requiredTypes) { if (!typePattern(typeName).test(generatedText)) { violations.push(`${manifest.generatedTypesPath} is missing generated type ${typeName}.`); diff --git a/test/unit/contracts/graft-structural-history-schema.test.ts b/test/unit/contracts/graft-structural-history-schema.test.ts index 3c8ff870..c401f931 100644 --- a/test/unit/contracts/graft-structural-history-schema.test.ts +++ b/test/unit/contracts/graft-structural-history-schema.test.ts @@ -6,6 +6,7 @@ import * as path from "node:path"; import { checkStructuralHistorySchemaArtifacts, EXPECTED_WESLEY_CLI_VERSION, + EXPECTED_WESLEY_L1_REGISTRY_HASH, readStructuralHistorySchemaManifest, } from "../../../scripts/check-structural-history-schema-artifacts.js"; import { @@ -44,7 +45,7 @@ function writeSchemaArtifactFixture(options: { schemaSourceSha256: sha256(schemaText), generatedTypesSha256: sha256(generatedText), wesleyCliVersion: EXPECTED_WESLEY_CLI_VERSION, - wesleyL1RegistryHash: "0f6d6d2109142a0cd33ee8db9ebc28f1718e0d1ec2863ec4837048a1340bff61", + wesleyL1RegistryHash: EXPECTED_WESLEY_L1_REGISTRY_HASH, requiredTypes: [], requiredOperationConstants: [], requiredEvidenceLabels: [], @@ -129,4 +130,19 @@ describe("Graft structural history schema authority", () => { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); + + it("rejects drift from the pinned Wesley L1 registry hash", () => { + const workspaceRoot = writeSchemaArtifactFixture({ + manifestOverrides: { + wesleyL1RegistryHash: "tampered-registry-hash", + }, + }); + try { + expect(checkStructuralHistorySchemaArtifacts(workspaceRoot).violations).toContain( + `Unexpected Wesley L1 registry hash tampered-registry-hash; expected ${EXPECTED_WESLEY_L1_REGISTRY_HASH}.`, + ); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); }); From 68da5b4a30f20d3f47d5160889503023c20aa50e Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 16:53:10 -0700 Subject: [PATCH 13/15] fix: add source span ordering invariants --- CHANGELOG.md | 3 +++ schemas/graft-structural-history.graphql | 10 ++++++++++ schemas/graft-structural-history.manifest.json | 2 +- .../graft-structural-history-schema.test.ts | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea9d95cf..d86ec59c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `schemas/graft-structural-history.graphql` schema with Wesley-generated TypeScript contracts and a deterministic artifact drift check, establishing the schema-first Echo migration boundary without changing Echo or Wesley. +- **Structural source span invariants**: The canonical structural-history schema + now rejects source spans whose end offsets or present end lines precede their + starts. - **Opened workspace paths**: MCP sessions can now call `workspace_open` to open another git worktree path, activate it by default, and inspect opened paths through `workspace_list_opened` without adding per-tool `cwd` routing. diff --git a/schemas/graft-structural-history.graphql b/schemas/graft-structural-history.graphql index 7f287754..d2d129aa 100644 --- a/schemas/graft-structural-history.graphql +++ b/schemas/graft-structural-history.graphql @@ -277,6 +277,16 @@ type GraftStructuralHistoryInvariants expr: "forall e in StructuralReadingEvidence: e.evidenceKind == FALLBACK_TRANSLATED implies e.nativeContinuumWitness == false", severity: "error" ) + @wes_invariant( + name: "source_span_offsets_are_ordered", + expr: "forall s in StructuralSourceSpan: s.endOffset >= s.startOffset", + severity: "error" + ) + @wes_invariant( + name: "source_span_lines_are_ordered_when_present", + expr: "forall s in StructuralSourceSpan: s.startLine == null or s.endLine == null or s.endLine >= s.startLine", + severity: "error" + ) { _placeholder: Boolean } diff --git a/schemas/graft-structural-history.manifest.json b/schemas/graft-structural-history.manifest.json index 340709e3..2e477dd1 100644 --- a/schemas/graft-structural-history.manifest.json +++ b/schemas/graft-structural-history.manifest.json @@ -1,7 +1,7 @@ { "schemaPath": "schemas/graft-structural-history.graphql", "generatedTypesPath": "src/generated/graft-structural-history.ts", - "schemaSourceSha256": "588d2bd5b873387e9443d94e557423e8c82d824b69eb78efe91c279585412d81", + "schemaSourceSha256": "492e2caefa1934fcd7c63a76fcd5dbef2954901c7a1f10ba10c9017491051459", "generatedTypesSha256": "79737d3c089067685d553f9526c8eb442cb7a20c099a49a771162c2be29559cc", "wesleyCliVersion": "0.0.4", "wesleyL1RegistryHash": "0f6d6d2109142a0cd33ee8db9ebc28f1718e0d1ec2863ec4837048a1340bff61", diff --git a/test/unit/contracts/graft-structural-history-schema.test.ts b/test/unit/contracts/graft-structural-history-schema.test.ts index c401f931..34def7ab 100644 --- a/test/unit/contracts/graft-structural-history-schema.test.ts +++ b/test/unit/contracts/graft-structural-history-schema.test.ts @@ -56,6 +56,10 @@ function writeSchemaArtifactFixture(options: { return workspaceRoot; } +function canonicalSchemaText(): string { + return fs.readFileSync(path.join(process.cwd(), "schemas/graft-structural-history.graphql"), "utf8"); +} + describe("Graft structural history schema authority", () => { it("keeps the GraphQL schema and Wesley-generated TypeScript artifact in lockstep", () => { expect(checkStructuralHistorySchemaArtifacts().violations).toEqual([]); @@ -85,6 +89,17 @@ describe("Graft structural history schema authority", () => { ]); }); + it("declares ordering invariants for structural source spans", () => { + const schemaText = canonicalSchemaText(); + + expect(schemaText).toContain('name: "source_span_offsets_are_ordered"'); + expect(schemaText).toContain("forall s in StructuralSourceSpan: s.endOffset >= s.startOffset"); + expect(schemaText).toContain('name: "source_span_lines_are_ordered_when_present"'); + expect(schemaText).toContain( + "forall s in StructuralSourceSpan: s.startLine == null or s.endLine == null or s.endLine >= s.startLine", + ); + }); + it("keeps generated structural reading values behaviorally typed", () => { const evidenceKind: StructuralEvidenceKind = "FALLBACK_TRANSLATED"; const evidence: StructuralReadingEvidence = { From 64fd77b8b7070617d2f68c038757fe74d340663e Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 16:54:35 -0700 Subject: [PATCH 14/15] fix: explain structural invariant placeholder --- schemas/graft-structural-history.graphql | 1 + schemas/graft-structural-history.manifest.json | 2 +- test/unit/contracts/graft-structural-history-schema.test.ts | 6 ++++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/schemas/graft-structural-history.graphql b/schemas/graft-structural-history.graphql index d2d129aa..394caf05 100644 --- a/schemas/graft-structural-history.graphql +++ b/schemas/graft-structural-history.graphql @@ -288,5 +288,6 @@ type GraftStructuralHistoryInvariants severity: "error" ) { + # Required because GraphQL object types must declare at least one field. _placeholder: Boolean } diff --git a/schemas/graft-structural-history.manifest.json b/schemas/graft-structural-history.manifest.json index 2e477dd1..f93b474a 100644 --- a/schemas/graft-structural-history.manifest.json +++ b/schemas/graft-structural-history.manifest.json @@ -1,7 +1,7 @@ { "schemaPath": "schemas/graft-structural-history.graphql", "generatedTypesPath": "src/generated/graft-structural-history.ts", - "schemaSourceSha256": "492e2caefa1934fcd7c63a76fcd5dbef2954901c7a1f10ba10c9017491051459", + "schemaSourceSha256": "20c335b4510efbfb5a57a61706bea28a7f82bfc063036b013bd19dc8176905ec", "generatedTypesSha256": "79737d3c089067685d553f9526c8eb442cb7a20c099a49a771162c2be29559cc", "wesleyCliVersion": "0.0.4", "wesleyL1RegistryHash": "0f6d6d2109142a0cd33ee8db9ebc28f1718e0d1ec2863ec4837048a1340bff61", diff --git a/test/unit/contracts/graft-structural-history-schema.test.ts b/test/unit/contracts/graft-structural-history-schema.test.ts index 34def7ab..f7769bcc 100644 --- a/test/unit/contracts/graft-structural-history-schema.test.ts +++ b/test/unit/contracts/graft-structural-history-schema.test.ts @@ -100,6 +100,12 @@ describe("Graft structural history schema authority", () => { ); }); + it("explains the invariant holder placeholder field", () => { + expect(canonicalSchemaText()).toContain( + "# Required because GraphQL object types must declare at least one field.\n _placeholder: Boolean", + ); + }); + it("keeps generated structural reading values behaviorally typed", () => { const evidenceKind: StructuralEvidenceKind = "FALLBACK_TRANSLATED"; const evidence: StructuralReadingEvidence = { From 8e32b04fbc92f30926f20ce2366fe49dc2decc5d Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 26 May 2026 17:01:28 -0700 Subject: [PATCH 15/15] fix: update tool context playback fixture --- tests/playback/CORE_v060-bad-code-burndown.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/playback/CORE_v060-bad-code-burndown.test.ts b/tests/playback/CORE_v060-bad-code-burndown.test.ts index eaee9649..9deefa91 100644 --- a/tests/playback/CORE_v060-bad-code-burndown.test.ts +++ b/tests/playback/CORE_v060-bad-code-burndown.test.ts @@ -90,6 +90,8 @@ describe("CORE_v060-bad-code-burndown", () => { getRepoState: () => { return {}; }, getCausalContext: () => { return {}; }, getWorkspaceStatus: () => { return {}; }, + openWorkspace: () => Promise.resolve({}), + listOpenedWorkspaces: () => Promise.resolve({}), }; expect(() => { assertToolContext(validCtx); }).not.toThrow(); @@ -107,6 +109,9 @@ describe("CORE_v060-bad-code-burndown", () => { // Rejects non-function method const badMethod = { ...validCtx, respond: "not a function" }; expect(() => { assertToolContext(badMethod); }).toThrow("ToolContext missing method: respond"); + + const { openWorkspace: _openWorkspace, ...missingOpenWorkspace } = validCtx; + expect(() => { assertToolContext(missingOpenWorkspace); }).toThrow("ToolContext missing method: openWorkspace"); }); it("Is secret scrubbing applied to both run-capture output and observability arg values?", () => {