fix(#4881): trust worktree.baseRef:"head" in harness mode; a WorktreeCreate hook withholds it and only a measured fork base restores it (#4921)

* fix(#4881): trust worktree.baseRef:"head" in harness mode and keep the #4868 observation as what restores it under a WorktreeCreate hook

The pre-dispatch base check still derived its harness-mode verdict from the
retired #48 premise that the harness never reads worktree.baseRef: with
"head" set and HEAD diverged from origin/HEAD it degraded every wave with
baseref-head-ignored-by-harness. #4868 inserted an observation of a clean
prior harness worktree at HEAD ahead of that comparison, but an
execute-phase run never has one at the moment it checks — the base-check
runs before any dispatch, a degraded wave creates no worktrees, and a wave
that did run in worktrees has them removed and HEAD moved before the next
check — so the common case was unchanged (#4881 repro states 1 and 4).

Re-scope of the closed #4752 onto current next, with #4868 kept:

- branch a trusts "head" in both isolation modes, the way the harness is
  measured to behave (#4588: three settings layers, three OSes), and the
  spawn-time exit-42 guard stays the observation-based backstop;
- a Claude Code WorktreeCreate hook in any settings file the check reads,
  or a file that does not parse, withholds that trust — the hook creates
  the worktree without applying the setting — and the inferred comparison
  runs, degrading with baseref-head-bypassed-by-hook;
- the #4868 observation (b2) now sits behind branch a: it is reached only
  when "head" was not trusted outright, and on a hook host it is what
  restores the trust — a hook that forks from HEAD leaves exactly that
  evidence, one that forks elsewhere never does. Its per-HEAD cache is
  unchanged. It is skipped under an explicit --observed-fork-base, which
  outranks an inference from a prior worktree;
- --observed-fork-base <sha> threads a measured fork base through the
  evaluation (strict full-hex, TypeError otherwise).

Tests: the #4868 rows are unchanged and still reachable (they run with the
setting unset); the #4752 rows re-land, with the one exit-128 row updated
to the degrade #4734 pinned since; a new #4881 block pins the start-of-run
state (baseref-head, git never consulted), the hook + observation
composition in both directions, and that an explicit observation skips
the probe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPXQQPzHGintWtbhBoQCRS

* docs(#4881): rewrite the eight surfaces that still state the harness ignores worktree.baseRef:"head"

Every prose surface #4868 left untouched still asserted the retired #48
premise as verified fact, starting with the step file the orchestrator
reads. Each now describes the measured behaviour, the WorktreeCreate-hook
exception, the --observed-fork-base input, and the #4868 observation as
what lifts the hook degrade; docs/CLI-TOOLS.md gains the
fork-from-head-observed reason row #4868 did not document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPXQQPzHGintWtbhBoQCRS

* chore(#4881): set changeset fragment pr to 4921

* fix(#4881): withhold the #4868 observation under the hook interlock

The hook interlock this PR added withheld the worktree.baseRef:"head"
trust but still let b2's prior-worktree observation restore it, and that
observation cannot be attributed to the hook. The evidence is a clean
agent worktree sitting at the orchestrator HEAD; nothing on disk records
which creator left it there, so one the plain harness created BEFORE a
WorktreeCreate hook was configured — with HEAD unmoved since — reads as
evidence for the hook. It was the one fail-open branch in a mechanism
documented as fail-closed.

Keying the observation cache by hook configuration does not close it.
observeHarnessForkFromHead has two legs: a HEAD-keyed cache and a live
probe over .claude/worktrees/agent-*. A hook-keyed cache simply misses,
and the miss falls through to the probe, which re-finds the same stale
worktree and re-confirms. The probe takes no hook input at all. So the
observation is not consulted under the interlock rather than re-keyed:
on a hook host the only admissible positive signal is an explicit
--observed-fork-base measurement of the dispatch in hand, and absent one
the inferred comparison runs and a mismatch degrades with
baseref-head-bypassed-by-hook, leaving the spawn-time exit-42 guard as
the backstop.

Scoped to the case branch a. declined to trust: "head" set AND a hook
(or an unparseable layer) in the harness's path. With no "head" setting
b2 is #4868's own arm and is unchanged, hook or not — re-scoping that
trust is a separate question this PR does not open, and a test pins the
boundary.

Cost, stated: a hook host with "head" set, on a branch diverged from
origin/HEAD and passing no observation, now runs sequentially. It still
runs parallel when HEAD matches origin/HEAD. No workflow threads an
observation today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ

* refactor(#4881): drop the now-unused forkRef message-builder parameter

buildMsgBaserefHeadIgnored stopped reading forkRef when the message was
made mode-neutral, and the parameter was retained with `void forkRef;`
for symmetry with its two sibling builders, which do read it. Symmetry
is not reason enough to keep a dead parameter on a module-private
function with one caller, so drop it (#4921 review).

Behaviour is unchanged; the message text is pinned by an existing
full-string assertion, which is what covers the only real risk here —
transposing the two remaining arguments at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ

* test(#4881): exercise both FULL_SHA_RE alternatives at their boundaries

The invalid-observation list pinned 39 and 41 hex around the 40-hex
SHA-1 arm but left the 64-hex SHA-256 arm's own +/-1 boundary
unexercised, which the repo's boundary-coverage convention asks for
(#4921 review). Adds 63, 65 and a 64-length non-hex string.

The regex already rejected all three -- this is coverage of correct
behaviour, not a fix -- so it carries no negative control against a
pre-fix base. That it is not vacuous was shown instead by widening the
arm to {63,65}, under which the row fails by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ

* docs(#4881): finish the surface sweep the hook interlock owes

Self-found by this round's own pre-push adversarial review, over six passes.
Eight sites, four classes.

FIVE were prose still asserting the stance the interlock overturned, which
reads as live canon to anyone arriving cold: docs/CLI-TOOLS.md,
docs/CONFIGURATION.md and gsd-core/references/planning-config.md each still
said the hook degrade is lifted "unless/until a clean prior harness worktree is
observed"; observeHarnessForkFromHead's own header still said a qualifying
worktree "can only exist if the harness forked from HEAD"; and a test name
still called the observation "required on a hook host" when it is now
inadmissible there. My own sweep had grepped for "restores"/"lifts" and missed
every one -- the ordinary failure of a grep, which returns what you thought to
search for.

The SIXTH is the same class one step worse: gsd-core/workflows/execute-plan.md
still said flatly that Claude Code's isolation="worktree" "forks from
origin/HEAD, not live local HEAD" -- in a paragraph THIS PR already edits, a
few sentences after the clause it corrected. A tombstone makes only its own
line clean; adjoining text asserting the dead stance is the other half of the
same defect. Now qualified on the setting, with the hook exception named.

The SEVENTH is a proof-strength overstatement that predates this PR, with a
driven counterexample: a worktree created from an older base and since `git
checkout --detach`ed onto HEAD is clean, sits at HEAD, and satisfies the probe
identically, so "can only exist" was false. The worktree's own reflog does
retain that original checkout -- the information is not lost, the probe simply
does not consult it.

The EIGHTH is that the header described only one of the function's two legs. A
cache hit returns the prior conclusion without reading any worktree, so "the
probe reads a worktree's present state" was true of the live probe and false of
the cache. The header now separates them, and names the cache's blindness as a
third reason the observation is inadmissible under a hook.

Gaps seven and eight are inherited from #4868 and accepted there for the
no-hook case. Nothing about the mechanism changes here; only what the header
claims for it.

No behavioural change -- comments, prose, and one test's registered name.

Emitted-Drift-Ack-Growth: execute-plan.md — the Pattern A paragraph gained a qualifying
 clause: it stated flatly that Claude Code forks from origin/HEAD, which is the premise
 this PR retires, a few sentences after the clause the PR had already corrected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuT9wTyrMaxjAPevqeH4YZ

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
0xdhx
2026-09-23 19:15:28 -05:00
committed by GitHub
parent d7b5b2c2b6
commit 238bee7b03
12 changed files with 1292 additions and 174 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4921
---
**`/gsd-execute-phase` waves no longer degrade to sequential on an unmerged branch when `worktree.baseRef:"head"` is set on a Claude Code host** — the pre-dispatch base check assumed the harness ignored the setting, a finding from an older Claude Code that upstream fixed in 2.1.128, and so reported `baseref-head-ignored-by-harness` for every wave whose HEAD differed from `origin/HEAD`. #4868 added an observation of a clean prior harness worktree at HEAD that lifts the degrade, but an `execute-phase` run never has one at the moment it checks, so the common case was unchanged. The check now trusts `"head"` in both isolation modes, the way the harness actually behaves (measured from all three settings layers on macOS, Windows and Linux), and gains a `--observed-fork-base <sha>` input so a measured fork base can replace the `origin/HEAD` inference — a mismatch under `"head"` still degrades and warns. A Claude Code `WorktreeCreate` hook in any of the settings files the check reads, or a settings file that does not parse, withholds that trust, because the hook creates the worktree without applying `worktree.baseRef`: the check then compares against `origin/HEAD` and degrades with `baseref-head-bypassed-by-hook`. The #4868 prior-worktree observation is withheld there rather than consulted: a worktree sitting at HEAD carries no record of which creator left it there, so one the plain harness created before the hook was configured is indistinguishable from one the hook created — on a hook host only `--observed-fork-base` restores a trusted verdict. Without the setting the harness does fork from `origin/HEAD`, and that degrade is unchanged. The spawn-time exit-42 guard remains the backstop on a host that does not honor the setting. (#4881)

View File

@@ -1287,19 +1287,26 @@ Diagnose and configure the worktree fork base used by Claude Code's `isolation="
# Returns JSON: { shouldDegrade, reason, message, headSha, forkRef, forkSha }
node gsd-tools.cjs worktree base-check
# Same check, but against the fork base a worktree this host created was
# actually observed to have (git rev-parse HEAD inside it, before any commit).
node gsd-tools.cjs worktree base-check --observed-fork-base <sha>
# Write worktree.baseRef:"head" into .claude/settings.local.json (no-clobber).
# Returns JSON: { changed, skipped, previous, baseRef, file }
node gsd-tools.cjs worktree set-baseref
```
**`worktree base-check`** reads `worktree.baseRef` from a three-layer cascade — `.claude/settings.local.json`, then `.claude/settings.json`, then the user/global `settings.json` under `CLAUDE_CONFIG_DIR` (or `~/.claude`) — and compares the current `HEAD` SHA against `origin/HEAD`. Project-level settings take precedence over the user/global layer, so a machine-wide `worktree.baseRef:"head"` set via `/config` is honored when no project override exists. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. `--mode` declares who creates the isolated worktree (#3659): `harness-worktree` (the default — the runtime harness forks it and does **not** read project-settings `baseRef`, #48) or `orchestrator-worktree` (GSD itself runs `git worktree add` with an explicit start-point and honors `"head"`); invalid values fail closed with an error. Possible `reason` values:
**`worktree base-check`** reads `worktree.baseRef` from a three-layer cascade — `.claude/settings.local.json`, then `.claude/settings.json`, then the user/global `settings.json` under `CLAUDE_CONFIG_DIR` (or `~/.claude`) — and compares the current `HEAD` SHA against `origin/HEAD`. Project-level settings take precedence over the user/global layer, so a machine-wide `worktree.baseRef:"head"` set via `/config` is honored when no project override exists. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. `--mode` declares who creates the isolated worktree (#3659): `harness-worktree` (the default — the runtime harness forks it) or `orchestrator-worktree` (GSD itself runs `git worktree add` with an explicit start-point). `worktree.baseRef:"head"` is honored by the orchestrator by construction and by the Claude Code harness as measured from all three settings layers (#4588; the #48 finding that the harness did not read the setting predates upstream claude-code#54940); Cursor, the other `harness-worktree` host, is unmeasured. With `"head"` set the check does not compare in either mode unless `--observed-fork-base` is given (below), with one further exception under `harness-worktree` and no observation: when a Claude Code `WorktreeCreate` hook is configured in any of the same three settings files, or one of them does not parse, the hook creates the agent worktree and Claude Code does not apply `worktree.baseRef` to it, so the check compares `HEAD` against `origin/HEAD` as if the setting were absent (#4588). The #4868 prior-worktree observation is **not** consulted on that path: a worktree sitting at `HEAD` records nothing about which creator made it, so one the plain harness left there before the hook was configured would read as evidence for the hook (#4881). Pass `--observed-fork-base` for a trusted verdict on a hook host. Hooks from managed policy settings, a `--settings` file, plugins, agent frontmatter or SDK registrations are not visible to the check; the exit-42 guard remains the backstop for those. `--observed-fork-base <sha>` supplies the fork base a worktree created for a dispatch was actually measured to have (`git rev-parse HEAD` inside it, before any commit — a full 40- or 64-hex sha, case-insensitive; abbreviations are refused because the comparison is exact); when given, it replaces the `origin/HEAD` inference as the fork side of the comparison and `"head"` no longer short-circuits — the verdict then reports a measurement, and a mismatch under `"head"` means the worktree was not forked from HEAD despite the setting. Invalid values for either flag fail closed with an error. Possible `reason` values:
| `reason` | `shouldDegrade` | Meaning |
|---|---|---|
| `baseref-head` | `false` | `worktree.baseRef:"head"` is set and `--mode orchestrator-worktree` declares GSD-managed worktrees — the fork base is the orchestrator HEAD by construction |
| `baseref-head-ignored-by-harness` | `true` | `worktree.baseRef:"head"` is set but HEAD differs from `origin/HEAD` in harness (default) mode — the harness does not read the setting (#48), so the run degrades to sequential (#3659) |
| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit |
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` |
| `baseref-head-bypassed-by-hook` | `true` | `worktree.baseRef:"head"` is set under `harness-worktree` with no fork base observed, but a Claude Code `WorktreeCreate` hook is configured in one of the three settings files (or one of them does not parse, so a hook cannot be ruled out), and `HEAD` differs from the inferred fork base. The hook creates the agent worktree and Claude Code does not apply `worktree.baseRef` to it, so the setting is not trusted; `message` names the file (#4588). The #4868 prior-worktree observation does **not** lift it: a worktree sitting at `HEAD` carries no record of which creator left it there, so one the plain harness created before the hook was configured is indistinguishable from one the hook created (#4881). Only `--observed-fork-base` restores a trusted verdict here. The fallback comparison is not a measurement of the hook: when `HEAD` matches the inferred fork base the check does not degrade, and the exit-42 guard remains the backstop. `--observed-fork-base` gives a measured verdict |
| `baseref-head` | `false` | `worktree.baseRef:"head"` is set and no fork base was observed (and, under `harness-worktree`, no `WorktreeCreate` hook was found in the settings files) — the fork base is the orchestrator HEAD in either mode (by construction under `orchestrator-worktree`; under `harness-worktree` as measured on Claude Code, #4588 — Cursor is unmeasured). The spawn-time `worktree-branch-check` exit-42 guard remains the backstop on a host that does not honor it |
| `baseref-head-ignored-by-harness` | `true` | `worktree.baseRef:"head"` is set but the fork base passed via `--observed-fork-base` differs from HEAD — the worktree was not forked from HEAD despite the setting (in either mode), so the run degrades to sequential (#3659, #4588) |
| `observed-fork-matches-head` | `false` | The fork base passed via `--observed-fork-base` equals HEAD (#4588) |
| `fork-from-head-observed` | `false` | Under `harness-worktree` with no `--observed-fork-base`, a clean linked worktree under `.claude/worktrees/agent-*` sits at the current `HEAD` — positive evidence that this host's worktree creator forks from `HEAD` (#4868). Reached only when `"head"` is absent or not `"head"` — with the setting trusted the check returns `baseref-head` first, and under a `WorktreeCreate` hook with `"head"` set the observation is withheld rather than consulted, because it cannot be attributed to the hook (#4881). Cached per `HEAD` in `.gsd/harness-fork-probe.json`; a dirty worktree, one at another commit, none at all, or a git failure is inconclusive and falls through |
| `head-matches-fork` | `false` | HEAD and the inferred fork base (`origin/HEAD`, or its symbolic-ref fallback such as `origin/next` — reported in `forkRef`) are the same commit |
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from the fork base — the inferred `origin/HEAD` (or its fallback), or the `--observed-fork-base` value when one was given (`forkRef: "observed"`) |
| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved |
| `no-head` | `true` for exit 128, `false` for exit 0 with empty stdout | Exit 128 is git's definitive "no resolvable HEAD here" answer — not a git repository, or a repository with no commits; no harness worktree can be created, so the check degrades to sequential (#4734), with a `message` explaining why. Exit 0 with empty stdout is ambiguous (git completed without a definitive answer) and stays non-degrading (`headAbsenceVerified` distinguishes the two: `true` / `false`) |
| `head-unresolvable` | `true` | `git rev-parse HEAD` did not return a definitive answer (timed out, `git` missing, or any other non-128 failure) — fails closed rather than being treated as `no-head` |

View File

@@ -566,7 +566,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `worktree.baseRef` | string | (unset) | Controls which ref the worktree-based parallel executor uses as the base when creating new phase/wave worktrees. When unset, the executor bases new worktrees on the repository default branch (`origin/HEAD`); if the current branch has diverged, execute-phase auto-degrades to sequential execution rather than halting (as of v1.4.0). Set to `"head"` to base new worktrees on the local `HEAD` instead. **Where it applies (#48/#3659):** honored on runtimes where GSD itself creates the worktrees (Codex, OpenCode, Kimi, Kimi Code) — there it restores wave-based parallel execution on diverged branches. On harness-isolated runtimes (Claude Code, Cursor) the harness does **not** read this setting (verified 5/5 in #48; upstream claude-code#44965): the base check compares against the real fork base regardless and auto-degrades to sequential execution before dispatch when `HEAD` has diverged, so the exit-42 halt is a last-resort backstop rather than the only guard. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). |
| `worktree.baseRef` | string | (unset) | Controls which ref the worktree-based parallel executor uses as the base when creating new phase/wave worktrees. When unset, the executor bases new worktrees on the repository default branch (`origin/HEAD`); if the current branch has diverged, execute-phase auto-degrades to sequential execution rather than halting (as of v1.4.0). Set to `"head"` to base new worktrees on the local `HEAD` instead — this restores wave-based parallel execution on diverged branches. **Where it applies (#3659/#4588):** honored on runtimes where GSD itself creates the worktrees (Codex, OpenCode, Kimi, Kimi Code) by construction, and on Claude Code by its harness — measured from the project-local, project-shared and user/global layers on macOS, Windows and Linux (#4588). Cursor also declares harness-created worktrees but has not been measured; there the check trusts the setting the same way and the exit-42 guard is the backstop. (#48 had found the harness did not read the setting; that was fixed upstream in claude-code#54940, and until #4588 the base check still assumed it, degrading every wave on an unmerged branch regardless of the setting.) With `"head"` set the pre-dispatch base check trusts it and does not compare, with two exceptions: a supplied `--observed-fork-base` is compared against HEAD instead, and on a harness-created run with no observation a Claude Code `WorktreeCreate` hook in one of those three settings files — or one that does not parse — withholds the trust and the check compares against `origin/HEAD` (#4588). The #4868 prior-worktree observation does not lift that: nothing on a worktree records which creator made it, so one the plain harness left at `HEAD` before the hook was configured would read as evidence for the hook — pass `--observed-fork-base` for a trusted verdict there (#4881). The spawn-time exit-42 guard in each executor remains the observation-based backstop on a host that does not honor the setting. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). |
### Executor isolation per runtime

View File

@@ -15,9 +15,9 @@ When you run `/gsd-execute-phase` or `/gsd-quick` on a branch that is ahead of t
```
⚠ Worktree base mismatch: HEAD (abc12345) differs from origin/HEAD (def67890).
Running this phase sequentially on the main working tree. Parallel worktrees
return once HEAD is merged/pushed so origin/HEAD matches it.
(worktree.baseRef:"head" applies only where GSD itself creates the worktree —
the runtime harness does not read it; #48, #3659.)
return once HEAD is merged/pushed so origin/HEAD matches it, or set
worktree.baseRef:"head" to fork worktrees from HEAD instead (honored by
GSD-created worktrees and by the Claude Code harness; #683, #4588).
```
The phase or quick task runs to completion sequentially; nothing is blocked. This is the runtime mitigation (`/gsd-execute-phase`: #683/#1369; `/gsd-quick`: #1941).
@@ -34,7 +34,7 @@ All worktree-isolated executors halt immediately. Zero progress is made.
## Why this happens
Claude Code's `isolation="worktree"` forks executor worktrees from the repository's default branch (`origin/HEAD`), not from your current `HEAD`. When your branch contains commits that `origin/HEAD` does not have — plan files, new source files, anything added since the branch diverged — those files are absent inside each worktree. GSD's `worktree-branch-check` safety guard correctly refuses to act on a worktree that does not match the orchestrator's state, and exits with code 42.
Unless `worktree.baseRef` is set to `"head"`, Claude Code's `isolation="worktree"` forks executor worktrees from the repository's default branch (`origin/HEAD`), not from your current `HEAD`. When your branch contains commits that `origin/HEAD` does not have — plan files, new source files, anything added since the branch diverged — those files are absent inside each worktree. GSD's `worktree-branch-check` safety guard correctly refuses to act on a worktree that does not match the orchestrator's state, and exits with code 42.
This is the guard working as designed: it prevents silent data loss or phantom edits in the wrong tree. The error is a branch-state condition, not an OS-specific or hardware issue.
@@ -52,20 +52,23 @@ Use this option when:
---
## Option 2 — Where `worktree.baseRef: "head"` actually applies (runtimes where GSD creates the worktrees)
## Option 2 — Set `worktree.baseRef: "head"` (restores parallel execution on a diverged branch)
**What this setting can and cannot do (#48, #3659):** on runtimes whose own harness creates isolated
worktrees (Claude Code's `Agent(isolation="worktree")`), the harness forks from the repository
default branch and **does not read project-settings `baseRef`** — verified 5/5 in #48; tracked
upstream at claude-code#44965/#43535. On those runtimes, setting `baseRef:"head"` does **not**
restore parallel worktrees on a diverged branch, and since #3659 it no longer silences the
pre-dispatch check: GSD compares `HEAD` against the real fork base and auto-degrades to sequential
execution before dispatch instead of letting every executor die at the exit-42 guard.
**What this setting does (#3659, #4588):** it makes new worktrees fork from your current `HEAD`
instead of `origin/HEAD`, so the files your branch added are present inside each executor
worktree and the wave can run in parallel. It is honored by GSD-created worktrees by construction
and, as measured, by Claude Code's harness:
The setting **is** honored where GSD itself runs `git worktree add <path> <start-point>` — the
orchestrator-managed isolation used on runtimes with a headless exec surface (Codex, OpenCode,
Kimi, Kimi Code). There `baseRef:"head"` really does fork from your current `HEAD`, the check
suppresses on it (`reason: "baseref-head"`), and parallel execution works on any branch.
- **Runtimes where GSD itself runs `git worktree add <path> <start-point>`** (Codex, OpenCode,
Kimi, Kimi Code) — by construction; the check suppresses on it (`reason: "baseref-head"`).
- **Claude Code's `Agent(isolation="worktree")`** — because the harness reads the setting and
forks from `HEAD`. This was measured on current Claude Code from the project-local,
project-shared and user/global settings layers on macOS, Windows and Linux (#4588). Cursor,
the other runtime whose harness creates the worktree, has not been measured — see below. An earlier finding that the harness did *not* read it (#48,
verified against the Claude Code of the time; upstream claude-code#44965) was fixed upstream in
claude-code#54940, but until #4588 GSD's pre-dispatch check still assumed it and degraded every
wave on an unmerged branch regardless of the setting — the `baseref-head-ignored-by-harness`
reason you may have seen on older installs.
Run the convenience command from your project root:
@@ -75,18 +78,51 @@ node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref
This writes `worktree.baseRef: "head"` into `.claude/settings.local.json` in your project root. It is no-clobber: if you already have an explicit `baseRef` set to something else, it leaves your value in place and tells you.
To verify the result on a harness-isolated runtime (Claude Code, Cursor), pass the dispatch mode so
the check evaluates the base the harness will actually use:
To verify the result, pass the dispatch mode the runtime uses:
```bash
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree base-check --mode harness-worktree
```
The output is JSON. On a diverged branch expect `shouldDegrade: true` with
`reason: "baseref-head-ignored-by-harness"` — GSD will run the phase sequentially; parallel
worktrees return once `HEAD` is merged/pushed so `origin/HEAD` matches it. On a GSD-managed runtime,
`--mode orchestrator-worktree` returns `shouldDegrade: false` with `reason: "baseref-head"` and
parallel worktrees work on any branch.
The output is JSON. With the setting in place expect `shouldDegrade: false` with
`reason: "baseref-head"` in either mode — the check does not compare `HEAD` against
`origin/HEAD` when the fork base is `HEAD` by configuration. Without the setting, on a diverged
branch, expect `shouldDegrade: true` with `reason: "head-diverged-from-fork"`.
**If you configure a Claude Code `WorktreeCreate` hook**, the setting does not reach the worktrees
Claude Code dispatches: the hook creates them from the directory it emits, and Claude Code does not
apply `worktree.baseRef` on that path. The check looks for such a hook in the same three settings
files it reads `worktree.baseRef` from. When it finds one, or when one of those files does not
parse, it compares `HEAD` against `origin/HEAD` as if the setting were absent, and a mismatch
returns `shouldDegrade: true` with `reason: "baseref-head-bypassed-by-hook"` and a message naming
the file (#4588). That fallback is the comparison the check makes without the setting, and it does
not know where your hook forks either: when `HEAD` matches `origin/HEAD` the check does not degrade,
and the exit-42 guard below still catches a hook that forks elsewhere. A hook cannot prove itself by
leaving a worktree behind: the #4868 observation looks for a clean agent worktree under
`.claude/worktrees/` sitting at the current `HEAD`, but nothing on disk records *which* creator left
it there, so one the plain harness created before you configured the hook — with `HEAD` unmoved
since — would read as evidence for the hook. The check therefore does not consult that observation
once a hook is in the path (#4881). To get a trusted verdict, pass the commit a hook-created
worktree starts at as `--observed-fork-base` (described next), or remove the hook. Hooks from managed policy settings, a `--settings` file, plugins, agent frontmatter or SDK
registrations are not visible to the check; there the exit-42 guard below is the backstop.
If you have a real measurement of what a worktree on this host forked from (`git rev-parse HEAD`
inside a freshly created isolated worktree, before any commit — the full sha, not an abbreviation),
pass it and the check evaluates that instead of inferring:
```bash
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree base-check --observed-fork-base <sha>
```
A match returns `reason: "observed-fork-matches-head"`; a mismatch with `"head"` set returns
`reason: "baseref-head-ignored-by-harness"` — that worktree was not forked from HEAD despite the
setting — and the run degrades to sequential as before.
**Measured on Claude Code only.** Cursor also declares harness-created worktrees; whether it honors
`worktree.baseRef` has not been measured. There the pre-dispatch check trusts the setting the same
way, and a host that does not honor it is caught by the exit-42 guard below. If you are on Cursor
and see exit 42 with the setting in place, the SHA the halted executor prints is exactly the
observation to pass here, and worth reporting.
Alternatively, set the value by hand in `.claude/settings.local.json`:
@@ -98,11 +134,11 @@ Alternatively, set the value by hand in `.claude/settings.local.json`:
}
```
**Note:** Fresh installs and upgrades of GSD Core both set `worktree.baseRef:"head"` automatically in `.claude/settings.local.json` (no-clobber) when `workflow.use_worktrees` is enabled (the default). This remains useful for GSD-managed runtimes and harmless elsewhere — post-#3659 it never silences the check on harness-managed ones.
**Note:** Fresh installs and upgrades of GSD Core both set `worktree.baseRef:"head"` automatically in `.claude/settings.local.json` (no-clobber) when `workflow.use_worktrees` is enabled (the default). If you see the degrade warning on a fresh install, check that the key is still present — a hand-edited `settings.local.json` is the usual reason it is not.
Use this option when:
- Your runtime uses GSD-managed worktrees (Codex, OpenCode, Kimi, Kimi Code) and you regularly work on long-lived or milestone branches
- You regularly work on long-lived or milestone branches, on any worktree-capable runtime
- You want parallel phase execution there (faster, lower context-window pressure)
---
@@ -135,7 +171,7 @@ See also: [`workflow.use_worktrees`](../CONFIGURATION.md#workflow-toggles) in th
## The exit-42 backstop
The `worktree-branch-check` guard (exit 42) remains active in all execution modes as a safety backstop. It fires only when an executor worktree's branch does not match the expected orchestrator state — a condition that should not arise once you have applied one of the options above. If you continue to see exit 42 after setting `worktree.baseRef: "head"`, run `/gsd-forensics` to investigate.
The `worktree-branch-check` guard (exit 42) remains active in all execution modes as a safety backstop. It fires only when an executor worktree's branch does not match the expected orchestrator state — a condition that should not arise once you have applied one of the options above. It is also the observation-based check behind Option 2: with `worktree.baseRef: "head"` set, the pre-dispatch check trusts the setting and does not compare, so a host that does *not* honor it is caught here — the mismatched executors of that dispatch halt (every one of them, in a concurrent wave), and nothing is merged. If you continue to see exit 42 after setting `worktree.baseRef: "head"`, your runtime is forking from somewhere other than `HEAD`; run `/gsd-forensics` to investigate, and pass the SHA the halted executor printed as `--observed-fork-base` to `worktree base-check` to see the verdict GSD would reach with that measurement.
---

View File

@@ -24,12 +24,13 @@
# Unset per-wave manifest so wave N+1 creates a fresh one (#3384, #1369).
unset WAVE_WORKTREE_MANIFEST
# Between-wave base re-check (#1369, #3659): after wave N merges and tracking commits,
# HEAD has advanced. Re-asserting worktree.baseRef:"head" is deliberately NOT done here —
# the runtime harness does not read project-settings baseRef (#48), so in
# harness-worktree mode the setting cannot influence the fork base. The safety re-check
# below compares HEAD against the REAL fork base and degrades the remaining waves
# whenever they diverge, avoiding the base-mismatch FATAL in executor agents.
# Between-wave base re-check (#1369, #3659, #4588): after wave N merges and tracking
# commits, HEAD has advanced. Re-asserting worktree.baseRef:"head" is NOT done here — the
# setting is either already in place (honored by GSD-created worktrees and by the Claude
# Code harness, #4588) or
# deliberately absent; a re-write would not change the fork base. The safety re-check
# below compares HEAD against the fork base and degrades the remaining waves whenever
# they diverge, avoiding the base-mismatch FATAL in executor agents.
if [ "$ISOLATION" = "harness-worktree" ] && [ "$USE_WORKTREES" != "false" ]; then
_BETWEEN_DEGRADE=$(gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade 2>/dev/null || echo "false")
if [ "$_BETWEEN_DEGRADE" = "true" ]; then

View File

@@ -1,10 +1,10 @@
0.5. **Inter-wave worktree base re-check (wave N+1 guard — #1369):**
After Wave N merges and tracking commits advance orchestrator HEAD, Claude Code's
`isolation="worktree"` still forks new worktrees from `origin/HEAD` (the "fresh" base),
not the live HEAD. This means Wave N+1 worktrees would be created from the stale
pre-Wave-N base, causing the `worktree_branch_check` guard inside each executor to halt
immediately with a base-mismatch fatal.
`isolation="worktree"` forks new worktrees from `origin/HEAD` (the "fresh" base) unless
`worktree.baseRef:"head"` is set — not the live HEAD. Without the setting, Wave N+1
worktrees would be created from the stale pre-Wave-N base, causing the
`worktree_branch_check` guard inside each executor to halt with a base-mismatch fatal.
**Run this check at the start of every wave when `USE_WORKTREES != "false"` and
`ISOLATION = "harness-worktree"`** (#2652 — the harness caches the fork base, so this is a
@@ -30,10 +30,21 @@
this check and may re-enable worktree isolation once `origin/HEAD` matches HEAD again
(e.g. via `git fetch` or a push that advances it).
**Why `worktree.baseRef:"head"` does not avoid this degrade (#48, #3659):** the runtime
harness does not read project-settings `baseRef` — an isolated dispatch always forks from
`origin/HEAD` regardless of the setting, so the check compares against the real fork base
and degrades whenever HEAD has diverged. Parallel worktrees return once HEAD is
merged/pushed so `origin/HEAD` matches it. The setting still restores parallel execution
on runtimes where GSD itself creates the worktrees (orchestrator-managed isolation:
Codex, OpenCode, Kimi, Kimi Code). See #683 for the base-ref configuration detail.
**How `worktree.baseRef:"head"` interacts with this degrade (#3659, #4588):** with the
setting in place the check trusts it and does not compare — the worktree creator forks from the
orchestrator HEAD (GSD's own `git worktree add` by construction; the Claude Code harness as
measured from all three settings layers, #4588; Cursor unmeasured), so outside the two
exceptions below this guard only fires when the setting
is absent and HEAD has diverged from `origin/HEAD`. Parallel worktrees then return once HEAD
is merged/pushed so `origin/HEAD` matches it, or once the setting is applied. The exceptions
(#4588): a supplied `--observed-fork-base` is compared against HEAD instead of trusted, in
both modes; and on a harness-created run with no observation, a Claude Code `WorktreeCreate`
hook in any of those settings files — or one of them that does not parse, so a hook cannot be
ruled out — withholds the trust: the hook creates the worktree without applying the setting,
so the check compares against `origin/HEAD` anyway and a mismatch degrades with
`baseref-head-bypassed-by-hook`. The #4868 prior-worktree observation does not lift that
degrade: a worktree at HEAD records nothing about which creator left it there, so one the
plain harness created before the hook was configured reads the same as one the hook created
(#4881). On a hook host only `--observed-fork-base` restores a trusted verdict. The exit-42
guard in each executor remains the backstop on a host that does not honor the setting.
See #683 for the base-ref configuration detail.

View File

@@ -440,7 +440,7 @@ Several config fields affect each other or trigger special behavior:
8. **`sub_repos` auto-sync** -- On every config load, GSD scans for child directories with `.git` and updates the `sub_repos` array if the filesystem has changed. Legacy `multiRepo: true` is automatically migrated to a detected `sub_repos` array.
9. **`workflow.use_worktrees` and branch divergence** -- When `use_worktrees` is `true` (default), executor worktrees are forked from `origin/HEAD` -- by the host's own harness on `dispatch.isolation: harness-worktree` runtimes (Claude Code, Cursor), or by GSD itself on `orchestrator-worktree` runtimes (Codex, OpenCode, Kimi, Kimi Code). The divergence behavior below is identical either way, because the fork base is a property of the repository rather than of whoever creates the worktree. If your current branch has commits that `origin/HEAD` does not (for example an unmerged milestone or feature branch), GSD automatically degrades to sequential execution for that run and prints a one-line `⚠ Worktree base mismatch` warning. To restore parallel execution permanently, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `gsd_run worktree set-baseref`). This makes the harness fork worktrees from the live HEAD instead of `origin/HEAD`. Both fresh installs and upgrades of GSD Core set this automatically (no-clobber) when `use_worktrees` is enabled; you can also run the command manually at any time. Setting `workflow.use_worktrees: false` is the alternative if worktrees are not needed at all. On a runtime whose declared `dispatch.isolation` is `none`, an explicit `true` is a config the execution workflows fail closed on; `/gsd:health` reports it as warning `W025` and `/gsd:settings` offers to repair it (#2486).
9. **`workflow.use_worktrees` and branch divergence** -- When `use_worktrees` is `true` (default) and `worktree.baseRef` is unset, executor worktrees are forked from `origin/HEAD` -- by the host's own harness on `dispatch.isolation: harness-worktree` runtimes (Claude Code, Cursor), or by GSD itself on `orchestrator-worktree` runtimes (Codex, OpenCode, Kimi, Kimi Code). The divergence behavior below is identical either way, because the fork base is a property of the repository rather than of whoever creates the worktree. If your current branch has commits that `origin/HEAD` does not (for example an unmerged milestone or feature branch), GSD automatically degrades to sequential execution for that run and prints a one-line `⚠ Worktree base mismatch` warning. To restore parallel execution permanently, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `gsd_run worktree set-baseref`). GSD-created worktrees then fork from the live HEAD instead of `origin/HEAD` by construction, and the Claude Code harness does the same as measured from every settings layer (#4588; Cursor has not been measured — its exit-42 spawn-time guard is the backstop). A configured Claude Code `WorktreeCreate` hook is the exception: it creates the worktree without applying the setting, so GSD keeps comparing against `origin/HEAD` on such a host (#4588). A prior harness worktree at the current HEAD does not lift that comparison — it records no creator, so one left there before the hook was configured would read as evidence for the hook; pass `--observed-fork-base` for a trusted verdict (#4881). Both fresh installs and upgrades of GSD Core set this automatically (no-clobber) when `use_worktrees` is enabled; you can also run the command manually at any time. Setting `workflow.use_worktrees: false` is the alternative if worktrees are not needed at all. On a runtime whose declared `dispatch.isolation` is `none`, an explicit `true` is a config the execution workflows fail closed on; `/gsd:health` reports it as warning `W025` and `/gsd:settings` offers to repair it (#2486).
---

View File

@@ -7,10 +7,19 @@ branch). This runs for **any** isolated run, not only Claude: fork-base divergen
of the repository, so it degrades a GSD-created worktree exactly as a harness-created one. The
auto-degrade prints a one-line warning to stderr and falls through to the sequential path so
executors do not hit the exit-42 worktree-branch-check halt. Setting `worktree.baseRef:"head"`
restores parallel execution only where GSD itself creates the worktrees (orchestrator-managed
runtimes — Codex, OpenCode, Kimi, Kimi Code); harness-isolated runtimes (Claude Code, Cursor) do
not read the setting (#48, verified 5/5; upstream claude-code#44965), so there the check compares
against the real fork base and parallel execution returns once HEAD is merged/pushed so
`origin/HEAD` matches it (#3659). The `worktree-branch-check` exit-42 guard inside each executor
remains in place as a backstop.
restores parallel execution on both isolation models: GSD-created worktrees (Codex, OpenCode,
Kimi, Kimi Code) fork from the orchestrator HEAD by construction, and harness-created ones do so
where the harness honors the setting — measured on Claude Code from all three settings layers
(#4588; #48's earlier finding that it did not predates upstream claude-code#54940), unmeasured on
Cursor. On a harness-created run with no observation, a Claude Code `WorktreeCreate` hook in any
of those three settings files, or one of them that does not parse, withholds that trust and the
check compares against `origin/HEAD` as before. The #4868 prior-worktree observation is withheld
there rather than consulted — a worktree at HEAD does not record which creator left it, so one the
plain harness created before the hook reads the same as one the hook created (#4881), and only
`--observed-fork-base` restores a trusted verdict. A supplied `--observed-fork-base` is never trusted
either: HEAD is compared against the observation, in both modes. Without the setting the fork
base is `origin/HEAD` and parallel execution returns once HEAD is merged/pushed so it matches
(#3659). The `worktree-branch-check` exit-42 guard inside each executor remains in place as the
backstop on a host that does not honor the setting; step 0.5 of
`gsd-core/references/execute-phase-wave-guard.md` carries the measurement detail.
</step>

View File

@@ -142,7 +142,7 @@ Otherwise: Apply checkpoint-based routing below.
> **Runtime-aware dispatch (#2508 Phase 4).** GSD workflows dispatch specialized subagents by role. Before dispatching on a built-in-only runtime (kimi-code — three built-ins only), resolve the role to a built-in via `gsd_run query resolve-dispatch-type --requested <role> --raw`. On named-dispatch runtimes (Claude/OpenCode/…) the role is returned unchanged; on kimi-code it maps to `coder`/`explore`/`plan` by role-suffix. The persona rides `${AGENT_SKILLS_<ROLE>}` (Phase 3) regardless. See @gsd-core/references/runtime-aware-dispatch.md.
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → **before spawning, run the #2649 pre-dispatch worktree base-check** (mirrors execute-phase #683/#1369 and quick #1941): if `ISOLATION = "harness-worktree"`, run `gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade` (#3659: the mode is what stops `worktree.baseRef:"head"` from suppressing the comparison — the harness does not honor that setting); if it returns `true`, print its `--pick message` to stderr, emit the `⚠ [#2649] Worktree fork base diverged from orchestrator HEAD — auto-degrading to sequential mode for this plan to avoid a base-mismatch halt.` warning, and treat `ISOLATION` as `"none"` for this dispatch (spawn without `{harnessFlag}`), **then re-record the degrade before spawning** — run `gsd_run query dispatch-isolation --raw --force-isolation none >/dev/null 2>&1 || true`. That re-record is mandatory, not bookkeeping: the resolve step already persisted `harness-worktree` to the run-scoped sentinel, the degrade above happens where the resolver cannot see it, and the shipped `PreToolUse` isolation guard (#3045) reads that sentinel at the instant of the `Agent()` call — a stale `harness-worktree` against a dispatch that correctly omits `{harnessFlag}` is denied with `exit 2`, so the plan does not run at all. See `Re-record after every degrade` in `gsd-core/references/dispatch-isolation-gate.md`. Claude Code's `isolation="worktree"` forks from `origin/HEAD`, not live local HEAD; without this gate a plan whose commit advanced local HEAD past a stale `origin/HEAD` hits the verify-only guard's `exit 42` mid-execution with no auto-degrade. → print `Spawning executor agent (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` → spawn Agent(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, honor checkpoint gate semantics (#3370) — gate="blocking" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate="blocking-human" always surfaces to a human; add no instruction overriding that protocol — report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `{harnessFlag}` only when `ISOLATION = "harness-worktree"` and the #2649 base-check did not degrade** — never hardcode `isolation="worktree"`, which is Claude Code's own literal and wrong on any other harness-worktree host. **When dispatching with `{harnessFlag}`, embed the `<worktree_branch_check>` block from `gsd-core/references/worktree-branch-check.md` into the prompt, substituting `{EXPECTED_BASE}` with the captured base SHA.** That guard is **verify-only and fail-closed** (#48) and stays active as a backstop whether or not the base-check degraded: it asserts a per-agent `agent-*` / `worktree-agent-*` branch and the exact base, forbids `git update-ref` self-recovery (#2924), and on any mismatch prints `FATAL:` and `exit 42` so the orchestrator can recover — the sub-agent never rewrites a worktree it did not create. This supersedes the former self-recovery (#2015), whose destructive base rewrite could fail silently under a deny rule; the base-drift it addressed affects all platforms, and base correction is now the orchestrator's responsibility.
**Pattern A:** init_agent_tracking → capture `EXPECTED_BASE=$(git rev-parse HEAD)` → **before spawning, run the #2649 pre-dispatch worktree base-check** (mirrors execute-phase #683/#1369 and quick #1941): if `ISOLATION = "harness-worktree"`, run `gsd_run query worktree.base-check --mode "$ISOLATION" --pick shouldDegrade` (#3659/#4588: `--mode` names the creator; `"head"` suppresses the check — honored by GSD worktrees and, measured, by Claude Code); if it returns `true`, print its `--pick message` to stderr, emit the `⚠ [#2649] Worktree fork base diverged from orchestrator HEAD — auto-degrading to sequential mode for this plan to avoid a base-mismatch halt.` warning, and treat `ISOLATION` as `"none"` for this dispatch (spawn without `{harnessFlag}`), **then re-record the degrade before spawning** — run `gsd_run query dispatch-isolation --raw --force-isolation none >/dev/null 2>&1 || true`. That re-record is mandatory, not bookkeeping: the resolve step already persisted `harness-worktree` to the run-scoped sentinel, the degrade above happens where the resolver cannot see it, and the shipped `PreToolUse` isolation guard (#3045) reads that sentinel at the instant of the `Agent()` call — a stale `harness-worktree` against a dispatch that correctly omits `{harnessFlag}` is denied with `exit 2`, so the plan does not run at all. See `Re-record after every degrade` in `gsd-core/references/dispatch-isolation-gate.md`. With `worktree.baseRef` unset, Claude Code's `isolation="worktree"` forks from `origin/HEAD`, not live local HEAD; without this gate a plan whose commit advanced local HEAD past a stale `origin/HEAD` hits the verify-only guard's `exit 42` mid-execution with no auto-degrade. Setting `"head"` is what changes that, and the harness is measured to honor it (#4588) — except where a `WorktreeCreate` hook creates the worktree, which the base-check detects and reports separately (#4881). → print `Spawning executor agent (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)` → spawn Agent(subagent_type="gsd-executor", model=executor_model) with prompt: execute plan at [path], autonomous, all tasks + SUMMARY + commit, follow deviation/auth rules, honor checkpoint gate semantics (#3370) — gate="blocking" (the default) is auto-approvable in auto-mode per the executor's own checkpoint protocol, gate="blocking-human" always surfaces to a human; add no instruction overriding that protocol — report: plan name, tasks, SUMMARY path, commit hash → track agent_id → wait → update tracking → report. **Include `{harnessFlag}` only when `ISOLATION = "harness-worktree"` and the #2649 base-check did not degrade** — never hardcode `isolation="worktree"`, which is Claude Code's own literal and wrong on any other harness-worktree host. **When dispatching with `{harnessFlag}`, embed the `<worktree_branch_check>` block from `gsd-core/references/worktree-branch-check.md` into the prompt, substituting `{EXPECTED_BASE}` with the captured base SHA.** That guard is **verify-only and fail-closed** (#48) and stays active as a backstop whether or not the base-check degraded: it asserts a per-agent `agent-*` / `worktree-agent-*` branch and the exact base, forbids `git update-ref` self-recovery (#2924), and on any mismatch prints `FATAL:` and `exit 42` so the orchestrator can recover — the sub-agent never rewrites a worktree it did not create. This supersedes the former self-recovery (#2015), whose destructive base rewrite could fail silently under a deny rule; the base-drift it addressed affects all platforms, and base correction is now the orchestrator's responsibility.
**Pattern B:** Execute segment-by-segment. Autonomous segments: spawn subagent for assigned tasks only (no SUMMARY/commit). Checkpoints: main context. After all segments: aggregate, create SUMMARY, commit. See segment_execution. **Segments run unisolated on the main working tree by design** — each continues where the previous one stopped — so dispatch them WITHOUT `{harnessFlag}`, and only after the `ISOLATION=none` re-record above has run (#2652/#3045).

View File

@@ -92,19 +92,76 @@ type ExecGitFn = typeof execGitSeam;
// never reaches this check).
type BaseCheckIsolationMode = 'harness-worktree' | 'orchestrator-worktree';
/**
* A settings layer that defeats the `worktree.baseRef:"head"` trust (#4588): either it
* declares a Claude Code `WorktreeCreate` hook (`kind: 'hook'`), or it exists but does not
* parse, so a hook in it cannot be ruled out (`kind: 'unparseable'`). `file` is the path.
*/
export type WorktreeCreateHookFinding = { file: string; kind: 'hook' | 'unparseable' };
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
// The fork side of the comparison is either an inferred ref (`origin/HEAD`,
// `origin/next`, …) or — when the caller supplies `observedForkBase` — the
// literal label below, meaning "the base a worktree this host created was
// measured to have" (#4588). Messages read the label to phrase the remedy.
const FORK_REF_OBSERVED = 'observed';
function describeForkRef(forkRef: string | null): string {
return forkRef === FORK_REF_OBSERVED ? 'the observed fork base' : String(forkRef);
}
// An observation is a fixed measurement of one past dispatch: pushing cannot change it,
// so the remedy for an observed mismatch is a fresh dispatch (a new observation), never
// "push until the observation matches". The inferred fork base (origin/HEAD) does move
// with a push, so that remedy stays for the inferred case.
function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so ${forkRef} matches it. (worktree.baseRef:"head" applies only where GSD itself creates the worktree — the runtime harness does not read it; #48, #3659.)`;
const fork = describeForkRef(forkRef);
const remedy = forkRef === FORK_REF_OBSERVED
? 'Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it'
: `Parallel worktrees return once HEAD is merged/pushed so ${fork} matches it`;
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${fork} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. ${remedy}, or set worktree.baseRef:"head" to fork worktrees from HEAD instead (honored by GSD-created worktrees and by the Claude Code harness; #683, #4588).`;
}
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. Parallel worktrees return once origin/HEAD resolves and matches HEAD. See #683, #3659.`;
function buildMsgBaserefHeadIgnored(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
return `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but the runtime harness does not honor it for isolated dispatch — the fork base stays ${forkRef} (${shortSha(forkSha)}) while HEAD is ${shortSha(headSha)} (#48; upstream claude-code#44965). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so ${forkRef} matches it, or on runtimes where GSD itself manages worktree creation. See #3659.`;
// Mode-neutral on purpose: the observation can come from a harness-created OR a
// GSD-created worktree, and the message must not attribute the miss to "the harness"
// when GSD's own `git worktree add` was the creator (P4.6 review, 2026-09-14).
function buildMsgBaserefHeadIgnored(headSha: string | null, forkSha: string | null): string {
return `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but a worktree created for this dispatch was observed to fork from ${shortSha(forkSha)} while HEAD is ${shortSha(headSha)} — the worktree was not forked from HEAD despite the setting. Running this phase sequentially on the main working tree. Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it. See #3659, #4588.`;
}
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Note: worktree.baseRef:"head" silences this check only where GSD itself creates the worktree (orchestrator-managed runtimes) — in harness mode it never applied (#48, #3659). Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
// Names the hook and its file, never "the harness": the user configured the hook, so the
// actionable remedy is theirs. An unparseable layer is phrased as "cannot be ruled out",
// because the check does not know a hook is there — it only cannot prove one is not.
// It deliberately promises nothing about pushing: neither HEAD nor the inferred fork base
// says where a hook forks, so the only measured way back to a trusted verdict is an
// observation (--observed-fork-base) or removing the cause (#4588 round review).
function buildMsgBaserefHeadHookBypass(
headSha: string | null,
forkRef: string | null,
forkSha: string | null,
finding: WorktreeCreateHookFinding
): string {
const fork = describeForkRef(forkRef);
const cause = finding.kind === 'hook'
? `a Claude Code WorktreeCreate hook is configured in ${finding.file}`
: `${finding.file} could not be parsed, so a Claude Code WorktreeCreate hook in it cannot be ruled out`;
const remove = finding.kind === 'hook'
? 'remove the hook'
: `fix ${finding.file} so it parses`;
return `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but ${cause}. A WorktreeCreate hook creates Claude Code's agent worktrees itself and Claude Code does not apply worktree.baseRef to them, so the setting is not trusted. Without it the check can only compare HEAD (${shortSha(headSha)}) against ${fork} (${shortSha(forkSha)}), and they differ. Running this phase sequentially on the main working tree. Neither ref says where the hook forks: for a measured verdict, pass the commit a hook-created worktree starts at as --observed-fork-base, or ${remove}. See #4588.`;
}
// A commit sha as `git rev-parse HEAD` prints it: 40 hex (SHA-1) or 64 hex (SHA-256).
// Abbreviated shas are refused rather than prefix-matched — the comparison below is
// exact, and an abbreviation that can never equal the full HEAD would silently always
// degrade (P4.6 review, 2026-09-14). Case is folded because the comparison is exact.
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const MSG_OBSERVED_FORK_BASE_INVALID = 'observedForkBase must be a full 40- or 64-hex commit sha (git rev-parse HEAD inside the worktree, before any commit)';
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
const MSG_NO_GIT_REPOSITORY = `⚠ No worktree base exists here (git resolved no HEAD — the root is not a git repository, or the repository has no commits), so a harness worktree cannot be created. Running this dispatch sequentially on the main working tree instead — no isolation flag is required. See #4734.`;
@@ -240,6 +297,70 @@ export function resolveEffectiveBaseRef(
return null;
}
/**
* Looks for a Claude Code `WorktreeCreate` hook in the settings layers that
* resolveEffectiveBaseRef reads (#4588). Such a hook replaces the harness's own worktree
* creation: the agent worktree is whatever directory the hook emits, and Claude Code does
* not consult `worktree.baseRef` on that path. So on a host that configures one, `"head"`
* says nothing about where a harness-created worktree forks from.
*
* Claude Code merges hooks across layers, so every layer is checked — not only the one
* that supplied `baseRef`. Layers, in the same order and with the same user/global
* de-duplication as resolveEffectiveBaseRef:
* 1. <claudeDir>/settings.local.json
* 2. <claudeDir>/settings.json
* 3. <userClaudeDir>/settings.json (only when provided and a different directory)
*
* Returns the first finding in that order, or null:
* - kind 'hook' — `hooks.WorktreeCreate` is present and not an empty list.
* - kind 'unparseable' — the file exists but is not valid JSON/JSONC. Fails closed: a hook
* in it cannot be ruled out, and a false degrade costs a sequential
* wave where false trust costs every executor halting at exit 42.
* A layer deps.readFile reports as null (absent or unreadable) is skipped, exactly as in
* resolveEffectiveBaseRef, and so is a whitespace-only file, which cannot declare a hook.
*
* Settings files are the only hook sources readable from here. Claude Code also takes
* hooks from managed policy settings, a --settings file, plugins, agent frontmatter and
* SDK registrations; those stay invisible to this check, and the spawn-time exit-42 guard
* remains the backstop for them.
*/
export function findWorktreeCreateHook(
claudeDir: string,
deps?: { readFile?: (p: string) => string | null },
userClaudeDir?: string | null
): WorktreeCreateHookFinding | null {
const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => {
try {
return fs.readFileSync(p, 'utf8');
} catch {
return null;
}
});
const layers = [path.join(claudeDir, 'settings.local.json'), path.join(claudeDir, 'settings.json')];
if (userClaudeDir && path.resolve(userClaudeDir) !== path.resolve(claudeDir)) {
layers.push(path.join(userClaudeDir, 'settings.json'));
}
for (const file of layers) {
const contents = readFile(file);
if (contents == null || contents.trim() === '') continue;
let parsed: unknown;
try {
parsed = parseJsonc(contents);
} catch {
return { file, kind: 'unparseable' };
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
const hooks = (parsed as Record<string, unknown>).hooks;
if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) continue;
const entry = (hooks as Record<string, unknown>).WorktreeCreate;
if (entry == null || (Array.isArray(entry) && entry.length === 0)) continue;
return { file, kind: 'hook' };
}
return null;
}
/**
* CLI command: check current worktree base-ref degradation status.
*
@@ -268,6 +389,20 @@ export function cmdWorktreeBaseCheck(
}
isolationMode = value;
}
// --observed-fork-base <sha> threads a measured fork base through to the
// evaluation (#4588): what `git rev-parse HEAD` returned inside a worktree
// this host created, before any commit. Same fail-closed shape as --mode —
// a malformed or missing value throws rather than silently falling back to
// the inference the flag exists to replace.
let observedForkBase: string | null = null;
const observedIdx = args.indexOf('--observed-fork-base');
if (observedIdx !== -1) {
const value = args[observedIdx + 1];
if (typeof value !== 'string' || !FULL_SHA_RE.test(value.trim().toLowerCase())) {
throw new Error(`worktree base-check: --observed-fork-base: ${MSG_OBSERVED_FORK_BASE_INVALID}, got ${JSON.stringify(value ?? null)}`);
}
observedForkBase = value.trim().toLowerCase();
}
const claudeDir = path.join(cwd, '.claude');
const userClaudeDir = Object.prototype.hasOwnProperty.call(deps ?? {}, 'userClaudeDir')
? (deps as { userClaudeDir?: string | null }).userClaudeDir
@@ -277,11 +412,19 @@ export function cmdWorktreeBaseCheck(
deps?.readFile ? { readFile: deps.readFile } : undefined,
userClaudeDir
);
// The WorktreeCreate-hook interlock (#4588) only matters where the evaluation would
// otherwise trust "head" without comparing: harness-created worktrees and no
// observation. Skip the settings reads everywhere else.
const worktreeCreateHook = effectiveBaseRef === 'head' && isolationMode === 'harness-worktree' && observedForkBase === null
? findWorktreeCreateHook(claudeDir, deps?.readFile ? { readFile: deps.readFile } : undefined, userClaudeDir)
: null;
const result = evaluateWorktreeBaseDegrade({
cwd,
effectiveBaseRef,
execGit: deps?.execGit,
isolationMode,
observedForkBase,
worktreeCreateHook,
});
// Default emit goes through fs.writeSync(1, …), NOT process.stdout.write:
// the CLI's --pick capture intercepts writeSync, and command substitution
@@ -423,16 +566,47 @@ export function classifyGitHead(deps?: {
* #4588 (decision A2) — observe, from documented git metadata, whether the
* harness forks its worktrees from the orchestrator HEAD.
*
* Substrate: the harness's own prior worktrees. A linked worktree under
* Substrate: the harness's own prior worktrees. TWO LEGS answer the question —
* a cache read, then a live probe on a miss — and they are not the same kind
* of evidence.
*
* The LIVE PROBE looks for a linked worktree under
* `<repo>/.claude/worktrees/agent-*` that is still CLEAN (no tracked
* modifications; untracked review notes are fine) and whose HEAD equals the
* current orchestrator HEAD can only exist if the harness forked from HEAD
* after that commit was made — an origin/HEAD fork would have landed on an
* older commit once the orchestrator advanced. That combination is therefore
* POSITIVE evidence of fork-from-HEAD, and it is the only configuration that
* counts: every other observation (dirty worktree, different HEAD, no
* worktrees, git failure) is inconclusive and fails closed to the caller's
* existing flow.
* current orchestrator HEAD. That is POSITIVE evidence of fork-from-HEAD: an
* origin/HEAD fork would ordinarily have landed on an older commit once the
* orchestrator advanced. It is the only configuration that counts — every
* other observation (dirty worktree, different HEAD, no worktrees, git
* failure) is inconclusive and fails closed to the caller's existing flow.
*
* The CACHE leg looks at no worktree at all. It replays this function's OWN
* earlier conclusion for the same orchestrator HEAD, so a hit inherits whatever
* that earlier probe was worth and re-examines nothing — not the worktree, not
* even whether one still exists.
*
* EVIDENCE, NOT PROOF, AND THE LIVE PROBE FAILS IN TWO INDEPENDENT WAYS
* (#4921). What it reads is a worktree's PRESENT state — is it clean, where is
* its HEAD — and none of `worktree list --porcelain`, `status --porcelain` or
* `rev-parse HEAD` carries provenance.
*
* 1. It cannot say WHICH CREATOR. Where the harness is the only creator that
* is a distinction without a difference; where a `WorktreeCreate` hook is
* configured it is not, because a worktree the plain harness left behind
* BEFORE the hook existed — with HEAD unmoved since — is
* indistinguishable from one the hook made.
* 2. It cannot say WHAT IT WAS FORKED FROM either. A worktree created from an
* older base and since `git checkout --detach`ed onto the orchestrator
* HEAD is clean, sits at HEAD, and satisfies the probe identically. Note
* the shape of this one precisely: the worktree's own reflog DOES retain
* that original checkout, so the information is not lost — the probe just
* does not consult it, and #4868 did not design it to. Inherited from
* #4868 rather than introduced here, and accepted there for the no-hook
* case; stated so the strength of the signal is not overread.
*
* Both are reasons the observation is inadmissible once a hook is in the
* creation path, and the cache leg is a third, since it re-examines nothing at
* all. So the caller withholds it entirely under that interlock rather than
* re-keying it; see `hookWithheldHeadTrust` in evaluateWorktreeBaseDegrade.
*
* The verdict is cached at `<cwd>/.gsd/harness-fork-probe.json` keyed by the
* orchestrator HEAD (the decision's keying): trusted only while the
@@ -525,8 +699,9 @@ export function observeHarnessForkFromHead(deps: {
/**
* Evaluates whether the current worktree HEAD has diverged from the fork base
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
* parallel worktree.
* a 'fresh' parallel worktree would be created from — `origin/HEAD` when the
* fork base is inferred, or the base a worktree was actually observed to have
* when the caller supplies one (#4588).
*
* Returns a structured result with shouldDegrade, reason, and a user-visible
* message when degradation is warranted.
@@ -537,11 +712,15 @@ export function evaluateWorktreeBaseDegrade(deps?: {
cwd?: string;
/**
* Who creates the isolated worktree (#3659). 'harness-worktree' (default):
* the runtime harness forks it and does NOT route through project-settings
* baseRef (#48, verified 5/5; upstream claude-code#44965) — 'head' must not
* suppress the comparison. 'orchestrator-worktree': GSD itself runs
* `git worktree add <path> <start-point>` with the orchestrator HEAD, so
* 'head' is honored by construction and still suppresses.
* the runtime harness forks it. 'orchestrator-worktree': GSD itself runs
* `git worktree add <path> <start-point>` with the orchestrator HEAD.
* `worktree.baseRef:"head"` is honored by the orchestrator by construction
* and by the Claude Code harness as measured across all three settings
* layers and three OSes (#4588; the #48 finding that the harness did not
* read the setting predates upstream claude-code#54940); Cursor, the other
* shipped harness-worktree host, is unmeasured. The mode is kept
* on the interface because the two paths stay distinct in the dispatch
* step and future host descriptors may differ again.
*/
isolationMode?: BaseCheckIsolationMode;
/**
@@ -552,6 +731,34 @@ export function evaluateWorktreeBaseDegrade(deps?: {
*/
probeStateRead?: (file: string) => string | null;
probeStateWrite?: (file: string, content: string) => void;
/**
* The fork base a worktree created by this host was actually observed to
* have — `git rev-parse HEAD` inside a freshly created isolated worktree,
* before any commit (#4588). When present it REPLACES the `origin/HEAD`
* inference as the fork side of the comparison, so the verdict reports a
* measurement rather than a belief about the harness, and `head` no longer
* short-circuits: a mismatch under `head` means the worktree was not forked
* from HEAD despite the setting and degrades with
* `baseref-head-ignored-by-harness`. Must be a full 40- or 64-hex sha (case
* folded); any other non-blank string, and any non-string value (a number,
* object or boolean), throws a TypeError — an abbreviation that can never
* equal the full HEAD would otherwise always degrade, and a non-string must
* not read as "no observation". Absent (null/undefined) or blank (the
* default) → the inference path, unchanged.
*/
observedForkBase?: string | null;
/**
* A settings layer declaring a Claude Code `WorktreeCreate` hook, or one that does not
* parse (findWorktreeCreateHook, #4588). Consulted only under `harness-worktree` with no
* observation: there a hook, not the harness, creates the worktree and `worktree.baseRef`
* is not applied, so `"head"` does not short-circuit and the inferred comparison runs; a
* mismatch degrades with `baseref-head-bypassed-by-hook`. With `"head"` set it also
* withholds the #4868 prior-worktree observation, which cannot be attributed to the hook
* (#4921): on a hook host only `observedForkBase` restores a trusted verdict. Ignored under
* `orchestrator-worktree` (GSD's own `git worktree add` never runs a Claude Code hook) and
* whenever an observation is supplied (the measurement already sees where a hook forked).
*/
worktreeCreateHook?: WorktreeCreateHookFinding | null;
}): {
shouldDegrade: boolean;
reason: string;
@@ -575,19 +782,63 @@ export function evaluateWorktreeBaseDegrade(deps?: {
const cwd = deps?.cwd;
const cwdOpts = cwd ? { cwd } : {};
// a. baseRef 'head' suppresses ONLY where GSD controls the fork start-point
// (#3659). The former unconditional suppress trusted the harness to honor the
// setting; #48 verified 5/5 that the Agent-isolation dispatch path never
// routes through project settings (upstream claude-code#44965), so in
// harness mode the fork base is always origin/HEAD and 'head' must fall
// through to the same comparison the fresh path runs. In
// orchestrator-worktree mode GSD itself runs `git worktree add <path>
// <start-point>` with the orchestrator HEAD — 'head' is honored by
// construction there and the suppress is correct. Any non-"head" value
// (including "fresh" and absent/null) has fresh/origin-HEAD semantics and is
// evaluated against origin/HEAD as before. (Reference: #683, #48, #3659.)
const headIgnoredByHarness = deps?.effectiveBaseRef === 'head';
if (headIgnoredByHarness && (deps?.isolationMode ?? 'harness-worktree') === 'orchestrator-worktree') {
const baseRefHead = deps?.effectiveBaseRef === 'head';
const observedRaw = deps?.observedForkBase;
// Only a string or an explicit absence is a legal observation. A number, object or
// boolean is a programmer error and must not be read as "no observation" — the same
// TypeError shape applyWorktreeBaseRef uses for a non-object (P4.6 review, round 2).
if (observedRaw != null && typeof observedRaw !== 'string') {
throw new TypeError(`evaluateWorktreeBaseDegrade: ${MSG_OBSERVED_FORK_BASE_INVALID}, got ${typeof observedRaw}`);
}
const observedTrimmed = typeof observedRaw === 'string' ? observedRaw.trim().toLowerCase() : '';
if (observedTrimmed && !FULL_SHA_RE.test(observedTrimmed)) {
throw new TypeError(`evaluateWorktreeBaseDegrade: ${MSG_OBSERVED_FORK_BASE_INVALID}, got ${JSON.stringify(observedRaw)}`);
}
const observedForkBase: string | null = observedTrimmed || null;
// a. baseRef 'head' with no observation: the fork base IS the orchestrator
// HEAD, in both isolation modes. orchestrator-worktree: GSD runs
// `git worktree add <path> <start-point>` with the orchestrator HEAD, so it
// holds by construction (#3659). harness-worktree: the harness honors the
// setting — measured on current Claude Code from the project-local,
// project-shared and user/global layers on macOS, Windows and Linux (#4588).
// The former harness-mode fall-through rested on #48's finding that the
// harness did not read the setting; that was true of the harness at the time
// and was fixed upstream (claude-code#54940), but the check inferred the
// fork base from the setting's value alone and so could not notice. It is
// not replaced with a version cutover: a host that does not honor `head`
// forks from somewhere else, and the spawn-time `worktree_branch_check`
// guard halts that executor at exit 42 before it commits — the
// observation-based check that already exists. A caller holding that
// observation passes it as `observedForkBase` and lands in c/d below, where
// a mismatch under `head` degrades with `baseref-head-ignored-by-harness`.
// Any non-"head" value (including "fresh" and absent/null) keeps
// fresh/origin-HEAD semantics and is evaluated against origin/HEAD as
// before — with the setting absent the harness does fork from origin/HEAD,
// so that degrade is a correct reading, not this bug. Measured on Claude
// Code only: Cursor also declares `harness-worktree` and is unmeasured, so
// there the trust rests on the exit-42 backstop alone until someone reads a
// worktree's HEAD on that host. (#683, #48, #3659, #4588.)
//
// The one exception is a Claude Code `WorktreeCreate` hook under harness-worktree
// (#4588): the hook creates the agent worktree from whatever directory it emits and the
// harness does not apply `worktree.baseRef` on that path, so the measurement above does
// not cover it. The short-circuit is withheld and the origin/HEAD inference below runs,
// as for a host without the setting; a mismatch degrades with
// `baseref-head-bypassed-by-hook`, and the ONLY thing that restores a trusted verdict
// there is an explicit `--observed-fork-base` measurement of this dispatch. b2's
// prior-worktree observation deliberately does NOT restore it — see
// `hookWithheldHeadTrust` below (#4868, #4881, #4921). orchestrator-worktree is
// unaffected — GSD runs `git worktree add` itself and no Claude Code hook is in that
// path.
const hookFinding: WorktreeCreateHookFinding | null = deps?.worktreeCreateHook ?? null;
const hookBypassesBaseRef = hookFinding !== null && (deps?.isolationMode ?? 'harness-worktree') === 'harness-worktree';
// The interlock's fail-closed half, scoped to exactly the case branch a. declined to
// trust: `"head"` is set AND a hook (or an unparseable layer) is in the harness's
// worktree-creation path. It is deliberately NOT `hookBypassesBaseRef` alone — with no
// `"head"` setting, b2 is #4868's own arm and this PR does not re-scope it.
const hookWithheldHeadTrust = baseRefHead && hookBypassesBaseRef;
if (baseRefHead && observedForkBase === null && !hookBypassesBaseRef) {
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
}
@@ -619,17 +870,37 @@ export function evaluateWorktreeBaseDegrade(deps?: {
}
const headSha = head.headSha;
// b2. #4588 (decision A2): OBSERVED fork-from-HEAD confirmation. A clean
// prior harness worktree sitting exactly at the orchestrator HEAD is
// b2. #4868 (#4588 decision A2): OBSERVED fork-from-HEAD confirmation. A
// clean prior harness worktree sitting exactly at the orchestrator HEAD is
// positive evidence the harness forks from HEAD — in harness mode that
// supersedes the origin/HEAD comparison for this dispatch (the stale
// origin/HEAD the comparison would degrade on is not where the harness
// forks). Fail-closed: every non-confirming observation falls through to
// the exact pre-#4588 flow below. The mode gate below (not branch a)
// excludes orchestrator-worktree mode — branch a only returns when
// worktree.baseRef:"head" is set — and probeStateRead/Write default to the
// .gsd cache file under cwd.
if ((deps?.isolationMode ?? 'harness-worktree') === 'harness-worktree') {
// the comparison below. Since #4881 this step is reached only when branch a
// did NOT trust the setting: the setting is absent or not "head", or a
// WorktreeCreate hook withheld the trust — and in that second case the
// observation is NOT consulted at all (`hookWithheldHeadTrust`), because it
// cannot be attributed to the hook. The evidence is a worktree sitting at
// HEAD; nothing on disk records WHICH creator left it there, so a clean
// worktree the plain harness created BEFORE the hook was configured, with
// HEAD unmoved since, is indistinguishable from one the hook created. Keying
// the cache by hook configuration does not close that: the cache is only one
// of the two legs, and a cache miss falls through to the live probe, which
// re-finds the same stale worktree and re-confirms. Under a hook the only
// admissible positive signal is an explicit `--observed-fork-base`
// measurement of THIS dispatch, which lands in c/d below; absent one the
// inferred comparison runs and a mismatch degrades with
// `baseref-head-bypassed-by-hook`, leaving the spawn-time exit-42 guard as
// the backstop. That keeps the interlock fail-closed end to end.
// This step is skipped for a second, unrelated reason when the caller
// supplies `observedForkBase`: an explicit measurement of this dispatch's
// fork base outranks an inference from a prior worktree, and the two must
// not disagree silently. The mode gate excludes orchestrator-worktree (no
// harness, no hook, nothing to observe), and probeStateRead/Write default to
// the .gsd cache file under cwd. With no `"head"` setting the #4868 arm is
// unchanged, hook or not — that trust predates this PR and is not re-scoped
// here (#4921 round 1).
if (observedForkBase === null && !hookWithheldHeadTrust && (deps?.isolationMode ?? 'harness-worktree') === 'harness-worktree') {
const observed = observeHarnessForkFromHead({
execGit,
cwd: deps?.cwd,
@@ -650,28 +921,35 @@ export function evaluateWorktreeBaseDegrade(deps?: {
}
}
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
// c. Resolve fork base. An observation wins outright: it is what a worktree
// this host created actually forked from, so there is nothing to infer
// (#4588). Otherwise infer origin/HEAD — what a 'fresh' worktree forks from.
let forkRef: string | null = null;
let forkSha: string | null = null;
// Try direct origin/HEAD rev-parse first.
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
if (directResult.exitCode === 0 && directStdout) {
forkRef = 'origin/HEAD';
forkSha = directStdout;
if (observedForkBase !== null) {
forkRef = FORK_REF_OBSERVED;
forkSha = observedForkBase;
} else {
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
if (symResult.exitCode === 0 && symStdout) {
const ref = symStdout;
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
if (symShaResult.exitCode === 0 && symShaStdout) {
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
forkRef = ref.replace(/^refs\/remotes\//, '');
forkSha = symShaStdout;
// Try direct origin/HEAD rev-parse first.
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
if (directResult.exitCode === 0 && directStdout) {
forkRef = 'origin/HEAD';
forkSha = directStdout;
} else {
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
if (symResult.exitCode === 0 && symStdout) {
const ref = symStdout;
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
if (symShaResult.exitCode === 0 && symShaStdout) {
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
forkRef = ref.replace(/^refs\/remotes\//, '');
forkSha = symShaStdout;
}
}
}
}
@@ -681,10 +959,28 @@ export function evaluateWorktreeBaseDegrade(deps?: {
return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null, headAbsenceVerified: null };
}
if (forkSha === headSha) {
return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha, headAbsenceVerified: null };
const reason = forkRef === FORK_REF_OBSERVED ? 'observed-fork-matches-head' : 'head-matches-fork';
return { shouldDegrade: false, reason, message: null, headSha, forkRef, forkSha, headAbsenceVerified: null };
}
if (headIgnoredByHarness) {
const message = buildMsgBaserefHeadIgnored(headSha, forkRef, forkSha);
if (baseRefHead && observedForkBase === null && hookFinding !== null) {
// Reachable only through the hook interlock in a.: "head" was not trusted because a
// WorktreeCreate hook (or an unparseable settings layer) is in the harness's path, b2
// was withheld there (`hookWithheldHeadTrust` — a prior worktree cannot be attributed
// to the hook), and HEAD differs from the inferred fork base (#4588, #4881, #4921).
// So this is now the unconditional harness-mode verdict for a hook host with "head"
// set, a diverged HEAD and no `--observed-fork-base`. The inferred comparison is the one
// this check made in harness mode before #4588 — it is not a measurement of the hook, so
// a match above (head-matches-fork) does not prove the hook forks from HEAD either; the
// spawn-time exit-42 guard stays the backstop for that case, and it halts even in a
// hook-emitted directory that is not a git worktree (its branch check fails first).
const message = buildMsgBaserefHeadHookBypass(headSha, forkRef, forkSha, hookFinding);
return { shouldDegrade: true, reason: 'baseref-head-bypassed-by-hook', message, headSha, forkRef, forkSha, headAbsenceVerified: null };
}
if (baseRefHead) {
// Reachable only with an observation (a. returned otherwise): the setting
// asked for HEAD and the measured fork base is something else — the
// existing degrade-and-warn, now reporting a measurement (#4588).
const message = buildMsgBaserefHeadIgnored(headSha, forkSha);
return { shouldDegrade: true, reason: 'baseref-head-ignored-by-harness', message, headSha, forkRef, forkSha, headAbsenceVerified: null };
}
const message = buildMsgDiverged(headSha, forkRef, forkSha);

View File

@@ -786,15 +786,16 @@ describe('execute-phase: between-wave manifest reset (#1369, #3384)', () => {
test('step 7c runs the mode-threaded base-check and does NOT re-assert set-baseref (#3659)', () => {
// The former pin required the set-baseref re-assert, whose stated mechanism
// (#1369: "so the Claude Code harness re-reads the live HEAD") was fiction —
// the harness does not read project-settings baseRef (#48, verified 5/5;
// upstream claude-code#44965). Rewritten per the #3659 sanction: the
// between-wave re-check threads --mode and never re-asserts the dead call.
// (#1369: "so the Claude Code harness re-reads the live HEAD") was fiction at
// the time (#48; upstream claude-code#44965). The harness honors the setting
// now (#4588), but the re-assert stays dead for its own reason: a setting
// already in place needs no re-write between waves, and one deliberately
// absent must not be re-imposed. The between-wave re-check threads --mode.
const content = fs.readFileSync(BETWEEN_WAVE_PATH, 'utf-8');
assert.ok(content.includes('worktree.base-check --mode "$ISOLATION"'),
'step 7c must thread the isolation mode through the base-check');
assert.ok(!content.includes('worktree.set-baseref'),
'step 7c must not re-assert set-baseref — the harness never read it (#48/#3659)');
'step 7c must not re-assert set-baseref — a re-write between waves changes nothing (#3659/#4588)');
});
test('step 7c appears after step 7b and before step 8 in the wave loop', () => {

View File

@@ -5,7 +5,8 @@
*
* Seam: gsd-core/bin/lib/worktree-base-ref.cjs
* Interface: shortSha, readBaseRefFromSettings, applyWorktreeBaseRef,
* resolveEffectiveBaseRef, evaluateWorktreeBaseDegrade
* resolveEffectiveBaseRef, findWorktreeCreateHook,
* evaluateWorktreeBaseDegrade
*
* Issue #683: worktree base-mismatch detection and degradation logic.
* All tests use dependency injection (inline stubs) — no real filesystem
@@ -30,6 +31,7 @@ const {
readBaseRefFromSettings,
applyWorktreeBaseRef,
resolveEffectiveBaseRef,
findWorktreeCreateHook,
evaluateWorktreeBaseDegrade,
classifyGitHead,
cmdWorktreeBaseCheck,
@@ -297,61 +299,242 @@ describe('evaluateWorktreeBaseDegrade', () => {
assert.strictEqual(called, false, 'orchestrator mode: GSD controls the fork start-point, head is honored by construction');
});
test('effectiveBaseRef="head" + harness mode (default) + diverged HEAD → degrade, reason baseref-head-ignored-by-harness (#3659)', () => {
// #48 verified 5/5 that the harness dispatch path never routes through
// project-settings baseRef — with head set on a diverged branch the check
// must compare and degrade, not trust the setting.
const HEAD_SHA = '11111111223344aa11111111223344aa11111111';
const FORK_SHA = '99999999223344bb99999999223344bb99999999';
test('effectiveBaseRef="head" + harness mode (default) + diverged HEAD → no degrade, reason baseref-head, execGit never called (#4588)', () => {
// #3659 made harness mode fall through to the origin/HEAD comparison on
// #48's finding that the harness did not read the setting. It does now —
// measured on current Claude Code from all three settings layers on macOS,
// Windows and Linux (#4588) — so `head` means the fork base IS the
// orchestrator HEAD in harness mode too, and there is nothing to compare.
let called = false;
const result = evaluateWorktreeBaseDegrade({
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
execGit: () => { called = true; return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; },
effectiveBaseRef: 'head',
});
assert.strictEqual(result.shouldDegrade, true,
'head must not suppress the comparison in harness mode (#3659)');
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'origin/HEAD');
assert.strictEqual(result.forkSha, FORK_SHA);
assert.strictEqual(result.shouldDegrade, false,
'head must suppress the comparison in harness mode: the Claude Code harness honors worktree.baseRef, as measured (#4588)');
assert.strictEqual(result.reason, 'baseref-head');
assert.strictEqual(result.message, null);
assert.strictEqual(result.headSha, null);
assert.strictEqual(result.forkRef, null);
assert.strictEqual(result.forkSha, null);
assert.strictEqual(called, false, 'no observation and head set: the fork base is known without asking git');
});
test('effectiveBaseRef="head" + explicit harness-worktree mode + diverged → degrade (#3659)', () => {
const HEAD_SHA = 'aaaa1111223344ccaaaa1111223344ccaaaa1111';
const FORK_SHA = 'bbbb1111223344ddbbbb1111223344ddbbbb1111';
test('effectiveBaseRef="head" + explicit harness-worktree mode + diverged → no degrade (#4588)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
execGit: () => { throw new Error('execGit must not be called'); },
effectiveBaseRef: 'head',
isolationMode: 'harness-worktree',
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
});
// ── observedForkBase (#4588): the measurement replaces the inference ─────
test('observedForkBase === HEAD + harness + head → no degrade, reason observed-fork-matches-head; origin/HEAD never resolved (#4588)', () => {
// The regression the triage brief named: an observed fork base equal to
// local HEAD in harness mode must be able to yield shouldDegrade:false.
const SAME_SHA = 'cccc1111223344eecccc1111223344eecccc1111';
const calls = [];
const result = evaluateWorktreeBaseDegrade({
execGit: (args) => {
calls.push(args.join(' '));
if (args.join(' ') === 'rev-parse HEAD') return { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null };
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
},
effectiveBaseRef: 'head',
isolationMode: 'harness-worktree',
observedForkBase: SAME_SHA,
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'observed-fork-matches-head');
assert.strictEqual(result.headSha, SAME_SHA);
assert.strictEqual(result.forkRef, 'observed');
assert.strictEqual(result.forkSha, SAME_SHA);
assert.deepStrictEqual(calls, ['rev-parse HEAD'],
'an observation is the fork base — origin/HEAD must not be consulted');
});
test('observedForkBase !== HEAD + harness + head → degrade, reason baseref-head-ignored-by-harness, message names the observation (#4588)', () => {
// Acceptance criterion (2): a genuine mismatch keeps the existing
// degrade-and-warn — now reporting a measurement, not a belief.
const HEAD_SHA = '11111111223344aa11111111223344aa11111111';
const OBSERVED = '99999999223344bb99999999223344bb99999999';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
}),
effectiveBaseRef: 'head',
observedForkBase: OBSERVED,
});
assert.strictEqual(result.shouldDegrade, true,
'head set but the host forked from elsewhere: the harness did not honor it — degrade (#4588)');
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'observed');
assert.strictEqual(result.forkSha, OBSERVED);
assert.ok(result.message !== null, 'divergence under head must carry the explanatory message');
// Pinned VERBATIM — the source declares these messages downstream dependencies, and a
// substring check lets the untested portions drift (P4.6 review, round 3).
const expectedMsg = `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but a worktree created for this dispatch was observed to fork from ${OBSERVED.slice(0, 8)} while HEAD is ${HEAD_SHA.slice(0, 8)} — the worktree was not forked from HEAD despite the setting. Running this phase sequentially on the main working tree. Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it. See #3659, #4588.`;
assert.strictEqual(result.message, expectedMsg);
assert.ok(result.message.includes('observed'), 'message must say the fork base was observed, not inferred');
assert.ok(!result.message.includes('runtime harness'),
'message must be mode-neutral — the observation may come from a GSD-created worktree (P4.6 review)');
assert.ok(result.message.includes('fresh dispatch'),
'remedy must ask for a fresh observation — pushing cannot change a fixed measurement');
assert.ok(result.message.includes(OBSERVED.slice(0, 8)) && result.message.includes(HEAD_SHA.slice(0, 8)),
'message must carry both short SHAs');
assert.ok(result.message.includes('sequentially'), 'message must state the sequential fallback');
assert.ok(result.message.includes('#4588'), 'message must cite the measurement issue');
});
test('observedForkBase !== HEAD + no baseRef → degrade, reason head-diverged-from-fork, forkRef "observed" (#4588)', () => {
const HEAD_SHA = 'aaaa1111223344ccaaaa1111223344ccaaaa1111';
const OBSERVED = 'bbbb1111223344ddbbbb1111223344ddbbbb1111';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
}),
observedForkBase: OBSERVED,
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
assert.strictEqual(result.forkRef, 'observed');
assert.strictEqual(result.forkSha, OBSERVED);
// Pinned verbatim (P4.6 review, round 4) — the observed branch of buildMsgDiverged.
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from the observed fork base (${OBSERVED.slice(0, 8)}). Running this phase sequentially on the main working tree. Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it, or set worktree.baseRef:"head" to fork worktrees from HEAD instead (honored by GSD-created worktrees and by the Claude Code harness; #683, #4588).`;
assert.strictEqual(result.message, expectedMsg);
assert.ok(result.message.includes('the observed fork base'),
'message must phrase the fork side as an observation, not as a ref name');
assert.ok(result.message.includes('fresh dispatch') && !result.message.includes('so the observed fork base matches it'),
'remedy must not tell the user to push until a fixed observation matches');
});
test('observedForkBase is case-folded: an uppercase 40-hex observation equal to HEAD matches (#4588 review)', () => {
const SAME_SHA = '0123456789abcdef0123456789abcdef01234567';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
}),
observedForkBase: SAME_SHA.toUpperCase(),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'observed-fork-matches-head');
assert.strictEqual(result.forkSha, SAME_SHA, 'forkSha is the canonical lowercase form');
});
test('observedForkBase accepts a 64-hex (SHA-256 repository) sha (#4588 review)', () => {
const SAME_SHA = 'a'.repeat(64);
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
}),
observedForkBase: SAME_SHA,
});
assert.strictEqual(result.reason, 'observed-fork-matches-head');
});
test('observedForkBase that is not a string (number, object, boolean) THROWS rather than reading as absent (#4588 review r2)', () => {
for (const bad of [123, {}, true, []]) {
assert.throws(
() => evaluateWorktreeBaseDegrade({
execGit: () => { throw new Error('execGit must not be called'); },
effectiveBaseRef: 'head',
observedForkBase: bad,
}),
/observedForkBase must be a full 40- or 64-hex commit sha/,
`observedForkBase=${JSON.stringify(bad)} must fail closed, not short-circuit as baseref-head`
);
}
});
test('observedForkBase that is non-blank but not a full sha THROWS — an abbreviation could never match and would always degrade (#4588 review)', () => {
// One full-string pin so the validation message itself cannot drift (P4.6 review, round 4).
assert.throws(
() => evaluateWorktreeBaseDegrade({ execGit: () => { throw new Error('unreachable'); }, observedForkBase: 'HEAD' }),
{ name: 'TypeError', message: 'evaluateWorktreeBaseDegrade: observedForkBase must be a full 40- or 64-hex commit sha (git rev-parse HEAD inside the worktree, before any commit), got "HEAD"' }
);
// Both alternatives of FULL_SHA_RE get their own ±1 boundary: 39/41 around the
// 40-hex (SHA-1) arm, 63/65 around the 64-hex (SHA-256) arm (#4921 review).
for (const bad of ['0123456', '0123456789abcdef0123456789abcdef0123456', 'HEAD', 'not-a-sha', 'g'.repeat(40), 'b'.repeat(41), 'c'.repeat(63), 'd'.repeat(65), 'g'.repeat(64)]) {
assert.throws(
() => evaluateWorktreeBaseDegrade({
execGit: () => { throw new Error('execGit must not be called before the observation is validated'); },
effectiveBaseRef: 'head',
observedForkBase: bad,
}),
/observedForkBase must be a full 40- or 64-hex commit sha/,
`observedForkBase=${JSON.stringify(bad)} must fail closed`
);
}
});
test('observedForkBase === HEAD + no baseRef → no degrade, reason observed-fork-matches-head (#4588)', () => {
const SAME_SHA = 'dddd1111223344ffdddd1111223344ffdddd1111';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
}),
observedForkBase: SAME_SHA,
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'observed-fork-matches-head');
});
test('observedForkBase + head + orchestrator mode → the observation still decides (no short-circuit) (#4588)', () => {
// An observation is stronger than either mode's belief about the fork
// base: if GSD's own `git worktree add` somehow forked from elsewhere, the
// measurement — not the construction argument — is what the verdict reads.
const HEAD_SHA = 'eeee1111223344abeeee1111223344abeeee1111';
const OBSERVED = 'ffff1111223344acffff1111223344acffff1111';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
}),
effectiveBaseRef: 'head',
isolationMode: 'orchestrator-worktree',
observedForkBase: OBSERVED,
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
});
test('effectiveBaseRef="head" + harness + HEAD == origin/HEAD → no degrade, reason head-matches-fork (#3659)', () => {
const SAME_SHA = 'cccc1111223344eecccc1111223344eecccc1111';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
}),
effectiveBaseRef: 'head',
isolationMode: 'harness-worktree',
});
assert.strictEqual(result.shouldDegrade, false,
'when the harness fork base happens to equal HEAD there is no mismatch to degrade for');
assert.strictEqual(result.reason, 'head-matches-fork');
test('observedForkBase empty or whitespace → treated as absent: head short-circuits, else origin/HEAD is inferred (#4588)', () => {
// Negative control for the observation path: an empty observation must
// not be mistaken for a measured fork base of "".
for (const empty of ['', ' ', null, undefined]) {
const viaHead = evaluateWorktreeBaseDegrade({
execGit: () => { throw new Error('execGit must not be called'); },
effectiveBaseRef: 'head',
observedForkBase: empty,
});
assert.strictEqual(viaHead.reason, 'baseref-head', `head + observedForkBase=${JSON.stringify(empty)} short-circuits`);
const HEAD_SHA = '12341234123412341234123412341234deadbeef';
const FORK_SHA = '43214321432143214321432143214321cafebabe';
const inferred = evaluateWorktreeBaseDegrade({
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
observedForkBase: empty,
});
assert.strictEqual(inferred.reason, 'head-diverged-from-fork', `no head + observedForkBase=${JSON.stringify(empty)} infers origin/HEAD`);
assert.strictEqual(inferred.forkRef, 'origin/HEAD');
}
});
test('harness head-diverge message cites the verified harness limitation (#3659)', () => {
const HEAD_SHA = 'dddd1111223344ffdddd1111223344ffdddd1111';
const FORK_SHA = 'eeee1111223344abeeceeee1111223344abeeceee';
test('observedForkBase does not bypass HEAD resolution: exit 128 → no-head, degrades as #4734 pinned (#4588)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeDivergedExecGit(HEAD_SHA, FORK_SHA),
effectiveBaseRef: 'head',
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null },
}),
observedForkBase: 'abcdef1234567890abcdef1234567890abcdef12',
});
assert.ok(result.message !== null, 'divergence under head must carry the explanatory message');
assert.ok(result.message.includes('#48'), 'message must cite the verified harness limitation');
assert.ok(result.message.includes('sequentially'), 'message must state the sequential fallback');
// #4734: exit 128 is git's definitive "no repository" answer and degrades —
// an observation cannot make a worktree creatable where none can exist.
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.headAbsenceVerified, true);
});
test('git rev-parse HEAD exits 128 (definitive no-repository) → degrades, reason no-head (#4734)', () => {
@@ -398,6 +581,11 @@ describe('evaluateWorktreeBaseDegrade', () => {
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
assert.ok(result.message, 'a fail-closed degrade must carry a non-null explanatory message');
// Pinned verbatim (P4.6 review, round 4). #4588 dropped the sentence claiming
// baseRef:"head" "never applied" in harness mode — with head set this path is
// no longer reached at all, so the note would have been false.
assert.strictEqual(result.message,
'⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.');
assert.strictEqual(result.headSha, null);
});
@@ -525,10 +713,12 @@ describe('evaluateWorktreeBaseDegrade', () => {
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'origin/HEAD');
assert.strictEqual(result.forkSha, FORK_SHA);
// Verify message contains the short SHAs and the corrected remediation
// (#3659: the old text advised setting baseRef:"head", which the harness
// does not read).
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so origin/HEAD matches it. (worktree.baseRef:"head" applies only where GSD itself creates the worktree — the runtime harness does not read it; #48, #3659.)`;
// Verify message contains the short SHAs and the remediation. #3659 had
// removed the baseRef:"head" advice on #48's finding that the harness did
// not read the setting; it does now (#4588), so the advice is back — with
// the setting absent the harness really does fork from origin/HEAD, and
// `head` is the fix for that.
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. Parallel worktrees return once HEAD is merged/pushed so origin/HEAD matches it, or set worktree.baseRef:"head" to fork worktrees from HEAD instead (honored by GSD-created worktrees and by the Claude Code harness; #683, #4588).`;
assert.strictEqual(result.message, expectedMsg);
});
@@ -640,7 +830,11 @@ describe('cmdWorktreeBaseCheck', () => {
assert.deepStrictEqual(parsed, result);
});
test('baseRef=head in settings + default (harness) mode + diverged HEAD → shouldDegrade true (#3659)', () => {
test('baseRef=head in settings + default (harness) mode + diverged HEAD → shouldDegrade false, reason baseref-head (#4588)', () => {
// The #4588 symptom end to end: project-local head, harness mode, HEAD
// ahead of origin/HEAD — this returned baseref-head-ignored-by-harness and
// degraded every wave on an unmerged branch. The Claude Code harness honors the
// setting, so the check no longer compares.
const cwd = '/repo';
const claudeDir = '/repo/.claude';
const HEAD_SHA = 'fade1111223344cafade1111223344cafade1111';
@@ -658,9 +852,87 @@ describe('cmdWorktreeBaseCheck', () => {
userClaudeDir: '/nonexistent-hermetic-user-dir',
};
const result = cmdWorktreeBaseCheck(cwd, [], deps);
assert.strictEqual(result.shouldDegrade, true,
'settings head must not suppress the harness-mode comparison (#3659)');
assert.strictEqual(result.shouldDegrade, false,
'settings head suppresses the harness-mode comparison: the Claude Code harness honors worktree.baseRef, as measured (#4588)');
assert.strictEqual(result.reason, 'baseref-head');
});
test('--observed-fork-base <sha> equal to HEAD + settings head → observed-fork-matches-head (#4588)', () => {
const cwd = '/repo';
const claudeDir = '/repo/.claude';
const SAME_SHA = 'feed1111223344dafeed1111223344dafeed1111';
const deps = {
readFile: (p) => {
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
return null;
},
execGit: makeExecGitCheck({
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
}),
write: () => {},
userClaudeDir: '/nonexistent-hermetic-user-dir',
};
const result = cmdWorktreeBaseCheck(cwd, ['--observed-fork-base', SAME_SHA], deps);
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'observed-fork-matches-head');
assert.strictEqual(result.forkRef, 'observed');
});
test('--observed-fork-base <sha> differing from HEAD + settings head → baseref-head-ignored-by-harness (#4588)', () => {
const cwd = '/repo';
const claudeDir = '/repo/.claude';
const HEAD_SHA = 'fade1111223344cafade1111223344cafade1111';
const OBSERVED = 'bead1111223344dbbead1111223344dbbead1111';
const deps = {
readFile: (p) => {
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
return null;
},
execGit: makeExecGitCheck({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
}),
write: () => {},
userClaudeDir: '/nonexistent-hermetic-user-dir',
};
const result = cmdWorktreeBaseCheck(cwd, ['--mode', 'harness-worktree', '--observed-fork-base', OBSERVED], deps);
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
assert.strictEqual(result.forkSha, OBSERVED);
});
test('--observed-fork-base rejects a missing or non-sha value — no silent fallback to inference (#4588)', () => {
const cwd = '/repo';
const deps = {
readFile: () => null,
execGit: makeExecGitCheck({}),
write: () => {},
userClaudeDir: '/nonexistent-hermetic-user-dir',
};
// Includes the abbreviated 7-char form: accepted by an earlier draft, it could never
// equal the full `rev-parse HEAD` output and so always degraded (P4.6 review).
for (const bad of [['--observed-fork-base'], ['--observed-fork-base', 'HEAD'], ['--observed-fork-base', 'abc'], ['--observed-fork-base', 'abcdef1'], ['--observed-fork-base', 'g'.repeat(40)], ['--observed-fork-base', 'a'.repeat(39)]]) {
assert.throws(
() => cmdWorktreeBaseCheck(cwd, bad, deps),
/--observed-fork-base: observedForkBase must be a full 40- or 64-hex commit sha/,
`args ${JSON.stringify(bad)} must fail closed`
);
}
});
test('--observed-fork-base folds case before comparing (#4588 review)', () => {
const cwd = '/repo';
const SAME_SHA = 'feed1111223344dafeed1111223344dafeed1111';
const deps = {
readFile: () => null,
execGit: makeExecGitCheck({
'rev-parse HEAD': { exitCode: 0, stdout: SAME_SHA, stderr: '', signal: null, error: null },
}),
write: () => {},
userClaudeDir: '/nonexistent-hermetic-user-dir',
};
const result = cmdWorktreeBaseCheck(cwd, ['--observed-fork-base', SAME_SHA.toUpperCase()], deps);
assert.strictEqual(result.reason, 'observed-fork-matches-head');
assert.strictEqual(result.forkSha, SAME_SHA);
});
test('--mode rejects invalid values — no silent default that would re-open the #3659 hole', () => {
@@ -1177,9 +1449,12 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
assert.strictEqual(result.reason, 'baseref-head');
});
test('user/global head + phase lane + default (harness) mode → shouldDegrade:true (#3659)', () => {
// The mirror of the KEY REGRESSION row: in harness mode the setting cannot
// suppress anything (#48), so the same lane degrades.
test('user/global head + phase lane + default (harness) mode → shouldDegrade:false (#4588)', () => {
// The mirror of the KEY REGRESSION row. #3659 had this lane degrade in
// harness mode on #48's finding that the harness did not read the setting;
// the user/global layer in particular is honored by the harness (measured
// on current Claude Code, #4588 — the arm #4090's triage left to the
// originating investigation), so the same lane no longer degrades.
const deps = {
execGit: makePhaseLaneExecGit(HEAD_SHA),
readFile: (p) => {
@@ -1194,9 +1469,9 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
userClaudeDir: USER_CLAUDE_DIR,
};
const result = cmdWorktreeBaseCheck(cwd, [], deps);
assert.strictEqual(result.shouldDegrade, true,
'harness mode: head must not suppress the lane degrade (#3659)');
assert.strictEqual(result.reason, 'fork-ref-unknown');
assert.strictEqual(result.shouldDegrade, false,
'harness mode: a user/global head suppresses the lane degrade — the Claude Code harness honors it, as measured (#4588)');
assert.strictEqual(result.reason, 'baseref-head');
});
test('(e negative) NO user/global head + same phase lane → shouldDegrade:true (proves lane degrades)', () => {
@@ -1241,6 +1516,262 @@ describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => {
const QUICK_WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'quick.md');
// ─── WorktreeCreate-hook interlock (#4588) ───────────────────────────────────
//
// A Claude Code WorktreeCreate hook creates the agent worktree from the directory it
// emits, and Claude Code does not apply worktree.baseRef on that path. So the #4588
// trust in "head" under harness-worktree must not hold on a host that configures one.
const HOOK_SETTINGS = JSON.stringify({
hooks: { WorktreeCreate: [{ hooks: [{ type: 'command', command: 'make-worktree.sh' }] }] },
});
function settingsReader(files) {
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
}
describe('findWorktreeCreateHook (#4588)', () => {
const claudeDir = '/repo/.claude';
const USER_CLAUDE_DIR = '/home/user/.claude';
const LAYERS = [
path.join(claudeDir, 'settings.local.json'),
path.join(claudeDir, 'settings.json'),
path.join(USER_CLAUDE_DIR, 'settings.json'),
];
test('a WorktreeCreate hook in each of the three layers is found and names that layer (#4588)', () => {
for (const file of LAYERS) {
const found = findWorktreeCreateHook(claudeDir, { readFile: settingsReader({ [file]: HOOK_SETTINGS }) }, USER_CLAUDE_DIR);
assert.deepStrictEqual(found, { file, kind: 'hook' }, `hook in ${file}`);
}
});
test('every layer is checked, not only the one that supplies baseRef (#4588)', () => {
const found = findWorktreeCreateHook(claudeDir, {
readFile: settingsReader({
[LAYERS[0]]: JSON.stringify({ worktree: { baseRef: 'head' } }),
[LAYERS[2]]: HOOK_SETTINGS,
}),
}, USER_CLAUDE_DIR);
assert.deepStrictEqual(found, { file: LAYERS[2], kind: 'hook' });
});
test('no settings, other hook events, an empty WorktreeCreate list or a blank file → null (#4588)', () => {
const cases = {
'no files': {},
'other hook events only': { [LAYERS[1]]: JSON.stringify({ hooks: { PreToolUse: [{ hooks: [] }], WorktreeRemove: [{ hooks: [] }] } }) },
'empty WorktreeCreate list': { [LAYERS[0]]: JSON.stringify({ hooks: { WorktreeCreate: [] } }) },
'baseRef only': { [LAYERS[0]]: JSON.stringify({ worktree: { baseRef: 'head' } }) },
'whitespace-only file': { [LAYERS[0]]: ' \n' },
'non-object top level': { [LAYERS[1]]: '[]' },
};
for (const [label, files] of Object.entries(cases)) {
assert.strictEqual(findWorktreeCreateHook(claudeDir, { readFile: settingsReader(files) }, USER_CLAUDE_DIR), null, label);
}
});
test('a layer that does not parse fails closed as kind "unparseable" (#4588)', () => {
const found = findWorktreeCreateHook(claudeDir, { readFile: settingsReader({ [LAYERS[1]]: '{ "hooks": ' }) }, USER_CLAUDE_DIR);
assert.deepStrictEqual(found, { file: LAYERS[1], kind: 'unparseable' });
});
test('JSONC comments and trailing commas still parse, so a commented hook is found (#4588)', () => {
const jsonc = '{\n // local hook\n "hooks": { "WorktreeCreate": [ { "hooks": [ { "type": "command", "command": "x" } ] }, ] },\n}\n';
const found = findWorktreeCreateHook(claudeDir, { readFile: settingsReader({ [LAYERS[0]]: jsonc }) }, USER_CLAUDE_DIR);
assert.deepStrictEqual(found, { file: LAYERS[0], kind: 'hook' });
});
test('user/global layer is read only when userClaudeDir is given and differs from claudeDir (#4588)', () => {
const readPaths = [];
const readFile = (p) => { readPaths.push(p); return null; };
findWorktreeCreateHook(claudeDir, { readFile }, claudeDir);
findWorktreeCreateHook(claudeDir, { readFile }, null);
findWorktreeCreateHook(claudeDir, { readFile });
assert.ok(!readPaths.includes(LAYERS[2]), 'user layer never read without a distinct userClaudeDir');
assert.strictEqual(readPaths.filter((p) => p === LAYERS[1]).length, 3, 'shared settings.json read once per call');
});
});
describe('evaluateWorktreeBaseDegrade — WorktreeCreate hook interlock (#4588)', () => {
const HEAD_SHA = 'a1a1a1a1b2b2b2b2c3c3c3c3d4d4d4d4e5e5e5e5';
const FORK_SHA = 'f6f6f6f6a7a7a7a7b8b8b8b8c9c9c9c9d0d0d0d0';
const HOOK = { file: '/repo/.claude/settings.json', kind: 'hook' };
const ok = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
function stubGit(responses) {
return (args) => {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) return responses[key];
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('head + harness mode + hook + HEAD diverged from origin/HEAD → degrade, reason baseref-head-bypassed-by-hook naming the file (#4588)', () => {
for (const isolationMode of [undefined, 'harness-worktree']) {
const result = evaluateWorktreeBaseDegrade({
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(FORK_SHA) }),
effectiveBaseRef: 'head',
isolationMode,
worktreeCreateHook: HOOK,
});
assert.strictEqual(result.shouldDegrade, true, `isolationMode=${isolationMode}`);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'origin/HEAD');
assert.strictEqual(result.forkSha, FORK_SHA);
assert.ok(result.message.includes('WorktreeCreate hook is configured in /repo/.claude/settings.json'), result.message);
assert.ok(!/harness does not honor/.test(result.message), 'the hook, not the harness, is named');
// origin/HEAD says nothing about where a hook forks, so the remedy must not promise that
// pushing restores parallelism; it points at a measurement instead (#4588 round review).
assert.ok(!/merged\/pushed/.test(result.message), result.message);
assert.ok(result.message.includes('--observed-fork-base'), result.message);
assert.ok(result.message.includes('remove the hook'), result.message);
}
});
test('head + harness mode + hook + HEAD equal to origin/HEAD → no degrade, reason head-matches-fork (#4588)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(HEAD_SHA) }),
effectiveBaseRef: 'head',
worktreeCreateHook: HOOK,
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'head-matches-fork');
});
test('an unparseable settings layer degrades with the same reason and says the hook cannot be ruled out (#4588)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(FORK_SHA) }),
effectiveBaseRef: 'head',
worktreeCreateHook: { file: '/repo/.claude/settings.local.json', kind: 'unparseable' },
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
assert.ok(result.message.includes('/repo/.claude/settings.local.json could not be parsed'), result.message);
assert.ok(result.message.includes('fix /repo/.claude/settings.local.json so it parses'), result.message);
assert.ok(!/merged\/pushed/.test(result.message), result.message);
});
test('hook + head + orchestrator-worktree mode → baseref-head unchanged, execGit never called (#4588)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: () => { throw new Error('execGit must not be called'); },
effectiveBaseRef: 'head',
isolationMode: 'orchestrator-worktree',
worktreeCreateHook: HOOK,
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
});
test('hook + head + an observed fork base → the observation decides, not the hook (#4588)', () => {
const match = evaluateWorktreeBaseDegrade({
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA) }),
effectiveBaseRef: 'head',
observedForkBase: HEAD_SHA,
worktreeCreateHook: HOOK,
});
assert.strictEqual(match.reason, 'observed-fork-matches-head');
const mismatch = evaluateWorktreeBaseDegrade({
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA) }),
effectiveBaseRef: 'head',
observedForkBase: FORK_SHA,
worktreeCreateHook: HOOK,
});
assert.strictEqual(mismatch.reason, 'baseref-head-ignored-by-harness');
});
test('hook without "head" set → the ordinary divergence verdict, not the hook reason (#4588)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: stubGit({ 'rev-parse HEAD': ok(HEAD_SHA), 'rev-parse --verify --quiet origin/HEAD': ok(FORK_SHA) }),
effectiveBaseRef: 'fresh',
worktreeCreateHook: HOOK,
});
assert.strictEqual(result.reason, 'head-diverged-from-fork');
});
test('no hook finding (null or omitted) → head still short-circuits to baseref-head (#4588)', () => {
for (const worktreeCreateHook of [null, undefined]) {
const result = evaluateWorktreeBaseDegrade({
execGit: () => { throw new Error('execGit must not be called'); },
effectiveBaseRef: 'head',
worktreeCreateHook,
});
assert.strictEqual(result.reason, 'baseref-head', `worktreeCreateHook=${worktreeCreateHook}`);
}
});
});
describe('cmdWorktreeBaseCheck — WorktreeCreate hook interlock (#4588)', () => {
const cwd = '/repo';
const claudeDir = '/repo/.claude';
const USER_CLAUDE_DIR = '/home/user/.claude';
const LOCAL = path.join(claudeDir, 'settings.local.json');
const SHARED = path.join(claudeDir, 'settings.json');
const USER = path.join(USER_CLAUDE_DIR, 'settings.json');
const HEAD_SHA = '0a0a0a0a1b1b1b1b2c2c2c2c3d3d3d3d4e4e4e4e';
const FORK_SHA = '5f5f5f5f6a6a6a6a7b7b7b7b8c8c8c8c9d9d9d9d';
const ok = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
function divergedGit() {
return (args) => {
const key = args.join(' ');
if (key === 'rev-parse HEAD') return ok(HEAD_SHA);
if (key === 'rev-parse --verify --quiet origin/HEAD') return ok(FORK_SHA);
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
function run(files, args = []) {
return cmdWorktreeBaseCheck(cwd, args, {
readFile: settingsReader(files),
execGit: divergedGit(),
write: () => {},
userClaudeDir: USER_CLAUDE_DIR,
});
}
test('head set + a WorktreeCreate hook in each layer read + default harness mode → degrade, baseref-head-bypassed-by-hook (#4588)', () => {
for (const hookFile of [LOCAL, SHARED, USER]) {
const files = { [hookFile]: HOOK_SETTINGS };
// head comes from a layer that does not carry the hook, except when the hook shares project-local
files[hookFile === LOCAL ? USER : LOCAL] = JSON.stringify({ worktree: { baseRef: 'head' } });
const result = run(files);
assert.strictEqual(result.shouldDegrade, true, `hook in ${hookFile}`);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook', `hook in ${hookFile}`);
assert.ok(result.message.includes(hookFile), result.message);
}
});
test('head and the hook in the same file also degrades (#4588)', () => {
const both = JSON.stringify({ worktree: { baseRef: 'head' }, hooks: JSON.parse(HOOK_SETTINGS).hooks });
const result = run({ [SHARED]: both });
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
});
test('the same hook changes nothing under --mode orchestrator-worktree or with --observed-fork-base (#4588)', () => {
const files = { [LOCAL]: JSON.stringify({ worktree: { baseRef: 'head' } }), [SHARED]: HOOK_SETTINGS };
assert.strictEqual(run(files, ['--mode', 'orchestrator-worktree']).reason, 'baseref-head');
assert.strictEqual(run(files, ['--observed-fork-base', HEAD_SHA]).reason, 'observed-fork-matches-head');
assert.strictEqual(run(files, ['--observed-fork-base', FORK_SHA]).reason, 'baseref-head-ignored-by-harness');
});
test('head set + no WorktreeCreate hook in any layer → baseref-head, the #4588 trust stays (#4588)', () => {
const result = run({
[LOCAL]: JSON.stringify({ worktree: { baseRef: 'head' }, hooks: { PreToolUse: [{ hooks: [] }] } }),
[USER]: JSON.stringify({ hooks: { WorktreeCreate: [] } }),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
});
test('head from the user layer + a project-local layer that does not parse → fails closed and degrades (#4588)', () => {
// resolveEffectiveBaseRef skips the unparseable layer and still resolves "head" from the
// user layer; the interlock treats that same layer as a possible hook, so the two stay
// consistent in direction — neither ever trusts "head" on the strength of a file it could not read.
const result = run({ [LOCAL]: '{ "worktree": ', [USER]: JSON.stringify({ worktree: { baseRef: 'head' } }) });
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
assert.ok(result.message.includes(`${LOCAL} could not be parsed`), result.message);
});
});
describe('quick: pre-dispatch worktree base re-check (#1941)', () => {
test('workflow file exists', () => {
assert.ok(fs.existsSync(QUICK_WORKFLOW_PATH), 'workflows/quick.md should exist');
@@ -1407,6 +1938,31 @@ describe('execute-plan Pattern A: pre-dispatch worktree base-check (#2649)', ()
});
});
// The base-check prose moved out of execute-phase.md into its own step file
// (#4683/#4828) while this fix was in review, and the move carried the retired
// claim that harness-isolated runtimes ignore worktree.baseRef. Pin the step
// file itself, so a later extraction cannot quietly bring the claim back.
const WORKTREE_BASE_CHECK_STEP_PATH = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'worktree-base-check.md',
);
describe('execute-phase worktree_base_check step prose (#4588)', () => {
test('cites #4588 and names both exceptions to the baseRef:"head" trust', () => {
const content = fs.readFileSync(WORKTREE_BASE_CHECK_STEP_PATH, 'utf-8');
assert.ok(content.includes('#4588'), 'the step must cite #4588');
assert.ok(content.includes('WorktreeCreate'),
'the step must name the WorktreeCreate-hook exception that withholds the "head" trust');
assert.ok(content.includes('--observed-fork-base'),
'the step must say a supplied observation is compared against HEAD, not trusted');
});
test('does not claim harness-isolated runtimes ignore the setting', () => {
const content = fs.readFileSync(WORKTREE_BASE_CHECK_STEP_PATH, 'utf-8');
assert.ok(!/harness-isolated runtimes[^.]{0,200}do\s+not\s+read\s+the\s+setting/i.test(content.replace(/\s+/g, ' ')),
'the step must not carry the retired #48 claim that harness-isolated runtimes do not read baseRef');
});
});
});
}
@@ -1771,3 +2327,199 @@ describe('#4588 A2: a clean prior harness worktree at the orchestrator HEAD conf
assert.strictEqual(result.reason, 'fork-from-head-observed');
});
});
// ─── #4881: the baseRef:"head" trust and the #4868 observation compose ──────────
describe('#4881: the baseRef:"head" trust and the prior-worktree observation compose', () => {
const HEAD_SHA = '1234123412341234123412341234123412341234';
const ORIGIN_SHA = 'abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd';
const WT_PATH = '/repo/.claude/worktrees/agent-hook';
const HOOK = { file: '/repo/.claude/settings.json', kind: 'hook' };
const ok = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
// A harness host: HEAD diverged from origin/HEAD, and either one clean
// agent worktree at HEAD or none at all.
function makeHostGit({ worktreeAtHead }) {
const list = worktreeAtHead
? `worktree /repo\nbranch refs/heads/feature\n\nworktree ${WT_PATH}\nbranch refs/heads/agent-hook\n`
: 'worktree /repo\nbranch refs/heads/feature\n';
return (args) => {
const key = args.join(' ');
if (key === 'rev-parse HEAD') return ok(`${HEAD_SHA}\n`);
if (key === 'worktree list --porcelain') return ok(list);
if (key === `-C ${WT_PATH} status --porcelain`) return ok('');
if (key === `-C ${WT_PATH} rev-parse HEAD`) return ok(`${HEAD_SHA}\n`);
if (key === 'rev-parse --verify --quiet origin/HEAD') return ok(`${ORIGIN_SHA}\n`);
if (key === 'symbolic-ref --quiet refs/remotes/origin/HEAD') return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null };
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('the start of every run: head + harness + no prior worktree + HEAD diverged → baseref-head, and git is never consulted (#4881)', () => {
// The state an execute-phase run is in at its first base-check and after
// every wave commit (#4881 repro state 1/4): no evidence can exist yet.
// The setting is trusted, so no worktree probe and no origin/HEAD
// comparison runs — the degrade the issue reports is unreachable.
let calls = 0;
const result = evaluateWorktreeBaseDegrade({
execGit: () => { calls += 1; throw new Error('the trust must not consult git'); },
effectiveBaseRef: 'head',
cwd: '/repo',
probeStateRead: () => { throw new Error('the trust must not read the probe cache'); },
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
assert.strictEqual(calls, 0, 'branch a returns before HEAD is resolved');
});
test('a wave commit cannot erase the verdict on the common path: the same call at a new HEAD returns baseref-head again (#4881)', () => {
// There is no per-HEAD verdict to lose — the trust is a property of the
// configuration, not of the commit — so advancing HEAD between waves
// (#4881 repro state 4) changes nothing.
for (const _head of ['1111111111111111111111111111111111111111', '2222222222222222222222222222222222222222']) {
const result = evaluateWorktreeBaseDegrade({
execGit: () => { throw new Error('the trust must not consult git'); },
effectiveBaseRef: 'head',
cwd: '/repo',
});
assert.strictEqual(result.reason, 'baseref-head');
}
});
test('head + harness + WorktreeCreate hook + a clean prior harness worktree at HEAD → baseref-head-bypassed-by-hook: the observation is withheld, not consulted (#4921)', () => {
// The stale-evidence case. A clean agent worktree sits at HEAD, so the
// #4868 probe WOULD confirm — but nothing on disk records which creator
// left it there, and a worktree the plain harness created before this hook
// was configured is indistinguishable from one the hook created. So the
// probe is not consulted at all: neither leg runs, which is why keying the
// cache by hook configuration would not have closed this (a cache miss
// falls through to the live probe and re-finds the same worktree).
// The stubs RECORD rather than throw: observeHarnessForkFromHead treats a throwing
// execGit or stateRead as an inconclusive observation and swallows it, so a throwing
// stub would fall through to the same verdict with or without the interlock and pin
// nothing. Recording keeps both assertions live under reversion.
const host = makeHostGit({ worktreeAtHead: true });
let consulted = false;
const result = evaluateWorktreeBaseDegrade({
execGit: (args) => {
if (args.join(' ') === 'worktree list --porcelain') consulted = true;
return host(args);
},
effectiveBaseRef: 'head',
cwd: '/repo',
worktreeCreateHook: HOOK,
probeStateRead: () => { consulted = true; return null; },
probeStateWrite: () => { consulted = true; },
});
assert.strictEqual(consulted, false, 'the observation must not be consulted once a hook withheld the trust');
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkSha, ORIGIN_SHA, 'the inferred comparison still governs');
});
test('head + harness + WorktreeCreate hook + no prior harness worktree → baseref-head-bypassed-by-hook: with no observation the inferred comparison governs (#4881)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeHostGit({ worktreeAtHead: false }),
effectiveBaseRef: 'head',
cwd: '/repo',
worktreeCreateHook: HOOK,
probeStateRead: () => null,
probeStateWrite: () => {},
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
assert.strictEqual(result.forkSha, ORIGIN_SHA, 'the inferred comparison still governs');
});
test('an unparseable settings layer withholds the observation the same way — a hook in it cannot be ruled out (#4921)', () => {
const host = makeHostGit({ worktreeAtHead: true });
let consulted = false;
const result = evaluateWorktreeBaseDegrade({
execGit: (args) => {
if (args.join(' ') === 'worktree list --porcelain') consulted = true;
return host(args);
},
effectiveBaseRef: 'head',
cwd: '/repo',
worktreeCreateHook: { file: '/repo/.claude/settings.local.json', kind: 'unparseable' },
probeStateRead: () => { consulted = true; return null; },
probeStateWrite: () => { consulted = true; },
});
assert.strictEqual(consulted, false, 'the observation must not be consulted once an unparseable layer withheld the trust');
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
});
test('a cached fork-from-head verdict at this HEAD does NOT survive the hook interlock (#4921)', () => {
// The cache leg stated explicitly, because the review's proposed remedy
// aimed at it: a cache entry keyed to this exact HEAD and carrying the
// confirmed verdict is still never read once a hook withheld the trust.
const host = makeHostGit({ worktreeAtHead: false });
const cached = JSON.stringify({
headSha: HEAD_SHA,
worktreePath: WT_PATH,
worktreeHead: HEAD_SHA,
verdict: 'fork-from-head-confirmed',
probedAt: '2026-09-01T00:00:00.000Z',
});
const result = evaluateWorktreeBaseDegrade({
execGit: host,
effectiveBaseRef: 'head',
cwd: '/repo',
worktreeCreateHook: HOOK,
probeStateRead: () => cached,
probeStateWrite: () => { assert.fail('the probe cache must not be written once a hook withheld the trust'); },
});
assert.strictEqual(result.shouldDegrade, true, `reason=${result.reason}`);
assert.strictEqual(result.reason, 'baseref-head-bypassed-by-hook');
});
test('the withholding is scoped to head + hook: with no setting, a hook does NOT withhold #4868 arm (#4921)', () => {
// The boundary this PR deliberately did not cross. b2 is #4868's own arm
// when `"head"` is absent, hook or not; re-scoping that trust is a separate
// question from the one #4881 opened, and is not taken here.
const result = evaluateWorktreeBaseDegrade({
execGit: makeHostGit({ worktreeAtHead: true }),
effectiveBaseRef: null,
cwd: '/repo',
worktreeCreateHook: HOOK,
probeStateRead: () => null,
probeStateWrite: () => {},
});
assert.strictEqual(result.shouldDegrade, false, `reason=${result.reason}`);
assert.strictEqual(result.reason, 'fork-from-head-observed');
});
test('an explicit observedForkBase outranks the prior-worktree probe: the probe never runs (#4881)', () => {
// A clean worktree at HEAD is present AND the caller measured a different
// fork base for this dispatch: the measurement wins and degrades. Were the
// probe consulted first, the stale worktree would suppress it.
const host = makeHostGit({ worktreeAtHead: true });
const result = evaluateWorktreeBaseDegrade({
execGit: (args) => {
if (args.join(' ') === 'worktree list --porcelain') throw new Error('the probe must not run under an explicit observation');
return host(args);
},
effectiveBaseRef: 'head',
cwd: '/repo',
observedForkBase: ORIGIN_SHA,
probeStateRead: () => { throw new Error('the probe cache must not be read under an explicit observation'); },
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'baseref-head-ignored-by-harness');
assert.strictEqual(result.forkRef, 'observed');
});
test('no setting + a clean prior harness worktree at HEAD → fork-from-head-observed, exactly as #4868 shipped it (#4881)', () => {
// The #4868 rows above run with the setting unset; this pins that #4881
// left that arm reachable and unchanged.
const result = evaluateWorktreeBaseDegrade({
execGit: makeHostGit({ worktreeAtHead: true }),
cwd: '/repo',
probeStateRead: () => null,
probeStateWrite: () => {},
});
assert.strictEqual(result.reason, 'fork-from-head-observed');
});
});