From 4a66d62d106f4770e72c8af72ffdc2369f5daf9b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 24 Jul 2026 21:23:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(#2584):=20Phase=202=20=E2=80=94=20worktree?= =?UTF-8?q?=20create=20verb=20+=20orchestrator-exec=20resolver=20(#2625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#2584): Phase 2 — worktree create verb + orchestrator-exec resolver Phase 2 of the negotiated executor-isolation feature (ADR-1239 Codex-binding amendment). Two building blocks for `dispatch.isolation: orchestrator-worktree` hosts, both unconsumed — no scheduler wires them yet (that is Phase 3), so no runtime behavior changes. worktree create verb (planWorktreeCreate / executeWorktreeCreatePlan / cmdWorktreeCreate in worktree-safety.cts, routed via routeWorktree in gsd-tools.cjs): validates the wave base, creates a bounded branch+worktree, records it in the run manifest reusing record-agent 4-field entry shape, returns the executor working directory. Bounded git (10s timeout, degrade-not-throw); all manifest read/parse/validate/dedupe precedes the single git side effect (no unmanifested-orphan on a bad manifest); timeout-only best-effort partial rollback (a clean collision-exit never removes a live peer worktree); fail-closed on bad base, unsafe leading-dash / .. inputs, and malformed/mis-shaped manifest. resolveOrchestratorExec (host-integration.cts): pure descriptor->argv resolver reading the new runtime.orchestratorExec descriptor field (codex/opencode/kimi/kimi-code), fail-closed on missing/invalid shape. Validator (capability-validator.cjs) + a parity guard asserting every orchestrator-worktree host declares a resolvable orchestratorExec. Adding the create route edits the installed gsd-core/bin/gsd-tools.cjs, so the golden-install-parity fixtures for all 19 runtimes are regenerated (npm run gen:golden) — the only changed hash is gsd-tools.cjs. CONTEXT.md glossary updated; capability-registry regenerated. Behavioral tests (worktree-safety + host-integration) incl. a fast-check property test and the parity sweep. Co-Authored-By: Claude Opus 4.8 * chore: rebuild tracked state-transition.cjs to match #2400 source The tracked compiled artifact drifted from src/state-transition.cts: #2400 (commit 2bcfaa2e2) added the progress.total_plans frontmatter sync to source but the tracked bin/lib/state-transition.cjs was never rebuilt, so the fix was not shipping to consumers of the compiled artifact. The mandatory build:lib step for Phase 2 surfaced the drift; recompiling makes the already-merged, already-changelogged #2400 fix effective. Artifact-only resync (no source/test change); drift class tracked by #2591. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- CONTEXT.md | 4 +- capabilities/codex/capability.json | 5 + capabilities/kimi-code/capability.json | 5 + capabilities/kimi/capability.json | 5 + capabilities/opencode/capability.json | 5 + gsd-core/bin/gsd-tools.cjs | 4 +- gsd-core/bin/lib/capability-registry.cjs | 48 ++ gsd-core/bin/lib/capability-validator.cjs | 53 ++ gsd-core/bin/lib/state-transition.cjs | 12 + src/host-integration.cts | 60 ++ src/worktree-safety.cts | 366 ++++++++++++ .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude-local.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../golden-install-parity/kimi-code.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- tests/fixtures/golden-install-parity/pi.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- tests/host-integration.test.cjs | 298 ++++++++++ tests/worktree-safety.test.cjs | 536 ++++++++++++++++++ 32 files changed, 1417 insertions(+), 22 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4edfd313b..53f953f96 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -113,7 +113,7 @@ Cross-seam principle (ADR-1411, epic #1411): context resolution — config loadi Diagnostic-output convention for the Resolution Provenance principle (ADR-1411 P3, #1416). Config-interpreting read verbs expose `Resolution { value, configured, reason, warnings }` (`src/resolution.cts`); agent-skills is the first adopter, where `value = { block, skills_count }` and `source`/`degraded` remain config-provenance extras outside the envelope. Other read verbs expose at least `warnings[]` (e.g. capability-state `{ runtimeConfigDir, capabilities, warnings? }`) without `configured`/`reason`, which are meaningful only for config-interpreting verbs. Mutation verbs expose `warnings[]` (advisory) PLUS `errors[]` (operation-not-applied), e.g. capability-writer `{ capabilities, warnings, errors }`. The shared seam across all shapes is `warnings: string[]`; a single generic `Resolution` across read+write verbs was rejected by the deletion test (`configured`/`reason` are meaningless for capability verbs; `errors[]` cannot fold into `warnings[]`) — ADR-1411 P3 amendment. Recurrence prevention is delivered by P4's CI guard (a configured input resolving empty must carry a `reason`), not by a shared envelope. A CI guard (`scripts/lint-resolution-provenance.cjs`, wired into `lint:ci`) enforces that every registered config-interpreting read verb keeps a `configured_empty`/`not_configured` contract test; the registry in that script is the registration point for future verbs (ADR-1411 P4 / #1417). ### Worktree Safety Policy Module -CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`, `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb). Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module. +CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`, `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb), `planWorktreeCreate(fields) → WorktreeCreatePlan` (write-strict `worktree create` planner — same missing-field-hint and `normalizeCleanupManifestEntry` validation as `planWorktreeRecordAgent`, pure/no-git), `executeWorktreeCreatePlan(plan, repoRoot, deps) → WorktreeCreateResult` (bounded `git rev-parse --verify` base check THEN `git worktree add -b `; fail-closed `base_unresolved`/`git_timeout`/`worktree_add_failed`; returns `cwd` — the working directory an executor spawn would use), `cmdWorktreeCreate(cwd, args, deps) → WorktreeCreateCmdResult` (CLI verb: plans, creates the worktree, then appends the manifest entry so it is immediately manageable by cleanup-wave/reap-orphans; dedupes by `(worktree_path, branch)`). #2584 ADR-1239 Codex-binding amendment, Phase 2: `worktree create` is the git-worktree-creation primitive for `dispatch.isolation: orchestrator-worktree` hosts — declared and testable but UNCONSUMED (no scheduler calls it yet; Phase 3 wires it). Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module. ### Worktree Lifecycle Module Workflow contract seam covering agent worktree lifecycle orchestration rules. The `worktree_branch_check` block lives in one canonical fragment (`gsd-core/references/worktree-branch-check.md`) that `execute-phase.md`, `quick.md`, `diagnose-issues.md`, and `execute-plan.md` embed at dispatch. Key invariants: `worktree_branch_check` is **verify-only and fail-closed** — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via `git symbolic-ref`; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; on base mismatch the sub-agent halts with `exit 42` and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at `execute_waves` entry that resolves the worktree root and refuses drift into an agent worktree (#48); cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`. @@ -128,7 +128,7 @@ Module owning bounded, never-throw git repository introspection — the single s Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`. ### Host-Integration Interface -Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; declared and negotiated but not yet consumed by any scheduler — Phase 1 of #2584). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016. +Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; declared and negotiated but not yet consumed by any scheduler — Phase 1 of #2584). `resolveOrchestratorExec(orchestratorExec, cwd) → { ok:true, command, args, cwd } | { ok:false, reason }` (#2584 Phase 2, pure, no I/O — resolves the `runtime.orchestratorExec` descriptor field, a sibling of `runtime.hostBehaviors` in `capability.json` carrying `{command, args?, cwdFlag?}`, into the concrete argv/cwd a process-spawn primitive would use for a `dispatch.isolation: orchestrator-worktree` host; appends `[cwdFlag, cwd]` to `args` when `cwdFlag` is a non-empty string, e.g. codex `exec --cd `, opencode `run --dir `, kimi `--work-dir `; when `cwdFlag` is `null`/absent — kimi-code's process-cwd case — no flag is appended and `cwd` alone is returned for the caller to bind via the subprocess's own working-directory option; fail-closed `missing_command`/`invalid_cwd`/`invalid_args`/`invalid_cwd_flag`; declared and testable but UNCONSUMED — no scheduler spawns anything with it yet, Phase 3 wires it). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016. ### Statusline Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens` and `statusline.state_format`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope. diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index 55df05aa9..d6b782ab3 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -75,6 +75,11 @@ "runtime": "node", "effortSurface": "argv" }, + "orchestratorExec": { + "command": "codex", + "args": ["exec"], + "cwdFlag": "--cd" + }, "hostBehaviors": { "reapplyCommand": "$gsd-update --reapply", "tomlConfigInstall": true, diff --git a/capabilities/kimi-code/capability.json b/capabilities/kimi-code/capability.json index 0f2bcc237..3399003a9 100644 --- a/capabilities/kimi-code/capability.json +++ b/capabilities/kimi-code/capability.json @@ -73,6 +73,11 @@ "transport": "mcp", "runtime": "node" }, + "orchestratorExec": { + "command": "kimi-code", + "args": [], + "cwdFlag": null + }, "hostBehaviors": { "reapplyCommand": "/skill:gsd-update --reapply", "localInstallDeferred": true, diff --git a/capabilities/kimi/capability.json b/capabilities/kimi/capability.json index 12a0a472c..c0f76b9ed 100644 --- a/capabilities/kimi/capability.json +++ b/capabilities/kimi/capability.json @@ -78,6 +78,11 @@ "runtime": "python", "effortSurface": "undocumented" }, + "orchestratorExec": { + "command": "kimi", + "args": [], + "cwdFlag": "--work-dir" + }, "hostBehaviors": { "reapplyCommand": "/skill:gsd-update --reapply", "localInstallDeferred": true, diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index ef8f15c46..5997fae07 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -87,6 +87,11 @@ "runtime": "bun", "effortSurface": "argv" }, + "orchestratorExec": { + "command": "opencode", + "args": ["run"], + "cwdFlag": "--dir" + }, "hostBehaviors": { "reapplyCommand": "/gsd-update --reapply", "attributionConfigResolver": "opencode", diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 20e1d84df..441729baa 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1505,8 +1505,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2)); } else if (subcommand === 'set-baseref') { require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2)); + } else if (subcommand === 'create') { + worktreeSafety.cmdWorktreeCreate(cwd, args.slice(2)); } else { - error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND); + error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref, create', ERROR_REASON.SDK_UNKNOWN_COMMAND); } } diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 2a5cdf7bc..41557688d 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -950,6 +950,13 @@ const capabilities = { "runtime": "node", "effortSurface": "argv" }, + "orchestratorExec": { + "command": "codex", + "args": [ + "exec" + ], + "cwdFlag": "--cd" + }, "hostBehaviors": { "reapplyCommand": "$gsd-update --reapply", "tomlConfigInstall": true, @@ -1753,6 +1760,11 @@ const capabilities = { "runtime": "python", "effortSurface": "undocumented" }, + "orchestratorExec": { + "command": "kimi", + "args": [], + "cwdFlag": "--work-dir" + }, "hostBehaviors": { "reapplyCommand": "/skill:gsd-update --reapply", "localInstallDeferred": true, @@ -1838,6 +1850,11 @@ const capabilities = { "transport": "mcp", "runtime": "node" }, + "orchestratorExec": { + "command": "kimi-code", + "args": [], + "cwdFlag": null + }, "hostBehaviors": { "reapplyCommand": "/skill:gsd-update --reapply", "localInstallDeferred": true, @@ -2162,6 +2179,13 @@ const capabilities = { "runtime": "bun", "effortSurface": "argv" }, + "orchestratorExec": { + "command": "opencode", + "args": [ + "run" + ], + "cwdFlag": "--dir" + }, "hostBehaviors": { "reapplyCommand": "/gsd-update --reapply", "attributionConfigResolver": "opencode", @@ -4674,6 +4698,13 @@ const runtimes = { "runtime": "node", "effortSurface": "argv" }, + "orchestratorExec": { + "command": "codex", + "args": [ + "exec" + ], + "cwdFlag": "--cd" + }, "hostBehaviors": { "reapplyCommand": "$gsd-update --reapply", "tomlConfigInstall": true, @@ -5182,6 +5213,11 @@ const runtimes = { "runtime": "python", "effortSurface": "undocumented" }, + "orchestratorExec": { + "command": "kimi", + "args": [], + "cwdFlag": "--work-dir" + }, "hostBehaviors": { "reapplyCommand": "/skill:gsd-update --reapply", "localInstallDeferred": true, @@ -5267,6 +5303,11 @@ const runtimes = { "transport": "mcp", "runtime": "node" }, + "orchestratorExec": { + "command": "kimi-code", + "args": [], + "cwdFlag": null + }, "hostBehaviors": { "reapplyCommand": "/skill:gsd-update --reapply", "localInstallDeferred": true, @@ -5367,6 +5408,13 @@ const runtimes = { "runtime": "bun", "effortSurface": "argv" }, + "orchestratorExec": { + "command": "opencode", + "args": [ + "run" + ], + "cwdFlag": "--dir" + }, "hostBehaviors": { "reapplyCommand": "/gsd-update --reapply", "attributionConfigResolver": "opencode", diff --git a/gsd-core/bin/lib/capability-validator.cjs b/gsd-core/bin/lib/capability-validator.cjs index 46eeb538f..a66667661 100644 --- a/gsd-core/bin/lib/capability-validator.cjs +++ b/gsd-core/bin/lib/capability-validator.cjs @@ -1316,6 +1316,59 @@ function validateRuntimeBody(cap) { } } + // orchestratorExec — ADR-1239 Codex-binding amendment (#2584), Phase 2. + // OPTIONAL top-level field (sibling of hostBehaviors), like hookEvents: + // only hosts whose hostIntegration.dispatch.isolation is + // 'orchestrator-worktree' need it, so it is not required on every runtime + // descriptor. When present it must be a well-formed exec descriptor for + // src/host-integration.cts's resolveOrchestratorExec. + if (r.orchestratorExec !== undefined) { + if (typeof r.orchestratorExec !== 'object' || r.orchestratorExec === null || Array.isArray(r.orchestratorExec)) { + errors.push( + 'runtime.orchestratorExec must be an object (got: ' + + (r.orchestratorExec === null ? 'null' : (Array.isArray(r.orchestratorExec) ? 'array' : typeof r.orchestratorExec)) + ')', + ); + } else { + const oe = r.orchestratorExec; + + // S2b: reserved-OWN-KEY guard (CodeQL barrier — inline literal comparisons) + if (Object.prototype.hasOwnProperty.call(oe, '__proto__')) { + errors.push('runtime.orchestratorExec must not contain reserved key "__proto__"'); + } + if (Object.prototype.hasOwnProperty.call(oe, 'constructor')) { + errors.push('runtime.orchestratorExec must not contain reserved key "constructor"'); + } + if (Object.prototype.hasOwnProperty.call(oe, 'prototype')) { + errors.push('runtime.orchestratorExec must not contain reserved key "prototype"'); + } + + // command — required non-empty string; reserved-name guard (CodeQL barrier) + if (oe.command === '__proto__' || oe.command === 'constructor' || oe.command === 'prototype') { + errors.push('runtime.orchestratorExec.command "' + oe.command + '" is a reserved name'); + } else if (typeof oe.command !== 'string' || oe.command.length === 0) { + errors.push( + 'runtime.orchestratorExec.command must be a non-empty string (got: ' + JSON.stringify(oe.command) + ')', + ); + } + + // args — optional array of strings + if (oe.args !== undefined) { + if (!Array.isArray(oe.args) || !oe.args.every((a) => typeof a === 'string')) { + errors.push( + 'runtime.orchestratorExec.args must be an array of strings (got: ' + JSON.stringify(oe.args) + ')', + ); + } + } + + // cwdFlag — optional; string or null + if (oe.cwdFlag !== undefined && oe.cwdFlag !== null && typeof oe.cwdFlag !== 'string') { + errors.push( + 'runtime.orchestratorExec.cwdFlag must be a string or null (got: ' + JSON.stringify(oe.cwdFlag) + ')', + ); + } + } + } + // GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX) // Only check if both fields are valid strings (individual field validators above report type errors). if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') { diff --git a/gsd-core/bin/lib/state-transition.cjs b/gsd-core/bin/lib/state-transition.cjs index c28a5b854..e957160b9 100644 --- a/gsd-core/bin/lib/state-transition.cjs +++ b/gsd-core/bin/lib/state-transition.cjs @@ -831,6 +831,18 @@ function plannedPhaseCore(content, intent, deps) { }, statusDefaults, lastActivityDefaults); if (body !== beforePos) updated.push('Current Position'); + // #2400 Bug B: sync progress.total_plans to the frontmatter when a plan count + // is given. This writes the explicitly-provided count — it is NOT a re-derivation + // from disk (#500 RC1 is about deriving from a half-planned snapshot, not about + // refusing to write an explicitly-passed argument). + if (intent.planCount !== null && intent.planCount !== undefined && hasFrontmatter) { + const fmProgress = existingFm['progress'] || {}; + if (fmProgress['total_plans'] !== intent.planCount) { + fmProgress['total_plans'] = intent.planCount; + existingFm['progress'] = fmProgress; + updated.push('progress.total_plans'); + } + } return { content: reassemble(body), updated }; } // ---------------------------------------------------------------------------- diff --git a/src/host-integration.cts b/src/host-integration.cts index da2704fbf..71d28b725 100644 --- a/src/host-integration.cts +++ b/src/host-integration.cts @@ -739,6 +739,65 @@ function extensionEventSurfaceFor(extensionEvents: unknown): readonly string[] | return EXTENSION_EVENT_SURFACES[extensionEvents] || null; } +// --------------------------------------------------------------------------- +// resolveOrchestratorExec — ADR-1239 Codex-binding amendment (#2584), Phase 2 +// +// The `orchestratorExec` descriptor field (sibling of `runtime.hostBehaviors` +// in capability.json) tells GSD how to process-spawn a host's own CLI as the +// executor inside a worktree GSD itself created (`isolation: +// 'orchestrator-worktree'`). Pure, no I/O — this only shapes the argv/cwd a +// caller would pass to a process-spawn primitive; it does not spawn anything +// itself. UNCONSUMED in Phase 2 — no scheduler calls this yet (Phase 3 wires +// it to the actual spawn). +// --------------------------------------------------------------------------- + +interface OrchestratorExec { + command: string; + args?: string[]; + cwdFlag?: string | null; +} + +type OrchestratorExecResolution = + | { ok: true; command: string; args: string[]; cwd: string } + | { ok: false; reason: string }; + +/** + * Resolve an `orchestratorExec` descriptor + target cwd into a concrete + * argv/cwd shape for a process-spawn primitive. + * + * Fail-closed: never throws, always returns a discriminated result. When + * `cwdFlag` is a non-empty string, `[cwdFlag, cwd]` is appended to `args` + * exactly once (e.g. codex: `exec --cd `); when `cwdFlag` is `null` or + * absent (e.g. kimi-code, which binds via the spawned process's own cwd — + * "process-cwd" case), no flag is appended and `cwd` is returned for the + * caller to bind via the subprocess's own working-directory option. + */ +function resolveOrchestratorExec(orchestratorExec: OrchestratorExec | undefined, cwd: string): OrchestratorExecResolution { + if (!orchestratorExec || typeof orchestratorExec !== 'object' || Array.isArray(orchestratorExec)) { + return { ok: false, reason: 'missing_command' }; + } + const oe = orchestratorExec as unknown as Record; + if (typeof oe.command !== 'string' || oe.command.length === 0) { + return { ok: false, reason: 'missing_command' }; + } + if (typeof cwd !== 'string' || cwd.length === 0) { + return { ok: false, reason: 'invalid_cwd' }; + } + if (oe.args !== undefined && (!Array.isArray(oe.args) || !oe.args.every((a) => typeof a === 'string'))) { + return { ok: false, reason: 'invalid_args' }; + } + if (oe.cwdFlag !== undefined && oe.cwdFlag !== null && typeof oe.cwdFlag !== 'string') { + return { ok: false, reason: 'invalid_cwd_flag' }; + } + + const baseArgs = Array.isArray(oe.args) ? [...oe.args] : []; + const args = typeof oe.cwdFlag === 'string' && oe.cwdFlag.length > 0 + ? [...baseArgs, oe.cwdFlag, cwd] + : baseArgs; + + return { ok: true, command: oe.command, args, cwd }; +} + // --------------------------------------------------------------------------- // Module export (CommonJS — matches existing src/*.cts pattern) // --------------------------------------------------------------------------- @@ -759,4 +818,5 @@ export = { resolveDispatchType, hookEventSurfaceFor, extensionEventSurfaceFor, + resolveOrchestratorExec, }; diff --git a/src/worktree-safety.cts b/src/worktree-safety.cts index 9d818b9f6..df3b22837 100644 --- a/src/worktree-safety.cts +++ b/src/worktree-safety.cts @@ -1104,6 +1104,369 @@ function cmdWorktreeRecordAgent(cwd: string, args: string[] = [], deps: RecordAg return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }; } +// ─── worktree create (#2584 ADR-1239 Codex-binding amendment — Phase 2) ─────── +// +// The `orchestrator-worktree` isolation ladder value (ADR-1239) requires GSD +// itself to create + bind the git worktree an executor runs in — this is that +// git-worktree-creation primitive. UNCONSUMED in Phase 2: no scheduler calls +// this yet (Phase 3 wires it). Mirrors the plan/execute/cmd split used by +// cleanup-wave and record-agent above so a created worktree is immediately +// manageable by cleanup-wave / reap-orphans without a second code path. + +interface WorktreeCreateFields { + agentId: string; + worktreePath: string; + branch: string; + base: string; +} + +interface WorktreeCreatePlan { + ok: boolean; + reason: string; + hint?: string; + entry: CleanupManifestEntry | null; +} + +/** + * Pure planner for `worktree create`. Validates the four required fields + * (write-strict, same missing-field-hint style as `planWorktreeRecordAgent`), + * then runs the candidate entry through the SAME `normalizeCleanupManifestEntry` + * validation the cleanup-wave reader and record-agent use — so a worktree this + * verb creates is guaranteed manageable by cleanup-wave/reap-orphans, and an + * entry that would fail the reader's branch-namespace guard is rejected here, + * fail-closed, before any git command runs. + */ +function planWorktreeCreate(fields: WorktreeCreateFields): WorktreeCreatePlan { + const agentId = (fields.agentId || '').trim(); + const worktreePath = (fields.worktreePath || '').trim(); + const branch = (fields.branch || '').trim(); + const base = (fields.base || '').trim(); + const missing: string[] = []; + if (!agentId) missing.push('--agent-id'); + if (!worktreePath) missing.push('--path'); + if (!branch) missing.push('--branch'); + if (!base) missing.push('--base'); + if (missing.length > 0) { + return { + ok: false, + reason: 'missing_field', + hint: `worktree create requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`, + entry: null, + }; + } + + const candidate = { + agent_id: agentId, + worktree_path: worktreePath, + branch, + expected_base: base, + }; + const entry = normalizeCleanupManifestEntry(candidate); + if (!entry) { + return { + ok: false, + reason: 'invalid_entry', + hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts both agent- and worktree-agent- namespaces; got branch="${branch}"). Fix the field and re-run.`, + entry: null, + }; + } + + // #2584 FIX 4 — git argument-injection guard: a value starting with '-' + // could be parsed by git as a FLAG rather than a positional argument (e.g. + // base="--upload-pack=x", path="-f"). `git worktree add` / `git rev-parse` + // support for a `--` end-of-options separator is inconsistent across git + // versions, so rejecting a leading dash outright — not relying on `--` — is + // the portable fix. + if (branch.startsWith('-') || base.startsWith('-') || worktreePath.startsWith('-')) { + return { + ok: false, + reason: 'unsafe_leading_dash', + hint: `--branch/--base/--path must not start with "-" (a leading dash would be parsed by git as a flag, not a value). Got branch="${branch}" base="${base}" path="${worktreePath}".`, + entry: null, + }; + } + + // #2584 FIX 4 — path-traversal guard: reject a ".." path segment in --path. + // Absolute paths ARE allowed (the orchestrator legitimately uses them — + // Phase-3 root confinement is out of Phase-2 scope); only a literal ".." + // component is rejected. Split on BOTH separators so the guard is effective + // on a Windows-style path too. + if (worktreePath.split(/[/\\]/).includes('..')) { + return { + ok: false, + reason: 'unsafe_path_traversal', + hint: `--path must not contain a ".." path segment (got: "${worktreePath}").`, + entry: null, + }; + } + + return { ok: true, reason: 'ok', entry }; +} + +interface WorktreeCreateResult { + ok: boolean; + reason: string; + worktree_path?: string; + branch?: string; + base?: string; + cwd?: string; + stderr?: string; +} + +/** + * Best-effort bounded rollback of a partial/orphaned worktree (#2584 FIX 3, + * scope narrowed by FIX 5). Invoked ONLY when a `git worktree add` TIMED OUT + * mid-operation — a SIGTERM'd `add` can leave a `.git/worktrees/` admin + * entry / directory on disk that got past validation into the file checkout, + * so the partial is genuinely THIS call's own creation and is safe to + * best-effort remove immediately. It is deliberately NOT invoked on a clean + * non-zero `add` exit (see FIX 5) — the most common such failure is a + * COLLISION (the path/branch is already a registered worktree), git fails + * FAST there having created nothing, and the branch namespace this verb + * writes into (`worktree-agent-*`/`agent-*`) is exactly the concurrent- + * executor namespace, so a colliding path is very plausibly a LIVE PEER + * executor whose uncommitted work `--force` would destroy. Also invoked from + * `cmdWorktreeCreate` when a successful `add` is followed by a manifest-write + * failure (#2584 FIX 1) — that path proves THIS call created the worktree, so + * removing it is safe. This is immediate best-effort hygiene, not the only + * safety net: `reapOrphanWorktrees` scans the `.git/worktrees/` admin + * directory directly (a genuine directory-scan backstop, not manifest-only), + * so any partial this call cannot reach is still eventually discovered and + * reaped there. The result is intentionally ignored and a throw is + * swallowed: this is best-effort cleanup, never a new source of truth, and + * must never mask or block the caller's own degraded-but-honest return. + */ +function rollbackPartialWorktree(execGit: ExecGitFn, worktreePath: string, repoRoot: string): void { + try { + execGit(['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot }); + } catch { + // best-effort only — a throwing rollback must never mask the original failure. + } +} + +/** + * Execute a `planWorktreeCreate` plan via bounded git. Fail-closed at every + * step — a timeout or a non-zero exit degrades to a structured result rather + * than throwing, and the base must resolve BEFORE any worktree is created (no + * partial/orphaned worktree on a bad base). Returns `cwd` — the working + * directory Phase 3's executor spawn will pass through. + */ +function executeWorktreeCreatePlan(plan: WorktreeCreatePlan, repoRoot: string, deps: WorktreeDeps = {}): WorktreeCreateResult { + const execGit = deps.execGit || execGitDefault; + if (!plan || !plan.ok || !plan.entry) { + return { + ok: false, + reason: plan ? plan.reason : 'missing_plan', + }; + } + + const { worktree_path: worktreePath, branch, expected_base: base } = plan.entry; + const normalizedPath = posixNormalize(worktreePath); + + // 1. Verify the base resolves BEFORE creating anything (fail-closed). Nothing + // has been created on this path yet, so there is nothing to roll back. + const baseCheck = execGit(['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: repoRoot }); + if (baseCheck.timedOut) { + return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base }; + } + if (!gitResultOk(baseCheck)) { + return { ok: false, reason: 'base_unresolved', worktree_path: normalizedPath, branch, base, stderr: baseCheck.stderr || '' }; + } + + // 2. Create the worktree + branch together. + const addResult = execGit(['worktree', 'add', '-b', branch, worktreePath, base], { cwd: repoRoot }); + if (addResult.timedOut) { + // #2584 FIX 3: a SIGTERM'd `add` can leave a partial worktree on disk. + rollbackPartialWorktree(execGit, worktreePath, repoRoot); + return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base }; + } + if (addResult.exitCode !== 0) { + // #2584 FIX 5: deliberately NO rollback here. A clean non-zero exit is + // most commonly a COLLISION (path/branch already a registered worktree), + // and git fails FAST on that — it creates nothing. A colliding path in + // this branch namespace is very plausibly a LIVE PEER executor; + // `git worktree remove --force` on it would destroy real, uncommitted + // work. The safe response to a clean failure is to fail loudly and leave + // whatever is already on disk untouched. + return { ok: false, reason: 'worktree_add_failed', worktree_path: normalizedPath, branch, base, stderr: addResult.stderr || '' }; + } + + return { + ok: true, + reason: 'created', + worktree_path: normalizedPath, + branch, + base, + cwd: normalizedPath, + }; +} + +interface WorktreeCreateCmdResult { + ok: boolean; + reason: string; + hint?: string; + entry?: CleanupManifestEntry | null; + cwd?: string; + manifest_path?: string; + stderr?: string; +} + +/** + * CLI command: create a git worktree + branch off `--base`, then append the + * validated manifest entry so the worktree is immediately manageable by + * `worktree cleanup-wave` / `worktree reap-orphans`. + * + * Usage: worktree create --manifest --agent-id --path --branch --base + * + * #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/ + * plan step runs BEFORE the git side effect (step 5). The ONLY manifest + * operation that can run AFTER `git worktree add` has succeeded is the final + * guarded write (step 6), and a failure there triggers a best-effort rollback + * of the just-created worktree — so a malformed/mis-shaped manifest, or a + * `writeFile` IO error, can never leave a REAL worktree on disk with no + * manifest entry (cleanup-wave/reap-orphans only discover worktrees via the + * manifest, never a directory scan) or an uncaught throw. + */ +function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCmdDeps & WorktreeDeps = {}): WorktreeCreateCmdResult { + const flag = (name: string): string => { + const i = args.indexOf(name); + return i >= 0 && i + 1 < args.length ? args[i + 1] : ''; + }; + const write = deps.write || ((s: string) => process.stdout.write(s)); + const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s)); + + const manifestPath = flag('--manifest'); + if (!manifestPath) { + writeErr('Usage: worktree create --manifest --agent-id --path --branch --base \n'); + process.exitCode = 2; + return { ok: false, reason: 'usage' }; + } + + // 1. Read the manifest (no side effect yet). + const resolved = path.resolve(cwd, manifestPath); + const readFile = deps.readFile || ((p: string) => fs.readFileSync(p, 'utf8')); + let manifestRaw: string; + try { + manifestRaw = readFile(resolved); + } catch (err) { + const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before creating agent worktrees.`; + writeErr(`[gsd] worktree.create: manifest_read_failed — ${hint}\n`); + write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: (err as Error).message }, null, 2)}\n`); + process.exitCode = 1; + return { ok: false, reason: 'manifest_read_failed', hint }; + } + + // 2. Parse + shape-validate the manifest BEFORE any git command runs. + // Mirrors planWorktreeRecordAgent's shell-acceptance rules (canonical + // {worktrees:[]} object OR a bare top-level array). + let parsed: unknown; + try { + parsed = JSON.parse(manifestRaw); + } catch { + const hint = 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before creating agent worktrees.'; + writeErr(`[gsd] worktree.create: invalid_manifest_json — ${hint}\n`); + write(`${JSON.stringify({ ok: false, reason: 'invalid_manifest_json', hint }, null, 2)}\n`); + process.exitCode = 1; + return { ok: false, reason: 'invalid_manifest_json', hint }; + } + + let worktrees: unknown[]; + let writeBack: unknown; + if (Array.isArray(parsed)) { + worktrees = parsed; + writeBack = worktrees; + } else if (parsed && typeof parsed === 'object') { + const container = parsed as Record; + if (container.worktrees === undefined) container.worktrees = []; + if (!Array.isArray(container.worktrees)) { + const hint = 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.'; + writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`); + write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`); + process.exitCode = 1; + return { ok: false, reason: 'manifest_shape_invalid', hint }; + } + worktrees = container.worktrees; + writeBack = container; + } else { + const hint = 'Manifest must be a JSON object {"worktrees": []} or a top-level array.'; + writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`); + write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`); + process.exitCode = 1; + return { ok: false, reason: 'manifest_shape_invalid', hint }; + } + + // 3. Plan (pure, no I/O) — still before any git command. + const plan = planWorktreeCreate({ + agentId: flag('--agent-id'), + worktreePath: flag('--path'), + branch: flag('--branch'), + base: flag('--base'), + }); + + if (!plan.ok || !plan.entry) { + writeErr(`[gsd] worktree.create: ${plan.reason} — ${plan.hint || ''}\n`); + write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`); + process.exitCode = 1; + return { ok: false, reason: plan.reason, hint: plan.hint }; + } + + // 4. Compute the deduped final manifest STRING in memory now — the ONLY + // manifest work left is the guarded write in step 6, after git succeeds. + // #2584 FIX 2: the on-disk entry is the SAME minimal 4-field shape + // `cmdWorktreeRecordAgent` writes — never the full normalized entry with + // the derived `allowed_bases` — so the two verbs never write divergent + // shapes into the same manifest (the reader re-derives `allowed_bases` + // from `expected_base` on load, exactly as it does for record-agent). + const recorded: CleanupManifestEntry = { + agent_id: plan.entry.agent_id, + worktree_path: plan.entry.worktree_path, + branch: plan.entry.branch, + expected_base: plan.entry.expected_base, + }; + const dedupeKey = `${recorded.worktree_path}\0${recorded.branch}`; + const alreadyPresent = worktrees.some((existing) => { + const normalized = normalizeCleanupManifestEntry(existing); + return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dedupeKey; + }); + if (!alreadyPresent) { + worktrees.push(recorded); + } + const manifestToWrite = `${JSON.stringify(writeBack, null, 2)}\n`; + + // 5. NOW run the git side effect. Every manifest problem above is caught + // before this point, so a malformed/mis-shaped manifest can never leave + // an unmanifested worktree on disk. `executeWorktreeCreatePlan` itself + // best-effort rolls back a partial worktree on its own add-timeout / + // add-failed paths (#2584 FIX 3). + const result = executeWorktreeCreatePlan(plan, cwd, deps); + if (!result.ok) { + writeErr(`[gsd] worktree.create: ${result.reason} — ${result.stderr || ''}\n`); + write(`${JSON.stringify({ ok: false, reason: result.reason, stderr: result.stderr }, null, 2)}\n`); + process.exitCode = 1; + return { ok: false, reason: result.reason, stderr: result.stderr }; + } + + // 6. Write the pre-computed manifest string, guarded. A write failure here + // means a REAL worktree now exists with NO manifest entry — roll it back + // (best-effort) rather than leaving an orphan cleanup-wave/reap-orphans + // can never reach (#2584 FIX 1). Never throws past this function. + const writeFile = deps.writeFile || ((p: string, content: string) => fs.writeFileSync(p, content, 'utf8')); + try { + writeFile(resolved, manifestToWrite); + } catch (err) { + const execGit = deps.execGit || execGitDefault; + rollbackPartialWorktree(execGit, plan.entry.worktree_path, cwd); + const hint = `The worktree was created but the manifest write failed (${(err as Error).message}); rolled back the worktree via a best-effort 'git worktree remove --force'.`; + writeErr(`[gsd] worktree.create: manifest_write_failed — ${hint}\n`); + write(`${JSON.stringify({ ok: false, reason: 'manifest_write_failed', hint, error: (err as Error).message }, null, 2)}\n`); + process.exitCode = 1; + return { ok: false, reason: 'manifest_write_failed', hint }; + } + + write(`${JSON.stringify({ ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved }, null, 2)}\n`); + return { ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved }; +} + /** * Reap orphaned linked worktrees whose lock owner process is dead, whose * branch tip is fully merged into the default branch, and whose lock file @@ -1405,6 +1768,9 @@ export = { cmdWorktreeCleanupWave, planWorktreeRecordAgent, cmdWorktreeRecordAgent, + planWorktreeCreate, + executeWorktreeCreatePlan, + cmdWorktreeCreate, reapOrphanWorktrees, cmdWorktreeReapOrphans, resolveWorktreeRoot, diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index c72c3317a..95129a4b9 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "81a14ee661fc6d9e", + "gsd-core/bin/gsd-tools.cjs": "6578e0a1bc8adb0a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index b258b4271..00b6c4580 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/claude-local.json b/tests/fixtures/golden-install-parity/claude-local.json index 706030e91..6012d0d13 100644 --- a/tests/fixtures/golden-install-parity/claude-local.json +++ b/tests/fixtures/golden-install-parity/claude-local.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index 9ac94b580..91287f6e4 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index a3809388b..2eb559fa3 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -42,7 +42,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf", - "gsd-core/bin/gsd-tools.cjs": "4b16121487e88694", + "gsd-core/bin/gsd-tools.cjs": "b9c55c2bbeec85fe", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 58251a23b..c7b8cf2c0 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index 3b510e23a..d56773928 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -145,7 +145,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index 6c925db52..591dc7c5c 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -40,7 +40,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "81a14ee661fc6d9e", + "gsd-core/bin/gsd-tools.cjs": "6578e0a1bc8adb0a", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index 97da6ef48..e57053384 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828", - "gsd-core/bin/gsd-tools.cjs": "8edb842f617d5774", + "gsd-core/bin/gsd-tools.cjs": "3ac4c0a9f3a509f9", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 264299f88..b0ec8d162 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f", - "gsd-core/bin/gsd-tools.cjs": "936cbc6c8670a2bd", + "gsd-core/bin/gsd-tools.cjs": "551974377e74c2bd", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index e32fee630..befd1a9ff 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/kimi-code.json b/tests/fixtures/golden-install-parity/kimi-code.json index 446f5e560..8042bf26b 100644 --- a/tests/fixtures/golden-install-parity/kimi-code.json +++ b/tests/fixtures/golden-install-parity/kimi-code.json @@ -67,7 +67,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index bcd39c42b..cd620df9c 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -103,7 +103,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 8680b26fb..42738c869 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/pi.json b/tests/fixtures/golden-install-parity/pi.json index 617ec1dab..d4db2a1c3 100644 --- a/tests/fixtures/golden-install-parity/pi.json +++ b/tests/fixtures/golden-install-parity/pi.json @@ -6,7 +6,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 53bb936da..c7bae87cb 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2", - "gsd-core/bin/gsd-tools.cjs": "3bdfaa6906a522db", + "gsd-core/bin/gsd-tools.cjs": "509319fab53d5022", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 604f8bfa3..8b3318c4c 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527", - "gsd-core/bin/gsd-tools.cjs": "9859f8d6249a0c5a", + "gsd-core/bin/gsd-tools.cjs": "70126458e4c1216f", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index 714d36815..666624216 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2", - "gsd-core/bin/gsd-tools.cjs": "e1c37ecdafc97d8f", + "gsd-core/bin/gsd-tools.cjs": "5e8b0e86049ca8ef", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index b2202efc1..7affbda59 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -110,7 +110,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34", + "gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1", diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs index 87579cb6a..6e0b646e7 100644 --- a/tests/host-integration.test.cjs +++ b/tests/host-integration.test.cjs @@ -28,6 +28,7 @@ const { HOOK_EVENT_SURFACES, extensionEventSurfaceFor, EXTENSION_EVENT_SURFACES, + resolveOrchestratorExec, } = hi; const { @@ -1317,3 +1318,300 @@ describe('#2584 dispatch.isolation — validator', () => { } }); }); + +// --------------------------------------------------------------------------- +// #2584 — ADR-1239 Codex-binding amendment, Phase 2: resolveOrchestratorExec +// + the `runtime.orchestratorExec` descriptor field (sibling of hostBehaviors). +// UNCONSUMED in Phase 2 — no scheduler calls this yet (Phase 3 wires it). +// --------------------------------------------------------------------------- + +describe('resolveOrchestratorExec — the 4 shipped orchestrator-worktree descriptors', () => { + const CWD = '/repo/.claude/worktrees/agent-a1'; + + test('codex: exec --cd ', () => { + const result = resolveOrchestratorExec({ command: 'codex', args: ['exec'], cwdFlag: '--cd' }, CWD); + assert.equal(result.ok, true); + assert.equal(result.command, 'codex'); + assert.deepEqual(result.args, ['exec', '--cd', CWD]); + assert.equal(result.cwd, CWD); + }); + + test('opencode: run --dir ', () => { + const result = resolveOrchestratorExec({ command: 'opencode', args: ['run'], cwdFlag: '--dir' }, CWD); + assert.equal(result.ok, true); + assert.equal(result.command, 'opencode'); + assert.deepEqual(result.args, ['run', '--dir', CWD]); + assert.equal(result.cwd, CWD); + }); + + test('kimi: --work-dir (no leading verb)', () => { + const result = resolveOrchestratorExec({ command: 'kimi', args: [], cwdFlag: '--work-dir' }, CWD); + assert.equal(result.ok, true); + assert.equal(result.command, 'kimi'); + assert.deepEqual(result.args, ['--work-dir', CWD]); + assert.equal(result.cwd, CWD); + }); + + test('kimi-code: cwdFlag:null — NO flag appended, cwd still returned (process-cwd case)', () => { + const result = resolveOrchestratorExec({ command: 'kimi-code', args: [], cwdFlag: null }, CWD); + assert.equal(result.ok, true); + assert.equal(result.command, 'kimi-code'); + assert.deepEqual(result.args, []); + assert.equal(result.cwd, CWD); + }); +}); + +describe('resolveOrchestratorExec — fail-closed', () => { + test('undefined descriptor → missing_command', () => { + const result = resolveOrchestratorExec(undefined, '/repo/wt'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing_command'); + }); + + test('{} (no command) → missing_command', () => { + const result = resolveOrchestratorExec({}, '/repo/wt'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing_command'); + }); + + test('command: "" (empty string) → missing_command', () => { + const result = resolveOrchestratorExec({ command: '' }, '/repo/wt'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'missing_command'); + }); + + test('command: non-string → missing_command', () => { + for (const bogus of [42, null, {}, []]) { + const result = resolveOrchestratorExec({ command: bogus }, '/repo/wt'); + assert.equal(result.ok, false, `command=${JSON.stringify(bogus)} must fail`); + assert.equal(result.reason, 'missing_command'); + } + }); + + test('empty cwd → invalid_cwd', () => { + const result = resolveOrchestratorExec({ command: 'codex' }, ''); + assert.equal(result.ok, false); + assert.equal(result.reason, 'invalid_cwd'); + }); + + test('non-string cwd → invalid_cwd', () => { + for (const bogus of [undefined, null, 42, {}]) { + const result = resolveOrchestratorExec({ command: 'codex' }, bogus); + assert.equal(result.ok, false, `cwd=${JSON.stringify(bogus)} must fail`); + assert.equal(result.reason, 'invalid_cwd'); + } + }); + + test('args not an array → invalid_args', () => { + const result = resolveOrchestratorExec({ command: 'codex', args: 'exec' }, '/repo/wt'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'invalid_args'); + }); + + test('args array with a non-string element → invalid_args', () => { + const result = resolveOrchestratorExec({ command: 'codex', args: ['exec', 42] }, '/repo/wt'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'invalid_args'); + }); + + test('cwdFlag a number → invalid_cwd_flag', () => { + const result = resolveOrchestratorExec({ command: 'codex', cwdFlag: 42 }, '/repo/wt'); + assert.equal(result.ok, false); + assert.equal(result.reason, 'invalid_cwd_flag'); + }); + + test('cwdFlag an object/array → invalid_cwd_flag', () => { + for (const bogus of [{}, []]) { + const result = resolveOrchestratorExec({ command: 'codex', cwdFlag: bogus }, '/repo/wt'); + assert.equal(result.ok, false, `cwdFlag=${JSON.stringify(bogus)} must fail`); + assert.equal(result.reason, 'invalid_cwd_flag'); + } + }); + + test('a reserved-name command string ("__proto__") still resolves at the resolver layer', () => { + // The resolver only checks "is it a non-empty string" — it never does a + // property lookup keyed by `command`, so there is no prototype-pollution + // surface here. The VALIDATOR (capability-validator.cjs) is what rejects + // "__proto__" at descriptor-load time — see the validator describe block below. + const result = resolveOrchestratorExec({ command: '__proto__' }, '/repo/wt'); + assert.equal(result.ok, true); + assert.equal(result.command, '__proto__'); + }); +}); + +describe('resolveOrchestratorExec — fast-check property test', () => { + const commandArb = fc.string({ minLength: 1 }).filter((s) => s.length > 0); + const argsArb = fc.array(fc.string()); + const cwdFlagArb = fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.string({ minLength: 1 }).filter((s) => s.length > 0), + ); + const cwdArb = fc.string({ minLength: 1 }).filter((s) => s.length > 0); + + test('property: ok:true, command preserved, cwdFlag appended exactly once (or never for null/absent)', () => { + fc.assert( + fc.property(commandArb, argsArb, cwdFlagArb, cwdArb, (command, args, cwdFlag, cwd) => { + // Guard against the astronomically-rare but non-zero case where the + // arbitrary `args` already happens to contain the exact `cwd` string — + // that would make "cwd appears exactly once, at the tail" a false + // assertion about pre-existing data rather than the resolver's own + // behavior. Deterministic given the seed; not a flakiness workaround. + fc.pre(!args.includes(cwd)); + const descriptor = cwdFlag === undefined ? { command, args } : { command, args, cwdFlag }; + const result = resolveOrchestratorExec(descriptor, cwd); + assert.equal(result.ok, true); + assert.equal(result.command, command); + assert.equal(result.cwd, cwd); + if (typeof cwdFlag === 'string' && cwdFlag.length > 0) { + assert.deepEqual(result.args.slice(-2), [cwdFlag, cwd]); + // exactly once: cwdFlag/cwd do not appear anywhere earlier in args + const earlier = result.args.slice(0, -2); + assert.ok(!earlier.includes(cwd), 'cwd must not appear before the trailing pair'); + } else { + assert.ok(!result.args.includes(cwd), 'cwd must not appear in args when cwdFlag is null/absent'); + } + }), + { numRuns: 200, seed: 2584 }, + ); + }); +}); + +describe('#2584 orchestratorExec — parity / divergence guard', () => { + const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities'); + + function loadCapability(id) { + return JSON.parse(fs.readFileSync(path.join(CAPABILITIES_DIR, id, 'capability.json'), 'utf8')); + } + + test('every capability whose dispatch.isolation is "orchestrator-worktree" declares a resolvable orchestratorExec', () => { + const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => { + const capPath = path.join(CAPABILITIES_DIR, entry, 'capability.json'); + return fs.existsSync(capPath); + }); + const orchestratorWorktreeHosts = []; + for (const id of capIds) { + const cap = loadCapability(id); + const iso = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch + && cap.runtime.hostIntegration.dispatch.isolation; + if (iso !== 'orchestrator-worktree') continue; + orchestratorWorktreeHosts.push(id); + + const orchestratorExec = cap.runtime.orchestratorExec; + assert.ok( + orchestratorExec, + `${id}: dispatch.isolation:"orchestrator-worktree" but no runtime.orchestratorExec declared — ` + + `a future orchestrator-worktree host MUST declare orchestratorExec (Phase 3 has nothing to spawn otherwise)`, + ); + const result = resolveOrchestratorExec(orchestratorExec, '/tmp/wt'); + assert.equal(result.ok, true, + `${id}: runtime.orchestratorExec must resolve cleanly; got reason="${result.ok ? '' : result.reason}"`); + } + // Sanity: the sweep actually found the 4 known hosts (guards a broken sweep + // silently matching zero capabilities and passing vacuously). + assert.deepEqual(orchestratorWorktreeHosts.sort(), ['codex', 'kimi', 'kimi-code', 'opencode']); + }); +}); + +describe('#2584 orchestratorExec — validator', () => { + function shippedCodexCapabilityWithoutOrchestratorExec() { + const cap = JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'codex', 'capability.json'), 'utf8'), + ); + delete cap.runtime.orchestratorExec; + return cap; + } + + test('a descriptor that omits orchestratorExec entirely still validates clean (optional field)', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + const errors = validateCapability(cap, 'codex'); + assert.deepEqual(errors, [], `omitted orchestratorExec must validate clean, got: ${JSON.stringify(errors)}`); + }); + + test('a well-formed orchestratorExec passes with zero orchestratorExec errors', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { command: 'codex', args: ['exec'], cwdFlag: '--cd' }; + const errors = validateCapability(cap, 'codex'); + const oeErrors = errors.filter((e) => e.includes('orchestratorExec')); + assert.deepEqual(oeErrors, []); + }); + + test('orchestratorExec: not an object (array/null/string) → rejected', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + for (const bogus of [[], null, 'codex', 42]) { + cap.runtime.orchestratorExec = bogus; + const errors = validateCapability(cap, 'codex'); + assert.ok( + errors.some((e) => e.includes('runtime.orchestratorExec must be an object')), + `orchestratorExec=${JSON.stringify(bogus)} must be rejected; got: ${JSON.stringify(errors)}`, + ); + } + }); + + test('command missing → rejected', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { args: [], cwdFlag: null }; + const errors = validateCapability(cap, 'codex'); + assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.command'))); + }); + + test('command: "" (empty string) → rejected', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { command: '' }; + const errors = validateCapability(cap, 'codex'); + assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.command'))); + }); + + test('command: "__proto__" (reserved name) → rejected', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { command: '__proto__' }; + const errors = validateCapability(cap, 'codex'); + assert.ok( + errors.some((e) => e.includes('runtime.orchestratorExec.command') && e.includes('reserved name')), + `"__proto__" must produce a reserved-name validator error; got: ${JSON.stringify(errors)}`, + ); + }); + + test('args: non-array → rejected', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { command: 'codex', args: 'exec' }; + const errors = validateCapability(cap, 'codex'); + assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.args'))); + }); + + test('args: array with a non-string element → rejected', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { command: 'codex', args: ['exec', 42] }; + const errors = validateCapability(cap, 'codex'); + assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.args'))); + }); + + test('cwdFlag: a number → rejected', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { command: 'codex', cwdFlag: 42 }; + const errors = validateCapability(cap, 'codex'); + assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.cwdFlag'))); + }); + + test('cwdFlag: null is valid (no error)', () => { + const cap = shippedCodexCapabilityWithoutOrchestratorExec(); + cap.runtime.orchestratorExec = { command: 'kimi-code', args: [], cwdFlag: null }; + const errors = validateCapability(cap, 'codex'); + const oeErrors = errors.filter((e) => e.includes('orchestratorExec')); + assert.deepEqual(oeErrors, []); + }); + + test('full descriptor sweep: every shipped capability.json still validates clean after the orchestratorExec addition', () => { + const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities'); + const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => { + const capPath = path.join(CAPABILITIES_DIR, entry, 'capability.json'); + return fs.existsSync(capPath); + }); + for (const id of capIds) { + const cap = JSON.parse(fs.readFileSync(path.join(CAPABILITIES_DIR, id, 'capability.json'), 'utf8')); + if (cap.role !== 'runtime') continue; + const errors = validateCapability(cap, id); + assert.deepEqual(errors, [], `${id}: capability.json must still validate clean; got: ${JSON.stringify(errors)}`); + } + }); +}); diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index 1d93a14c8..64cf791c9 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -40,6 +40,9 @@ const { executeWorktreeWaveCleanupPlan, planWorktreeRecordAgent, cmdWorktreeRecordAgent, + planWorktreeCreate, + executeWorktreeCreatePlan, + cmdWorktreeCreate, } = require(WORKTREE_SAFETY_PATH); const isWindows = process.platform === 'win32'; @@ -1046,6 +1049,539 @@ describe('worktree record-agent — real CLI dispatch (#1298)', () => { }); }); +// ─── worktree create (#2584 ADR-1239 Codex-binding amendment — Phase 2) ─────── +// UNCONSUMED in Phase 2: no scheduler calls this yet (Phase 3 wires it). These +// tests pin the git-worktree-creation primitive itself. + +describe('planWorktreeCreate', () => { + const okFields = { + agentId: 'a1', + worktreePath: '/repo/.claude/worktrees/agent-a1', + branch: 'worktree-agent-a1', + base: 'abc123', + }; + + test('happy path returns ok:true with the normalized entry', () => { + const plan = planWorktreeCreate(okFields); + assert.equal(plan.ok, true); + assert.equal(plan.reason, 'ok'); + assert.equal(plan.entry.agent_id, 'a1'); + assert.equal(plan.entry.worktree_path, okFields.worktreePath); + assert.equal(plan.entry.branch, 'worktree-agent-a1'); + assert.equal(plan.entry.expected_base, 'abc123'); + }); + + test('missing --agent-id reports the missing flag', () => { + const plan = planWorktreeCreate({ ...okFields, agentId: '' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'missing_field'); + assert.match(plan.hint, /--agent-id/); + assert.equal(plan.entry, null); + }); + + test('whitespace-only field is treated as missing', () => { + const plan = planWorktreeCreate({ ...okFields, base: ' ' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'missing_field'); + assert.match(plan.hint, /--base/); + }); + + test('branch-regex fail-closed: a branch outside the worktree-agent-* namespace is rejected', () => { + const plan = planWorktreeCreate({ ...okFields, branch: 'not-worktree-agent-x' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'invalid_entry'); + assert.equal(plan.entry, null); + }); + + test('accepts the current agent- namespace (#1995)', () => { + const plan = planWorktreeCreate({ ...okFields, branch: 'agent-a1' }); + assert.equal(plan.ok, true); + assert.equal(plan.entry.branch, 'agent-a1'); + }); + + // ─── #2584 FIX 4: git argument-injection + path-traversal guards ────────── + + test('FIX4: a leading-dash branch is rejected (fail-closed via the pre-existing branch-namespace regex)', () => { + // The leading-dash guard runs AFTER normalizeCleanupManifestEntry (per the + // fix ordering), and WORKTREE_AGENT_BRANCH_RE anchors on `^(worktree-)?agent-` + // — no string starting with '-' can ever match it. So a dash-prefixed branch + // is already fully fail-closed by the EARLIER regex gate (reason + // 'invalid_entry') and never reaches the leading-dash check at all; the + // net security property (a branch value can never reach git as a bare + // flag) holds either way. + const plan = planWorktreeCreate({ ...okFields, branch: '-x' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'invalid_entry'); + assert.equal(plan.entry, null); + }); + + test('FIX4: a leading-dash base is rejected', () => { + const plan = planWorktreeCreate({ ...okFields, base: '-f' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'unsafe_leading_dash'); + }); + + test('FIX4: a leading-dash path is rejected', () => { + const plan = planWorktreeCreate({ ...okFields, worktreePath: '-f' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'unsafe_leading_dash'); + }); + + test('FIX4: a ".." path-traversal segment is rejected (POSIX separator)', () => { + const plan = planWorktreeCreate({ ...okFields, worktreePath: 'a/../../etc/x' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'unsafe_path_traversal'); + }); + + test('FIX4: a ".." path-traversal segment is rejected (Windows separator)', () => { + const plan = planWorktreeCreate({ ...okFields, worktreePath: 'a\\..\\..\\etc\\x' }); + assert.equal(plan.ok, false); + assert.equal(plan.reason, 'unsafe_path_traversal'); + }); + + test('FIX4: a normal path with a hyphen in a segment name still passes', () => { + const plan = planWorktreeCreate({ ...okFields, worktreePath: '/tmp/wt-1' }); + assert.equal(plan.ok, true); + }); + + test('FIX4: an absolute path is NOT rejected (the orchestrator legitimately uses absolute paths)', () => { + const plan = planWorktreeCreate({ ...okFields, worktreePath: '/repo/.claude/worktrees/agent-a1' }); + assert.equal(plan.ok, true); + assert.equal(plan.entry.worktree_path, '/repo/.claude/worktrees/agent-a1'); + }); +}); + +describe('executeWorktreeCreatePlan', () => { + const okFields = { + agentId: 'a1', + worktreePath: '/repo/.claude/worktrees/agent-a1', + branch: 'worktree-agent-a1', + base: 'abc123', + }; + + test('base_unresolved: a non-zero rev-parse --verify blocks BEFORE any worktree add is attempted', () => { + const plan = planWorktreeCreate(okFields); + const calls = []; + const execGit = (args) => { + calls.push(args); + if (args[0] === 'rev-parse') return { exitCode: 1, stdout: '', stderr: 'fatal: bad revision', timedOut: false }; + return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + }; + const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'base_unresolved'); + assert.ok(!calls.some((c) => c[0] === 'worktree'), 'must NOT attempt `git worktree add` when the base is unresolved'); + }); + + test('successful create: ok:true, cwd is the posix-normalized worktree path', () => { + const plan = planWorktreeCreate(okFields); + const calls = []; + const execGit = (args) => { + calls.push(args); + return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + }; + const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit }); + assert.equal(result.ok, true); + assert.equal(result.reason, 'created'); + assert.equal(result.cwd, okFields.worktreePath); + assert.equal(result.worktree_path, okFields.worktreePath); + assert.equal(result.branch, 'worktree-agent-a1'); + assert.equal(result.base, 'abc123'); + assert.ok(calls.some((c) => c[0] === 'worktree' && c[1] === 'add'), 'must call `git worktree add`'); + }); + + test('timeout on the base check degrades to git_timeout — does not throw, and no rollback is attempted (nothing was created)', () => { + const plan = planWorktreeCreate(okFields); + const calls = []; + const timeoutStub = makeTimeoutStub(); + const execGit = (args, opts) => { calls.push(args); return timeoutStub(args, opts); }; + const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'git_timeout'); + assert.ok(!calls.some((c) => c[0] === 'worktree'), 'must NOT attempt any `git worktree` call (nothing was ever created)'); + }); + + test('FIX3: timeout on `git worktree add` degrades to git_timeout AND best-effort rolls back the partial worktree', () => { + const plan = planWorktreeCreate(okFields); + const timeoutStub = makeTimeoutStub(); + const calls = []; + const execGit = (args, opts) => { + calls.push(args); + if (args[0] === 'rev-parse') return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + return timeoutStub(args, opts); + }; + const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'git_timeout'); + const rollbackCall = calls.find((c) => c[0] === 'worktree' && c[1] === 'remove'); + assert.ok(rollbackCall, 'a best-effort `git worktree remove --force` rollback must be attempted'); + assert.ok(rollbackCall.includes('--force')); + assert.equal(rollbackCall[rollbackCall.length - 1], okFields.worktreePath); + }); + + test('FIX5 [data-loss guard]: worktree_add_failed (clean collision exit) does NOT roll back — leaves a pre-existing peer worktree untouched', () => { + // The most common non-zero `add` exit is a COLLISION: the target + // path/branch is already a registered worktree. git fails FAST there and + // creates nothing. The branch namespace this verb writes into + // (worktree-agent-*/agent-*) IS the concurrent-executor namespace, so a + // colliding path is very plausibly a LIVE PEER executor — rolling it back + // would destroy real, uncommitted work. This must NEVER call + // `git worktree remove`. + const plan = planWorktreeCreate(okFields); + const calls = []; + const execGit = (args) => { + calls.push(args); + if (args[0] === 'rev-parse') return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + return { exitCode: 128, stdout: '', stderr: `fatal: '${okFields.worktreePath}' already exists`, timedOut: false }; + }; + const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'worktree_add_failed'); + assert.match(result.stderr, /already exists/); + assert.ok( + !calls.some((c) => c[0] === 'worktree' && c[1] === 'remove'), + 'a clean non-zero `add` exit must NEVER trigger a rollback — the colliding path may be a live peer executor', + ); + }); + + test('the timeout-path rollback never masks the original failure even if the rollback execGit itself throws', () => { + // The throwing-rollback contract is only exercised on the SURVIVING + // rollback call site (the timeout path) after FIX 5 removed the + // clean-exit rollback. + const plan = planWorktreeCreate(okFields); + const execGit = (args) => { + if (args[0] === 'rev-parse') return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + if (args[0] === 'worktree' && args[1] === 'add') { + return { exitCode: null, stdout: '', stderr: '', timedOut: true, signal: 'SIGTERM' }; + } + throw new Error('rollback execGit exploded'); + }; + const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'git_timeout'); + }); + + test('a skip plan (invalid_entry) is echoed back without any git call', () => { + const plan = planWorktreeCreate({ ...okFields, branch: 'not-worktree-agent-x' }); + const calls = []; + const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit: (args) => { calls.push(args); return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; } }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'invalid_entry'); + assert.equal(calls.length, 0); + }); + + test('Windows path: a worktreePath with backslashes is posix-normalized in the result (worktree_path and cwd)', + () => { + const winFields = { ...okFields, worktreePath: 'C:\\repo\\.claude\\worktrees\\agent-a1' }; + const plan = planWorktreeCreate(winFields); + const execGit = () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }); + const result = executeWorktreeCreatePlan(plan, 'C:\\repo\\main', { execGit }); + assert.equal(result.ok, true); + assert.equal(result.cwd, 'C:/repo/.claude/worktrees/agent-a1'); + assert.equal(result.worktree_path, 'C:/repo/.claude/worktrees/agent-a1'); + assert.ok(!result.cwd.includes('\\'), 'cwd must contain no backslashes'); + }); +}); + +describe('cmdWorktreeCreate', () => { + function withExitCode(fn) { + const saved = process.exitCode; + try { return fn(); } finally { process.exitCode = saved; } + } + + const okArgs = [ + '--manifest', 'manifest.json', + '--agent-id', 'a1', + '--path', '/repo/.claude/worktrees/agent-a1', + '--branch', 'worktree-agent-a1', + '--base', 'abc123', + ]; + + function okExecGit() { + return () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }); + } + + test('creates the worktree and appends the entry to an empty manifest', () => { + let writtenPath = null; + let writtenContent = null; + const out = []; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}', + writeFile: (p, c) => { writtenPath = p; writtenContent = c; }, + write: (s) => out.push(s), + writeErr: () => {}, + execGit: okExecGit(), + }); + assert.equal(result.ok, true); + assert.equal(result.reason, 'created'); + assert.equal(result.cwd, '/repo/.claude/worktrees/agent-a1'); + assert.equal(writtenPath, path.resolve('/repo/main', 'manifest.json')); + const written = JSON.parse(writtenContent); + assert.equal(written.worktrees.length, 1); + assert.equal(written.worktrees[0].agent_id, 'a1'); + assert.equal(written.worktrees[0].worktree_path, '/repo/.claude/worktrees/agent-a1'); + assert.equal(written.worktrees[0].branch, 'worktree-agent-a1'); + assert.equal(written.worktrees[0].expected_base, 'abc123'); + assert.deepEqual( + Object.keys(written.worktrees[0]).sort(), + ['agent_id', 'branch', 'expected_base', 'worktree_path'], + 'FIX2: the on-disk entry must be the minimal 4-field shape — no derived allowed_bases', + ); + assert.match(out.join(''), /"ok": true/); + }); + + test('boundary: appending to a manifest with 1 existing entry yields 2', () => { + let writtenContent = null; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => JSON.stringify({ + orchestrator_root: '/repo/main', + worktrees: [{ agent_id: 'other', worktree_path: '/repo/.claude/worktrees/agent-other', branch: 'worktree-agent-other', expected_base: 'def456' }], + }), + writeFile: (_p, c) => { writtenContent = c; }, + write: () => {}, + writeErr: () => {}, + execGit: okExecGit(), + }); + assert.equal(result.ok, true); + const written = JSON.parse(writtenContent); + assert.equal(written.worktrees.length, 2); + }); + + test('boundary: appending to a manifest with 2 existing entries yields 3', () => { + let writtenContent = null; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => JSON.stringify({ + orchestrator_root: '/repo/main', + worktrees: [ + { agent_id: 'x1', worktree_path: '/repo/.claude/worktrees/agent-x1', branch: 'worktree-agent-x1', expected_base: 'def456' }, + { agent_id: 'x2', worktree_path: '/repo/.claude/worktrees/agent-x2', branch: 'worktree-agent-x2', expected_base: 'def456' }, + ], + }), + writeFile: (_p, c) => { writtenContent = c; }, + write: () => {}, + writeErr: () => {}, + execGit: okExecGit(), + }); + assert.equal(result.ok, true); + const written = JSON.parse(writtenContent); + assert.equal(written.worktrees.length, 3); + }); + + test('dedupe: re-recording an identical (worktree_path, branch) entry does NOT grow the manifest', () => { + let writtenContent = null; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => JSON.stringify({ + orchestrator_root: '/repo/main', + worktrees: [ + { agent_id: 'a1', worktree_path: '/repo/.claude/worktrees/agent-a1', branch: 'worktree-agent-a1', expected_base: 'abc123' }, + ], + }), + writeFile: (_p, c) => { writtenContent = c; }, + write: () => {}, + writeErr: () => {}, + execGit: okExecGit(), + }); + assert.equal(result.ok, true); + const written = JSON.parse(writtenContent); + assert.equal(written.worktrees.length, 1, 'dedupe must not append a second identical entry'); + }); + + test('exits 2 with usage when --manifest is missing', () => { + withExitCode(() => { + const errs = []; + const result = cmdWorktreeCreate('/repo/main', ['--agent-id', 'a1'], { + writeErr: (s) => errs.push(s), + write: () => {}, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'usage'); + assert.equal(process.exitCode, 2); + assert.match(errs.join(''), /Usage: worktree create/); + }); + }); + + test('exits 1 loudly when the manifest cannot be read', () => { + withExitCode(() => { + const errs = []; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => { throw new Error('ENOENT'); }, + writeErr: (s) => errs.push(s), + write: () => {}, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'manifest_read_failed'); + assert.equal(process.exitCode, 1); + assert.match(errs.join(''), /manifest_read_failed/); + }); + }); + + test('does not write the manifest and does not call git when the entry is invalid', () => { + withExitCode(() => { + let wrote = false; + let gitCalled = false; + const result = cmdWorktreeCreate('/repo/main', + ['--manifest', 'm.json', '--agent-id', 'a1', '--path', '/p', '--branch', 'feature/x', '--base', 'abc123'], { + readFile: () => '{"worktrees":[]}', + writeFile: () => { wrote = true; }, + write: () => {}, + writeErr: () => {}, + execGit: () => { gitCalled = true; return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'invalid_entry'); + assert.equal(wrote, false); + assert.equal(gitCalled, false, 'must not call git before the entry validates'); + assert.equal(process.exitCode, 1); + }); + }); + + test('does not write the manifest when the base is unresolved', () => { + withExitCode(() => { + let wrote = false; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => '{"worktrees":[]}', + writeFile: () => { wrote = true; }, + write: () => {}, + writeErr: () => {}, + execGit: (args) => (args[0] === 'rev-parse' + ? { exitCode: 1, stdout: '', stderr: 'fatal: bad revision', timedOut: false } + : { exitCode: 0, stdout: '', stderr: '', timedOut: false }), + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'base_unresolved'); + assert.equal(wrote, false, 'must not write the manifest when the worktree was never created'); + assert.equal(process.exitCode, 1); + }); + }); + + // ─── #2584 FIX 1: manifest work must ALL run before the git side effect ─── + + test('FIX1: a malformed (truncated JSON) manifest fails with the parse reason BEFORE any git command runs', () => { + withExitCode(() => { + let addCalled = false; + let wrote = false; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => '{"worktrees": [', // truncated JSON — a git stub here WOULD succeed if ever called + writeFile: () => { wrote = true; }, + write: () => {}, + writeErr: () => {}, + execGit: (args) => { + if (args[0] === 'worktree' && args[1] === 'add') addCalled = true; + return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'invalid_manifest_json'); + assert.equal(addCalled, false, 'git worktree add must never be invoked when the manifest fails to parse (no orphan possible)'); + assert.equal(wrote, false); + assert.equal(process.exitCode, 1); + }); + }); + + test('FIX1: a manifest whose "worktrees" is not an array fails with manifest_shape_invalid BEFORE any git command runs', () => { + withExitCode(() => { + let addCalled = false; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => JSON.stringify({ orchestrator_root: '/repo/main', worktrees: 'not-an-array' }), + writeFile: () => {}, + write: () => {}, + writeErr: () => {}, + execGit: (args) => { + if (args[0] === 'worktree' && args[1] === 'add') addCalled = true; + return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'manifest_shape_invalid'); + assert.equal(addCalled, false, 'git worktree add must never be invoked when the manifest shape is invalid'); + assert.equal(process.exitCode, 1); + }); + }); + + test('FIX1: a writeFile failure AFTER a successful git create rolls back the worktree and reports manifest_write_failed', () => { + withExitCode(() => { + const gitCalls = []; + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}', + writeFile: () => { throw new Error('EACCES: permission denied'); }, + write: () => {}, + writeErr: () => {}, + execGit: (args) => { gitCalls.push(args); return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'manifest_write_failed'); + assert.equal(process.exitCode, 1); + assert.ok(gitCalls.some((c) => c[0] === 'worktree' && c[1] === 'add'), 'the worktree must actually have been created before the write failed'); + const rollbackCall = gitCalls.find((c) => c[0] === 'worktree' && c[1] === 'remove'); + assert.ok(rollbackCall, 'a git worktree remove --force rollback call must be made after a manifest write failure'); + assert.ok(rollbackCall.includes('--force')); + assert.equal(rollbackCall[rollbackCall.length - 1], '/repo/.claude/worktrees/agent-a1'); + }); + }); + + test('FIX1: a writeFile failure does not throw past cmdWorktreeCreate even when the rollback execGit itself throws', () => { + withExitCode(() => { + const result = cmdWorktreeCreate('/repo/main', okArgs, { + readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}', + writeFile: () => { throw new Error('ENOSPC: no space left on device'); }, + write: () => {}, + writeErr: () => {}, + execGit: (args) => { + if (args[0] === 'worktree' && args[1] === 'remove') throw new Error('rollback execGit exploded'); + return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; + }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'manifest_write_failed'); + assert.equal(process.exitCode, 1); + }); + }); +}); + +// ─── #2584 FIX 2: on-disk entry-shape parity between create and record-agent ── +// Generative-fix-divergence guard: both verbs write into the SAME manifest, so +// they must persist the identical field-set for equivalent inputs. + +describe('cmdWorktreeCreate / cmdWorktreeRecordAgent — on-disk entry parity (#2584 FIX 2)', () => { + test('both verbs persist the identical 4-field entry for equivalent inputs', () => { + const argsFor = (manifestFlag) => [ + manifestFlag, 'manifest.json', + '--agent-id', 'a1', + '--path', '/repo/.claude/worktrees/agent-a1', + '--branch', 'worktree-agent-a1', + '--base', 'abc123', + ]; + + let createdContent = null; + cmdWorktreeCreate('/repo/main', argsFor('--manifest'), { + readFile: () => '{"worktrees":[]}', + writeFile: (_p, c) => { createdContent = c; }, + write: () => {}, + writeErr: () => {}, + execGit: () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }), + }); + + let recordedContent = null; + cmdWorktreeRecordAgent('/repo/main', argsFor('--manifest'), { + readFile: () => '{"worktrees":[]}', + writeFile: (_p, c) => { recordedContent = c; }, + write: () => {}, + writeErr: () => {}, + }); + + assert.ok(createdContent, 'cmdWorktreeCreate must have written a manifest'); + assert.ok(recordedContent, 'cmdWorktreeRecordAgent must have written a manifest'); + const createdEntry = JSON.parse(createdContent).worktrees[0]; + const recordedEntry = JSON.parse(recordedContent).worktrees[0]; + assert.deepEqual( + Object.keys(createdEntry).sort(), + Object.keys(recordedEntry).sort(), + 'both verbs must persist the SAME field-set (no derived allowed_bases from create)', + ); + assert.deepEqual(createdEntry, recordedEntry, 'identical inputs must produce byte-identical on-disk entries'); + }); +}); + // ─── executeWorktreeWaveCleanupPlan ─────────────────────────────────────────── describe('executeWorktreeWaveCleanupPlan', () => {