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

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