Commit Graph

5784 Commits

Author SHA1 Message Date
Tom Boucher
615b74ff45 fix(#4460): correct two stale changesets left by an admin-merge race (#4572)
* fix: address orthogonal-review findings on the new work in this PR

Isolated code-review + security-review of everything added to this PR
since its original review (hono override, check-env.cjs rewrite/revert,
new lib file, its test, installer enumeration). Security review: clean,
no findings. Code review found:

- BLOCKER: .changeset/silly-hens-relax.md described a hono override
  this PR no longer actually makes -- PR #4560 landed the identical fix
  on next first, and this branch's own hono commit became a genuine
  no-op the moment it was rebased onto that updated next (git diff
  origin/next -- package.json package-lock.json is empty). Deleted the
  orphaned changeset; next already carries #4560's equivalent one
  (.changeset/zesty-seals-click.md).
- HIGH: .changeset/tame-hens-jump.md's body still described the
  execNpm-routing approach that was tried and reverted -- stale text
  from before that revert, would have shipped a release note for code
  that isn't actually in the diff. Rewritten to describe what actually
  shipped (self-contained spawnSync, 15s timeout, accurate ENOENT vs.
  timeout vs. non-zero-exit diagnosis).
- LOW: no comment explaining why the spawnSync call has no try/catch
  (safe -- its documented contract routes failures through the returned
  result, never a throw -- but worth stating given this file's whole
  purpose is graceful degradation). Added one.
- nit: exitCode 0 + empty stdout fell through to "npm binary not found
  on PATH", misdescribing a real npm binary that simply printed
  nothing. Gave it its own message; updated the corresponding test.

Manually re-verified describeNpmVersionCheckFailure's branches and the
real check:env success path before re-running gsd-test, since this
repo blocks local node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: rest of the orthogonal-review fixes (previous commit only caught the deletion)

Tooling mistake in the previous commit: a git add with the already-staged
deleted changeset mixed into the same pathspec list errored out and
silently skipped staging the other four files, so only the changeset
deletion actually committed. This commit carries the rest of that same
change: tame-hens-jump.md's rewritten body, check-env.cjs's no-try/catch
comment, npm-version-check-diagnosis.cjs's exitCode-0-empty-stdout fix,
and the corresponding test update.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4460): fix changeset pr field to point at this PR, not the original

