Commit Graph

26 Commits

Author SHA1 Message Date
Tom Boucher
15af0f5536 enhance(#3951): B6+B7 — widen two unreachable lint rules and make the guard ledger true (#3965)
* fix(#3951): two lint rules that could not reach the code they govern

B6 names two widenings. Measuring them first turned up a defect the criterion did
not know about, and refuted the reason it gave for one of them.

1. no-adhoc-markdown-parsing self-gates on its own filename.

   Lines 107-110 short-circuit create() to {} unless the path matches
   /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in
   eslint.config.mjs - but doing only that ships an INERT rule, because the gate
   still returns {} for every new path. Both halves have to change, and the gate
   is the load-bearing one.

   That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file
   to sit directly in src/. The registered glob is src/**/*.cts, which includes
   subdirectories. 28 .cts files - health-diagnostic-rules/ (10),
   installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2),
   vendor/ (2) - are inside the registered glob and silently skipped.

   Measured with the gate neutralized: 0 violations there today. The hole is
   hiding nothing right now, and is fixed anyway, because "no violations today" is
   not a property that keeps holding.

   The fix is not invented: require-subprocess-timeout.cjs:196 already carries the
   correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over.
   Checked the other 21 rules for the same bug - no-adhoc-regex-escape and
   no-private-binary-resolution short-circuit only to exempt their own seam file,
   which is the right shape, and no-crlf-fragile-split has no filename gate at
   all. This bug is unique to the one rule.

2. no-adhoc-regex-escape could not see the shape that actually occurs.

   Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'.
   Every check below it - the _SOURCE provenance check, the
   isSoleReturnOfOwnParameter shape - lives inside that branch, so
   new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all.
   Runtime data arrives as a property access far more often than as a bare
   identifier, which is exactly why this rule never fired on the #3477 ReDoS.

   Widened to MemberExpression, measured by AST walk across all five registered
   blocks rather than by grep. 27 sites, zero TSAsExpression:

     18  safe new RegExp(X.source, flags)  -> exempted, keyed strictly on the
         PROPERTY being `source`, never on the object. Keying on the object would
         wave through X.anything and buy nothing. B6 estimated ~10; that was an
         undercount.
      3  _SOURCE-suffixed constants reached through a required module namespace
         (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class
         the rule already recognizes for bare identifiers, extended to reach them.
         Without this the widening produces 3 false flags.
      6  real findings -> marked, each a test extracting a pattern from a shipped
         file at test time, where the runtime contract IS the product.

   Deliberately the NARROW MemberExpression form. The rule's own
   isSoleReturnOfOwnParameter doc comment records that an earlier broad
   "any non-literal identifier" heuristic produced ~25 false positives and was
   rejected; a re-run of the census after this change flags exactly the 6 above
   and nothing else.

Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts,
still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned
by a test proven to fail against the old regex. build:lib, lint and lint:ci all
exit 0.

Refs #3951

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

* fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds

The rule self-gates on filename AND is registered on one glob, so widening either
half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and
scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the
same two.

A test pins that the gate and the registration AGREE, in both directions. The
original defect was a gate narrower than its registration; the failure mode of
this fix is a gate wider than its registration. Both are silent, so the test
asserts the pair rather than either half.

80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed
through the existing seams - scanFencedBlocks, collectSection, stripFencedCode,
tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable,
findTableWithColumns from markdown-table. Headerless STATE.md tables use
splitTableRow per line, because parseMarkdownTable needs a real delimiter row.

10 are suppressed, 12.5%, well under the third that would have meant the rule is
mis-scoped for tests/ rather than the tests carrying debt. Each names its reason:
three regression guards (#3873 / bug-#21) are deliberately independent of the
generator's own fence handling, and routing them through the seam would have them
test the generator against itself; one is a negative-text probe that extracts
nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the
table fingerprint and is not markdown parsing at all.

All ten sit in tests whose subject is .md content, which is normally a reason to
prefer the seam. The marker used is allow-adhoc-markdown, distinct from
no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports
the same 280/280 unverified count as before - checked rather than assumed, because
those two markers are easy to conflate.

The widening earned its keep immediately: it found a test that passed for the
wrong reason.

  tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the
  TYPE column instead of the DEFAULT column. notEqual('number', '600') is true
  forever, so the guard against workflow.subagent_timeout regressing to the old
  seconds default could never fire. docs/CONFIGURATION.md:434 is
  `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell
  index 2; the assertion is now row-scoped through splitTableRow and reads 300000.

That is the argument for the widening in one case: the violation was invisible to
lint, the suite was green, and the assertion was vacuous. A rule that cannot reach
a file cannot tell you the file is lying.

Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by
this widening. tests/package-legitimacy-gate.test.cjs yields zero violations even
with the gate bypassed - its hand-rolled scans are real, but built from line
filters and split('|') rather than the regex-literal fingerprints this rule
detects. They need new detectors. The epic assumed a wider glob would catch them.

build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and
scripts/** is 0 violations.

Refs #3951

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

* fix(#3951): B7 — and #3356's defects were still live in the code

B7 asks that each closed child be driven fail-first with a behavioral identity
test at the CONSUMER's output. Four of eleven children had no test citing their
issue number. Auditing them by BEHAVIOR rather than by number-grep changed the
answer for three of the four.

#3364 and #2540 — traceability only. Both were implemented by #3941 and their
consumer-output tests exist and were shown failing-first; neither cited its
originating issue, so an audit that greps for the number reports them uncovered.
Tagged the specific asserting test in each file, following the citation form those
files already use.

#3372 — covered, but only at helper level, and the triage narrowed it. Of the four
commands the issue names, only estimate-cli's collectCalibrationSamples actually
enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from
ROADMAP/body text and never reach the sentinel path, so they are benign by
construction and were left alone rather than "fixed" into churn. The existing #3882
rows asserted the helper's return value. Added a consumer-output test driving
`query estimate-calibrate` and asserting sample_count and the persisted document.
RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real
CLI - sample_count 3, sentinel leaked; restored - sample_count 2.

#3356 — NOT covered, and BOTH halves of the defect were still live in source. The
issue is closed; the bug was not fixed. Fixed here rather than writing tests that
document a bug as correct.

  Defect 1, the contradicted row. quick.md:627 claimed
  `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did
  not: the `#` cell was a positional ordinal and `Directory` read `—`, because the
  route had no way to receive a quick id or task directory. Added OPTIONAL
  `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the
  original #2133 caller - omits them and gets the byte-identical prior row, so
  nothing existing changes. A caller that HAS a real id and directory now gets the
  canonical row quick.md:632 renders. The false-equivalence sentence itself is
  corrected rather than left to mislead the next reader.

  Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no
  options, so a body-only append to the Quick Tasks table triggered a full
  re-derive of the disk-derived progress.* frontmatter. Every other body-only
  writer passes { resync: false } - src/state.cts's own docstring prescribes it -
  and this route was the lone outlier. RED proof: reverted the option, seeded a
  project with 2 real phase dirs and a curated total_phases of 25, ran
  quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25.

That second one is the shape this epic exists to close: a silent write that
replaces curated state with a re-derivation nobody asked for, exit 0 throughout.

build:lib, lint and lint:ci all exit 0.

Refs #3951

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

* docs(#3951): amend B6's ledger to what was measured, and document the new flags

The ADR gains a ledger amendment in its own correction style - the sixth wrong
premise it records, found the same way as the other five, by measuring before
building.

B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the
epic's filing commit to origin/next. The attribution is the point, though. Five of
the seven came from PRs unrelated to this epic, one was added by a phase of it, and
the epic did retire something sub-file - #3884 removed a detector with an explicit
"net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two
already carry retractions in this same document, and a sweep of all 22 rules plus
every scripts/lint-* found no provably dead guard. There is no honest way to make
the count fall; forcing it would trade coverage for a number, which is the Goodhart
outcome Decision 6 exists to prevent.

The amendment also records that B6's own prescribed fix for one widening was inert.
no-adhoc-markdown-parsing self-gates on its filename, so widening only the files:
glob - which is what the criterion says to do - ships a rule that still returns {}
for every new path. And #3426/#3239 are not reachable by that widening at all;
their scans use line filters and split('|'), not the regex fingerprints the rule
detects. The roster row tracked them against the wrong mechanism.

Three roster rows updated from aspiration to fact: the two widenings are DONE with
their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather
than "expected casualty - verify before retiring", because Phase 5 verified it and
kept it.

The rule Decision 6 should carry forward is stated plainly: a guard ledger is a
claim about COVERAGE, not about COUNT. "Net count must fall" is measurable and
wrong. "Every guard is reachable, and each retirement names what makes its defect
unrepresentable" is the property that was actually wanted.

CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says
plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the
append no longer re-derives progress frontmatter.

New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited.
Changeset is Changed, pr:0 pending backfill.

Refs #3951

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

* test(#3951): correct four rows that pinned the lint rule's old narrow reach

The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs.
They are stale tests, not a regression: four rows assert that
no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the
contract this deliverable changes.

Confirmed by reading rather than inferred from the names - the row at :1981 used
filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots
the rule now covers on purpose.

Worth recording WHY local gates missed this. npm run lint and lint:ci were green,
and the touched test files passed standalone. Lint only reports violations in real
files; these rows assert the rule's REACH using synthetic RuleTester filenames, so
nothing but the full suite could see them. Local green on a rule change says
nothing about the rule's own tests.

Each row is rewritten with BOTH halves rather than flipped from valid to invalid:

  - the same fingerprint under tests/ or scripts/ is now flagged, with the right
    messageId
  - the negative space is preserved - the same fingerprint under a path outside
    all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged

The second half is the one that matters. Without it the rule has no boundary and
nothing would catch an over-wide gate later, which is the mirror image of the bug
this deliverable just fixed.

Each row is renamed to state the current contract; the old names said
"non-src/*.cts ... is not flagged" and would have been actively misleading once
the bodies changed.

Proven to test the widening rather than restate it: every flagged half was run
against HEAD~2's pre-widening rule and does NOT fire there, then against the
current rule and does. 12/12 on that probe; the full file is 178/178.

Swept for the same staleness elsewhere and found none.
require-subprocess-timeout's own "inert outside src/*.cts" row is untouched -
that rule's gate was not widened here - and no-adhoc-regex-escape's test file
already carries correctly-targeted rows.

Refs #3951

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

* test(#3951): acknowledge the quick.md growth the attribution guard reported

The full suite came back RED with one failure, and it is mine:

  1 file(s) grew without an acknowledgment:
    quick.md grew 364 bytes

gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting
its false 'performs the equivalent write' claim trips emitted-attribution by
construction. This is the acknowledgment, not a workaround - there is nothing
to regenerate.

The fragment names ONE path, which is the only one the guard reported. The four
spent acknowledgments it also listed (audit-uat, plan-phase, progress, review)
belong to other fragments whose ripple the base already absorbs; they are inert,
not failures, and are deliberately NOT copied here - naming paths I did not
change would make this record false in the other direction.

Byte figure corrected before committing: the guard reported 37220 -> 37584
(+364), but origin/next has since moved and quick.md is 37232 there now, so the
measured delta is +352. The reason text says so and names the base as a moving
figure rather than pinning a number that is already stale.

Refs #3951

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

* test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment

The acknowledgment mechanism changed under this branch. Merging next brought in
the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which
was in the merge status and which I did not register at the time - and the guard
now says so directly:

  Add a trailer to a commit in this PR (never a new file).
    Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate>

So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on
arrival. A fragment file is no longer read by anything, and leaving it would be a
dead record that looks like an active one. It is deleted here rather than kept
"just in case".

The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier
fragment said +352, measured before the merge auto-merged quick.md itself. The
trailer carries no number, which is the better design - the figure was stale
twice in two attempts.

Refs #3951

Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3951): backfill changeset pr number

Refs #3951

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 23:10:49 -04:00
Tom Boucher
107eb8c1d9 feat(#3753): run docs guards on the PR that changes the docs they read (#3787)
A PR whose diff is entirely under docs/ runs zero tests, so a guard whose INPUT
is shipped prose cannot protect the PR lane of the diffs it exists to check. Its
only firing opportunity is after merge, on the shared branch -- which is how next
went red on dacae9273 while the PR that caused it (#3746) was green on every
check.

The docs-lint job in .github/workflows/docs-required.yml -- an ALREADY-REQUIRED
context -- now selects and runs the docs guards that read the specific docs files
the PR changed.

  scripts/docs-guard-registry.cjs    test file -> the docs paths it reads (63)
  scripts/select-docs-guards.cjs     pure (changedPaths, registry) -> test files
  scripts/lint-docs-guard-registration.cjs   drift guard, wired into lint:ci

scripts/ci-test-scope.cjs is NOT touched -- `git diff origin/next --` on it is
empty -- so #764's saving stands and its 21 pinning tests are untouched.

Selection: exact path; trailing-slash directory prefix (boundary-checked --
docs/adrenaline.md does NOT match docs/adr/, which a naive startsWith gets
wrong); and '*' for the 6 entries that walk docs/ generally or read a computed
path. Unknown maps to '*' -- guessing narrow is how a guard silently stops
running. Measured: a typo fix selects 6 of 63; docs/AGENTS.md selects 12;
docs/COMMANDS.md selects 18.

Four things this got wrong first, each found by an independent reviewer or by
probe, and each having been asserted safe in a comment:

1. The registry started as a RULE in ci-test-scope.cjs's RULES, on the theory
   that classify()'s !codeChanged normalization made it inert. True for
   docs-ONLY diffs; false for MIXED docs+code diffs, where codeChanged is true
   and the normalization never runs:

     node scripts/ci-test-scope.cjs --files "docs/a.md src/semver.cts"
       with the RULE:  25 targeted_tests
       origin/next:     3 targeted_tests

   Category error: RULES is the scoped lane's input; a docs-guard registry is a
   lane manifest for a consumer that never calls classify(). Extracted; pinned
   by value.

2. The second attempt was a dedicated workflow with paths: [docs/**]. Such a
   workflow never reports on a non-docs PR, so it can never be a required
   context without hanging every non-docs PR -- and a non-required check does not
   block a merge, so the guard would have been advisory and #3753 unfixed.
   docs-required.yml already has no paths: filter, already supplies the required
   docs-lint context, already computes docs_changed, and already ran one docs
   guard gated on it. Generalizing that step needs no ruleset edit at all.

3. The registry and the drift lint were built from ONE path-segment heuristic, so
   both were blind identically -- and blind at the guard that motivated the issue.
   The reader-call regex required a character BEFORE its keyword, so a callee
   named exactly read( / load( / parse( / doc( / file( / content( could never
   match; and only an INLINE path.join(ROOT,'docs','X.md') argument was caught,
   missing the two-step-via-variable form -- the MAJORITY spelling -- plus
   template literals and concatenation. Detector 1 fired on 14 of ~450 files, so
   35 genuine guards sat unregistered while the lint reported 0 violations,
   including cursor-reviewer (reads docs/COMMANDS.md, asserts
   .includes('--cursor')) and inventory-headings-countfree. The "accepted blind
   spot" this shipped with was the common case, not a fringe.

4. With detection fixed the true population is 115 files: 63 genuine guards, 52
   incidental. Running all 63 in a REQUIRED check on a one-line typo fix is the
   cost #764 exists to avoid -- install.test.cjs is 7840 lines and reads exactly
   one docs file, docs/AGENTS.md, for its frontmatter. Dropping it reproduces the
   bug; running it for a typo elsewhere is waste. Hence the map.

Then a second review round found six more, all fixed here:

- fragment-single-edit-propagation.install.test.cjs was EXEMPTED as
  "overlay fixture only". False: it reads the real docs/registries/eos.json and
  asserts on a registry entry name, and reads the real ADR-0001 and asserts its
  H1. A docs-only PR touching either would have gone green and red next -- #3753
  shipping again, from inside the fix for it. Now registered against both paths,
  and all 52 remaining exemptions were re-audited one by one.
- The SUITES-collision guard compared RAW registry keys, but run-tests.cjs strips
  a leading `tests/` BEFORE its suite check. So it caught 'all' and missed
  'tests/all' -- the only spelling that can actually occur, since every key
  carries the prefix. One typo would have run all 824 test files inside the
  required job. Now normalized the same way run-tests.cjs normalizes.
- The lint failed OPEN on an unreadable tests dir or candidate file: 0 violations,
  ok:true. A guard that cannot read its input must never report success.
- The exemption ratchet gated identity only, so a baselined file that later
  STARTED asserting on shipped docs stayed exempt silently -- 52 permanently blind
  files. The baseline now fingerprints the docs paths each exempted file
  references and fails when that set changes, naming what changed.
- The exemption marker was still honored inside a multi-line template literal in
  the header window. The scanner now tracks template-literal and block-comment
  state.
- `git diff --name-only | grep '^docs/'` silently dropped C-quoted non-ASCII docs
  paths, making docs_changed=false a green zero-guard check. Both call sites now
  pass -c core.quotepath=false.
- The run step was gated on hashFiles(), which a force-committed
  .docs-guard-tests.txt would satisfy. The step now rm -f's both scratch files
  first and gates on an output it sets itself.

Three empty states, deliberately distinct, because conflating them rebuilds
#3753: an empty or malformed registry HARD-FAILS; docs changed with no guard
covering them logs and skips; no docs change is already gated. The middle state
must never be expressed as an empty --files-from, which prints `no tests in suite
"all"` and exits 0 -- a green check that guarded nothing. With the current
registry that state is unreachable, because the six '*' entries always match;
the branch is kept as defensive handling for a future registry and says so.

timeout-minutes: 15 bounds the required job against a hanging fork-supplied test;
it had none. npm ci was added because the job never installed dependencies -- the
previous single-file step got away without it, the registry does not.

docs/contributing/docs-guard-registration.md documents the rule, following its
sibling cross-platform-portability-rules.md, and CONTRIBUTING.md's CI Test
Quality Checks table links to it. It is also load-bearing: without a docs/ file
in the diff this PR would not have triggered its own lane, shipping an
unexercised change to a required check.

One unrelated fix, included because this PR surfaced it and CLAUDE.md forbids
deferring a defect found while working. On this branch's first CI run,
`full test (windows-latest, 24, shard 3/3)` was CANCELLED at exactly 30 minutes;
tests were still passing 0.8s before the cancel, so it is a wall-clock timeout,
not a hang, and a cancelled job reddens `Required tests`.

The cause is not this PR's test file, which costs ~60ms. Shard composition is
unstable: adding ONE file to the unit suite reshuffled 115 of 268 files between
shards, and shard 3 drew a heavier mix. Underneath that is a real pre-existing
defect. tests/ci-test-job-timeout-budget.test.cjs requires every lane's budget to
be >= 1.5x its MEASURED cost -- "a lane that got slower must be re-budgeted, not
excused" -- and its test-full entry recorded 19m from a windows-22 shard. That is
stale. Measured on `next` with none of this PR's changes present: 26m18s (run
32614439702, windows-latest/24 shard 3/3), 23m36s and 23m17s on shard 2/3. So the
lane costs ~26m and the 30-minute cap carried 1.14x headroom, not 1.5x. The gate
had been out of compliance with its own rule; this PR was merely the file
addition that reshuffled shard 3 past the cliff.

Fixed as that file prescribes: measuredMinutes 19 -> 27 with fresh evidence, and
test-full timeout-minutes 30 -> 45. The rule's minimum for 27m is 41; 45 is
deliberately above it because the reshuffle means per-shard worst case moves run
to run, and a budget pinned to the exact minimum would be re-breached by the next
test file anyone adds. Only that one job's timeout changed; test.yml's scope,
matrix and steps are untouched, so #764's saving is unaffected.

Raising that cap let the Windows shard finish (28m45s, inside 45) and uncovered
a real failure the 30-minute cancel had been masking:
`new quick-task branch branches off origin/main (#2916)` died with
`outcome=timed_out exitCode=null`, SIGTERM, at the 15000ms bound.

tests/quick-branching.test.cjs:149 `runStep` runs a `#!/usr/bin/env bash` script
executing MULTIPLE git commands, but was bound to GIT_TIMEOUT_MS (15000) -- the
norm for a SINGLE git plumbing call. tests/helpers/timeouts.cjs already documents
this exact failure and exists to fix it: HOOK_FANOUT_TIMEOUT_MS was created after
PR #3285 recorded "outcome=timed_out exitCode=null at exactly the 15000ms probe
bound while every other lane passed the same commit", and calls that "a bound
sized for the wrong class, not a slow machine". Our failure is that case
verbatim, so both sites move to the class norm rather than to a bigger number.

The same class also failed on `next` itself 21 hours earlier -- run 32608945654,
windows-latest/24 shard 1/3, `plan touching only src/ in a submodule project
keeps worktree isolation ENABLED` -- where tests/worktree-safety.test.cjs:5845
`runGate` fans out to `git config --file .gitmodules` under a hardcoded 30000.
Fixed too, since it is a defect in the tree regardless of which branch surfaced
it.

A survey of the whole tests/ tree found the same class-mismatch at further
bash fan-out sites bound under 60000ms, and the maintainer approved sweeping
them rather than leaving them latent to surface the same way one at a time. 16
fan-out sites across 16 files now use the class norm.

The sweep is class-correctness, not raising numbers until things pass. Sites
were moved ONLY where the bash body demonstrably spawns something (git, node,
npm, a CLI); self-contained shell snippets were left where they are, and are
listed as deliberately unchanged: pure if/printf bodies (copilot-install), pure
array/case builtins (code-review-pipeline-regression:638), a documented
pure-shell gsd_run stub (host-integration), single-process hook calls
(workflow-guard:222/271/302), and a deliberately tight 5000ms fast-check hook
(gsd-write-guard.property). Nothing was lowered. process-seam.test.cjs:513
(literal 300) is untouched on purpose -- it tests timeout BEHAVIOR, so raising
it would destroy what it asserts.

Shared file-level constants were the trap here, and were handled per file rather
than by redefinition: GIT_TIMEOUT_MS has ~15 users in git-base-branch and only 1
is a fan-out; WORKTREE_TIMEOUT_MS has 16 users in worktree.test.cjs and 3 are;
PROBE_TIMEOUT_MS has several in three more files. In each the CALL SITE was
changed and the constant left alone, so no single-plumbing-call site silently
inherited a 60s bound. The one exception is hooks-opt-in.test.cjs, where
HOOK_TIMEOUT_MS has exactly one consumer -- spawnHook, the fan-out itself -- so
redefining it is identical in effect and reads better.

Only two of these sites have actually been observed failing. The rest cite that
shared class and those two run ids rather than inventing evidence of their own.

Co-authored-by: sim <sim@local>
2026-08-23 21:21:21 -04:00
Tom Boucher
69e7afd0c7 chore(#3212): bounded quantifiers over document content — prohibition with teeth — Phase 4 (#3441)
* feat(#3415): ship local/no-unbounded-quantifier, burn down ReDoS class

Phase 4 of epic #3212 (ADR-3212 §5/§7, the final phase). New rule flags
an unbounded */+/{n,} quantifier over a broad character class
([\s\S], dotAll ., or a 1-2-unit negated class like [^\n]/[^)\n] — the
exact #2128-fixed shape) applied to a regex whose match target is
data-flow-traced to readFileSync content.

eslint-rules/lib/readfilesync-trace.cjs extracts the data-flow tracer
shared with no-crlf-fragile-split (Phase 2) rather than a second copy
— no-crlf-fragile-split refactored onto it with zero behavior change,
parity-tested.

Real triage, not 798 mechanical edits: the ADR's census (2026-08-08)
screened every unbounded quantifier in the tree unscoped. Correctly
scoped to readFileSync-derived content (matching Phase 2's own G2/G3
scoping), the rule found 162 real hits across two detection waves — the
second wave (93) surfaced only after a genuine off-by-one bug in this
rule's own first draft was caught while writing its RuleTester tests
and fixed (the bug silently missed every directly-quantified [\s\S]*
with no gap before the quantifier — exactly the class this rule exists
to catch). 3 hits landed in production src/ (commands.cts, milestone.cts,
roadmap.cts) and were each empirically timed against adversarial input
(matching #2128's own measured-not-assumed precedent) — all confirmed
linear-time/benign, left unbounded with a measured-evidence comment
rather than mechanically bounded. The remaining 159 are test-file
fixture parsing (test-author-controlled, fixed-size content, not
adversarial input) — each suppressed with a specific, non-generic
reason. Zero functional behavior changed anywhere in this diff.

tests/no-pending-3212-markers.test.cjs locks the epic's own closing
invariant (ADR §7: "assert zero pending #3212 markers remain") — ground
truth confirmed trivially true today (no phase left any such marker
behind), now regression-locked going forward.

Design: .gsd/phase/chore-3415-prohibition-with-teeth/40-design.md
Test matrix: .gsd/phase/chore-3415-prohibition-with-teeth/50-test-matrix.md

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

* fix(#3415): correct rule category mislabel, add CI test-scope entry

An orthogonal Standards-axis review found eslint-rules/no-unbounded-quantifier.cjs
mistakenly carried meta.docs.category: 'Portability', copied from a sibling
rule without realizing what that implied: docs/contributing/cross-platform-
portability-rules.md governs an ADR-1703 rule family under a hard "zero
escape hatches" contract (tests/portability-rule-disable-ban.test.cjs's
PROTECTED_RULES bans eslint-disable for those rules entirely). This rule is
not part of that family — it's ADR-3212 (ReDoS/CWE-1333), a different epic —
and its eslint-disable-next-line suppressions (159 of them, added earlier
this same phase after empirical benign-verification) are an intentional,
correct design, not a bypass. Corrected to category: 'Best Practices',
matching the actual precedent (no-adhoc-regex-escape.cjs, Phase 1 of the
same epic, which is also correctly outside PROTECTED_RULES), and the rule's
own docstring now states this explicitly so a future reader doesn't have to
re-derive it.

Also registers a new scripts/ci-test-scope.cjs bucket so editing this rule
or the shared eslint-rules/lib/readfilesync-trace.cjs helper re-runs their
own test suites under targeted CI selection — was previously unregistered
and invisible to that fast-path (this PR's own gsd-test checkpoint runs the
full suite regardless, so this only affects future narrowly-scoped PRs).

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

* fix(#3415): bound no-unbounded-quantifier's own scanner (CWE-1333, ironic)

Security review found the rule meant to catch algorithmic-complexity bugs
had one of its own: hasUnboundedBroadQuantifier's negated-class inner
scan walked from each `[^` occurrence to the next `]` (or EOF) with no
bound, while the outer loop only ever advanced by one character — O(n²)
total work on a pattern with many unclosed `[^` runs. Runs unconditionally
inside checkPattern on any `new RegExp('literal string')` argument in any
linted file, before the (cheap) readFileSync data-flow gate — so a single
crafted string literal, no valid regex syntax required, could make
`npm run lint` / CI hang.

Empirically confirmed both the bug and the fix: pre-fix, n=4000/8000/
16000/32000 chars took 30.8/115.6/463.8/1874.3ms (~4x work per 2x n,
quadratic); extrapolated, the 300000-char repro from the finding would
run ~165s. Post-fix (bail the inner scan once units exceeds the rule's
own 1-2-unit scope, rather than continuing to hunt for a closing `]`),
the same 300000-char input runs in 8.7ms via the real rule module,
independently reconfirmed at 18ms via a fresh Linter.verify() call.

New regression row in tests/no-unbounded-quantifier.rule.test.cjs
asserts the RuleTester run on a 50000-char adversarial pattern
completes and returns a defined result — no wall-clock assertion
(CLAUDE.md Clock Seams / local/no-elapsed-assertion).

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

* fix(#3415): triage 3 new sites, re-raise ceiling after upstream batch

next merged 12 more PRs during this PR's review. Two consequences:

- tests/edit-phase.test.cjs (fix #3262, unrelated) added 3 new
  content.match(/<tag>([\s\S]*?)<\/tag>/) reads of this repo's own
  workflow .md content — the same Class A pattern as the ~159 sites
  already triaged elsewhere in this PR. Suppressed with the same
  established reason.
- lint-allow-test-rule-refs' ratchet ceiling needed re-raising again
  (301 -> 303) for the same reason as the two prior bumps: organic
  growth from unrelated, already-reviewed PRs landing concurrently,
  not a defect in this branch's own diff.

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-08-14 10:02:28 -04:00
Tom Boucher
3fac6e629f test(#3145): bound the installer/runtime cluster onto the process seam (#3176)
* test(#3145): bound the installer/runtime cluster onto the process seam

Migrates 156 unbounded sync spawn sites across 47 files. Allowlist 120 to 73.

Timeouts are sized from evidence already in the tree rather than a house
default, because this wave spawns installers rather than git plumbing and an
undersized bound does not catch a hang -- it manufactures CI flake, which is
worse, since a flake gets re-run instead of investigated. install.test.cjs
records a real spawnSync ETIMEDOUT at a 60000ms cap on a loaded bench while
another lane passed the same commit in 12.7s, so full installs are bound at
120000ms against that recorded incident.

Also adds an auditable escape to the guard's timeout ceiling. The 600000ms
cap was set in #3143 from partial evidence, but fragment-single-edit-
propagation carries a documented, load-tested 900000ms bound on a run that
chains a full build plus eight generators -- the guard would have rejected a
correct timeout the moment that file left the allowlist. A value above the
ceiling is now permitted only with an inline allow-spawn-timeout-ceiling
marker carrying a non-empty reason. It raises the ceiling; it never waives
the requirement for a bound, which is asserted directly.

install-shared.cjs keeps its hand-rolled assert rather than routing through
throwIfFailed: its message embeds both streams, and throwIfFailed carries
only a trimmed stderr. The message now also names the outcome, so a bounded
timeout reads as such across its 38 importers instead of as
expected null to equal 0.

* test(#3145): extract class-norm timeouts and correct the build-hooks sizing

A pre-PR review found 52 copies of four class-norm timeout constants across
this wave. These are not per-suite fixture bindings -- they are shared facts
about how long a class of subprocess takes, derived from a recorded bench
incident. That norm already moved once (60000 to 120000 after a real
ETIMEDOUT), and 52 copies would have drifted the next time it moved.

Extracts tests/helpers/timeouts.cjs, where each norm is justified once, and
converts the copies. A site that genuinely differs -- a real tsc compile, or
regen:derived -- keeps its own local constant with its own justification.

Also corrects a misclassification: scripts/build-hooks.js was sized as a
build at 120000 in twelve places and 60000 in another, but it compiles and
bundles nothing. Its own header says no bundling needed; it copies pre-built
files and syntax-checks them with vm. Three different values bounded one
script; now there is one.

* test(#3145): fix red CI — lint self-match and a Windows chunk overrun

Two failures on PR 3176.

lint-allow-test-rule-refs read a RuleTester fixture as a real exemption. The
fixture exists to prove an unrelated marker does NOT suppress the rule, so it
carries that marker's literal text as test data. Split via concatenation, the
same idiom no-unbounded-spawn-allowlist.test.cjs already uses for its own
self-match problem. The explanatory comment needed the same treatment.

The Windows shard 3/3 chunk was killed at its 600000ms budget. Output stopped
seven minutes before the kill, so this was an overrun rather than a slow
chunk: regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface runs
regen:derived bounded at 900000ms, which is larger than the whole chunk
budget, so the chunk killer always fires first and it can never complete
there. Both the test and that bound predate this change; modifying the file
pulled it into the Windows targeted set and exposed it. Skipped on Windows
with the reason recorded; the Linux lanes cover it. The 900000 bound and its
ceiling marker are unchanged -- they are correct.

* test(#3145): refresh the stale test-timings cost table

The Windows shard was killed at its 600000ms per-chunk budget. run-tests.cjs
packs chunks by measured duration from tests/test-timings.json, and an
unknown file falls back to the table's median weight -- advisory by design,
but it silently underweights exactly the files that matter.

Four of the failing chunk's 22 files were absent from the table, including
the two heaviest: fragment-single-edit-propagation.install.test.cjs at 230s
(it runs regen:derived) and agent-fragments-emission.install.test.cjs at 79s.
Both were weighted as average, so the chunk's total weight read 53.68 against
a budget of 60 and the packer produced a single chunk.

Regenerated from a passing full-suite run, per the remedy the script itself
documents. 700 to 770 entries, 70 added, 0 dropped -- verified, since
gen-test-timings.cjs replaces the table wholesale rather than merging.

Proven against the real packer: the same 22 files now weigh 103.91 and split
into two chunks. No logic, budget, or timeout was changed; raising a budget
to make a red gate pass is not a fix.

---------

Co-authored-by: sim <sim@local>
2026-08-07 15:18:18 -04:00
Tom Boucher
1d208e5af6 test(#3144): bound the git/worktree cluster onto the process seam (#3152)
* test(#3144): bound the git/worktree cluster onto the process seam

Migrates 180 unbounded sync spawn sites across 19 files. Every previously
unbounded call now carries an explicit timeout with a comment giving the
number and why.

The migration is not a callee swap. execSync and execFileSync throw on a
non-zero exit and the seam never does, so each site was classified first:
sites that rely on the throw route to gitOrThrow, and sites that already read
.status to detect an EXPECTED non-zero -- an intended cherry-pick conflict, a
rev-parse outside a repo driving a skip -- route to the never-throwing runGit
instead, which would otherwise throw on exactly the exit being probed for.

Two same-named git() helpers in worktree-cleanup.test.cjs have different
return contracts, one trimmed and one raw; both are preserved rather than
unified.

Collapses five hand-rolled throw wrappers onto one throwIfFailed in
git-fixture.cjs, which gitOrThrow now also uses so the shape cannot drift.

Allowlist drops 139 to 120; BASELINE lowered to match.

* test(#3144): fix pre-PR review findings

Documents throwIfFailed in the CONTEXT.md glossary and CONTRIBUTING.md --
it became the shared throw mechanism without either doc naming it.

Routes the sixth and seventh hand-rolled copies of the throw shape through
throwIfFailed (worktree-baseref-install, worktree-safety-reap); the first
consolidation missed both.

Converts ci-rebase-check's 8 fixture-setup calls from unchecked runGit to
gitOrThrow so a failed setup step aborts where it fails rather than
surfacing later as a confusing failure against the wrong subject.

Adds 12 direct unit tests for throwIfFailed, which until now was only
exercised transitively.

Splits verify.test.cjs's non-git grep/sed bound off GIT_TIMEOUT_MS.

---------

Co-authored-by: sim <sim@local>
2026-08-07 10:58:34 -04:00
Tom Boucher
10da377794 fix(#3021): recognize worktree-wf_* branch namespace in all guards (#3109)
* fix(#3021): recognize worktree-wf_* branch namespace in all guards

The Claude-orchestration Workflow backend (#1143) creates per-plan
worktrees on branches named worktree-wf_<runid>-<n>. Four independent
copies of the agent branch allow-list regex (^(worktree-)?agent-...) never
learned this namespace:
- hooks/gsd-worktree-path-guard.js:176 — FAILED OPEN (process.exit(0)),
  silently disabling path containment for exactly the concurrent dispatch
  mode where cross-worktree writes are most likely
- src/worktree-safety.cts:21 — silently dropped cleanup-wave manifest
  entries
- agents/gsd-executor.md:503 — FATAL halt on branch check
- gsd-core/references/worktree-branch-check.md:33 — same FATAL halt

Extended all four to ^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$.
The path guard now correctly blocks cross-worktree writes for Workflow-
backend branches instead of no-op'ing.

* chore(#3021): backfill changeset PR number 3109

---------

Co-authored-by: sim <sim@local>
2026-08-06 04:26:59 -04:00
Tom Boucher
5fd5c81042 test(#3055): add the process seam so a subprocess timeout is expressible as data (#3066)
* test(#3055): add the process seam and route runGsdTools through it

Adds tests/helpers/process-seam.cjs — runNode/runGit/runHook over spawnSync,
each returning a typed discriminated union
{ outcome, exitCode, stdout, stderr, timedOut, signal, killed, code }.
Every call is timeout-bounded; there is no unbounded path.

runGsdTools becomes an adapter over the seam. Its legacy
{ success, output, error, exitCode } shape and retry-once-on-kill behaviour
are preserved byte-identically, so none of its 136 caller files change.

Outcome discrimination was corrected against probed runtime behaviour rather
than assumption: a timeout and a maxBuffer overflow are identical on both
status (null) and signal (SIGTERM), and differ only by code (ETIMEDOUT vs
ENOBUFS). Overflow is therefore classified before timeout. This fixes a live
defect — the previous isKilled() treated an overflow as a kill, retried it for
a second full 60s run, and then reported "host OOM or scheduler contention"
for a child that had merely printed too much.

Also widens the ESLint tests glob from tests/**/*.test.cjs to tests/**/*.cjs,
which brought 31 previously unlinted shared helpers under the same rules their
sibling test files already obey, and fixes the 5 violations that surfaced —
including a bare npm invocation without shell:true in
tests/helpers/emitted-runtime.cjs (DEFECT.WINDOWS-TEST-PORTABILITY), now
routed through the existing portable runNpm helper.

Refs #3051

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

* test(#3055): migrate every local spawn wrapper onto the process seam

Replaces the spawn body of all 25 local runHook/runGuard/runGate definitions
with a call to tests/helpers/process-seam.cjs. Each wrapper keeps its name,
parameter list, return shape and post-processing (JSON parse, ANSI strip, env
sanitising, field extraction) — only the spawn mechanism changes, so no test
assertion moves.

The 4 bash-driven wrappers use the seam's explicit `interpreter` option rather
than a fourth primitive; it is explicit rather than inferred from the file
extension, because guessing an interpreter from a path fails silently when a
script's name does not match its shebang.

Seven wrappers were previously unbounded and now carry an explicit timeout
sized to what each actually runs, not the seam default. Two of those seven
(gsd-write-guard, lint-docs-command-form) were absent from the issue's
inventory entirely and were found by scanning after the migration.

Adds the CONTEXT.md `### Process seam` glossary entry and a CONTRIBUTING.md
reference section covering the three primitives, the discriminated union, and
the two rules the seam enforces.

Scope disclosure recorded in the phase design notes: the issue scoped three
identifier names. A scan for local helpers that spawn AND return the spawn
result finds 113 across 82 names, 71 of them unbounded, plus 122 unbounded
direct git call sites. This change bounds 25 of those. The remaining surface
is the same defect class and is NOT closed by this PR.

Refs #3051

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

* fix(#3055): classify an externally-killed child as KILLED, not EXITED

Blocker found in this branch's own diff, independently confirmed by an
isolated reviewer.

A child killed by an external signal — a genuine bench OOM kill — makes
spawnSync return { status: null, signal: 'SIGKILL' } with NO .error field.
The seam's "no error implies EXITED" rule therefore classified it as a clean
exit, and runGsdTools returned { success: false, exitCode: 1 } without
retrying. That silently defeated the #969 kill-discrimination for precisely
the case it was built for: the old isKilled() fired on `signal != null`,
retried once, then threw a labelled resource-starvation error. A real OOM
would have been reported as an ordinary assertion failure.

Adds a fifth outcome, KILLED, for "no error but a signal is set", and makes
the adapter retry on TIMED_OUT or KILLED — reproducing the old
`killed || signal != null || code === 'ETIMEDOUT'` condition exactly.
SPAWN_FAILED still does not retry (matching the old behaviour, where signal
was null). BUFFER_OVERFLOW still does not retry, which remains a deliberate
divergence: the old code retried it because signal was SIGTERM, burning a
second 60s run on a child that had merely printed too much.

All five outcomes verified against the live runtime rather than assumed:
SIGKILL -> killed, exit 0/7 -> exited, timeout -> timed_out (ETIMEDOUT),
>1MB stdout -> buffer_overflow (ENOBUFS).

Refs #3051

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

* fix(#3055): address standards-review findings on this branch

Three findings from the standards axis of the review, all in this branch's
own diff.

The CONTEXT.md glossary entry this branch introduced was already stale on the
branch's own last commit: it enumerated a 4-member OUTCOME while the code had
5, because the KILLED fix did not update it. That is precisely the drift the
"module changes update Domain-terms" gate exists to catch, so the entry now
lists all five and explains KILLED.

api-coverage-gate-e2e compared an outcome against the raw string 'exited'
rather than OUTCOME.EXITED, the only such outlier; the enum is now imported
and used. A sweep for the other four outcome literals found no further
comparison sites.

Three call sites hand the literal bash flag '-c' to the seam's first
parameter, which the JSDoc described as an absolute script path. Rather than
add a fourth primitive, the contract is corrected to match reality: the
parameter is renamed `target` and documented as the first argv element handed
to the interpreter — normally a script path, but for an interpreter invoked
with an inline program it may be that interpreter's own flag. No behaviour
change.

Refs #3051

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

* test(#3055): assert the cross-platform timeout contract, not the macOS one

The remote runner failed on both Linux lanes (node 22 and node 24, identical)
while the same tests passed locally on macOS. Two assertions encoded a
platform-specific behaviour as a cross-platform guarantee.

When spawnSync times out, macOS preserves the child's partial stdout/stderr;
Linux discards it and returns empty strings. Verified on node v26.5.1 both
ways. The seam passes through whatever spawnSync hands it and cannot
manufacture output that was discarded, so the production code was correct —
the tests were wrong.

Both tests now assert the guarantee the seam actually makes on every
platform: outcome TIMED_OUT, timedOut true, and stdout/stderr always being
strings rather than undefined or a Buffer. The partial-content assertions are
retained behind an explicit process.platform === 'darwin' guard so the macOS
coverage is not lost, and the first test is renamed to say what it now
guarantees.

This is the failure mode the remote matrix exists to catch: local macOS
verification would have shipped it.

Refs #3051

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

* fix(#3055): classify a failed spawn as SPAWN_FAILED, not a timeout

Windows CI caught two defects the Linux matrix could not.

tests/context-predicates-query.test.cjs passes a 32K-char argv value. On
Windows that exceeds the argv limit and spawnSync fails with code
ENAMETOOLONG, signal null, status null. The seam's fallback rule — "otherwise,
status === null implies TIMED_OUT" — swallowed it, so the adapter retried a
spawn that can never succeed and then threw the resource-starvation error. The
old isKilled() returned false for that shape and returned an ordinary failure
result.

TIMED_OUT is now identified positively: code === 'ETIMEDOUT' OR signal is set.
Anything else carrying an error is SPAWN_FAILED, which covers ENAMETOOLONG,
E2BIG, EACCES and ENOENT alike. The signal clause is what keeps a platform
whose timeout errno differs classified correctly, so the greedy catch-all is no
longer needed.

The second defect is a contract regression I introduced and had claimed
otherwise. That same test asserts `typeof r.exitCode === 'number'`, and
toLegacyShape was returning null for BUFFER_OVERFLOW and SPAWN_FAILED, so the
assertion failed on type. The old code returned `err.status ?? 1` on every
non-retried failure path. The adapter now returns 1 again for both, and the
comment claiming "never coerced to exitCode:1, unlike the pre-seam helper" is
retracted: the seam keeps the richer truth (exitCode null plus a distinct
outcome), the legacy adapter keeps the old numeric contract its callers
actually depend on.

Verified on this host: a 4MB argv yields E2BIG -> SPAWN_FAILED; ENOENT ->
SPAWN_FAILED; timeout -> TIMED_OUT; >1MB stdout -> BUFFER_OVERFLOW; SIGKILL ->
KILLED; clean exit -> EXITED.

Refs #3051

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:06:39 -04:00
Tom Boucher
ff4a57b78c chore(#1671): migrate the remaining 13 LARGE/XL workflows to the fragment model — Phase 6.3 (#3030)
* chore(#2994): fragmentize progress.md forensic audit onto the fragment model

Extract the --forensic-gated forensic_audit step to
workflows/progress/steps/forensic-audit.md behind a section marker, and
repair progress.md's init line to forward --forensic so the atom is
actually true in production rather than only under direct CLI tests.

progress.md shrinks 32630 -> 27207 bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize the four manifest-wired workflows

new-project, quick, new-milestone and progress each already had a
dedicated cmdInit* entry point but zero marked sections. Extract nine
gated bodies to workflows/<wf>/steps/ behind section markers and repair
each init line to forward its flags.

Fold --full into the discuss/research/validate facts inside cmdInitQuick
so the when= grammar never sees an OR, per the chunked-mode precedent.

Fixes found while working, per the no-defer rule:
- cmdInitProgress passed no phase info to buildSectionManifestField, so
  state:phase-mvp-mode was permanently false — an atom in the vocabulary
  whose fact could never be computed.
- the quick init router folded flag tokens into the free-text
  description, which the new forwarding would have corrupted.
- a #2508 dispatch note was nested inside quick.md's Agent(prompt=)
  fence, leaking orchestrator guidance into the subagent prompt.
- progress.md had a 3-vs-4 backtick outer-fence imbalance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize verify-work.md and admit state:ui-phase-active

Wire cmdInitVerifyWork to buildSectionManifestField — it was a dedicated
entry point that never emitted a manifest — and mark two sections.

state:ui-phase-active folds (plan:pre hooks include an active ui step) OR
(the phase dir holds a *-UI-SPEC.md) into one boolean in init.cts, so the
grammar still sees a single operator-free atom. The inner Playwright-MCP
check stays as prose inside the fragment: it is live session state and no
init seam can precompute it.

The MVP false-branch note is a real fallback, not redundant prose, so it
sits outside the marker — gating it away would delete the text needed
precisely when MVP mode is off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): follow moved workflow content in drift guards

Retarget every guard that asserted on content this branch moved into
workflows/<wf>/steps/, mirroring 815b3d897. Each retargeted assertion was
verified to still fail when its step file is blanked, so none was
weakened into vacuity.

Three assertions in verify-mvp-uat were genuinely red. Three more were
worse than red — passing for the wrong reason:
- quick-commit-boundary and worktree-cleanup anchored on indexOf('Step
  5.6'), which matched a later cross-reference and sliced 16069 chars
  that coincidentally held the asserted substrings. Replaced with an
  expandWorkflowSections helper that splices step content back in place.
- phase6-review-capabilities lost its end boundary and widened to EOF.
- playwright-ui-verify matched 'UI' in an unrelated bullet and 'fall
  back' in a subagent-dispatch line after the real content moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize code-review and complete-milestone, admit three atoms

Add dedicated cmdInitCodeReview and cmdInitCompleteMilestone entry points
alongside the shared generic ones rather than modifying them — init.phase-op
and init.manager carry a CRITICAL blast radius (179 dependents, 24
processes) and stay byte-identical for their other callers.

Admit flag:--fix, state:fallow-enabled and state:git-create-tag, each with
a consuming section and a fact its own entry point computes.

Both sections had the resolver-in-body hazard: the fallow config-gate and
the git.create_tag check each sat inside the very block being gated, so
gating would have disabled the resolver that decides the gate. Both are
hoisted into init and the bodies now consume the resolved fact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): retarget code-review and milestone drift guards, fix two red tests

Retarget guards that asserted on content moved into steps/, proving
non-vacuity by blanking each step file and confirming failure.

Also fixes two genuinely red tests found while working, per the no-defer
rule:
- workflow-fragments' frozen-vocabulary lock was missing
  state:ui-phase-active, so commit 7ef7f8336 shipped red. Lint and build
  both passed over it, which is why neither is sufficient verification.
- code-review's quick.md capability-hook assertion carried a stale
  delimiter after the 18ff35d20 extraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize autonomous.md and admit state:plan-strategy-converge

Five sections share one atom, the pattern plan-phase already uses for
flag:--research-phase. The atom folds --converge OR --cross-ai into a
single boolean in cmdInitAutonomous so the grammar stays operator-free.

cmdInitAutonomous is additive; init.milestone-op, init.manager and
init.phase-op are untouched and still consumed. The $PLAN_STRATEGY bash
resolver is deliberately retained — ungated local-planning bullets still
read it, so the init-side fact supplements it rather than replacing it.

converge-fail-fast required splitting one bash fence so the always-run
CONVERGENCE_ARGS construction stays outside the marker. All three
flag-absent fallbacks were left outside their markers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize review and discuss-phase-assumptions

Admit state:reviewer-instances-configured (two peripheral notes share it;
the core reviewer-lane dispatch stays unmarked — it is the workflow's
primary always-evaluated logic, not an optional branch) and
state:auto-advance-active, which folds --auto OR two config keys into one
boolean so the grammar stays operator-free.

discuss-phase-assumptions was the highest-risk edit in this PR. Its
auto_advance step is a full if/elif/else; gating it whole would have
deleted the flag-absent fallback needed exactly when --auto is off. Split
verified exact: resolvers 636-651 and the 'End here' fallback 668-669 both
stay outside the marker; only 653-667 is gated.

Adds emitted-drift acks for the two files that grew — review.md (+55 B)
and autonomous.md (+737 B from 80799211c, which had none and would have
red-gated the push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): fragmentize docs-update, update, transition and new-milestone Part A

Completes the 13-workflow rollout. Three of these had no init call at all
and gained a dedicated entry point plus their first gsd_run query line.

Admits state:is-monorepo and adds state:next-channel, state:workstream-active
and state:flat-mode. Vocabulary 26 -> 30 atoms.

Part A of new-milestone applies when NO workstream is active — the negation
of state:workstream-active. Rather than teach the grammar negation, which is
the Greenspun drift the frozen list exists to prevent, it gets a separate
positively-phrased atom whose fact is the inverse. Part B, which always runs,
stays outside the marker.

flag:--verify-only is deliberately NOT admitted: docs-update has no
contiguous purely-additive region for it, and an atom without a consuming
section is dead vocabulary. Evidence recorded in the slice report.

update.md reuses its existing resolved $GSD_TOOLS rather than prepending the
canonical preamble, which would have clobbered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): stop automated-ui-verification re-resolving its own gate, retire dead vocabulary

Two defects the new tests caught.

The automated-ui-verification step re-ran gsd_run loop render-hooks and
recomputed UI_PHASE_ACTIVE inside a body that is only read when that fact
is already true — the circular self-disabling pattern this design forbids,
introduced by 3c654b168. cmdInitVerifyWork now exposes ui_phase_active and
the step consumes it. Its launcher preamble goes too: no gsd_run remains.
The Playwright-MCP check stays as prose — that is live session state.

Dead vocabulary predating this PR: flag:--full and state:needs-codebase-map
were admitted with a gate-1 claim that never materialized. flag:--full is
removed, redundant once quick folds it into discuss/research/validate.
state:needs-codebase-map gets the real consumer it always lacked, gating
new-project's codebase-map offer. Vocabulary 30 -> 29, and no atom is now
without a consuming section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): add the atom-admission, inversion and resolver-hoist gates

The two existing parity guards prove vocabulary/predicate symmetry but
never that a fact is computed — an atom no cmdInit* assembles evaluates
false forever. These close that hole:

- per-atom satisfiability for all 29 atoms, plus an anti-vacuity assertion
  so the loop cannot silently cover zero atoms
- dead-vocabulary check against the shipped manifest
- inversion guard: the flag-absent fallbacks in discuss-phase-assumptions
  and verify-work must stay outside their markers
- data-driven resolver-hoist guard over the shipped manifest, so a future
  extraction cannot reintroduce the circular class
- compound-fold coverage (--full, --cross-ai, --rc, config-only --auto)
- null-vs-[] degraded/computed distinction, and flag value shapes

Also repairs the frozen-vocabulary lock, which was stale and red for the
seven atoms earlier commits on this branch shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#2994): add changeset for the fragment-model rollout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(#2994): cite the issue on the two new allow-test-rule exemptions

ADR-456 requires an issue ref on the same line as the annotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(#2994): correct the atom-count claims after retiring flag:--full

The vocabulary doc comments still said 30 entries; it is 29 since
flag:--full was removed as dead vocabulary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): dedupe the phase-fallback block and harden --ws parsing

Review findings.

MAJOR: the three new init entry points each pasted a verbatim copy of the
guardedFindPhase/guardedGetRoadmapPhase fallback, taking the repo from four
copies to seven — DEFECT.GENERATIVE-FIX. Extracted applyRoadmapFallback and
folded six of the seven; each call site keeps its own field-set via a
closure. Duplication removed rather than papered over with a parity test.
cmdInitPhaseOp stays out: its fallback omits has_reviews, so it is not a
byte-identical copy, and it is CRITICAL-radius.

LOW, pre-existing: GSD_WS captured [^[:space:]]+ and expands unquoted, so a
workstream name holding glob metacharacters would expand against the
filesystem. Narrowed to [A-Za-z0-9._-]+. The unquoted expansion is kept —
it must word-split into two args and vanish when empty.

Also restores the vocabulary ordering convention, and fixes a masked test
bug the mandated run surfaced: the flag-forwarding guard checked only the
first init line per workflow, but new-milestone has two, so a real failure
was reporting exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): drop the stale new-milestone emitted-drift ack

new-milestone.md was acked for a +406 B growth measured against an
intermediate commit. Net against origin/next it SHRANK by 8 bytes, so
nothing needed the ack and it explained nothing — which the differential
attribution check reports as a stale acknowledgment, not a pass.

update.md's entry stays: it genuinely grew +703 B.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): resolve the 15 failures from the full matrix run

All 15 were real and identical on both lanes.

REAL REGRESSION: autonomous.md hit 41479 chars against the #2196 guard's
40960 cap — a CHARS cap distinct from the LARGE tier byte cap, which the
five section stubs pushed it over. Extracted the 3a.5 UI Design Contract
body to references/; now 39968 chars, and the file nets -795 B vs base, so
its growth ack is deleted rather than left stale.

REAL DEFECT: docs referenced /gsd-transition, which is not a live
registered command. Reworded.

STALE FIXTURE: the emission byte-identity test hardcoded two marked
workflows; this branch legitimately marks fifteen. Fixture corrected — the
source was right.

The rest were drift guards over the eight workflows the earlier sweep did
not cover, retargeted at where the content now lives with non-vacuity
proven by blanking each step file and confirming failure. The GSD_WS
forwarding guard was checked as a possible real break and is not one: the
charclass narrowing is intact and forwarding works end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): drop the ack for a newly-added reference file

A new file's emitted ripple is attributable to the diff that adds it, so
the acknowledgment explained nothing and the differential check reports it
as stale. Removing the last entry removes the fragment — an empty one
signals nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(#2994): retarget the UI-contract guards and clear two transitive advisories

The §3a.5 extraction that brought autonomous.md under the #2196 char cap
moved its body to references/autonomous-ui-design-contract.md, so ten
guards in autonomous-ui-steps and check-ui-safety-gate were asserting it
against the host. Retargeted via a combined read, each proven non-vacuous
by blanking the reference file and confirming failure.

This class had already bitten twice on this branch because each sweep was
scoped to the workflows touched at that moment, so this one was
exhaustive: ~70 test files across all 13 workflows, zero further broken or
vacuous assertions found.

Also clears two high transitive advisories the matrix flagged on one lane
— fast-uri GHSA-7p8r-x3mc-p8w7 and three ip-address SSRF/trust-boundary
issues. Both pre-date this branch: package-lock.json was untouched until
now, so the production tree was byte-identical to the base. Lockfile-only,
package.json unchanged, verified against a real npm ci install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(#2994): backfill changeset pr number to 3030

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:59:58 -04:00
Tom Boucher
067a4d1c6c fix(#2650): bound and auto-recover plan-phase planner/plan-checker stalls (#3015)
* test(#2650): add failing-first regression for plan-phase stall detection

Regression test for gsd_stall_should_recover / gsd_stall_watch and the
planner.stall_* config keys, none of which exist yet — proves RED before
the fix lands in the next commit.

* fix(#2650): bound and auto-recover plan-phase planner/plan-checker stalls

Mirrors the already-shipped executor.stall_* pattern (execute-phase.md, bug
#3212) but with a dispatch change the executor's prose-only surveillance
lacks: the standard planner spawn, chunked-outline planner spawn,
chunked-per-plan planner spawn, plan-checker spawn, and revision-loop
planner respawn now dispatch with run_in_background=true and are followed
by a real, bounded bash poll (gsd_stall_watch) that returns control to the
orchestrator on its own schedule instead of waiting indefinitely on a
subagent that may never return. On stall, the existing accept-plans/retry/
stop recovery menu (9a/11a) is auto-surfaced instead of requiring a manual
interrupt.

New config keys planner.stall_detect_interval_minutes (default 5) /
planner.stall_threshold_minutes (default 10) mirror executor.stall_*.

The helper functions (gsd_stall_should_recover, gsd_stall_watch) live in a
new lazily-loaded gsd-core/workflows/plan-phase/steps/stall-detection-
helpers.md rather than inline, and per-site prose is kept minimal, because
plan-phase.md is frozen under the ADR-857 Phase 6 PRE_PHASE6 gate
(tests/phase6-capstone-conformance.test.cjs) with ~36 bytes of headroom at
baseline; the net effect is plan-phase.md.md ships slightly SMALLER than
before (the old unconditional-wait ORCHESTRATOR RULE sentences are gone at
the five touched sites, superseded by the bounded watcher).

Also fixes a stale doc comment in tests/workflow-size-budget.test.cjs that
still described the per-file workflow-size-baseline.json guard removed by
#2724 (ADR-2719 Phase 4) as if it were still the enforcement mechanism —
discovered while verifying this fix's own byte budget.

Researcher and pattern-mapper spawns are untouched (out of scope per the
issue's Agent Brief).

* fix(#2650): make gsd_stall_watch single-cycle; harden numeric config inputs

Two review findings addressed on top of the prior commit:

1. gsd_stall_watch previously looped internally for the full
   threshold+interval duration inside ONE Bash tool call (up to 15 min at
   defaults) — a single call blocking that long risks the host tool's own
   timeout killing it before it ever prints a result, silently defeating the
   fix. Redesigned to a single sleep-and-check cycle per call, taking an
   explicit dispatch_ts so the orchestrator prose can repeat the (short,
   default 5 min) call until it resolves; the outer threshold is now
   enforced by dispatch_ts accumulating across calls, not by one call's
   duration. Documented the resulting trade-off (up to one interval of
   added latency on the success path) in the changeset and reference doc.

2. PLANNER_STALL_INTERVAL_MINUTES/THRESHOLD_MINUTES are config-controlled
   values that flow into bash arithmetic ($(( ))). A review flagged this as
   command injection; empirically verified against both macOS bash 3.2.57
   and Docker bash:5 that this is NOT actually exploitable (bash hard-errors
   on a `$(cmd)`-shaped arithmetic operand rather than invoking it) — but an
   unvalidated malformed value WOULD abort the stall-watcher itself with
   that bash error, silently defeating the exact hang-recovery this issue
   ships. Added integer validation with safe-default fallback, both at the
   config-resolution point and defensively inside gsd_stall_should_recover.

Also adds the previously-missing integration coverage for gsd_stall_watch's
real execution (grep/find/date plumbing), not just the pure classifier.

* fix(#2650): correct AC2 self-test — helpers doc may name teams-status in prose

The AC2 regression test asserted the stall-detection-helpers.md step file
never contains the substring "teams-status" at all, but the file's own
prose explicitly documents its independence from that guard (containing
the word by design). Narrowed the assertion to what actually matters: no
second `query teams-status` call site and no gating on it, not a blanket
absence of the word.

* test(#2650): regenerate golden install-tree fixtures for the new step file

gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md is an
emitted file (installed for every runtime), so adding it changes the
install tree even though it is invisible to docs/INVENTORY.md and
docs/INVENTORY-MANIFEST.json (both explicitly scope to non-recursive
gsd-core/workflows/*.md — verified against the execute-phase #2930 and
pre-existing plan-phase step-file precedent, which are equally absent from
both inventory artifacts). The golden install tree snapshots the sorted
list of emitted relative paths per runtime, so a file invisible to the
inventory is still visible here. Regenerated via `npm run gen:install-tree`
— one line added per runtime fixture (19 files), no other drift.

* fix(#2650): restore 7 ORCHESTRATOR RULE labels; sync runtime-launcher preamble

Two more consequences of extracting helper bodies out of plan-phase.md,
both caught by verification (0017e1a78, 9 unique failures):

1. tests/plan-phase-drift-guard.test.cjs (#913) requires at least 7
   "ORCHESTRATOR RULE — ALL RUNTIMES" labels in plan-phase.md itself, one
   per agent spawn site. Moving the full explanatory blocks to
   plan-phase/steps/stall-detection-helpers.md carried 5 of the 7 labels
   out with them (only the untouched researcher/pattern-mapper sites kept
   theirs). Restored a short label at each of the 5 stall-watch sites,
   trimmed a few more redundant words ("Per 7.99, " — already established
   by the adjacent step-7.99 pointer) to stay under the frozen
   PRE_PHASE6 cap (94497 bytes, 21 bytes headroom).

2. tests/runtime-launcher-parity.test.cjs (#373) requires exactly one
   canonical gsd_run preamble, byte-equal to
   gsd-core/workflows/_runtime-launcher.snippet.sh, before the first
   gsd_run call in any workflow .md that calls it (recursive scan under
   gsd-core/workflows/, unlike the non-recursive inventory/step-tag-balance
   checks). The new step file's config-get calls use gsd_run without one.
   Fixed via `node scripts/sync-runtime-launcher.cjs`, verified: exactly 1
   preamble occurrence, before the first call, including the .claude/ and
   .codex/ home fallback arms.

Also verified (no fix needed, evidence recorded): the generic
`gsd-core-verbatim` identity rule in tests/helpers/emitted-provenance.cjs
(roots: ['gsd-core'], pattern matching workflows/.+) self-attributes any
new gsd-core/workflows/** path to itself, so the new step file needs no
drift-ack entry — consistent with plan-phase.md's own net shrinkage
requiring none either.

* test(#2650): acknowledge plan-phase.md's +14 byte drift

Restoring the 5 ORCHESTRATOR RULE — ALL RUNTIMES labels (#913) flipped
plan-phase.md from -142 bytes (post-extraction) to +14 bytes net growth
against baseline (94483 -> 94497), which the differential attribution
size ratchet (tests/emitted-attribution.test.cjs) correctly flags as
unacknowledged growth. Added tests/emitted-drift-acks/2650-plan-phase-
stall-detection.json, keyed on the bare filename plan-phase.md per the
existing fragment schema (see tests/emitted-drift-acks/2649-diagnose-
execute-plan-base-check.json), explaining the growth as exactly the 5
restored labels — still verified under the PRE_PHASE6 cap (94497 < 94519)
and satisfying #913's 7-label requirement.

* fix(#2650): bind {outputFile} from the real Agent() return — was dead code

Independent review blocker: PLANNER_OUTPUT_FILE/CHECKER_OUTPUT_FILE were
read by every gsd_stall_watch call but never assigned anywhere in the
diff. With the variable permanently empty, `[ -f "$output_file" ]` was
always false, marker_found could never become true, and marker_received
was unreachable — the marker-based detection path was permanently dead.

Worse for the plan-checker spawn specifically: a checker that PASSES
touches no *-PLAN.md files, so it had no working completion signal at
all without the marker path. A healthy plan-checker finishing cleanly in
two minutes would be declared stalled once planner.stall_threshold_minutes
elapsed and the recovery menu would fire on an already-succeeded agent —
worse than the original unbounded hang.

Fixed by replacing the dead bash variable with the `{outputFile}`
orchestrator-substitution token, the same convention docs-update.md:471
already uses for a real run_in_background=true Agent() return ("Read
tool: file_path: `{outputFile from README agent result}`"). This is a
net BYTE SAVING at each site (`"{outputFile}"` is shorter than
`"$PLANNER_OUTPUT_FILE"`), which funded moving the full binding
explanation — including why plan-checker's *-PLAN.md glob alone is not
a working completion signal — into the lazily-loaded reference file to
stay under the frozen PRE_PHASE6 cap (94496 bytes, 22 headroom; net +13
over baseline, acknowledged in tests/emitted-drift-acks/2650-plan-phase-
stall-detection.json).

Added a regression test asserting plan-phase.md itself binds {outputFile}
at all 5 spawn sites and contains no dangling $PLANNER_OUTPUT_FILE /
$CHECKER_OUTPUT_FILE reference — the previous test suite only exercised
gsd_stall_watch's behavior when handed a valid argument, which is why
the dead production wiring survived two rounds of review. Also fixed
tests/fix-2650-plan-phase-stall-detection.test.cjs:170-195's raw
try/finally to use t.after(), per CONTRIBUTING's test-cleanup convention.

* chore(#2650): backfill changeset PR number to 3015

* fix: normalize CRLF at the read boundary in all .md-bash-extraction tests

Maintainer-authorized scope expansion, folded into this PR rather than
deferred: the Windows CI lane on this PR's own tests/fix-2650-plan-phase-
stall-detection.test.cjs exposed DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE
(CONTEXT.md; recurring since #1700) as a repo-wide latent class, not a
one-off. Ten test files parse a fenced ```bash block out of a workflow
.md file and execute it via spawnSync/execFileSync; a Windows checkout
can yield CRLF line endings despite .gitattributes eol=lf, and bash then
treats the trailing \r on every extracted line as part of the token —
"unexpected EOF while looking for matching `"'" or a bare syntax error,
partway through the script.

Added tests/helpers.cjs:readFileNormalized() — strips \r\n -> \n at the
read boundary, before any fence-slicing or regex runs, so every
downstream operation is correct by construction. Migrated all ten call
sites to it:

Previously broken (fs.readFileSync with no normalization anywhere
between read and spawn):
- tests/worktree-cleanup.test.cjs (extractCwdGuardBash) — also fixes a
  misleading comment claiming the fence regex alone was "CRLF-safe"; it
  protected only the fence delimiters, never the captured body.
- tests/new-milestone-clear-phases.test.cjs (extractFenceBetween,
  extractFenceContaining)
- tests/code-review-pipeline-regression.test.cjs (extractPostProcessingScript)
- tests/drift-detection.test.cjs (readGate/bashBlock, plus the snippet-file
  comparison read in the same test)
- tests/graphify-visualization.test.cjs (extractStep3Block)
- tests/pause-work-improvements.test.cjs (extractCheckBlock)
- tests/plan-review-convergence.test.cjs (extractReviewerFlagsParseBlock
  and the inline post-config-gate resolution-block slices)

Already correct (split(/\r?\n/) then join('\n')), migrated to the shared
helper for consistency rather than a fourth/fifth/sixth copy of the same
fix:
- tests/git-base-branch.test.cjs (extractHandleBranchingBash)
- tests/quick-branching.test.cjs (extractStep25Bash)
- tests/runtime-launcher-parity.test.cjs (extractResolverSnippet)

Verified against a simulated Windows CRLF checkout (not assumed): for
both the worktree-cleanup.test.cjs and new-milestone-clear-phases.test.cjs
extraction shapes, confirmed the pre-fix code produces a real bash syntax
error on CRLF input and the post-fix code does not.

One eslint follow-up: local/no-crlf-fragile-split statically flags any
bare `\n` inside a markdown-fence-shaped regex, regardless of whether the
receiver was already normalized — it cannot see the readFileNormalized()
data-flow. Kept `\r?\n` in extractCwdGuardBash's fence regex (redundant
but harmless on pre-normalized input) rather than fight the rule.

Scope note: this diff is broader than issue #2650's own change (plan-
phase.md stall detection) because the Windows lane surfaced a genuine
repo-wide defect class while verifying that fix, and the maintainer
authorized fixing it here rather than filing it separately and shipping
a known-broken pattern.

Runtime impact: none — this is a test-harness-only defect. The live
orchestrator (Claude Code or another runtime) does not do a byte-exact
extract-and-pipe of .md content into a shell the way these tests do; it
reads the instructions and generates its own bash invocation text, which
does not reproduce a raw CRLF pass-through the same way.

Not touched: tests/plan-review-convergence.test.cjs's separate, tracked
spawnSync ETIMEDOUT flake under bench load (#3005, reproduced on
unmodified next) — unrelated load-sensitivity, not a CRLF symptom.

* fix(#2650): remove stale drift-ack fragment — plan-phase.md is self-explaining

tests/emitted-drift-acks/2650-plan-phase-stall-detection.json acknowledged
plan-phase.md's own emitted-path hash move, but plan-phase.md is directly
edited in this diff. Per the emitted-attribution law (ADR-2719,
tests/emitted-attribution.test.cjs), a workflow's emitted key equals its
own source path (gsd-core-verbatim identity rule), so a direct edit to the
source is self-explaining and auto-attributed — no ack was ever needed.

Verified via the pre-merge lint (scripts/lint-emitted-drift-ack.cjs, run
through npm run lint:ci with a fully cleared eslint cache): it passes clean
with the fragment removed, confirming no contradiction between the lint and
the runtime attribution gate — this was simply an unnecessary fragment.

* fix(#2650): restore plan-phase.md drift-ack — size ratchet demands it against next

tests/emitted-drift-acks/2650-plan-phase-stall-detection.json was deleted in
the previous commit because, against an earlier verification base, it was
inert: it explained a moved emitted hash that a direct edit to plan-phase.md
already self-attributes. Against origin/next@f1af47766a the demand is
different: plan-phase.md is 13 bytes larger than the base copy, which trips
the emitted-attribution size ratchet — a job this same ack also performs.

Recreated in the documented shape, keyed on the bare filename plan-phase.md
(not the full path, and not restating the byte delta per review guidance),
describing the actual change: the {outputFile} binding fix for the dead
PLANNER_OUTPUT_FILE/CHECKER_OUTPUT_FILE variables and the 5 restored
ORCHESTRATOR RULE labels required by #913, both at the stall-watch spawn
sites, with explanatory bodies living in the lazily-loaded
gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md reference.

Confirmed no other fragment (on this branch or on next) claims the bare key
"plan-phase.md" before recreating — scripts/lint-emitted-drift-ack.cjs's
duplicate check is an exact string match, and the only other mention of
plan-phase.md in tests/emitted-drift-acks/ (2658-trae-instruction-file-path.json)
uses the full path as its key, so there is no collision.

* fix(#2650): real cause of Windows CI failure — bash -c argv-transport, not CRLF

The CRLF diagnosis for PR #3015's Windows failure was wrong. Proven wrong,
not assumed: .gitattributes' blanket `* text=auto eol=lf` means a Windows
checkout never receives CRLF for stall-detection-helpers.md, and the
extracted fence's line 64 is byte-identical and correctly balanced on every
platform. The real cause: runShouldRecover() passed a 70+ line, quote-dense
script as ONE argv element to `spawnSync('bash', ['-c', script, arg0, ...])`
PLUS four more positional args. Windows has no execve — Node serializes
that whole argv into a single CreateProcess command-line string, and Git
Bash's MSYS layer re-splits and unescapes it with its own rules. The
boundary between the script and the trailing args was not stable across
that round trip (live evidence: one failure's stderr was prefixed
`gsd_stall_should_recover_test:` — arg0 arrived — another `/usr/bin/bash:`
— arg0 did not).

Fixed by writing the script to a temp file and running `bash <file> <args>`
instead — the four values are now normal, quote-free positional args, and
the script itself never enters argv transport at all. Mirrors
tests/quick-branching.test.cjs's extractStep25Bash/runStep, which already
uses this exact shape and is green on Windows on `next`.
tests/worktree-cleanup.test.cjs's extractCwdGuardBash/runGuard stays on
`bash -c` but never appends extra positional args beyond the script itself,
so it never hits the same boundary — checked both siblings per review, not
assumed.

Corrected the now-actively-misleading CRLF comment in
extractStallHelpersBash(), and corrected the changeset's claim that the
repo-wide CRLF-normalization fix (folded into this branch, maintainer-
authorized) explains this PR's own Windows failure — it doesn't, though it
remains defensible on its own merits as general test-portability hardening.

Separately, while auditing the shipped (non-test) gsd_stall_watch for
Windows portability per review request, found and fixed a second, real
user-facing defect: the artifact-freshness check used GNU find's
`-newermt "@<epoch>"` shorthand, which the BSD find(1) actually shipped on
macOS does NOT understand ("Can't parse date/time: @<epoch>", verified live
against /usr/bin/find on both a stale and a genuinely fresh file). With the
adjacent `2>/dev/null`, that failed silently and permanently degraded
artifact_fresh to false on every macOS run — a plan-checker or planner
actively writing plan files could still be reported "stalled." Replaced
with `find $glob -mmin -N` ("modified less than N minutes ago"), which
needs no date-string parsing and is supported identically by GNU find and
BSD find; verified live that the old shape fails and the new shape passes
against the same real fresh file. Added a real-execution regression test
(gsd_stall_watch with `sleep` stubbed to a no-op so the test doesn't
actually wait, but the real `find ... -mmin` line still runs) proving the
fix, replacing the prior "not integration-tested" note for that path.

Note: the remote gsd-test runner is Linux-only, so it cannot itself confirm
the Windows fix — only the actual windows-latest CI lane can.

* fix(#2650): route the third bash -c call site through the same temp-file seam

runWatch() and a `-mmin` regression test still passed their script via
`bash -c <script>` after the previous commit only converted
runShouldRecover() — live Windows CI on 4b86cc57f confirmed the mechanism:
failures went 11 -> 4, and `full test (windows-latest, 22, shard 1/3)` and
`shard 2/3` flipped from fail to pass, but the remaining 4 failures (all in
this file, all still `bash: -c:`) were exactly the gsd_stall_watch describe
block, which runWatch() serves. runWatch() passes NO extra positional args
at all, so this also rules out the trailing-args theory from the prior
commit: the ~73-line, quote-dense script itself is what does not survive
Windows argv serialization when passed as a single `-c` element, regardless
of how many (if any) further argv elements follow it.

Extracted one shared runBashScript(script, args, opts) helper — write to a
fs.mkdtempSync'd file, run `bash <file> [args...]`, clean up in `finally` —
and routed all three bash-invoking call sites in this file through it
(runShouldRecover, runWatch, and the -mmin freshness test that builds its
own script inline for the `sleep` stub). One transport seam means a fourth
call site in this file cannot silently reintroduce the bug in isolation,
which is exactly what happened here with a second call site.

Corrected extractStallHelpersBash()'s doc comment a second time to state
the mechanism precisely (script content, not argv-element count) and cite
the live evidence (11->4 failures, shards 1 and 2 flipping green) so the
next reader does not have to rediscover it.

Audited every other bash-invoking call site in files this branch touches,
per review request:
- tests/code-review-pipeline-regression.test.cjs (runPostProcessing),
  tests/graphify-visualization.test.cjs (runBlock), and
  tests/drift-detection.test.cjs (two execFileSync('bash', ['-c', ...])
  sites, one of them carrying the same giant runtime-launcher preamble
  text) — all pre-existing, UNCHANGED by this branch (only touched for the
  readFileNormalized() CRLF swap), and already exercised on `next`'s last
  six Windows CI runs per the reviewer's own citation. Left as-is: no
  evidence of failure, and converting untested pre-existing code outside
  #2650's scope on an unverifiable guess would be its own risk.
- tests/git-base-branch.test.cjs (runHandleBranchingStep) and
  tests/quick-branching.test.cjs (runStep) already use the same temp-file
  pattern. No action needed.
- tests/runtime-launcher-parity.test.cjs (runResolver) uses `bash -c` but
  is explicitly `if (process.platform === 'win32') return '';` guarded off
  on Windows entirely, for an unrelated extension-less-PATH-stub reason —
  never reaches Windows argv transport at all. No action needed.
- tests/worktree-cleanup.test.cjs (runGuard) confirmed by the reviewer as
  correct and verified; not touched, per instruction.

Do not touch: the -mmin fix, the drift-ack fragment, the changeset — all
three confirmed correct in prior rounds and left untouched here.

Note: the remote gsd-test runner is Linux-only and cannot confirm this;
only the windows-latest lanes on #3015 can.

* fix(#2650): give runBashScript a default timeout

runShouldRecover() was the only one of the three call sites through
runBashScript() with no timeout — runWatch() and the -mmin test both pass
timeout: 10000 explicitly. Not a regression (this path never had a bound
before), but CONTEXT.md's unbounded-subprocess guidance applies directly,
and runShouldRecover() is driven repeatedly by a fast-check property test:
one pathological input that fails to terminate would hang CI indefinitely
instead of failing.

timeout: 10000 is now the helper's own default, with ...opts spread after
it so the two existing explicit timeout: 10000 call sites are unchanged
and any future caller inherits a bound automatically.

* fix(#2650): build the -mmin freshness test's glob with forward slashes

Windows CI on d6ddda6ea reported the last failure: the -mmin regression
test expected 'active' but got 'waiting' — find matched nothing, the same
silent-degradation shape as the macOS -newermt defect, but this time in the
test's own fixture rather than the shipped bash.

Traced what production actually passes: every gsd_stall_watch call site in
plan-phase.md builds artifact_glob as `"${PHASE_DIR}"'/*-PLAN.md'` —
PHASE_DIR is a POSIX-style .planning/phases/NN-slug value, and the whole
thing runs under Git Bash regardless of host OS, so production's glob is
always forward-slash. The test instead built it with
`path.join(tmp, '*-PLAN.md')`, which on Windows yields a backslash path
(C:\Users\RUNNER~1\...\*-PLAN.md). In bash pathname expansion a backslash
escapes the next character, so that pattern can never match a real path —
find silently returns empty under the existing 2>/dev/null, same shape as
the macOS bug. Confirmed as a test artifact, not a production defect:
production never constructs the glob this way, so no Windows user is
affected.

Fixed by forward-slashing the tmp dir before appending the glob suffix,
matching production's own convention, with a comment recording why (so a
future "simplify this back to path.join" edit doesn't silently reintroduce
the failure). The shipped bash's unquoted $artifact_glob is untouched —
quoting it would break the multi-file glob expansion it exists for.

Note: the remote runner is Linux-only and already passed clean at
d6ddda6ea (0/29,603, both node lanes); only the windows-latest lanes on
#3015 can confirm this fix.

* fix(#2650): forward-slash the three remaining runWatch globs (vacuous-pass CR)

The :353 fix (833c11da9) only converted the -mmin freshness test's glob.
Three sibling tests in the same describe block still built theirs with
path.join(tmp, '*-PLAN.md'), which yields a backslash path on Windows.

Two of those three were silently passing for the wrong reason: the
'-> stalled' and '-> waiting' tests both expect the glob to match nothing,
and on Windows a backslash path matches nothing regardless of whether the
directory is actually empty (bash eats each backslash as an escape before
the pattern is even evaluated). They would have passed identically with
glob expansion completely broken, which is a vacuous pass — not exercising
what they claim to. The third ('-> marker_received') is outcome-independent
of the glob, so it was merely inconsistent rather than wrong.

Converted all three to the same `${tmp.replace(/\\/g, '/')}/*-PLAN.md`
construction already used at the -mmin test, so every glob in the file now
matches production's own forward-slash `"${PHASE_DIR}"'/*-PLAN.md'` shape,
and the two negative tests are meaningful on Windows instead of accidentally
correct. Reworded the trailing comment on the 'stalled' test's glob line:
it now describes the fixture (the tmp dir contains no *-PLAN.md files)
rather than the pattern, since "matches nothing" read as a property of the
glob syntax when it's a property of what's on disk.

No assertion, the sleep stub, runBashScript, or the shipped bash changed.
Smoke-tested all three updated tests manually before committing (not via
node --test): marker_received / stalled / waiting, all correct.

* fix(#2650): fix own regression tests for #2993's plan-phase.md relocation

531101843's merge with origin/next brought in #2993 (unrelated, epic #1671
Phase 6.2), which extracted plan-phase.md's whole "Chunked Planning Mode"
section into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md,
leaving a <!-- gsd:section --> pointer behind. tests/plan-phase-drift-guard.
test.cjs (#913) was already updated to read the combined surface (host file
+ every steps/*.md) so its label count didn't go blind — my own #2650
regression tests were not, and searched plan-phase.md alone for the two
chunked spawn sites' headings, which no longer exist there. Two tests
failed outright (indexOf returning -1); a third ("standard planner spawn")
was silently weakened to an unbounded slice-to-EOF by the same relocation,
since its own end-boundary heading also moved — passing by accident rather
than by testing what it claimed.

Promoted the drift guard's local readPlanPhaseCombined() to a shared,
exported tests/helpers.cjs readWorkflowCombined(workflowPath) (host file +
sorted steps/*.md, CRLF-normalized at the read boundary) so a second,
divergent implementation is never written — the drift guard now delegates
to it via a same-named local wrapper, unchanged at every existing call site.

Fixed the three affected tests in tests/fix-2650-plan-phase-stall-detection.
test.cjs:
- "standard planner spawn (step 8)": end boundary changed from the now-gone
  "## 8.5. Chunked Planning Mode" heading to "## 9. Handle Planner Return",
  which still exists in plan-phase.md.
- "chunked outline spawn (8.5.1)" / "chunked per-plan spawn (8.5.2)": now
  read gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md
  directly (not the generic multi-file combined blob, whose file-sort
  ordering would put unrelated step files between 8.5.2's slice and any
  downstream anchor) — the same heading-to-heading slicing as before still
  works because the file is small and self-contained.
- Extended the "no unbound $PLANNER_OUTPUT_FILE/$CHECKER_OUTPUT_FILE" check
  to also scan chunked-planning-mode.md, since two of the five spawn sites
  now live there.
- Added a new count-based test asserting exactly 5 (not "at least one")
  `gsd_stall_watch "$TS" "{outputFile}"` invocations across the combined
  surface, mirroring #913's own label-count guard, so every one of the five
  spawns stays provably bounded and a future relocation can't silently drop
  one without a test noticing.

Also added a small positive test that plan-phase.md's <!-- gsd:section -->
pointer to chunked-planning-mode.md exists (#2993 is unrelated to #2650 but
its presence is now load-bearing for where 2 of the 5 spawn sites live).

Audited every other test file in the repo for a stale reference to content
#2993 relocated (searched for the moved headings/prose and for
"chunked-planning-mode"/"CHUNKED_MODE" across all *.test.cjs): only this
file and the drift guard needed changes.
tests/issue-2762-plan-reviews-chunked.test.cjs already reads
chunked-planning-mode.md directly (brought in correct by the same merge).
gen-section-manifest.test.cjs, init.test.cjs, and workflow-fragments.test.cjs
reference "chunked-planning-mode" only as a manifest/section-id fixture
value for #2993 itself, not as a stale pointer to relocated content.

Did not touch: the ported ORCHESTRATOR RULE lines, run_in_background=true,
the glob constructions, runBashScript, the -mmin change, the timeout
default, or the drift-ack fragment (confirmed correct against the stale
local `next` ref two rounds ago and left alone).

---------

Co-authored-by: sim <sim@local>
2026-08-03 10:46:22 -04:00
Tom Boucher
77bf21b3a6 fix(#1995): widen worktree branch regex to accept agent-<id> namespace (#2548)
* test(#1995): regression test for agent-<id> branch namespace

Add failing-first tests proving that normalizeCleanupManifestEntry and
planWorktreeRecordAgent reject Claude Code's current agent-<id> isolation
branches (only worktree-agent-<id> is accepted). Boundary tests cover both
namespaces plus rejection cases.

* fix(#1995): widen worktree branch regex to accept agent-<id> namespace

Claude Code's isolation="worktree" branch naming changed from
worktree-agent-<id> to agent-<id>. Widen the regex in all 7 locations
from ^worktree-agent-[A-Za-z0-9._/-]+$ to ^(worktree-)?agent-[A-Za-z0-9._/-]+$
so both namespaces are accepted. Introduce a shared WORKTREE_AGENT_BRANCH_RE
constant in src/worktree-safety.cts to prevent future drift.

Closes #1995

* fix(#1995): update workflow guards, test assertions, and baselines

Widen the branch-check regex in execute-phase.md and execute-plan.md.
Update all test assertions that checked for ^worktree-agent- to expect
the widened ^(worktree-)?agent- pattern. Regenerate golden-install-parity
fixtures, agent-size-baseline, and workflow-size-baseline.

Closes #1995

* fix(#1995): update extractCwdGuardBash sanity check for widened regex

The e2e test's sanity check verified the extracted bash block contained
'worktree-agent-'. After widening to '(worktree-)?agent-', update the
check to match the new pattern.

* fix(#1995): widen missed workflow-guard branch check + changeset + lint fixes

- hooks/gsd-workflow-guard.js: widen startsWith('worktree-agent-') to
  /^(worktree-)?agent-/ regex — same defect class, was missed in prior commit
- tests/worktree.test.cjs: fix indentation regression from prior edit
- Add .changeset/1995-worktree-agent-branch-namespace.md (pr:0 placeholder)

Found by orthogonal code review (Step 4).

* fix(#1995): regenerate golden + size baselines for workflow-guard change

* docs(#1995): backfill changeset PR number (2548)
2026-07-23 07:36:53 -04:00
Tom Boucher
6d072435d0 test(#1975): consolidate 51 CLI + scripts-tooling regression tests into module suites
Fold 51 issue-named CLI black-box + scripts-tooling regression files into their
canonical module suites (runtime-launcher-parity, worktree-safety, install-*, managed-hooks,
read-guard, capability-registry, etc.), plus a NEW slash-command-namespace.test.cjs grouping
the 4 slash/colon-namespace-leak invariant suites that had no canonical owner. Verbatim
block-scoped describe wrappers; 427 subtests conserved 1:1.

Host-env pre-check (per B2): no CLI-receiving host sets a redirecting GSD_WORKSTREAM/GSD_PROJECT
value. One folded suite (bug-3668 runtime resolver) creates an extension-less PATH gsd-tools
stub + bash -c; co-locating it with the host's chmodSync tripped local/no-unguarded-nonportable-exec,
so it's now Windows-guarded (skip on win32) matching the host suite's own bash -c guard.

Regenerates regression-name allowlist (222->182), ratchets file-count allowlist (graphify 7->6,
docs entry removed), makes 26 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456;
prunes stale ids). Repoints 13 tests/ references across CONTEXT.md, COMMANDS.md/FEATURES.md
(EN + ja/ko/pt/zh) and ADR-0002. lint:ci green.

Part of epic #1969. Closes #1975.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:22:11 -04:00
Tom Boucher
2c32e8d890 test(#1973): consolidate 48 workflow regression tests into workflow-aspect suites
Fold 48 issue-named workflow-markdown regression files into the canonical test
that owns each workflow aspect (execute-phase-*, plan-phase-*, quick-*, discuss-*,
worktree-cleanup, secure-phase, verify, update, settings, etc.), across 32 existing
suites. Verbatim block-scoped describe wrappers; 281 subtests conserved 1:1. No new
test files. Host-env pre-check: only gsd-settings-advanced spawns CLI and it sets no
GSD_WORKSTREAM/GSD_PROJECT value — no leak risk.

Regenerates regression-name allowlist (222->181), ratchets file-count allowlist
(verify 11->10), makes 30 relocated allow-test-rule exemptions issue-ref-compliant
(ADR-456; prunes 30 stale ids). lint:ci green.

Part of epic #1969. Closes #1973.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:17:32 -04:00
Tom Boucher
f08b177215 feat(#1726): G1-G6 portability AST rules; fix all offenders; delete the ratchet (Phase 4) (#1731)
Phase 4 of epic #1702. Closes #1726.
2026-06-25 18:24:39 -04:00
Behruz Nassre Esfahani
faac9331f2 feat(#1298): add validated worktree record-agent writer verb for wave manifests (#1448)
Closes #1298
2026-06-21 15:38:44 -04:00
Tom Boucher
9540fe43b9 fix: have executor self-report worktree metadata (#1349) 2026-06-16 14:19:08 -04:00
Tom Boucher
6e242bd76a fix: allow quick worktree parent plan base (#1347) 2026-06-16 14:00:06 -04:00
Tom Boucher
c89197972e fix(#630): pin wave-cleanup to orchestrator root via manifest, not worktree-list first-entry (#643)
* fix(#630): pin wave-cleanup to orchestrator root via manifest, not list first-entry

Follow-up to #590. #590 fixed the dispatch-side orchestrator cwd anchor
(ORCHESTRATOR_WT via git rev-parse --show-toplevel) but the two
wave-cleanup guards still resolved PRIMARY_WT from `git worktree list
--porcelain`'s first entry — always the main checkout. An orchestrator
running from a non-primary (per-phase lane) worktree was therefore cd'd
off its own lane at cleanup, tripping the #3174 branch-drift assertion
(ORCH_BRANCH != EXPECTED_BRANCH) and refusing merge-back — the same
failure #590 set out to fix, surviving on the cleanup side.

Persist the dispatch-time orchestrator root (show-toplevel, captured from
the lane the orchestrator dispatches from) into WAVE_WORKTREE_MANIFEST as
`orchestrator_root`, and resolve PRIMARY_WT from it at both cleanup sites.
The git-worktree-list first entry survives only as a guarded fallback for
pre-#630 manifests. Byte-identical for a primary orchestrator (its root
IS the first entry); unblocks the non-primary-orchestrator topology.

Regression test (bug-630-...): behaviorally proves the pivot by running
the shipped manifest-reader one-liner against a real non-primary-worktree
git topology — it resolves to the lane while first-entry resolves to main
— plus contract assertions. Updates the #3425 worktree-cleanup contract
tests to the new manifest-based resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#630): add changeset for wave-cleanup orchestrator-root fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#630): canonicalize paths with realpathSync.native for Windows 8.3 parity

On Windows the CI runner's os.tmpdir() yields an 8.3 short name (RUNNER~1)
while `git worktree list` reports the long form (runneradmin); plain
realpathSync preserved each input's form, so the first-entry/main sanity
comparison mismatched. Canonicalize both sides (and the reader output)
via fs.realpathSync.native, which reconciles 8.3 and long forms. Test-only;
the shipped manifest reader is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:07:16 -04:00
Tom Boucher
463cffd894 chore(#604): rename get-shit-done/ runtime directory to gsd-core/ (#615)
* chore(#604): rename get-shit-done/ runtime directory to gsd-core/

Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the
on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary
(`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers
are unaffected.

Mechanical (bulk, ~90% of the diff):
- `git mv get-shit-done gsd-core`
- Swept path/identifier references across the repo via
  `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead
  preserves the five legitimate slug variants that are NOT the directory:
  get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names).
- Build/manifest wiring: package.json (bin, files, coverage globs),
  tsconfig.build.json (outDir), ~86 .gitignore build-output entries,
  stryker.config.mjs, scan-ignore files, install.js path strings.
- Frozen (not rewritten): CHANGELOG.md history; translated docs
  (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/).

New logic (review here):
- src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper
  ADR-0008 installer migration. On upgrade it walks the legacy
  `~/.claude/get-shit-done/` tree, classifies each file via the prior install
  manifest, and emits remove-managed / backup-and-remove for managed files
  while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked
  root and symlinked entries; bounds-checks every path under configDir). The
  framework rolls back on install failure. Emptied dirs may remain (framework
  has no recursive dir-removal primitive) — documented.
- scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare
  `get-shit-done` directory token (split token to avoid self-match; case-
  insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists
  CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines).
  Wired into the lint-tests CI job.
- Restored scripts/lint-package-identity-drift.cjs detection regexes (the
  mechanical sweep had wrongly rewritten the old-name patterns it exists to
  detect) and marked them as intentional legacy references.
- TDD tests for the migration and the guard; do.md slash-command guard regex
  tightened so a `/gsd-core/bin` path segment is not mistaken for a command;
  changeset + docs/installer-migrations.md row added.

Breaking: the installed runtime path moves `~/.claude/get-shit-done/` ->
`~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed
files (preserving user files) on upgrade. Users with custom hooks/configs
hardcoding the old path must update them.

Closes #604

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unsweep pending changesets + allowlist injection-example docs

CI fixes for the rename PR:
- Do not sweep pending .changeset/*.md (ephemeral release-note fragments,
  like CHANGELOG); reverted those body edits so 5 pre-existing malformed
  fragments (missing type/pr) no longer enter the PR diff and trip docs-lint.
  Allowlisted .changeset/ in the legacy-name guard accordingly.
- Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in
  prompt-injection-scan.sh: they contain intentional injection examples /
  security-model prose; the path-reference rewrites are kept.

CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR;
none in the new migration/guard) and are out of scope for the rename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): resolve CodeQL alerts surfaced on this PR

The rename diff touched files carrying pre-existing CodeQL findings; per the
no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving
them off. All behavior-preserving:

- scripts/ci-test-scope.cjs: build the config-path match from string
  .includes() instead of a RegExp over an arg-derived value (js/regex-injection).
- src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName
  so the table-cell escape is complete (js/incomplete-sanitization).
- tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment
  strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization).
- tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace,
  keep the meaningful POSIX-class conversion (js/identity-replacement).

Verified: build:lib green; the touched test files + ci-test-scope + profile-output
suites pass; lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization)

The prior commit's fixes for two alerts were ineffective:
- ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file`
  reaching static regex `.test(file)` calls (not the config rule). Removed ALL
  regex over file/t — startsWith/includes/=== string checks + an isWindowsHint
  helper — so there is no regex sink for the tainted value.
- js/incomplete-multi-character-sanitization (3 test files): a single
  `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint
  loop (replace until stable) plus a final bare-opener strip.

Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass;
lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL

CodeQL flags the regex PATTERNS syntactically (regex-injection on the
--files arg split; incomplete-multi-character-sanitization on the <!--...-->
replace), so loop fixes do not satisfy it. Made these paths regex-free:
- ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/).
- 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)).
Behavior preserved; ci-test-scope + the 3 suites pass; guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unblock security base64 scan on the large rename diff

The security job hit its 10m timeout: base64-scan.sh choked on the binary
test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/
non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings),
and the ~800-file rename diff is slow to scan regardless.

- scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they
  can't carry base64-obfuscated *text* and feeding NUL bytes through the
  per-line scanner is pathologically slow. collect_files already filtered
  binary *extensions*; this catches binary *content* in text extensions.
- .github/workflows/security-scan.yml: raise the security job timeout 10m->30m
  to accommodate very large diffs (the scan itself is unchanged).

Verified locally: scan skips the fixture, 0 "ignored null byte" warnings,
0 findings, exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): sweep get-shit-done refs introduced by merging next

The branch was updated with next (#614/#384/#618 etc.), which reference the
get-shit-done/ dir (still named that on next). Swept the stale references in
the merged files to gsd-core so the rename stays consistent and lint:legacy-name
passes:
- commands/gsd/discuss-phase.md (runtime-launcher shim paths)
- src/core.cts (getAgentsDir layout comments)
- tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib)

Verified: guard 0 violations; build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant

The #614 runtime-launcher shim added to discuss-phase.md references
`${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y
refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it
mis-read the directory path as a dangling `/gsd-core` command ref (same class as
the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path
segments are not treated as slash-command references.

Verified locally on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22 image) full suite: 0 failures
- bug-3683 + bug-2954 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI)

CI intermittently failed state.test's gsd-tools subprocess with
"findProjectRoot is not a function" (flip-flopping across legs; not reproducible
on mac full suite, gsd-test linux full suite, test:unit, or state.test x8).
findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs);
binding it via destructure at module-load can be undefined under a load-ordering
edge. Resolve it lazily at call time via a small wrapper so the lookup happens
after core.cjs is fully initialized.

Verified green on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22) full suite: 0 failures
- state.test.cjs: 106/106; gsd-tools loads cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): allowlist verification-patterns.md placeholder examples in secret scan

The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling
it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var
examples (illustrative Stripe test-key / database-URL / API-key placeholders) —
not real credentials. Added it to .secretscanignore with the strict annotation,
mirroring the existing gsd-core/workflows/plan-phase.md exception.

Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next
exits 0 with 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:35:29 -04:00
Tom Boucher
0c1084ba88 fix(#48): verify-only worktree_branch_check + orchestrator cwd-drift guard (#590)
Closes #48. Makes the canonical worktree_branch_check fragment verify-only/fail-closed (exit 42, no git reset self-recovery), adds an orchestrator fail-closed collection rule and a cwd-drift guard at execute_waves entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:10:59 -04:00
Tom Boucher
b88d6d6ef1 refactor(#588): consolidate duplicated worktree_branch_check into one canonical fragment (#589)
Extracts the fail-closed worktree branch-check guard into a single canonical fragment (get-shit-done/references/worktree-branch-check.md) and repoints all five sites at it; orchestrator embeds the runnable block at dispatch. All safety invariants preserved; adversarially reviewed; full matrix green. Closes #588.
2026-06-01 18:08:10 -04:00
Tom Boucher
2b9fa009d3 fix(#373): replace unquoted $GSD_SDK with space-safe gsd_run launcher (#379)
* fix(#373): replace unquoted $GSD_SDK with space-safe gsd_run launcher

Workflow bash blocks resolved the runtime as GSD_SDK="node $GSD_TOOLS" and
invoked it unquoted ($GSD_SDK query ...). On install paths containing spaces
(e.g. /Volumes/Mini Me/...) the unquoted expansion word-split into
`node /Volumes/Mini gsd-tools.cjs ...`, failing with "Cannot find module
'/Volumes/Mini'" and getting masked by `2>/dev/null || echo "{}"` into a
silent empty state.

Replace the string variable with a single-line shell launcher that defines a
gsd_run function, invokes the runtime with a fully-quoted path and "$@", and
preserves the local-cjs / installed-gsd-tools-on-PATH fallback (#3668) plus
the loud not-found error and install hint. The launcher uses _GSD_SHIM_NAME
indirection so no workflow emits the /gsd-tools substring that the do.md
dispatcher-parity scanner would misread, and is single-line to stay within
the per-file progressive-disclosure line budgets (#2551).

The canonical launcher lives in
get-shit-done/workflows/_runtime-launcher.snippet.sh, is propagated once per
file by scripts/sync-runtime-launcher.cjs, and is locked by
tests/runtime-launcher-parity.test.cjs (fails CI on drift, on a reappearing
$GSD_SDK token, or on a /gsd-tools substring). Dependent workflow-assertion
tests are updated from $GSD_SDK to gsd_run, and the runtime launcher is
registered in CONTEXT.md.

Fixes #373

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(#373): add changeset fragment and allow-test-rule for parity guard

The runtime-launcher parity test is a structural drift guard that reads
workflow markdown to assert the canonical launcher is present and the
retired $GSD_SDK / /gsd-tools tokens are absent; annotate it with
allow-test-rule per the no-source-grep lint escape hatch. Add the required
.changeset fragment for this user-facing fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(#373): make parity PATH-fallback assertion cross-platform (Windows)

Subtest (E) compared GSD_TOOLS against the Node-side absolute temp path,
but the value originates from git-bash which reports the POSIX form, so the
prefix comparison failed on windows-latest while the launcher itself worked
(the installed stub was invoked). Assert the resolved binary by normalized
suffix (/bin/gsd-tools, not .cjs) instead of the absolute prefix; the
behavioral stub-invocation assertion is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:21:05 -04:00
Tom Boucher
7ad1a5edf5 fix(3668): isolate --local install from global gsd-sdk (#89)
* fix(3668): isolate --local install from global gsd-sdk

- `buildGsdSdkVersionMismatchReport` now accepts `opts.isLocal`; when
  true it sets `fix_command` to `npx get-shit-done-cc@latest --claude
  --local` instead of `npm install -g …`, removing the misleading global
  upgrade suggestion for local installs.
- Propagate `isLocal` from `installSdkIfNeeded` into the mismatch report
  builder so the right fix_command reaches the renderer.
- Export `buildGsdSdkVersionMismatchReport` and
  `renderGsdSdkVersionMismatchReport` so tests can assert on the IR
  contract directly.
- Add `command -v gsd-sdk … elif node "$GSD_TOOLS"` preflight SDK
  resolution block to all 69 workflow files that called bare `gsd-sdk`
  with no fallback, matching the pattern established in update.md,
  execute-phase.md, and quick.md.
- Add `tests/bug-3668-local-install-sdk-soft-dep.test.cjs` with 5 tests
  covering Defects 1-3, including a CI lint guard that blocks future
  workflow regressions.

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

* changeset: add Fixed entry for #3668

* fix(3668): add allow-test-rule to suppress false lint-no-source-grep violation

The test reads workflow .md files (product content) to assert structural
invariants — not .cjs source files. The file-presence check is the only
viable IR for markdown guard patterns. Add the // allow-test-rule annotation
so lint-no-source-grep passes.

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

* fix(3668): fix do.md false-positive and discuss-phase.md size overflow

Two CI failures introduced by the 69-workflow preflight block:

1. do.md: the path `bin/gsd-tools.cjs` contains `/gsd-tools` which the
   bug-2954 parity test regex `/\/gsd[:-]([a-z][a-z0-9-]*)/g` mistakenly
   extracts as a slash command named `tools`. Fix: store the shim filename
   in _GSD_SHIM_NAME so the path construction no longer contains a static
   `/gsd-tools` literal. Also wire $GSD_SDK into the actual query call.

2. discuss-phase.md: the file was at 499 lines (the 500-line budget from
   #2551). Adding the 11-line preflight block pushed it to 510, failing
   workflow-size-budget.test.cjs. Fix: compress the 11-line preflight +
   2-line invocations into 3 lines (one-liner guard + two $GSD_SDK calls)
   returning the file to 499 lines while retaining the command -v guard
   required by bug-3668-local-install-sdk-soft-dep.test.cjs.

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

* fix(3668): wire \$GSD_SDK through all workflow callsites (#3797)

PR #3797 introduced the resolution preflight block (setting \$GSD_SDK) in
69 workflows but left every downstream gsd-sdk callsite using the bare
command. On local-only installs the preflight exits cleanly, then the
very next line fails with 'command not found'. This is the structural
gap the Codex review flagged.

Changes:
- 687 bare `gsd-sdk` callsites replaced with `\$GSD_SDK` across 75
  workflow files (all bash/sh fenced blocks excluding the resolution
  guard blocks themselves)
- execute-phase.md: was missing the preflight block entirely — added
  the standard 11-line resolution block at the initialize step
- execute-phase.md: inline `if command -v gsd-sdk` availability guard
  (legacy #3384 fallback) replaced with `\$GSD_SDK` + error fallback
  since the new preflight guarantees SDK availability or exits 1
- 6 sub-workflow files (discuss-phase/modes/*, execute-phase/steps/*)
  that have no preflight of their own but use \$GSD_SDK — these are
  loaded by parent workflows that set the variable; callsites updated
  to use \$GSD_SDK so they work when variable is in scope

Transformation script used: /private/tmp/fw2.js (regex-based fence
parser with segment join invariant verification — preserves all blank
lines and prose formatting).

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

* test(3668): upgrade CI guard to detect bare callsite routing (#3797)

The previous Defect 3 test checked that 'command -v gsd-sdk' appeared
as a string in the file — a guard-presence check, not a callsite-routing
check. A workflow with the preflight block but 40 bare gsd-sdk calls
below it passed the old test. This is exactly the bug state PR #3797
was supposed to fix.

Upgraded test:
- Parses each workflow file into markdown segments using a regex-based
  fence extractor (preserves all content invariantly)
- Skips bash/sh blocks that contain 'command -v gsd-sdk' (those are
  resolution guards — bare references there are expected)
- Flags any remaining bash/sh block line that invokes gsd-sdk without
  the \$ prefix (isBareGsdSdkInvocation predicate)
- Counter-test proves the predicate correctly flags real callsite lines
  and correctly exempts guard assignments, comments, and \$GSD_SDK refs

Also adds helper functions parseMarkdownSegments, isBareGsdSdkInvocation,
and findMdFiles which are used by both the upgraded Defect 3 test and
the counter-test.

This test would have caught the originally-shipped bug: the preflight
block was present but callsites still used bare gsd-sdk.

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

* fix(tests): update workflow content tests to accept \$GSD_SDK callsite form (#3797)

Six regression tests assert on the exact textual pattern of gsd-sdk calls
inside workflow .md files. After the #3797 callsite replacement (687 bare
`gsd-sdk` invocations replaced with `\$GSD_SDK`), these tests failed because
they searched for the literal string `gsd-sdk query <cmd>` which no longer
appears at callsites.

Updated each test to accept both the pre-#3797 bare form and the post-#3797
variable form using `(?:\$GSD_SDK|gsd-sdk)` regex alternation (or two-branch
`includes()` checks for non-regex assertions). The structural invariants each
test enforces are unchanged — we're accepting the same behavioral contract
through the new callsite surface.

Tests fixed:
- bug-2334-quick-gsd-sdk-preflight: find init.quick call via \$GSD_SDK or bare
- bug-2661-roadmap-sync-parallel: roadmap.update-plan-progress call pattern
- bug-3360-codex-execute-phase-worktrees: RUNTIME config-get call detection
- bug-3381-verify-work-workstream: init.verify-work / phase.mvp-mode calls
- enh-2433-todo-phase-linking: commit call in new-milestone.md
- enh-2792-namespace-skills: validate.context invocation in context_check step

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

* fix(tests): update remaining workflow content tests to accept \$GSD_SDK form (#3797)

After #3797 callsite replacement, ultraplan-phase.test.cjs and worktree-cleanup.test.cjs
still assert bare gsd-sdk form. Update to accept either \$GSD_SDK or gsd-sdk. Also trim
the execute-phase.md preflight comment to stay within the XL line-count budget (1810).

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

* fix(3668): adopt inline-per-fence SDK resolution + restore safety semantics

The brief offered three options:
  (a) inline preflight block per fence
  (b) wrapper script
  (c) shared shell fragment sourced at the top

71 of 72 workflow files already had inline preflight blocks (just broken ones).
Option (b)/(c) would have required changes to install.js + a new shared artifact,
with significant risk of breaking the install pipeline. Option (a) was the path
of least resistance and least new blast radius.

**BLOCKER 1+2+3 (quick.md — GSD_SDK never assigned):**
- quick.md had 12 `$GSD_SDK` references but zero `GSD_SDK=` assignments.
- Added proper local-first preflight block with `git rev-parse --show-toplevel`
  path (not the broken `CLAUDE_FILE_PATHS` which is always empty in Claude Code).
- Each Bash fence in Claude Code runs as a fresh `bash -c`, so env vars don't
  persist. The preflight block must appear in every fence that uses $GSD_SDK.

**BLOCKER 4 (execute-phase.md — || exit 1 dropped):**
- Restored `|| exit 1` after every `worktree.cleanup-wave` call. SDK safety
  refusals (drift detection #3174, deletion block #2384) must surface, not be
  swallowed by the old `|| { fallback }` branch.

**F5 (verify-work.md untyped fence):**
- Changed bare `gsd-sdk` in an untyped fence to `$GSD_SDK`.
- Changed fence tag from untyped to `bash`.

**F6 (non-recursive readdirSync):**
- Defect 2 test now uses `findMdFiles` (recursive) to cover workflow
  subdirectories, not the flat `fs.readdirSync` that missed subdirs.

**F7 (lint misses untyped fences):**
- `parseMarkdownSegments` now treats `lang === ''` fences as bash-fences.

**F8 (missing propagation test):**
- Added two propagation tests in the Defect 3 describe block.

**F9 (priority inverted — global before local):**
- All 72 workflow files now check `[ -f "$GSD_TOOLS" ]` before `command -v gsd-sdk`.
- Path: `$(git rev-parse --show-toplevel 2>/dev/null || pwd)/get-shit-done/bin/gsd-tools.cjs`

**F10/F11 (broken quoting):**
- Changed `GSD_SDK="node "$GSD_TOOLS""` → `GSD_SDK="node $GSD_TOOLS"` across all files.

**SDK-absence fallback removal:**
- The old `|| { STATE_BACKUP=...; while IFS=...WAS_DELETED...; done }` fallback
  code was dead — preflight now exits if neither local nor global SDK exists.
  Removed from quick.md, execute-phase.md. Tests updated to verify SDK delegation
  rather than inline shell mechanics.

**Tests updated:**
- bug-2384, bug-2501, bug-2838, bug-3091, bug-3195, bug-3521, bug-3668,
  worktree-cleanup — all updated to reflect SDK delegation contract.
- Defect 2 test now uses bash-fence scan (not raw content) to skip docs-only
  gsd-sdk prose references (e.g. discuss-phase/modes/text.md).

Closes #3668

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

* fix(3668): restore _GSD_SHIM_NAME indirection in do.md to prevent false-positive

The top commit re-introduced a literal /get-shit-done/bin/gsd-tools.cjs path
in do.md, causing bug-2954 test to match /gsd-tools as an unshipped slash
command. Restore the _GSD_SHIM_NAME variable indirection (from ff9939e5) to
break the literal path while preserving local-first preference order.

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

* fix(tests): update worktree.test.cjs to accept SDK delegation contract (#3797)

Mirror the contract update already applied to worktree-cleanup.test.cjs:
- pre-merge deletion check tests: accept worktree.cleanup-wave + deletion
  mention as valid (inline --diff-filter=D was in the removed shell fallback)
- quick.md bug-2431 tests (lock-aware, unlock retry, residual warning): accept
  worktree.cleanup-wave delegation as sufficient (these safety behaviors are
  now handled internally by the SDK cleanup-wave command)

execute-phase.md tests unchanged: it retains inline .git/worktrees/, locked,
git worktree unlock, and Residual worktree in its cleanup-tail snippet.

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

* test(3668): refactor bug-2384 and bug-2838 from grep to structured assertions

Replace content.includes() on readFileSync-bound variables with parser
functions that split lines and return typed boolean fields, matching the
project's no-source-grep contract (lint-no-source-grep rule F/G).

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:54:20 -04:00
Tom Boucher
4f56c3b10b refactor(tests): consolidate Worktree Module — 13 files → 3 (#3752)
* refactor(tests): consolidate Worktree Module — 13 files → 2

Closes #3742

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

* fix(changeset): correct frontmatter format for 3742 fragment

type:/pr: fields required by docs-lint; replaces @changesets/cli
package-bump format with the repo's custom fragment schema.

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

* refactor(tests): split consolidated worktree.test.cjs along cleanup seam (≤ 800 LOC/file)

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

* chore(changeset): update 3742 fragment — 13→3 files, ≤800 LOC/file

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

* chore(changeset): fix pr reference 3738→3752 in 3742 fragment

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 20:56:26 -04:00
Tom Boucher
918f987a19 feat(#2982): extend no-source-grep lint to catch var-binding readFileSync.includes() (#2985)
* feat(#2982): extend no-source-grep lint to catch var-binding readFileSync.includes()

The base lint (scripts/lint-no-source-grep.cjs) only catches
readFileSync(...).<text-method>() chained directly. The much more
common var-binding form escapes it:

  const src = fs.readFileSync(p, 'utf8');
  // 50 lines later
  if (src.includes('foo')) {}        // ← still grep, lint missed it

Scan of the test suite found ~141 files using this pattern.

Implementation built TDD per #2982 with structured-IR assertions:

  scripts/lint-no-source-grep-extras.cjs
    - detectVarBindingViolations(src) — pure detector, two passes:
      pass 1 collects vars bound from readFileSync, pass 2 finds any
      <var>.<includes|startsWith|endsWith|match|search>( on those vars.
    - detectWrappedAssertOkMatch(src) — flags
      assert.ok(<expr>.match(...)) which escapes the assert.match rule.
    - VIOLATION enum exposes stable codes for tests to assert on.

  scripts/lint-no-source-grep.cjs
    - Wires the new detectors into the existing per-file check; one
      additional violation row per file with the first 3 sample tokens.

  tests/bug-2982-lint-var-binding.test.cjs
    - 13 tests, all assertions on typed VIOLATION enum / structured
      records. Covers all 5 text-match methods, multi-var, no-bind,
      string literal (must NOT trigger), wrapped assert.ok(.match),
      and assert.match (must NOT double-flag).

Migration backlog (#2974 expanded scope):

  - 42 files annotated `// allow-test-rule: source-text-is-the-product`
    (legitimate — they read .md/.json/.yml files whose deployed text
    IS the product)
  - 3 files annotated `// allow-test-rule: pending-migration-to-typed-ir [#2974]`
    (read .cjs/.js source — clear migration debt)
  - 95 files annotated `pending-migration-to-typed-ir [#2974]` with
    `Per-file review may reclassify as source-text-is-the-product
    during migration` (mixed — manual review under #2974)

After this lands the lint reports 0 violations on main; new
violations in PRs surface immediately.

Closes #2982
Refs #2974

* test(#2982): fix truncated test name per CR

The label ended with a bare '(' from a copy-paste mishap. Now reads
'does NOT flag .matchAll(...) — matchAll is not match, so
assert.ok(.matchAll(...)) is not flagged'.

* chore(#2982): add changeset fragment for PR #2985

* chore(#2982): add changeset fragment for PR #2985
2026-05-01 19:50:10 -04:00
Tom Boucher
2703422be8 refactor(tests): standardize to node:assert/strict and t.after() per CONTRIBUTING.md (#1675)
* refactor(tests): standardize to node:assert/strict and t.after() per CONTRIBUTING.md

- Replace require('node:assert') with require('node:assert/strict') across
  all 73 test files to enforce strict equality (no type coercion)
- Replace try/finally cleanup blocks with t.after() hooks in core.test.cjs
  and hooks-opt-in.test.cjs per the test lifecycle standards
- Utility functions in codex-config and security-scan retain try/finally
  as that is appropriate for per-function resource guards, not lifecycle hooks

Closes #1674

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

* perf(tests): add --test-concurrency=4 to test runner for parallel file execution

Node.js --test-concurrency controls how many test files run as parallel child
processes. Set to 4 by default, configurable via TEST_CONCURRENCY env var.
Fixes tests at a known level rather than inheriting os.availableParallelism()
which varies across CI environments.

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

* fix(security): allowlist verify.test.cjs in prompt-injection scanner

tests/verify.test.cjs uses <human>...</human> as GSD phase task-type
XML (meaning "a human should verify this step"), which matches the
scanner's fake-message-boundary pattern for LLM APIs. This is a
false positive — add it to the allowlist alongside the other test files
that legitimately contain injection-adjacent patterns.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:29:03 -04:00
Tibsfox
316337816e fix(worktree): add post-execution cleanup for orphan worktrees
Executor agents spawned with isolation="worktree" committed to temporary
branches in separate working trees, but no step existed to merge those
changes back or clean up. This left orphan worktrees and unmerged
branches after every execution.

Changes:
- execute-phase.md: add step 4.5 "Worktree cleanup" after wave
  completion — merges worktree branch, removes worktree, deletes temp
  branch. Handles merge conflicts gracefully.
- quick.md: add worktree cleanup step after executor returns, before
  summary verification
- Both workflows skip cleanup when workflow.use_worktrees is false
- Both workflows skip silently when no worktrees are found

Closes #1496

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:57:56 -07:00