* fix(#2652): gate quick/diagnose dispatch on dispatch.isolation, not runtime name quick.md and diagnose-issues.md kept the pre-#2584 `RUNTIME != "claude"` worktree gate, so every non-Claude runtime failed closed regardless of the capability it negotiated — including Codex, which declares orchestrator-worktree. Route both through the negotiated dispatch.isolation seam via a new shared reference, and migrate the two execute-phase reference fragments that carried the same runtime-name gate. - new gsd-core/references/dispatch-isolation-gate.md: canonical ISOLATION resolution, harness-flag resolution, single-agent degrade rule - quick.md / diagnose-issues.md read the gate; dispatch uses the {harnessFlag} placeholder rather than a hardcoded isolation="worktree" - execute-phase-wave-guard.md / execute-phase-between-wave-reset.md: migrate [ "$RUNTIME" = "claude" ] -> [ "$ISOLATION" = "harness-worktree" ] - every degrade site now clears BOTH USE_WORKTREES and ISOLATION; clearing one dispatched an isolated agent with no base guard and no manifest - parity guard in host-integration.test.cjs scans workflows AND references and matches six reintroduction shapes - migrate four tests that pinned the pre-#2584 runtime-name contract Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2652): use the /gsd:<cmd> namespace in the isolation degrade messages The degrade warnings cited /gsd-execute-phase, the retired hyphen form that slash-command-namespace.test.cjs rejects in Claude-facing source. Same length, so the quick.md size budget is unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(#2652): add changeset for PR #2728 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2652): normalize dispatch-site paths to forward slashes for Windows path.relative() returns backslash-separated paths on Windows, so the #2652 dispatch-site parity test compared "gsd-core\workflows\quick.md" against the hardcoded forward-slash literal "gsd-core/workflows/quick.md" and failed on every windows-latest CI lane. Normalize with .replace(/\\/g, '/'), matching the existing convention used elsewhere in this suite (e.g. tests/branch-no-track-guard.test.cjs:37). * test(#2652): restore the size-growth acknowledgment The rebase dropped tests/emitted-drift-ack.json. #2757/#2758 fixed the ATTRIBUTION axis, but the SIZE-GROWTH axis is independent: diagnose-issues.md (+2086) and quick.md (+230) still need an ack naming them and saying why. Verified: 65/66 without it (both files named), 66/66 with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2652): convert execute-plan.md Pattern A onto the dispatch-isolation gate Pattern A hardcoded `isolation="worktree"` — Claude Code's own literal — gated only on `workflow.use_worktrees`, with no capability negotiation at all. It is the same defect #2652 fixes at the other four sites, just a different shape: the file contains no RUNTIME variable, so the new detector correctly does not flag it. Concrete break: a Codex user who follows this PR's own newly-documented pattern and sets `workflow.use_worktrees: true` to get isolated dispatch via /gsd:quick then runs a plan through /gsd-execute-plan Pattern A, and hits an unconverted path — either an Agent() call erroring on an unrecognized parameter or silent unisolated execution, depending on host tolerance. Pattern A is a single-agent dispatch site through the host's own subagent tool, so it takes the same treatment as quick.md and diagnose-issues.md: resolve ISOLATION/HARNESS_FLAG through the canonical reference, degrade to sequential on orchestrator-worktree hosts, and substitute the host's declared {harnessFlag} instead of Claude Code's literal. while the area was open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(#2652): add the INVENTORY row for dispatch-isolation-gate.md, refresh CONTEXT Two bookkeeping gaps flagged in review: INVENTORY.md had no row for the new gsd-core/references/dispatch-isolation-gate.md. INVENTORY-MANIFEST.json was regenerated correctly and its --check only diffs a live directory scan against the committed manifest, so CI passed regardless — but gen-inventory-manifest.cjs's own stderr guidance says to add the matching INVENTORY.md row. This is the repo's named "Inventory Drift" pattern. Placed with the dispatch/isolation cluster (worktree-branch-check, runtime-aware-dispatch) rather than alphabetically, matching how that table is grouped. CONTEXT.md's Host-Integration Interface entry still described dispatch.isolation as "declared and negotiated but not yet consumed by any scheduler — Phase 1 of #2584". That was already stale before this PR (execute-phase graduated in Phase 3) and more so now with three single-agent dispatch sites consuming it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(#2652): detect reversed-operand runtime gates; add a permutation property All five reintroduction regexes assumed $RUNTIME on the LEFT of the comparison, so `[ "claude" != "$RUNTIME" ]` — the same gate written backwards — evaded every one of them. Verified against the old patterns before fixing: all four reversed shapes (single bracket, double bracket, test builtin, JS template) scored EVADED. Each comparison shape is now generated in both operand orders from a single template, so a shape cannot be added in one order and forgotten in the other. The mutation table gains the four reversed cases. Also adds the fast-check property review suggested in place of the hand-rolled cases: it generates the cross product of the axes an author actually varies — bracket form, operator, operand order, quoting, spacing, runtime id — so a permutation the hand-written patterns miss surfaces here rather than in production. The 11 explicit cases stay as named regression anchors. execute-plan.md joins the scan's required-identities list now that it is a converted dispatch site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(#2652): acknowledge the execute-plan.md size growth The Pattern A conversion adds 811 bytes to an emitted workflow. Per #2719 the size axis needs its own acknowledgment, independent of attribution. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(#2652): repin the execute-plan.md PROSE_ALLOWLIST line after the rebase The #2751 command-position gate pins its prose exemptions by line number. This branch inserts the dispatch-isolation resolution above the `validated downstream by gsd-tools uat classify-coverage` sentence, moving it from execute-plan.md:387 to :397 — which fired the gate twice for one displacement (an un-allowlisted mention at 397, a stale entry at 387). The prose itself is unchanged from next; only the pin moves. Fixes #2652 * fix(#2652): gate the #2649 base-check on ISOLATION in diagnose-issues.md The rebase onto next merged #2649's pre-dispatch base-check textually, but its degrade flipped USE_WORKTREES after ISOLATION was already resolved, so the degrade never reached the dispatch decision. Gate the block on ISOLATION = "harness-worktree" and degrade ISOLATION itself, the same pairing quick.md already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2652): key quick.md post-dispatch bookkeeping on ISOLATION, not the Claude literal Review Blocker: the manifest append (l.822), worktree merge-back (l.825), and its skip clause (l.839) all conditioned on the literal isolation="worktree" — Claude Code's own rendering of {harnessFlag}. Cursor renders --worktree, so a newly-unblocked isolated Cursor run created a worktree whose committed work was never merged back and never cleaned up, silently. All three now key on ISOLATION = "harness-worktree" at dispatch. The existing parity detector cannot catch this class (its ISOLATION_TOKEN treats the literal as a legitimate marker), so this adds a dedicated literal-condition detector with a discrimination proof against both pre-fix sentences, a benign-mention control, and a positive pin on all three re-keyed conditions. Verified fail-first against the pre-fix quick.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2652): scope the use_worktrees=false install stamp to isolation=none runtimes `_stampNonClaudeRuntimeDefaults` rewrote every non-Claude runtime's `workflow.use_worktrees` read to `--default false`. That default resolved before `gsd_run query dispatch-isolation` was ever consulted, so the five runtimes that declare worktree support — cursor (harness-worktree) and codex/opencode/kimi/kimi-code (orchestrator-worktree) — got ISOLATION=none regardless of what they negotiated. The gate this PR migrates dispatch onto was therefore still deciding isolation by runtime name, one layer down. The stamp's #1521 premise was that worktree isolation *was* Claude Code's isolation="worktree" spawn parameter, which no other host honored. #2584 replaced that premise with the negotiated capability. The stamp is now scoped to runtimes whose negotiated isolation really is `none`, where the default it writes is the outcome the resolver reaches anyway. `_negotiatedDispatchIsolation` mirrors routeDispatchIsolation's resolution against the same registry — closed vocabulary, a harness-worktree host must declare its flag, an orchestrator-worktree host must carry a descriptor that resolves — and fails closed to `none` on anything else, so an undeclared or unknown runtime keeps today's behavior. Two #1515 tests pinned the superseded premise for codex and are re-pointed at the new contract rather than deleted: the safety property they protect is now held by the isolation gate's fail-closed resolution, not by a name-scoped install-time default. Verified fail-first — all five assertions red against the pre-fix source, green after. * test(#2652): acknowledge the emitted ripple and re-point the end-to-end stamp proof Scoping the use_worktrees stamp changes emitted output, and two gates caught it. `gsd-core/workflows/execute-phase.md` now differs at emit time for the five hosts that declare worktree support (cursor harness-worktree; codex, opencode, kimi, kimi-code orchestrator-worktree) — the source file is byte-identical, only the stamp is gone. Acknowledged in this PR's fragment. `tests/install.test.cjs`'s real-install assertion pinned the superseded premise end-to-end, asserting codex receives `--default false`. Re-pointed rather than deleted, matching the two unit tests: it now proves codex keeps the unstamped `true` read. A second arm installs windsurf — which declares isolation `none` — and asserts the false stamp is still applied there, so the change cannot silently degrade into "never stamp" without a test noticing. The ack entry collides with `2658-trae-instruction-file-path.json`, which is fully spent (merged via #2925, so all 25 of its entries are present at base and gate nothing) and is pruned for the same reason and by the same rule as the spent `2649-*` fragment this PR already removed. #2566 prunes the same file for the same collision on `new-project.md`; a delete/delete merges cleanly either way, and the base-side cleanup would make both unnecessary. * fix(#2652): re-record the sentinel when a dispatch site degrades isolation Review Blocker B1/B2/B3. Every isolation degrade in a dispatch site is decided in shell, where routeDispatchIsolation cannot see it. That resolver persists whatever it resolved to the run-scoped sentinel as an unconditional side effect (#3045), so a degrade that only reassigns $ISOLATION leaves the sentinel asserting harness-worktree while the dispatch correctly omits the harness flag. The shipped PreToolUse guard reads the sentinel at the instant of the Agent() call and denies that mismatch with exit 2 — the work does not run unisolated, it does not run at all. Latent on this branch and lands on rebase, since8f75e275(#3045) is not yet in the merge-base. Four sites now push the final shell-computed value through the same single write path with --force-isolation, matching the idiom #3045 established in executor-isolation-dispatch.md: - quick.md, after the #1941 base-check degrade - diagnose-issues.md, after the config-gate degrades and after #2649's - execute-plan.md Pattern A, before spawning - references/dispatch-isolation-gate.md, both degrade paths, plus a new "Re-record after every degrade" section — the canonical file taught the defect, so fixing only the call sites would leave the source of truth wrong Tests assert the RECORDED value, not $ISOLATION. Asserting the local variable is what let this class through: $ISOLATION was already `none` at every site and the defect was entirely in what reached the sentinel. Each workflow's own degrade block is executed under a gsd_run stub that captures the write, with a fail-first proof that the pre-fix shape records nothing (while $ISOLATION reads `none` in both), plus a coverage guard so a new degrade site cannot skip it. Also corrects the drift-ack rationale (review Minor 5): @-references are eagerly inlined, so extracting the gate does not reduce loaded context. The reason to extract is single-sourcing across five dispatch sites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(#2652): satisfy the new CRLF-portability and cleanup lint rules in host-integration.test.cjs next's local/no-crlf-fragile-split and no-raw-rmsync-in-tests rules now cover the fenced-block regexes, log-line split, and temp-dir removal this suite added: bash-fence matchers and line counting accept \r\n, and the raw fs.rmSync becomes helpers.cleanup (Windows-EBUSY retry budget). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2652): restore next's 2658 ack fragment, minus the one colliding key trek-e (PR #2728, 2026-08-07): the branch deleted tests/emitted-drift-acks/2658-trae-instruction-file-path.json wholesale while next had modified it. That was correct against the 08-03 base, where the fragment was fully spent; it is wrong against next @1d208e5a, which still carries 23 live entries. next's copy is restored byte-identical except for the single key that genuinely collides with this PR's own fragment, gsd-core/workflows/execute-phase.md. Both acks name that path for different deltas -- 2658's is the trae CLAUDE.md replacement-target rewrite, ours is the emit-time _stampNonClaudeRuntimeDefaults ripple from review round 3. Per the ack-lifecycle law (#2789), an entry already at the base is spent and inert, so this PR's entry is the live one and 2658's is dropped. This follows the guidance given on #2566 in the 08-06 round: "Regenerate rather than delete -- the collision is one entry." Verified: lint-emitted-drift-ack ok (0 problems, 357 keys, no cross-source duplicates); emitted-attribution 170/170 with GSD_EMITTED_BASE=upstream/next; host-integration 222/222; runtime-converters 130/130. * fix(#2652): bound the degrade-harness spawn and close the round-6 majors B1 (CI red, ours): tests/host-integration.test.cjs spawned bash with no timeout, violating local/no-unbounded-spawn. `next` deleted the allowlist outright (#3148), so the merge-commit run flags it even though this branch still carries the file's grandfathered entry. Bounded at 15s, with a named failure on timeout/signal rather than an opaque `exited null`. M1: add a parity test between `_negotiatedDispatchIsolation` (install time) and `routeDispatchIsolation` (dispatch time). Both read the same capability registry and the same `resolveOrchestratorExec`, but duplicate the DECISION on top of them across two surfaces with no call edge between them, so neither symbol appears in the other's impact graph and nothing static can catch them drifting apart. The resolver leg drives the real gsd-tools CLI per registered runtime, both ways it is really called: with `--cwd-target` (the executor spawn, which resolves the orchestrator descriptor — the same question install time asks) and without it (the `Resolve ISOLATION` call every dispatch site makes first, which does not). The second leg is what catches an orchestrator host whose descriptor stops resolving: the install would stamp `use_worktrees=false` while the workflow gate still reported `orchestrator-worktree`. M2: add install-level Cursor coverage. A real `--cursor` install, then the gate blocks that install emitted, run against the gsd-tools that install emitted, with the runtime declared through `.planning/config.json` — the tier `resolveRuntime` actually reads — and any ambient GSD_RUNTIME blanked, so the install has to reach the right resolver on its own. It then performs the documented `{harnessFlag}` substitution against the `Agent()` call that install emitted and asserts on the rendered dispatch: exactly one emitted Agent() call carries the slot, it is the gsd-executor / gsd-debugger dispatch rather than some other call in the same file, and rendering it yields `--worktree` with no residual placeholder and no `isolation="worktree"`. This is artifact-level — it proves the emitted wiring, not a live Cursor host invocation. Asserting the shell variable alone would have stayed green if the placeholder were deleted from the emitted dispatch, or drifted onto the reviewer call beside it. M3: diagnose-issues.md inlined a reordered copy of the reference this PR introduces as the single source of truth. It now reads the reference the same way quick.md and execute-plan.md do; the drift-ack entry is corrected to describe what the file actually contains, and to name the four files that reference the gate rather than claiming five. M4: CONTEXT.md still called `resolveOrchestratorExec` UNCONSUMED in the same paragraph this PR edits. It has been consumed since #2584 Phase 3 — routed through `query dispatch-isolation --json` and process-spawned by executor-isolation-dispatch.md — and #2652 adds a second consumer. Every new assertion verified fail-first against a real mutation: cursor's negotiated isolation (breaks the target-bound parity leg), the no-target orchestrator branch in routeDispatchIsolation (breaks the gate parity leg), cursor's harnessIsolationFlag (breaks the resolved value), deleting `{harnessFlag}` from quick.md's emitted Agent() call (breaks the slot), and moving it onto the code-reviewer dispatch (breaks the wrong-call guard). Refs #2652 * fix(#2652): serialize unisolated diagnosis, scope the execute-plan gate to dispatching patterns Round-7 review findings (independent cross-AI pass over the whole PR against the current base). BLOCKER — diagnose-issues.md announced sequential mode and then fanned out. The `orchestrator-worktree` degrade sets ISOLATION=none and prints "debug agents run sequentially on the main working tree", but the spawn step still said "All agents spawn in single message (parallel execution)". On Codex, OpenCode and Kimi that dispatched N unisolated debuggers concurrently against the primary checkout — the exact outcome the degrade exists to prevent, and reachable only because this PR removed the FATAL that used to stop those hosts earlier. Fan-out is now keyed on ISOLATION: parallel only when each agent has its own worktree, one at a time otherwise. BLOCKER — execute-plan.md Pattern B could not dispatch at all on Claude or Cursor. The gate recorded `harness-worktree` to the #3045 sentinel, but only Pattern A carries `{harnessFlag}`; Pattern B's segment executors carry none, and `hooks/gsd-agent-isolation-guard.js` blocks precisely that mismatch with exit 2. Segments are unisolated BY DESIGN — each continues on the working tree the previous one left behind, so per-agent worktrees would break the sequence — so Pattern B now records `none` before its first dispatch and dispatches without the flag. MAJOR — the same gate ran before routing was chosen, so an isolation-`none` host with `use_worktrees=true` hit the fail-closed FATAL even when routing would have selected Pattern C, which is fully inline and dispatches nothing. Resolution now happens after the pattern is known, and Pattern C skips it. MAJOR — tests/host-integration.test.cjs fed `fs.readFileSync` output straight to bash. The `\r?\n` fence regex guards only the delimiter, leaving embedded CR on every line of the captured body — DEFECT.WINDOWS-CRLF-TEST-PORTABILITY, which helpers.cjs documents by name. Now reads through `readFileNormalized`. MAJOR — the "every dispatch-site degrade block re-records" test hand-listed three files, so its name was a claim its scan could not support. The scan is now derived from the workflow/reference tree (SCAN_ROOTS/collectMarkdown hoisted to module scope so there is one definition, not two). Verified fail-first against execute-plan.md — a file the previous scan never opened. The two wave fragments are exempt because they delegate the re-record to per-plan-worktree-gate.md via USE_WORKTREES_FOR_PLAN; that delegation is now ASSERTED, so deleting the delegate fails this test instead of widening a hole silently. MINOR — the changeset claimed the FATAL was gone for "non-Claude runtimes" full stop. Narrowed: isolation-`none` hosts still fail closed when worktrees are explicitly enabled, which is the contract rather than the defect. Two further findings were investigated and rejected, with evidence: - Raw `spawnSync` vs `tests/helpers/process-seam.cjs`: the seam exposes runNode/runGit/runHook and cannot express the `bash -c` harness these tests need; `installAndRead` in this same file is byte-identical to the base and still uses raw spawnSync with an explicit timeout, which is the form the lint sanctions. Migrating only the new call sites would split the file's convention for no safety gain. - `pending-migration-to-typed-ir` on the runtime-converters parity test: the annotation and the rendered-text loop both exist at the merge-base under #3090. This PR extends an already-tracked test rather than adding a new one under a category CONTRIBUTING closes to new tests. Refs #2652 * test(#2652): re-point the execute-plan prose allowlist at its shifted line `PROSE_ALLOWLIST` in tests/no-bare-gsd-tools-command-position.test.cjs keys entries by LINE NUMBER. The previous commit added the post-routing isolation block to execute-plan.md, which pushed the `validated downstream by gsd-tools uat classify-coverage` prose mention from line 397 to 414. That broke the guard in both directions at once: the entry at 397 went stale, and the real mention at 414 became an unallowlisted offender. Caught by CI (7 red jobs, all shard 3/3 plus ubuntu-22) rather than locally, because I verified only the suites I believed the change touched. Any edit to a workflow .md shifts line numbers, and this repo carries line-keyed allowlists — so a workflow edit needs the full suite, not a subset. Refs #2652 * test(#2652): route the new subprocesses through the process seam Retracting a rejection I made on the record. In the round-6 response I argued these call sites could keep a hand-rolled `spawnSync` because the seam exposes only runNode/runGit/runHook and cannot express `bash -c`, and because `installAndRead` in the same file uses that shape. The first half was true and irrelevant, the second half is not a licence: CONTRIBUTING is unambiguous — "Anything that shells out goes through tests/helpers/process-seam.cjs — never a hand-rolled spawnSync/execFileSync in your suite", and "Never use try/finally inside test bodies." `runHook` already documents `interpreter: 'bash'` for running a shell script, so writing the harness to a file complies without extending the seam. I had the rule and the seam's own documentation in front of me and reasoned around both. Converted: - host-integration.test.cjs degrade harness: spawnSync('bash', ['-c', …]) → runHook(scriptFile, [], { interpreter: 'bash' }). - install.test.cjs cursor gate: `which bash` probe → process.platform; the installer spawn → runNode(…, { env: installSpawnEnv({HOME, USERPROFILE}) }), which also blanks ambient GSD_HOME/runtime-location vars that could otherwise make capability discovery host-dependent; the emitted-gate spawn → runHook(gateScript, [], { interpreter: 'bash' }). - Three try/finally test bodies → t.after(). Class-norm timeouts: tests/helpers/timeouts.cjs arrived with this branch's latest base merge, so the literals written earlier (15000/120000/60000) now duplicate PROBE_TIMEOUT_MS and INSTALL_TIMEOUT_MS. Imported instead — that module exists because INSTALL_TIMEOUT_MS had already drifted 60s→120s once after a real bench ETIMEDOUT. Deliberately NOT converted: `installAndRead`'s spawnSync, which is byte-identical to the merge-base and predates this PR — converting shared scaffolding is an unrelated change. Verified equivalent, not assumed: argv/cwd/env/encoding/timeout and every assertion are preserved; t.after() still cleans up on the assertion-failure path the try/finally covered; and the cursor test still resolves Cursor under a hostile ambient GSD_RUNTIME=claude. Refs #2652 * fix(#2652): replace the falsified use_worktrees doc row; distinguish an unresolvable gate from a declared none Round-8 review findings. BLOCKER — docs/CONFIGURATION.md's `Non-Claude note` asserted three things this PR overturns: that worktree isolation "no other runtime honors" (Cursor declares harness-worktree with `--worktree`, and this PR's own install test asserts that flag reaching the emitted Agent() slot), that non-Claude installs default the key to `false`, and that forcing `true` always fails closed. Replaced with the capability-based description, and the `#1515, #1521` citation dropped — those are the two issues whose premise this PR removes. The reviewer flagged that the fix is merge-order dependent, because #2531 rewrites the same row and its replacement text is written in anticipation of this PR landing. Rather than pick an order, BOTH sides are now order-independent: #2531's "Current default … until #2652" paragraph becomes a plain troubleshooting note, and this row states the capability rule without asserting a stamp state. Whichever merges first, the row is correct; the second merge is a textual conflict at worst. MINOR — the gate reported a capability verdict the tool never returned. `ISOLATION=$(… || echo "none")` made a shim-resolution failure, a non-zero exit and an empty stdout indistinguishable from a declared `none`, so a transient query failure aborted /gsd:quick on Claude Code with "runtime 'claude' declares no executor-isolation primitive" — false. The gate now tracks ISOLATION_RESOLVED separately: both paths still fail closed, but only a real verdict claims the host declares nothing; the unresolved branch says it could not resolve and points at the shim. Fixed in the canonical reference so every dispatch site inherits it. MINOR — quick.md:527 cited #2649 for its own degrade; that is #1941, and #2649 is the diagnose-issues/execute-plan gate. Corrected, and the distinction stated so the next reader does not chase it. MINOR — quick.md:413 (manifest init) and :429 (worktree_branch_check embed) still branched on USE_WORKTREES while dispatch, manifest-append, merge-back and the skip clause had all moved to ISOLATION. Safe only by coincidence — both now key on ISOLATION. MINOR — the diff removes a second drift-ack entry (the execute-phase.md key from 2658-trae-instruction-file-path.json), forced by the same duplicate-key lint rule as the 2649 removal. Disclosed in the PR comment; the earlier disclosure covered only one of the two. Verified: lint:ci green; 300/300 across host-integration, fix-1941-quick-worktree-stale-base, execute-phase-wave and workflow-guard. Refs #2652 * test(#2652): anchor the emitted-gate finder on the heading, not the assignment `b89c3fbf` added a finder that located the gate's `Resolve ISOLATION` block by the literal `ISOLATION=$(gsd_run query dispatch-isolation --raw`. `f3bccf21` then split that assignment into `_ISOLATION_RAW`/`ISOLATION_RESOLVED` so a shim failure stops masquerading as a declared `none` — and the finder stopped matching. The test did not report the drift it exists to catch; it reported "emitted dispatch-isolation-gate.md has no Resolve ISOLATION bash block" and went red, and stayed red because the earlier full-suite run was read from a truncated log. Anchored on the heading instead. The workflows tell a dispatch site to run the `Resolve ISOLATION` and `Resolve the harness flag` blocks BY NAME, so the heading is the contract and the body is free to change under it. Verified: 413/413 in tests/install.test.cjs. The test still bites — mutating the gate's `ISOLATION="$_ISOLATION_RAW"` to `ISOLATION=none` turns it red (the emitted gate then resolves cursor to none and exits 1 instead of printing harness-worktree), and reverting restores green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(#2652): wire the canonical resolver at the one dispatch site that still inlined the old shape Codex review of the whole PR on the new base found one Major, and it was real. executor-isolation-dispatch.md declares references/dispatch-isolation-gate.md canonical at line 10, then kept the OLDER resolver inline: `|| echo "none"`, no ISOLATION_RESOLVED. So the one site that resolves isolation for the wave path collapsed a shim failure into a declared `none` and aborted with "runtime '$RUNTIME' declares no executor-isolation primitive" — false for a Claude or Cursor user whose resolver merely failed to answer. Still fail-closed, so not an unsafe-dispatch hole, but the correction this PR is about was unwired at the site that matters most. Replaced with the gate's exact shape: capture the raw value, track ISOLATION_RESOLVED, and emit the "could not resolve" FATAL when no verdict was learned. Added a regression test in the #2652 dispatch-site parity suite: every file that ASSIGNS from `gsd_run query dispatch-isolation --raw` must carry ISOLATION_RESOLVED, must not use the collapsing form, and must have a distinct unresolved message. Nothing covered this before — install.test.cjs checks the emitted REFERENCE, not each site's own inline copy, which is exactly how the two drifted apart. The test's first draft also flagged quick.md, diagnose-issues.md and execute-plan.md. That was a false positive worth recording: those three @-reference the gate and only make `--force-isolation` re-record calls, which carry no verdict. The predicate now matches an assignment from the resolver, not any mention of it, so it flags sites that can actually be wrong. Verified by mutation: restoring the collapsing line reds the new test. Validated: lint:ci clean; full suite shows the same 7 known failures as the pre-change baseline — #1160 _resolveManifest and the #3053 quick_id host-timezone tests (both reproduce on pristine next @33fca50d), plus helpers-cleanup "outside os.tmpdir()", which fails only in a worktree. * test(#2652): close two vacuous-pass holes in the new inline-resolver guard Codex cleared the push and flagged the guard test itself. Both holes were real. SCAN_ROOTS already yields references/dispatch-isolation-gate.md, and the test appended it a second time, so the candidate list was [executor, gate, gate] and `length >= 2` was satisfiable by the gate alone. If the executor site had dropped out of the predicate — the exact regression the test exists to catch — it would still have passed. Paths are deduped and the assertion now pins the two expected inliner identities instead of a count. The collapse detector keyed on `ISOLATION=$(…)`, so `_ISOLATION_RAW=$(… || echo "none")` restored the identical defect while satisfying every other assertion (ISOLATION_RESOLVED still appears in the file). Codex mutation-probed exactly that and it passed. The pattern now matches any assignment target and any `|| … echo` tail. Verified: that mutation now reds the test. Test-only change; the workflow bash is byte-identical to the commit the full suite ran green against. lint:ci clean, host-integration 223/223. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
committed by
GitHub
parent
e87fb409ee
commit
2076d450d7
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"diagnose-issues.md": "#2649: spawn_agents step gained a pre-dispatch worktree.base-check gate (mirrors execute-phase #683/#1369 and quick #1941). Claude Code's isolation=\"worktree\" forks from origin/HEAD, not live local HEAD; without the gate the documented GSD steady state (commit every step locally, push only on request) hit the verify-only worktree_branch_check guard's exit-42 halt mid-investigation. Growth is the base-check bash block (gsd_run query worktree.base-check --pick shouldDegrade → USE_WORKTREES=false + stderr warning) + the #2649 rationale comment. The verify-only guard stays as a backstop."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"quick.md": "#2652 — same migration. The resolution block lives in gsd-core/references/dispatch-isolation-gate.md and this file @-references it, so this file's own bytes stay under its prompt-stuffing size limit (SIZE_ONLY_WORKFLOWS on next). To be accurate about what that buys: @-references are EAGERLY INLINED, so the extraction does NOT reduce loaded context — it slightly increases it (reference body plus the pointer). The reason to extract is single-sourcing the gate across the four sites that reference it (quick.md, diagnose-issues.md, execute-plan.md, execute-phase/steps/executor-isolation-dispatch.md), not context economy; an earlier revision of this entry claimed otherwise and was wrong. Growth is the reference pointer, the ISOLATION=none degrade pairing, the post-dispatch bookkeeping conditions re-keyed from the Claude-rendered literal onto ISOLATION (round-4 review Blocker), and the #3045 re-record after the #1941 base-check degrade (round-5 review Blocker B1).",
|
||||
"diagnose-issues.md": "#2652 — migrates the dispatch site off the pre-#2584 RUNTIME != \"claude\" gate onto the negotiated dispatch.isolation seam. Growth is the pointer at gsd-core/references/dispatch-isolation-gate.md, the ISOLATION-conditional WORKTREE_GUARD/{harnessFlag} substitution instructions this file had no equivalent of, and the #3045 re-record after the #2649 base-check degrade (round-5 review Blocker B1). The resolution / orchestrator-worktree degrade / harness-flag shell is NOT inline here: an earlier revision of this branch inlined a reordered copy of the reference, which is the divergence a single source of truth exists to prevent (round-6 review Major 3). This file now reads the reference the same way quick.md and execute-plan.md do, so its own delta shrank accordingly.",
|
||||
"execute-plan.md": "#2652 — the fifth dispatch site, converted after review: Pattern A hardcoded isolation=\"worktree\" (Claude Code's literal) gated only on workflow.use_worktrees, with no capability negotiation. Growth is the USE_WORKTREES/RUNTIME config reads and the pointer at gsd-core/references/dispatch-isolation-gate.md (the resolution block itself living in that reference rather than inline here), the #2649 base-check merged into Pattern A's paragraph by that rebase, and the #3045 re-record that degrade now performs before spawning (round-5 review Blocker B1).",
|
||||
"gsd-core/workflows/execute-phase.md": "#2652 review round 3 — not a content change to this workflow, which is byte-identical in the source tree. The delta is emit-time: _stampNonClaudeRuntimeDefaults no longer rewrites the workflow.use_worktrees read to --default false for a runtime whose negotiated dispatch.isolation is not none, so the emitted line keeps the unstamped true default. It moves for exactly the five hosts that declare worktree support (cursor harness-worktree; codex/opencode/kimi/kimi-code orchestrator-worktree) and is unchanged for every isolation-none runtime. Fixes the review Blocker: the install-time stamp resolved USE_WORKTREES=false before dispatch-isolation was ever consulted, so the gate this PR migrates dispatch onto was still deciding isolation by runtime name one layer down."
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
"gsd-core/templates/README.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
|
||||
"gsd-core/templates/claude-md.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
|
||||
"gsd-core/templates/codebase/structure.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
|
||||
"gsd-core/workflows/execute-phase.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
|
||||
"gsd-core/workflows/execute-plan.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
|
||||
"gsd-core/workflows/help/modes/full.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
|
||||
"gsd-core/workflows/milestone-summary.md": "#2658: same CLAUDE.md replacement-target change as checkpoints.md above (bare directory '.trae/rules/' -> concrete file '.trae/rules/rules.md').",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"execute-plan.md": {
|
||||
"reason": "Issue #3218 (epic #3180 phase 8): replaced a raw `ls *-PLAN.md | wc -l` re-derivation with a `gsd_run query find-phase` call plus jq extraction of the count field, routing the plan count through scanPhasePlans (ADR-3180 §7.5) instead of a glob. A few lines longer than the shell one-liner it replaces."
|
||||
},
|
||||
"plan-phase.md": {
|
||||
"reason": "Issue #3218 (epic #3180 phase 8): grew the most of the four files because it migrated TWO sites off `ls *-PLAN.md | wc -l` (the step 9a and step 11a filesystem-fallback DISK_PLANS checks) to `gsd_run query find-phase` + jq, and because both sites now take the PHYSICAL plan set (`plan_count_all`, status:superseded included) for their 'did the planner/checker write files to disk?' question — the old glob undercounted on the nested plans/ layout and missed loosely-named files."
|
||||
},
|
||||
|
||||
@@ -702,11 +702,24 @@ describe('execute-phase: inter-wave worktree base re-check (#1369)', () => {
|
||||
assert.ok(refIdx < step1Idx, 'wave-guard @-reference must appear before step 1');
|
||||
});
|
||||
|
||||
test('step 0.5 guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => {
|
||||
// #2652: previously required `RUNTIME = "claude"`, encoding the pre-#2584 premise
|
||||
// that worktree isolation is Claude-specific. #2584 replaced that with the
|
||||
// negotiated dispatch.isolation capability — Cursor declares harness-worktree too,
|
||||
// and the harness fork-base caching this guard exists for is a property of the
|
||||
// isolation model, not of the runtime name.
|
||||
test('step 0.5 guards on the negotiated capability, not a runtime id', () => {
|
||||
const content = fs.readFileSync(WAVE_GUARD_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 0.5 must guard on RUNTIME=claude'
|
||||
content.includes('ISOLATION') && content.includes('harness-worktree'),
|
||||
'step 0.5 must guard on ISOLATION = harness-worktree'
|
||||
);
|
||||
assert.ok(
|
||||
!/\[\s*"\$RUNTIME"\s*=/.test(content),
|
||||
'step 0.5 must NOT branch on a RUNTIME literal (#2584/#2652)'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('ISOLATION=none'),
|
||||
'degrade must clear ISOLATION as well as USE_WORKTREES — dispatch reads ISOLATION (#2652)'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -780,11 +793,21 @@ describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
|
||||
assert.ok(refPtr < idx8, 'between-wave @-reference must appear before step 8');
|
||||
});
|
||||
|
||||
test('step 7c guards on RUNTIME=claude for worktree-specific operations', () => {
|
||||
// #2652: see the step 0.5 note above — migrated from the runtime-name premise to
|
||||
// the negotiated dispatch.isolation capability.
|
||||
test('step 7c guards on the negotiated capability, not a runtime id', () => {
|
||||
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('RUNTIME') && (content.includes('"claude"') || content.includes("'claude'")),
|
||||
'step 7c must guard on RUNTIME=claude'
|
||||
content.includes('ISOLATION') && content.includes('harness-worktree'),
|
||||
'step 7c must guard on ISOLATION = harness-worktree'
|
||||
);
|
||||
assert.ok(
|
||||
!/\[\s*"\$RUNTIME"\s*=/.test(content),
|
||||
'step 7c must NOT branch on a RUNTIME literal (#2584/#2652)'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('ISOLATION=none'),
|
||||
'degrade must clear ISOLATION as well as USE_WORKTREES — dispatch reads ISOLATION (#2652)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,23 +46,38 @@ describe('quick: pre-dispatch worktree base re-check (#1941)', () => {
|
||||
assert.ok(content.includes('#1941'), 'quick.md must reference #1941');
|
||||
});
|
||||
|
||||
test('degrade check sets USE_WORKTREES=false when shouldDegrade is true', () => {
|
||||
test('degrade check clears BOTH USE_WORKTREES and ISOLATION when shouldDegrade is true', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(baseCheckIdx, baseCheckIdx + 600);
|
||||
const block = content.slice(baseCheckIdx, baseCheckIdx + 900);
|
||||
assert.ok(block.includes('shouldDegrade'), 'degrade check must branch on shouldDegrade');
|
||||
// Both must move together (#2652). Dispatch keys on ISOLATION while the prompt
|
||||
// guard and worktree manifest key on USE_WORKTREES; clearing only one dispatches
|
||||
// an isolated executor with no base guard and no manifest, then blocks in cleanup
|
||||
// looking for a manifest that was never initialized.
|
||||
assert.ok(block.includes('USE_WORKTREES=false'), 'degrade must set USE_WORKTREES=false');
|
||||
assert.ok(
|
||||
block.includes('shouldDegrade') && block.includes('USE_WORKTREES=false'),
|
||||
'degrade check must override USE_WORKTREES=false when shouldDegrade is true'
|
||||
block.includes('ISOLATION=none'),
|
||||
'degrade must ALSO set ISOLATION=none — dispatch reads ISOLATION, so clearing only ' +
|
||||
'USE_WORKTREES still passes the harness isolation flag (#2652)'
|
||||
);
|
||||
});
|
||||
|
||||
test('degrade check guards on RUNTIME=claude (worktree isolation is Claude Code-specific)', () => {
|
||||
// #2652: this assertion previously required `RUNTIME = "claude"`, encoding the
|
||||
// pre-#2584 premise that worktree isolation is Claude-specific. #2584 replaced
|
||||
// that with the negotiated dispatch.isolation capability, so the guard now keys
|
||||
// on the capability — Cursor also declares harness-worktree.
|
||||
test('degrade check guards on the negotiated capability, not a runtime id', () => {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const baseCheckIdx = content.indexOf('worktree.base-check');
|
||||
const block = content.slice(Math.max(0, baseCheckIdx - 200), baseCheckIdx + 200);
|
||||
const block = content.slice(Math.max(0, baseCheckIdx - 300), baseCheckIdx + 200);
|
||||
assert.ok(
|
||||
block.includes('RUNTIME') && (block.includes('"claude"') || block.includes("'claude'")),
|
||||
'degrade check must guard on RUNTIME=claude'
|
||||
block.includes('ISOLATION') && block.includes('harness-worktree'),
|
||||
'degrade check must guard on ISOLATION = harness-worktree'
|
||||
);
|
||||
assert.ok(
|
||||
!/\[\s*"\$RUNTIME"\s*=/.test(block),
|
||||
'degrade check must NOT branch on a RUNTIME literal (#2584/#2652)'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
1
tests/fixtures/install-tree/antigravity.json
vendored
1
tests/fixtures/install-tree/antigravity.json
vendored
@@ -71,6 +71,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/augment.json
vendored
1
tests/fixtures/install-tree/augment.json
vendored
@@ -142,6 +142,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/claude.json
vendored
1
tests/fixtures/install-tree/claude.json
vendored
@@ -70,6 +70,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/cline.json
vendored
1
tests/fixtures/install-tree/cline.json
vendored
@@ -74,6 +74,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/codebuddy.json
vendored
1
tests/fixtures/install-tree/codebuddy.json
vendored
@@ -142,6 +142,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/codex.json
vendored
1
tests/fixtures/install-tree/codex.json
vendored
@@ -177,6 +177,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/copilot.json
vendored
1
tests/fixtures/install-tree/copilot.json
vendored
@@ -72,6 +72,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/cursor.json
vendored
1
tests/fixtures/install-tree/cursor.json
vendored
@@ -71,6 +71,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/hermes.json
vendored
1
tests/fixtures/install-tree/hermes.json
vendored
@@ -71,6 +71,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/kilo.json
vendored
1
tests/fixtures/install-tree/kilo.json
vendored
@@ -142,6 +142,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi-code.json
vendored
1
tests/fixtures/install-tree/kimi-code.json
vendored
@@ -103,6 +103,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/kimi.json
vendored
1
tests/fixtures/install-tree/kimi.json
vendored
@@ -139,6 +139,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/opencode.json
vendored
1
tests/fixtures/install-tree/opencode.json
vendored
@@ -142,6 +142,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/pi.json
vendored
1
tests/fixtures/install-tree/pi.json
vendored
@@ -39,6 +39,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/qwen.json
vendored
1
tests/fixtures/install-tree/qwen.json
vendored
@@ -71,6 +71,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/trae.json
vendored
1
tests/fixtures/install-tree/trae.json
vendored
@@ -71,6 +71,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/windsurf.json
vendored
1
tests/fixtures/install-tree/windsurf.json
vendored
@@ -71,6 +71,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
1
tests/fixtures/install-tree/zcode.json
vendored
1
tests/fixtures/install-tree/zcode.json
vendored
@@ -142,6 +142,7 @@
|
||||
"gsd-core/references/debugger-semantic-recall.md",
|
||||
"gsd-core/references/debugger-techniques.md",
|
||||
"gsd-core/references/decimal-phase-calculation.md",
|
||||
"gsd-core/references/dispatch-isolation-gate.md",
|
||||
"gsd-core/references/doc-conflict-engine.md",
|
||||
"gsd-core/references/domain-probes.md",
|
||||
"gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json",
|
||||
|
||||
@@ -35,6 +35,7 @@ const {
|
||||
_HOST_INTEGRATION_VOCAB,
|
||||
validateCapability,
|
||||
} = require('../gsd-core/bin/lib/capability-validator.cjs');
|
||||
const { cleanup, readFileNormalized } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
@@ -1952,3 +1953,552 @@ describe('#2627 dispatch-isolation CLI route', () => {
|
||||
assert.equal(pi.harnessFlag, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2652 — dispatch-site parity: isolation is decided by the negotiated
|
||||
// dispatch.isolation capability, never by a runtime id.
|
||||
//
|
||||
// #2584 migrated the phase scheduler off `RUNTIME != "claude"` but left quick.md
|
||||
// and diagnose-issues.md behind, so Codex — which declares orchestrator-worktree
|
||||
// — was refused isolation it had negotiated. That is this repo's
|
||||
// DEFECT.GENERATIVE-FIX-DIVERGENCE shape: parallel surfaces reading one contract,
|
||||
// one migrated and the others silently stale. This guard fails when any dispatch
|
||||
// site reintroduces a runtime-name test around its isolation decision.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan workflows AND the reference fragments they inline: scheduler branches that
|
||||
// mutate USE_WORKTREES live in gsd-core/references/ too (execute-phase-wave-guard,
|
||||
// execute-phase-between-wave-reset), and a workflows-only scan misses them.
|
||||
// Shared by both #2652 and #2728 suites below — a second, hand-listed copy is how
|
||||
// the degrade scan silently narrowed to three files (round-7 review, Major 6).
|
||||
const SCAN_ROOTS = [
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows'),
|
||||
path.join(REPO_ROOT, 'gsd-core', 'references'),
|
||||
];
|
||||
|
||||
function collectMarkdown(dir) {
|
||||
const out = [];
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...collectMarkdown(full));
|
||||
else if (entry.name.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('#2652 dispatch-site parity — isolation gates on capability, not runtime id', () => {
|
||||
const ISOLATION_TOKEN = /USE_WORKTREES|ISOLATION|isolation="worktree"|harnessFlag/;
|
||||
|
||||
// Any shape that reads RUNTIME as a branch condition. Line-based matching missed
|
||||
// multiline `&&`, [[ ]], case, `test`, and JS-template forms, so match the RUNTIME
|
||||
// test itself and then look for an isolation token within the following window.
|
||||
//
|
||||
// Operand ORDER is not fixed either: `[ "claude" != "$RUNTIME" ]` is the same gate
|
||||
// written backwards, and hand-written left-only patterns let it through. Each
|
||||
// comparison shape is therefore generated in both orders from a single template, so
|
||||
// a new shape cannot be added in one order and forgotten in the other.
|
||||
const RT = '"?\\$\\{?RUNTIME\\}?"?'; // $RUNTIME / "$RUNTIME" / "${RUNTIME}"
|
||||
const JS_RT = 'RUNTIME'; // bare identifier inside a ${…} template
|
||||
const QLIT = '["\'][a-z-]+["\']'; // "claude"
|
||||
const LIT = '["\']?[a-z-]+["\']?'; // claude / "claude" ([[ ]] permits bare)
|
||||
|
||||
/** One comparison shape → two regexes, operands in either order. */
|
||||
const bothOrders = (tpl, a, b) => [
|
||||
new RegExp(tpl.replace('%L', a).replace('%R', b)),
|
||||
new RegExp(tpl.replace('%L', b).replace('%R', a)),
|
||||
];
|
||||
|
||||
const RUNTIME_TESTS = [
|
||||
...bothOrders('\\[\\s*%L\\s*(?:!=|==?)\\s*%R\\s*\\]', RT, QLIT), // [ "$RUNTIME" = "x" ]
|
||||
...bothOrders('\\[\\[\\s*%L\\s*(?:!=|==?)\\s*%R\\s*\\]\\]', RT, LIT), // [[ "$RUNTIME" == x ]]
|
||||
...bothOrders('\\btest\\s+%L\\s*(?:!=|=)\\s*%R', RT, QLIT), // test "$RUNTIME" = "x"
|
||||
...bothOrders('\\$\\{\\s*%L\\s*(?:===|!==|==|!=)\\s*%R', JS_RT, QLIT), // ${RUNTIME === "x" ? ...}
|
||||
/\bcase\s+"?\$\{?RUNTIME\}?"?\s+in\b/, // case "$RUNTIME" in (no reversed form)
|
||||
];
|
||||
|
||||
const WINDOW = 400; // chars after the RUNTIME test to look for an isolation decision
|
||||
|
||||
function isolationGateOffenders(text, label) {
|
||||
const hits = [];
|
||||
for (const re of RUNTIME_TESTS) {
|
||||
const global = new RegExp(re.source, 'g');
|
||||
let m;
|
||||
while ((m = global.exec(text)) !== null) {
|
||||
const window = text.slice(m.index, m.index + WINDOW);
|
||||
if (ISOLATION_TOKEN.test(window)) {
|
||||
const line = text.slice(0, m.index).split('\n').length;
|
||||
hits.push(`${label}:${line}: ${m[0].trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const dispatchSites = SCAN_ROOTS.flatMap(collectMarkdown).filter(f =>
|
||||
ISOLATION_TOKEN.test(fs.readFileSync(f, 'utf-8')),
|
||||
);
|
||||
|
||||
test('the scan covers the known dispatch sites (guards against a vacuous pass)', () => {
|
||||
const rel = dispatchSites.map(f => path.relative(REPO_ROOT, f).replace(/\\/g, '/'));
|
||||
// Assert identities, not just a count: a count survives the scan silently
|
||||
// drifting off the files that actually matter.
|
||||
for (const required of [
|
||||
'gsd-core/workflows/quick.md',
|
||||
'gsd-core/workflows/diagnose-issues.md',
|
||||
'gsd-core/workflows/execute-plan.md',
|
||||
'gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md',
|
||||
]) {
|
||||
assert.ok(rel.includes(required), `dispatch-site scan must cover ${required}; found ${rel.length} files`);
|
||||
}
|
||||
});
|
||||
|
||||
// #2652 review: executor-isolation-dispatch.md declared
|
||||
// references/dispatch-isolation-gate.md canonical while keeping the OLDER
|
||||
// collapsing resolver inline — `|| echo "none"`, no ISOLATION_RESOLVED — so a
|
||||
// transient shim failure aborted /gsd:execute-phase telling a Claude or
|
||||
// Cursor user their runtime "declares no executor-isolation primitive". Still
|
||||
// fail-closed, so not an unsafe-dispatch hole, but the correction this PR is
|
||||
// about was unwired at one of the five sites. Nothing caught it: the emitted
|
||||
// coverage in install.test.cjs checks the REFERENCE, not each site's own copy.
|
||||
test('every site that inlines the resolver uses the non-collapsing shape', () => {
|
||||
// A site "inlines the resolver" only if it ASSIGNS from it. The other
|
||||
// dispatch sites @-reference the gate and merely re-record a degrade
|
||||
// (`--force-isolation …` with no assignment), which carries no verdict of
|
||||
// its own — matching those too would flag files that have nothing to fix.
|
||||
const INLINE_RESOLVE = /\w+=\$\(gsd_run query dispatch-isolation --raw/;
|
||||
// Dedupe: SCAN_ROOTS already yields the gate reference, so appending it
|
||||
// again made `length >= 2` satisfiable by the gate alone — the executor
|
||||
// site could drop out of the predicate entirely and this would still pass.
|
||||
const candidates = [...new Set(
|
||||
[...dispatchSites, path.join(REPO_ROOT, 'gsd-core', 'references', 'dispatch-isolation-gate.md')]
|
||||
.filter(f => fs.existsSync(f))
|
||||
.map(f => path.resolve(f)),
|
||||
)];
|
||||
const inliners = candidates
|
||||
.map(f => ({ rel: path.relative(REPO_ROOT, f).replace(/\\/g, '/'), text: fs.readFileSync(f, 'utf-8') }))
|
||||
.filter(({ text }) => INLINE_RESOLVE.test(text));
|
||||
|
||||
// Pin identities, not a count. A count cannot tell "the executor site was
|
||||
// fixed" from "the executor site stopped matching the predicate".
|
||||
assert.deepEqual(
|
||||
inliners.map(i => i.rel).sort(),
|
||||
[
|
||||
'gsd-core/references/dispatch-isolation-gate.md',
|
||||
'gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md',
|
||||
],
|
||||
'the set of files inlining the resolver changed — a new inliner needs the same treatment, and a missing one means the predicate stopped seeing it',
|
||||
);
|
||||
|
||||
for (const { rel, text } of inliners) {
|
||||
// Any assignment target, not just ISOLATION — `_ISOLATION_RAW=$(… || echo
|
||||
// "none")` restores the identical defect while leaving ISOLATION_RESOLVED
|
||||
// in the file, so a name-specific pattern passes on a broken block.
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
/\w+=\$\(gsd_run query dispatch-isolation --raw[^\n]*\|\|[^\n]*echo/,
|
||||
`${rel}: collapses a resolver failure straight to none — that reports "declares no primitive" for a host that simply could not be queried`,
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/ISOLATION_RESOLVED=false/,
|
||||
`${rel}: inlines the resolver but never records that no verdict was learned`,
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/could not resolve this runtime's executor-isolation capability/,
|
||||
`${rel}: has no distinct message for the unresolved case, so both outcomes read as a capability verdict`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the detector flags every known runtime-gate shape (discrimination proof)', () => {
|
||||
// Each of these slipped past the original same-line, single-bracket detector.
|
||||
const mutations = {
|
||||
'single bracket, same line':
|
||||
'if [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]; then',
|
||||
'multiline &&':
|
||||
'if [ "$RUNTIME" != "claude" ] && \\\n [ "$USE_WORKTREES" != "false" ]; then',
|
||||
'double bracket':
|
||||
'if [[ "$RUNTIME" == claude ]]; then\n USE_WORKTREES=false\nfi',
|
||||
'nested, later assignment':
|
||||
'if [ "$RUNTIME" = "codex" ]; then\n echo hi\n ISOLATION=none\nfi',
|
||||
'case statement':
|
||||
'case "$RUNTIME" in\n claude) USE_WORKTREES=true ;;\nesac',
|
||||
'js template':
|
||||
'${RUNTIME === "claude" ? \'isolation="worktree",\' : \'\'}',
|
||||
'test builtin':
|
||||
'if test "$RUNTIME" = "claude"; then\n ISOLATION=harness-worktree\nfi',
|
||||
// Reversed operands — the same gate written backwards. Every one of these
|
||||
// evaded the original left-only patterns (#2728 review, Minor).
|
||||
'reversed single bracket':
|
||||
'if [ "claude" != "$RUNTIME" ] && [ "$USE_WORKTREES" != "false" ]; then',
|
||||
'reversed double bracket':
|
||||
'if [[ claude == "$RUNTIME" ]]; then\n USE_WORKTREES=false\nfi',
|
||||
'reversed test builtin':
|
||||
'if test "claude" = "$RUNTIME"; then\n ISOLATION=harness-worktree\nfi',
|
||||
'reversed js template':
|
||||
'${"claude" === RUNTIME ? \'isolation="worktree",\' : \'\'}',
|
||||
};
|
||||
for (const [name, snippet] of Object.entries(mutations)) {
|
||||
assert.equal(
|
||||
isolationGateOffenders(snippet, 'mutation').length >= 1,
|
||||
true,
|
||||
`detector must flag the "${name}" reintroduction — otherwise the guard below proves nothing`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the detector flags generated shell-comparison permutations (property)', () => {
|
||||
// The mutation table above is 11 hand-picked cases; this generates the cross
|
||||
// product of the axes an author actually varies — bracket form, operator,
|
||||
// operand order, quoting, spacing, runtime id. A permutation the hand-written
|
||||
// patterns miss shows up here rather than in production (#2728 review, Nit).
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom('[', '[[', 'test'),
|
||||
fc.constantFrom('=', '==', '!='),
|
||||
fc.boolean(), // reversed operands?
|
||||
fc.constantFrom('"$RUNTIME"', '$RUNTIME', '"${RUNTIME}"'),
|
||||
fc.constantFrom('claude', 'codex', 'kimi-code'),
|
||||
fc.boolean(), // quote the literal?
|
||||
fc.constantFrom('', ' '), // extra padding
|
||||
(form, op, reversed, rtTok, id, quoted, pad) => {
|
||||
// `test` has no `==` form and never takes brackets; bare literals are
|
||||
// only legal inside [[ ]].
|
||||
if (form === 'test' && op === '==') return true;
|
||||
const lit = quoted || form !== '[[' ? `"${id}"` : id;
|
||||
const [l, r] = reversed ? [lit, rtTok] : [rtTok, lit];
|
||||
const cond = `${l}${pad} ${op} ${pad}${r}`;
|
||||
const snippet = form === 'test'
|
||||
? `if test ${cond}; then\n ISOLATION=none\nfi`
|
||||
: `if ${form} ${cond} ${form === '[[' ? ']]' : ']'}; then\n ISOLATION=none\nfi`;
|
||||
return isolationGateOffenders(snippet, 'prop').length >= 1;
|
||||
},
|
||||
),
|
||||
{ numRuns: 300 },
|
||||
);
|
||||
});
|
||||
|
||||
test('a RUNTIME read with no isolation decision nearby is NOT flagged (no false positive)', () => {
|
||||
const benign = 'RUNTIME=$(gsd_run query config-get runtime --raw)\necho "runtime is $RUNTIME"';
|
||||
assert.deepEqual(isolationGateOffenders(benign, 'benign'), []);
|
||||
});
|
||||
|
||||
test('no dispatch site gates isolation on a runtime id', () => {
|
||||
const offenders = [];
|
||||
for (const file of dispatchSites) {
|
||||
offenders.push(
|
||||
...isolationGateOffenders(
|
||||
fs.readFileSync(file, 'utf-8'),
|
||||
path.relative(REPO_ROOT, file).replace(/\\/g, '/'),
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'isolation must be resolved from `gsd_run query dispatch-isolation` (see ' +
|
||||
'gsd-core/references/dispatch-isolation-gate.md), never from a RUNTIME comparison:\n' +
|
||||
offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
// #2728 review Blocker — the ISOLATION_TOKEN regex above treats
|
||||
// `isolation="worktree"` as a legitimate isolation marker, so the runtime-gate
|
||||
// detector cannot catch a *conditional* keyed on that literal. But the literal
|
||||
// is Claude Code's own rendering of {harnessFlag}; Cursor's rendering is
|
||||
// `--worktree`, so any post-dispatch step gated on the literal is a silent
|
||||
// no-op for a correctly-isolated Cursor run (quick.md's manifest append and
|
||||
// worktree merge-back were exactly this — isolated work never merged, never
|
||||
// cleaned up). Post-dispatch bookkeeping must key on the negotiated ISOLATION
|
||||
// value instead (dispatch-isolation-gate.md's "never hardcode" rule).
|
||||
const LITERAL_CONDITION = /\bIf\b[^.\n]*`isolation="worktree"`/g;
|
||||
|
||||
function literalConditionOffenders(text, label) {
|
||||
const hits = [];
|
||||
let m;
|
||||
const re = new RegExp(LITERAL_CONDITION.source, 'g');
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const line = text.slice(0, m.index).split('\n').length;
|
||||
hits.push(`${label}:${line}: ${m[0].trim()}`);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
test('the literal-condition detector flags the pre-fix quick.md shapes (discrimination proof)', () => {
|
||||
const preFix = {
|
||||
'manifest append':
|
||||
'If the executor ran with `isolation="worktree"`, append its returned metadata to `QUICK_WORKTREE_MANIFEST` before cleanup.',
|
||||
'worktree cleanup':
|
||||
'1. **Worktree cleanup:** If the executor ran with `isolation="worktree"`, merge the worktree branch back and clean up:',
|
||||
};
|
||||
for (const [name, snippet] of Object.entries(preFix)) {
|
||||
assert.equal(
|
||||
literalConditionOffenders(snippet, 'mutation').length,
|
||||
1,
|
||||
`detector must flag the pre-fix "${name}" conditional — otherwise the guard below proves nothing`,
|
||||
);
|
||||
}
|
||||
// Explanatory prose that merely *names* the literal (no conditional) stays legal.
|
||||
assert.deepEqual(
|
||||
literalConditionOffenders(
|
||||
'Claude Code\'s `isolation="worktree"` forks new worktrees from `origin/HEAD`.',
|
||||
'benign',
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('no dispatch site conditions post-dispatch behavior on the Claude-rendered literal', () => {
|
||||
const offenders = [];
|
||||
for (const file of dispatchSites) {
|
||||
offenders.push(
|
||||
...literalConditionOffenders(
|
||||
fs.readFileSync(file, 'utf-8'),
|
||||
path.relative(REPO_ROOT, file).replace(/\\/g, '/'),
|
||||
),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'post-dispatch steps must key on `ISOLATION = "harness-worktree"`, never on ' +
|
||||
'Claude Code\'s rendered `isolation="worktree"` literal (a Cursor dispatch renders ' +
|
||||
'`--worktree` and would silently skip these steps):\n' + offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('quick.md post-dispatch bookkeeping keys on the negotiated ISOLATION value', () => {
|
||||
const quick = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md'), 'utf-8',
|
||||
);
|
||||
assert.match(
|
||||
quick,
|
||||
/If the executor ran isolated \(`ISOLATION = "harness-worktree"` at dispatch\), append its returned/,
|
||||
'the QUICK_WORKTREE_MANIFEST append must be gated on ISOLATION',
|
||||
);
|
||||
assert.match(
|
||||
quick,
|
||||
/\*\*Worktree cleanup:\*\* If the executor ran isolated \(`ISOLATION = "harness-worktree"` at dispatch\)/,
|
||||
'the worktree merge-back/cleanup must be gated on ISOLATION',
|
||||
);
|
||||
assert.match(
|
||||
quick,
|
||||
/If `ISOLATION` was not `"harness-worktree"` at dispatch[^\n]*skip this step/,
|
||||
'the cleanup skip clause must mirror the same ISOLATION gate (USE_WORKTREES stays true on an isolated Cursor run)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2728 review BLOCKER (B1/B2/B3) — a degrade must re-RECORD, not just reassign.
|
||||
//
|
||||
// Every isolation degrade in a dispatch site is decided in SHELL, where
|
||||
// `routeDispatchIsolation` cannot see it. That resolver persists whatever it
|
||||
// resolved to the run-scoped sentinel as an unconditional side effect (#3045
|
||||
// CORE REDESIGN, hooks/lib/isolation-sentinel.js), so a degrade that only
|
||||
// reassigns `$ISOLATION` leaves the sentinel asserting `harness-worktree`
|
||||
// while the dispatch correctly omits the harness flag. The shipped PreToolUse
|
||||
// guard reads the sentinel at the instant of the `Agent()` call and denies the
|
||||
// mismatch with exit 2 — the work does not run unisolated, it does not run.
|
||||
//
|
||||
// WHY THIS ASSERTS THE RECORDED VALUE, NOT `$ISOLATION`: asserting the local
|
||||
// variable is precisely what let this class through. `$ISOLATION` was already
|
||||
// correct at all three sites — `none` — and the defect was entirely in what
|
||||
// reached the sentinel. So these tests execute each workflow's own degrade
|
||||
// block under a `gsd_run` stub that captures every call, and assert on the
|
||||
// value the workflow PUSHED THROUGH THE WRITE PATH.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('#2728 B1 — isolation degrades re-record through the single write path', () => {
|
||||
const { runHook } = require('./helpers/process-seam.cjs');
|
||||
// Class-norm timeout, not a local literal (CONTRIBUTING: class-norm
|
||||
// timeouts live in tests/helpers/timeouts.cjs). This harness is a short
|
||||
// shell probe against a gsd_run stub — exactly the PROBE class.
|
||||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
const os = require('node:os');
|
||||
|
||||
const WORKFLOWS = path.join(REPO_ROOT, 'gsd-core', 'workflows');
|
||||
|
||||
/**
|
||||
* Pull the fenced ```bash block containing `marker` out of a workflow.
|
||||
*
|
||||
* DEFECT.WINDOWS-CRLF-TEST-PORTABILITY: the captured body is handed to
|
||||
* `bash` below, so it must be CRLF-free. A `\r?\n` fence regex is NOT
|
||||
* sufficient — it only protects the delimiter match, leaving embedded `\r`
|
||||
* on every line of the body, which bash treats as part of the token
|
||||
* (helpers.cjs documents exactly this trap). Normalize at the READ
|
||||
* boundary so everything downstream is LF-only by construction.
|
||||
*/
|
||||
function bashBlockContaining(file, marker) {
|
||||
const text = readFileNormalized(file);
|
||||
for (const m of text.matchAll(/```bash\r?\n([\s\S]*?)```/g)) {
|
||||
if (m[1].includes(marker)) return m[1];
|
||||
}
|
||||
assert.fail(`no \`\`\`bash block containing ${JSON.stringify(marker)} in ${file}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a degrade block with the base-check forced to fire, under a `gsd_run`
|
||||
* stub that logs its argv. Returns every `dispatch-isolation` call the block
|
||||
* made, in order — i.e. the writes that would have hit the sentinel.
|
||||
*/
|
||||
function recordedWrites(block) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-degrade-'));
|
||||
const log = path.join(dir, 'calls.log');
|
||||
// The stub answers the base-check `true` so the degrade path is TAKEN;
|
||||
// every other query returns empty. It logs the full argv of each call.
|
||||
const harness = [
|
||||
'set -u',
|
||||
`gsd_run() { printf '%s\\n' "$*" >> ${JSON.stringify(log)};`,
|
||||
' case "$*" in',
|
||||
' *"worktree.base-check"*"shouldDegrade"*) printf true ;;',
|
||||
' *"worktree.base-check"*"message"*) printf "base diverged" ;;',
|
||||
' *) printf "" ;;',
|
||||
' esac; }',
|
||||
'ISOLATION=harness-worktree',
|
||||
'USE_WORKTREES=true',
|
||||
'RUNTIME=claude',
|
||||
'PHASE_NUMBER=7',
|
||||
block,
|
||||
// Prove the local variable was ALSO correct, so a failure below can only
|
||||
// mean the recording is missing — not that the degrade itself misfired.
|
||||
'printf "FINAL_LOCAL=%s\\n" "$ISOLATION"',
|
||||
].join('\n');
|
||||
|
||||
// Through the process seam, never a hand-rolled spawnSync (CONTRIBUTING
|
||||
// "Spawning a subprocess: use the process seam"). `runHook` documents
|
||||
// `interpreter: 'bash'` for running a shell script, so the harness is
|
||||
// written to a file rather than passed as `-c`. Bounded by construction
|
||||
// (DEFECT.UNBOUNDED-SUBPROCESS): pure shell against a `gsd_run` stub — no
|
||||
// git, no network, no real CLI — so it completes in milliseconds.
|
||||
const scriptPath = path.join(dir, 'degrade-harness.sh');
|
||||
fs.writeFileSync(scriptPath, harness);
|
||||
const res = runHook(scriptPath, [], { interpreter: 'bash', timeoutMs: PROBE_TIMEOUT_MS });
|
||||
if (res.outcome !== 'exited') {
|
||||
cleanup(dir);
|
||||
assert.fail(`degrade block did not complete: outcome=${res.outcome} ${res.stderr || ''}`);
|
||||
}
|
||||
assert.equal(res.exitCode, 0, `degrade block exited ${res.exitCode}: ${res.stderr}`);
|
||||
assert.match(res.stdout, /FINAL_LOCAL=none/, 'the degrade must set $ISOLATION=none locally');
|
||||
|
||||
const calls = fs.existsSync(log)
|
||||
? fs.readFileSync(log, 'utf-8').split(/\r?\n/).filter(Boolean)
|
||||
: [];
|
||||
cleanup(dir);
|
||||
return calls.filter(c => c.includes('dispatch-isolation'));
|
||||
}
|
||||
|
||||
/** The isolation mode the last write pushed, or null if nothing was written. */
|
||||
function recordedIsolation(block) {
|
||||
const writes = recordedWrites(block);
|
||||
if (writes.length === 0) return null;
|
||||
const last = writes[writes.length - 1];
|
||||
const m = last.match(/--force-isolation\s+(\S+)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
const DEGRADE_SITES = [
|
||||
{
|
||||
label: 'quick.md #1941 base-check degrade',
|
||||
file: path.join(WORKFLOWS, 'quick.md'),
|
||||
marker: '_QUICK_SHOULD_DEGRADE',
|
||||
},
|
||||
{
|
||||
label: 'diagnose-issues.md #2649 base-check degrade',
|
||||
file: path.join(WORKFLOWS, 'diagnose-issues.md'),
|
||||
marker: '_DIAG_SHOULD_DEGRADE',
|
||||
},
|
||||
];
|
||||
|
||||
for (const site of DEGRADE_SITES) {
|
||||
test(`${site.label} records none, not just the local variable`, () => {
|
||||
const block = bashBlockContaining(site.file, site.marker);
|
||||
assert.equal(
|
||||
recordedIsolation(block),
|
||||
'none',
|
||||
`${site.label}: $ISOLATION degraded to none but the block never pushed that ` +
|
||||
'through `query dispatch-isolation --force-isolation`. The sentinel still ' +
|
||||
'asserts harness-worktree, so the #3045 PreToolUse guard denies the dispatch ' +
|
||||
'with exit 2. Re-record immediately after the degrade.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('the harness detects a degrade that only reassigns (fail-first proof)', () => {
|
||||
// Strip the re-record from the shipped block. If the assertion above can
|
||||
// still pass against this, it is not testing what it claims to test.
|
||||
const block = bashBlockContaining(
|
||||
path.join(WORKFLOWS, 'quick.md'), '_QUICK_SHOULD_DEGRADE',
|
||||
);
|
||||
const preFix = block
|
||||
.split('\n')
|
||||
.filter(l => !l.includes('--force-isolation'))
|
||||
.join('\n');
|
||||
|
||||
assert.notEqual(preFix, block, 'the shipped block must contain a --force-isolation re-record');
|
||||
assert.equal(
|
||||
recordedIsolation(preFix),
|
||||
null,
|
||||
'the pre-fix shape must record NOTHING — otherwise these tests prove nothing. ' +
|
||||
'Note $ISOLATION is `none` in BOTH shapes: that is exactly why asserting the ' +
|
||||
'local variable would have passed on the defect.',
|
||||
);
|
||||
});
|
||||
|
||||
test('every dispatch-site degrade block re-records before the block ends', () => {
|
||||
// Coverage guard: a NEW degrade site added later cannot silently skip the
|
||||
// re-record. Scans the shipped shell rather than a hand-listed set.
|
||||
const offenders = [];
|
||||
// DERIVED, not hand-listed. The previous revision named three files, so a
|
||||
// degrade added anywhere else passed a check whose name claims "every"
|
||||
// (#2728 round-7 review, Major 6). Scan every workflow and reference.
|
||||
const scan = SCAN_ROOTS.flatMap(collectMarkdown);
|
||||
assert.ok(
|
||||
scan.length > 10,
|
||||
`the degrade scan collected only ${scan.length} files — the walk is broken, not the tree`,
|
||||
);
|
||||
|
||||
// Wave sites re-record PER PLAN, not inline: `execute-phase-wave-guard.md` and
|
||||
// `execute-phase-between-wave-reset.md` degrade `USE_WORKTREES=false` together
|
||||
// with `ISOLATION=none`, and `per-plan-worktree-gate.md` seeds
|
||||
// `USE_WORKTREES_FOR_PLAN="$USE_WORKTREES"` and pushes `--force-isolation none`
|
||||
// before each plan's dispatch. That is a real re-record, just delegated — so
|
||||
// these two are exempt. The exemption is ASSERTED below rather than assumed,
|
||||
// so deleting the delegate fails this test instead of silently widening a hole.
|
||||
const DELEGATED_TO_PER_PLAN_GATE = new Set([
|
||||
'gsd-core/references/execute-phase-wave-guard.md',
|
||||
'gsd-core/references/execute-phase-between-wave-reset.md',
|
||||
]);
|
||||
const perPlanGate = readFileNormalized(
|
||||
path.join(WORKFLOWS, 'execute-phase', 'steps', 'per-plan-worktree-gate.md'),
|
||||
);
|
||||
assert.ok(
|
||||
perPlanGate.includes('USE_WORKTREES_FOR_PLAN="$USE_WORKTREES"') &&
|
||||
perPlanGate.includes('--force-isolation none'),
|
||||
'per-plan-worktree-gate.md no longer inherits USE_WORKTREES and re-records `none`, so the ' +
|
||||
'wave degrade sites above are no longer covered by delegation — either restore the ' +
|
||||
'delegate or make those sites re-record inline',
|
||||
);
|
||||
|
||||
for (const file of scan) {
|
||||
const rel = path.relative(REPO_ROOT, file).replace(/\\/g, '/');
|
||||
if (DELEGATED_TO_PER_PLAN_GATE.has(rel)) continue;
|
||||
const text = readFileNormalized(file);
|
||||
for (const m of text.matchAll(/```bash\r?\n([\s\S]*?)```/g)) {
|
||||
const block = m[1];
|
||||
if (!/^\s*ISOLATION=none\s*$/m.test(block)) continue;
|
||||
if (!block.includes('--force-isolation')) {
|
||||
const line = text.slice(0, m.index).split(/\r?\n/).length;
|
||||
offenders.push(`${rel}:${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'these shell blocks degrade $ISOLATION to none without re-recording it through ' +
|
||||
'`query dispatch-isolation --force-isolation` — the #3045 guard will deny the ' +
|
||||
'resulting dispatch:\n' + offenders.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6924,7 +6924,15 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, installSpawnEnv } = require('./helpers.cjs');
|
||||
// #2652 round-7: new subprocesses go through the process seam, never a
|
||||
// hand-rolled spawnSync (CONTRIBUTING). The pre-existing `installAndRead`
|
||||
// spawn below is byte-identical to the base and left alone as out of scope.
|
||||
const { runNode: seamRunNode, runHook: seamRunHook } = require('./helpers/process-seam.cjs');
|
||||
// Class-norm timeouts, not local literals (CONTRIBUTING: they live in
|
||||
// tests/helpers/timeouts.cjs). The install is the INSTALL class; the emitted
|
||||
// gate is a short CLI probe against a temp fixture, i.e. the PROBE class.
|
||||
const { INSTALL_TIMEOUT_MS, PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const INSTALL = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
@@ -6958,15 +6966,31 @@ function installAndRead(runtime) {
|
||||
// RED tests: these MUST FAIL before the copyWithPathReplacement wiring is added
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('real install: codex-emitted execute-phase.md resolves runtime=codex and defaults worktrees off (#1521)', () => {
|
||||
test('real install: codex-emitted execute-phase.md resolves runtime=codex and leaves worktrees to the isolation negotiation (#1521, #2652)', () => {
|
||||
// End-to-end counterpart of the unit coverage in
|
||||
// tests/runtime-converters.test.cjs. #1521 asserted `--default false` here
|
||||
// because worktree isolation *was* Claude Code's isolation="worktree" spawn
|
||||
// parameter and no other host honored it. #2584 replaced that premise with
|
||||
// the negotiated `dispatch.isolation` capability, and codex declares
|
||||
// `orchestrator-worktree` — GSD creates the worktree and spawns
|
||||
// `codex exec --cd <worktree>`. Keeping the stamp would resolve
|
||||
// USE_WORKTREES=false before `gsd_run query dispatch-isolation` was ever
|
||||
// consulted, re-deciding isolation by runtime name (#2652's Blocker).
|
||||
//
|
||||
// The runtime-identity stamp is unaffected and still asserted below — only
|
||||
// the use_worktrees half moved.
|
||||
const c = installAndRead('codex');
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default codex --raw'),
|
||||
'codex runtime default not stamped in real install',
|
||||
);
|
||||
assert.ok(
|
||||
c.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
'codex use_worktrees not defaulted false in real install',
|
||||
!c.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
'codex install stamped use_worktrees=false, which pre-empts the dispatch.isolation negotiation for a host that declares orchestrator-worktree (#2652)',
|
||||
);
|
||||
assert.ok(
|
||||
c.includes('config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'),
|
||||
'codex install lost the unstamped use_worktrees read — the isolation gate has nothing left to negotiate',
|
||||
);
|
||||
assert.ok(
|
||||
!c.includes('config-get runtime --default claude --raw'),
|
||||
@@ -6974,6 +6998,23 @@ test('real install: codex-emitted execute-phase.md resolves runtime=codex and de
|
||||
);
|
||||
});
|
||||
|
||||
test('real install: an isolation-none runtime still gets use_worktrees stamped false (#1521 preserved, #2652 scoped)', () => {
|
||||
// The other arm: #2652 narrowed the stamp, it did not remove it. windsurf
|
||||
// declares `dispatch.isolation: none`, so the false default it writes is the
|
||||
// outcome the resolver reaches anyway, and #1521's protection stays intact
|
||||
// for every host that genuinely cannot isolate. Without this arm the change
|
||||
// above could silently become "never stamp" and nothing would notice.
|
||||
const c = installAndRead('windsurf');
|
||||
assert.ok(
|
||||
c.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
'windsurf declares isolation=none and must still receive the #1521 false stamp',
|
||||
);
|
||||
assert.ok(
|
||||
c.includes('config-get runtime --default windsurf --raw'),
|
||||
'windsurf runtime default not stamped in real install',
|
||||
);
|
||||
});
|
||||
|
||||
test('real install: cursor-emitted execute-phase.md resolves runtime=cursor (#1521)', () => {
|
||||
const c = installAndRead('cursor');
|
||||
assert.ok(
|
||||
@@ -6986,6 +7027,203 @@ test('real install: cursor-emitted execute-phase.md resolves runtime=cursor (#15
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2652 review round-5/6 Major 2 — Cursor end-to-end, not by reasoning.
|
||||
//
|
||||
// Cursor is the runtime this PR newly enables: it declares
|
||||
// `dispatch.isolation: harness-worktree` with `harnessIsolationFlag: "--worktree"`,
|
||||
// and narrowing the #1521 `use_worktrees=false` stamp is what lets its
|
||||
// negotiation be reached at all. The resolver agreeing with itself is not
|
||||
// evidence that `--worktree` — rather than Claude Code's own
|
||||
// `isolation="worktree"` literal, or nothing at all — is what ends up in the
|
||||
// `{harnessFlag}` slot of the `Agent()` call.
|
||||
//
|
||||
// So: run a REAL cursor install, then execute the gate blocks THAT INSTALL
|
||||
// EMITTED against the gsd-tools.cjs THAT INSTALL EMITTED, and read the two
|
||||
// shell variables the dispatch sites substitute. Nothing here is a fixture.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Skipped on Windows, where there is no bash. Checked by platform rather than
|
||||
// by shelling out to `which`, which is itself non-portable.
|
||||
const NO_BASH = process.platform === 'win32';
|
||||
|
||||
test('real install: cursor negotiates --worktree through its own emitted gate and it lands in the emitted Agent() slot (#2652)', { skip: NO_BASH }, (t) => {
|
||||
const { readFileNormalized } = require('./helpers.cjs');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-inst-cursor-gate-'));
|
||||
t.after(() => cleanup(dir));
|
||||
// Through the process seam and the install isolation seam, never a
|
||||
// hand-rolled spawnSync from raw process.env (CONTRIBUTING "Spawning a
|
||||
// subprocess: use the process seam"): ambient GSD_HOME / runtime-location
|
||||
// vars otherwise leak in and make capability discovery host-dependent.
|
||||
const res = seamRunNode(
|
||||
[INSTALL, '--cursor', '--global', '--config-dir', dir],
|
||||
{ env: installSpawnEnv({ HOME: dir, USERPROFILE: dir }), timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
assert.strictEqual(res.outcome, 'exited', `install --cursor did not complete: ${res.outcome}`);
|
||||
assert.strictEqual(res.exitCode, 0, `install --cursor failed: ${res.stderr || res.stdout}`);
|
||||
|
||||
const tools = path.join(dir, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const gate = path.join(dir, 'gsd-core', 'references', 'dispatch-isolation-gate.md');
|
||||
assert.ok(fs.existsSync(tools), `cursor install emitted no gsd-tools.cjs at ${tools}`);
|
||||
assert.ok(fs.existsSync(gate), `cursor install emitted no dispatch-isolation-gate.md at ${gate}`);
|
||||
|
||||
// Precondition, stated as an assertion rather than assumed: the emitted
|
||||
// quick.md must still carry the UNSTAMPED use_worktrees read. If #1521's
|
||||
// `--default false` stamp came back for cursor, USE_WORKTREES resolves
|
||||
// false and the gate below degrades to none before negotiating anything —
|
||||
// the harness flag would then be empty for a reason unrelated to the flag.
|
||||
const quick = readFileNormalized(path.join(dir, 'gsd-core', 'workflows', 'quick.md'));
|
||||
assert.ok(
|
||||
quick.includes('config-get workflow.use_worktrees --raw 2>/dev/null || echo "true"'),
|
||||
'cursor-emitted quick.md lost the unstamped use_worktrees read — the isolation negotiation is pre-empted at install time (#2652)',
|
||||
);
|
||||
|
||||
// Pull the gate's OWN blocks — the ones a dispatch site is told to run —
|
||||
// out of the emitted reference. readFileNormalized strips CRLF first
|
||||
// (DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE).
|
||||
// Anchor on the gate's own HEADINGS, not on an assignment literal inside a
|
||||
// block. The workflows tell a dispatch site to run the `Resolve ISOLATION`
|
||||
// and `Resolve the harness flag` blocks BY NAME, so the heading is the
|
||||
// contract and the block body is free to change under it. Keying off the
|
||||
// body instead is what broke here: when the resolver grew its
|
||||
// `_ISOLATION_RAW`/`ISOLATION_RESOLVED` split — so a shim failure stops
|
||||
// masquerading as a declared `none` — the old `ISOLATION=$(gsd_run query
|
||||
// dispatch-isolation --raw` finder stopped matching, and a test whose
|
||||
// subject is "does the emitted gate resolve cursor correctly" failed as
|
||||
// "there is no such block".
|
||||
const gateText = readFileNormalized(gate);
|
||||
const blockUnder = (heading) => {
|
||||
const at = gateText.indexOf(`## ${heading}`);
|
||||
if (at === -1) return undefined;
|
||||
return (gateText.slice(at).match(/```bash\r?\n([\s\S]*?)```/) || [])[1];
|
||||
};
|
||||
const resolveBlock = blockUnder('Resolve ISOLATION');
|
||||
const flagBlock = blockUnder('Resolve the harness flag');
|
||||
assert.ok(resolveBlock, 'emitted dispatch-isolation-gate.md has no `## Resolve ISOLATION` heading with a bash block under it');
|
||||
assert.ok(flagBlock, 'emitted dispatch-isolation-gate.md has no `## Resolve the harness flag` heading with a bash block under it');
|
||||
|
||||
// A disposable project dir: `query dispatch-isolation` writes the #3045
|
||||
// sentinel into its cwd as an unconditional side effect.
|
||||
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-gate-proj-'));
|
||||
t.after(() => cleanup(proj));
|
||||
// Declare the runtime the way a real Cursor project does — through
|
||||
// `.planning/config.json`, which is the tier `resolveRuntime` actually
|
||||
// reads (GSD_RUNTIME > .planning/config.json > 'claude'). Injecting
|
||||
// GSD_RUNTIME=cursor instead would prove the resolver works when handed
|
||||
// the answer, not that a Cursor install reaches it on its own. (The
|
||||
// install's ~/.gsd/defaults.json `runtime` is NOT a resolveRuntime tier.)
|
||||
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(proj, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
|
||||
|
||||
const script = [
|
||||
'set -u',
|
||||
`gsd_run() { node ${JSON.stringify(tools)} "$@"; }`,
|
||||
'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")',
|
||||
'RUNTIME=$(gsd_run query config-get runtime --default cursor --raw 2>/dev/null || echo "cursor")',
|
||||
resolveBlock,
|
||||
flagBlock,
|
||||
'printf "ISOLATION=%s\\nHARNESS_FLAG=%s\\n" "$ISOLATION" "$HARNESS_FLAG"',
|
||||
].join('\n');
|
||||
|
||||
// Bounded (DEFECT.UNBOUNDED-SUBPROCESS): a handful of local gsd-tools
|
||||
// invocations, no network and no git beyond repo introspection.
|
||||
// GSD_RUNTIME outranks the config tier in resolveRuntime, so an ambient
|
||||
// one in the developer's shell would satisfy this test without the
|
||||
// install's own config ever being read. Blank it explicitly.
|
||||
const hermeticEnv = { ...process.env, HOME: dir, USERPROFILE: dir };
|
||||
delete hermeticEnv.GSD_RUNTIME;
|
||||
|
||||
// Seam again — `runHook` documents `interpreter: 'bash'` for a shell
|
||||
// script, so the gate is written to a file rather than passed as `-c`.
|
||||
const gateScript = path.join(proj, 'run-gate.sh');
|
||||
fs.writeFileSync(gateScript, script);
|
||||
const run = seamRunHook(gateScript, [], {
|
||||
interpreter: 'bash',
|
||||
cwd: proj,
|
||||
env: hermeticEnv,
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
assert.strictEqual(
|
||||
run.outcome, 'exited',
|
||||
`emitted cursor gate did not complete: outcome=${run.outcome} ${run.stderr || ''}`,
|
||||
);
|
||||
assert.strictEqual(run.exitCode, 0, `emitted cursor gate exited ${run.exitCode}: ${run.stderr}`);
|
||||
|
||||
assert.match(
|
||||
run.stdout,
|
||||
/^ISOLATION=harness-worktree$/m,
|
||||
`cursor declares dispatch.isolation=harness-worktree but its own emitted gate resolved otherwise:\n${run.stdout}\n${run.stderr}`,
|
||||
);
|
||||
const flagLine = run.stdout.match(/^HARNESS_FLAG=(.*)$/m);
|
||||
assert.ok(flagLine, `the gate printed no HARNESS_FLAG line:\n${run.stdout}\n${run.stderr}`);
|
||||
const harnessFlag = flagLine[1];
|
||||
assert.strictEqual(
|
||||
harnessFlag,
|
||||
'--worktree',
|
||||
`cursor resolved "${harnessFlag}" instead of its declared --worktree. Claude Code's own isolation="worktree" literal reaching a Cursor dispatch is the #2652 defect inverted.`,
|
||||
);
|
||||
// The reviewer framed this as argv-injection-shaped: the value is spliced
|
||||
// into an argument list. Pin that it stays a single bare token.
|
||||
assert.ok(
|
||||
/^[-\w=".]+$/.test(harnessFlag),
|
||||
`the harness flag carries shell/argv-significant characters and is spliced into an argument slot: ${JSON.stringify(harnessFlag)}`,
|
||||
);
|
||||
|
||||
// ── The slot itself ──────────────────────────────────────────────────
|
||||
// Resolving the value is only half of it. Perform the substitution the
|
||||
// emitted workflows document ("{harnessFlag}" → "$HARNESS_FLAG" plus a
|
||||
// comma when ISOLATION = harness-worktree) against the Agent() call THIS
|
||||
// INSTALL EMITTED, and assert on the rendered dispatch. Without this,
|
||||
// deleting the placeholder from the emitted Agent() — the exact way the
|
||||
// flag would stop reaching the dispatch — leaves the test green.
|
||||
// Name the dispatch each site's isolated agent actually goes through, so
|
||||
// "some Agent() call in the file has the placeholder" cannot stand in for
|
||||
// "the EXECUTOR/DEBUGGER dispatch has it". Both files contain other
|
||||
// Agent() calls (quick.md dispatches a reviewer too); the placeholder
|
||||
// drifting onto one of those is a real defect that a `.find()` alone
|
||||
// would report as a pass.
|
||||
for (const { wf, agent } of [
|
||||
{ wf: 'quick.md', agent: 'gsd-executor' },
|
||||
{ wf: 'diagnose-issues.md', agent: 'gsd-debugger' },
|
||||
]) {
|
||||
const text = readFileNormalized(path.join(dir, 'gsd-core', 'workflows', wf));
|
||||
const calls = text.match(/Agent\(\n(?:[^\n]*\n)*?\)/g) || [];
|
||||
const withSlot = calls.filter(b => b.includes('{harnessFlag}'));
|
||||
assert.strictEqual(
|
||||
withSlot.length,
|
||||
1,
|
||||
`${wf}: expected exactly one emitted Agent() call carrying {harnessFlag}, found ${withSlot.length}. ` +
|
||||
'Zero means the negotiated flag has no slot to reach; more than one means the isolated ' +
|
||||
`dispatch is ambiguous.\n${withSlot.join('\n---\n')}`,
|
||||
);
|
||||
const call = withSlot[0];
|
||||
assert.ok(
|
||||
call.includes(`subagent_type="${agent}"`),
|
||||
`${wf}: the {harnessFlag} slot is not on the ${agent} dispatch — it drifted onto a different Agent() call, so the isolated agent would be spawned without it:\n${call}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
(call.match(/\{harnessFlag\}/g) || []).length,
|
||||
1,
|
||||
`${wf}: expected exactly one {harnessFlag} slot in the dispatch call, got:\n${call}`,
|
||||
);
|
||||
|
||||
const rendered = call.replace('{harnessFlag}', `${harnessFlag},`);
|
||||
assert.match(
|
||||
rendered,
|
||||
/^\s*--worktree,$/m,
|
||||
`${wf}: rendering the emitted Agent() call for cursor did not put --worktree in its argument list:\n${rendered}`,
|
||||
);
|
||||
assert.ok(
|
||||
!rendered.includes('{harnessFlag}'),
|
||||
`${wf}: an unsubstituted {harnessFlag} survives into the dispatch:\n${rendered}`,
|
||||
);
|
||||
assert.ok(
|
||||
!rendered.includes('isolation="worktree"'),
|
||||
`${wf}: the rendered cursor dispatch carries Claude Code's own harness literal — the flag is descriptor data, never hardcoded:\n${rendered}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('real install: claude-emitted execute-phase.md keeps claude default + worktrees on (#1521)', () => {
|
||||
const c = installAndRead('claude');
|
||||
assert.ok(
|
||||
|
||||
@@ -86,7 +86,7 @@ const PROSE_ALLOWLIST = [
|
||||
{ file: 'agents/gsd-phase-researcher.md', line: 33, reason: 'package-legitimacy provenance rule names the command as the source of an OK verdict; descriptive' },
|
||||
{ file: 'agents/gsd-roadmapper.md', line: 642, reason: 'parenthetical "e.g." naming SDK queries a user *could* run; not an agent instruction' },
|
||||
{ file: 'agents/gsd-intel-updater.md', line: 40, reason: 'cross-platform note names the `gsd-tools intel <subcommand>` CLI surface descriptively ("CLI invocations go through..."); not an agent instruction' },
|
||||
{ file: 'gsd-core/workflows/execute-plan.md', line: 387, reason: 'describes the downstream SDK validation step (`validated downstream by ...`); names the mechanism, does not instruct the agent to type it' },
|
||||
{ file: 'gsd-core/workflows/execute-plan.md', line: 414, reason: 'describes the downstream SDK validation step (`validated downstream by ...`); names the mechanism, does not instruct the agent to type it' },
|
||||
{ file: 'agents/gsd-research-synthesizer.md', line: 65, reason: 'a code comment inside a fenced block explaining what the commit step loads (`# Planning config loaded via gsd-tools query ...`); descriptive, not an invocation — and explicitly names gsd-tools.cjs as the alternative' },
|
||||
];
|
||||
|
||||
|
||||
@@ -934,21 +934,30 @@ test('codex emit stamps its own runtime default into the runtime-resolution line
|
||||
);
|
||||
});
|
||||
|
||||
test('codex emit defaults workflow.use_worktrees to false', () => {
|
||||
test('codex emit leaves workflow.use_worktrees at the true default — isolation is negotiated, not stamped (#1515 premise superseded by #2584/#2652)', () => {
|
||||
// #1515 stamped `--default false` here because worktree isolation *was*
|
||||
// Claude Code's isolation="worktree" spawn parameter, so a Codex install that
|
||||
// resolved use_worktrees=true would have run unisolated while believing it was
|
||||
// isolated. #2584 removed that premise: Codex declares
|
||||
// `dispatch.isolation: orchestrator-worktree`, meaning GSD creates the
|
||||
// worktree itself and spawns `codex exec --cd <worktree>` — supported, not
|
||||
// unsafe. Keeping the stamp would resolve USE_WORKTREES=false before
|
||||
// dispatch-isolation is consulted, re-deciding isolation by runtime name,
|
||||
// which is the exact defect #2652 removes. The safety property #1515 protects
|
||||
// is now held by the isolation gate's fail-closed resolution, not by a
|
||||
// name-scoped install-time default.
|
||||
const line =
|
||||
'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n';
|
||||
const out = conversion._applyRuntimeRewrites(line, 'codex', '$HOME/.codex/', true, undefined);
|
||||
assert.ok(
|
||||
out.includes('config-get workflow.use_worktrees --default false --raw'),
|
||||
`Expected 'config-get workflow.use_worktrees --default false --raw' in output; got:\n${out}`,
|
||||
assert.strictEqual(
|
||||
conversion._negotiatedDispatchIsolation('codex'),
|
||||
'orchestrator-worktree',
|
||||
'codex must declare orchestrator-worktree for this expectation to hold',
|
||||
);
|
||||
assert.ok(
|
||||
out.includes('|| echo "false")'),
|
||||
`Expected '|| echo "false")' in output; got:\n${out}`,
|
||||
);
|
||||
assert.ok(
|
||||
!out.includes('|| echo "true")'),
|
||||
`Expected '|| echo "true")' to be fully rewritten; got:\n${out}`,
|
||||
assert.strictEqual(
|
||||
out,
|
||||
line,
|
||||
`Expected the use_worktrees read to survive codex emit untouched; got:\n${out}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -986,12 +995,17 @@ test('regression: every edited workflow gets codex-stamped (source↔engine pari
|
||||
for (const wf of WORKFLOWS) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'workflows', wf), 'utf8');
|
||||
const out = conversion._applyRuntimeRewrites(src, 'codex', '$HOME/.codex/', true, undefined);
|
||||
// No un-stamped claude/true resolution line may survive codex emit on ANY surface.
|
||||
// No un-stamped claude runtime line may survive codex emit on ANY surface.
|
||||
assert.ok(!out.includes(CLAUDE_RUNTIME), `${wf}: residual un-stamped runtime read — engine regex no longer matches source line (parity drift)`);
|
||||
assert.ok(!out.includes(TRUE_WT), `${wf}: residual un-stamped use_worktrees read — parity drift`);
|
||||
// If the source HAS such a read, the codex form must be present.
|
||||
if (src.includes(CLAUDE_RUNTIME)) assert.ok(out.includes(CODEX_RUNTIME), `${wf}: runtime read not stamped to codex`);
|
||||
if (src.includes(TRUE_WT)) assert.ok(out.includes(FALSE_WT), `${wf}: use_worktrees read not defaulted to false`);
|
||||
// #2652: codex declares orchestrator-worktree, so the use_worktrees read is
|
||||
// left for `gsd_run query dispatch-isolation` to decide at run time — the
|
||||
// install-time `--default false` stamp would pre-empt that negotiation.
|
||||
if (src.includes(TRUE_WT)) {
|
||||
assert.ok(out.includes(TRUE_WT), `${wf}: use_worktrees read was stamped away for a runtime that negotiates worktree isolation (#2652)`);
|
||||
assert.ok(!out.includes(FALSE_WT), `${wf}: codex gained the --default false use_worktrees stamp (#2652)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1036,11 +1050,16 @@ test('property: codex stamping is idempotent on resolution lines (#1515)', () =>
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for #1521: every non-Claude runtime stamps its own runtime
|
||||
* identity + workflow.use_worktrees=false into emitted workflows.
|
||||
* identity into emitted workflows, and stamps workflow.use_worktrees=false
|
||||
* where its negotiated dispatch.isolation is `none`.
|
||||
*
|
||||
* GSD's worktree isolation relies on Claude Code's isolation="worktree" spawn
|
||||
* parameter, which no other runtime honors. #1519 (Codex-only fix) is
|
||||
* generalized here to ALL non-Claude runtimes.
|
||||
* #1519 (Codex-only fix) was generalized here to ALL non-Claude runtimes on the
|
||||
* premise that GSD's worktree isolation was Claude Code's isolation="worktree"
|
||||
* spawn parameter, which no other runtime honored. #2584 replaced that premise
|
||||
* with the negotiated `dispatch.isolation` capability, and #2652 scoped the
|
||||
* use_worktrees stamp to match: a host declaring harness-worktree or
|
||||
* orchestrator-worktree keeps the `true` default, because stamping it false
|
||||
* pre-empts the negotiation and re-decides isolation by runtime name.
|
||||
*
|
||||
* All tests assert on the SUT's RETURN VALUE (engine output), not raw file reads,
|
||||
* except the parity integration test which carries the allow-test-rule exemption.
|
||||
@@ -1068,13 +1087,14 @@ const FALSE_WT_LINE = 'config-get workflow.use_worktrees --default false --raw 2
|
||||
// Parity across ALL non-Claude runtimes × all 5 workflows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('parity: every non-Claude runtime stamps its own runtime default and use_worktrees=false on all workflows (#1521)', () => {
|
||||
test('parity: every non-Claude runtime stamps its own runtime default, and use_worktrees=false only where isolation negotiates to none (#1521, #2652)', () => {
|
||||
// allow-test-rule: pending-migration-to-typed-ir [#3090]
|
||||
// `out` is _applyRuntimeRewrites's engine-transformed shell text, not shipped
|
||||
// source — substring-matching it is the "Rendered file" row CONTRIBUTING
|
||||
// requires a typed-IR builder for. No such IR exists yet for the shell
|
||||
// rewrite output; production change out of scope here. Tracked under #3090.
|
||||
for (const rt of NON_CLAUDE) {
|
||||
const isolation = conversion._negotiatedDispatchIsolation(rt);
|
||||
for (const wf of WORKFLOWS) {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', wf),
|
||||
@@ -1088,12 +1108,6 @@ test('parity: every non-Claude runtime stamps its own runtime default and use_wo
|
||||
`${rt}/${wf}: residual un-stamped claude runtime read — _stampNonClaudeRuntimeDefaults not applied`,
|
||||
);
|
||||
|
||||
// No un-stamped use_worktrees=true line may survive
|
||||
assert.ok(
|
||||
!out.includes(TRUE_WT_LINE),
|
||||
`${rt}/${wf}: residual un-stamped use_worktrees=true read — _stampNonClaudeRuntimeDefaults not applied`,
|
||||
);
|
||||
|
||||
// If the source had a runtime read, the output must have --default <rt>
|
||||
if (src.includes(CLAUDE_RUNTIME_LINE)) {
|
||||
assert.ok(
|
||||
@@ -1102,17 +1116,215 @@ test('parity: every non-Claude runtime stamps its own runtime default and use_wo
|
||||
);
|
||||
}
|
||||
|
||||
// If the source had a use_worktrees read, the output must have --default false
|
||||
if (src.includes(TRUE_WT_LINE)) {
|
||||
if (!src.includes(TRUE_WT_LINE)) continue;
|
||||
|
||||
// #2652: the use_worktrees=false stamp is scoped to the runtimes whose
|
||||
// negotiated dispatch.isolation really is `none`. A host that declares
|
||||
// harness-worktree (cursor) or orchestrator-worktree (codex, opencode,
|
||||
// kimi, kimi-code) must keep the unstamped `true` default, or the stamp
|
||||
// resolves USE_WORKTREES=false before dispatch-isolation is consulted and
|
||||
// the runtime is judged by its name after all.
|
||||
if (isolation === 'none') {
|
||||
assert.ok(
|
||||
!out.includes(TRUE_WT_LINE),
|
||||
`${rt}/${wf}: residual un-stamped use_worktrees=true read — _stampNonClaudeRuntimeDefaults not applied`,
|
||||
);
|
||||
assert.ok(
|
||||
out.includes(FALSE_WT_LINE),
|
||||
`${rt}/${wf}: use_worktrees line not defaulted to false`,
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
out.includes(TRUE_WT_LINE),
|
||||
`${rt}/${wf}: declares dispatch.isolation=${isolation} but the use_worktrees read was stamped away — the install-time default pre-empts the negotiated capability (#2652)`,
|
||||
);
|
||||
assert.ok(
|
||||
!out.includes(FALSE_WT_LINE),
|
||||
`${rt}/${wf}: declares dispatch.isolation=${isolation} but gained the --default false stamp`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2652: the stamp's scope is the negotiated capability, not the runtime name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('regression #2652: _stampNonClaudeRuntimeDefaults leaves use_worktrees alone for every runtime that negotiates a worktree isolation', () => {
|
||||
const line =
|
||||
'USE_WORKTREES=$(gsd_run query config-get workflow.use_worktrees --raw 2>/dev/null || echo "true")\n';
|
||||
|
||||
// The set is derived from the registry, not hand-listed, so a newly declared
|
||||
// worktree host is covered the moment it lands — the same reason
|
||||
// NON_CLAUDE_RUNTIMES is derived rather than literal (#1521).
|
||||
const declaresWorktree = NON_CLAUDE.filter(
|
||||
(rt) => conversion._negotiatedDispatchIsolation(rt) !== 'none',
|
||||
);
|
||||
const declaresNone = NON_CLAUDE.filter(
|
||||
(rt) => conversion._negotiatedDispatchIsolation(rt) === 'none',
|
||||
);
|
||||
|
||||
// Both arms must be non-empty or the test proves nothing about either.
|
||||
assert.ok(
|
||||
declaresWorktree.length > 0,
|
||||
'no non-Claude runtime negotiates a worktree isolation — the regression this pins is unreachable',
|
||||
);
|
||||
assert.ok(
|
||||
declaresNone.length > 0,
|
||||
'every non-Claude runtime negotiates a worktree isolation — the false stamp is dead code',
|
||||
);
|
||||
|
||||
for (const rt of declaresWorktree) {
|
||||
assert.strictEqual(
|
||||
conversion._stampNonClaudeRuntimeDefaults(line, rt),
|
||||
line,
|
||||
`${rt}: negotiates ${conversion._negotiatedDispatchIsolation(rt)} but its use_worktrees default was stamped false at install time, so dispatch-isolation-gate.md resolves ISOLATION=none regardless of what it declared (#2652)`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rt of declaresNone) {
|
||||
assert.ok(
|
||||
conversion._stampNonClaudeRuntimeDefaults(line, rt).includes(FALSE_WT_LINE),
|
||||
`${rt}: negotiates none, so the false default must still be stamped (#1521)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('regression #2652: _negotiatedDispatchIsolation fails closed on an undeclared or unknown runtime', () => {
|
||||
// Mirrors routeDispatchIsolation's fail-closed contract (ADR-1239): anything
|
||||
// outside the closed vocabulary resolves to `none`, so the #1521 stamp — and
|
||||
// the workflows' isolation gate — degrade to sequential rather than to an
|
||||
// unisolated dispatch that believes it is isolated.
|
||||
for (const unknown of ['not-a-runtime', '', 'CLAUDE', '__proto__']) {
|
||||
assert.strictEqual(
|
||||
conversion._negotiatedDispatchIsolation(unknown),
|
||||
'none',
|
||||
`${JSON.stringify(unknown)}: expected fail-closed 'none'`,
|
||||
);
|
||||
}
|
||||
|
||||
// A registry-known host whose declared value is the `undocumented` sentinel
|
||||
// (or absent) is out of vocabulary and must degrade the same way.
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const undocumented = NON_CLAUDE.filter((rt) => {
|
||||
const declared = registry?.runtimes?.[rt]?.runtime?.hostIntegration?.dispatch?.isolation;
|
||||
return declared === 'undocumented' || declared == null;
|
||||
});
|
||||
assert.ok(undocumented.length > 0, 'no runtime declares the undocumented sentinel — nothing to pin');
|
||||
for (const rt of undocumented) {
|
||||
assert.strictEqual(conversion._negotiatedDispatchIsolation(rt), 'none', `${rt}: undocumented must degrade to none`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2652 review round-5/6 Major 1 — PARITY with the runtime resolver.
|
||||
//
|
||||
// `_negotiatedDispatchIsolation` (install time, this module) and
|
||||
// `routeDispatchIsolation` (dispatch time, gsd-core/bin/gsd-tools.cjs) are two
|
||||
// implementations of ONE rule: what isolation may this host negotiate. They
|
||||
// read the same inputs — the capability registry and `resolveOrchestratorExec`
|
||||
// — but duplicate the DECISION on top of them, on two surfaces (the install
|
||||
// engine and the CLI query hub) with no call edge between them. So neither
|
||||
// symbol appears in the other's impact graph and no static analysis sees the
|
||||
// duplication. Divergence would be silent, and its consequence is the #2652
|
||||
// defect returning by the back door: the installer stamping
|
||||
// `use_worktrees=false` for a host the resolver would have granted a worktree
|
||||
// (or the reverse — an unstamped host whose dispatch then refuses).
|
||||
//
|
||||
// The assertion is therefore behavioral on BOTH sides: the resolver leg drives
|
||||
// the REAL CLI and reads its actual stdout.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('regression #2652: _negotiatedDispatchIsolation agrees with the routeDispatchIsolation CLI for every registered runtime', () => {
|
||||
const { runGsdTools, createTempProject, cleanup: cleanupDir } = require('./helpers.cjs');
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
const RUNTIME_IDS = Object.keys(registry.runtimes).sort();
|
||||
|
||||
// Install time cannot know the worktree a future dispatch will target, so
|
||||
// `_negotiatedDispatchIsolation` probes the orchestrator descriptor with a
|
||||
// placeholder. The CLI is asked BOTH ways, because the two calls are both real:
|
||||
//
|
||||
// --cwd-target <probe> the executor-spawn call, which resolves the descriptor.
|
||||
// Same question install time asks; must match exactly.
|
||||
// (no --cwd-target) the FIRST call every dispatch site makes — the
|
||||
// `Resolve ISOLATION` block in dispatch-isolation-gate.md.
|
||||
// It skips descriptor resolution, so it can only differ
|
||||
// from install time for an orchestrator host whose
|
||||
// descriptor does not resolve. No such host exists today
|
||||
// and that is worth pinning: if one lands, the install
|
||||
// stamps `use_worktrees=false` while the workflow gate
|
||||
// still reports `orchestrator-worktree` — the #2652 split
|
||||
// brain, in the one shape the target-bound leg cannot see.
|
||||
const PROBE_TARGET = '/gsd-orchestrator-worktree-probe';
|
||||
|
||||
const dir = createTempProject('gsd-2652-parity-');
|
||||
try {
|
||||
const disagreements = [];
|
||||
const seen = new Set();
|
||||
for (const rt of RUNTIME_IDS) {
|
||||
const res = runGsdTools(
|
||||
['query', 'dispatch-isolation', '--json', '--cwd-target', PROBE_TARGET],
|
||||
dir,
|
||||
{ GSD_RUNTIME: rt, HOME: dir },
|
||||
);
|
||||
assert.equal(res.success, true, `${rt}: dispatch-isolation query failed: ${res.error}`);
|
||||
const parsed = JSON.parse(res.output);
|
||||
|
||||
// Guard the guard: if resolveRuntime normalized `rt` to some other id, the
|
||||
// comparison below would be pinning the wrong host and silently pass.
|
||||
assert.equal(
|
||||
parsed.runtime,
|
||||
rt,
|
||||
`${rt}: the CLI resolved runtime "${parsed.runtime}" instead — parity would be measured against the wrong host`,
|
||||
);
|
||||
|
||||
const gateRes = runGsdTools(
|
||||
['query', 'dispatch-isolation', '--raw'],
|
||||
dir,
|
||||
{ GSD_RUNTIME: rt, HOME: dir },
|
||||
);
|
||||
assert.equal(gateRes.success, true, `${rt}: no-target dispatch-isolation query failed: ${gateRes.error}`);
|
||||
const gateIsolation = gateRes.output.trim();
|
||||
|
||||
const installTime = conversion._negotiatedDispatchIsolation(rt);
|
||||
seen.add(installTime);
|
||||
if (installTime !== parsed.isolation) {
|
||||
disagreements.push(
|
||||
`${rt}: install-time _negotiatedDispatchIsolation → "${installTime}", ` +
|
||||
`dispatch-time routeDispatchIsolation (--cwd-target) → "${parsed.isolation}"`,
|
||||
);
|
||||
}
|
||||
if (installTime !== gateIsolation) {
|
||||
disagreements.push(
|
||||
`${rt}: install-time _negotiatedDispatchIsolation → "${installTime}", ` +
|
||||
`workflow-gate routeDispatchIsolation (no --cwd-target) → "${gateIsolation}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
disagreements,
|
||||
[],
|
||||
'the install-time and dispatch-time isolation resolvers disagree. One of them was ' +
|
||||
'changed without the other (DEFECT.GENERATIVE-FIX-DIVERGENCE): the installer and the ' +
|
||||
'dispatch gate would then make opposite isolation decisions for the same host.\n' +
|
||||
disagreements.join('\n'),
|
||||
);
|
||||
|
||||
// A parity check over a set that only ever answers `none` proves nothing —
|
||||
// both legs could be stubbed to a constant and still agree.
|
||||
for (const mode of ['harness-worktree', 'orchestrator-worktree', 'none']) {
|
||||
assert.ok(
|
||||
seen.has(mode),
|
||||
`no registered runtime resolves to "${mode}" — the parity above never exercised that branch`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
cleanupDir(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude unchanged — no stamping for the native runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1196,21 +1408,38 @@ test('execute-phase.md, quick.md, and diagnose-issues.md guards are generalized
|
||||
// #2584 Phase 3 (#2627): execute-phase.md graduated PAST the `!= "claude"`
|
||||
// guard — worktree isolation there is now keyed on the negotiated
|
||||
// `dispatch.isolation` capability, so no runtime name appears in its guard at
|
||||
// all. quick.md and diagnose-issues.md still use the #1521 generalized form
|
||||
// (they do not negotiate isolation), so they keep asserting it.
|
||||
const GUARD_WORKFLOWS = ['quick.md', 'diagnose-issues.md'];
|
||||
// all.
|
||||
//
|
||||
// #2652: quick.md and diagnose-issues.md have now graduated too. This block
|
||||
// previously asserted they still carried the #1521 generalized form, with a
|
||||
// comment noting "they do not negotiate isolation" — i.e. the test knowingly
|
||||
// pinned the un-migrated state #2652 was filed about. They negotiate now, so
|
||||
// they are held to the same capability-keyed contract as execute-phase.md.
|
||||
const GUARD_WORKFLOWS = ['quick.md', 'diagnose-issues.md', 'execute-phase.md'];
|
||||
for (const wf of GUARD_WORKFLOWS) {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', wf),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('[ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]'),
|
||||
`${wf}: expected generalized guard [ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]`,
|
||||
!src.includes('[ "$RUNTIME" != "claude" ] && [ "$USE_WORKTREES" != "false" ]'),
|
||||
`${wf}: worktree isolation must branch on dispatch.isolation, not on != "claude" (#2584/#2652)`,
|
||||
);
|
||||
assert.ok(
|
||||
!src.includes('[ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]'),
|
||||
`${wf}: found Codex-specific guard — should have been generalized to != "claude"`,
|
||||
`${wf}: found Codex-specific guard — isolation is a negotiated capability (#2584/#2652)`,
|
||||
);
|
||||
}
|
||||
|
||||
// The two migrated sites must actually negotiate, not merely drop the guard.
|
||||
for (const wf of ['quick.md', 'diagnose-issues.md']) {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', wf),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('query dispatch-isolation'),
|
||||
`${wf}: must resolve ISOLATION via \`gsd_run query dispatch-isolation\` (#2652)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user