.changeset/tame-hens-jump.md's pr field still said 4552 (the PR its
original text was authored under), but this PR (#4572) is what's
actually landing the corrected body -- changeset-lint's own
DEFECT.CHANGESET-PR-FIELD-DRIFT check caught it: "pr: 4552, expected
pr: 4572".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:26:25 -04:00
Tom Boucher
385ed619f1 fix(#4487): stamp broken-windows ledger entries with the resolved milestone (#4583)
* enhance(#4487): stamp windows-ledger entries with the resolved milestone

Broken-windows ledger entries (`.planning/WINDOWS.md`) carry `phase` as
a bare number. Phase numbers are unique only within one active phases/
directory -- `milestone complete` archives phases and frees their
numbers for reuse, so two milestones routinely produce entries sharing
the same phase value with nothing distinguishing them. Since
`/gsd-ship` blocks while any entry is open, an already-archived
milestone's open entries could silently block shipping the CURRENT
milestone, with no supported way to attribute which entry belonged to
which milestone short of manually cross-referencing MILESTONES.md
timestamps against decision IDs that happened to appear in description
prose.

Added an optional `milestone: string | null` field to WindowEntry,
stamped by `windows append` (cmdWindowsAppend, which already does file
I/O) from the workstream's resolved milestone version. Reused the
existing `readCurrentMilestoneVersion` (workstream-inventory.cts --
STATE.md `milestone:` frontmatter first, ROADMAP.md in-progress marker
as fallback) rather than writing a parallel implementation: exported it
via that module's existing `export = {...}` CJS-interop convention
(matching the `import ... = require(...)` pattern already used in
workstream.cts/init.cts). appendWindow itself stays pure -- it accepts
milestone as an optional input field and passes it through; only the
CLI-facing cmdWindowsAppend resolves it from disk.

Backward compatible by construction: validateEntryShape does NOT add
`milestone` to its required fields, so an existing ledger entry with no
milestone key at all parses without error and reads back as null --
exactly "recorded before this change," no migration needed. The
rendered markdown table is deliberately left unchanged (the issue's own
words: "the JSON is the source of truth"); adding a table column would
be a separate, larger change than adding an optional JSON field.

Two smaller gaps the issue itself flags as separable ("happy to split
them out") are explicitly NOT addressed here: no verb to amend an
entry's description, and the table/JSON drift-repair advice that can
destroy table-only edits on a parse failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4487): preserve absent-vs-null milestone through parse/render roundtrip

validateEntryShape stamped an explicit `milestone: null` onto every
entry lacking the key, so a pre-#4487 ledger entry gained permanent
JSON churn ("milestone": null) the first time ANY entry in the ledger
was touched -- breaking the pure parse/render roundtrip-identity
property test (render(parse(render(ledger))) must equal render(ledger))
and, in real usage, contaminating unrelated entries' diffs on every
append/waive/fixed of an old ledger.

Fixed by distinguishing "key genuinely absent" (undefined -- JSON.
stringify drops it, matching pre-#4487 behavior exactly) from "recorded
but unresolvable" (explicit null, the real signal appendWindow stamps
on brand-new entries). WindowEntry.milestone is now optional
(`milestone?: string | null`) so returning undefined type-checks.

Updated tests/broken-windows.test.cjs's roundtrip property generator to
exercise all three states (absent/null/string) -- its prior silence on
this field is exactly what let the regression through. Also corrected
the earlier backward-compatibility test's assertion: a pre-#4487 entry
reads as milestone: undefined, not null, and re-rendering it must not
introduce a milestone key at all.

Also ran npm run regen:derived: docs/features/broken-windows-ledger.md
(edited in an earlier commit) had never been propagated to its
generated docs/FEATURES.md projection, which is what was independently
failing tests/features-index-gate.test.cjs and, as a side effect of
staleness, tripping tests/fragment-single-edit-propagation.install.
test.cjs's second-source-surface check.

Manually verified via the compiled lib (500 fast-check iterations plus
direct legacy/new-entry roundtrip checks) before wiring the test file,
since this repo blocks local node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4487): materialize milestone via conditional spread, not undefined assignment

An object literal property set to `milestone: undefined` is still an
OWN property -- `'milestone' in entry` reads true regardless of the
assigned value, only JSON.stringify treats undefined specially. My
prior commit's own new backward-compat test asserted `'milestone' in
entry === false` for a pre-#4487 entry and failed on exactly this.
Switched to conditionally spreading the key in only when the source
object actually had it, so a genuinely absent milestone is not
materialized at all -- matching both the `in` check and JSON
serialization. Re-verified via the compiled lib (500 fast-check
roundtrip iterations, plus the specific in/undefined/JSON assertions
the failing test makes) before re-running gsd-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4487): backfill changeset pr number to 4583

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:37:50 -04:00
Tom Boucher
8deb40722a fix(#4478): anchor collectAnalyzePhases's phase-heading regex to line start (#4578)
* fix(#4478): anchor collectAnalyzePhases's phase-heading regex to line start

phasePattern (src/roadmap.cts, backing `gsd-tools roadmap analyze`) had no
line anchor -- #{2,4} could match a `### Phase N:`-shaped mention ANYWHERE
the global regex scan reached: mid-sentence prose, inside a blockquote,
inside an inline code span (backtick-quoted on the same line, not a fenced
code block tokenizeHeadings would exclude). Any such line minted a phantom
phase entry, inflating phase_count and able to collide on a phase NUMBER
with a real heading nearby.

Two correctly-anchored reference implementations already exist for the
same heading grammar in this codebase: tokenizeHeadings
(src/markdown-sectionizer.cts:453) and findRoadmapPhaseInContent
(src/roadmap-parser.cts:1385), which anchors against the tokenizer's own
output. collectAnalyzePhases was the one path scanning raw content
directly instead. Anchored to line start with the same 0-3 leading-space
tolerance tokenizeHeadings uses (rather than routing through the
tokenizer, which the issue offers as the more thorough fix but which
would require re-deriving this function's bracket/number/name capture
groups and section-boundary lookup from tokenized output instead of a
single combined regex scan -- a materially larger refactor than a bug fix
warrants; the issue itself offers anchoring as the sufficient fallback).

Added coverage to the existing tests/roadmap.test.cjs "roadmap analyze
command" describe block (not a new file -- the roadmap module already
had 4 test files and lint-test-file-count.cjs's own remedy is to
consolidate, not add a 5th) against the issue's own 5-row prose-lookalike
table, its duplicate-number consequence, and a boundary case (a
legitimately-indented real heading must still count).

Independent code review on this same diff found one more consequence:
the "next heading" section-boundary lookup (nextHeader, a few lines
below phasePattern) lacked the SAME {0,3} leading-space tolerance --
a legitimately-indented NEXT phase heading was invisible to it, letting
the prior phase's own goal/mode/depends_on extraction bleed across the
section boundary into the next phase's body. Confirmed via a targeted
repro (**Depends on:** -- a field only the second phase has, so the
bleed is directly observable) and fixed with the same tolerance, plus
its own regression test.

CI-adjacent findings caught by gsd-test on a stale sha, fixed inline:
(1) my own explanatory comment block was inserted BETWEEN a pre-existing
`phase-id-owner:` sanction comment and the regex it sanctions, pushing
it out of the "line directly above" position lint-phase-id-drift.cjs
requires -- reordered so the sanction stays immediately above the
regex; (2) the standalone test file this fix originally added tripped
lint-test-file-count.cjs's per-module cap -- consolidated into the
existing describe block as described above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4478): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:50:02 -04:00
Tom Boucher
e0e7531530 fix(#4469): anchor beginPhaseCore's focusPattern regex to line start (#4577)
* fix(#4469): anchor beginPhaseCore's focusPattern regex to line start

focusPattern (src/state-transition.cts, beginPhaseCore's first-time-
execution branch) rewrites the **Current focus:** body line via a regex
with no line anchor and \s* (crosses newlines) instead of a same-line
whitespace class -- the same defect class already fixed for a different
function, stateReplaceField, in #4243/PR #4453. A bold **Current focus:**
quoted mid-sentence anywhere in the body (e.g. an Accumulated Context
bullet documenting the field) matched first and had the rest of its line
silently overwritten with the new focus label -- the #4010 data-loss
class, applied to a different field.

Anchored using the exact same idiom PR #4453 established:
^([ \t]*\*\*Current focus:\*\*[ \t]*)(.*)$ with the /m flag. [ \t]* (not
\s*) avoids consuming the newlines before the label into the match; $
documents the match ends at end-of-line.

Added a regression test to tests/state-transition.test.cjs: a body with
NO real **Current focus:** field yet present, but a mid-sentence prose
mention of the same bold text in an Accumulated Context bullet -- proving
the unanchored pattern would corrupt the prose (empirically verified via
direct node -e execution before wiring the test) while the anchored
pattern's .test() correctly returns false and leaves it untouched.

stateReplaceProgressPercent's bold branch (~line 51-71) is explicitly NOT
touched -- the issue itself flags it as entangled with the recorded #2177
form-priority decision, needing a maintainer call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4469): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:02:59 -04:00
Tom Boucher
37b965c0d1 enhance(#4139): Phase 7 — the agent-skill seam picks the payload in code (#4553)
* enhance(#4139): Phase 7 — the agent-skill seam picks the payload in code

ADR-4139 stream 2. The non-Claude `#2454` persona fallback in cmdAgentSkills
(src/init.cts) now selects between a canonical agents/<name>.md and a
token-minimized agents/<name>.compact.md sibling based on
workflow.compact_content, resolved in code (a real function call with a real
exit code) rather than a prose config-get gate — the same precedent stream 1's
spine/detail split established for a load-bearing seam, applied here because
this seam already runs through TypeScript instead of an eager @-include.

A missing compact sibling falls back to the canonical persona and discloses
the fallback in the served payload itself (a leading HTML-comment provenance
line), so the Done-when contract — compact when on, canonical when off, never
silent or empty — holds even for an agent nobody has compacted yet.

Authored a .compact.md sibling for all 35 shipped agents (agents/gsd-*.md),
each an independent, complete rewrite (not an extraction — nothing is "moved"
the way spine/detail moves text) that preserves frontmatter, every @-include,
every output-format contract, and every guardrail verbatim while cutting
restatement and verbose framing. Verified mechanically: every pair registers
(a canonical sibling exists), every compact file is strictly smaller, and the
full @-include set matches canonical's — including which references are
standalone eager-load lines versus inline prose mentions, since demoting one
to inline changes what the host actually substitutes.

Traced the install path before writing any code (.gsd/phase/.../40-design.md):
stageAgentsForRuntimeWithConverter glob-copies every agents/*.md file with no
stem filtering under the default full profile, so the new .compact.md files
install for free with zero installer changes — matching issue #4407's stated
scope. A tiered agent profile that doesn't stage a compact sibling degrades
through the same fallback-with-provenance path already required for an
unauthored one, so no installer change is needed there either.

Extends tests/helpers/compact-content-variant.cjs with an AGENTS_ROOT export
(deliberately not folded into DEFAULT_VARIANT_ROOTS, since agent variants are
reached by a generic code construction rather than a literal path in prose,
and checkReachability's markdown-search shape has nothing to find there).
Reachability is instead proven behaviorally: tests/agent-skills.test.cjs's new
"#4407 compact payload selection" describe block spawns gsd_run agent-skills
against real compact/canonical fixture pairs and asserts on the served
payload, which can only pass if the seam genuinely wires through.

Fixed a pre-existing test whose agents/*.md glob incidentally matched the new
.compact.md siblings (tests/agent-skills.test.cjs's Skill-frontmatter drift
guard) and added the 35 new agents/*.compact.md entries to docs/INVENTORY.md's
roster, both real, unrelated-to-content defects the new files' mere existence
surfaced.

Regenerated: install-tree fixtures (19 runtimes now ship 35 more agent files
under the full profile), INVENTORY-MANIFEST.json, and the variant-swap token
benchmark baseline (npm run benchmark:compact-content-variants --write).

Closes #4407.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4407): apply orthogonal review findings from the compact-payload seam

Standards axis of /code-review: extracted readNonEmptyFileOrNull(filePath)
to collapse the duplicated read-and-empty-check shape between the compact
and canonical branches in cmdAgentSkills, and updated the adjacent comment
enumerating flat JSON extras to name agent_payload_variant alongside
source/degraded (added by the prior commit, comment left stale).

Security review and the Spec axis found no defects requiring a code change;
their non-blocking observations (a pre-existing, unmodified path-construction
pattern; the reasoned, documented substitution of a behavioral test for the
literal reachability check) are recorded in
.gsd/phase/enhance-4407-agent-skill-seam/60-review.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4407): repo-wide roster/cap fixes surfaced by shipping .compact.md agents

Root-caused via a real gsd-test run (93 failures) rather than guessing which
tests glob agents/ naively. Two classes of defect, both genuine:

1. Identity-roster confusion (11 files/areas): many tests and one production
   script derive "the set of GSD agents" from `readdirSync(agentsDir).filter(f
   => f.endsWith('.md'))`, which incidentally matched the new .compact.md
   variant siblings too — a compact file is a rendering of an EXISTING agent
   identity, not a new one. Fixed at the shared root
   (tests/helpers/agent-roster.cjs's listAgentFiles, which several tests
   already consolidated on) and at each independent glob that didn't use it:
   agent-size-budget.test.cjs (tier-cap lookup now strips the .compact suffix
   before checking XL/LARGE membership, so a compact file inherits its
   canonical sibling's tier instead of silently falling through to DEFAULT),
   agent-skills-bootstrap.test.cjs, check-contract-drift.test.cjs (the actual
   script, not just its test), codex-config.test.cjs (confirmed directly
   against generateCodexAgentToml that a compact role's derived sandbox_mode
   is byte-identical to its canonical sibling's before excluding it — not
   assumed), and copilot-install.test.cjs (two counts that legitimately DO
   need both files — an installed-file count and a full-conversion smoke test
   — fixed to expect 70, not stay pinned to 35).

   no-bare-gsd-tools-command-position.test.cjs needed the opposite kind of fix:
   two compact files reproduce descriptive prose already allowlisted at their
   canonical file's line number; added matching entries at the compact files'
   own line numbers rather than excluding them from the scan (a genuine bare
   gsd-tools command-position bug in a compact file would be as real a defect
   as in canonical).

2. A hard, non-ackable cap (found via emitted-attribution.test.cjs's real-tree
   run): six agents' compact renditions (gsd-debugger, gsd-executor,
   gsd-phase-researcher, gsd-plan-checker, gsd-planner, gsd-verifier) exceed
   the 32,768-byte NEW_FILE_CAP (ADR-1610) even after aggressive compaction —
   confirmed structural, not a compaction-quality gap: each is dominated by
   content this phase's own rules require verbatim (the ~2.6 KB gsd_run
   bootstrap preamble runtime-launcher-parity.test.cjs requires inlined in
   every agent that calls gsd_run, output-format contracts, guardrails).
   ADR-4139's prescribed remedy (spine + lazily-read parts) has no landing
   spot in cmdAgentSkills's single-file synchronous read. Removed these 6
   compact files rather than ship an over-cap file or invent a multi-part
   read mechanism out of scope for this phase; recorded by name with the
   reason in .gsd/phase/enhance-4407-agent-skill-seam/40-design.md and
   50-test-matrix.md, per #4407's own "or explicitly recorded as not worth
   covering" allowance. Their canonical personas are served correctly today
   via the fallback-with-disclosed-provenance path this phase's own Done-when
   #2 already requires — 29 of 35 agents now have a compact variant.

Also fixes an unrelated, genuinely pre-existing defect this gsd-test run
surfaced: gsd-core/workflows/execute-plan.md sat 21 bytes over its own
DEFAULT-tier hard cap (40,960 bytes) at the branch point, before any change in
this PR touched it — confirmed via `git show <merge-base>:...execute-plan.md
| wc -c`. Per CLAUDE.md's no-deferral rule, fixed inline rather than filed:
two meaning-preserving trims in the <success_criteria> block (a repeated
parenthetical replaced with a same-exception reference; one redundant
qualifier dropped) bring it to 40,940 bytes.

Regenerated install-tree fixtures, INVENTORY-MANIFEST.json, and the variant
benchmark baseline to reflect the 6 removed files. Docs/INVENTORY.md's 6
now-orphaned roster rows removed alongside them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4407): make .compact.md-aware roster checks resilient to partial coverage

Round 2 of the gsd-test-driven roster fixes: two checks assumed every agent
has a compact sibling (true for 29 of 35 after the NEW_FILE_CAP exception),
breaking once 6 stems legitimately have none.

- tests/agent-classification-parity.test.cjs: the INVENTORY.md parser was
  picking up the "### Compact Payload Variants" subsection's rows as
  phantom/uncounted entries in the primary/advanced/inventory-only
  classification this test validates — a compact row documents an existing
  agent's alternate rendition and never gets its own AGENTS.md heading, so it
  was never meant to participate in that classification. Excluded at the
  parser, not per-assertion.
- tests/copilot-install.test.cjs: the derived expected-file-list generator
  assumed every listAgentFiles() stem has a .compact.md source sibling;
  checks disk per stem now instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4407): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:38:59 -04:00
dependabot[bot]
c504e715c6 chore(deps-dev): bump js-yaml from 4.3.1 to 4.3.2 in the npm_and_yarn group across 1 directory (#4565)
* chore(deps-dev): bump js-yaml

Bumps the npm_and_yarn group with 1 update in the / directory: [js-yaml](https://github.com/nodeca/js-yaml).


Updates `js-yaml` from 4.3.1 to 4.3.2
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: refresh vendored js-yaml to 4.3.2 (#4565)

lint-vendored-deps caught the drift: this PR's lockfile-only bump left
gsd-core/bin/lib/vendor/js-yaml.cjs and the package.json pin behind the
new js-yaml 4.3.2 resolved by package-lock.json (merge-key CPU-limit
backport, GHSA for excessive merge-key processing). Refreshes the
vendored copy from node_modules and bumps the manifest pin to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: add Security changeset for js-yaml 4.3.2 vendor bump (#4565)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:46:35 -04:00
Tom Boucher
6bd5c22f68 ci: auto-refresh vendored js-yaml/re2js on Dependabot PRs (#4573) (#4576)
lint-vendored-deps.cjs gates gsd-core/bin/lib/vendor/{js-yaml.cjs,re2js.cjs}
for byte-freshness against node_modules and requires package.json's pin to
literally match the installed version. Dependabot regularly opens
lockfile-only PRs bumping these packages within the existing semver range,
which it can never satisfy (it has no awareness of the vendor copy or the
pin), so every such PR sits permanently red until a human manually runs the
refresh and pushes a fixup commit. PR #4565 was the latest instance.

Adds `--fix` to lint-vendored-deps.cjs (fixRow): mechanically re-copies the
upstream build artifact over the vendored .cjs/.d.cts twins and bumps the
manifest pin, preserving its range-operator style. It never touches a
hand-authored twin's declared type surface (js-yaml.d.cts) — a remaining
finding there means a real upstream API break, and --fix leaves it failing
rather than mask it.

Adds .github/workflows/dependabot-vendor-refresh.yml: on a same-repo
dependabot[bot] PR touching package.json/package-lock.json (same
defense-in-depth identity check as dependabot-auto-merge.yml), runs
`--fix` and, only on a clean result, commits and pushes the refresh back
to the PR branch via GSD_BOT_PR_TOKEN (falling back to GITHUB_TOKEN, same
pattern as auto-backmerge.yml) so the push re-triggers `synchronize` and
the real lint-vendored-deps check in test.yml genuinely re-passes. A
non-clean --fix result (real incompatibility) makes no commit, leaving
the actual failure visible for a human — this fixes the check's own
complaint, it does not bypass or weaken the check.

Closes #4573

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:46:12 -04:00
Tom Boucher
30468f16fe test(#4515): migrate installer-runs batch to named timeout constants (#4575)
* test(#4515): migrate installer-runs batch to named timeout constants

Batch 4 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property across
tests/install.test.cjs, tests/install-minimal-hooks.test.cjs,
tests/fragment-single-edit-propagation.install.test.cjs,
tests/install-regressions.test.cjs, tests/install-runtime-artifacts.test.cjs,
tests/npm-integrity-gate.test.cjs, tests/faulty-deps.test.cjs,
tests/release-tarball-smoke.install.test.cjs,
tests/install-write-confinement.test.cjs, tests/plugin-manifest.test.cjs,
and tests/packaging-shipped-scripts-require-only-shipped.test.cjs with a
named constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes
these 11 files from the rule's allowlist.

Adds one new shared constant to tests/helpers/timeouts.cjs for a
fixture-JSON value (in seconds, not ms) mimicking a Claude Code
settings.json hook-entry's own timeout field, shared across two files in
this batch. Every other new constant is file-local, each carrying a
comment explaining why its call site is a distinct operational class from
the existing shared norms even where its digits numerically coincide with
one. No src/bin file touched, no numeric timeout value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: combine gsd-worktree-path-guard's git rev-parse calls to cut subprocess count under CI load

Discovered while verifying PR #4575 (a full test (windows-latest) failure in an unrelated test, tests/kilo-upgrades.test.cjs's worktree-path-guard rejection test). Root cause: this hook's up-to-4 sequential git spawns (git-dir, branch, worktree-toplevel, file-toplevel), invoked inside a native-plugin's own 8000ms-capped subprocess wrapper, left zero margin for node/git startup overhead — the guard is deliberately fail-open on any timeout, so CI-load-induced latency in this chain silently downgrades a security block into an allow. Combines the first three git rev-parse calls (git-dir, branch via --abbrev-ref, worktree-toplevel) into ONE spawn instead of three, using git's documented multi-flag rev-parse output (one line per flag, in order) — verified empirically including the detached-HEAD edge case. No timeout value changed; this reduces subprocess COUNT, not any tolerance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: add changeset fragment for the worktree-path-guard fix

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:45:44 -04:00
Tom Boucher
5823d2ec7a docs(#4484): correct native-plugin-install's install-time-config parity claim (#4579)
* docs(#4484): correct native-plugin-install's install-time-config parity claim

The doc claimed the plugin path and the npm installer "differ in
namespace and lifecycle only." False: the native plugin path
(claude plugin install, marketplace discovery, and the skills-dir
zero-friction load) materializes the repository tree directly and never
runs GSD's install engine, so install-time config baked into generated
artifact files at install time never applies there -- confirmed for
agent_tools (#4238/#4032, reproduced live in #4484: 35/35 files granted
via npm install, 0/35 via plugin install, even after
`claude plugin update`). model_overrides is the same architectural class
(install-time-only logic on the npm-install call tree, per
src/install-model-override-resolver.cts) but hedged, not claimed
confirmed, matching the issue's own hedging.

Reporter explicitly frames this as a docs-only fix: the code behavior
(zero install step on the plugin path) is presumably intentional design;
the bug is the doc's incorrect parity claim, not the missing
functionality. No code changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4484): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:57:28 -04:00
Tom Boucher
ba26aa065d docs(#4467): document fallow's structural-pre-pass has no upper-bound scope (#4574)
* docs(#4467): document fallow's structural-pre-pass has no upper-bound scope

structural-pre-pass.md's FALLOW_SCOPE_ARGS=(--changed-since "$FALLOW_BASE")
derives a correct, phase-anchored LOWER bound (lockstep with Tier 3's own
scope step, #3995), but fallow's --changed-since is one-sided by design --
verified against fallow 2.70.0's own --help: the only other scoping flags
are --changed-workspaces (workspace selection, not a file range) and
--diff-file (its own help text scopes it to line-range refinement within
the hot-path-touched verdict, not general file selection; gsd-core never
uses it). Reviewing an earlier phase after a later one has landed pulls
the later phase's files into the earlier phase's structural audit.

Not fixable inside this file: fixing fallow itself is a third-party
concern, and working around it (e.g. auditing from a temporary worktree
checked out at the phase tip) is disproportionate machinery for what is
supplementary structural-analysis context, not a blocking gate -- both
routes the issue's own analysis already ruled out. Documented the
asymmetry at the point the scope is derived instead, so a future reader
does not assume this step's tip agrees with Tier 3's just because the
base does.

No regression test: documentation-only, no runtime behavior change.

A prior revision of this commit carried an Emitted-Drift-Ack-Growth
trailer for this growth -- gsd-test's own emitted-attribution check
rejected it as stale ("written or reworded in THIS diff, but nothing
here needed them"), meaning this file (nested under
gsd-core/workflows/code-review/steps/, unlike a top-level
gsd-core/workflows/*.md file) is not tracked by that specific growth
conservation law. Removed the now-confirmed-unnecessary trailer rather
than guess again -- the test's own verdict is authoritative here, not
a re-derivation of its tracked-path rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4467): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:45:20 -04:00
Tom Boucher
59da9f016b fix(#4466): bound quick.md's post-execute review scope tip at the task's own last commit (#4571)
* fix(#4466): bound quick.md's post-execute review scope tip at the task's own last commit

The review-scoping step computed CHANGED_FILES as `git diff --name-only
"${DIFF_BASE}..HEAD"`. DIFF_BASE is correctly bound to the quick task's
start (via the oldest QUICK_COMMITS entry's parent), but the tip was bare
HEAD -- unbounded. Anything landing on the shared tree between the task's
own commits and this review step running (a worktree merge-back, another
session sharing the tree) got folded into the quick task's own review
scope.

QUICK_COMMITS (newest-first) already holds the correct tip as its first
line -- read QUICK_TIP from the value already computed, diff against
that instead of HEAD. No new derivation, no new git call.

Added tests/quick-review-scope-tip-bound.test.cjs: extracts the scoping
fence verbatim from quick.md and runs it against a real git fixture
matching the issue's own scenario (quick task's own commit, then a later
unrelated commit on the shared tree). Manually verified watch-it-fail
(bare HEAD includes the unrelated file) / watch-it-pass (bounded tip
excludes it) via direct bash execution before wiring the test file,
since this repo blocks local node --test.

Independent code review caught one drive-by finding: an allow-test-rule
marker copied from a sibling test's pattern was unnecessary here (and
there) -- local/no-source-grep's looksLikeSourcePath only matches
readFileSync targets ending in .cjs/.cts/.js/.mjs/.mts/.ts, never .md,
so the rule can never fire regardless of the marker. Confirmed by
reading eslint-rules/no-source-grep.cjs directly; removed.

Emitted-Drift-Ack-Growth: quick.md — the fix adds a QUICK_TIP line and its explanatory comment; not a regeneration artifact, a hand-authored bug fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4466): backfill changeset PR number

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:12:06 -04:00
Tom Boucher
bfcc7b2acb Merge pull request #4552 from open-gsd/fix/4460-code-review-tier3-ignores-files-override
fix(#4460): gate Tier 3's #2666 cross-check on FILES_OVERRIDE
2026-09-09 09:16:18 -04:00
Tom Boucher
d1cd04e808 Merge pull request #4566 from open-gsd/test/4514-batch3-git-adjacent
test(#4514): migrate git-adjacent workflow checks to named timeout constants
2026-09-09 09:15:25 -04:00
sim
8731a90be6 fix: revert execNpm import in check-env.cjs, keep the diagnosis improvement
The execNpm-routing redesign (previous commit) broke every real CI job:
check-env.cjs runs as its own standalone "Environment check" step BEFORE
`npm ci` / `npm run build:lib` -- a deliberate pre-flight, run before
there is even a node_modules to build with. Its require of
../gsd-core/bin/lib/shell-command-projection.cjs (a tsc-compiled artifact
that plain does not exist at that point in the pipeline) crashed with
MODULE_NOT_FOUND on every platform, immediately, confirmed via the real
CI log. My own local gsd-test run never caught this because it doesn't
replicate that exact pre-build step ordering.

Reverted the cross-module require entirely; check-env.cjs is back to a
self-contained spawnSync(npmCmd, ...) call, no requires reaching into
gsd-core/bin/lib. Kept the two things actually worth keeping from that
detour:
  - the 15_000ms timeout (matches execNpm's own default elsewhere in this
    repo -- not invented, an existing precedent -- vs. the original 10s
    that failed twice under real Windows CI contention);
  - computing `timedOut` via `error.code === 'ETIMEDOUT'` inline (the
    same canonical, cross-platform-correct predicate that seam uses),
    rather than the earlier signal === 'SIGTERM' check, which that seam's
    own docstring documents as platform-fragile with a Windows-specific
    false-negative risk.

Manually verified check-env.cjs runs correctly with gsd-core/bin/lib
temporarily removed entirely (simulating the real pre-npm-ci CI
ordering) before re-running gsd-test, since this repo blocks local
node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
1cd17cc3d6 fix: route check-env.cjs's npm-version check through the canonical execNpm seam
Per /research + /diagnose direction: the timeout fix landed earlier this
session correctly diagnosed the failure (a real spawnSync timeout under
Windows CI contention, not npm being absent) but it recurred on the very
next push -- same chunk, same ~51-file load. Rather than raise the
hand-rolled 10s timeout myself (CLAUDE.md's own rule: fix the cost, not
the tolerance, and never touch a timeout without explicit instruction),
investigated the repo's own precedent first.

Found: this repo already has a canonical OS-shell-projection seam for
exactly this (src/shell-command-projection.cts's execNpm), already used
by dozens of other scripts/*.cjs files (require('../gsd-core/bin/lib/...')
is an extremely well-established pattern), with:
  - the same npm.cmd/shell:true Windows handling check-env.cjs was
    hand-rolling, but centralized;
  - a 15s default timeout (vs. check-env.cjs's 10s) -- not invented here,
    an EXISTING value already governing npm subprocess calls elsewhere;
  - isSpawnTimeout / result.timedOut, the canonical cross-platform timeout
    predicate (error.code === 'ETIMEDOUT'), whose own docstring explicitly
    warns that checking signal === 'SIGTERM' (what my first fix did) is
    "platform-fragile" with a Windows-specific false-negative risk -- the
    exact platform this bug lives on.

check-env.cjs's npm-version check now calls execNpm(['--version']) instead
of hand-rolling spawnSync + npmCmd + shell:true, and
describeNpmVersionCheckFailure now operates on execNpm's SpawnResultOutput
shape (using timedOut, not signal) rather than a raw spawnSync result.
This is a genuine architectural fix, not just a bigger number: it removes
a duplicate, slightly-divergent re-implementation of an existing seam and
inherits whatever that seam's timeout/handling becomes in the future.

Manually verified end-to-end (npm run check:env against the real
environment) and re-verified describeNpmVersionCheckFailure's branches
directly against execNpm's actual return shape before wiring the test
file, since this repo blocks local node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
8c3ef049b2 fix: enumerate the new scripts/lib file in GSD_SCRIPTS_LIB_FILES
scripts/lib/npm-version-check-diagnosis.cjs (added earlier in this
branch) ships to every install (bin/install.js copies scripts/lib/
wholesale) but was missing from GSD_SCRIPTS_LIB_FILES, so uninstall()
would never remove it -- it would orphan on every uninstall. Confirmed
by gsd-test: tests/install.test.cjs's own parity check named the exact
missing filename and the array to add it to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
8b5e347377 chore: regenerate golden install-tree fixtures for the new lib file
scripts/lib/npm-version-check-diagnosis.cjs (added in the previous
commit) is a new shipped file under the "scripts" files-glob, so it
needs to appear in every runtime's golden install-tree fixture.
Confirmed by gsd-test: 23 tests/golden-install-tree.test.cjs failures,
one per runtime, each showing the same single added path. Ran
npm run gen:install-tree; diff is exactly one line per fixture file,
matching the new file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
0aeafc6425 fix: report the real cause when check-env.cjs's npm-version check fails
Discovered blocking this PR's own Windows CI (unrelated to this PR's
actual diff, fixed inline per this repo's no-defer policy): PR #4552's
"full test (windows-latest, 24, shard 2/3)" job failed
tests/check-env.test.cjs's npm-version subtest with "npm binary not
found on PATH" under chunk 5/9's heavy load (51 concurrent files,
5+ minutes). Root-caused via the CI log: the check's spawnSync call
used a 10s timeout, and every failure mode -- ENOENT, a signal-killed
timeout, a non-zero exit, a thrown spawn error -- collapsed into that
one message (only `res.status === 0 && res.stdout` was checked), so a
genuine npm.cmd cold-start timeout under contention was indistinguishable
from npm actually being absent.

Extracted the reason-selection into describeNpmVersionCheckFailure, a
pure function in the new scripts/lib/npm-version-check-diagnosis.cjs
(kept out of check-env.cjs itself, which runs its CLI unconditionally
on require with no `require.main === module` guard, so the pure logic
can be unit-tested without triggering a real environment check).
Reports ENOENT, signal-kill, non-zero-exit, and thrown-error cases
distinctly. Does NOT raise the 10s timeout itself -- a slow subprocess
under contention is a cost to reduce, not a tolerance to widen.

Also fixed a stale tsconfig.build.tsbuildinfo incremental-build cache
discovered while verifying this change: npm run build:lib was silently
omitting gsd-core/bin/lib/markdown-table.cjs (a real, needed compiled
module -- src/state-document.cts requires it), which only surfaced via
npm run lint:generated-sync's gen-health-docs check failing with
Cannot find module. Deleting the cache and rebuilding fresh restored
it; docs/INVENTORY-MANIFEST.json needed no net change once the build
was genuinely complete.

Manually verified describeNpmVersionCheckFailure's five branches
directly (ENOENT, signal-kill, non-zero exit, thrown error, defensive
default) before wiring the test file, since this repo blocks local
node --test. Re-ran node scripts/check-env.cjs directly to confirm the
real success path is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
ba25e3989d fix: pin transitive hono dependency to >=4.13.5 (moderate advisory)
A moderate-severity advisory chain (GHSA-gqvv-2mrq-wpjv,
GHSA-g6gw-c38x-mqfc, GHSA-crvj-82cr-hjcx) is published against
hono <4.13.5, pulled in transitively via @anthropic-ai/claude-agent-sdk
-> @modelcontextprotocol/sdk. This has been blocking
tests/npm-integrity-gate.test.cjs identically across every issue in
this session's bug-fixer sweep -- fixed here, in #4460's own PR, per
explicit direction, rather than waiting on a separate tracking issue.

Adds "hono": ">=4.13.5" to package.json's existing overrides block
(same pattern already used for qs, body-parser, @hono/node-server).
npm audit --omit=dev now reports 0 vulnerabilities.

Note: an equivalent fix (commit bba20b51fd) already exists riding along
in the unrelated, already-green PR #4560 (#4513's batch) -- whichever
of the two lands on next first resolves this for every other pending
issue in the sweep; landing it here too removes this PR's own
dependency on that other PR's timing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:34 -04:00
sim
10ad91dafb fix(#4460): drop the superfluous allow-test-rule marker
local/no-source-grep's looksLikeSourcePath only matches readFileSync
targets ending in .cjs/.cts/.js/.mjs/.mts/.ts -- WORKFLOW_PATH here
points at code-review.md, so the rule can never fire regardless of the
marker. Confirmed by reading eslint-rules/no-source-grep.cjs directly
before removing it, not assumed. Caught by an independent code-review
pass on the sibling #4466 fix, which copied this same now-unnecessary
marker pattern -- fixed there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
7fe3fd80c2 fix(#4460): stop sourcing Tier 1's fence -- confirmed non-portable path check
CI diagnostics from the round-6 push (captured via the new stderr-wired
harness) gave the actual root cause after three prior guesses failed
identically: on Windows CI, `git rev-parse --show-toplevel` returns a
mixed-format path (C:/Users/..., drive letter + forward slashes) while
GNU realpath (also bundled with Git for Windows) returns a genuine
POSIX path for the identical location (/c/Users/...). Tier 1's
REPO_ROOT-prefix containment check can never match between these two
formats, so every --files entry is misclassified as "outside the
repository" on every Windows run -- deterministically, not flakily,
and unrelated to the `-m` flag or 8.3 short names (both already tried
and both ineffective).

This is a real, structural, pre-existing Tier 1 defect, not something
this test can fix without expanding #4460's scope (same out-of-scope
bucket as #4461, code-review.md's fences not being cross-platform-
robust -- see cr-2 in the review notes). The correct fix is the same
treatment already applied to Tier 2: stop sourcing Tier 1's fence, and
seed REVIEW_FILES directly with the value a working Tier 1 would have
produced. This isolates the test to Tier 3's own gate -- the actual
subject of #4460 -- from Tier 1's unrelated defect, on every platform.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
779fff03a4 fix(#4460): drop unused execFileSync import (lint-tests finding)
Left over from round 5's switch to spawnSync for stderr capture -- CI's
lint-tests job (eslint --max-warnings 0) caught the now-unused import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
eb4b8ba8c9 fix(#4460): fix two self-inflicted bugs in round 5's diagnostic harness
Round 5's own gsd-test run failed on Linux (not just Windows), proving
the instrumentation itself was broken, not Tier 1/3:

1. Attaching `__diagnostics` directly onto the returned files array made
   assert.deepEqual fail even when the file list was exactly right --
   Node's deepEqual compares an array's own properties too, so a decorated
   array never structurally equals a same-valued plain array literal.
   Switched runTiers() to return {files, diagnostics} instead.

2. The new "[diag] REPO_ROOT=$REPO_ROOT" probe referenced REPO_ROOT
   unconditionally, but Tier 1 only sets it inside its own `if [ -n
   "$FILES_OVERRIDE" ]` body -- under `set -u`, the "without --files" case
   (FILES_OVERRIDE empty) hit an unbound-variable exit before Tier 3 ever
   ran. Default-expanded to ${REPO_ROOT:-<unset...>}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
f6d610788e test(#4460): capture Tier 1/3 diagnostics instead of guessing a 4th cause
Round 4's fs.realpathSync.native() fix did not resolve the Windows CI
failure either -- the identical widened-to-5-files symptom recurred a
third time on PR #4552, proving that diagnosis was also incomplete or
wrong. Rather than guess a fourth root cause blind, switch the harness
from execFileSync (which discards stderr) to spawnSync capturing it,
redirect the tiers' own diagnostic echoes (previously discarded via
`> /dev/null`) to stderr instead, and add explicit "[diag] REPO_ROOT="
/ "[diag] REVIEW_FILES(post-tier1)+=" probes right after Tier 1 runs.
A future failure now carries what Tier 1 actually computed instead of
requiring another round of speculation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
14e84e1fc5 fix(#4460): use fs.realpathSync.native for the Windows-CI tmp fixture path
Round 3's `.replace(/\brealpath -m\b/, 'realpath')` workaround did not
actually fix the "test (windows-latest, 24, shard 1/3)" failure -- the
same widened-to-5-files symptom recurred identically on PR #4552's next
push, proving the `-m` flag was never the real cause.

Root-caused via tests/helpers.cjs's own documented Windows caveat
(tmpRootCandidates(), ~line 369): GitHub's Windows runners report
os.tmpdir() in the 8.3 SHORT form (C:\Users\RUNNER~1\...), and
fs.realpathSync() -- what this test used -- does not reliably expand
that; only fs.realpathSync.native() does. The un-expanded short-form
tmpDir path this test's Node side used for cwd/file construction can
diverge from what bash's own `git rev-parse --show-toplevel` /
`realpath` independently resolve inside Tier 1's containment check,
which is exactly the failure mode observed: --files gets classified as
"outside the repository", REVIEW_FILES stays empty, and control falls
through to the full-diff path instead of exercising the gate under
test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
4e5e097544 fix(#4460): strip realpath -m from Tier 1 in the test (Windows CI)
CI's test (windows-latest, shard 1/3) failed: --files=src/alpha.js
widened to all 5 files instead of staying at 1. Root-caused (not
assumed): this is the SAME pre-existing Tier-1 `realpath -m`
portability gap already documented as out-of-scope for this fix
(confirmed on macOS during manual verification) -- also real on
Windows CI. `-m` only changes behavior for a path that doesn't (yet)
exist; on a platform where it errors or behaves differently, every
--files entry gets misclassified as "outside the repository",
REVIEW_FILES stays empty, and the test ends up exercising the OUTER
`if [ ${#REVIEW_FILES[@]} -eq 0 ]` full-diff fallback instead of ever
reaching the elif this fix's own gate lives on.

Fixed in the TEST only (code-review.md's Tier 1 is untouched -- this
gap is real, pre-existing, and out of #4460's scope per cr-2). Strip
`-m` from the extracted Tier 1 fence before running it: every path in
these fixtures already exists, so `-m` is a behavioral no-op here, and
this makes the test exercise Tier 3's gate (the actual subject of this
fix) on every platform gsd-test runs on. Manually re-verified both
cases locally before re-pushing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
bbbcf43631 docs(#4460): backfill changeset PR number
pr: 0 -> pr: 4552

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
d3e3a8f535 fix(#4460): restore compact-file reachability + fix test stdout capture
Two more gsd-test-surfaced findings:

1. The previous execute-plan.md trim removed the literal
   `summary.compact.md` filename mention, breaking
   tests/compact-content-variant-guard.test.cjs's reachability check
   (ADR-4139 Phase 6): every registered .compact.md variant must be
   named by at least one workflow "spine" file, and execute-plan.md was
   apparently the only spine naming this one. Restored the bare
   filename (kept the shortened surrounding wording) -- read
   tests/helpers/compact-content-variant.cjs's checkReachability/
   isUnprefixedMatch directly to confirm the fix rather than guessing.
   40926 bytes, still 34 under the size cap.

2. The redesigned test (previous commit) still failed: both tiers'
   diagnostic `echo`/`printf "Warning: ..."` lines were mixing into the
   captured stdout the assertions parse as the file list, so
   "--files=src/alpha.js" appeared to produce 2 lines instead of 1.
   Wrapped both tier fences in a `{ ...; } > /dev/null` brace group
   (not a subshell -- REVIEW_FILES still persists to the enclosing
   shell) so only the final printf reaches stdout. Manually re-verified
   both cases against a real git fixture before re-running the suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
db7a349a8c fix(#4460): trim execute-plan.md under its size budget (unrelated regression)
gsd-test surfaced a SEPARATE, unrelated failure while re-verifying this
branch: tests/workflow-size-budget.test.cjs found execute-plan.md at
40981 bytes, 21 over the 40960 DEFAULT hard cap. Root-caused (not
assumed): already-merged PR #4540 (enhance(#4139), unrelated to
#4460/#4459/#4461) added two near-identical explanatory parentheticals
about .compact.md template variants across two nearby steps
(user_setup, create_summary), pushing the file over. Confirmed directly
against origin/next independent of any merge with this branch --
`next` itself already carries this.

This branch's fork point predated PR #4540's merge, so gsd-test's
merge-testing against the current next only now surfaced it (merged
origin/next into this branch in a separate commit first -- 0 conflicts,
after discovering and fixing that this worktree's git clone was
SHALLOW, via `git fetch --unshallow`, which is what made a plain `git
merge origin/next` fail with "refusing to merge unrelated histories").

Fixed by trimming the SECOND (of two near-identical) parentheticals in
the create_summary step to a short back-reference to the first -- same
information, no duplication, no cap raised (the test explicitly warns
against raising it). 40931 bytes, 29 under the cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
5946926b94 fix(#4460): rework test to not depend on Tier 2's broken bash (#4461)
A fresh code-review pass found the test's original approach (concatenate
and execute Tier 1 + Tier 2 + Tier 3 verbatim, matching the issue's own
reproduction) cannot run: Tier 2's own fence -- untouched by this diff --
is not currently parseable bash. Two unescaped `"` inside its embedded
`node -e "..."` regex literal (`raw.replace(/^['"]|['"]$/g, '')`)
terminate the outer double-quoted string early, which breaks bash's
PARSE of the whole concatenated script even though Tier 2's body never
executes under --files. Independently confirmed via manual extraction
and execution before accepting the finding.

This is a real, separately-filed, already-queued sibling issue (#4461,
filed by #4460's own reporter specifically to avoid folding it in here)
-- not fixed in this PR. Instead reworked the test to run only Tier 1 +
Tier 3 verbatim, seeding the Tier-2-equivalent REVIEW_FILES state
directly for the "without --files" case (documented in the module
docblock, explaining why Tier 2 isn't sourced and pointing at #4461).

Also fixed a nit from the same review pass: a code comment overstated
Tier 2's guard as "immediately above" when it's ~150 lines away.

Manually re-verified both test cases against a real git fixture with a
GNU-realpath-compatible `realpath` (matching gsd-test's Linux bench --
this Mac's BSD realpath lacks the `-m` flag Tier 1 uses, a SEPARATE
pre-existing portability gap surfaced during this check, masked on Linux
CI, not touched by this fix) before re-running the full suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
b1c78f0d2e fix(#4460): gate Tier 3's #2666 cross-check on FILES_OVERRIDE
code-review.md states (line 144) "Skip SUMMARY/git scoping entirely when
--files is provided." Tier 2 honors this via `if [ -z "$FILES_OVERRIDE"
]`, but Tier 3's #2666 SUMMARY/diff cross-check had no FILES_OVERRIDE
reference at all -- reached via `elif [ -n "$DIFF_BASE" ]` whenever
REVIEW_FILES was already non-empty (true under --files, since Tier 1
fills it), so it silently appended the whole phase's changed files onto
an explicit user-supplied file list. --files is documented as the
highest-precedence scoping tier (D-08) and is the flag Tier 3's own
fail-closed path recommends when no reliable diff base is found; a user
narrowing a review to two files silently got the whole phase instead,
and the reviewer agent spent its budget on files nobody asked about.

Gated the elif on the same condition Tier 2 already uses:

  elif [ -z "$FILES_OVERRIDE" ] && [ -n "$DIFF_BASE" ]; then

The issue's own narrowest suggested form, reasoned through against two
alternatives (wrapping the whole Tier-3 fence, or changing the stated
invariant instead) -- both explicitly rejected there for good reasons
concurred with after reading the surrounding code.

Added tests/code-review-tier3-files-override-scoping.test.cjs, mirroring
the issue's own verified reproduction methodology: extracts the Tier
1/2/3 fences VERBATIM from code-review.md (never reimplemented) and runs
them against a real constructed git fixture matching the issue's own
scenario exactly (5 files, a SUMMARY listing only 1). Confirms --files
stays scoped to exactly the requested file, and separately confirms the
#2666 cross-check still widens a genuinely partial SUMMARY scope when
--files is absent (proving this is a gate, not a blanket disable).

Emitted-Drift-Ack-Growth: code-review.md — #4460 gates the Tier-3 #2666 cross-check on FILES_OVERRIDE, matching Tier 2's own guard, net +453 bytes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 08:07:33 -04:00
sim
ab0405ad22 test(#4514): migrate git-adjacent workflow checks to named timeout constants
Batch 3 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property in
tests/ci-rebase-check.test.cjs, tests/gsd-validate-commit-crash-policy.test.cjs,
tests/pr-branch-planning-filter.test.cjs, tests/reapply-verify-hunks.test.cjs,
tests/ship-notes-wedged-pr.test.cjs, tests/slug-derivation-drift-guard.test.cjs,
and tests/worktree-safety.test.cjs with a named constant, per
eslint-rules/no-adhoc-timeout-literal.cjs. Removes the 7 files from the
rule's allowlist.

Mints a new shared class norm, QUICK_SPAWN_TIMEOUT_MS (10000ms), in
tests/helpers/timeouts.cjs: 5 sites across 4 of this batch's files had
independently arrived at the same value for the same shape (a cheap,
trivial subprocess/hook invocation with no real git/network/fan-out
work). No src/bin file touched, no numeric value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 07:05:48 -04:00
Tom Boucher
fd37b6a171 Merge pull request #4560 from open-gsd/test/4513-batch2-git-plumbing 2026-09-09 06:53:14 -04:00
Behruz Nassre Esfahani
137f3115a1 fix(#4492): index the suffix window instead of pattern-matching it (#4539)
* fix(#4492): index the suffix window instead of pattern-matching it

`MSG_SUFFIX="${CMD#*"$MSG_MATCH"}"` is quadratic in the -m message. bash tries
every prefix length and compares the whole matched literal at each, and
MSG_MATCH is BASH_REMATCH[0] — the entire `-m "..."` — so the cost grows with
the thing being scanned. Measured on the real hook: 10.0s at 64KB, 22.0s at
96KB, 30.2s at 112KB, 40.1s at 128KB. `bash -x` with an EPOCHREALTIME PS4
attributes 10.116s of a 10.2s run to that one expansion, which computes an
empty string. Conforming and non-conforming cost the same, so this is the path
every commit takes, and Claude Code blocks on PreToolUse hooks.

MSG_PREFIX on the line above has already located the match, so the suffix is
arithmetic rather than a search. Same first-occurrence assumption both
expansions always made — MSG_MATCH is a literal substring of CMD by
construction. Equivalence checked across 480 comparisons on bash 3.2.57 and
5.3.15 under C, UTF-8 and SJIS locales, including multibyte text, repeated
matches, metacharacters and invalid bytes.

Three regression rows, all deliberately on the RESOLVE=1 path so they pin the
suffix scan alone and do not depend on the separate #4429 SIGPIPE fix:
non-conforming and conforming 112KB heredocs, plus a suffix-window row whose
padding sits before the heredoc opener's newline so the COMMAND is large while
the message stays small. Red against the true base — all three killed at the
10s bound with the head -1 sites still present — and green with only this
change.

Fixture sizes stay under Linux MAX_ARG_STRLEN (131072 on a 4KB-page kernel).
Above it execve fails, the classifier cannot launch and the hook fails open, so
a larger fixture measures the argument limit rather than the suffix scan; an
earlier 131225-byte draft passed on base AND head for exactly that reason.
Every row asserts empty stderr, which is what separates "validated" from
"failed open".

The bound is enforced by killing the process GROUP, not the direct child: the
hook spawns a node classifier that inherits stdout, so killing only bash can
leave the pipe open and `close` never arrives. `local/no-elapsed-assertion`
forbids asserting on elapsed time, and `{ timeout }` is inert on a synchronous
body, so the rows are async and the kill is the signal.

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

* chore(#4492): add changeset

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-09 05:48:17 +00:00
Norman Yee
42c02a00c0 enhance(#3418): report real codebase drift instead of the whole repository (#4124)
* fix(#3418): write the codebase-drift baseline from code instead of agent prose

writeMappedCommit shipped correct and callerless, so no full map-codebase run ever wrote last_mapped_commit. The gate then read null and diffed HEAD against the empty tree, reporting every tracked file as newly added on every run.

Adds the stamp-codebase-map leaf verb and calls it from the map-codebase workflow and the execute-phase auto-remap path, replacing the prose instruction that asked the mapper agent to stamp its own output. An agent that concludes its work is already done skips a prose step silently, which is the failure the stamp exists to detect.

The gate now reports an absent or unresolvable baseline as skipped, with reason no-mapped-commit or unresolvable-mapped-commit, rather than as whole-repo drift. Files under .planning/ are excluded from the diff so the map's own commit does not read as seven new directories on the next run.

Emitted-Drift-Ack-Growth: map-codebase.md — adds the stamp_codebase_map step and its rationale, new workflow content this change requires

* test(#3418): cover the stamp writer and the absent-baseline gate

* docs(#3418): document how the drift baseline is written and skipped

* docs(#3418): note that a manual stamp reflows the map's whitespace

writeMappedCommit writes through platformWriteSync, which normalizes markdown whitespace on .md targets. Run in its workflow position the stamp lands on documents the mapper just wrote, so the normalization is folded into the same commit, but a hand-run stamp over an already-committed map reflows that map as a side effect. Reported on the issue thread.

* chore(#3418): add changeset fragment

Typed Changed to match the enhancement route the linked issue's label sets. The docs-required lint is satisfied by the ARCHITECTURE.md update already in this branch.

* fix(#3418): anchor the planning-artifact filter to the repo root

git diff --name-status prints repo-root-relative paths whatever the cwd, so computing the exclusion prefix against cwd yielded ".planning/" while git printed "sub/.planning/" and the filter silently matched nothing from a subdirectory.

* fix(#3418): derive the planning prefix from git, not from path arithmetic

Anchoring the exclusion prefix with path.relative() against `rev-parse --show-toplevel` broke on Windows, where os.tmpdir() hands back the 8.3 short form and git resolves the long one, so relative() produced a "../.." chain that matched nothing. `rev-parse --show-prefix` gives the cwd's root-relative prefix from the same producer as the diff paths, so the two sides cannot disagree.

* fix(#3418): take the planning lock around the codebase-map stamp

Stamping seven documents is seven frontmatter read-modify-writes, and two stampers can run at once: the full map-codebase run and the execute-phase auto-remap. Wrap the write loop in withPlanningLock, the same lock the other .planning/ writers take, so a concurrent pair cannot lose an update.

Also corrects the path-arithmetic comment, which read as if the Windows short-path hazard applied to the .planning half of the prefix. It applies to the rejected --show-toplevel alternative; both sides of the surviving relative() call are the same cwd string.

* fix(#3418): read HEAD and the map file list under the planning lock

The stamp resolved HEAD and listed the present codebase-map documents before it acquired the planning lock, so a stamper that then waited on the lock could write its now-stale sha over a newer one, or recreate a document deleted while it waited as a frontmatter-only stub. Both reads now happen inside the lock, matching the read-and-write-in-one-lock pattern config.cts and phase.cts already use. An empty --files value is refused as well instead of silently widening the stamp to all seven documents.

* fix(#3418): narrow the map stamp to the documents an update run refreshed

An "Update - only update specific documents" run reached the new stamp step with no --files narrowing, so the six documents the user did not select were stamped at HEAD and read as freshly mapped. The selection now threads through to --files, the same way the auto-remap path already does.

A bare --files (an unquoted empty shell variable drops the token) parsed to null, indistinguishable from an absent flag, so it skipped the empty-filter refusal and stamped all seven. Presence is now read off argv.

* fix(#3418): require the drift baseline to resolve to a commit, not any object

`git cat-file -t` exits 0 for a tree or blob sha and for a ref name, and `git diff <tree> HEAD` is valid, so an exit-code-only probe accepted a baseline that is not a commit and reported the resulting diff as real drift. Check the reported type instead of the exit code alone, which routes every non-commit stamp to the same `unresolvable-mapped-commit` skip.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-09 05:08:20 +00:00
Behruz Nassre Esfahani
3ad75a6d59 enhance(#4285): resolve context-monitor fire-points from .planning/config.json (#4366)
* enhance(#4285): resolve context-monitor fire-points from .planning/config.json

The monitor's WARNING (35%) and CRITICAL (25%) fire-points were module
constants, so the only way to tune them was editing gsd-context-monitor.js —
a file in the MANAGED hooks registry, whose body the next install re-stages,
silently discarding the edit. The alternative was turning the safety net off.

Both are now readable from the config block the hook already opens:
hooks.context_warning_threshold and hooks.context_critical_threshold. Absent
keys resolve to today's 35/25, so every existing project is byte-identical.

Resolution is total and never throws — this hook must not block the tool call
it rides in on. A value is usable only if Number.isFinite (type-strict, so the
string "30" and true are rejected) and inside the 0-100 domain of the
remaining_percentage it is compared against; anything else falls back to the
default. The PAIR falls back together: critical >= warning has no coherent
reading, and honouring one side silently picks which of the operator's two
numbers to discard. That also covers a single override contradicting the other
key's default.

config-set validates the domain per key so accept and honour agree, but
deliberately does not enforce the pair — it writes one key per call, so a
two-step retune is transiently inconsistent on disk and refusing it there
would block a legitimate configuration.

Registration follows the statusline.show_git precedent: schema manifest plus
src/config.cts validation, not config-defaults.manifest.json and not
buildNewProjectConfig — emitting 35/25 into every new project would pin the
defaults at creation time for a setting nobody has tuned.

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

* enhance(#4285): address Codex review — per-key fallback docs, discriminating tests

Codex full-PR review (gpt-6-astra, read-only) returned five findings. Each was
verified against source before acting; all five are real.

1. docs/CONFIGURATION.md described the wrong fallback. An out-of-domain value
   falls back PER KEY; both defaults apply only when the RESOLVED pair violates
   critical < warning. warning 150 with critical 30 resolves to 35/30, not
   35/25 — at remaining 28 that difference changes the severity emitted. The
   table now states the two rules in the order they compose, and
   docs/context-monitor.md gains the same worked example.

2. The inconsistent-pair test could not prove the CRITICAL side reverts: its
   pair was 20/25, and 25 is already the default, so an implementation that
   reset only `warning` passed it. A 45/50 pair — both halves away from their
   defaults — now pins each side with its own reading, and an equal 45/45 pair
   pins that the rule is strict (`<`, not `<=`).

3. The rejection table's rows could not tell rejection from acceptance: an
   accepted -5 pairs with the default critical 25, trips the pair check, and
   produces the same silence. Two rows now separate those: a below-domain
   critical must escalate remaining 20 to CRITICAL (proving -5 was rejected,
   not honoured), and an unusable critical beside a usable warning 45 must
   still fire WARNING at remaining 40 (proving per-key fallback rather than
   reset-both). The over-claiming comments are narrowed to what each row
   actually shows.

4. Scope, reproduced rather than assumed: config-set writes through
   planningDir(), so under GSD_WORKSTREAM it lands in
   .planning/workstreams/<name>/config.json while this hook reads only
   <cwd>/.planning/config.json. That is the pre-existing root-only scope
   hooks.context_warnings has always had, but this PR advertises the setter
   route, so both docs now say the keys are root-project settings.

5. Four other English docs still stated 35/25 as fixed: the REQ-CTX-02/03
   requirements fragment, ARCHITECTURE.md's hook table and threshold table,
   and INVENTORY.md's hook row. All now name them as defaults and point at the
   config keys; docs/FEATURES.md is regenerated from its fragment via
   scripts/gen-features.cjs --write, not hand-edited.

Four new mutations, each reverted after: resetting only the warning half on an
inconsistent pair (1 red), resetting both on any unusable key (1), dropping the
>= 0 bound (1), and accepting critical == warning (1). perf-317 is 116/0.

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

* enhance(#4285): tighten claims after Codex round 2 — scoped paths, one more discriminator

Confirmation round found no runtime defect and confirmed the five round-1 fixes
landed. Four precision items, all real, all fixed here.

1. The scoped-write note named the wrong path for GSD_PROJECT. planningDir()
   composes three distinct shapes, confirmed by running config-set under each:
   .planning/<project>/config.json, .planning/workstreams/<ws>/config.json, and
   .planning/<project>/workstreams/<ws>/config.json. docs/context-monitor.md
   now tabulates all four cases instead of collapsing them into one.

2. The 45/50 silence row asserted empty stdout without pinning the exit code.
   runMonitorRaw turns a spawn failure, a non-zero exit or a timeout into empty
   stdout as well, so the row could have passed on a dead child. It asserts
   exitCode === 0 first now, like the equal-pair row already did.

3. The sibling row's message claimed it proved critical fell back to 25. It
   does not: coercing '30' to 30 yields WARNING at remaining 40 too, so the row
   pins the WARNING side surviving and nothing more. Message narrowed, and a
   new row reads the same config at remaining 28, where the two candidate
   resolutions diverge — rejected gives (45, 25) and WARNING, coerced gives
   (45, 30) and CRITICAL. Mutation-verified: swapping Number.isFinite for the
   coercing global reds it.

4. "Accept and honour must agree" was too absolute in the src/config.cts and
   tests/config.test.cjs comments. The agreement holds on the DOMAIN and per
   key: an accepted value can still lose to the hook's pair check at read time,
   and a scoped write never reaches the hook at all. Likewise a two-step retune
   only CAN be transiently inconsistent — 35/25 to 20/10 is valid throughout if
   critical moves first — so the docs now say what a setter-side pair check
   would actually cost: rejecting that intermediate write and forcing an order.

The same over-absolute phrasing is in b7d179c89's message, which is left as
written rather than rewriting history; this commit and the PR body carry the
precise claim.

perf-317 117/0, config 192/0, config-field-docs 47/0, features-index-gate 84/0,
lint:ci clean cold.

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

* chore(#4285): add changeset

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

* enhance(#4285): address review — planning-config rows, resolveThresholds properties

Two Minor findings from the maintainer review, no behaviour change.

Minor 1: gsd-core/references/planning-config.md's "Hook Fields" table gains
rows for hooks.context_warning_threshold and hooks.context_critical_threshold,
in that table's 5-column form, carrying the same per-key-fallback,
pair-reversion and root-config-scope claims docs/CONFIGURATION.md already
makes. hooks.workflow_guard's absence from that table is pre-existing and
out of scope here.

Minor 2: resolveThresholds() gets fast-check property coverage, which ADR 456
requires of a threshold/limit contract. Reaching it needed a require-time
seam: the resolver was previously observable only by spawning the hook, and a
subprocess per case cannot drive 200 runs — the same conclusion CONTEXT-INDEX
records for the ROADMAP Requirements parser. The stdin adapter therefore moves
into main() behind `require.main === module`, mirroring
gsd-cursor-subagent-start.js and gsd-statusline.js, and module.exports exposes
the resolver plus both default constants so a test asserts fallback against
the source of truth rather than a second copy of 35/25. Spawned behaviour is
unchanged: the 10s stdin timeout still arms per invocation (stdinTimeout is
now a module-scope let assigned in main(), still cleared by the end handler),
and the try/catch crash(ON_CRASH) path is untouched.

Seven properties: totality, ordering, exactness, togetherness, non-vacuity,
per-key fallback, non-object argument. Exactness is stated PER KEY — a mixed
result (one key honoured, one fallen back) is legal and is the documented
contract; the property falsified a per-pair phrasing of it in 4 runs.

Verified: cold lint:ci 0; perf-317 file 125/0; seven mutations killed and
restored, one of which (upper bound widened to 120) is invisible to the 17
hand-written cases and caught only by a property.

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

* enhance(#4285): close the Codex-found gap in the property coverage

Codex whole-PR review of round 3 returned no Blocker and no Major. Two items,
both in the tests added this round, both verified against source before acting.

Minor — the per-key fallback property was asymmetric: it required a usable
warning to survive an unusable critical, but never the reverse. A resolver
that reverted BOTH keys the moment warning was unusable passed all seven
properties. Reproduced exactly: that mutant answers 35/25 for
{warning: 150, critical: 30} where the resolver answers 35/30, and the file
stayed green at 125/0. The mirrored property closes it — with the mutant
re-applied it is now the single failing row, and it is the only row that
fails, so it is load-bearing rather than incidental.

Nit — the ordering property's comment credited it with catching a
half-honoured pair, which it does not: 45/50 "repaired" by resetting only
critical yields 45/25, perfectly ordered. That case belongs to togetherness.
The same comment claimed the behavioural rows sample an inconsistent pair at
exactly one point; stale — they cover 20/25, 45/50 and the 45/45 equality
boundary. Both claims corrected in place.

Verified: cold lint:ci 0; perf-317 file 126/0; the mutant above killed by the
new property alone and the hook restored byte-identical afterwards.

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

* enhance(#4285): name the installed-monitor prerequisite; close the negative-critical gap

Second Codex whole-PR pass, run because the base moved: the author's three
"Update branch" merges pulled ~26 upstream commits in, so the previously
reviewed diff sat on a base that no longer exists. No Blocker, no Major, two
Minor — both verified against source before acting.

Minor 1, and only reachable because of what the merge brought in: #2586
(03738824d) landed in that window and stops staging
hooks/gsd-context-monitor.js for Codex, since the metrics bridge it reads is
written only by hooks/gsd-statusline.js, which Codex never installs
(bin/install.js: "gsd-context-monitor.js is deliberately NOT copied for
Codex"). These two keys are read by that hook and nothing else, so on such a
runtime config-set stores and validates them and nothing consumes them — a
claim the docs this PR adds did not make. docs/context-monitor.md now carries
the explanation and both key tables carry a clause pointing at it; the FEATURES
and INVENTORY entries already link through to those two files, so they are not
edited again. The changeset says it too, because it is user-facing.

Accepting the keys on every runtime is kept deliberately: config is shared
across runtimes, so validation stays runtime-independent and the runtime
caveat lives in documentation rather than in the setter.

Minor 2: the per-key fallback property's junk generator had no negative arm,
though its mirror did — and that asymmetry hid a gap. A resolver reverting
BOTH keys whenever critical is negative answers 35/25 for {45, -5} where the
resolver answers 45/25, and it passed all 126 tests. With the negative arm
added it is the single failing row.

Verified: cold lint:ci 0; perf-317 126/0; both mutants above killed and the
hook restored byte-identical; 538/0 across the config, changeset, doc-parity
and emitted-attribution gates.

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

* enhance(#4285): refuse the two dead threshold endpoints; resolve absent keys

Maintainer review round 2 raised two Minors and a nit.

Minor 1 — `hooks.context_warning_threshold: 0` was accepted and stored but can
never take effect: `critical < warning` must hold and both sides are clamped to
0-100, so nothing can sit below a warning of 0. Verifying it surfaced the MIRROR
case the review did not name: `critical: 100` is equally dead, since nothing can
sit above it. Both confirmed against the real resolver for partners {absent, 0,
50, 100}, with 0.001 and 99.999 honoured as controls.

`config-set` now refuses both, because storing a value the reader always
discards is the accept-then-discard shape this codebase refuses elsewhere. The
hook is unchanged and still total — it degrades to defaults rather than
throwing, so a project that already carries one of these on disk still loads.
The old "accepts the domain bounds 0 and 100" row asserted the misleading half
and is replaced by tables that make the asymmetry the point (0 is legal for
critical and illegal for warning; 100 is the reverse), plus a control row so
"refuse both endpoints outright" would not pass in its place.

Minor 2 — the keys are absent from config-defaults.manifest.json /
buildNewProjectConfig where the sibling `hooks.context_warnings` lives. Kept
that way: buildNewProjectConfig writes a hooks object into every NEW project's
config.json, which would freeze today's fire-points as an explicit per-project
override everywhere — the opposite of this PR's premise. But the underlying
complaint was real, so the actual symptom is fixed: `config-get` on an absent
key returned "Key not found" while the hook silently used 35/25. It now resolves
through SCHEMA_DEFAULTS. Restated rather than derived because CONFIG_DEFAULTS is
re-exported flattened and has no `hooks` member at runtime; the one resulting
copy of 35/25 outside the hook is pinned against the hook's exported constants
by a drift test (red-checked: moving the literal to 40 reds it).

Nit — PR-body counts unverifiable from the diff. Noted, no code change.

Codex round 3 then found a broken doc link (`context-monitor.md` resolved
inside gsd-core/references/, where it does not exist; the emitted tree's own
convention is `../../docs/...`) and a stale comment still describing the
manifest-derived approach I had backed out. Both fixed. It also corrected my
rationale on a point of fact: manifest entries alone would NOT have reached new
project configs, since buildNewProjectConfig builds its own literal — the
freezing argument applies to that function, not to the manifest. The comment now
says so rather than running the two together.

Verified: cold lint:ci 0; full suite 36,082 / 0 fail before these two fixes,
config + perf-317 321/0 after; drift pin red-checked.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-09 04:31:14 +00:00
Michel Moreira
6c5e11049b fix(#4383): require phase before planned-phase writes (#4534)
* fix(#4383): require phase before planned-phase writes

* chore: add changeset for #4534

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-09 03:38:23 +00:00
Michel Moreira
c9d3e66631 fix(#4341): reference-count the git-config sandbox the three guard suites share (#4389)
* fix(#4341): reference-count the git-config sandbox the three guard suites share

node:test evaluates every describe body during collection, before any test
runs, so the three isolateGlobalGitConfig() calls happened back to back and
each captured the PREVIOUS call's temp path as its "original": A captured
undefined, B captured A's path, D captured B's. Suite A's after() fires first
and restored undefined — deleting GIT_CONFIG_GLOBAL outright — so suites B and
D ran against the developer's real ~/.gitconfig for the rest of the file. A
also cleanup()'d a directory B still pointed at.

One sandbox now, with the true original captured once and released when the
last holder lets go; each returned restorer is idempotent, so an extra call
cannot release someone else's hold. The three call sites are unchanged.

Reproduced with a global core.hooksPath (via a fixture HOME carrying a
.gitconfig, so the developer's real one is never touched):

  next:   ℹ pass 291  ℹ fail 7   — "core.hooksPath is set to ...; a hook
                                    written to .../pre-commit would never run"
  branch: ℹ pass 296  ℹ fail 3

The 3 that remain are two suites (pr-subrepo, #3776 query commit --files) that
never called isolateGlobalGitConfig at all — the same class, a different gap,
and outside this issue's scope. Noted on the PR.

D0 is the regression guard and is deterministic on every lane: suite A's
after() runs before this suite's tests, so on the old helper GIT_CONFIG_GLOBAL
is already gone by then regardless of what the host's git config contains —
which is what makes it fail on CI, where the core.hooksPath that exposed the
defect is absent. Verified: full-file run reds on the old helper, greens on the
new one.

* test(#4341): clean filtered gitconfig sandboxes on exit

* test(#4341): retain exit cleanup until release succeeds
2026-09-09 02:31:45 +00:00
sim
342bd8ca6b fix: backfill changeset fragment PR number for #4560
Forgot the pr:0 -> real-number backfill step from CONTRIBUTING.md's
documented changeset workflow after opening PR #4560, which broke
changeset-lint and docs-lint (fail_invalid_fragment /
fail_malformed_fragment).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:01:15 -04:00
sim
bba20b51fd fix: pin transitive hono dependency to >=4.13.5 (moderate advisory)
A moderate-severity advisory chain (GHSA-gqvv-2mrq-wpjv,
GHSA-g6gw-c38x-mqfc, GHSA-crvj-82cr-hjcx) was newly published against
hono <4.13.5, pulled in transitively via @anthropic-ai/claude-agent-sdk
-> @modelcontextprotocol/sdk. Discovered blocking
tests/npm-integrity-gate.test.cjs while verifying #4513 (unrelated to
that batch's diff); fixed inline per this repo's no-defer policy.

Adds "hono": ">=4.13.5" to package.json's existing overrides block
(same pattern already used for qs, body-parser, @hono/node-server).
npm audit --omit=dev now reports 0 vulnerabilities.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 21:33:51 -04:00
Lorenz Leslie Espinosa
b33df03726 enhance(#4089): add minimum-solution reasoning check (#4118)
* enhance(planning): add minimum-solution reasoning check

* chore: add changeset for planning guidance

* chore: bind changeset to PR 4118

* docs: document planning sufficiency check

* docs: distinguish planning sufficiency guidance

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-09-09 00:58:23 +00:00
sim
95e6a58fd4 test(#4513): migrate git plumbing batch to named timeout constants
Batch 2 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property in
tests/git-base-branch.test.cjs, tests/commit-files-pathspec.test.cjs,
and tests/git-fixture.test.cjs with a named constant, per
eslint-rules/no-adhoc-timeout-literal.cjs. Removes the 3 files from
the rule's allowlist.

This batch introduces a violation shape not seen in Batch 1: several
sites are pinned-value test assertions verifying the EXACT timeout
production code hardcodes (not bounds on this suite's own subprocess
calls). Named as four separate constants even where values coincide,
so the tests keep catching independent production drift instead of
silently tolerating it. No src/bin file touched, no numeric value
changed anywhere -- verified site-by-site by two independent isolated
review passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 19:56:08 -04:00
sim
ff5c5782ee test(#4512): migrate process-seam batch to named timeout constants
Batch 1 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property in
tests/process-seam.test.cjs, tests/helpers-process-isolation.test.cjs,
tests/run-with-timeout.test.cjs, and tests/helpers.cjs with a named
constant, per eslint-rules/no-adhoc-timeout-literal.cjs. Removes the
4 files from the rule's allowlist.

Adds SEAM_DEFAULT_TIMEOUT_MS to tests/helpers/timeouts.cjs (shared
across 2 batch files, mirroring process-seam.cjs's own un-exported
default). File-local constants elsewhere for values not shared across
files or not a bench-derived class norm. No src/bin file touched, no
numeric value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 19:38:41 -04:00
sim
c9016b2e35 docs(#4554): add changeset fragment
changeset-lint flagged the PR as touching user-facing shipped content
(execute-plan.md, a workflow instruction file) with no fragment. Type Fixed
per CONTRIBUTING.md — Fixed/Security changesets are exempt from the
docs/-touch requirement Added/Changed/Deprecated/Removed carry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 17:52:35 -04:00
sim
8bcf633e21 fix(#4554): trim execute-plan.md 21 bytes under its DEFAULT size-tier cap
gsd-core/workflows/execute-plan.md sat 21 bytes over its own DEFAULT-tier
hard cap (40,960 bytes, tests/workflow-size-budget.test.cjs, ADR-1610) at
next@a27cb6b2fa — introduced by #4540's call-site wiring for the summary.md/
user-setup.md .compact.md variants, which nobody caught crossing this exact
margin before merge. This trips next's own Tests run on every shard/OS
combination, which in turn blocks the repo's Base branch health PR gate
(#4422/#4428) for every open and future PR regardless of that PR's own diff.

Two meaning-preserving trims in the <success_criteria> block: a repeated
parenthetical ("— unless parallel mode (orchestrator handles)", appearing
twice) replaced with a "— same exception" back-reference on its second
occurrence, and one redundant qualifier ("prominently") dropped — its
behavioral content (surface the USER-SETUP.md warning at the TOP of output)
is already fully specified earlier in the same file. 40,981 -> 40,940 bytes,
20 bytes of headroom under the cap. No procedural content lost.

Fixes #4554.

Emitted-Drift-Ack-Hash: gsd-core/workflows/execute-plan.md — deliberate content trim to clear the DEFAULT size-tier cap (#4554); not a regeneration artifact, a hand-authored byte reduction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 17:52:35 -04:00
Tom Boucher
a27cb6b2fa enhance(#4139): Phase 6 — the lazily-read remainder and the artifact templates (#4540)
* enhance(#4406): the lazily-read remainder and the artifact templates

ADR-4139 Decision 3, Phase 6 of the #4139 Compact Content epic. Covers stream 1b
(gsd-core/workflows/<name>/{modes,steps,templates}/*.md) and stream 4
(gsd-core/templates/**) with a variant-swap mechanism, confirmed with the user:
two independent, complete files per covered path (canonical + .compact.md
sibling), with the gate picking which one gets Read at the call site. This is
a different shape from Phase 5's spine+detail partition, and is safe here
specifically because these files are already reached only by a runtime Read —
a missed Read already means zero overlay content today, with or without
workflow.compact_content, so selecting between two independently-complete
files introduces no new failure mode (documented in
gsd-core/references/compact-content-gate.md's new "Streams 1b and 4" section).

Disposition, after inspecting every candidate rather than trusting a byte-size
threshold (same rigor Phase 5 applied to review.md):

- Stream 1b: 1 of 78 files compacted (help/modes/full.md, a user-facing
  reference doc emitted verbatim, not orchestrator instruction). The other 9
  size-threshold candidates are dominated by fail-closed guards, exact CLI
  invocations, or output-format contracts (AskUserQuestion blocks) — recorded
  not-worth-compacting, same reasoning as Phase 5's review.md.
- Stream 4: a ground-truth reachability audit replaced the initial size-only
  candidate list. Two files (summary.md, user-setup.md) got compact variants;
  a third (spec.md) was drafted, then dropped after discovering its only two
  call sites are eager @-includes, not a runtime Read — stream-1 material
  hiding under gsd-core/templates/, not stream-4's actual mechanism. summary.md
  itself has 3 eager call sites and only 1 genuine runtime-Read call site
  (execute-plan.md); only that one was wired, so the compact variant's savings
  apply to the sequential single-plan execution path only.
- Discovered while auditing reachability: 12 gsd-core/templates/** files with
  zero references anywhere in workflow/agent/command prose, compiled source,
  or tests — dead scaffolding predating this phase. Deleted in this same PR
  per this repo's no-defer policy, after re-verifying against a computed
  path.join(...) pattern (not just a plain-string search) that nearly caused
  two genuinely load-bearing templates (user-profile.md, dev-preferences.md)
  to be misclassified as dead.

New checker (tests/helpers/compact-content-variant.cjs): registration,
reachability, protected-content-preserved, size-smaller — replacing Phase
3/5's disjointness/completeness checks, which assume a partition rather than
two deliberately-overlapping documents. The reachability check's own
"unprefixed match" guard had a real bug (rejected the repo's own
`~/.claude/gsd-core/...` convention), caught by running it against the
already-wired help/modes/full.compact.md pair rather than only synthetic
fixtures — fixed to anchor on the nearest `gsd-core` path segment instead.

Template consumer parity (tests/compact-content-template-variant-parity.test.cjs):
proves each compact variant's `## File Template` fenced block — the actual
output-format contract a generated SUMMARY.md/USER-SETUP.md is parsed
against — is byte-identical to the canonical file, then runs the one real
deterministic consumer (gsd-core/bin/lib/coverage.cjs's classifyContent,
backing `gsd-tools uat classify-coverage`) against content built from that
shared contract.

Added a sibling benchmark script (scripts/benchmark-compact-content-variants.cjs)
rather than extending the existing spine/detail one — different data shape,
and the existing script's own contract deliberately isolates it from a
test-only helper's shape changing.

Emitted-drift acknowledgement: not needed. Every changed/added path in this
diff is hand-authored and present in the diff itself, so diffEmitted's
attribution loop resolves `via` to the path's own source before reaching the
ack-lookup branch (same reasoning Phase 5 verified for its own diff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* enhance(#4406): address code-review findings on the variant-swap gate

- docs/CONFIGURATION.md and gsd-core/references/planning-config.md's
  workflow.compact_content entries described only the spine+detail mechanism
  (Phase 5) and were missing this phase's variant-swap mechanism and its
  benchmark:compact-content-variants script entirely — required since this
  PR's changeset is type Added (CLAUDE.md's "Missing Docs for Changesets"
  rule). Both now describe both mechanisms and which call sites are wired.
- Added the missing RED^-1/no-op fixture for checkProtectedContentPreserved:
  a canonical file with zero <!-- gsd:protected --> blocks must be a
  no-op, not a violation — the only branch of that function the existing
  fixtures didn't exercise.
- Collapsed findCompactFiles/findMarkdownFiles in
  tests/helpers/compact-content-variant.cjs into one findFilesWithSuffix
  helper — the two were identical recursive walks differing only in the
  extension predicate (minor Duplicated-Code finding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): restore copilot-instructions.md, a false-positive dead-template classification

gsd-test caught this, not static analysis: 10 real failures in
tests/copilot-install.test.cjs, tests/installer-migration-install.integration.test.cjs,
and tests/repo-layout.test.cjs — all downstream of bin/install.js's Copilot install
path, which does
fs.readFileSync(path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'))
after copying gsd-core/templates/** into the target project, then merges it into both
.github/copilot-instructions.md and (local installs) AGENTS.md. The reachability audit
that flagged this file as dead checked src/*.cts and gsd-core/bin/*.cjs but never the
repo-root bin/install.js — a separately maintained installer bundle outside the
src/-to-gsd-core/bin/lib/ compiled-output convention. The fs.existsSync guard around
that read degrades to a silent skip rather than a crash when the template is missing,
which is why this surfaced only once the real E2E install test ran, not from any
static check.

Re-verified the remaining 11 deleted filenames against bin/install.js specifically
(plain substring and quoted-filename search) before trusting that list — all 11 have
zero hits there, confirmed dead by the same standard this one file failed.

Regenerated the installer emitted-tree goldens (tests/fixtures/install-tree/*.json) to
reflect the restored file, and corrected the "Removed" changeset (jolly-lynx-sprint.md)
and the phase design doc from 12 to 11 deleted files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: execute-plan.md — call-site wiring for the summary.md and user-setup.md .compact.md variants
Emitted-Drift-Ack-Growth: help.md — call-site wiring for full.compact.md, same variant-resolution rule

* docs(#4406): backfill changeset PR numbers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): resolve removed-but-needed lint findings on the dead-template deletion

CI's own full-test matrix (not gsd-test's matrix, which does not run this
check) caught 4 more false-positive dead-template classifications via
tests/removed-but-needed-lint.test.cjs / scripts/lint-removed-but-needed.cjs
— a literal, word-boundary basename check across .github/workflows/,
gsd-core/, and docs/ (excluding docs/adr/** and docs/research/**) for every
file a PR deletes. It has no semantic awareness, so a deleted template's
basename colliding with something else entirely still fires:

- claude-md.md: gsd-core/templates/README.md had a stale table row claiming
  /gsd-profile reads this template to generate CLAUDE.md. Verified false (no
  code reads it anywhere, same search that already covered bin/install.js) —
  fixed the row to *(inline)*, matching every other command-generated
  artifact in that table. File stays deleted.
- codebase/testing.md: collided with docs/guides/testing.md, an illustrative
  example row in docs-update.md's sample output table (an unrelated real
  generated-docs path). Swapped the example topic to "contributing" — the
  row is illustrative, any topic works. File stays deleted.
- codebase/architecture.md, codebase/stack.md: collided with docs/reference/
  planning-artifacts.md's directory listing of a user's own generated
  .planning/codebase/architecture.md and stack.md output — the same
  semantic mismatch already investigated and dismissed as unrelated earlier
  in this phase's audit, now caught by a gate instead of judgment. That
  listing repeats across 5 locale copies of the doc.
- continue-here.md: collided with the real .continue-here.md pause-work
  artifact, referenced across 15+ locale and workflow files.

For the last two, the lint's own error message offers "restore the file or
update every consumer in the same commit." Rewording 15+ files across
languages I cannot verify translation quality for, to shave 2 already-tiny
templates that were merely presumed dead, is disproportionate to this PR's
actual scope — restored codebase/architecture.md, codebase/stack.md, and
continue-here.md instead, and corrected docs/ARCHITECTURE.md's Templates
section accordingly.

Final confirmed-dead set: claude-md.md, codebase/concerns.md,
codebase/conventions.md, codebase/integrations.md, codebase/structure.md,
codebase/testing.md, debug-subagent-prompt.md, discovery.md — 8 files, down
from the original 12. Verified locally: GSD_REMOVED_BUT_NEEDED_BASE=next
node scripts/lint-removed-but-needed.cjs now passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: docs-update.md — swapped an illustrative example-table topic (testing -> contributing) to avoid a removed-but-needed basename collision with the deleted codebase/testing.md template; net +10 bytes

* fix(#4406): split codex-config.test.cjs to fix a genuine Windows CI timeout

Root cause of the `full test (windows-latest, 24, shard 2/3)` failure the
user asked to be actually fixed, not just re-run past: PR #4497 (landed
2026-09-07, one day before this PR's CI run) isolated
tests/codex-config.test.cjs into its own dedicated chunk because its
measured weight (17.87, ~45% of the post-cut Windows budget) made it unsafe
to share a chunk with any other file. That isolation was necessary but not
sufficient — even alone, with zero companion-file contention, the file's
real Windows execution time sits right at the 600s per-chunk ceiling. Two
independent CI runs on two unrelated PRs (this one and #4154) were both
killed within ~1.4s of the identical 600000ms mark — not random contention,
a deterministic near-miss the isolation fix couldn't address because it
never reduced the file's own cost, only removed the risk of a companion
file's cost stacking on top of it (which the PR #4497 comment explicitly
anticipated: "if a future profiling pass genuinely speeds up
codex-config.test.cjs itself, this isolation can be revisited").

The file itself explains why it's this heavy: 11,262 lines / 433 tests / 79
describe blocks, accumulated over dozens of bug-fix PRs (#2695, #2760,
#3245, #3285, #3346, #3426, #3427, #3562, #3566, #3582, #3808, and more),
several of which are explicitly documented as "folded" in from separate
files that were never actually split back out ("Verified non-duplicate
against both the pre-existing target and the other three folded sources").

Split into 4 files by top-level AST statement boundaries (never a naive
column-0 regex — an early attempt at that overcounted 79 apparent
"describe(" matches when only 21 are genuinely top-level; the rest are
nested inside a handful of large folded-in blocks, which a regex can't tell
apart from real top-level statements). Verified lossless twice: the split
script asserts byte-for-byte reconstruction of every source character, and
independently, total test()/describe() call counts match exactly between
the original file and the sum across all 4 new files (433/79 both sides).
Each new file carries the complete original shared header (imports/helpers)
for safety; per-file unused-import warnings from that duplication are
resolved via ESLint-precise alias renames (`{ foo: _foo }`, the standard
form for an intentionally-unused destructured binding — never a bare `{
_foo }`, which would destructure a different, nonexistent property).

No change needed to scripts/run-tests.cjs's ISOLATED_HEAVY_FILES or its
pinned test in tests/run-tests-harness.test.cjs: the file that keeps the
original name (tests/codex-config.test.cjs) is now only ~28% of the
original's size and safely isolated in its own chunk as before; the other
three new files re-enter normal weight-balanced packing, none individually
close to disproportionate. Confirmed no other file hardcodes the hardcoded
filename anywhere that would silently stop these tests from running (the
CI test-selection scripts determine scope algorithmically, not by literal
filename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:31:04 -04:00
Tom Boucher
66dbb104a0 fix(#4459): anchor update_codebase_map's diff base on the phase directory (#4549)
* fix(#4459): anchor update_codebase_map's diff base on the phase directory

execute-plan.md's update_codebase_map step derived its diff base from:

  git log --oneline --grep="feat({phase}-{plan}):" ... --reverse | head -1

A phase number is unique within a MILESTONE, not a repository (#3995).
`--reverse | head -1` deliberately selects the OLDEST matching commit
subject, so on a milestone that reuses a phase number, the diff base
lands in the PREVIOUS milestone's same-numbered phase -- silently
widening the file list that then drives which .planning/codebase/*.md
files get amended, with no warning and nothing downstream that would
notice.

This is the same defect class already fixed at two other sites in this
repo (code-review.md, structural-pre-pass.md) via a phase-DIRECTORY
anchor instead of a commit-subject grep: PHASE_START = the first commit
that ADDED anything under the phase directory, diffing from its parent
(or the commit itself on a root commit). Mirrored that exact pattern
here rather than inventing a new one.

Added tests/execute-plan-update-codebase-map-diff-base.test.cjs:
static regression guards (old grep gone, new #3995-shaped anchor
present) plus a real-execution test reproducing the issue's own
scenario -- two milestones reusing a phase number with a real
constructed git fixture, extracting and running the step's actual bash
fence, asserting the resulting diff is scoped to the current
milestone's files only.

Emitted-Drift-Ack-Growth: execute-plan.md — #4459 replaces the unbounded commit-subject grep with the phase-directory anchor already used by code-review.md/structural-pre-pass.md, net +687 bytes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4459): cite this issue in the new file's allow-test-rule marker

gsd-test caught tests/lint-allow-test-rule-refs.test.cjs failing: the new
test file's `// allow-test-rule: source-text-is-the-product` comment
(copied from the two sibling precedent files) was missing the required
issue-ref suffix -- ADR-456 requires a NEW exemption to cite an issue via
`#NNN` on the same comment line. Added `(see #4459)`. Verified via
`node scripts/lint-allow-test-rule-refs.cjs` directly (clean) before
re-running the full suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4459): backfill changeset PR number

pr: 0 -> pr: 4549

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:25:56 -04:00
Tom Boucher
511c900052 fix(#4458): reuse detectSubRepos for new-project.md's sub-repo detection (#4548)
* fix(#4458): reuse detectSubRepos for new-project.md's sub-repo detection

new-project.md's Step 5.1 (Sub-Repo Detection) ran its own bash predicate:

  find . -maxdepth 1 -type d -not -name ".*" -not -name "node_modules" \
    -exec test -d "{}/.git" \; -print

`test -d` requires .git to be a DIRECTORY. A linked git worktree's .git is
a FILE (a `gitdir: <path>` pointer), so this predicate silently excluded
valid linked-worktree children while still finding ordinary clones.

src/core-utils.cts's detectSubRepos(cwd) already handles this correctly
(fs.existsSync, type-agnostic) but had zero callers anywhere in the
codebase -- orphaned logic the workflow never actually used, despite
duplicating a narrower version of the same check inline.

Wired detectSubRepos into cmdInitNewProject's JSON output as a new
sub_repos_detected field (matching the file's existing pattern of
similar directory-scan-derived fields like has_existing_code/
is_brownfield/has_codebase_map) and replaced the workflow's raw find
fence with a gsd_run query init.new-project call reading that field --
removing the duplicate, narrower detection logic entirely rather than
patching it in place, per the issue's own "reuse a central policy"
framing.

Added the missing .git-as-FILE test case to the existing
tests/core-utils.test.cjs detectSubRepos coverage (proving the helper
was already correct -- the defect was entirely in the unwired workflow
predicate) plus CLI-level end-to-end coverage in
tests/init-manager.test.cjs using a REAL `git worktree add` fixture,
matching the issue's own reproduction steps, alongside an ordinary
child-clone case and a non-repository-directory negative case.

Refreshed tests/fixtures/compact-content-benchmark-baseline.json (gsd-test
caught the drift from new-project.md's byte-count change; the benchmark
script itself always exits 0 -- report, not gate -- but the wrapper test
enforces the committed baseline stays in sync).

Emitted-Drift-Ack-Growth: new-project.md — #4458 replaces the raw find predicate in Step 5.1 with a gsd_run query call reading the new sub_repos_detected field, net +140 bytes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4458): backfill changeset PR number

pr: 0 -> pr: 4548

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4458): bound the new git worktree add spawn with a named timeout

CI's lint-tests caught two ESLint findings my local gsd-test run
couldn't see (gsd-test's matrix doesn't run npm run lint:ci -- same gap
already observed on #4456's PR):

- local/no-unbounded-spawn: the new execFileSync('git', ['worktree',
  'add', ...]) call had no timeout, an indefinite-hang risk.
- local/no-adhoc-timeout-literal: my first fix (a bare `timeout: 15_000`
  literal) was itself flagged -- two independent hardcoded copies of a
  guessed timeout can silently drift or collide (this repo hit exactly
  that on 2026-09-06, PR #4428).

Fixed by importing GIT_FIXTURE_TIMEOUT_MS from tests/helpers/timeouts.cjs
-- `git worktree add` checks out files into a new working tree, the same
"construction" weight class as init/config/add/commit that constant
already covers, not plain plumbing (GIT_TIMEOUT_MS's class).

Verified via `npm run lint` directly (clean) before re-running gsd-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4458): reduce redundant git subprocess overhead in new tests

CI's full-test Windows shard 2/3 failed: chunk 1/9 (306 files) exceeded
its internal 600s budget and was force-killed, with an unrelated file
(codex-config.test.cjs) in flight at the moment of the kill -- meaning
the chunk's AGGREGATE runtime, not any single hang, blew the budget.

This PR's own three new tests each independently called
createTempGitProject() (git init + a commit), and one of them also runs
git worktree add -- real subprocess spawns, each Defender-scanned on
Windows CI (tests/helpers/timeouts.cjs's own documented rationale for
why Windows spawn classes get generous budgets). That's a genuine,
quantifiable overhead addition to the exact chunk that timed out, not
something to wave off as unrelated flake without checking.

Two of the three tests never actually needed a real git repo --
detectSubRepos only inspects a CHILD directory's own .git, never the
root's git state, and the existing SUBCOMMANDS loop earlier in this same
file already proves `init new-project` succeeds against a plain,
non-git createTempProject() fixture. Switched those two tests to the
lighter fixture, leaving only the one test that genuinely needs a real
repo (git worktree add requires one) on createTempGitProject().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:39:34 -04:00
Tom Boucher
00b7e622e1 test(#4457): cover init.new-milestone's project_exists/project_path under an active workstream (#4547)
#4457 reports init.progress and init.new-milestone reporting project_exists:false
(and a workstream-scoped project_path) for a root-shared PROJECT.md after a normal
migration. Direct read of current src/init.cts shows this is already fixed on next
by PR #4543 (#4455's self-discovered follow-up): withProjectRoot,
buildInitCompletenessFields, cmdInitProgress's project_exists, and
cmdInitNewMilestone's project_exists/project_path all already resolve PROJECT.md via
the root-aware planningDir(cwd, null).

PR #4543 added parametrized regression coverage for ingest-docs/resume/progress/
new-project (plus a dedicated milestone-op test), but its own comment explicitly
deferred new-milestone's coverage to "#4456's own new-milestone.md
workstream-forwarding work" (PR #4543). PR #4545 (#4456) never added it -- its test
additions only exercised the *workflow's* --ws argv forwarding through a stubbed
gsd_run, never cmdInitNewMilestone's real output. That gap is exactly what #4457's
own acceptance ask requests ("Add a migration-to-init regression test with a root
PROJECT.md and no workstream-local copy").

Folds 'new-milestone' into the existing SUBCOMMANDS loop in
tests/init-manager.test.cjs (it has no manager-style readiness precondition, and
exposes both project_exists and project_path, so it fits the loop's existing
assertion shape without a dedicated test). No production code change.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:19:16 -04:00
Tom Boucher
147c89a9b8 fix(#4456): forward --ws to every downstream new-milestone.md call (#4545)
* fix(#4456): forward --ws to every downstream new-milestone.md call

new-milestone.md's Step 1 parses --ws <name> into GSD_WS, but each
workflow step's bash fence is a separate shell invocation — GSD_WS set in
Step 1 never survived to Steps 5, 6, or 7. Four call sites never
forwarded it: init.new-milestone (both calls), state.milestone-switch,
and both phases.clear branches. Under GSD_WORKSTREAM env or a stored
session pointer differing from the explicitly requested --ws, every
downstream operation silently operated on the wrong workstream (or root)
instead of the one the caller asked for.

Confirmed --ws is a universally-parsed CLI flag (gsd-core/bin/gsd-tools.cjs:
4867, resolveActiveWorkstream) — stripped from argv and written into
process.env.GSD_WORKSTREAM for the rest of that process, so appending it to
ANY gsd_run query call works uniformly. Fixed by persisting GSD_WS to
.planning/.gsd-ws-arg right after Step 1 parses it (mirroring the
established .gsd-outgoing-milestone round-trip idiom this same file
already uses for the identical cross-fence problem), reading it back in
each later step, and appending it unquoted (matching the ${GSD_WS}
splicing convention documented in workstream-flag.md). Cleaned up after
its last use in Step 7.

Bundled, in-scope fixes found while implementing the above (per this
repo's no-defer policy):

- Step 6's phase-archive `git add .planning/milestones/ .planning/phases/`
  hardcoded literal ROOT paths — both directories are workstream-scoped
  (matching cmdMilestoneComplete's established #1911 precedent), so under
  a workstream this staged nothing real. Added phases_dir/archive_dir
  fields to cmdInitNewMilestone and resolved through them instead.
- Step 6's milestone-start commit hardcoded .planning/STATE.md — also
  workstream-scoped, so it would commit the wrong (or a stale) file under
  a workstream. Resolved through init.new-milestone's existing state_path
  field instead; PROJECT.md correctly stays a literal-shaped-but-resolved
  root path (shared, per the #4455 follow-up already merged).
- cmdInitNewMilestone's config_path field: config.json is ALSO a shared
  file (marked `# Shared` in workstream-flag.md's directory diagram, same
  as PROJECT.md) but was resolved via the workstream-aware planningDir —
  fixed alongside cmdInitNewProject's identical instance of the same bug
  (found via grep, matching the precedent from the #4455 follow-up of
  fixing every occurrence of an identically-evidenced bug uniformly).

Verified: direct CLI invocation confirms phases_dir/archive_dir/state_path
resolve into the workstream while project_path/config_path stay root under
GSD_WORKSTREAM=alpha. Manual bash-fence execution of every modified fence
(Step 1 parse+persist, Step 5 forwarding, both phases.clear branches, the
git add fence, Step 7's forward+cleanup, the commit fence) confirms correct
behavior in both flat and --ws modes, including two flags composing
together (--archive-version + --ws; --reset-phase-numbers + --ws).

Rewrote the pre-existing "step 6: commit stages PROJECT.md" test, which
asserted the literal (buggy) --files string verbatim — it now asserts the
resolved paths via a JSON-returning stub, and gained isolated per-test tmp
dirs (the prior version ran with no explicit cwd, at real risk of writing
a stray .gsd-ws-arg into this repo's own .planning/ once Step 1's fence
started performing a real write).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4456): Steps 9/10 also commit workstream-scoped files via literal root paths

A fresh isolated code-review pass on the first version of this fix found
the identical bug in two more places, missed in the initial sweep:

- Step 9's requirements commit (`gsd_run query commit ... --files
  .planning/REQUIREMENTS.md`) and Step 10's roadmap commit (`--files
  .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md`)
  both hardcoded literal ROOT paths for files that are workstream-scoped.
- Worse: `.planning/.gsd-ws-arg` was being deleted at the end of Step 7,
  but Steps 9 and 10 run AFTER Step 7 and still needed to re-read it —
  the round-trip mechanism this fix builds was already gone before its
  two remaining consumers ran.

Fixed by moving the `.gsd-ws-arg` cleanup to Step 10 (its true last
consumer, after the roadmap commit) and adding the same
fetch-then-_gsd_field-extract pattern already used in Step 6 to Steps 9
and 10, resolving `requirements_path`/`roadmap_path`/`state_path` through
`init.new-milestone $GSD_WS_ARG` instead of literal paths.

Also fixed (MEDIUM, same review pass): Step 1's `.gsd-ws-arg` write had
no `2>/dev/null || true`, unlike every other round-trip write in this
same file — brought into line with the established idiom.

Verified: reproduced the pre-fix bug directly (Step 9/10 fences echoing
the literal root paths regardless of --ws), confirmed both fences now
resolve the workstream-scoped paths correctly, and confirmed the
round-trip file survives Step 7 and is only removed after Step 10.
Updated the Step 7 test that previously asserted premature cleanup
(inverted to assert the file survives); added new coverage for Steps 9
and 10 in both flat and --ws modes.

Two remaining LOW/pre-existing findings from the same review pass,
deliberately left as-is: `phase_archive_path` (src/init.cts, untouched by
this diff) resolves via the same root-only `getLatestCompletedMilestone`
this fix's earlier commit already declined to touch, for the same
genuine-product-intent-ambiguity reason (workstream-scoped vs
project-pooled "latest completed milestone" is not resolvable from the
code alone). `.planning/research/` staying root-scoped in the #222
self-heal prose is consistent with the existing (unchanged) `research_dir`
field, not a new inconsistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4456): revert wrong config_path change; fix isolated-cwd test env

gsd-test caught two real regressions from this fix's earlier commits:

1. config_path is NOT shared like PROJECT.md. The prior commit's
   grep-and-replace ("fix six more functions with the identical bug")
   also touched cmdInitExecutePhase's config_path (a fourth call site
   beyond the two I'd manually checked) — but tests/init.test.cjs's
   pre-existing, ADR-0006-governed "init handlers honor GSD_WORKSTREAM"
   coverage explicitly asserts config_path IS workstream-scoped for
   execute-phase/plan-phase/phase-op/milestone-op. workstream-flag.md's
   "# Shared" marking for config.json is stale (the same class of
   staleness already found for milestones/ during the #4455 follow-up);
   ADR-0006 plus its real, passing tests is the authoritative source.
   Reverted config_path to the plain workstream-aware planningDir(cwd)
   in all four functions it was wrongly changed in.

2. Isolating cwd to a tmpDir (needed once Step 1's fence started
   performing a real .gsd-ws-arg write) broke the runtime-launcher
   preamble's own gsd-tools.cjs discovery — no git repo at an isolated
   tmpDir, no global gsd_run on the CI bench's PATH. Fixed by passing
   RUNTIME_DIR explicitly in every isolated-cwd test's env, matching
   the preamble's own documented override precedence.

Verified: direct CLI invocation confirms execute-phase's config_path is
workstream-scoped again under GSD_WORKSTREAM=wsx; the RUNTIME_DIR fix
confirmed against a stripped PATH (no global gsd_run), matching the
bench condition that surfaced the original failure.

Emitted-Drift-Ack-Growth: new-milestone.md — #4456 forwards --ws to every downstream gsd_run call across 7 fences (Steps 1/5/6x3/7/9/10), adding a persisted round-trip file plus resolved-path fetches that replace several hardcoded literal paths
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4456): backfill changeset PR number and correct final scope

pr: 0 -> pr: 4545, and removed the changeset's claim that config.json
is a shared file -- that was the change this same PR later reverted
after gsd-test caught it contradicting ADR-0006's established,
workstream-scoped config_path contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4456): baseline the 10 new SC2086 findings from --ws forwarding

The lint-tests CI job failed with a hard exit 1. Diagnosis (not assumed):
the log's two `fatal: ambiguous argument 'origin/next...HEAD'` git errors
(lines 244/248) are a red herring — both belong to
lint-removed-but-needed.cjs, which prints its own "could not resolve
origin/next, skipping" message and exits gracefully, exactly like the
already-handled two-dot-form error from lint-fix-has-regression-tests
earlier in the same log. Neither contributes to the actual failure.

The real cause is lint-workflow-shellcheck: this fix's new fences append
$GSD_WS_ARG unquoted to gsd_run calls (deliberately, so it splits into 0
or 2 argv tokens — the same idiom gsd-core/workflows/verify-work.md
already uses for ${GSD_WS} and already has baselined). ShellCheck
correctly flags each as SC2086, and lint-workflow-shellcheck.cjs's
baseline is a deliberate ratchet (#4109) requiring new findings to be
explicitly accepted, not auto-passed. new-milestone.md previously had
zero baselined SC2086 findings, so all 10 new (correct, intentional)
occurrences were reported as new and failed the gate.

Added 10 {file, code, message} entries to
scripts/lint-workflow-shellcheck-baseline.json for
gsd-core/workflows/new-milestone.md's SC2086 findings, matching the
established, already-accepted precedent for the identical pattern in
verify-work.md. No source or workflow file changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:23:20 -04:00