Commit Graph

3970 Commits

Author SHA1 Message Date
Tom Boucher
c638665d59 fix(3569): surface phase_status from init.plan-phase; gate /gsd:plan-phase on closed phases (re-submit of #3578) (#3581)
* fix(3569): surface phase_status from init.plan-phase + gate /gsd:plan-phase on closed phases

Adds a new `phase_status` field to the `init.plan-phase` SDK + CJS query
output and a §1.5 "Closed-Phase Gate" in workflows/plan-phase.md that
short-circuits on closed phases instead of silently replanning over
shipped code.

`gsd-sdk query init.plan-phase <N>` returned the same "ready to plan"
payload for a closed phase (REQUIREMENTS Met, VERIFICATION.md status:
passed, ROADMAP flipped) as for an open one. No field signaled closure,
so `/gsd:plan-phase --reviews` happily replanned over closed phases —
risking documentation drift on already-shipped code.

- Export `determinePhaseStatus` from `commands.cjs` (already present, was
  module-private).
- Both `cmdInitPlanPhase` (CJS) and `initPlanPhase` (TS SDK) now compute
  `phase_status` from plan/summary counts + VERIFICATION.md status using
  the existing `determinePhaseStatus` helper — the project-wide phase
  lifecycle vocabulary (Pending | Planned | In Progress | Executed |
  Complete | Needs Review). No directory yet → Pending.
- Workflow `plan-phase.md` adds §1.5 "Closed-Phase Gate":
  - `phase_status == "Complete"` with `--reviews` → hard-stop, no
    override (replanning a closed phase via review feedback is never
    legitimate; concerns belong in a follow-up phase or new issue).
  - `phase_status == "Complete"` without `--force` → exit with a clear
    notice pointing at VERIFICATION.md.
  - `phase_status == "Complete"` with `--force` → continue with a
    transcript banner so the deliberate replan is visible.

`Executed` and `Needs Review` are intentionally not gated — those mean
planning finished but verification did not pass, and replanning is the
correct next step.

- SDK: 4 new `phase_status` cases in init.test.ts covering Pending /
  Planned / Executed / Complete transitions.
- Existing init.plan-phase golden parity test continues to pass (the
  `researcher_model: '' vs sonnet` drift in that test predates this
  change and is unrelated).
- Full Mac+Docker suite: 9323 / 9323 passed (Mac), 9318 / 9323 passed
  (Docker, 5 skipped).

Fixes #3569

* chore(3569): add changeset fragment for PR #3581

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-16 13:14:21 -04:00
Tom Boucher
2e4fe65816 fix(3579): ship graphify hook + lib/ helper through build-hooks + install (#3640)
* fix(3579): ship graphify hook + lib/ helper through build-hooks + install

`scripts/build-hooks.js` `HOOKS_TO_COPY` did not include
`gsd-graphify-update.sh` (added in #3347 / PR #3557), so it never landed
in `hooks/dist/` and `bin/install.js` — which `readdirSync`s the dist —
never copied it to `~/.claude/hooks/`. The hook's detached rebuild
helper at `hooks/lib/gsd-graphify-rebuild.sh` was also silently dropped
because both build-hooks.js (flat allowlist) and bin/install.js (readdir
+ isFile filter) only walked top-level files.

The published tarball gap (Gap 3 in the issue body) is not reproduced
on origin/main — `npm pack --dry-run --json` shows both source files
are present today. Only Gaps 1 and 2 are in scope.

Changes:
- Add `gsd-graphify-update.sh` to `HOOKS_TO_COPY`.
- Add `HOOKS_SUBDIRS_TO_COPY = ['lib']` and copy whitelisted hook
  subdirectories (`hooks/<dir>/*` → `hooks/dist/<dir>/*`) in
  build-hooks.js, with the same syntax-check + atomic-rename path the
  top-level loop uses.
- `bin/install.js`: when copying `hooks/dist/`, recurse one level into
  any directory entry so subdir files (e.g. `lib/gsd-graphify-rebuild.sh`)
  land at the mirrored target path the hook's REBUILD_SCRIPT lookup
  expects. Top-level if/else structure for files is unchanged.

Regression test `tests/bug-3579-graphify-hook-publish.test.cjs`:
- Drift guard: every top-level `hooks/*.sh` must appear in
  `HOOKS_TO_COPY`. Generalizes beyond graphify so the next .sh hook
  added cannot regress.
- After build: `hooks/dist/gsd-graphify-update.sh` AND
  `hooks/dist/lib/gsd-graphify-rebuild.sh` exist.
- After install: both files land at the target, and no
  "Missing expected hook: gsd-graphify-update.sh" warning is emitted.

Fixes #3579

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

* test(3579): replace source-grep drift guard with filesystem-behavior assertion

The Gap-1 drift guard read scripts/build-hooks.js as text and regex-parsed the
HOOKS_TO_COPY literal, which tripped lint-no-source-grep and is brittle under
refactors. Replace with a behavior-based assertion: run the build, then for
every top-level hooks/*.sh assert hooks/dist/<name> exists. Strictly stronger
— catches both the original allowlist gap and any future regression that
silently drops a hook for any other reason.

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-16 13:14:19 -04:00
Tom Boucher
05a8395566 fix(3406): detect + warn on stale @gsd-build/sdk@0.1.0 global shadow (#3641)
* fix(3406): detect + warn on stale @gsd-build/sdk@0.1.0 global shadow

`@gsd-build/sdk@0.1.0` is the only published version of the standalone
SDK package (the SDK now ships embedded in get-shit-done-cc). When a
user has the stale 0.1.0 globally installed, its `gsd-sdk` bin shadows
the shim get-shit-done-cc wires up — and the 0.1.0 binary only knows
`run | auto | init` (no `query`), so every `gsd-sdk query <cmd>` call
from skills and hooks fails silently until the user runs
`npm uninstall -g @gsd-build/sdk`.

Per maintainer triage decision (option 2): detect at install time and
surface the remediation, instead of waiting for the user to discover
the failure through a broken workflow.

Changes:
- New helper `detectStaleStandaloneSdk(runNpmLs)` (pure function,
  accepts an injected executor). Returns `{stale: true, version}` when
  `@gsd-build/sdk` is in the top-level dependency tree; returns
  `{stale: false}` for every other input including executor throws,
  malformed JSON, missing keys, and null/undefined returns.
- New helper `formatStaleStandaloneSdkWarning(info)` — message names
  the package, version, the exact `npm uninstall -g @gsd-build/sdk`
  remediation command, and references the issue.
- Call site in `install()` for `isGlobal` runs. Spawns
  `npm ls -g @gsd-build/sdk --json --depth=0`, recovers the JSON
  attached to the non-zero-exit error (npm's "absent" signal),
  forwards to detectStaleStandaloneSdk, prints the warning if stale.
  Best-effort: any failure is swallowed so detection never blocks
  install.
- `GSD_SKIP_STALE_SDK_CHECK=1` opt-out for CI/test environments that
  need silence (also used by the install-side test below).

Regression test `tests/bug-3406-stale-sdk-shadow-detect.test.cjs`:
- 8 unit tests pinning every detectStaleStandaloneSdk path (exported,
  absent, present, executor-throws, malformed-JSON, no-deps-field,
  null/undefined, format).
- 1 install-side end-to-end test confirming that when the package is
  absent, the install run does NOT mention `@gsd-build/sdk` or `#3406`
  in stdout. Uses a per-test `npm_config_prefix` so the test never
  depends on the host's npm dependency tree.

Fixes #3406

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

* fix(3406): correct changeset pr field — 3406 was the issue number, not the PR

CodeRabbit caught that .changeset/fix-3406-detect-stale-sdk-shadow.md
referenced `pr: 3406` (the issue number) instead of `pr: 3641` (the
PR number). Per CONTEXT.md PRED.k329 changeset frontmatter pr: must
reference the pull request number.

Local: docker gsd-test-summary 11214/0 on plex2.

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

* fix(3406): two CR follow-ups on bin/install.js stale-shadow check

Round-2 CodeRabbit findings on PR #3641:

1. bin/install.js:7769 — GSD_SKIP_STALE_SDK_CHECK opt-out now matches
   only explicit "1" / "true" / "yes". The previous any-truthy check
   silently disabled the warning for `GSD_SKIP_STALE_SDK_CHECK=0` and
   `GSD_SKIP_STALE_SDK_CHECK=false`.

2. bin/install.js:10568 — detectStaleStandaloneSdk now gates stale=true
   on version === '0.1.0' (the known-bad shadow). Any newer published
   version is intentional and must not flag a "stale shadow" warning
   on every install. Added a regression test for non-0.1.0 versions
   (1.50.0-canary.0 and 2.0.0) returning stale:false.

Local: 11/11 in the bug-3406 test file + docker gsd-test-summary 11215/0
on plex2.

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-16 13:14:16 -04:00
Tom Boucher
cfbdf5f832 fix(3643): resolve full Claude model id under resolve_model_ids: true (#3648)
* fix(3643): resolve full Claude model id under resolve_model_ids: true

The SDK resolveModel handler bailed out of resolveRuntimeTier for
runtime: "claude" (config-query.ts:149) — the implicit/default runtime —
and fell through to return { model: alias } without consulting the
catalog when resolve_model_ids: true. Consumers received tier aliases
("opus" / "sonnet" / "haiku") instead of the full IDs the CJS resolver
produces (core.cjs:1348-1350 maps via MODEL_ALIAS_MAP).

Added a runtime === 'claude' && resolveModelIds === true branch that
calls resolveRuntimeTierDefault('claude', tier) from the shared
model-catalog so SDK and CJS paths derive Claude IDs from one source
of truth. model_overrides, phase-type tier override, and
resolve_model_ids: "omit" precedence unchanged.

7 new tests in sdk/src/query/config-query.test.ts cover:
- claude + resolve_model_ids:true × {budget, balanced, quality}
  × {gsd-executor, gsd-planner} → full Claude IDs
- claude + phase-type override → opus full id
- claude WITHOUT resolve_model_ids → still returns alias (regression)
- claude + resolve_model_ids:"omit" → still wins
- model_overrides[agentType] → still wins

Fixes #3643

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

* chore(3643): backfill changeset pr field with #3648

Per DEFECT.CHANGESET-PR-FIELD-DRIFT.fix-forward — the changeset was
authored with pr: 0 placeholder before the PR existed; now pinned to
the actual PR number.

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

* fix(3643): resolve full Claude model id when runtime is implicit

CodeRabbit caught that the resolve_model_ids:true branch at
sdk/src/query/config-query.ts only matched explicit runtime === 'claude'.
The resolveRuntimeTier bail-out at line ~149 treats empty/missing
runtime as implicit Claude, so projects without an explicit runtime
field fell through to the alias return — leaving callers that asked
for resolved model IDs with the tier alias instead of the full id
(e.g. 'sonnet' instead of 'claude-sonnet-4-6').

isClaudeRuntime = runtime === '' || runtime === 'claude' covers both
the explicit and implicit cases.

Local: docker gsd-test-summary 11201/0 on plex2.

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-16 13:14:12 -04:00
Tom Boucher
e090e91646 fix(3588)(security): clear production npm-audit advisories (#3642)
* fix(3588)(security): clear production npm-audit advisories

Before: 6 production advisories (1 high, 5 moderate) reported by
`npm audit --omit=dev` — fast-uri (high), @anthropic-ai/sdk,
express-rate-limit, hono, ip-address (moderate), all pulled in through
@anthropic-ai/claude-agent-sdk and @modelcontextprotocol/sdk.

After: `npm audit fix` bumped the lockfile-pinned transitive versions
to patched releases. No package.json edits — only package-lock.json
and sdk/package-lock.json. Production audit is clean on both:
`npm audit --omit=dev` → 0 vulnerabilities.

Regression test `tests/bug-3588-npm-audit-clean.test.cjs` runs
`npm audit --omit=dev --json` against root and sdk/ and asserts the
metadata vulnerability counts are zero across info/low/moderate/high/
critical. RED on origin/main (1 high + 5 moderate at root), GREEN after
the lockfile bumps. Skips gracefully when node_modules/ is absent so
fresh checkouts mid-`npm install` don't false-fail.

Fixes #3588

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

* fix(3588): npm audit harness throws on unexpected JSON shape

CodeRabbit caught that auditProductionVulns returned null both for
"node_modules missing → skip" AND for "unexpected JSON shape" — and
callers interpret null uniformly as skip, so a real audit harness
failure (npm changed output format, audit aborted before metadata
section, etc.) would silently no-op instead of failing the test.

null is now reserved for the skip signal only. Any other unexpected
shape throws with the cwd in the message so the test fails loudly.

Local: docker gsd-test-summary 11204/0 on plex2.

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-16 13:14:01 -04:00
Cristian Uibar
05316369ae fix(3583): normalize retired colon-form commands in generated Claude/Qwen/Hermes SKILL.md bodies (#3629)
* Add first-class grok runtime support (maps to ~/.agents); wire installer, runtime-homes.cjs and sync-skills; update Grok Build engine in local ~/.agents to latest; record session progress in discussion doc

* Normalize gsd colon references to hyphen in generated Claude SKILL.md bodies using the shared transformer. Fixes #3583.

* Refine #3583 implementation after review: cache command names, improve tests, clean up comments

* Harden gsd colon-to-hyphen transformer with bidirectional word boundaries and body-only regression guard

* Track quick-wins batch status and local session notes for #3583/#3579 handoff

* Port installer robustness (hoist copyLibDir + selective Codex hooks) from 3579 to make Codex tests pass on this branch. Fixes ReferenceError and prevents extra hook pollution in Codex installs.

* Restore #3583 transformer wiring and Codex .sh GSD_VERSION branch lost in 50ff8f17 port

Commit 50ff8f17 ('Port installer robustness from #3579') accidentally reverted:
- the top-level require of transformContentToHyphen/readGsdCommandNames
- the body normalization inside convertClaudeCommandToClaudeSkill
- the Codex hook loop's .sh branch with {{GSD_VERSION}} substitution

These were the actual #3583 fix and the Codex half of the #2136 invariant.
Failing tests fixed: bug-2808-skill-hyphen-name, claude-skills-migration #3583
case, bug-2136 Codex .sh substitution.

* Exempt 'sync-skills' slug from docs-parity check (skill dir name in path references)

gsd-sync-skills is an installed Claude skill name and a workflow file but
not a registered slash command. The docs-parity regex catches /gsd-sync-skills
from filesystem path references like ~/.agents/skills/gsd-sync-skills/ in
docs/discussions/grok-build-support-2026-05.md.

Adding to INTERNAL_COMPONENT_SLUGS matches the existing exemption pattern
for 'statusline', 'workspaces', 'graphify-update', etc.

* Restrict hooks/lib/ install to hook-enabled runtimes and managed allowlist

Codex/Copilot/Cursor/Windsurf/Trae/Cline already skip the hooks block but were still copying hooks/lib/ helpers, contradicting the downstream Codex comment. Gate the call on the same runtime check and pass GSD_HOOK_LIB_FILES so install scope matches the uninstall/manifest scope.
2026-05-16 13:09:58 -04:00
Cristian Uibar
7f8b5701bf Enforce documentation updates via lint:docs + PR templates (#3651)
* Enforce documentation updates via lint:docs + PR templates (#3213)

New scripts/lint-docs-required.cjs + Docs Required CI workflow fail any
PR whose changeset fragment is typed Added / Changed / Deprecated / Removed
without modifying at least one file under docs/.

Mirrors scripts/changeset/lint.cjs: pure evaluateLint({ changedFiles,
fragments, labels }) returning { ok, reason, triggering } over a frozen
LINT_REASON enum; CLI wrapper reads the PR diff and parses each touched
changeset fragment via the existing parseFragment helper.

Escape hatches:
- no-docs PR label (global)
- per-fragment <!-- docs-exempt: <reason> --> marker, all triggering
  fragments must carry it for the PR to pass

Fixed and Security fragments do not trigger the lint — bug fixes restore
documented behavior, they do not introduce new behavior to document.

PR templates (enhancement.md, feature.md) gain a Documentation checklist
section pointing at the which-doc-to-update matrix. CONTRIBUTING.md adds
a Documentation Updates section codifying that matrix, the English-canonical
language policy for docs/ and the root README, and the two opt-out routes.

Closes #3213

* Address Codex review: fail-closed on malformed fragments and strip docs-exempt marker from rendered release notes (#3213)

Two P2 issues caught by `codex review --base main`:

1) Malformed fragments could silently bypass docs enforcement. parseFragment
   would return ok:false on a triggering Added fragment with bad frontmatter
   and readFragmentsFromDisk dropped it, so evaluateLint saw no triggering
   fragments and passed. The changeset-required lint only checks fragment
   _presence_ not _validity_, so the assumed fallback did not catch it.

   Fix: readFragmentsFromDisk now returns { fragments, malformed }; evaluateLint
   accepts a malformed param and emits a new FAIL_MALFORMED_FRAGMENT verdict
   that outranks every OK path (including the no-docs label) — a parse failure
   must be fixed before docs lint can decide anything else.

2) The per-fragment <!-- docs-exempt: reason --> marker lived in the fragment
   body, so the existing changelog (serializeChangelog) and GitHub release-notes
   (formatBullet) serializers published it verbatim. Worse, both serializers
   append `(#NNNN)` to the body's last line — with the marker as the trailing
   line, the PR suffix attached to the hidden comment instead of the visible
   bullet.

   Fix: parseFragment now extracts the marker into a typed `docsExempt` field
   and strips it from `body`, so all downstream renderers produce clean output
   without remembering to strip. The regex is anchored to its own line (^...$
   with m flag) so inline mentions of the marker syntax in documentation
   (e.g. inside backticks) cannot accidentally exempt a fragment. Bounded
   character class [^\n>] keeps the regex linear-time.

Test additions:
- tests/lint-docs-required.test.cjs: FAIL_MALFORMED_FRAGMENT coverage,
  end-to-end "Added fragment with bad pr → malformed → fail-closed" regression
  test, updated readFragmentsFromDisk return-shape assertions, isExemptFragment
  now checks the typed docsExempt field rather than body content.
- tests/changeset-parse.test.cjs: extractDocsExempt extraction cases (with/
  without reason, case-insensitive, EMPTY_BODY when body is only a marker),
  inline-mention false-positive guard, real-marker-wins-when-also-inline test.
- tests/changeset-new.test.cjs: fragment shape now includes docsExempt: null.

CONTRIBUTING.md updated to clarify the "on its own line" requirement and the
parse-time stripping behavior. The bootstrap fragment cleaned up so its body
no longer contains a literal marker example that would have triggered the
false-positive case.

Full suite: 9696/9696 pass.

* CRLF-safe docs-exempt marker stripping (Codex review pass 2, #3213)

Second `codex review --commit` pass caught a CRLF regression in the
docs-exempt extraction added in the previous commit.

Repro: a Windows-authored fragment

  ---\r\ntype: Added\r\npr: 1\r\n---\r\nFeature.\r\n\r\n<!-- docs-exempt: x -->\r\n

would parse to body `Feature.\r\n\r\n\r` because:

  - The previous trailing-newline slice trimmed only `\n`, leaving `\r`.
  - DOCS_EXEMPT_RE was anchored with `$` only — in multiline mode `$`
    matches before `\n` but does not consume `\r`, so the marker line's
    trailing `\r` was left behind after replace.
  - The cleanup regex stripped trailing `\n` but not `\r`.

Net effect: serializeChangelog emitted

  - Feature.\r
  \r
  \r (#1)

— the `(#1)` PR suffix landed on a blank line instead of attached to
the visible bullet. Same bug surfaces in github-release-notes formatBullet.

Fix:
- DOCS_EXEMPT_RE: add `\r?` before `$` so the regex consumes the CR of a
  CRLF terminator. Switch reason character class from `[^\n>]` to
  `[^\r\n>]` so CRLF-authored reasons don't carry a trailing `\r`.
- extractDocsExempt cleanup: `[ \t\r]+$/gm` strips trailing `\r` on each
  line; `(?:\r?\n){3,}` collapses CRLF triple-blank-lines; `[\r\n]+$`
  strips every trailing line terminator (LF or CR).
- parseFragment trailing-newline slice: CRLF-aware — strips `\r\n` (2
  chars) before falling through to single `\n`.

Tests: two CRLF regression cases in tests/changeset-parse.test.cjs —
Codex's exact repro (end-to-end through serializeChangelog) plus the
no-marker CRLF passthrough case. Full suite: 9698/9698 pass.

* CRLF regression test asserts on parseChangelog IR not rendered text (Codex review pass 3, #3213)

Third `codex review` pass caught that the CRLF regression test added in
the previous commit asserted on serializeChangelog's rendered Markdown
via `out.split('\n')` + `assert.match`. That violates CONTRIBUTING.md's
"Prohibited: Raw Text Matching on Test Outputs" rule and the documented
serializer contract in `serialize.cjs`:

  > tests assert via round-trip (parse(serialize(ir)))
  > rather than by inspecting serialized text

Replace the regex check with the established `parseChangelog(out)`
round-trip and assert on the structured `{ body: 'Feature.', pr: 1 }`
bullet. This is also a stronger regression check than the substring
match: Codex's own probe in the review session confirmed the pre-fix
buggy body shape (`Feature.\r\n\r\n\r`) breaks parseChangelog's bullet
regex entirely (returns `bullets: []`), so the round-trip catches the
exact failure mode end-to-end.

Full suite: 9698/9698 pass.

* Address CodeRabbit findings: anchor link + require non-empty docs-exempt reason (#3213)

CodeRabbit's review on the PR caught two actionable issues, both quick wins.

Anchor link in PR templates pointed to a heading that does not exist. The
CONTRIBUTING.md heading "Documentation Updates — Update the Relevant Docs"
contains an em-dash, which GitHub strips entirely when generating anchor
slugs (it does NOT collapse to a hyphen). The actual anchor is
#documentation-updates-update-the-relevant-docs (single hyphen between every
word), not #documentation-updates--update-the-relevant-docs (double hyphen
where the em-dash was). Both feature.md and enhancement.md fixed.

The docs-exempt marker matched a bare `<!-- docs-exempt -->` with no reason,
which defeats the entire purpose of the escape hatch — the marker exists to
leave an audit trail explaining WHY a PR is exempt. Without a reason it is
a silent bypass.

Fix: DOCS_EXEMPT_RE now requires both the colon AND a non-whitespace first
reason character. Bare `<!-- docs-exempt -->`, empty `<!-- docs-exempt: -->`,
and whitespace-only `<!-- docs-exempt:   -->` are all rejected as if the
marker were not present (`docsExempt: null`). The lint then falls through
to its normal docs-required / no-docs-label checks.

`isExemptFragment` in the lint module tightened too — defense-in-depth: even
if a caller constructs a fragment with `docsExempt: ''` directly, it does
not count as exempt. The predicate now requires `typeof === 'string'` and
non-empty after trim.

Tests:
- changeset-parse.test.cjs: three new explicit-rejection cases (bare marker,
  empty reason, whitespace-only reason). Existing DOCS_EXEMPT_RE shape test
  extended with negative assertions for the same three forms.
- lint-docs-required.test.cjs: prior "empty reason still exempt" test
  inverted — empty/whitespace docsExempt now produces FAIL_DOCS_MISSING.
  isExemptFragment helper test extended with the same negative cases.
- CONTRIBUTING.md: clarified that the reason is required and non-empty.

Skipped CodeRabbit's third finding ("use `npm run lint:docs` in CI workflow
instead of `node scripts/lint-docs-required.cjs`") — the existing
changeset-required.yml uses the direct-node form for the equivalent
changeset lint, so the new docs-required.yml is convention-consistent.
Switching one without the other would create drift, and switching both is
out of scope for #3213.

Bootstrap fragment continues to extract cleanly under the stricter regex
(verified — `docsExempt` field still contains the full bootstrap reason).
Full suite: 9701/9701 pass.
2026-05-16 13:09:54 -04:00
Tom Boucher
90e60b4145 fix(3597): avoid cleanup EPERM when cwd is inside tmp test dir 2026-05-16 13:05:45 -04:00
Tom Boucher
6e68e19b3d chore: untrack CLAUDE.md (already in .gitignore as personal/local-only)
CLAUDE.md was committed before the .gitignore entry on line 4 took effect
(.gitignore only filters untracked files). Switching it to local-only as
intended by the existing ignore rule; file stays on disk for working
copies that already have it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:54:12 -04:00
Tom Boucher
5292bc5477 refactor(3597): extract captureConsole + toPosixPath to helpers; dedup makeTmp wrappers; CLAUDE.md MemPalace
## Helper deduplication (16 files)

Two genuinely-duplicated test helpers extracted to tests/helpers.cjs:

- captureConsole(fn) → {stdout, stderr} with ANSI strip + exception
  re-throw after console restore (preserves the #2775 CR contract).
  Removed from 6 bug-N test files (bug-2775, bug-2829, bug-3033,
  bug-3211, bug-3231, bug-3359) where the implementation was either
  byte-identical or trivially-different. installer-migration-install-
  integration's captureConsole has a different signature ({value,
  output}) and stays as-is.

- toPosixPath(p) → p with path.sep → '/'. Returns input unchanged if
  null/undefined for safe optional-chain composition. Replaces the
  local normalizePath in bug-3491-nested-git-worktree (the
  prune-orphaned-worktrees inline normalizeSlashes was already swapped
  for canonicalPath in the cluster-J batch).

## makeTmp/mkScratch wrappers (8 files)

Reduced each local wrapper from a 3-line fs.mkdtempSync(path.join(
os.tmpdir(), ...)) block to a 1-line arrow delegating to
createTempDir (already in helpers.cjs). Bug-number prefix conventions
stay local for readability:

  - bug-2256, bug-2794, feat-3023, feat-3024 → `gsd-<bug>-${prefix}-`
  - bug-3288 → makeTmpDir = createTempDir (identity)
  - bug-3571 → 'gsd-3571-' (no parameter)
  - feat-3595 → mkScratch = `fs-fault-${name}-`
  - project-root-generator → 'gsd-parity-'

bug-3288 also collapsed local rmTmpDir into `cleanup` from helpers
(removes a separate inline rmSync site).

## CLAUDE.md — MemPalace protocol

Adds explicit instruction to call mempalace_status at session start
and mempalace_search / mempalace_kg_query before answering questions
about people, past work, or prior decisions in this project.

## Why this is a real consolidation

Initial survey suggested the bug-N test files could be parameterized
into one install-end-to-end.test.cjs — that turned out to oversell the
savings (~200 LOC on 2238) and to bury per-bug fixture context in a
table. The genuinely duplicated surface was the helpers themselves:
captureConsole copy-pasted ~6×, makeTmp variant copy-pasted ~8×.
Extracting them retires ~170 LOC of pure copy-paste without changing
any test semantics.

Validated: holodeck (ubuntu docker) 11232/0 pass; ratchet guard
still 7/7 at baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:52:46 -04:00
Tom Boucher
ca3be82f71 fix(3597): clear residual Windows test failures + add ratchet lint guard
Two more diagnostic passes (clusters J: residuals in already-touched files,
K: 12 untouched files) plus a production-code path fix and a new
ratchet-style lint guard.

## Test-only fixes (14 files)

bug-1736, bug-2248, bug-2698 — replace inline 1s-budget rmSync with the
shared cleanup() helper (5s budget, 20×250ms retries). The earlier
inline maxRetries:10 / retryDelay:100 wasn't enough to absorb Windows
Defender's deferred-scan handle hold on cold runners.

bug-2256, skill-manifest — also override USERPROFILE alongside HOME in
beforeEach/runGsdTools calls. os.homedir() reads USERPROFILE on win32,
so HOME-only stubs leak the runner's real home into the SUT.

bug-2784, bug-3608, enh-2500, enh-2790, few-shot-calibration,
gsd-settings-advanced — CRLF tolerance: literal \n in regexes against
file content (frontmatter anchors, bash-fence regex, multi-line
numbered-list captures, awk-block extractors) becomes \r?\n; split('\n')
becomes split(/\r?\n/). Windows checkout with autocrlf=true puts \r
before every \n; .+ doesn't match \r in JS regex by default.

bug-2966 — three-part fix to extractStepRun (CRLF split), awk regex
(\r?\n), and conflict-marker parser (rawLine + \r$ strip).

bug-2969, config — normalize separators on test assertions where the
SUT correctly emits \ on win32 but the test compares against /.

prompt-injection-scan — normalize relPath via replace(/\\/g, '/') before
ALLOWLIST.has() lookup. ALLOWLIST keys are POSIX; path.relative returns
backslashes on win32 → falsely scans allowlisted security module → trips
the boundary-tag detector on its own legitimate detection code.

prune-orphaned-worktrees — use the existing canonicalPath +
listedWorktreePaths(repoDir).has(...) helpers instead of substring
matching the raw path. git stores long-form canonical paths
(runneradmin), but mkdtempSync returns 8.3 short-form (RUNNER~1) on
Windows runners; plain string compare misses every entry.

## Production-code fix (1 file)

get-shit-done/bin/lib/init.cjs — bug-3491 in_nested_subdir computation
canonicalizes both worktreeRoot and cwd via fs.realpathSync.native +
path.relative before declaring "nested." Windows runner cwd (8.3 short
name) vs git's --show-toplevel (long form, forward slashes) made the
raw string compare always say true even at the worktree root, breaking
the "init new-project at worktree root" subtest.

## New ratchet lint guard

tests/windows-test-parity-guard.test.cjs — scans tests/ for 7
anti-patterns that drove the Windows failure clusters. Each rule has a
baseline count snapshot from this PR; the test fails when a new
occurrence appears (count grows above baseline), ratcheting down as
existing offenders are fixed. Patterns covered:

  G1 split('\n') after readFileSync (use /\r?\n/)
  G2 ```bash\n fence regex (use ```bash\r?\n)
  G3 ^---\n frontmatter anchor (use ^---\r?\n)
  G4 hardcoded "/tmp/..." literal passed to fs.* (use os.tmpdir())
  G5 bare 'npm' to execFileSync without {shell:true} on win32
  G6 process.env.HOME stub with no USERPROFILE
  G7 fs.rmSync({recursive,force}) without maxRetries

Future Windows-parity regressions get caught at PR time rather than
five iterations into a CI loop.

Validated: holodeck (ubuntu docker) 11232/0 pass (count +8 = the 7
new ratchet tests + parent describe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:39:02 -04:00
Tom Boucher
3a98ce83e5 fix(3597): windows parity batch — sdk/install/hook/worktree clusters (21 files)
Three parallel diagnostic agents (G: sdk/install path-sep, H: hook scripts,
I: worktree/workspace) characterised the remaining windows-22 failures on
23b52f1a. Patches by class:

CRLF in test parsers / file-content reads
  - bug-2136-sh-hook-version: shebang check split('\n') → split(/\r?\n/)
  - bug-3542-executor-git-stash-prohibition: strip \r from read content
    (git rewrites stashed text with CRLF on win autocrlf=true checkout)
  - workspace: BOM-strip + explicit \r strip in parseCommandFile (BOM at
    byte 0 defeats /^---/ anchor → fmMatch null → fm.name undefined)

Windows path-separator / 8.3-shortname normalization
  - bug-3491-nested-git-worktree: use fs.realpathSync.native to expand
    %TEMP% RUNNER~1 → runneradmin; normalize sep before path-equality
  - prune-orphaned-worktrees: normalize \\→/ before substring includes
    (git emits forward-slash in --porcelain on Windows even when
    path.join produced backslashes)
  - bug-3017-codex-hook-absolute-node: accept POSIX path OR path with
    drive-letter prefix in hookPath equality assertion
  - bug-3126-global-skills-base-runtime-path: use path.join for expected
    /xdg/<runtime> values (production calls path.join → \xdg\… on win32)

Test under-specified platform / forgot win32 env
  - bug-2979-hook-absolute-node: pass {platform:'linux'} to
    buildHookCommand + rewriteLegacyManagedNodeHookCommands so the
    POSIX-branch tests don't pick up the #3393 GitBash code path
  - bug-3288-model-catalog + bug-3571-config-manifest: also set
    USERPROFILE alongside HOME so os.homedir() on win32 redirects to
    the test fixture instead of the runner's real ~

External-cmd resolution
  - bug-2647-outer-tarball-sdk-dist: use npm.cmd + {shell:true} on win32
    so execFileSync resolves PATHEXT (literal `npm` is ENOENT)

ESM loader: tests/runtime-bridge-sync-smoke.test.cjs already migrated to
pathToFileURL in 23b52f1a (cluster E).

Explicit per-test/describe skip on win32 (with required string reasons
to satisfy no-unconditional-win32-skip guard)
  - feat-3347-graphify-auto-update-hook: 3 describes — harness spawns
    bash/kill/sleep + the hook itself is bash
  - feat-3595-fs-fault-injection: move \t and \n filenames into the
    POSIX-only branch (NTFS forbids 0x00–0x1F in filenames)
  - bug-2775/2829/3033/3231/3359: POSIX shim under ~/.local/bin with
    chmod 0o755 — not how Windows install works
  - bug-3211: single subtest where cp.execSync reassignment isn't
    picked up on win32 (POSIX coverage via the mock; live windows
    behavior covered by 3211-D which keeps running)
  - install-path-detection: parses sh-style export PATH= rc files;
    Windows has no rc files (registry Path)
  - worktree-safety-policy: single test using POSIX /repo/wt fixture
    paths that can't be expressed under win32 path.resolve

Validated: plex2 (ubuntu docker) 11224/0 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:16:20 -04:00
Tom Boucher
23b52f1a14 fix(3597): windows test parity batch — CRLF parsers, ESM file URLs, posix-tmp, bash-only skips, retry bump
Six independent windows-only failure clusters identified from the
windows-22 CI log on 1be0e4e2:

- scripts/command-contract-helpers.cjs: parseFrontmatter split on `\n`
  → on Windows checkout (autocrlf=true) every line carries trailing \r,
  lines.indexOf('---', 1) returns -1, all fields read as missing. Drove
  the bulk of three "67 subtests failed" suite-level errors covering
  workstreams.md, workspace.md, verify-work.md, add-tests.md, etc.
  Fix: split(/\r?\n/).

- tests/enh-3271-sdk-adr-structure.test.cjs: same CRLF pattern — H2
  captures pulled '\r' into headings like "decision\r", breaking
  equality checks on "## Decision" / "## Consequences". Fix: same.

- tests/runtime-bridge-sync-smoke.test.cjs: await import(BRIDGE_PATH)
  passed a Windows absolute path to Node's ESM loader, which rejects
  with "Only URLs with a scheme in: file, data, and node are
  supported." Fix: wrap once at module scope with pathToFileURL.
  pathToFileURL is a no-op for POSIX absolute paths.

- tests/bug-2957-claude-global-postinstall-message.test.cjs: hardcoded
  '/tmp/gsd-test-settings.json' resolved to D:\tmp\... on Windows
  where the parent dir doesn't exist → ENOENT on fs.writeFileSync
  inside finishInstall. Fix: os.tmpdir() + pid suffix.

- tests/bug-2774-worktree-cleanup-workspace-safety.test.cjs: the
  "while/read loop" subtest and the "end-to-end against real git
  worktrees" describe both assert POSIX shell behavior (process
  substitution `< <(...)`, RUNNER~1 8.3-shortname mismatch). Skip on
  win32 with explicit reasons (satisfies the
  no-unconditional-win32-skip guard).

- tests/bug-2838-summary-rescue-gitignored-planning.test.cjs: entire
  describe extracts bash rescue blocks from workflow .md files and
  runs them; the shell contract itself is the test's point. Skip on
  win32 with reason.

- tests/helpers.cjs cleanup(): bumped rmSync retry budget from
  10×100ms to 20×250ms — 1s wasn't enough for Windows Defender's
  deferred handle release; bumping to 5s should absorb the residual
  EBUSY failures observed in bug-1736 / bug-2248 / bug-2698 after the
  first retry bump landed in 1be0e4e2.

Validated: holodeck (ubuntu docker) 11224/0 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:03:44 -04:00
Tom Boucher
aac6d3635b fix(3597): make markdown-fence regex CRLF-tolerant in 3 windows-failing tests
bug-2801, bug-3072, bug-3321 each read .md workflow files from disk and
parse fenced code blocks with /\`\`\`bash\n(...)\`\`\`/ — on a Windows
checkout with autocrlf=true the file content is CRLF, the regex never
matches, code blocks come back empty, and the assertion fails with
"expected bash code blocks in workflow" (and similar).

Fix: accept optional \r before each literal \n in the fence delimiter
and split lines on /\r?\n/. Same pattern bug-2839 had with its local
parseFrontmatter copy (already migrated to the CRLF-aware shared helper
in 1be0e4e2). bug-2995 + tests/security-scan.test.cjs also match this
grep but use \n in literal-string fixtures (not file-content regexes);
not affected.

Validated: holodeck (ubuntu docker) 11224/0 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:55:55 -04:00
Tom Boucher
1be0e4e2ee fix(3597): make windows test cleanup retry on EBUSY + drop CRLF-broken local parseFrontmatter
Two windows-only failure clusters surfaced when the chunking fix in
52f23ac0 made Windows tests actually run:

1) Cleanup EBUSY race. On Windows, fs.rmSync without {maxRetries,retryDelay}
   races against AV scanners / file-indexers / just-exited child processes
   that still hold handles when teardown fires. Surfaces as
       EBUSY: resource busy or locked, rmdir 'C:\Users\...\AppData\Local\Temp\gsd-...'
   in bug-1736, bug-2248, bug-2698, bug-2838, and every test routing through
   the shared tests/helpers.cjs cleanup() (≈170 consumers — bug-2774 et al).
   Fix: add {maxRetries: 10, retryDelay: 100} to the shared helper plus the
   four inline cleanup sites. On POSIX the retry loop is dead code (first
   try succeeds), so no impact on ubuntu/macos.

2) bug-2839 had a local parseFrontmatter that anchored on /^---\n/ — on a
   Windows checkout with autocrlf=true the file content is CRLF, the regex
   never matches, the function returns null, and the before() hook asserts
   "agent must have YAML frontmatter" → entire suite cancels. The shared
   tests/helpers.cjs parseFrontmatter is already CRLF-aware (split /\r?\n/);
   switch to it.

Validated: plex2 (ubuntu docker) 11224/0 pass post-patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:49:26 -04:00
Tom Boucher
f8eda5bf16 fix(3597)(3347): write graphify rebuild lock in parent hook to close ENOTEMPTY race
The hook double-forked the rebuild subprocess and returned before the
subprocess wrote .planning/graphs/.rebuild.lock. Callers (notably the
feat-3347 test cleanup) waited for the lock to disappear before
rm -rf'ing the tmpdir, but an absent lock was ambiguous: it could mean
"subprocess finished and trapped lock removal" OR "subprocess hasn't
started yet." Under ubuntu CI load the second case won, cleanup raced
ahead, and rmSync walked into .planning/graphs while the subprocess
was still creating files — surfacing as
  ENOTEMPTY: directory not empty, rmdir '/tmp/gsd-3347-*/.planning/graphs'
on the "dispatches on: git commit -m fix" test.

Spawn the rebuild as a regular backgrounded job, capture $!, and write
the lock file synchronously in the parent before exit. Lock-presence
is now a reliable in-flight signal; the rebuild script's existing
trap-on-EXIT rm still owns cleanup.

Validated: holodeck (ubuntu docker) 11224 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:39:08 -04:00
Tom Boucher
7fa5eb7e63 fix(3597): resolve CR threads — drop substring-assertion guidance, use $testDir
Two CodeRabbit threads from PR #3649 review:

- docs/TESTING-SUITES.md:75 — removed the "stable message substring"
  fallback from the error-assertion guidance. Project rule (per the
  no-source-grep lint and lint-no-source-grep.cjs) is structured/typed
  checks only — err.code, JSON fields, enums. Substring matching
  re-introduces the exact prose-coupling we banned.

- scripts/run-tests.cjs:106 — the "no test files found" error now
  reports the resolved testDir variable instead of the hardcoded
  'tests/' string, so when GSD_TEST_DIR points elsewhere the message
  names the actual directory the harness searched.

Local: docker gsd-test-summary 11224/0 on holodeck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:44:51 -04:00
Tom Boucher
52f23ac0a0 fix(3597): chunk node --test spawn to survive Windows CreateProcess limit
Windows CreateProcess caps lpCommandLine at 32,767 chars. The original
`execFileSync(node, ['--test', ...546 paths])` exceeded that on every
Windows runner and exited within ~70ms with no test output. Linux/macOS
allow ~2 MB ARG_MAX so the same call worked there.

`scripts/run-tests.cjs` now splits selected files into chunks that keep
each spawn's argv under 28,000 chars (operator-overridable via
RUN_TESTS_MAX_CMDLINE_CHARS), runs them sequentially, and reports the
first non-zero exit. Cross-platform regression test forces chunking with
a low ceiling and asserts the `run-tests: chunk N/M …` stderr marker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:25:36 -04:00
Tom Boucher
e82876fe45 feat(3597): split test suites and add Node 22/24/26 OS matrix
scripts/run-tests.cjs gains `--suite <name>` filtering using a filename
suffix convention (`*.security.test.cjs`, `*.integration.test.cjs`, …).
Files with no marker are `unit` (the default fast lane); files with a
marker land in the matching suite. No `--suite` flag preserves the prior
behavior of running every test (backcompat for `npm test` and
`npm run test:coverage`).

New package scripts wire the suites to stable entrypoints:
test:unit, test:integration, test:install, test:security, test:slow,
test:coverage:unit, test:coverage:all. Unknown suite → exit 2 with the
list of valid suites; empty suite → exit 0 with a stderr notice so empty
lanes (e.g. `security` before adversarial tests land) don't gate CI.

CI matrix grows from `ubuntu × {22,24}` + a single macOS lane to
`{ubuntu, macos, windows} × {22, 24, 26}`. `fail-fast: false` so one
lane failure doesn't cancel siblings. Node 26 is `continue-on-error`
until actions/setup-node stabilises that image. PR CI runs unit +
integration + security on every cell; `install` and `slow` only on
`main` push. A dedicated `coverage` job runs `test:coverage:unit` on
ubuntu/Node 24 and uploads the report.

Grouping policy lives in docs/TESTING-SUITES.md with a pointer from
CONTRIBUTING.md. New harness test covers arg parsing, filter selection,
empty-suite behavior, and failure propagation.

Closes #3597.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:34:09 -04:00
Tom Boucher
beb7e05254 docs(3635): add v1.42.3 release notes and link from docs index (#3636)
New docs/RELEASE-v1.42.3.md follows v1.42.1's section layout
(hotfix-shaped: Fixed only, no Added/Changed). Headline is Codex CLI
0.130.0 install routability — after install, $gsd-* skills now
resolve under Codex 0.130.0+ where the previous build left zero
routable entrypoints. Twelve user-visible fix categories covered:

  - Codex CLI 0.130.0 install materializes routable skills (#3562, #3582)
  - Runtime-aware slash formatter (#3584)
  - Argv-based subprocess for check.ship-ready (#3587)
  - phase_status field + /gsd:plan-phase closed-phase gate (#3569)
  - Canonical [features].hooks for Codex configs (#3566)
  - SDK bridge loads via public package export (#3567)
  - W006/W007 skip archived and future phases (#3559, #3560)
  - Ultraplan gates on Claude Code markers (#3561)
  - Padded phase IDs match unpadded ROADMAP prose (#3537)
  - Prompt-user migrations resolve in non-TTY runs (#3541)
  - Executor agents forbidden from git stash (#3542)
  - Configuration manifests load from installed payload (#3571)

docs/README.md updates:
  - Documentation Index gets a row for v1.42.3 above v1.42.1
  - Quick Links "What's new" lists v1.42.3 first, then v1.42.1

Codex CLI 0.130.0 minimum-version note added to the Installing
section so operators upgrading to 1.42.3 know the Codex dependency.

Closes #3635

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:42:41 -04:00
Tom Boucher
418b361a99 test(3595): filesystem fault-injection for platformWriteSync atomic-write seam (#3634)
* test(3595): filesystem fault-injection for platformWriteSync atomic-write seam

Per CONTRIBUTING.md §"QA Matrix Requirements / Filesystem writes and
installers", adds adversarial coverage against the canonical write
seam every CJS config/state/generated-artifact writer routes through —
`platformWriteSync` in get-shit-done/bin/lib/shell-command-projection.cjs.

Fault matrix exercised via node:test mock.method() on real fs seams,
with t.after() restoring mocks so failures don't leak between tests:

  - happy path baseline (atomicity + no orphan tmp file)
  - renameSync EXDEV → fallback path writes directly, tmp cleaned up
  - tmp writeFileSync ENOSPC → fallback writes directly
  - both tmp and fallback fail → fallback error propagates (PINNED:
    original cause is swallowed; open follow-up for .cause chaining)
  - mkdirSync EACCES → escapes unhandled (PINNED current behavior)
  - target path is an existing directory → typed errno code surfaces
    AND the directory is preserved
  - paths with spaces / Unicode / tabs / newlines (POSIX only for \n)
  - 25 sequential writes leave 0 tmp orphans (cleanup invariant)
  - platformEnsureDir is idempotent (no EEXIST throw)
  - platformEnsureDir EACCES propagates

Symlink-safety invariants (security-critical):

  - REPLACES a symlink with a regular file rather than following it —
    a planted symlink in .planning/ pointing at ~/.ssh/authorized_keys
    is NOT clobbered. Test pins this so a future refactor to
    fs.writeFileSync (which follows symlinks) is a visible regression.
  - Broken symlinks are replaced with the intended regular file.

Concurrent-write collision: a renameSync EBUSY on the in-flight write
must still produce a parseable, complete final file via the fallback —
never a half-written corruption.

13 new tests; total 192/192 pass when bundled with the pre-existing
state/config/worktree-safety suites (179 of theirs). Zero production
code changes — test-only PR.

Two known-open gaps deliberately NOT fixed in this PR (warrant separate
focused issues):
  - platformWriteSync fallback path swallows the original tmp-write
    error. Operator only sees the fallback's error when both fail.
  - platformWriteSync mkdirSync(dirname, recursive:true) error
    escapes without context — no message-wrapping or typed reason.

Closes #3595

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

* test(3595): use t.skip() for Win32 symlink gates + fix rename mock delegation

Two Codex review findings addressed:

1. Symlink tests previously used `if (process.platform === 'win32') return`
   which CI reports as PASS even though the test ran zero assertions.
   Replaced with `t.skip('symlinks on Win32 need admin'); return;` so
   CI correctly reports SKIPPED on Windows lanes. Applied to both the
   symlink-replace and broken-symlink tests.

2. The concurrent-collision test's rename mock referenced a
   non-existent `fs.renameSync.wrapped` property in its fallback
   branch — that path would silently no-op instead of delegating to
   the real renameSync. Capture the real `fs.renameSync` BEFORE
   installing the mock and call `originalRename.call(fs, src, dest)`
   in the fallback branch, matching the ENOSPC test's pattern.

Codex review on PR #3634.

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-16 00:40:34 -04:00
Tom Boucher
b1317633db test(3594): adversarial parser fixtures + frontmatter/roadmap matrix + property-style suite (#3633)
* test(3594): adversarial parser fixtures + frontmatter/roadmap matrix + property-style suite

Lands the adversarial parser-input corpus that CONTRIBUTING.md
§"QA Matrix Requirements / Parser and project-file inputs" and
TEST-EXAMPLES.md §"Parser Adversarial Fixtures" describe.

New tests/fixtures/adversarial/ layout:

  frontmatter/
    duplicate-keys.md            — same key twice (collapses last-wins)
    crlf-mixed.md                — CRLF endings throughout
    unclosed-block.md            — `---` open with no close
    unicode-keys-and-values.md   — non-ASCII + emoji + Greek
    null-byte-value.md           — U+0000 in a value
    huge-bounded.md              — 2000-item array, ~30KB

  roadmap/
    phase-heading-inside-fenced-code.md   — #2787 fence shadowing
    nested-fenced-code.md                 — outer + inner ``` blocks
    unicode-phase-titles.md               — JP / Greek / emoji titles
    repeated-phase-ids.md                 — phase 1 declared twice
    decimal-phase-mixed.md                — 2 vs 2.1 vs 2.10 vs 21
    markdown-headings-inside-html-comment.md — comment shadowing

Test files (all node:test, no try/finally in test bodies, no source-grep,
no raw-text matching on stdout/file content):

  tests/feat-3594-parser-adversarial-frontmatter.test.cjs (12 tests)
    Loads each fixture, pins parser invariants on extractFrontmatter()
    return shape. Cross-corpus "does not throw on any fixture" sweep.

  tests/feat-3594-parser-adversarial-roadmap.test.cjs (18 tests)
    Loads each fixture into a temp project's .planning/ROADMAP.md and
    drives `gsd-tools roadmap get-phase <N>` via the runCli harness
    introduced by #3593. Asserts on the typed JSON payload.

  tests/feat-3594-parser-property-style.test.cjs (2 tests)
    Deterministic mulberry32 PRNG generates 500 malformed-ish
    frontmatter inputs per test. Pins (a) extractFrontmatter is total
    over the corpus (no null-deref TypeError, always returns a plain
    object on success), (b) the suite completes well under 2 seconds
    (quadratic-regression guard).

Known-open bugs surfaced and pinned (intentionally NOT fixed in this
PR — separate issues warranted):

  - CJS roadmap parser matches `## Phase N:` headings inside fenced
    code blocks (the SDK parser tracks fences per the #2787 comment in
    sdk/src/query/roadmap.ts but the CJS path has not caught up).
  - CJS roadmap parser matches `## Phase N:` headings inside HTML
    comments.

Both are documented in-test with the "currently STILL matches it
(open: needs <fix>)" naming pattern so the day the production fix
lands, flipping the assertion from `found: true` to `found: false` is
the regression guard.

Test totals:
  - 32 new feat-3594-* tests (12 frontmatter + 18 roadmap + 2 property)
  - 108/108 pass when running together with the pre-existing
    frontmatter.test.cjs + roadmap.test.cjs suites (76 of theirs).

Closes #3594

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

* test(3594): use Fisher-Yates shuffle for deterministic seeded inputs

Replaces `arr.sort(() => rng() - 0.5)` with a Fisher-Yates shuffle
driven by the supplied PRNG. The sort-based shuffle is non-transitive:
V8's TimSort behavior on non-transitive comparators is engine-defined,
so the same seed produced different orderings across Node versions —
undermining the test's stated reproducibility guarantee.

Fisher-Yates is O(n), transitive (no comparator at all), and consumes
exactly n-1 RNG values in a fixed order. The mulberry32 seed now
determines the input sequence end-to-end.

Codex review on PR #3633.

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-16 00:23:27 -04:00
Tom Boucher
5583b81f35 fix(3628)(security): whitelist bundled hook filenames in classifier (#3630)
#3610 added a `bundled-gsd-hook` classifier to classifyPromptUserAction
that auto-removes blocked hook files on first-time-baseline scan. The
match shape was a regex `/^hooks\/gsd-[^/]+\.(?:js|sh|cjs|mjs)$/` that
matched ANY file under hooks/ named gsd-<name>.{js,sh,cjs,mjs}, not only
the 13 hooks the npm package actually ships. As a result the classifier
silently auto-classified — and the resolver auto-removed — user-authored
custom hooks (hooks/gsd-personal-experiment.js) and retired bundled hooks
from prior versions (hooks/gsd-old-statusline.js).

Evidence the maintainer was already working around this: 0862df15 (the
#3610 follow-up) renamed the integration-test fixture
hooks/gsd-retired-hook.js -> hooks/gsd-retired-hook.txt specifically to
dodge the classifier so the test could exercise the "explicit block" path
it was written for.

Fix: replace the shape regex with an explicit Set of the 13 shipped hook
filenames (BUNDLED_GSD_HOOK_FILES). Files outside the whitelist fall
through to the existing block-or-prompt flow so users retain control.

A regression guard (tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs)
fails CI if the whitelist drifts from the on-disk hooks/ directory in
either direction: whitelisted-but-missing OR shipped-but-not-whitelisted.
The latter check uses the SAME shape regex the buggy classifier used,
re-purposed as a lint that ensures every gsd-*-shaped file shipped in the
distribution IS in the whitelist.

Behavioural tests cover: every entry in BUNDLED_GSD_HOOK_FILES classifies
as bundled-gsd-hook -> remove; six user-owned / retired filenames return
null (proves the regression is fixed); nested hooks/gsd-*/ directories
still return null (the #3610 nested-directory boundary stays intact);
non-gsd hooks still return null (the user-hook-preservation boundary
stays intact).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:07:59 -04:00
Tom Boucher
037a49c9c2 test(3593): CLI negative-matrix harness + config family + universal sweep (#3627)
Adds the shared adversarial-input harness described in TEST-EXAMPLES.md
§"CLI Negative Matrix" and applies it across two layers:

  1. tests/helpers/cli-negative.cjs — runCli() wraps spawnSync of
     get-shit-done/bin/gsd-tools.cjs, prepends --json-errors by default,
     and returns a typed IR { status, ok, reason, message,
     hasStackTrace, ... } so adversarial-case tests assert on
     reason codes — never on stderr prose.

  2. tests/feat-3593-cli-negative-config.test.cjs — full 12-category
     matrix for the config command family (the highest-risk read/write
     surface): missing/empty/whitespace args, duplicate --cwd,
     unknown subcommand, value-looks-like-a-flag, corrupt config.json,
     50KB key, Unicode/emoji keys and values, and 9 distinct shell-
     metacharacter payloads asserted as NOT-executed via per-test
     sentinel-file probes.

  3. tests/feat-3593-cli-negative-universal.test.cjs — narrower
     cross-family sweep (phase, roadmap, state, config, workstream,
     init, validate). Pins the three universal invariants every
     family must satisfy: bare invocation does not crash with a V8
     stack trace, unknown subcommand emits a typed reason, and shell
     payloads as argv values are not executed.

  4. tests/feat-3593-cli-negative-harness.test.cjs — meta-test that
     pins the harness IR contract so a future regression in the
     parser (stack-trace detection, JSON shape extraction, hostile
     stderr handling) surfaces before it cascades through every
     matrix file.

Bug fix surfaced by the new tests:

  get-shit-done/bin/lib/config.cjs cmdConfigSet — invoking
  `config-set <key>` with no value silently returned exit 0 and
  emitted { updated: true } even though the value parameter was
  undefined. JSON.stringify dropped the key during the write or
  persisted a corrupt entry. Now rejected with typed ERROR_REASON.USAGE
  before any write. Matching guard added to SDK configSet for parity.

Harness coverage delivered: 58 new tests (9 meta + 26 config + 23
universal sweep). Pre-existing config suites (101 tests) all pass.
lint-no-source-grep clean.

Refs #3593

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:57:38 -04:00
Tom Boucher
90eb9e0f8b Merge pull request #3607 from gsd-build/feat/3592-test-rewrite-text-existence-checks-into-
test: rewrite alias coverage as behavioral contract
2026-05-15 23:11:34 -04:00
Tom Boucher
724133967a Merge pull request #3606 from gsd-build/fix/3584-runtime-bin-lib-cjs-emit-gsd-cmd-in-user
fix(3584): runtime-aware slash formatter for user-facing emissions
2026-05-15 23:05:37 -04:00
Tom Boucher
afbf0d80de Merge pull request #3609 from gsd-build/fix/3582-codex-0-130-0-gsd-1-42-2-installation-su
fix(3582): regression test for Codex install skill-materialization
2026-05-15 23:04:45 -04:00
Tom Boucher
2cf4c8e8ec Merge pull request #3611 from gsd-build/fix/3587-bug-security-check-ship-ready-shell-inje
fix(3587)(security): argv-based subprocess for check.ship-ready
2026-05-15 23:04:42 -04:00
Tom Boucher
38f5201935 Merge pull request #3613 from gsd-build/fix/3605-stale-slash-command-references-in-5-agen
fix(3605): scrub retired slash commands from agents/*.md
2026-05-15 23:04:39 -04:00
Tom Boucher
d4dbca5b49 Merge pull request #3614 from gsd-build/fix/3608-bug-antigravity-gsd-update-does-not-dete
fix(3608): model Antigravity as a first-class runtime in update.md
2026-05-15 23:04:35 -04:00
Tom Boucher
55e1360b2c Merge pull request #3616 from gsd-build/fix/3599-bug-roadmap-get-phase-no-longer-matches-
fix(3599): preserve project-code prefix when looking up roadmap phases
2026-05-15 23:04:32 -04:00
Tom Boucher
6bd4091aa0 Merge pull request #3618 from gsd-build/fix/3610-error-installing-v1-42-2-in-codex
fix(3610): unblock fresh Codex install when leftover bundled hooks present
2026-05-15 23:04:29 -04:00
Tom Boucher
9351d0a0e7 Merge pull request #3619 from gsd-build/fix/3601-bug-phase-remove-can-delete-following-de
fix(3601): preserve peer-depth decimal phases on integer phase removal
2026-05-15 23:04:26 -04:00
Tom Boucher
9fab09cdce Merge pull request #3620 from gsd-build/fix/3602-bug-phase-remove-leaves-stale-slugged-pl
fix(3602): renumber slugged plan references on phase removal
2026-05-15 23:04:22 -04:00
Tom Boucher
8c46c3f1af Merge pull request #3622 from gsd-build/fix/3600-bug-init-new-milestone-counts-prefixed-p
fix(3600): count project-code-prefixed phase dirs in milestone filter
2026-05-15 23:04:20 -04:00
Tom Boucher
afb0692522 Merge pull request #3624 from gsd-build/fix/3591-bug-gsdtools-native-runtime-drops-workst
fix(3591): forward workstream to native registry dispatch
2026-05-15 23:04:16 -04:00
Tom Boucher
8b31739a01 Merge pull request #3626 from gsd-build/fix/3589-bug-security-sdk-planningpaths-accepts-u
fix(3589)(security): validate workstream in relPlanningPath against traversal
2026-05-15 23:04:13 -04:00
Tom Boucher
66a429adf4 docs(3621): forbid bundling test-fixture updates into docs: commits (#3625)
Adds a Pull Request Guidelines bullet making explicit what v1.42.3
hotfix taught us the hard way: when a production change makes an
existing test assertion stale, the test correction must be its own
test: (or fix:) commit, not bundled into a docs: commit that also
explains the change.

The release-sdk hotfix cherry-pick filter routes by commit-subject
prefix (fix:, chore:, test: — see release-sdk.yml). A docs: commit
that hides a test fix is invisible to the picker. The result is a
half-shipped state on the hotfix branch: production code changed,
test assertion stale, CI red.

This is the upstream contributor-side mitigation. The picker-side
fix landed in PR #3623 (broadens the prefix filter and the
classifier's CI-gating-path detection); this PR documents the
upstream discipline that keeps the picker from getting fooled in
the first place.

Refs #3621

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:04:08 -04:00
Tom Boucher
11a6d3fdd6 test(3589): assert empty-string workstream fallback 2026-05-15 22:58:36 -04:00
Tom Boucher
8bc4148eea test(3582): annotate adapter text assertion for source-grep lint 2026-05-15 22:56:32 -04:00
Tom Boucher
6e9f8101da fix(3605): retire stale slash syntax and satisfy planner size gate 2026-05-15 22:56:32 -04:00
Tom Boucher
0862df15df fix(3610): replace resolved prompt actions and keep explicit block path 2026-05-15 22:56:32 -04:00
Tom Boucher
a2967bfa24 fix(3589)(security): validate workstream in relPlanningPath against traversal
`relPlanningPath(workstream)` previously called `posix.join('.planning',
'workstreams', workstream)` without validating the workstream argument.
Direct SDK callers — and `planningPaths` / `ContextEngine` which both
forward through `relPlanningPath` — could pass values like
`'../../../outside'`, `'foo/bar'`, or `'foo\\bar'` and route planning
operations outside the intended `.planning/workstreams/<name>` subtree.

The env-sourced workstream code path in `planningPaths` already validated
via `validateWorkstreamName` (line 444-445, pre-filtering to `null` on
failure per the #2791 silent-fallback contract). Explicit SDK arguments
had no equivalent gate.

Fix: validate inside `relPlanningPath` using the same shared
`validateWorkstreamName` policy. Every caller — direct SDK use,
`planningPaths`, `ContextEngine` — fails closed at the same seam.
Empty/undefined workstream still returns `.planning` for back-compat
(treated as "no workstream provided"); non-empty invalid names throw a
synchronous Error with the offending value in the message.

Env-sourced behaviour is unchanged: `planningPaths` continues to filter
invalid env values to `null` before they reach `relPlanningPath`, so the
silent-fallback path for malformed `GSD_WORKSTREAM` env still works.

Regression test
(sdk/src/bug-3589-planning-paths-validation.test.ts):

  - 9 traversal/invalid cases (.., /, \\, spaces, .hidden, /abs,
    -leading-hyphen) all throw with a `/workstream/i`-matching message.
  - Valid names (`frontend`, `api_v2`, `alpha.beta-1`) continue to
    produce the expected `.planning/workstreams/<name>` path.
  - `planningPaths('/tmp', '../../../outside')` rejects before path
    construction (proven via try/catch — resultPath stays null).
  - Valid workstream + `planningPaths` produces the expected subtree
    (`.planning/workstreams/frontend/STATE.md` etc.).
  - Omitted workstream still returns root `.planning` with no `workstreams`
    segment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:52:14 -04:00
Tom Boucher
ed4dfbea16 test(3591): assert native dispatch forwards projectDir/workstream 2026-05-15 22:44:25 -04:00
Tom Boucher
0efe4f0612 fix(3621): release-sdk hotfix cherry-picks test-fixture commits (#3623)
* fix(3621): cherry-pick test-fixture commits in hotfix runs

The release-sdk hotfix loop excluded test-fixture updates that align CI
with a cherry-picked production fix, leaving the hotfix branch with new
production behavior and stale test assertions. Broke v1.42.3 CI (run
25949422676) when fix(3562) was cherry-picked but its bundled test
correction in docs(3562) commit 08848df8 was POLICY_SKIPPED by the
prefix filter.

Two-part fix:

1. release-sdk.yml prefix regex now accepts test: alongside fix:/chore:.
   feat:, docs:, refactor: still POLICY_SKIPPED as before.

2. scripts/diff-touches-shipped-paths.cjs treats tests/-rooted paths and
   sdk/src vitest specs as CI-gating-equivalent. A test: commit touching
   only those paths now passes the shipped-paths gate.

The #2980 push-blocking guard is preserved as a separate first-priority
check: any commit touching .github/workflows/<file> still skips
regardless of test paths in the same bundle, because the default
GITHUB_TOKEN lacks the workflow scope and the push step would fail.

New regression coverage in tests/bug-3621-cherry-pick-test-fixtures.test.cjs:
- workflow prefix regex includes test:
- isCiGating accepts tests/ and sdk/src vitest specs, rejects
  non-spec sdk/src paths and incidental "test" name occurrences
- classifier exits 0 for test-only, mixed test+docs, and the original
  shipped paths
- classifier exits 1 for pure docs-only and workflow-only diffs
- new explicit assertion that #2980 push-blocking wins over #3621:
  workflow + test + changelog bundle still skips

Adjusted one pre-existing bug-2980 test fixture to use a non-
push-blocking non-shipped path (planning/notes.md) instead of
.github/workflows/release-sdk.yml. The original assertion was
documenting "mixed diff includes shipped path → include" but its
fixture happened to also trigger the push-blocking guard now made
explicit by this PR.

Fixes #3621

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

* fix(release-sdk): align hotfix summary labels with test matcher

* fix(3621): align operator-facing strings with the fix/chore/test matcher

The candidate-loop regex was updated to accept test: but several
human-facing strings in the same job still read fix/chore. Update every
description/comment/summary line for consistency so operators reading
the run summary or workflow_dispatch inputs see the same set of accepted
prefixes the matcher actually applies.

Also corrected the NON_SHIPPED_SKIPPED summary text — it claimed test
changes belong on main, not in a hotfix. That assumption is what #3621
fixes; tests under tests/ and sdk/src vitest specs are now CI-gating
candidates and may be picked. The summary now scopes the never-pick
guidance to CI / docs / planning paths only.

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-15 22:43:21 -04:00
Tom Boucher
ab51253370 fix(3599): treat prefixed phase headings as section boundaries 2026-05-15 22:40:18 -04:00
Tom Boucher
06da35a95a fix(3601): stop phase removal at non-numeric peer headers 2026-05-15 22:40:18 -04:00
Tom Boucher
4824aefe42 fix(3591): forward workstream to native registry dispatch in GSDToolsRuntime
createGSDToolsRuntime accepted opts.workstream and forwarded it to the
QuerySubprocessAdapter (line 38) but the QueryNativeDirectAdapter's
dispatch closure dropped it:

  dispatch: (registryCommand, registryArgs) =>
    registry.dispatch(registryCommand, registryArgs, opts.projectDir)

`registry.dispatch(command, args, projectDir, workstream?)` accepts a
4th workstream argument and forwards it to handlers. When a GSDTools
instance was created with a workstream, the native fast-path silently
routed planning-path queries to the root `.planning/` tree instead of
`.planning/workstreams/<name>/`. Subprocess dispatch correctly carried
the workstream; native dispatch did not — runtime-bridge mode parity
broke for any workstream-aware GSDTools consumer using the native path.

One-line fix: pass opts.workstream as the 4th arg to registry.dispatch.

Regression test exercises three paths:

  1. Constructor-seam unit test: spy on QueryNativeDirectAdapter,
     capture the dispatch closure, verify it reaches a registry that
     reports the unknown-command error message.
  2. Back-compat: same with workstream omitted — closure still reaches
     the registry.
  3. End-to-end: spy on createRegistry to inject a probe registry with a
     registered handler that records its args. Invoke through
     runtime.bridge.dispatchHotpath(). Assert the handler observed
     workstream='frontend-ws' as its 3rd arg.

RED verified: end-to-end probe fails on pre-fix tree with
`expected undefined to be 'frontend-ws'`. GREEN after the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:35:13 -04:00
Tom Boucher
55e50cf392 fix(3600): count project-code-prefixed phase dirs in milestone filter
`init.new-milestone` reported `phase_dir_count: 0` for projects whose
phase directories carry a project_code prefix (`.planning/phases/CK-01-name`)
when the ROADMAP used numeric `### Phase N:` headings. Verified via a
temp-project repro that mirrors the reporter's setup.

Root cause: `getMilestonePhaseFilter` builds an `isDirInMilestone(dirName)`
predicate that tries two paths:

  1) Numeric — requires the dir name to START with a digit. `CK-01-name`
     starts with `C`, so this skips.
  2) Custom-ID — captures the leading kebab token (`CK-01-name` as a
     whole) and compares it to the normalised milestone phase IDs
     (`{"1"}`). No match.

There was no path that stripped the project_code prefix before retrying
the numeric match. Added a third path that strips the same shape
`normalizePhaseName` already recognises (`^[A-Z]{1,6}-(?=\d)`) and retries
the numeric match. This runs AFTER the custom-ID path so a ROADMAP that
uses `### Phase PROJ-42:` continues to win via the custom-ID match for
a `PROJ-42` directory; the new branch only fires when the milestone is
keyed on the bare numeric form.

The fix lands in both:

  - get-shit-done/bin/lib/core.cjs:isDirInMilestone (active CJS runtime)
  - sdk/src/query/state.ts:isDirInMilestone (SDK twin)

`getMilestonePhaseFilter` is shared by multiple callers — init.new-milestone,
phase complete, verify-work, validate-health — so the fix benefits every
caller that walks `.planning/phases/` against a numeric ROADMAP.

Regression test
(tests/bug-3600-milestone-phase-filter-project-code-prefix.test.cjs):

  1. Reporter's case: CK-01-name + CK-02-build dirs against Phase 1 / 2
     headings → phase_dir_count === 2.
  2. Existing contract: 01-first dir against Phase 1 heading still counts.
  3. Custom-ID contract: PROJ-42 dir against `### Phase PROJ-42:` still
     counts via the existing custom-ID match (no regression).
  4. Counter-test: CK-99-backlog and CK-100-future dirs MUST NOT count
     against a milestone with only Phase 1 — the strip-and-retry must
     still respect the milestone's actual phase set.

All assertions go through `init new-milestone --json` (typed payload —
`phase_dir_count`). No raw text matching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:28:49 -04:00
Tom Boucher
99567d8eb5 fix(3602): renumber slugged plan references on phase removal
phase.cjs:updateRoadmapAfterPhaseRemoval renumbers plan references in
ROADMAP.md via a regex that captured `NN-NN` followed by an optional
suffix:

  /(?<![0-9-])(\d{2})-(\d{2})(?=(?:-(?:PLAN|SUMMARY)\.md)?(?![0-9-]))/g

The suffix branch was strict: it only accepted `-(PLAN|SUMMARY).md`
directly after the plan number. A slug like
`07-01-cherry-pick-foundation-PLAN.md` placed `-cherry-…` between the
number and the canonical suffix, so both the suffix branch AND the
"bare token" branch (`(?![0-9-])` — fails because the next char is `-`)
failed. Result: the on-disk file got renamed to
`06-01-cherry-pick-foundation-PLAN.md` by the directory-rename pass,
but the ROADMAP entry kept pointing at the stale `07-01-…` prefix —
disk/ROADMAP inconsistency.

Fix: extend the suffix branch to allow an optional kebab-case slug
between the plan number and the PLAN/SUMMARY suffix:

  (?:(?:-[A-Za-z][A-Za-z0-9-]*)*-(?:PLAN|SUMMARY)\.md)|(?![0-9-])

Each slug token must start with a letter so `07-01-02-PLAN.md` is not
silently consumed as one slugged token (the `-02` is correctly
unreachable from the slug branch because it starts with a digit).

Regression test exercises three cases via the typed `roadmap get-phase
--json` query (no raw text matching on ROADMAP.md content):

  1. Slugged PLAN + SUMMARY filenames get renumbered (#3602 fix).
  2. Compact `NN-NN-PLAN.md` filenames still renumber correctly
     (#3601 / earlier contracts preserved).
  3. Counter-test: ISO dates (`2026-01-01`) and version tags (`v1-2-3`)
     in ROADMAP prose are NOT modified — the existing `(?<![0-9-])` /
     `(?![0-9-])` boundaries hold against false positives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:23:46 -04:00