fix(#3582): route every hook's compiled-module require through the self-heal build seam (#3629)

* test(3582): failing-first cold-tree coverage and the seam drift lint

On a plugin-channel install the compiled gsd-core/bin/lib/*.cjs are legitimately
absent (ADR-457 build-at-publish; the npm package builds before publishing, a raw
tree materialization never does). gsd-tools.cjs calls ensureRuntimeBuild() before
requiring ./lib; no hook does, so the isolation guard's Cannot-find-module lands in
its fail-closed catch and is misreported as an unreadable dispatch-isolation
configuration, blocking every executor dispatch.

These tests fail on that: cold-tree runs of the isolation guard, statusline, cursor
guard and update worker, plus the seam's actionable build error surfacing instead of
the generic misreport.

Also adds the drift lint the acceptance criteria require, with a fixture proving it
CAN fail — a guard never shown to fail is worthless. It is red here by design: it
flags today's unfixed hooks, which is exactly the defect.

* fix(3582): route every hook's compiled-module require through the self-heal seam

RED proven at 5b174b0d: 11 failures — the cold-tree runs for the isolation guard,
cursor guard and update worker, the fail-closed-with-actionable-message assertion, and
the lint's own real-tree check.

The compiled runtime library is produced by build:lib and gitignored (ADR-457,
build-at-publish). The npm package builds before publishing; a plugin-marketplace or
git-clone install materializes the raw tree and never does, so on that channel those
modules are legitimately absent. The self-heal seam added by #2002 exists to heal exactly
this, and the CLI entrypoint already calls it — no hook did. The isolation guard's
Cannot-find-module therefore landed in its fail-closed catch and was reported as
'could not read or resolve dispatch-isolation configuration', so an ARTIFACT ABSENCE was
misdiagnosed as an unreadable project config and every executor dispatch was blocked.

All SEVEN affected files now call the seam before their first compiled require. The issue
named four; a scan found six; implementing it surfaced a seventh — the shared isolation
sentinel helper, used by BOTH guards, which requires two compiled modules itself and
would have defeated the guards' own fix on a genuinely cold tree. Same defect class, so
fixed here rather than left as a known-broken remainder.

Failure posture is deliberately split by hook kind:
- Gates (agent isolation guard, cursor subagent start) surface the seam's actionable
  build error distinctly instead of swallowing it into the generic text, and stay
  fail-closed — a genuinely unreadable project config still DENIES exactly as before.
- Cosmetic and detached hooks (statusline, update worker, update check, update banner)
  DEGRADE rather than crash: the statusline draws on every render and the worker is a
  detached process, so a build failure there must not take down the prompt.

The npm path is untouched: the seam's already-built fast path returns immediately, so
prebuilt installs pay nothing and behave bit-for-bit as before.

Adds a drift lint, wired into the CI lint chain, so the invariant is enforced rather than
remembered — without it the next hook to add a compiled require reintroduces the class
silently. It is proven able to fail: a fixture hook requiring a compiled module without
the seam is flagged, and one that uses the seam is not. Verified directly — on the
unfixed tree it named all seven offenders; with the fix it passes.

While writing the lint's comment stripper, a naive whole-text block-comment regex ate its
own fixture, because this repo's comments legitimately spell the compiled-lib glob whose
star-slash reads as a comment opener. Rewritten as a line-based scanner with a regression
test pinning that case.

* fix(3582): test the three untested seam call sites and assert typed reason codes

Two independent reviews converged on the same major gap: the fix wired the seam into
seven files but only four had cold-tree tests. The adversarial pass put it plainly —
deleting the shared isolation-sentinel helper's seam call would not have failed any test
in the diff. That file was my own addition beyond the issue's four, so it shipped
untested; that is now closed.

- Shared isolation-sentinel helper: its seam call is only reached when .planning is NOT
  directly under cwd, and every existing cold-tree fixture puts it there, so the early
  return always fired first. Now covered, and proven load-bearing by mutation: with the
  call removed the spy records zero seam invocations and the test fails.
- update-check hook and update-banner hook: cold-tree tests added asserting the DEGRADED
  VERDICT — the fallback cache filename, and silent suppression when the package name
  degrades to null — rather than merely 'did not throw'. The banner hook previously had
  no test file at all.

Standards violation fixed: two tests asserted on free-form prose via assert.match against
a JSON reason string, which CONTRIBUTING bans by name — its own BAD example is exactly
that. The ESLint rule only covers readFileSync/spawnSync text, so tooling did not catch
it. Both isolation guards now emit a machine-readable reason_code from a frozen enum,
following the repo's existing REASON convention, and the tests assert that instead. The
human-readable message is unchanged for operators; only the assertion target moved.

The duplicated degrade boilerplate across the three cosmetic hooks was deliberately NOT
extracted, and the reason is recorded at each site: both viable shapes — a
path-parameterized helper, or a ceremony-only wrapper — defeat the drift lint's per-file
literal co-occurrence check, so extracting would require the lint to special-case its own
helper. Triplication is the lesser evil while the lint stays a co-occurrence scan.

The lint's header now states what it does and does not catch (literal quoted requires
only; hooks/ scan root), so a future reader does not over-trust a guard that a
concatenated path or a require inside a non-hooks helper would evade.

* chore(3582): regenerate the committed install-tree fixtures

Adding a new shipped hook helper changed the install tree, and those fixtures are
committed-and-derived (regen:derived / gen:install-tree), so 12 'install tree — <runtime>'
tests failed on 541a1913. Regenerated rather than hand-edited.

The delta across all 15 runtime fixtures is exactly two lines — the new helper under both
its hooks/ and gsd-hooks/ install paths — and nothing else, so the regeneration pulled in
no unrelated drift.

This is the bookkeeping ripple a new file under hooks/ carries; it was not visible from
lint:ci, which passed both before and after.

* chore(3582): backfill changeset PR number (#3629)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-18 14:11:23 -04:00
committed by GitHub
parent cc3fd4548d
commit bf2332e67c
31 changed files with 1250 additions and 27 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3629
---
**Executor dispatch no longer blocks on a plugin-marketplace install** — the compiled runtime library is a build artifact produced at publish time and gitignored, so a plugin or git-clone install materializes a tree that never has it. Every hook that required one of those modules did so without the existing self-heal build seam that the CLI entrypoint already calls, so the agent-isolation guard's missing-module error landed in its fail-closed catch and was reported as `could not read or resolve dispatch-isolation configuration` — blocking every `gsd-executor` dispatch from the first dispatch of a session, while the statusline and update-check worker crashed at module load on the same tree. All seven affected hook files now self-heal first: the isolation guards surface the build seam's own actionable error instead of a misleading config message and stay fail-closed, and the cosmetic hooks degrade quietly rather than taking down the prompt. The guards also now emit a machine-readable `reason_code` alongside the human message. Installs from npm are unaffected — the seam's already-built fast path returns immediately. (#3582)

View File

@@ -64,6 +64,15 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js');
const { REASON_CODE } = require('./lib/isolation-deny-reason.js');
// #3582: gsd-core/bin/lib/*.cjs (runtime-name-policy.cjs, capability-registry.cjs
// below) are tsc build artifacts (ADR-457), gitignored and absent on a raw
// plugin-marketplace / git-clone install that never ran `npm run build:lib`.
// Self-heal before the first such require (resolveRegistryIsolation, below) —
// see ensureRuntimeBuild's own header for the full rationale. This module
// itself (gsd-core/bin/ensure-runtime-build.cjs) depends on nothing under
// ./lib, so requiring it here is always safe.
const { ensureRuntimeBuild, RuntimeBuildError } = require('../gsd-core/bin/ensure-runtime-build.cjs');
// No other executor-shaped subagent_type exists in agents/ today
// (verified: only agents/gsd-executor.md). A Set, not a bare string compare,
@@ -239,6 +248,15 @@ function resolveHarnessFlag(runtimeId, runtimes) {
* run, e.g. a manual Agent() call before any sentinel has been written).
*/
function resolveRegistryIsolation(cwd, configPath) {
// #3582: self-heal the compiled runtime library BEFORE either require
// below — this is the only reaching path to both (resolveRegistryIsolation
// is the sole caller of each), so one call here covers both. Throws
// RuntimeBuildError on an unbuildable tree; the caller (resolveIsolationState)
// already wraps this whole function in try/catch and folds any error into
// its fail-closed `error` result — evaluateDispatch below distinguishes a
// RuntimeBuildError there so it surfaces this seam's actionable message
// instead of being misreported as an unreadable config.json (#3050 lesson).
ensureRuntimeBuild();
const { resolveRuntimeNameFromCandidates } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
@@ -416,13 +434,27 @@ function evaluateDispatch(data, { clock = Date } = {}) {
if (!state.gsdProject) return { action: 'allow' };
if (state.error) {
const reason =
`Agent isolation guard: could not read or resolve this project's dispatch-isolation ` +
`configuration ('.planning/config.json' under '${cwd}'). Refusing to dispatch ` +
`subagent_type="${subagentType}" without being able to verify whether isolation is ` +
`required — a guard that cannot verify must not answer "safe" (#3050). Retry once the ` +
`project configuration is readable.`;
return { action: 'block', reason };
// #3582: a missing/unbuildable compiled runtime library (RuntimeBuildError,
// thrown by ensureRuntimeBuild in resolveRegistryIsolation) is a DIFFERENT,
// actionable failure from an unreadable/unparsable config.json — surface
// its own message instead of misreporting it as the generic
// "could not read or resolve ... configuration" text (the exact #3050
// misreport this issue exists to fix). Both cases still fail closed
// (block); only the message differs.
const isBuildFailure = state.error instanceof RuntimeBuildError;
const reason = isBuildFailure
? `Agent isolation guard: cannot resolve this project's dispatch-isolation ` +
`configuration because the GSD runtime library failed to self-build. ` +
`${state.error.message} Refusing to dispatch subagent_type="${subagentType}" until ` +
`the runtime library is built — a guard that cannot verify must not answer "safe" ` +
`(#3050).`
: `Agent isolation guard: could not read or resolve this project's dispatch-isolation ` +
`configuration ('.planning/config.json' under '${cwd}'). Refusing to dispatch ` +
`subagent_type="${subagentType}" without being able to verify whether isolation is ` +
`required — a guard that cannot verify must not answer "safe" (#3050). Retry once the ` +
`project configuration is readable.`;
const reasonCode = isBuildFailure ? REASON_CODE.RUNTIME_BUILD_FAILED : REASON_CODE.CONFIG_UNREADABLE;
return { action: 'block', reason, reasonCode };
}
if (state.isolation !== 'harness-worktree') return { action: 'allow' };
@@ -438,7 +470,7 @@ function evaluateDispatch(data, { clock = Date } = {}) {
`${parsed.param}="${parsed.value}". Add ${parsed.param}="${parsed.value}" to the Agent() ` +
`call so the executor runs in an isolated worktree instead of the primary checkout ` +
`(gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md).`;
return { action: 'block', reason };
return { action: 'block', reason, reasonCode: REASON_CODE.HARNESS_FLAG_MISSING };
}
/* istanbul ignore next -- stdin adapter, exercised via spawnSync in tests */
@@ -453,7 +485,7 @@ function main() {
const data = JSON.parse(input);
const decision = evaluateDispatch(data);
if (decision.action === 'block') {
const out = { decision: 'block', reason: decision.reason };
const out = { decision: 'block', reason: decision.reason, reason_code: decision.reasonCode };
process.stdout.write(JSON.stringify(out));
// Kimi feeds stderr (not stdout) back to the model on exit 2.
process.stderr.write(decision.reason);

View File

@@ -11,18 +11,61 @@
const fs = require('fs');
const path = require('path');
const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
// Latest-version lookup is delegated to the single deterministic adapter
// (#498). checkLatestVersion() owns the npm-view call, the timeout/semver
// policy, and the package name — sourced from the baked Package Identity seam.
// The previous `require('../package.json').name` (#378) never yielded a name in
// the installed tree — at the time it resolved to the synthetic
// {"type":"commonjs"} marker GSD wrote at the config root, which has no `.name`,
// so the background check never reported updates. Since #2544 GSD writes no
// marker there at all, so that require would now fail to resolve outright.
// Either way the name must come from the baked seam, never a walk-up.
const { checkLatestVersion } = require('../gsd-core/bin/check-latest-version.cjs');
const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs');
// #3582: gsd-core/bin/lib/semver-compare.cjs and package-identity.cjs (and,
// transitively, check-latest-version.cjs's own gsd-core/bin/lib/cli-exit.cjs
// + shell-command-projection.cjs) are tsc build artifacts (ADR-457),
// gitignored and absent on a raw plugin-marketplace / git-clone install that
// never ran `npm run build:lib`. This worker is a DETACHED SessionStart
// background process (spawned with stdio: 'ignore') — a build failure here
// must DEGRADE to the no-signal fallbacks below (mirroring the
// managed-hooks-registry.cjs degrade just below) so the worker still runs to
// completion and writes a result cache record, rather than dying silently
// with no visible signal and no cache-file write at all.
//
// This try/require/ensureRuntimeBuild/require/catch shape repeats (with
// different destructured names) in hooks/gsd-check-update.js and
// hooks/gsd-update-banner.js. It is deliberately NOT extracted into a shared
// hooks/lib/ helper: scripts/lint-hooks-runtime-build-seam.cjs enforces this
// exact seam textually, PER FILE — it greps each hooks/ file for its OWN
// literal `require('.../ensure-runtime-build.cjs')` + `ensureRuntimeBuild(`
// call co-occurring with its OWN literal `require('.../gsd-core/bin/lib/*.cjs')`.
// A generic helper taking the compiled module's path as a variable would move
// the literal compiled-lib require OUT of this file and into the helper,
// called with a non-literal argument — the scan's regex (see that script's
// "Known limitations") cannot see a require() called with a variable, so this
// file would then read as "requires nothing" and the lint would stop
// protecting it. A ceremony-only helper (just the ensureRuntimeBuild call,
// each caller keeping its own literal compiled-lib require) fails the SAME
// way from the other side: it would remove this file's own literal
// `require('.../ensure-runtime-build.cjs')` + `ensureRuntimeBuild(` call,
// which the lint also requires to be textually present in THIS file. Either
// shape needs the lint script itself widened to special-case the helper,
// which is a bigger, riskier change than the ~6 duplicated lines it would
// save; kept inline instead.
let isSemverNewer = () => false;
let checkLatestVersion = () => ({ ok: false });
let PACKAGE_NAME = null;
try {
const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
ensureRuntimeBuild();
({ isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs'));
// Latest-version lookup is delegated to the single deterministic adapter
// (#498). checkLatestVersion() owns the npm-view call, the timeout/semver
// policy, and the package name — sourced from the baked Package Identity seam.
// The previous `require('../package.json').name` (#378) never yielded a name in
// the installed tree — at the time it resolved to the synthetic
// {"type":"commonjs"} marker GSD wrote at the config root, which has no `.name`,
// so the background check never reported updates. Since #2544 GSD writes no
// marker there at all, so that require would now fail to resolve outright.
// Either way the name must come from the baked seam, never a walk-up.
({ checkLatestVersion } = require('../gsd-core/bin/check-latest-version.cjs'));
({ PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs'));
} catch (e) {
// Runtime library missing/broken and could not self-build — degrade to the
// no-signal fallbacks declared above; the worker still writes a result
// cache record (package_name: null, update_available: false).
}
// Authoritative list of managed hooks — shared with tests to retire source-grep
// assertions (pending-migration-to-typed-ir [#455]).
// NOTE: managed-hooks-registry.cjs must be in HOOKS_TO_COPY (scripts/build-hooks.js)

View File

@@ -8,7 +8,25 @@ const path = require('path');
const os = require('os');
const { spawn } = require('child_process');
const { updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
// #3582: gsd-core/bin/lib/package-identity.cjs is a tsc build artifact
// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone
// install that never ran `npm run build:lib`. This SessionStart hook must
// DEGRADE (fall back to a generic cache filename) rather than crash session
// start. gsd-check-update-worker.js — the process this hook spawns — degrades
// identically and independently, so the shared fallback literal keeps the
// cache path consistent between writer and reader even in the (rare)
// doubly-degraded case. This try/require/ensureRuntimeBuild/require/catch
// shape is deliberately duplicated (not extracted to hooks/lib/) — see
// gsd-check-update-worker.js's identical #3582 comment for why.
let updateCacheFileName = 'gsd-update-check.json';
try {
const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
ensureRuntimeBuild();
({ updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'));
} catch (e) {
// Runtime library missing/broken and could not self-build — degrade to the
// fallback filename above rather than crash the SessionStart hook.
}
const homeDir = os.homedir();
const cwd = process.cwd();

View File

@@ -62,6 +62,15 @@ const os = require('os');
// writeCursorHooksJson so the require always resolves post-install.
const { resolveStatePath } = require('./lib/cursor-workspace.js');
const { readSentinel, VALID_ISOLATION, extractDispatchIdentifiers, sentinelAppliesToDispatch } = require('./lib/isolation-sentinel.js');
const { REASON_CODE } = require('./lib/isolation-deny-reason.js');
// #3582: gsd-core/bin/lib/*.cjs (runtime-homes.cjs, worktree-safety.cjs,
// runtime-name-policy.cjs, capability-registry.cjs — required below, inside
// resolveIsolationEvidence and resolveFallbackIsolation) are tsc build
// artifacts (ADR-457), gitignored and absent on a raw plugin-marketplace /
// git-clone install that never ran `npm run build:lib`. Self-heal once, in
// evaluateRootIsolation, before any of those four requires run — see the
// call site below. This module itself depends on nothing under ./lib.
const { ensureRuntimeBuild, RuntimeBuildError } = require('../gsd-core/bin/ensure-runtime-build.cjs');
const MSG_PRESENT =
'GSD: Subagent session started — review .planning/STATE.md for the current phase and any blockers before acting.';
@@ -458,6 +467,28 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
}
if (!isGsdProject) return { action: 'allow' };
// #3582: self-heal the compiled runtime library BEFORE any of its four
// downstream requires (resolveFallbackIsolation's two, resolveIsolationEvidence's
// two — reached only below this point). Checked separately from the
// sentinel/fallback try block below so a build failure surfaces its own
// actionable RuntimeBuildError message rather than being folded into the
// generic "could not read or resolve ... configuration" deny reason (the
// #3050 misreport this issue exists to fix). Still fails closed either way.
try {
ensureRuntimeBuild();
} catch (err) {
return {
action: 'deny',
reason:
`GSD subagent isolation guard: cannot resolve this project's dispatch-isolation ` +
`configuration because the GSD runtime library failed to self-build. ` +
`${err instanceof RuntimeBuildError ? err.message : String(err && err.message || err)} ` +
`Refusing to allow this subagent to spawn until the runtime library is built — a guard ` +
`that cannot verify must not answer "safe" (#3050).`,
reasonCode: REASON_CODE.RUNTIME_BUILD_FAILED,
};
}
let declaredIsolation;
try {
// #3045 BLOCKER fix: a fresh sentinel is authoritative for THIS
@@ -478,6 +509,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
`Refusing to allow this subagent to spawn without being able to verify whether ` +
`isolation is required — a guard that cannot verify must not answer "safe" (#3050). ` +
`Retry once the project configuration is readable.`,
reasonCode: REASON_CODE.CONFIG_UNREADABLE,
};
}
@@ -494,6 +526,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
`"harness-worktree", but the subagentStart payload for this dispatch carries no usable ` +
`subagent_type. Refusing to allow it to spawn without being able to confirm whether it ` +
`is a GSD executor — a guard that cannot verify must not answer "safe" (#3050).`,
reasonCode: REASON_CODE.NO_SUBAGENT_TYPE,
};
}
@@ -510,6 +543,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
`could not be determined (git did not respond). Refusing to allow subagent_type=` +
`"${subagentType}" to spawn without being able to verify isolation — a guard that ` +
`cannot verify must not answer "safe" (#3050). Retry once git is responsive.`,
reasonCode: REASON_CODE.CANNOT_DETERMINE_ISOLATION,
};
}
@@ -522,6 +556,7 @@ function evaluateRootIsolation(root, subagentType, { clock = Date, dispatchIds =
`directly, with no consent and no warning. Start an isolated session first (the ` +
`"--worktree" CLI flag or the "/worktree" chat command; Cursor manages these worktrees ` +
`under "~/.cursor/worktrees/") and retry.`,
reasonCode: REASON_CODE.NOT_ISOLATED_WORKTREE,
};
}
@@ -571,7 +606,7 @@ function main() {
decision = { action: 'allow' };
}
if (decision.action === 'deny') {
const out = { permission: 'deny', user_message: decision.reason };
const out = { permission: 'deny', user_message: decision.reason, reason_code: decision.reasonCode };
if (additionalContext !== null) out.additional_context = additionalContext;
process.stdout.write(JSON.stringify(out));
return;

View File

@@ -9,6 +9,24 @@ const os = require('os');
// Namespace (not destructured) so tests can inject spawn failures by
// monkeypatching childProcess.execFileSync.
const childProcess = require('child_process');
// #3582: gsd-core/bin/lib/*.cjs (semver-compare.cjs, state-document.cjs,
// active-workstream-store.cjs, planning-workspace.cjs — required below) and
// package-identity.cjs are tsc build artifacts (ADR-457), gitignored and
// absent on a raw plugin-marketplace / git-clone install that never ran
// `npm run build:lib`. The statusline renders on EVERY prompt, so a build
// failure here must DEGRADE (print nothing, exit 0) rather than crash
// Claude Code's per-render statusline hook. Scoped to the spawned-as-a-script
// path (`require.main === module`) — a test `require()` of this module for
// its pure helpers assumes a built tree, same as every other hook test.
if (require.main === module) {
try {
const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
ensureRuntimeBuild();
} catch (e) {
process.stdout.write('');
process.exit(0);
}
}
const { isSemverNewer } = require('../gsd-core/bin/lib/semver-compare.cjs');
const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs');

View File

@@ -15,7 +15,28 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs');
// #3582: gsd-core/bin/lib/package-identity.cjs is a tsc build artifact
// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone
// install that never ran `npm run build:lib`. This is an opt-in SessionStart
// hook — a build failure here must DEGRADE, not crash session start. With
// PACKAGE_NAME left null, buildBannerOutput's own lineage guard
// (`!cache.package_name || cache.package_name !== PACKAGE_NAME`) always
// treats the cache as untrusted, so main() falls through to its existing
// silent "print nothing" path below — no separate degrade branch needed.
// This try/require/ensureRuntimeBuild/require/catch shape is deliberately
// duplicated (not extracted to hooks/lib/) — see
// gsd-check-update-worker.js's identical #3582 comment for why.
let PACKAGE_NAME = null;
let updateCacheFileName = 'gsd-update-check.json';
try {
const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
ensureRuntimeBuild();
({ PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'));
} catch (e) {
// Runtime library missing/broken and could not self-build — degrade to the
// fallbacks above rather than crash the SessionStart hook.
}
// Suppress repeat parse-error banners for 24 hours so a genuinely broken
// cache file doesn't nag the user every session.

View File

@@ -0,0 +1,39 @@
'use strict';
// hooks/lib/isolation-deny-reason.js — shared, frozen reason-code enum for
// the #3045 dispatch-isolation guards' block/deny decisions
// (hooks/gsd-agent-isolation-guard.js, hooks/gsd-cursor-subagent-start.js).
//
// CONTRIBUTING.md ("Prohibited: Raw Text Matching on Test Outputs") bans
// asserting on a hook's free-form, human-readable reason/user_message prose
// — that text is for the operator/model reading the denial and may change
// wording without notice. Every block/deny decision therefore ALSO carries
// one of these STABLE codes (surfaced on the hook's stdout JSON as
// `reason_code`), so tests assert `out.reason_code === REASON_CODE.X`
// instead of regexing the message (mirrors the REASON enum convention in
// gsd-core/bin/verify-reapply-patches.cjs).
//
// Adding a new code requires updating this enum AND any test that locks the
// documented set.
const REASON_CODE = Object.freeze({
// The compiled runtime library (gsd-core/bin/lib/*.cjs) is missing and
// could not be self-built (ensure-runtime-build.cjs's RuntimeBuildError).
RUNTIME_BUILD_FAILED: 'runtime_build_failed',
// The project's dispatch-isolation configuration ('.planning/config.json')
// could not be read or resolved for a reason OTHER than a runtime-build
// failure (unreadable/malformed config, unexpected resolver error).
CONFIG_UNREADABLE: 'config_unreadable',
// Isolation resolves to "harness-worktree" but the Agent()/Task() dispatch
// is missing the harness's isolation flag/kwarg.
HARNESS_FLAG_MISSING: 'harness_flag_missing',
// Isolation resolves to "harness-worktree" but the dispatch payload carries
// no usable subagent_type, so the guard cannot confirm it is a GSD executor.
NO_SUBAGENT_TYPE: 'no_subagent_type',
// Isolation resolves to "harness-worktree" but whether the workspace root
// is an isolated worktree could not be determined (e.g. git unresponsive).
CANNOT_DETERMINE_ISOLATION: 'cannot_determine_isolation',
// Isolation resolves to "harness-worktree" and the workspace root is
// confirmed NOT an isolated worktree.
NOT_ISOLATED_WORKTREE: 'not_isolated_worktree',
});
module.exports = { REASON_CODE };

View File

@@ -130,6 +130,15 @@ function resolveSentinelRoot(cwd) {
if (fs.existsSync(path.join(cwd, '.planning'))) {
return cwd;
}
// #3582: worktree-safety.cjs / project-root.cjs are tsc build artifacts
// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone
// install that never ran `npm run build:lib`. Self-heal before either
// require below; a RuntimeBuildError (or any other failure) falls through
// to the existing catch's degrade-to-raw-`cwd` — unchanged behavior, just
// now attempted-healed-first rather than silently degrading on the first
// cold-tree encounter.
const { ensureRuntimeBuild } = require('../../gsd-core/bin/ensure-runtime-build.cjs');
ensureRuntimeBuild();
const { resolveWorktreeRoot } = require('../../gsd-core/bin/lib/worktree-safety.cjs');
const { root } = resolveWorktreeRoot(cwd);
const { findProjectRoot } = require('../../gsd-core/bin/lib/project-root.cjs');

View File

@@ -119,7 +119,7 @@
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
"lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs",
"lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && node scripts/check-contract-drift.cjs",
"lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-unreachable-guard-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-state-write-path-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-planning-artifact-writer-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs && node scripts/lint-no-adhoc-regex-escape.cjs && node scripts/lint-vendored-deps.cjs && npm run lint:hooks-runtime-build-seam && node scripts/check-contract-drift.cjs",
"lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
"lint:descriptions": "node scripts/lint-descriptions.cjs",
@@ -132,6 +132,7 @@
"lint:qa-smells": "node scripts/qa-smell-ratchet.cjs",
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
"lint:docs-command-form": "node scripts/lint-docs-command-form.cjs",
"lint:hooks-runtime-build-seam": "node scripts/lint-hooks-runtime-build-seam.cjs",
"ci:test-scope": "node scripts/ci-test-scope.cjs",
"changeset": "node scripts/changeset/new.cjs",
"changelog:render": "node scripts/changeset/cli.cjs render",

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env node
'use strict';
/**
* lint-hooks-runtime-build-seam.cjs — every `hooks/**` file that requires a
* compiled runtime-library module (`gsd-core/bin/lib/*.cjs`) must also go
* through the self-healing build seam (`gsd-core/bin/ensure-runtime-build.cjs`,
* #2002) before that require can be reached (#3582).
*
* ## Why
*
* `gsd-core/bin/lib/*.cjs` are compiled from `src/*.cts` by `npm run
* build:lib` (ADR-457, "build-at-publish") and are gitignored. The npm
* package builds before publishing, so the artifacts exist on that path.
* `gsd-core/bin/gsd-tools.cjs` calls `ensureRuntimeBuild()` before requiring
* its own `./lib` — but a plugin-marketplace / git-clone install that never
* runs `npm run build:lib` materializes the raw tree with the compiled
* `./lib` absent, and NO hook self-healed before this issue. A hook that
* requires a compiled module directly dies at module load (or first call)
* with `Cannot find module`, and — in a guard whose own catch treats "cannot
* resolve" as fail-closed — that `Cannot find module` gets misreported as an
* unrelated "could not read or resolve …configuration" denial instead of the
* seam's own actionable message (#3050 lesson: a guard that cannot verify
* must not silently misreport why).
*
* ## What this enforces
*
* For every `.js`/`.cjs` file under `hooks/` (excluding the generated
* `hooks/dist/` build-output copy, which is not source): if the file's code
* (comments stripped) contains a `require(...)` of a path that names
* `gsd-core/bin/lib/<name>.cjs` (any relative-path spelling — `../gsd-core/…`,
* `../../gsd-core/…`, etc. — the segment `gsd-core/bin/lib/` plus a trailing
* `.cjs` is the discriminator, not the exact prefix), the SAME file must ALSO
* contain BOTH:
* 1. a `require(...)` of a path naming `gsd-core/bin/ensure-runtime-build.cjs`
* (the seam module itself), AND
* 2. an actual INVOCATION `ensureRuntimeBuild(` — not merely a destructuring
* import with no call, which would import the seam but never run it.
*
* File-level co-occurrence, not line-by-line ordering: this repo's hooks
* define helper functions in whatever order reads best and call them in a
* different order at runtime (e.g. `hooks/gsd-cursor-subagent-start.js`'s
* `resolveFallbackIsolation` is defined textually ABOVE the single
* `ensureRuntimeBuild()` call site in `evaluateRootIsolation`, which is its
* only caller) — a strict "seam call must appear on an earlier LINE than the
* require" check would false-positive on exactly that, real, correct
* pattern. This guard is therefore a drift ratchet at the file level ("did
* the seam get wired in at all"), not a full control-flow verifier; exact
* placement (does the call precede every reaching path) is a code-review
* concern, same tradeoff every other structural drift guard in this repo
* makes (see e.g. `lint-unreachable-guard-drift.cjs`'s own per-line, not
* per-flow, scope).
*
* ## What PASSES
*
* - A hook that never requires `gsd-core/bin/lib/*.cjs` at all (most of
* `hooks/`) — nothing to check.
* - A hook that requires a compiled module AND requires + calls
* `ensureRuntimeBuild()` somewhere in the same file.
* - A prose comment that mentions `gsd-core/bin/lib/…` without a real,
* well-formed `require('....cjs')` call (comments are stripped before
* scanning).
*
* ## What FAILS
*
* - A hook that requires a compiled `gsd-core/bin/lib/*.cjs` module but never
* requires `ensure-runtime-build.cjs`, or requires it but never calls
* `ensureRuntimeBuild(...)`.
*
* ## Known limitations (documented, not defects — read before trusting a green run)
*
* - **Literal-string matching only.** `REQUIRE_RE` matches `require(...)`
* called with a single- OR double-quoted string literal argument. A
* concatenated/computed path (`require(base + '/gsd-core/bin/lib/x.cjs')`),
* a template literal (`` require(`${dir}/x.cjs`) ``), or `createRequire(...)`
* / `require.resolve` used indirectly all evade `isCompiledLibRequire` and
* `isSeamRequire` — none of them appear as a literal quoted `require(...)`
* call, so a file using one of these forms scans as "requires nothing",
* even if it genuinely needs the seam.
* - **`hooks/` only.** `scanRepo`'s `walk()` only descends `SCAN_ROOT`
* (`hooks/`, minus `hooks/dist/`). A compiled `gsd-core/bin/lib/*.cjs`
* require sitting inside a NON-hooks helper module that a hook file then
* requires (directly or transitively) is invisible to this scan — the scan
* only ever reads the hook file's OWN text, never follows its require
* graph. Deliberate: this is a same-file drift ratchet ("did the hook wire
* the seam in at all"), not a whole-program static analyzer.
*
* Because of both limits, a file that passes this lint is not a
* correctness proof — it is a co-occurrence heuristic that catches the
* exact regression shape #3582 fixed (a bare literal compiled-lib require
* with no seam call anywhere in the same hook file). Anything routed around
* a literal require or around `hooks/` needs a code-review check, not this
* script, to catch a missing self-heal.
*/
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.join(__dirname, '..');
const SCAN_ROOT = 'hooks';
const SCAN_EXT = new Set(['.js', '.cjs']);
// hooks/dist/ is a generated build-output copy (scripts/build-hooks.js),
// gitignored and not source — never scanned.
const EXCLUDE_DIR_NAMES = new Set(['dist']);
// Line-based comment stripper (deliberately NOT the naive two-regex
// `/\*[\s\S]*?\*\//g` then `//` approach other tests in this repo use for
// simpler files): this module's own source comments legitimately spell
// `gsd-core/bin/lib/*.cjs` (a glob) inside a `//` line, which forms a bare
// `/*` token — a whole-text block-comment regex applied naively would read
// that as an OPENING block comment and swallow everything up to the next
// unrelated `*/` anywhere later in the file, silently deleting real code
// (verified while authoring this guard: it ate the very require() line the
// guard exists to check for). Processing line-by-line and stripping a
// trailing `//` comment BEFORE ever checking that line's remainder for `/*`
// means a `/*`-shaped token inside a `//` comment is never seen at all.
function stripComments(text) {
const lines = text.split(/\r?\n/);
const out = [];
let inBlock = false;
for (const raw of lines) {
let line = raw;
if (inBlock) {
const end = line.indexOf('*/');
if (end === -1) { out.push(''); continue; }
line = line.slice(end + 2);
inBlock = false;
}
if (line.trim().startsWith('//')) { out.push(''); continue; }
// Strip a trailing `//` comment (not a `://` URL) BEFORE any `/*` check
// on the remainder — see the function header for why this ordering
// matters.
const trailing = /(^|[^:])\/\/.*$/.exec(line);
if (trailing) line = line.slice(0, trailing.index + trailing[1].length);
// Same-line and newly-opened block comments in what remains.
for (;;) {
const start = line.indexOf('/*');
if (start === -1) break;
const end = line.indexOf('*/', start + 2);
if (end !== -1) {
line = line.slice(0, start) + line.slice(end + 2);
// keep scanning from the same position in case of a second
// same-line block comment
} else {
line = line.slice(0, start);
inBlock = true;
break;
}
}
out.push(line);
}
return out.join('\n');
}
// Captures the quoted path of every require(...) call. Single bounded
// negated-class quantifier per alternative, no nesting.
const REQUIRE_RE = /require\(\s*(['"])([^'"]+)\1\s*\)/g;
function isCompiledLibRequire(requirePath) {
return requirePath.includes('gsd-core/bin/lib/') && requirePath.endsWith('.cjs');
}
function isSeamRequire(requirePath) {
return requirePath.endsWith('gsd-core/bin/ensure-runtime-build.cjs');
}
// An actual invocation, not merely a `{ ensureRuntimeBuild }` destructuring
// import — the identifier immediately followed by `(`.
const SEAM_CALL_RE = /\bensureRuntimeBuild\s*\(/;
/**
* Pure: scan one file's contents for the violation described above.
* Returns `{ compiledLibRequires: string[], hasSeamRequire: boolean,
* hasSeamCall: boolean }`. `compiledLibRequires` is `[]` when the file
* requires no compiled module — the caller treats that as "nothing to
* check" regardless of the other two fields.
*/
function scanFile(text) {
const code = stripComments(text);
const compiledLibRequires = [];
let hasSeamRequire = false;
let m;
REQUIRE_RE.lastIndex = 0;
while ((m = REQUIRE_RE.exec(code)) !== null) {
const requirePath = m[2];
if (isCompiledLibRequire(requirePath)) compiledLibRequires.push(requirePath);
if (isSeamRequire(requirePath)) hasSeamRequire = true;
}
const hasSeamCall = SEAM_CALL_RE.test(code);
return { compiledLibRequires, hasSeamRequire, hasSeamCall };
}
function walk(dir) {
const out = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out; // missing root is not an error here — caller decides
}
for (const entry of entries) {
if (entry.isDirectory() && EXCLUDE_DIR_NAMES.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walk(full));
} else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
out.push(full);
}
}
return out;
}
/**
* Scan `root`'s hooks/ tree and return every violating file.
* @param {string} root repo root (or a fixture root for tests)
* @returns {{ file: string, compiledLibRequires: string[], missing: string[] }[]}
*/
function scanRepo(root) {
const violations = [];
for (const full of walk(path.join(root, SCAN_ROOT))) {
const text = fs.readFileSync(full, 'utf8');
const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text);
if (compiledLibRequires.length === 0) continue;
if (hasSeamRequire && hasSeamCall) continue;
const missing = [];
if (!hasSeamRequire) missing.push('require(".../gsd-core/bin/ensure-runtime-build.cjs")');
if (!hasSeamCall) missing.push('a call to ensureRuntimeBuild(...)');
violations.push({
file: path.relative(root, full).split(path.sep).join('/'),
compiledLibRequires,
missing,
});
}
return violations;
}
function main() {
const violations = scanRepo(ROOT);
if (violations.length > 0) {
const detail = violations
.map((v) => (
` ${v.file}\n` +
` requires: ${v.compiledLibRequires.join(', ')}\n` +
` missing: ${v.missing.join(' AND ')}`
))
.join('\n');
throw new ExitError(
1,
`lint-hooks-runtime-build-seam: ${violations.length} hooks/ file(s) require a compiled\n` +
`gsd-core/bin/lib/*.cjs module without also self-healing via\n` +
`ensureRuntimeBuild() from gsd-core/bin/ensure-runtime-build.cjs first (#3582) — on a\n` +
`plugin-marketplace/git-clone install that never ran \`npm run build:lib\`, that\n` +
`require crashes (or is silently misreported) instead of self-building:\n\n${detail}\n`,
);
}
console.log('ok lint-hooks-runtime-build-seam: every hooks/ compiled-lib require goes through ensureRuntimeBuild()');
}
module.exports = { scanFile, scanRepo, walk, stripComments, isCompiledLibRequire, isSeamRequire, SEAM_CALL_RE };
if (require.main === module) runMain(main);

View File

@@ -69,13 +69,16 @@ function buildProbeSource(hookPath) {
* @param {string} opts.cwd - fake cwd (project base) for this run.
* @param {object} [opts.envOverrides] - applied after HOME/USERPROFILE and
* after CLAUDE_CONFIG_DIR is deleted, so a row can reintroduce it.
* @param {string} [opts.hookPath] - override the hook under test (defaults to
* the real hooks/gsd-check-update.js). Used by the #3582 cold-tree suite
* below to point at a fixture copy instead.
*/
function probe({ homeDir, cwd, envOverrides = {} }) {
function probe({ homeDir, cwd, envOverrides = {}, hookPath = CHECK_UPDATE_PATH }) {
const childEnv = { ...process.env, HOME: homeDir, USERPROFILE: homeDir };
delete childEnv.CLAUDE_CONFIG_DIR;
Object.assign(childEnv, envOverrides);
const result = runNode(['-e', buildProbeSource(CHECK_UPDATE_PATH)], {
const result = runNode(['-e', buildProbeSource(hookPath)], {
cwd,
env: childEnv,
timeoutMs: PROBE_TIMEOUT_MS,
@@ -307,3 +310,39 @@ describe('detectConfigDir runtime behavior (#1860)', () => {
);
});
});
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degraded cache filename ─
//
// gsd-core/bin/lib/package-identity.cjs is a tsc build artifact (ADR-457),
// gitignored and absent on a raw plugin-marketplace / git-clone install that
// never ran `npm run build:lib`. This SessionStart hook degrades to the
// hardcoded fallback cache filename ('gsd-update-check.json') rather than
// crash session start (see the hook's own #3582 comment). The DEGRADED
// VERDICT this test locks is observable via the SAME spawn-env probe seam
// used above: the GSD_CACHE_FILE env var the hook hands to its worker must
// end with the fallback literal, not throw and not silently vanish.
// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the
// REAL gsd-core/bin/lib/ is never touched.
describe('gsd-check-update.js: #3582 cold tree — degrades to the fallback cache filename', () => {
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
test('missing compiled runtime library -> worker still launched, with the hardcoded fallback cache filename', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-cold-home-')));
const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cu-cold-project-')));
t.after(() => { cleanup(home); cleanup(project); });
const result = probe({
homeDir: home,
cwd: project,
hookPath: path.join(cold.hooksDir, 'gsd-check-update.js'),
});
assert.equal(
path.basename(result.cache),
'gsd-update-check.json',
`expected the hardcoded degrade fallback cache filename when package-identity.cjs cannot be built; got: ${result.cache}`,
);
});
});

View File

@@ -29,6 +29,7 @@ const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS } = require('../hooks/lib/isolation-sentinel.js');
const { REASON_CODE } = require('../hooks/lib/isolation-deny-reason.js');
const { runNode } = require('./helpers/process-seam.cjs');
const { gitOrThrow, toLegacyResult } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
@@ -997,3 +998,50 @@ describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marke
);
});
});
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — self-heal surfacing ────
//
// Mirrors tests/gsd-agent-isolation-guard.test.cjs's identical #3582 case for
// the sibling Claude guard. evaluateRootIsolation now calls
// ensureRuntimeBuild() immediately after the GSD-project existence check —
// before resolveFallbackIsolation's runtime-name-policy.cjs/
// capability-registry.cjs requires or resolveIsolationEvidence's
// runtime-homes.cjs/worktree-safety.cjs requires — so a missing compiled
// runtime library denies with the seam's own actionable message rather than
// the generic "could not read or resolve ... configuration" text (#3050).
// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the
// REAL gsd-core/bin/lib/ is never touched.
describe('gsd-cursor-subagent-start.js: #3582 cold tree — RuntimeBuildError surfaces distinctly', () => {
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
test('missing compiled runtime library -> DENY (fail-closed) with the seam\'s own actionable message, not the generic config-unreadable text', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const project = createTempDir('gsd-cs-cold-');
t.after(() => cleanup(project));
fs.mkdirSync(path.join(project, '.planning'), { recursive: true });
fs.writeFileSync(path.join(project, '.planning', 'config.json'), JSON.stringify({ runtime: 'cursor' }));
const env = { ...process.env };
delete env.GSD_RUNTIME;
delete env.CURSOR_CONFIG_DIR;
const r = toLegacyResult(runNode([path.join(cold.hooksDir, 'gsd-cursor-subagent-start.js')], {
input: JSON.stringify(subagentPayload([project])),
cwd: require('node:os').tmpdir(),
env,
timeoutMs: PROBE_TIMEOUT_MS,
}));
assert.equal(r.status, 0, `this hook always exits 0; stdout: ${r.stdout} stderr: ${r.stderr}`);
const out = JSON.parse(r.stdout);
assert.equal(out.permission, 'deny');
// Typed reason code (CONTRIBUTING.md "Prohibited: Raw Text Matching on
// Test Outputs" — assert the stable code, not the free-form
// `user_message` prose). RUNTIME_BUILD_FAILED and CONFIG_UNREADABLE are
// distinct codes, so this equality check itself proves the build
// failure is NOT misreported as the generic unreadable-config case.
assert.equal(out.reason_code, REASON_CODE.RUNTIME_BUILD_FAILED);
// `user_message` remains free-form operator-facing text — not asserted here.
assert.equal(typeof out.user_message, 'string');
assert.ok(out.user_message.length > 0);
});
});

View File

@@ -398,6 +398,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -469,6 +469,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -469,6 +469,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -398,6 +398,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -469,6 +469,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -375,6 +375,7 @@
"hooks/gsd-cursor-subagent-start.js",
"hooks/gsd-cursor-subagent-stop.js",
"hooks/lib/cursor-workspace.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/package.json",
"scripts/changeset/README.md",

View File

@@ -398,6 +398,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -469,6 +469,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -399,6 +399,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -469,6 +469,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -366,6 +366,7 @@
"gsd-hooks/lib/git-cmd.js",
"gsd-hooks/lib/gsd-graphify-rebuild.sh",
"gsd-hooks/lib/injection-patterns.js",
"gsd-hooks/lib/isolation-deny-reason.js",
"gsd-hooks/lib/isolation-sentinel.js",
"gsd-hooks/managed-hooks-registry.cjs",
"gsd-hooks/package.json",

View File

@@ -398,6 +398,7 @@
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-deny-reason.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",

View File

@@ -54,6 +54,7 @@ const { toLegacyResult, gitOrThrow } = require('./helpers/git-fixture.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const { createTempDir, createTempProject, runGsdTools, cleanup } = require('./helpers.cjs');
const { SENTINEL_RELATIVE_PATH, SENTINEL_STALE_MS, readSentinel } = require('../hooks/lib/isolation-sentinel.js');
const { REASON_CODE } = require('../hooks/lib/isolation-deny-reason.js');
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-agent-isolation-guard.js');
@@ -1271,3 +1272,52 @@ describe('#2486 regression: inspect-dispatch-isolation is the sentinel-free read
assert.ok(inspectedJson.exec, 'precondition: this branch actually populates exec, so the comparison means something');
});
});
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — self-heal surfacing ────
//
// gsd-core/bin/lib/*.cjs are tsc build artifacts (ADR-457), gitignored and
// absent on a raw plugin-marketplace / git-clone install that never ran
// `npm run build:lib`. Before #3582, resolveRegistryIsolation's
// require('../gsd-core/bin/lib/runtime-name-policy.cjs') threw a bare
// "Cannot find module", which resolveIsolationState's catch folded into the
// SAME generic "could not read or resolve ... configuration" reason as an
// unreadable config.json (row 8/12 above) — a misreport of a completely
// different failure (#3050 lesson). The fix: resolveRegistryIsolation now
// calls ensureRuntimeBuild() first; a RuntimeBuildError surfaces its own
// actionable message instead. Simulated hermetically via a fixture install
// tree that copies hooks/ + the seam module but never gsd-core/bin/lib/ or
// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the
// REAL gsd-core/bin/lib/ is never touched.
describe('gsd-agent-isolation-guard.js: #3582 cold tree — RuntimeBuildError surfaces distinctly', () => {
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
test('missing compiled runtime library -> DENY (fail-closed) with the seam\'s own actionable message, not the generic config-unreadable text', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const project = mkProject('gsd-aig-cold-');
t.after(() => cleanup(project));
writeConfig(project, JSON.stringify({ runtime: 'claude' }));
const env = { ...process.env };
delete env.GSD_RUNTIME;
const r = runHookSeam(path.join(cold.hooksDir, 'gsd-agent-isolation-guard.js'), [], {
input: JSON.stringify(agentPayload()),
cwd: project,
env,
timeoutMs: PROBE_TIMEOUT_MS,
});
const result = toLegacyResult(r);
assert.equal(result.status, 2, `expected fail-closed DENY; stdout: ${result.stdout} stderr: ${result.stderr}`);
const out = JSON.parse(result.stdout);
assert.equal(out.decision, 'block');
// Typed reason code (CONTRIBUTING.md "Prohibited: Raw Text Matching on
// Test Outputs" — assert the stable code, not the free-form `reason`
// prose). RUNTIME_BUILD_FAILED and CONFIG_UNREADABLE are distinct codes,
// so this equality check itself proves the build failure is NOT
// misreported as the generic unreadable-config case (rows 8/12 above).
assert.equal(out.reason_code, REASON_CODE.RUNTIME_BUILD_FAILED);
// `reason` remains free-form operator-facing text — not asserted here.
assert.equal(typeof out.reason, 'string');
assert.ok(out.reason.length > 0);
});
});

View File

@@ -88,6 +88,62 @@ describe('worker delegates the npm spawn (does not re-open the gate, #498)', ()
});
});
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ─────
//
// gsd-core/bin/lib/semver-compare.cjs, package-identity.cjs, and (via
// check-latest-version.cjs's own transitive requires) gsd-core/bin/lib/
// cli-exit.cjs + shell-command-projection.cjs are tsc build artifacts
// (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone
// install that never ran `npm run build:lib`. This worker is a DETACHED
// SessionStart background process (spawned with stdio: 'ignore' by
// hooks/gsd-check-update.js) — before #3582 a missing library crashed the
// worker at module load with no visible signal (stderr discarded by the
// parent) and no cache-file write at all, so the statusline/banner would
// silently never see an update signal. The fix wraps ensureRuntimeBuild()
// and the three compiled-lib requires in one try/catch and degrades to
// no-signal fallbacks (isSemverNewer -> false, checkLatestVersion -> not ok,
// PACKAGE_NAME -> null) on failure — the worker still runs to completion and
// writes a result cache record. Simulated hermetically via a fixture install
// tree that copies hooks/ + the seam module but never gsd-core/bin/lib/ or
// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the REAL
// gsd-core/bin/lib/ is never touched.
{
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
describe('gsd-check-update-worker.js: #3582 cold tree — degrade, not crash', () => {
test('missing compiled runtime library -> worker still writes a degraded result cache, no crash', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const cacheDir = createTempDir('gsd-worker-cold-');
t.after(() => cleanup(cacheDir));
const cacheFile = path.join(cacheDir, 'cache.json');
const env = {
...process.env,
GSD_CACHE_FILE: cacheFile,
GSD_PROJECT_VERSION_FILE: path.join(cacheDir, 'no-such-project', 'VERSION'),
GSD_GLOBAL_VERSION_FILE: path.join(cacheDir, 'no-such-global', 'VERSION'),
};
const r = runHookSeam(path.join(cold.hooksDir, 'gsd-check-update-worker.js'), [], {
env,
timeoutMs: 8000,
});
assert.equal(r.exitCode, 0, `worker must exit 0 on a build failure; stderr: ${r.stderr}`);
assert.ok(fs.existsSync(cacheFile), 'worker must still reach the end and write a cache record');
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
assert.equal(cache.package_name, null, 'degraded package_name must be null (no-signal, never a stale/foreign value)');
assert.ok(!cache.update_available, 'degraded update_available must be falsy');
assert.equal(cache.installed, '0.0.0', 'installed detection is unaffected by the compiled-lib degrade');
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2992-check-latest-version.test.cjs — consolidation epic #1969 (B5 #1974)
@@ -783,6 +839,58 @@ describe('gsd-update-banner.js end-to-end', () => {
});
});
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ─────
//
// gsd-core/bin/lib/package-identity.cjs is a tsc build artifact (ADR-457),
// gitignored and absent on a raw plugin-marketplace / git-clone install that
// never ran `npm run build:lib`. This is an opt-in SessionStart hook — a
// build failure must degrade (PACKAGE_NAME stays null), not crash session
// start. The DEGRADED VERDICT this locks: buildBannerOutput's own lineage
// guard (`!cache.package_name || cache.package_name !== PACKAGE_NAME`)
// unconditionally distrusts ANY cache once PACKAGE_NAME is null, so even a
// cache written by a healthy worker (real package_name, update_available:
// true) must be suppressed rather than surfaced — the hook stays SILENT
// (exit 0, empty stdout), never a crash and never a stale/wrong banner.
// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the
// REAL gsd-core/bin/lib/ is never touched.
describe('gsd-update-banner.js: #3582 cold tree — degrades to silent, never crashes', () => {
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
test('missing compiled runtime library -> exits 0 with empty stdout even for an otherwise-valid update-available cache', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-banner-cold-home-'));
t.after(() => cleanup(home));
fs.mkdirSync(path.join(home, '.cache', 'gsd'), { recursive: true });
// The generic fallback filename the hook's own #3582 degrade uses when
// package-identity.cjs cannot be built (mirrors gsd-check-update.js's
// identical fallback literal) — this is the SAME cache path a degraded
// worker would also have written to.
fs.writeFileSync(
path.join(home, '.cache', 'gsd', 'gsd-update-check.json'),
JSON.stringify({
update_available: true,
installed: '1.39.0',
latest: '1.40.0',
package_name: '@opengsd/gsd-core',
}),
);
const r = seamRunHook(path.join(cold.hooksDir, 'gsd-update-banner.js'), [], {
env: { ...process.env, HOME: home, USERPROFILE: home },
timeoutMs: 10_000,
});
assert.equal(r.exitCode, 0, `hook must exit 0 on a build failure; stderr: ${r.stderr}`);
assert.equal(
r.stdout.trim(),
'',
'a cold tree must silently suppress the banner (PACKAGE_NAME degrades to null, ' +
'so the lineage guard distrusts every cache) rather than crash or print stale content',
);
});
});
// ─── Install.js wiring: prompt + SessionStart entry registration ────────────
//
// These tests load bin/install.js as a module via GSD_TEST_MODE and assert on

View File

@@ -2415,3 +2415,49 @@ describe('evaluateUpdateCache lineage guard', () => {
});
});
}
// ─── #3582: cold tree (no gsd-core/bin/lib/*.cjs) — degrade, not crash ─────
//
// gsd-core/bin/lib/semver-compare.cjs, package-identity.cjs,
// state-document.cjs, active-workstream-store.cjs, and planning-workspace.cjs
// are tsc build artifacts (ADR-457), gitignored and absent on a raw
// plugin-marketplace / git-clone install that never ran `npm run build:lib`.
// The statusline renders on EVERY prompt — before #3582 a missing library
// crashed the whole hook process at module load (bare "Cannot find module"),
// so Claude Code's statusline would show nothing AND emit a visible error on
// every single render. The fix: the spawned-as-a-script path
// (`require.main === module`) calls ensureRuntimeBuild() first and, on
// failure, writes empty stdout and exits 0 — the SAME quiet no-signal
// behavior every other internal failure in this hook already degrades to
// (see e.g. the `try { ... } catch (e) { /* Silent fail */ }` wrapping
// runStatusline's own body). Simulated hermetically via a fixture install
// tree that copies hooks/ + the seam module but never gsd-core/bin/lib/ or
// tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the REAL
// gsd-core/bin/lib/ is never touched.
{
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const { runHook: runHookSeam } = require('./helpers/process-seam.cjs');
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
describe('gsd-statusline.js: #3582 cold tree — degrade to empty output, exit 0', () => {
test('missing compiled runtime library -> empty stdout, exit 0, no crash', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const payload = JSON.stringify({
model: { display_name: 'Claude' },
workspace: { current_dir: os.tmpdir() },
session_id: `test-3582-${Date.now()}`,
});
const r = runHookSeam(path.join(cold.hooksDir, 'gsd-statusline.js'), [], {
input: payload,
timeoutMs: 4000,
});
assert.equal(r.exitCode, 0, `must exit 0 on a build failure; stdout: ${r.stdout} stderr: ${r.stderr}`);
assert.equal(r.stdout, '', 'must degrade to empty output, not throw a stack trace to stdout');
});
});
}

