feat(#2584): Phase 2 — worktree create verb + orchestrator-exec resolver (#2625)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-24 21:23:06 -04:00
committed by GitHub
parent 7e1c736a3e
commit 4a66d62d10
32 changed files with 1417 additions and 22 deletions

View File

@@ -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<T> { 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<T>` 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 <branch> <path> <base>`; 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 <cwd>`, opencode `run --dir <cwd>`, kimi `--work-dir <cwd>`; 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.

View File

@@ -75,6 +75,11 @@
"runtime": "node",
"effortSurface": "argv"
},
"orchestratorExec": {
"command": "codex",
"args": ["exec"],
"cwdFlag": "--cd"
},
"hostBehaviors": {
"reapplyCommand": "$gsd-update --reapply",
"tomlConfigInstall": true,

View File

@@ -73,6 +73,11 @@
"transport": "mcp",
"runtime": "node"
},
"orchestratorExec": {
"command": "kimi-code",
"args": [],
"cwdFlag": null
},
"hostBehaviors": {
"reapplyCommand": "/skill:gsd-update --reapply",
"localInstallDeferred": true,

View File

@@ -78,6 +78,11 @@
"runtime": "python",
"effortSurface": "undocumented"
},
"orchestratorExec": {
"command": "kimi",
"args": [],
"cwdFlag": "--work-dir"
},
"hostBehaviors": {
"reapplyCommand": "/skill:gsd-update --reapply",
"localInstallDeferred": true,

View File

@@ -87,6 +87,11 @@
"runtime": "bun",
"effortSurface": "argv"
},
"orchestratorExec": {
"command": "opencode",
"args": ["run"],
"cwdFlag": "--dir"
},
"hostBehaviors": {
"reapplyCommand": "/gsd-update --reapply",
"attributionConfigResolver": "opencode",

View File

@@ -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);
}
}

View File

@@ -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",

View File

@@ -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') {

View File

@@ -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 };
}
// ----------------------------------------------------------------------------

View File

@@ -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 <cwd>`); 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<string, unknown>;
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,
};

View File

@@ -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-<id> and worktree-agent-<id> 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/<name>` 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 <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
*
* #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 <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\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<string, unknown>;
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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 <cwd>', () => {
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 <cwd>', () => {
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 <cwd> (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)}`);
}
});
});

View File

@@ -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-<id> 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', () => {