View File

@@ -0,0 +1,60 @@
'use strict';
/**
* Build a hermetic "cold tree" install fixture for #3582 — a copy of hooks/
* plus gsd-core/bin/ensure-runtime-build.cjs with the compiled
* gsd-core/bin/lib/*.cjs directory and tsconfig.build.json deliberately
* ABSENT, mirroring a raw plugin-marketplace / git-clone install that never
* ran `npm run build:lib`.
*
* Deliberately NOT `tests/helpers/copy-script-fixture.cjs`'s
* `copyScriptWithDeps`: that helper walks the require graph and copies every
* dependency it finds — including gsd-core/bin/lib/*.cjs, which exist in
* THIS repo's already-built tree — so it would faithfully reproduce a WARM
* tree, the opposite of what a cold-tree test needs. This helper copies only
* hooks/ and the seam module itself, and never touches gsd-core/bin/lib/ or
* tsconfig.build.json — so `ensureRuntimeBuild()` inside the fixture
* deterministically throws `RuntimeBuildError` ("tsconfig.build.json not
* found") the first time any fixture hook reaches it, without ever deleting
* or touching the real repo's gsd-core/bin/lib/.
*/
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
/**
* @param {(dir: string) => void} [t.after] optional node:test `t` for auto-cleanup registration; caller may also ignore and use the returned `cleanup`.
* @returns {{ dir: string, hooksDir: string, cleanup: () => void }}
*/
function buildColdInstallTree() {
const dir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-cold-tree-'));
// hooks/ — entire directory (top-level hook scripts + hooks/lib/*.js +
// managed-hooks-registry.cjs + hooks.json). hooks/dist/ (gitignored,
// build-hooks.js output) is excluded — it is not present in a raw
// marketplace checkout either.
fs.cpSync(path.join(REPO_ROOT, 'hooks'), path.join(dir, 'hooks'), {
recursive: true,
filter: (src) => path.basename(src) !== 'dist',
});
// gsd-core/bin/ensure-runtime-build.cjs — the seam itself. Deliberately
// NOT gsd-core/bin/lib/ (absent — isBuilt() reads false) and NOT
// tsconfig.build.json at the fixture root (absent — ensureRuntimeBuild's
// "cannot auto-build" branch fires deterministically).
fs.mkdirSync(path.join(dir, 'gsd-core', 'bin'), { recursive: true });
fs.copyFileSync(
path.join(REPO_ROOT, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'),
path.join(dir, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'),
);
function cleanup() {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
}
return { dir, hooksDir: path.join(dir, 'hooks'), cleanup };
}
module.exports = { buildColdInstallTree, REPO_ROOT };

View File

@@ -0,0 +1,122 @@
'use strict';
/**
* Tests for hooks/lib/isolation-sentinel.js's resolveSentinelRoot() — specifically
* the #3582 self-heal call it makes before resolving a linked-worktree/ancestor
* project root. That call is reached ONLY when '.planning' is NOT directly under
* the cwd passed in (the early return at the top of the function fires first
* otherwise).
*
* #3582 review finding 1a: every existing #3582 cold-tree fixture
* (tests/helpers/cold-runtime-lib-fixture.cjs) puts '.planning' directly under
* the fixture project root, so none of them ever reach this branch — deleting
* the seam call would fail nothing in those suites. The one existing test that
* DOES pass a cwd whose '.planning' is not directly present
* (tests/gsd-agent-isolation-guard.test.cjs's "#3045 MINOR" linked-worktree
* test) runs against the REAL, already-built dev tree, where
* ensureRuntimeBuild() is a fast successful no-op — removing the seam call
* there would not change that test's outcome either, since the next require
* (worktree-safety.cjs) would resolve identically either way.
*
* This file closes that gap. Rather than a real tsc build (which would need
* this repo's own node_modules/typescript to be reachable from a throwaway
* fixture root, or a directory symlink into it — the latter a privileged,
* CI-unsafe operation on Windows per tests/ensure-runtime-build.test.cjs's own
* comment), it substitutes the THREE modules resolveSentinelRoot requires
* (the seam itself, worktree-safety.cjs, project-root.cjs) via require.cache,
* keyed by their real resolved absolute paths. This directly OBSERVES whether
* the seam call ran (a spy counter), rather than inferring it from a return
* value that a missing call could coincidentally also produce — and never
* touches gsd-core/bin/lib on disk. Injected cache entries are restored (or
* deleted, if absent beforehand) in `t.after()` so no other test sharing this
* worker process ever observes the substitution.
*
* Mutation check performed while authoring this test (not re-run on every CI
* pass — see the assertions' own doc comments): removing the two-line seam
* call from resolveSentinelRoot makes `seamCalls` stay 0 and
* `worktreeSafetyCalls` become 1 (the fake, reachable stub now answers), so
* this test fails exactly when the fix regresses.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const SENTINEL_MODULE_PATH = path.join(REPO_ROOT, 'hooks', 'lib', 'isolation-sentinel.js');
const SEAM_PATH = require.resolve(path.join(REPO_ROOT, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'));
const WORKTREE_SAFETY_PATH = require.resolve(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'worktree-safety.cjs'));
const PROJECT_ROOT_PATH = require.resolve(path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'project-root.cjs'));
const SENTINEL_RESOLVED = require.resolve(SENTINEL_MODULE_PATH);
/** Minimal shape Node's Module cache expects; only `.exports` is read by require(). */
function fakeModule(filename, exportsObj) {
return { id: filename, filename, loaded: true, exports: exportsObj, children: [], paths: [] };
}
describe('hooks/lib/isolation-sentinel.js: resolveSentinelRoot self-heal reachability (#3582 review finding 1a)', () => {
test('the seam call fires — and short-circuits the downstream requires — when .planning is not directly under cwd', (t) => {
const savedSeam = require.cache[SEAM_PATH];
const savedWorktreeSafety = require.cache[WORKTREE_SAFETY_PATH];
const savedProjectRoot = require.cache[PROJECT_ROOT_PATH];
const savedSentinel = require.cache[SENTINEL_RESOLVED];
t.after(() => {
const restore = (key, saved) => { if (saved) require.cache[key] = saved; else delete require.cache[key]; };
restore(SEAM_PATH, savedSeam);
restore(WORKTREE_SAFETY_PATH, savedWorktreeSafety);
restore(PROJECT_ROOT_PATH, savedProjectRoot);
restore(SENTINEL_RESOLVED, savedSentinel);
});
let seamCalls = 0;
let worktreeSafetyCalls = 0;
let projectRootCalls = 0;
class FakeRuntimeBuildError extends Error {}
require.cache[SEAM_PATH] = fakeModule(SEAM_PATH, {
RuntimeBuildError: FakeRuntimeBuildError,
ensureRuntimeBuild: () => {
seamCalls += 1;
throw new FakeRuntimeBuildError('fake cold-tree build failure (#3582 reachability test)');
},
});
require.cache[WORKTREE_SAFETY_PATH] = fakeModule(WORKTREE_SAFETY_PATH, {
resolveWorktreeRoot: () => {
worktreeSafetyCalls += 1;
return { root: 'SHOULD-NOT-BE-REACHED' };
},
});
require.cache[PROJECT_ROOT_PATH] = fakeModule(PROJECT_ROOT_PATH, {
findProjectRoot: () => {
projectRootCalls += 1;
return 'SHOULD-NOT-BE-REACHED';
},
});
// Fresh require of isolation-sentinel.js itself — not strictly required
// (its own three requires live inside the function body and are
// re-evaluated on every call regardless of module-cache state), but keeps
// this test independent of whatever load order other files in the same
// worker already forced.
delete require.cache[SENTINEL_RESOLVED];
const { resolveSentinelRoot } = require(SENTINEL_MODULE_PATH);
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-iso-sentinel-reach-'));
t.after(() => cleanup(cwd));
assert.equal(
fs.existsSync(path.join(cwd, '.planning')),
false,
'precondition: no .planning directly under cwd, so the early return must NOT fire',
);
const result = resolveSentinelRoot(cwd);
assert.equal(seamCalls, 1, 'ensureRuntimeBuild() must be called exactly once when .planning is not directly under cwd');
assert.equal(worktreeSafetyCalls, 0, 'a thrown RuntimeBuildError must short-circuit before resolveWorktreeRoot is ever reached');
assert.equal(projectRootCalls, 0, 'a thrown RuntimeBuildError must short-circuit before findProjectRoot is ever reached');
assert.equal(result, cwd, 'resolveSentinelRoot degrades to the raw cwd on a build failure, same as any other resolution failure');
});
});

View File

@@ -0,0 +1,255 @@
'use strict';
/**
* Tests for `scripts/lint-hooks-runtime-build-seam.cjs` — the CI guard that
* every `hooks/**` file requiring a compiled `gsd-core/bin/lib/*.cjs` module
* must also self-heal via `ensureRuntimeBuild()` first (#3582).
*
* Mirrors the sibling `lint-*-drift.cjs` test convention (see e.g.
* `tests/lint-planning-artifact-writer-drift.test.cjs`): pure-function unit
* tests against in-memory strings for the fast cases, plus an on-disk
* fixture-tree test (via `scanRepo`) proving the guard genuinely detects a
* real violating file — "a ratchet that has never been proven to fail is
* worthless" (CLAUDE.md).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
scanFile,
scanRepo,
stripComments,
} = require('../scripts/lint-hooks-runtime-build-seam.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
// ─── Case 1: the guard passes clean against the REAL hooks/ tree ───────────
describe('scanRepo — real hooks/ tree', () => {
test('reports zero violations against the actual hooks/ tree', () => {
const violations = scanRepo(REPO_ROOT);
assert.deepStrictEqual(
violations,
[],
`unexpected hooks/ runtime-build-seam violation(s): ${JSON.stringify(violations, null, 2)}`,
);
});
test('the real tree has REAL compiled-lib requires (the detector is not silently inert)', () => {
// A detector that never matches anything would also report zero
// violations. Prove it actually found the six #3582 hook files' real
// gsd-core/bin/lib/*.cjs requires and confirmed each is seam-wired.
const hookFiles = [
'gsd-agent-isolation-guard.js',
'gsd-cursor-subagent-start.js',
'gsd-statusline.js',
'gsd-check-update-worker.js',
'gsd-check-update.js',
'gsd-update-banner.js',
];
for (const name of hookFiles) {
const text = fs.readFileSync(path.join(REPO_ROOT, 'hooks', name), 'utf8');
const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text);
assert.ok(compiledLibRequires.length > 0, `${name}: expected at least one compiled-lib require`);
assert.ok(hasSeamRequire, `${name}: expected a require of ensure-runtime-build.cjs`);
assert.ok(hasSeamCall, `${name}: expected an ensureRuntimeBuild(...) call`);
}
});
});
// ─── Case 2: pure-function unit tests (in-memory strings) ─────────────────
describe('scanFile — pure detection', () => {
test('a file with no compiled-lib require has nothing to check', () => {
const text = "'use strict';\nconst fs = require('fs');\nmodule.exports = {};\n";
const { compiledLibRequires } = scanFile(text);
assert.deepStrictEqual(compiledLibRequires, []);
});
test('a compiled-lib require with NO seam require/call is detected', () => {
const text = [
"'use strict';",
"const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');",
'module.exports = { runtimes };',
'',
].join('\n');
const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text);
assert.deepStrictEqual(compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']);
assert.equal(hasSeamRequire, false);
assert.equal(hasSeamCall, false);
});
test('a compiled-lib require WITH a seam require + call is not flagged', () => {
const text = [
"'use strict';",
"const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');",
'function f() {',
' ensureRuntimeBuild();',
" const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');",
' return runtimes;',
'}',
'module.exports = { f };',
'',
].join('\n');
const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text);
assert.deepStrictEqual(compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']);
assert.equal(hasSeamRequire, true);
assert.equal(hasSeamCall, true);
});
test('a seam REQUIRE with no CALL still counts as missing (import-only bypass)', () => {
const text = [
"'use strict';",
// Imported but never invoked — must not satisfy the guard.
"const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');",
"const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');",
'module.exports = { runtimes, ensureRuntimeBuild };',
'',
].join('\n');
const { hasSeamRequire, hasSeamCall } = scanFile(text);
assert.equal(hasSeamRequire, true);
assert.equal(hasSeamCall, false);
});
test('a real require() inside a // comment is NOT counted (comment-only mention)', () => {
const text = [
"'use strict';",
"// example: require('../gsd-core/bin/lib/capability-registry.cjs')",
"const fs = require('fs');",
'',
].join('\n');
const { compiledLibRequires } = scanFile(text);
assert.deepStrictEqual(compiledLibRequires, []);
});
// Regression: this repo's own hook comments legitimately spell the glob
// `gsd-core/bin/lib/*.cjs` inside a `//` line — a `/` immediately followed
// by `*` forms a bare `/*` token. A naive whole-text
// `/\*[\s\S]*?\*\//g` block-comment stripper reads that as an OPENING
// block comment and silently deletes everything up to the next unrelated
// `*/` later in the file — including real require() lines. Caught while
// authoring this guard (it ate its own seam require in
// hooks/gsd-agent-isolation-guard.js); locked here so it cannot regress.
test('a `//` comment containing a glob like lib/*.cjs does not swallow later real code', () => {
const text = [
"'use strict';",
'// gsd-core/bin/lib/*.cjs (foo.cjs, bar.cjs) are compiled artifacts.',
"const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');",
'function f() {',
' ensureRuntimeBuild();',
" const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');",
' return runtimes;',
'}',
'/* a real, later, unrelated block comment */',
'module.exports = { f };',
'',
].join('\n');
const stripped = stripComments(text);
assert.ok(
stripped.includes("require('../gsd-core/bin/ensure-runtime-build.cjs')"),
'the seam require must survive comment-stripping',
);
assert.ok(stripped.includes('ensureRuntimeBuild();'), 'the seam call must survive comment-stripping');
const { compiledLibRequires, hasSeamRequire, hasSeamCall } = scanFile(text);
assert.deepStrictEqual(compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']);
assert.equal(hasSeamRequire, true);
assert.equal(hasSeamCall, true);
});
test('a genuine multi-line block comment is still stripped (no false-positive require inside it)', () => {
const text = [
"'use strict';",
'/**',
" * Example: require('../gsd-core/bin/lib/capability-registry.cjs') is",
' * mentioned here only as documentation prose.',
' */',
"const fs = require('fs');",
'',
].join('\n');
const { compiledLibRequires } = scanFile(text);
assert.deepStrictEqual(compiledLibRequires, []);
});
});
// ─── Case 3: on-disk fixture tree via scanRepo — proves the guard CAN fail ─
describe('scanRepo — on-disk fixture tree (proves the guard is not vacuous)', () => {
function writeFixtureTree(dir, { withSeam }) {
const hooksDir = path.join(dir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const badLines = [
"'use strict';",
"const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');",
'module.exports = { runtimes };',
'',
];
fs.writeFileSync(path.join(hooksDir, 'bad-hook.js'), badLines.join('\n'));
const goodLines = withSeam
? [
"'use strict';",
"const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');",
'ensureRuntimeBuild();',
"const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');",
'module.exports = { runtimes };',
'',
]
: [
"'use strict';",
"const fs = require('fs');",
'module.exports = { fs };',
'',
];
fs.writeFileSync(path.join(hooksDir, 'good-hook.js'), goodLines.join('\n'));
// hooks/dist/ is the generated build-output copy — never scanned, even
// when it contains an identical un-sealed require.
const distDir = path.join(hooksDir, 'dist');
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(path.join(distDir, 'bad-hook.js'), badLines.join('\n'));
}
test('a fixture hook requiring a compiled module with NO seam is FLAGGED', (t) => {
const dir = createTempDir('gsd-lint-hooks-seam-');
t.after(() => cleanup(dir));
writeFixtureTree(dir, { withSeam: true });
const violations = scanRepo(dir);
const files = violations.map((v) => v.file);
assert.ok(files.includes('hooks/bad-hook.js'), `expected hooks/bad-hook.js flagged, got: ${JSON.stringify(files)}`);
assert.ok(
!files.includes('hooks/good-hook.js'),
`hooks/good-hook.js (seam-wired) must NOT be flagged, got: ${JSON.stringify(files)}`,
);
const bad = violations.find((v) => v.file === 'hooks/bad-hook.js');
assert.deepStrictEqual(bad.compiledLibRequires, ['../gsd-core/bin/lib/capability-registry.cjs']);
assert.ok(bad.missing.length > 0);
});
test('hooks/dist/ (generated build-output copy) is never scanned, even with the same violation', (t) => {
const dir = createTempDir('gsd-lint-hooks-seam-dist-');
t.after(() => cleanup(dir));
writeFixtureTree(dir, { withSeam: true });
const violations = scanRepo(dir);
const files = violations.map((v) => v.file);
assert.ok(
!files.some((f) => f.startsWith('hooks/dist/')),
`hooks/dist/ must be excluded from the scan, got: ${JSON.stringify(files)}`,
);
});
test('a fixture tree with NO offending hooks reports zero violations (no false positive)', (t) => {
const dir = createTempDir('gsd-lint-hooks-seam-clean-');
t.after(() => cleanup(dir));
writeFixtureTree(dir, { withSeam: false });
fs.unlinkSync(path.join(dir, 'hooks', 'bad-hook.js'));
const violations = scanRepo(dir);
assert.deepStrictEqual(violations, []);
});
});