Commit Graph

2752 Commits

Author SHA1 Message Date
0xdhx
285cd41be0 fix(#3206): define "explicit evidence" inline at verifier 5b; repair stale honest-verifier cites (#3435)
* fix(#3206): define "explicit evidence" inline at verifier 5b; repair stale honest-verifier cites

Step 3 item 5b abstained on non-inferable (backstop) truths "unless
confirmed by explicit evidence" with the term undefined — its definition
lived only in the non-included gsd-core/references/honest-verifier.md,
behind a stale bare `references/` cite that 404s. Undefined, the term
falls back to presence + wiring, the exact false-pass the #1154
abstention protocol refuses.

- 5b: inline the compressed definition (a passing wired
  held-out/property-based test or directly observed behavior; presence +
  wiring never qualifies) and fix the cite. +84 B on the rewritten line;
  file lands at 49,151 of the 49,152 LARGE cap.
- verifier-phase-gates.md (already <required_reading>): gains the
  backstop-abstention reporting contract — AFK completion line
  ("complete with N unverified non-inferable checks", never silent,
  never a halt) and reason-distinctness (insufficient_spec vs manual-UAT
  human_needed). New content, no relocation of measured prose.
- 5c (line 204) and MVP-mode (line 644) bare cites repaired to
  gsd-core/references/ (+9 B each).
- Drift acks per ADR-2719 §4; two entries merge-appended into existing
  fragments (two ack sources may never name the same path).
- Changeset fragment with the sanctioned pr: 0 placeholder (post-create
  backfill).

Sibling census at next@7976b1ca0: 7 bare-cite instances in 4 agent
files; the 3 in gsd-verifier.md are fixed here, gsd-executor.md:429,439
and gsd-doc-synthesizer.md:20,176 stay with the epic #1891 follow-up.

Refs #1891

* chore(#3206): set changeset fragment pr to 3435

* fix(#3206): drop stale emitted-drift-ack entries that trip the ADR-2719 ratchet

The round's ack bookkeeping explained ripples that were already
self-attributed, so `tests/emitted-attribution.test.cjs` failed
deterministically on the PR head with 5 stale acknowledgments.

`agents/gsd-verifier.md` and `gsd-core/references/verifier-phase-gates.md`
appear directly in `git diff --name-only`, so PROVENANCE_RULES attributes
their emitted deltas without an ack; `agents/gsd-verifier.agent.md`,
`agents/gsd-verifier.toml` and `agents/subagents/gsd-verifier.md` are
derived emissions of a changed source and are attributed the same way.
None of the five entries could ever be consumed, so all five were stale.

Removed: the whole `3206-verifier-explicit-evidence.json` fragment (all
four entries) and the `#3206 append` to `0000-legacy-migration.json`.

Deliberately KEPT: the `#3206 append` to
`1955-verifier-coincidental-reliance.json`. Its `gsd-verifier.md` entry is
consumed by the size-growth ratchet, not the hash pass — the agent grew
49049 -> 49151 bytes, and `diffEmitted` treats a base-identical ack as
spent and excludes it from `ackEntries`. Reverting that append as well
turns the stale-ack failure into `1 file(s) grew without an
acknowledgment` (verified both ways locally).

* fix(#3206): compress 5b and re-acknowledge growth after rebase onto next

The rebase onto next (159145435, PR #3558) invalidated two things at once:

- #3558 grew agents/gsd-verifier.md to 49,098 bytes, leaving 54 bytes of
  LARGE-cap headroom where this PR's +102 no longer fits. Compressed the
  5b rewrite to +34 net by dropping the trailing honest-verifier cite —
  superseded by the now-inline definition; honest-verifier.md stays
  cited at the adjacent 5c line. File lands at 49,150 (2 under the cap).
- #3558 deleted tests/emitted-drift-acks/1955-verifier-coincidental-
  reliance.json, which carried this PR's growth acknowledgment, and its
  own 3409 fragment now names gsd-verifier.md. Re-homed the #3206 growth
  ack as an append to that entry (two ack sources may never name the
  same path).

The changeset is updated to match: two bare references/ cites repaired
(5c honest-verifier.md, MVP-mode verify-mvp-mode.md), the third (5b's)
superseded by the inline definition rather than repaired.

Reversion controls: restoring the uncompressed 5b fails
agent-size-budget.test.cjs (LARGE hard cap); reverting the ack append
fails emitted-attribution.test.cjs (differential attribution over the
real tree, GSD_EMITTED_BASE=upstream/next). Both re-verified green at
this tree: 213/213 (size + attribution), 992/992 across the 14 suites
reading the touched files.

Refs #3206

* test(#3206): pin the 5b explicit-evidence definition and cite resolution

* fix(#3206): pin regression tests to the shipped contract text

---------

Co-authored-by: sim <sim@local>
2026-08-16 14:14:27 -04:00
Tom Boucher
3c61b4a838 enh(#3565): sentinel/contract registry + check:contract-drift lint (#3571)
* enh(#3565): sentinel/contract registry + check:contract-drift lint

* fix(#3565): report artifact-row markers once and dedupe per marker

* docs(#3565): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-16 12:59:08 -04:00
Tom Boucher
a4a02a7a01 enhance(#2874): return the executed plan and route install IO through a seam (#3568)
* test(#2874): add failing-first gate for the executed-plan return

Four rows from the matrix's red-first order. E3 pins the one early return,
for the opencode family, where a void-shaped hole would otherwise survive
unnoticed. E13 sweeps every runtime in the registry - enumerated from the
registry rather than hardcoded, so a runtime added later cannot slip past.
F2 proves absence of real filesystem contact rather than merely that the
happy path ran, which is the difference between a complete seam and a
partial one.

G1 and G3 are the additive guard and must be green before and after. G3
deliberately leaves the two existing adapter test doubles untouched: if
this change required editing them it would not be additive, and the
acceptance criterion would be unmet.

No production code. All 19 runtimes install without throwing today, so
E3 and E13 fail on the undefined comparison alone.

Refs #2874

* feat(#2874): return the executed plan and route install IO through a seam

installRuntimeArtifacts returned void, so its correctness was observable
only by re-reading disk. It now returns what it executed - per kind, per
scope - including on the combinedFamilyInstall path, which was the one
early return where a void-shaped hole would have survived unnoticed.

Failure still throws rather than becoming an ok:false return, so control
flow is unchanged for both existing callers. A best-effort cleanup that
fails is still swallowed, but is now visible in the returned value rather
than silently absent.

The fs seam is ambient rather than threaded. Explicit deps through
install-profiles and the 3000-line conversion module was impractical; the
tradeoff, the synchronous-only re-entrancy assumption, the restore
guarantee and the partial-adapter fallback trap are all documented at the
seam. findInstallSourceRoot and its sibling stay unrouted by design -
they locate the package's own source, not the install destination.

readCmdNames keeps a second implementation because the standalone CLI
that owns the original cannot require the compiled adapter without a
build-order dependency on its own output. A parity test fails if the two
ever disagree.

Refs #2874

* chore(#2874): gitignore the new build artifact

install-fs-adapter.cjs is tsc output from src/install-fs-adapter.cts, not
a tracked source file. It was added to eslint's ignore list but not to
.gitignore, so it landed as a tracked file - the third time this step of
the new-.cts ripple has been missed on this epic.

Refs #2874

* fix(#2874): close two seam leaks and correct a false comment

A correctness review found the seam still leaked in two places, both
subtler than the three already closed.

readGsdCommandNames was routed when it should not have been: it reads the
package's own commands directory, which a destination-fake is never
seeded with, so under a fake adapter it returned an empty or wrong roster
instead of failing loudly. It now reads real fs, matching the precedent
already documented for findInstallSourceRoot.

cleanupStagedSkills ran raw rmSync from a process exit handler, which is
real filesystem work deferred past the point where withInstallFs has
restored - the one thing the synchronous-only contract exists to
exclude. Staging now captures the adapter that created each directory and
cleanup replays it, so a real install cleans up exactly as before and a
fake-staged path never reaches the real filesystem.

Also corrected a comment claiming the migration reads were an unrouted,
untested residual gap. They are routed and exercised; a comment
understating the seam is as corrosive as one overstating it in a module
whose trust rests on being honestly documented.

Refs #2874

* test(#2874): migrate the exemplar group and cover the matrix

AC3's exemplar migration lands in place: the qwen install group now
asserts skills and agents destinations from the returned plan in one
deepStrictEqual instead of probing the filesystem for each.

Nine facts the old probes established were enumerated first. Two moved to
the value assertion; seven were retained deliberately - per-file SKILL.md
existence, the VERSION file written outside this function, the manifest
content, and the post-uninstall absence checks all sit outside the plan's
per-kind contract. A migration that quietly asserts less looks like a win
and is a regression, so the enumeration is the guard rather than the
line count.

Also implements the rest of the matrix: the executed-plan shape, adapter
failure modes, the security-boundary rows including a fake that cannot
certify an install the real filesystem would refuse, cleanup visibility,
and two seeded property tests. Only the two external CI gates are left
unticked, because self-certifying them would be a claim rather than a
check.

Refs #2874

* fix(#2874): restore streaming hashes and derive F2 from the boundary rule

The checkpoint found three things reasoning had missed.

sha256File had been converted from raw-fd streaming to a single
readFileSync on the assumption that GSD artifacts are never large. A test
named for exactly that contract already existed and went red. Streaming is
restored, now routed through the adapter, which gains openSync, readSync
and closeSync. The contract was the specification; the assumption was not.

Three existing tests inject faults by monkeypatching real fs. They broke
because mkInstallTempDir stopped calling real mkdtempSync, not because of
any binding subtlety - the real adapter was already late-bound. It now
calls the real function when no fake is injected, so a monkeypatch applied
after import is still seen and the additive contract holds.

F2 poisoned real fs by method, so a deliberately unrouted package-source
read failed a correct design. It now poisons by path: destination IO is
forbidden, package-source IO is allowed and positively asserted. The claim
was always zero real destination IO, and the test now derives from that
rule instead of coincidentally matching it.

Refs #2874

* docs(#2874): add the contributor how-to for plan-based test migration

The phase gate caught a real gap. The docs plan was Reference plus
Explanation only, and every CI check would have passed, because the
docs-required lint only verifies that some file under docs/ moved.

But this phase exists to demonstrate a pattern for follow-on work, and
that work is other contributors migrating probing test groups. The
sequence has two live traps - a partial fake silently falls back to real
fs, and the seam is ambient and synchronous-only - plus one discipline
nobody infers: enumerate the facts before converting, or you assert less
and call it a win.

The page carries the qwen migration's arithmetic, nine facts enumerated
and only two converted, because a reader seeing only the diff would
reasonably conclude the pattern is to replace probes wholesale.

No locale mirrors: none of the four carries any contributor-only how-to,
so a single translated file would manufacture parity rather than provide
it.

Refs #2874

* chore(#2874): backfill changeset pr number

* test(#2874): normalize both sides of the G1 tree comparison

G1 failed on Windows only, deterministically on both shards. The defect
was in the test helper, not production.

_computePathPrefix posix-normalizes the resolved config dir
unconditionally, so on Windows the path embedded in every emitted
SKILL.md body is forward-slash form. hashDirTree stripped against the raw
backslash path from mkdtempSync, so the substring never matched and each
install's unique temp suffix stayed baked into every file - all fifteen
skill bodies hashed differently for two runs that had written identical
bytes.

Both sides are now normalized unconditionally rather than gated on
path.sep, matching the rule this repo already records: backslash paths
arrive on Linux too.

Production code is untouched and was verified correct. Normalizing this
away on the production side would have hidden a real portability bug if
one had existed.

Refs #2874

---------

Co-authored-by: sim <sim@local>
2026-08-16 02:48:24 -04:00
Tom Boucher
2b9713a6b2 fix(#3557): accept claude code session id in the workstream session probe (#3570)
* test(#3557): failing-first regression for claude code session key probe

* test(#3557): assert adapter source vocabulary in session probe test

* fix(#3557): accept claude code session id in the workstream session probe

* test(#3557): pin the new session key against both immediate neighbors

* chore(#3557): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-16 02:09:39 -04:00
Tom Boucher
9448736872 fix(#3547): exercise the real global config-home shape in the install harness (#3567)
* test(#3547): failing-first regression for collapsed global install shape

* fix(#3547): exercise the real global config-home shape in the install harness

* test(#3547): align ripple suites with the real global install shape

* test(#3547): update stale collapsed-shape pins in provenance and migration suites

* fix(#3547): bump emitted-baseline schema version for the real install shape

---------

Co-authored-by: sim <sim@local>
2026-08-16 01:14:26 -04:00
Tom Boucher
c5b83cb050 chore(#3560): delete two unreachable workflows, gate workflow reachability in lint (#3564)
* chore(#3560): delete two unreachable workflows, gate reachability in lint

discovery-phase.md and plan-milestone-gaps.md shipped to all 19 runtime
install trees with no command, agent, or skill referencing them.
plan-milestone-gaps' command was deleted by #2790 and the workflow was
left behind; discovery-phase's own header claimed a caller in
plan-phase.md's mandatory_discovery step, and that step does not exist —
plan-phase.md contains zero occurrences of "discovery".

docs/INVENTORY.md asserted discovery-phase.md was an alternate entry for
/gsd-new-project. new-project.md never referenced it. The row and the
matching note sentence are removed across all five locales rather than
corrected.

Adds rule 6 to lint-command-contract: every shipped workflow must be
reachable from a loader, walking the transitive closure over the three
reference shapes this repo uses. The closure seeds ONLY from
commands/agents/skills, so a workflow that references only itself and a
pair that reference only each other are both correctly reported rather
than satisfying themselves; a visited set makes reference cycles
terminate. The measure is a mention in a LOADER — docs/ and install-tree
fixtures deliberately do not count, because scan.md proved a file can be
documented and shipped while entirely unreached.

Ships blocking, not report-only: #3561 is in this branch's base, so the
tree reports 0 unreachable from the start.

Closes #3560

* test(#3560): drive rule 6 end-to-end, sweep a stale allowlist, update ADR-0002

Review findings.

Rule 6 had no end-to-end coverage: the tests exercised the pure closure
with in-memory data, so the wiring — file collection, exit code,
diagnostic — was unproven, and #3560's acceptance list explicitly wants
a fixture showing the rule FAILS on a planted orphan. Adds an optional
--root to lint-command-contract (default behavior unchanged) and four
tests driving the real CLI through the process seam against a temp
fixture: clean=0, planted orphan=1, orphan referenced only from docs/=1,
orphan reachable transitively=0. The docs/ case is what pins the
Goodhart defense — a mention outside a loader must not confer
reachability.

Deletes two tests that were byte-identical to a third and could not
assert anything loader-specific, since the closure is source-agnostic by
design; that distinction lives in the lint script's file collection and
is now covered above.

Removes a stale ALLOWLIST entry for discovery-phase.md in
planner-language-regression — the exact sweep-miss class rule 6 exists
to catch, found in the PR that adds the rule.

ADR-0002 described five per-file frontmatter checks; rule 6 is a
repo-level reachability graph, so the Decision section now says so.

Refs #3560

* test(#3560): cut the bug-3298 test pin on the deleted plan-milestone-gaps workflow

The remote runner went red with four failures: tests/phase.test.cjs
asserted the plan-milestone-gaps workflow exists and checked its mkdir
patterns, so deleting the file broke the test that pinned it. This is the
fence the epic describes — the content-sync test IS what keeps an
unreachable file alive — and cutting the coupling is what makes the
deletion safe.

Removes only that arm. The bug-3298 block guards three workflows against
phase-dir prefix drift; the import and add-backlog arms and both shared
mkdir-pattern helpers are untouched.

Worth recording where the sweep failed: my reachability walk covered
commands, agents, skills, gsd-core and docs, and lint-removed-but-needed
covers .github/workflows, gsd-core, docs and package.json. Neither looks
at tests/, so a test-pinned deletion is invisible to both and surfaces
only on the remote runner. The how-to added by this PR names that gap
explicitly so the next deletion searches tests/ by hand.

Refs #3560

* docs(#3560): add a how-to for resolving unreachable-workflow findings

* chore(#3560): backfill changeset pr number to 3564

---------

Co-authored-by: sim <sim@local>
2026-08-15 23:29:30 -04:00
Tom Boucher
c2e453e2a1 fix(#3543): bake no tier model when the effective model_profile is unverifiable (#3563)
* test(#3543): add failing-first regression for unverifiable profile bake

* fix(#3543): bake no tier model when the effective model_profile is unverifiable

* test(#3543): use cleanup helper for planning dir removal in regression test

* test(#3543): use shared temp-dir and console-capture helpers in regression suite

* chore(#3543): backfill changeset pr number

* test(#3543): clear ambient xdg env overrides in global install tests

* test(#3543): assert baked model line by equality instead of dynamic regexp

---------

Co-authored-by: sim <sim@local>
2026-08-15 23:18:14 -04:00
Tom Boucher
e6e32da224 fix(#3561): route /gsd-map-codebase --fast to a scan.md path the runtime can resolve (#3562)
* test(#3561): failing-first coverage for the --fast dangling dispatch

/gsd-map-codebase --fast routes to "the scan workflow" in prose, but
commands/gsd/map-codebase.md names no resolvable path and its
execution_context includes only map-codebase.md, so scan.md is never
loaded. Same class as epic #1891's F8/F9: dispatch keyed on a token
that never arrives.

Adds workflowPathRefs() to command-contract-helpers.cjs — one shared
pure resolver for the three reference shapes this repo uses (eager
@-include, lazy absolute-ish path, lazy parent-relative steps//modes/
path). Placing it in the helpers module keeps the lint script and the
test suite reading the same definition, which is what that module
exists for; #3560 consumes the same function rather than re-deriving it.

Tests 16/17 are RED until the routing fix lands.

Refs #3561

* fix(#3561): route --fast to a scan.md path the runtime can resolve

commands/gsd/map-codebase.md documented --fast and told the agent to
"run the scan workflow", but named no path and included only
map-codebase.md in execution_context, so gsd-core/workflows/scan.md was
never loaded and the single-agent scan was improvised.

Names the path in the routing line so it is read on demand, rather than
adding an eager @-include: --fast is the minority path and the
progressive-disclosure split (#717) exists to keep the common full-map
invocation from paying for it. The accompanying test pins that choice —
execution_context must still carry exactly one @-ref.

skills/gsd-map-codebase/SKILL.md is regenerated, not hand-edited.

Closes #3561

* fix(#3561): bound the .md match and bind the regression test to the --fast line

Two majors from the isolated adversarial review.

Both resolver regexes ended at a literal .md with no trailing boundary,
so a longer extension was truncated into a plausible-looking but wrong
path: workflows/foobar.mdx returned workflows/foobar.md. Adds a
(?![A-Za-z0-9_]) lookahead to both shapes so .mdx and .md5 are rejected
outright rather than silently rewritten.

The regression test scanned the whole command file, so it did not bind
to the defect — the reviewer showed that an unrelated comment mentioning
scan.md anywhere made it pass while the dispatch defect was still
present. It now extracts the "- If it is `--fast`" bullet and scans that
line alone, and a new test drives a synthetic pre-fix fixture to prove
the false-pass path is closed.

Refs #3561

* chore(#3561): backfill changeset pr number to 3562

---------

Co-authored-by: sim <sim@local>
2026-08-15 22:18:50 -04:00
Tom Boucher
1591454357 feat(#3409): reject shell guards that cannot observe their own failure arm (#3558)
* test(#3409): failing-first regression tests for unreachable shell guard arms

Drives the three live defects fail-first, executing the shipped workflow
snippets rather than a re-typed copy:

- G1/G2 plan-phase.md Walking Skeleton gate reads `--pick summaries_total`,
  a field that does not exist, so PRIOR_SUMMARIES is always "" and the gate
  has never fired (#3365). G2 is the load-bearing negative-space case: it
  rejects a fix that treats "no answer" as "zero" and fires unconditionally.
- G3 plan-phase.md PHASE_REQ_IDS resolves "" instead of the TBD sentinel on
  a phase with zero requirements.
- G4 complete-milestone.md's bare `cat <glob>` blocks on stdin under a
  nullglob left set by an earlier block (measured hang).

Skipped on Windows for G4 only: the FIFO-blocked-stdin mechanism is POSIX
only, and a weakened assertion there would pass vacuously.

Refs #3409

* fix(#3409): make nine shell guards observe their own failure arm

`--pick` coerces a missing field to empty string and exits 0, so the
`|| echo <default>` fallback after it fires only on a verb typo, never on
the field absence it was written for. Nine sites relied on that arm.

- plan-phase.md walking-skeleton gate: `--pick summaries_total` names a
  field that does not exist under any flag combination, so the gate has
  never fired on any project (#3365). Repointed at the existing single
  owner, `phases.list --type summaries --pick count`, which returns a real
  integer in every case including a project with no `.planning` directory.
  No new counter is added: a second one would duplicate the ownership
  ADR-3180 Decision 1 forbids. The gate now fires only on a literal "0",
  so an unanswerable query fails safe instead of entering skeleton mode.
- plan-phase.md phase_req_ids: now falls back to the documented TBD.
- The remaining seven convert to an explicit empty test.
- complete-milestone.md read all phase summaries through a bare
  `cat <glob>`; under a nullglob left set by an earlier block that is zero
  operands, so cat blocks on stdin. Guarded with the array shape the
  #3300 fix already established in review.md.

Refs #3409

* fix(#3409): guard eleven more globs that defeat their own fallback arm

The nullglob audit this issue asks for turned up the same class in files
#3300 never touched.

- Eight bare `cat <glob>` reads (transition, complete-milestone, planner x4,
  verifier, phase-researcher). With nullglob set that is zero operands, so
  cat reads stdin and blocks; measured rc=137 at 3s.
- Three `ls <glob> || echo "<message>"` sites (session-report,
  review-backlog and its generated skill). nullglob makes ls succeed
  listing the cwd, so the message never prints and the user gets a
  directory listing instead.

Guarded with `[ -e "${_ARR[0]}" ]` rather than `[ ${#_ARR[@]} -gt 0 ]`.
The count form is correct only when nullglob is set, and six of these
seven files never set it: without it the array holds the unmatched literal
pattern, so the count is 1 and the guard passes wrongly. `-e` is correct
in both worlds. review.md keeps its count guards — that block sets
nullglob two lines above them.

skills/gsd-review-backlog regenerated from commands/, never hand-edited.

Refs #3409

* feat(#3409): add the unreachable-shell-guard drift lint

A sibling of lint-planning-prompt-drift.cjs, consuming the shared
scripts/lib/drift-scan.cjs rather than copying it, wired into lint:ci.

Both detectors are one shape — a fallback arm defeated by a legitimate
success-on-empty:

- Detector A: `--pick` and `|| echo` on one line. `--pick` is the
  discriminator because "missing field renders empty at exit 0" is a
  documented CLI contract, not a heuristic. A rule keyed on gsd_run
  matched 111 lines, ~132 of them legitimate, and was rejected.
- Detector B: `cat <glob>` in command position, and `ls <glob>` whose
  exit code feeds a real fallback or an if/while head. Informational
  `ls <glob>` whose stdout is consumed (97 sites) and `|| true` failure
  suppression (~15) are not guards and never fire.

Shrink-only ratchet keyed on (file, trimmed text) with a per-pair count,
POSIX-normalized unconditionally so Windows CI cannot report everything
fresh and stale at once. Ships with a ZERO-entry baseline: every site it
can find is fixed. Exemption is the per-line `# gsd-scan-ignore: #NNN`
marker whose reason must name an issue or URL; a malformed reason reports
a distinct error rather than silently exempting. No file allowlists.

ADR-3409 records the invariant, the measurements behind both detectors,
and why the upstream `--pick` contract fix belongs to #3473.

Refs #3409

* fix(#3409): resolve review findings — typed surface, sanitized reports, tighter marker

Standards axis (blocker): the guard's tests asserted on human-readable
stdout/stderr and on free-form baseline-load prose, which CONTRIBUTING
prohibits by name. Added the typed surface it prescribes instead of
weakening the tests: a frozen REASON enum, a --json report mode,
structured loadBaseline errors, and a test locking Object.keys(REASON)
so a new reason stays three coordinated changes.

Security axis: sanitizeForReport covered every violation field but not
the baseline-load error path, which embeds raw JSON.stringify output --
that escapes nothing above 0x1f, so bidi and C1 controls reached CI logs
unfiltered. Routed through the sanitizer at the output seam.

Security axis: the scan-ignore marker accepted `#0` and a bare
`http://`. Tightened to a positive issue number and a URL with a host.
This diverges deliberately from the sibling in
tests/commit-files-pathspec.test.cjs, whose looser form was copied
verbatim; the header now records the divergence.

Security axis: G4 built its FIFO with `mktemp -u`, reserving a name
without creating it. Now created inside a `mktemp -d` directory.

Spec axis: ADR-3409 claimed a ninth site landed after the issue was
filed. git blame disproves it -- all nine predate it; the issue's hand
count missed one. Corrected. The design and test matrix still specified
B9 as a FLAG after implementation reversed it to PASS; both now record
the reversal and why.

Refs #3409

* docs(#3409): add the how-to for resolving unreachable-guard findings

Reference and Explanation are carried by ADR-3409; this is the
task-oriented quadrant CI cannot check for.

The page exists mainly for one thing the lint structurally cannot catch:
both `[ -e "${_ARR[0]}" ]` and `[ ${#_ARR[@]} -gt 0 ]` remove the glob
from the command and therefore both pass, but the count form is correct
only when nullglob is set — and nullglob is usually set in a different
block of the same file. A reference table cannot carry that; a how-to can.

Also documents the reason codes, so a reader can tell "nothing to report"
from "could not look".

No tutorial: this is a gate inside an existing CI loop, not a new entry
point a newcomer starts from.

Refs #3409

* fix(#3409): bring the touched prompt files back under their size gates

The remote run was red on 14 tests, all size/attribution, none of them
the regression suite.

- agents/gsd-planner.md was 194 chars over a 49152 cap enforced by four
  separate tests, each of which says the remedy is extraction, not a bump.
  It had 41 chars of headroom before this branch. Its `## Checkpoint
  Types` section was an unlinked, condensed duplicate of
  references/checkpoints.md, which already carries all three types and
  their XML shapes; the section now points there and keeps the three
  names and percentages inline. Net -969, margin 1010.
- gsd-core/workflows/execute-phase.md sat 2 chars under a comfortable
  margin assertion. Dropped the AUTO_MODE default: the `|| echo "false"`
  it replaced was unreachable, so the value was already sometimes empty
  on next, and its only consumer compares against `true`. Net -16.
  Left plan-phase.md's AUTO_CHAIN default alone -- that file names an
  explicit `false` branch, so empty would match neither branch.
- Acknowledged the seven prompt files that genuinely grew, one specific
  reason each. Five of those paths were already claimed by spent
  fragments identical to next, which blocks a second source naming the
  same path; removed just the colliding key from each, deleting the two
  that this emptied.

Refs #3409

* test(#3409): extract the whole PHASE_REQ_IDS block, not just its first line

G3 failed on the remote runner with '' !== 'TBD'. The test was wrong, not
the workflow.

The shipped contract is now two consecutive lines -- the capture and the
`${PHASE_REQ_IDS:-TBD}` default -- but the helper's `^PREFIX=.*$` regex
returns only the first match, so the test executed half the contract and
correctly observed the empty string. Renamed to extractAssignmentBlockFor
and taught it to consume the contiguous run of lines sharing the prefix.

The assertion is untouched: TBD is the right expectation, and weakening
it to accept the empty string would have reinstated exactly the class
this suite exists to catch -- a check that cannot observe the thing it
is checking.

extractFencedBashAfterAnchor is unaffected: it is fence-delimited rather
than line-anchored, so G1/G2/G4 still capture their full blocks.

Refs #3409

* chore(#3409): drop a spent ack fragment that collided on complete-milestone.md

#3458 landed on next while this branch was in flight and its fragment
claims complete-milestone.md, which this branch also grows. Two ack
sources may never name the same path.

Its entry is spent: the +9163 it explains is already absorbed at base, so
it can no longer clear anything, and the checker's own guidance for spent
entries is to delete them. Removing the key emptied the fragment, so the
file goes too -- an empty one signals nothing.

Refs #3409

* chore(#3409): backfill changeset pr number 3558

* test(#3409): hoist a regex subject out of exec() to clear the injection scan

CI's prompt-injection scan flagged `MARKER_RE.exec('# gsd-scan-ignore: ...')`.
The pattern `exec[[:space:]]*\(["']` is receiver-blind on purpose, so it
catches `require('child_process').exec('...')` -- and the scanner's own
header records that RegExp.prototype.exec is collateral, to be handled by
its allowlist.

Allowlisting the file would blind it to the real exec vector permanently,
so the subject is hoisted into a const instead: same assertion, scanner
left at full strength, no security surface widened.

Refs #3409

---------

Co-authored-by: sim <sim@local>
2026-08-15 21:09:05 -04:00
Tom Boucher
abf3cf7c25 fix(#3458): scan archived milestone phases, and make [A] Acknowledge actually suppress (#3555)
* fix(#3458): scan archived milestone phases in the four audit-open scanners

`query audit-open` resolved exactly one phase root, `.planning/phases/`. When a
milestone closes its phase directories move to
`.planning/milestones/v<X.Y>-phases/`, so an item still unresolved at that
moment — the `[R]/[A]/[C]` prompt accepts "accept" and "carry forward", not only
"resolve" — became invisible to the v1.1 pre-close audit and every audit after
it. The window in which an unresolved item is visible to this gate was exactly
one milestone wide, and nothing announced when it closed.

Reproduced before fixing, with byte-identical artifacts in the two layouts and
the active layout as the control:

  active   → has_open_items=true   deferred=1  uat_gaps=1  total=2
  archived → has_open_items=false  deferred=0  uat_gaps=0  total=0

`scanDeferredItems`' own doc comment names this as the thing it was built to
prevent — "phase directories archive to `milestones/vX.Y-phases/` (#1871) and
the entry leaves the live tree having never been triaged" — while the
implementation eleven lines below cannot read that path. It catches an entry at
its own milestone close and goes blind at precisely the transition the comment
describes.

This is not cosmetic under-reporting. `auditOpenArtifacts` sums all nine
category counts into `counts.total` and returns `has_open_items: counts.total >
0`, so four blind scanners can flip the gate's headline boolean and let
`/gsd-complete-milestone` assert a clean close it never verified. In a
fully-archived project `.planning/phases/` may not exist at all, and the
scanners' `if (!fs.existsSync(phasesDir)) return []` produced a value
indistinguishable from "nothing is open".

## One enumeration, not four

The four scanners each hand-rolled the same active-only walk. They now share
`listAuditPhaseTargets(planDir, cwd)`, which yields both roots — the shape of
fix epic #3473's B2 asks for, and the reason the fix is one seam rather than
four edits.

Three properties are load-bearing:

  * the ACTIVE enumeration is unchanged — still a raw `readdirSync`, NOT
    `listMilestonePhaseDirs`. These scanners are deliberately not
    milestone-filtered today, and switching would silently add window and
    sentinel filtering: a behavior change belonging to #3372, not here.
  * a missing or unreadable active root skips that half instead of returning
    early. That early return WAS the bug in a fully-archived project.
  * archived dirs are deliberately NOT milestone-filtered, per the comment
    `src/uat.cts` already carries: archived phases belong to past milestones by
    definition, so applying the current-milestone filter discards every one and
    silently reinstates this bug.

Each item now carries `archived_milestone` when it comes from a closed
milestone, matching how the sibling module already labels archived results —
without it an operator triaging `[R]/[A]/[C]` cannot tell a live item from one
carried over. Additive: no existing test or doc asserted an exact key set.

`scripts/lint-phase-enumeration-drift.cjs`'s exemption list for this file drops
from the four scanner names to the single helper, since that is now the only
place the enumeration lives.

## Tests

Written failing-first and confirmed red for the right reason before the fix, all
four driven through the real `audit-open` CLI rather than private functions:
archived-only (was 0/0/0/0 with `has_open_items=false`, now 1/1/1/1 true),
mixed active+archived (was 1/1/1/1 — the archived half dropped — now 2/2/2/2),
active-only unchanged, and an all-resolved archived phase contributing 0.

That last one passed vacuously before the fix, because the archived path was not
reached at all; it was re-verified as genuinely discriminating afterward by
flipping one archived item to unresolved and watching the count rise.

Closes #3458

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

* fix(#3458): restore the scan_error sentinel and show archive provenance

Adversarial review found one BLOCKER that the previous revision introduced,
which a green remote-runner suite did not catch because nothing in the tree
asserts `scan_error` at all.

## The regression

Consolidating four hand-rolled walks into `listAuditPhaseTargets` swallowed the
active-root `readdirSync` throw in a bare `catch {}`. Pre-fix each scanner
returned `[{scan_error: true, …}]`; after, each returned `[]`. Measured with
`.planning/phases` created as a FILE (so `existsSync` passes and `readdirSync`
throws ENOTDIR):

  before this fix: uat_gaps/verification_gaps/context_questions/deferred_items
                   each `[{"scan_error":true,…}]`
  the regression:  each `[]`

`complete-milestone.md` re-runs `audit-open --json` and reads those counts, so a
machine consumer could no longer tell "I/O failed" from "verified clean" — the
exact conflation this issue exists to remove, reintroduced on the failure path.
`listAuditPhaseTargets` now reports `activeUnreadable` and each scanner pushes
the sentinel shape recovered verbatim from `origin/next`, not reinvented.

The docstring claiming the active enumeration was "UNCHANGED" was false while
that sentinel was missing, and is corrected to state what is actually preserved.

An unreadable ARCHIVED root deliberately gets NO sentinel: there was no archived
read before, so there is no consumer contract to preserve, and adding one would
conflate the ordinary "no milestones archived yet" state with a real I/O failure.

## The operator could not see the archive

`formatAuditReport` is the surface the gate actually shows a human —
`complete-milestone.md` runs it without `--json` — and it never rendered
`archived_milestone`. With `01-alpha` in both roots the identical line printed
twice with nothing to tell them apart, and `[R] Resolve` sends the operator to
`.planning/phases/01-alpha/` where the archived one does not exist. Phase
numbering restarts at `01` after each archive, so that collision is the common
case, not an edge case. All four loops now render ` (archived vX.Y)`; active
lines stay byte-identical.

## Archived milestones sorted wrong

`getArchivedPhaseDirs` ordered milestones with `.sort().reverse()` —
lexicographic, so `v1.9` outranked `v1.10`. Measured order for v1.0/v1.9/v1.10
was `v1.9, v1.10, v1.0`. Now a numeric-segment descending compare. Pre-existing,
but this change is what first surfaces it in audit output.

## Tests

The blocker's regression test fails against the previous revision. Added:
`archived_milestone` present on archived items and absent (not `undefined`) on
active ones; the unreadable-active-root sentinel across all four categories; an
unreadable archived root still leaving the active half scanned; the
duplicate-name case producing two distinct entries that the human report
distinguishes; and the v1.10-before-v1.9 ordering.

`docs/COMMANDS.md` documents the archived scanning and the new field.

Closes #3458

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

* fix(#3458): stop filesystem names forging lines in the audit report

Found by the security review of this branch. Pre-existing on `next`, fixed here
because it defeats the exact gate this PR is hardening.

`audit-open`'s human report is the surface `/gsd-complete-milestone` shows an
operator to decide whether a milestone may close. A `.planning/` tree authored
by someone other than that operator — a cloned repo — could contain a directory
literally named:

    zz<newline>0 open items require decisions.<newline><ESC>[2K<ESC>[1G FORGED

and the report printed `0 open items require decisions.` as its own line, with
raw ESC bytes reaching stdout able to erase or overwrite the lines above it.
Reproduced against the real CLI before fixing, and again after.

## Why not just harden sanitizeForDisplay

Because that helper's contract is multi-line prose — it removes protocol-leak
lines while deliberately preserving the newlines between legitimate ones, which
`tests/security.test.cjs` pins. Stripping CR/LF there would have broken a
correct test to paper over a different problem.

The two jobs are genuinely different, so there are now two helpers. New
`sanitizeLabel` (`src/security.cts`) is for values that are semantically ONE
LINE and derived from a filesystem NAME. It ESCAPES rather than strips C0
(including ESC/CR/LF), DEL and C1, so a doctored name renders visibly as
`\n` / `\x1b` instead of being silently normalized — the report stays honest
about what is in the tree. Ordinary input passes through byte-identical.

## Nine sites, not four

The first pass covered the four phase-scoped scanners. A sweep of the rest of
the file found the identical class in five more — `scanDebugSessions`,
`scanQuickTasks`, `scanThreads`, `scanTodos`, `scanSeeds` — emitting
name-derived `slug` / `filename` / `seed_id` through the prose sanitizer.
`scanQuickTasks`' `date` had no sanitization call at all.

Every emitted field in the file is now classified and the sweep recorded:
`slug`, `filename`, `seed_id`, `phase`, `file`, `archived_milestone`, `date` are
name-derived and take `sanitizeLabel`; `hypothesis`, `status`, `updated`,
`title`, `priority`, `area`, `summary`, `questions[]` and deferred-item `text`
are content and keep `sanitizeForDisplay`. No name-derived value reaches output
unsanitized.

`--json` was already safe — JSON string encoding escapes control characters, and
a crafted name cannot break out of the string. Verified rather than assumed.

Closes #3458

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

* chore(#3458): backfill changeset pr number

* test(#3458): skip control-character fixtures where the OS forbids the name

CI red on `test (windows-latest, 24, shard 1/3)`: the four forgery-rejection
tests build directories whose names embed a newline and ESC, and NTFS forbids
control characters in path components, so `mkdir` threw ENOENT.

The remote runner is Linux-only, so it could not have caught this class.

Semantically the skip is honest rather than a workaround: on Windows the
directory-name forgery vector does not exist, because the OS refuses to create
the name. The sanitizer's own behavior stays covered there by the
`sanitizeLabel` unit tests, which are pure string tests with no filesystem
calls — verified.

Uses the repo's established capability-probe convention
(`tests/adr-index-gate.test.cjs`'s `trySymlink`), which `t.skip()`s on the real
errno rather than branching on `process.platform`, and whose comment gives the
reason: a bare `return` "would silently report a PASS ... and hide the gap this
guard exists to close". A skipped test is visibly skipped.

Swept every test added on this branch for names Windows would reject or POSIX
path assumptions; these four were the only ones.

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

* feat(#3458): make [A] Acknowledge actually suppress, without overwriting a verdict

Making archived phases visible exposed the other half of the problem: an item
unresolved at a milestone close now resurfaces at every later close forever,
because `[A] Acknowledge` wrote a prose block to STATE.md that
`auditOpenArtifacts` never reads. `verified_closeout` became unreachable and the
gate degraded to a mandatory `[A]` every time.

## The prompt does not change

`[A] Acknowledge all` already promises "document as deferred and proceed with
close". It documented but never deferred. This makes `[A]` do what it says.
`[R]` and `[C]` stay abort paths. No "carry forward" option is invented — an
item that is not acknowledged simply keeps surfacing, which is the default.

## The marker lives inside the artifact

Not a ledger. The audit mints no ids and has no stable identity — `phase` is a
token that collides across directories, `file` for deferred items is a constant,
and identity otherwise degrades to the item's own prose after a lossy sanitizer.
Any ledger must re-derive that key every close, so a reworded item silently
un-suppresses or, worse, mis-suppresses a different one. Storing the
acknowledgment next to the thing it suppresses makes that class of bug
structurally impossible, and it is the pattern `src/uat.cts` already argues for
with `deferred-items.md`'s in-place `status: resolved`.

## The marker is verdict-preserving and self-invalidating

`status:` is never overwritten — writing `resolved` into an unresolved UAT would
be a lie in the artifact of record, and the disclosure has to be additive.

    audit_acknowledged:
      milestone: v1.0
      at: 2026-08-15
      status: gaps_found      # snapshot of what was true when acknowledged

Suppression applies ONLY while the snapshot still matches reality: `status` for
seven categories, `question_count` for context questions, and for deferred items
a new per-entry `status: acknowledged` distinct from `resolved`, which keeps
meaning "actually fixed". Change the artifact and the acknowledgment stops
applying, so the item comes back on its own.

That is what makes re-opening answer itself with no extra state, and it fails in
the safe direction: a stale acknowledgment can never hide a NEW problem. A
malformed marker is treated as absent — a bad marker must never silence an item.

The check is ONE shared `isAuditItemAcknowledged`, not nine copies. This file has
already been through that defect family twice in this PR.

## Observable, not silent

`audit-open --json` now reports an `acknowledged` count beside `counts`, so a
reviewer can tell a close that is clean because things were fixed from one that
is clean because things were silenced.

## Writer

New `audit-open acknowledge` verb snapshots current state itself, so the marker
is never hand-authored from workflow prose — the gap that left the STATE.md
block with no writer, no schema and two conflicting formats. Writes route
through the existing path-confinement seam.

## Two deliberate limits, failing closed

Heading-delimited deferred entries (#3457) are REFUSED with
`unsupported_heading_shape` rather than edited, because mapping a heading entry
back to its exact source span is not safely derivable when headless and heading
entries interleave in one file. A loud refusal beats a mis-targeted write.

A quick task with no summary gets one created to carry the marker, since there
is otherwise nowhere to put it.

## Tests

Self-invalidation is the important one and is covered per category: acknowledge,
then change the status or question count, and the item resurfaces. Also
malformed markers not suppressing, `status:` byte-unchanged after acknowledging,
the writer refusing a path outside the project, and the four original #3458
scenarios unchanged.

Closes #3458

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

* feat(#3458): wire [A] to the acknowledge verb and converge the disclosure table

Consumer side of the suppression seam.

## The workflow stops hand-authoring the mechanism

`[A]` now calls `audit-open acknowledge` once per open item, then writes the
STATE.md `## Deferred Items` table as before. The table stays as a
human-readable disclosure; it is no longer the mechanism. That closes the gap
where the block had no writer, no schema and no reader — the marker is now
written by the tool, which snapshots current state itself.

The `[R]` / `[A]` / `[C]` prompt is unchanged, `[C]` still means "Cancel — exit
without closing", and no carry-forward option is invented.

The all-clear branch now distinguishes a close that is clean because items were
FIXED from one that is clean because they were ACKNOWLEDGED, using the
`acknowledged.total` count, and carries that into the MILESTONES.md disclosure
line beside the existing override count. A clean close that was bought with
acknowledgments should say so.

## Format drift resolved

Two incompatible `## Deferred Items` shapes shipped simultaneously — 3 columns
in the workflow, 4 in the template, with different body lines. Converged on one
5-column shape carrying the source Milestone, since archived items now appear
and the archived-milestone disambiguator was previously discarded at write time.
The workflow enumerates the categories instead of trailing off in `...`.

## Ack fragment bookkeeping

`complete-milestone.md` grows 6,764 bytes (31,228 → 37,992; cap 61,440), covered
by a new `tests/emitted-drift-acks/3458-*.json`.

`2962-zsh-nomatch-for-glob-portability.json`'s `complete-milestone.md` entry is
REMOVED — the no-duplicate-path rule hard-blocks two sources naming one path.
That entry is spent: the nullglob shim it acknowledges is present in both
`origin/next` and the CI emitted baseline `fd2b97a5`, so its ripple is already
absorbed and it can never clear anything again — verified directly, not assumed,
and the gate's own message directs deleting spent entries. Its other three files'
entries are untouched.

`scripts/sync-runtime-launcher.cjs` wanted to rewrite `explore.md` as well —
pre-existing drift unrelated to this change, reverted. `complete-milestone.md`
still carries exactly one canonical preamble.

Docs cover the verb's real flag surface, the marker's verdict-preserving and
self-invalidating behavior, and the new `acknowledged` count. A second `Added`
changeset covers the verb, since the existing `Fixed` fragment describes only
the archived-phase scanning.

Closes #3458

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

* fix(#3458): close three blockers in the acknowledgment seam

Adversarial review of the seam. Three BLOCKERs, one of which disproves a safety
claim I published in the PR body, the changeset and the docs.

## The claim was false; the code is fixed rather than the claim softened

I wrote that "a stale acknowledgment can never hide a NEW problem". It could.
`context_questions` snapshotted only the question COUNT, so replacing two
acknowledged questions with two brand-new blockers kept the item suppressed.
`uat_gaps` snapshotted only `status`, so adding five more pending scenarios
(`open_scenario_count` 1→6) kept it suppressed.

The snapshot now identifies CONTENT, not size: a digest of the whole question
set, and a status + open-scenario-count composite. Any edit invalidates. The
other seven categories were checked and their single tracked dimension is
already the whole story. Both disproofs now resurface the item.

## Writing to the wrong line, and reporting success

`acknowledgeDeferredItem` built an unanchored regex and exec'd it over the whole
file while match-selection and the ambiguity guard ran over the section body
only, so the write landed at the first match ANYWHERE. A file with `# Notes`
holding `- Fix the parser` above a `## Deferred Items` section holding the same
bullet: the CLI exited 0 saying `acknowledged: true`, injected `status:
acknowledged` under `# Notes`, and re-audit still reported the entry open. It
corrupted unrelated content, suppressed nothing, and claimed success — and since
`--file` is unconstrained the same path could inject into a UAT or VERIFICATION
body.

Matching is now anchored to the selected section, and the matched span is
re-verified against the selected entry before any write; a mismatch refuses with
`match_verification_failed` rather than writing.

## Acknowledging todos hid the ones never shown

`scanTodos` capped at five files and then checked acknowledgment. With seven
todos, acknowledging the five that were LISTED drove `todos: 0`,
`has_open_items: false`, and items six and seven never appeared in any later
scan. The workflow's own "repeat until no todos items" remedy terminates after
one pass. Pre-feature this was unreachable because the count was pinned at five.

That is silent over-suppression — the exact direction this PR exists to remove.
Acknowledged items are now filtered BEFORE the display cap, so unacknowledged
todos beyond it still drive the count.

## The [A] branch could not fail closed

Every acknowledge call sat in a `cmd | while read` pipeline with no status
accumulation, so any refusal was discarded and the close proceeded as
`override_closeout`. Separately, `io.output` swaps payloads over 50000 chars for
an `@file:<path>` sentinel — every `jq` would then fail, every loop body run
zero times, nothing be suppressed, and the close happen anyway.

Both closed: failures accumulate across all invocations and halt before close,
and the sentinel is dereferenced using the same pattern `verify_readiness`
already uses for `INIT_MANAGER`. Quoting was verified sound by the review and is
left alone.

## Also

Suppression is now visible in the human report, not only `--json` — the
"clean because fixed vs clean because silenced" distinction was promised for the
surface an operator actually reads.

The CRLF-preservation branches in the writer were dead: every `.md` write goes
through `_normalizeMd`, which normalizes line endings and blank lines whatever
the writer does. Deleted and documented rather than left as code that cannot run.

## Why these shipped

The review named it exactly: there was no coverage for
`unsupported_heading_shape`, `ambiguous`, `not_found`, duplicate-text
mis-targeting, todos beyond the cap, or CRLF. All are now tested, alongside both
snapshot disproofs and the mixed-section fixture.

Closes #3458

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

* test(#3458): align the items-open footer wording with its assertion

Remote runner red on one test: the items-open footer must match
`/previously acknowledged item/i`.

The disclosure was NOT missing — the items-open branch already printed
"N additional items previously acknowledged and still suppressed." The word
order simply did not match the regex the test in the same change asserts. A
wording mismatch between my own test and my own implementation, not a behavior
gap.

Reworded to "N previously acknowledged items also suppressed above the M open
items", which satisfies the assertion and states the relationship between the
two counts more plainly than the original did.

Swept `formatAuditReport` for other branches that could skip the tally: the only
early return is the all-clear path, which already discloses it. `scan_error`
sentinels are filtered per category and excluded from `counts.total`, so an
all-error project falls through to that same branch. No inconsistency remains.

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

* fix(#3458): splice by carried span, digest the untruncated question set

Security review of the writer. Both findings are the same shape, and both are
cases where an earlier fix of mine was incomplete in the same direction: a value
derived for DISPLAY was reused for an IDENTITY or LOCATION decision.

## Writing to the wrong entry, again

The previous fix anchored matching to the `## Deferred Items` SECTION but still
re-found the entry inside it with an unanchored regex, so the write landed at
the first SUBSTRING occurrence rather than the entry's own span. The
`match_verification_failed` guard could not catch it, because the mis-targeted
span is byte-identical to the target.

Probe-confirmed, in a cloned repo's own artifact:

    - CRITICAL unfixed auth bypass
      see also: - minor typo
    - minor typo

Acknowledging "minor typo" appended `status: acknowledged` into the CRITICAL
entry, suppressing it at every future close, while the typo stayed open — exit
0, `"acknowledged": true`. A variant where the target text appears inside
unrelated prose split that line mid-sentence, acknowledged nothing, and still
exited 0, so the workflow's `ACK_FAILURES` halt never fired.

Fixed structurally rather than with a better regex: `splitGapsEntriesWithSpans`
carries each entry's own character span out of the splitter, and the write
splices by that recorded span. The location is already known at selection time —
re-deriving it by searching was the entire defect class. Added as a sibling so
`splitGapsEntries`' three existing callers are untouched. With index-splicing,
`match_verification_failed` becomes a genuine independent cross-check instead of
a guard that could never fire.

## The digest was blind past the third question

`deriveOpenQuestions` truncated to three questions, and clamped each to 200
chars, BEFORE the digest hashed it — so the snapshot could not see the fourth
and later. Ship three innocuous questions, acknowledge, then add real blockers,
and they are permanently invisible: measured `open=0, acknowledged=1`, report
"All artifact types clear."

That is the same self-invalidation property this digest was added to guarantee
one revision ago. The digest now covers the untruncated list; truncation is
display-only.

Found while fixing it: the previous digest joined on a literal raw NUL byte
embedded in the source — collisions are constructible, and reachable through
attacker-controlled YAML `\x00` escapes. Verified both ways. Replaced with a
length-prefixed encoding so no two question sets can collide by concatenation.

## Sweep

Because this is the third incomplete fix on this seam, every identity and
location derivation was swept for the display-vs-identity confusion: uat_gaps
uses status plus a full-content count, the other seven categories use a scalar
status or presence, the deferred `--text` identity is never truncated, and all
five flat categories resolve their file by path rather than by content search.
No further instances.

Closes #3458

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

* test(#3458): correct two assertions that over-reached the measured behavior

Remote runner red on two of the F1 tests. The source is correct — reproduced
both fixtures against the built CLI — and both failures were bugs in the
assertions I wrote. `src/` is untouched by this commit.

The first is worth recording. It computed the CRITICAL entry's block as

    content.slice(content.indexOf('- CRITICAL'), content.indexOf('- minor typo'))

and `indexOf` found the FIRST SUBSTRING occurrence, which lives inside that
entry's own continuation line `  see also: - minor typo`. The block was
truncated mid-line, so the assertion could never match. The test committed the
exact first-substring-match mistake it exists to catch, one revision after that
mistake was fixed in the source.

The second asserted `deferred_items === 0` after acknowledging the typo entry,
but the decoy `- Note: reference - minor typo elsewhere, ignore` is itself an
open entry and was never acknowledged, so the correct count is 1. It now also
asserts WHICH item remains open — that is what actually proves the right entry
was suppressed, and the original assertion would have passed even if both had
been silenced.

Both now derive their expectations from measured CLI output. A comment records
that the write seam normalizes markdown (`_normalizeMd` inserts a blank line
before a list item following a non-list line) so the inserted line is not later
mistaken for a regression; that is repo-wide behavior for every `.md` write
through the single write projection, not something this change should diverge
from.

Root cause of both: the previous two dispatches verified behavior with direct
CLI probes but never executed the test file, so assertions could over-reach what
had actually been measured. Every other assertion added in those two commits has
since been re-derived from real output; no further mismatches.

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-15 17:00:52 -04:00
Tom Boucher
66ad3d6250 test(#3523): rewrite two undetected source-greps as behavioral tests (#3548)
* test(#3523): rewrite two undetected source-greps as behavioral tests

Both sites read a real shipped hook and text-searched it, and both were
invisible to local/no-source-grep because the path was bound to a separate
const the rule never resolves back to its literal.

tests/check-update-config-dir.test.cjs carried three such reads, not the
one the issue cites. All three are replaced by a harness that runs the
real hooks/gsd-check-update.js under a fake HOME and observes the config
dirs detectConfigDir resolved, via the env the hook hands its worker.
Coverage now includes the CLAUDE_CONFIG_DIR precedence cases and the
full adjacent-pair search order the deleted static grep only asserted
for one pair.

tests/security-prompt-injection.security.test.cjs asserted the scanner
hook's SOURCE TEXT contained each canonical MARKDOWN_LINK_PATTERNS regex
source. It now drives probes through the real hook and asserts the
emitted ruleId, with a completeness gate so a new canonical pattern
without a probe fails loudly, plus safePredicate parity the text grep
never checked.

No allow-test-rule marker is added. The now-false marker on
check-update-config-dir.test.cjs is removed and its identity-allowlist
entry pruned, which the ratchet requires.

Refs #3464

* feat(#3523): emit typed findings IR from the read-injection scanner

The scanner built a structured findings array internally and discarded the
structure when rendering its advisory sentence, so the only thing a test
could assert on was that prose. CONTRIBUTING's 'Prohibited: Raw Text
Matching on Test Outputs' names that exact situation and prescribes adding
the typed surface rather than matching the text.

findings is now an array of {ruleId, match} records and the advisory is
derived from it through a single renderFinding mapper, so the rendered
text and the IR cannot drift. The array is emitted additively on
hookSpecificOutput for both the advisory and blocking output shapes.

The advisory string itself is unchanged, byte for byte: verified across
six payload shapes (single markdown-link hit, 3+ finding HIGH, invisible
unicode, unicode tag block, injection-pattern-only, mixed) by running the
pristine and modified hooks against identical stdin and comparing. 28
existing assertions across four suites substring-match that string.

The #3523 parity assertions now read the IR, and a new test binds the two
surfaces together by asserting every MD-LINK ruleId in findings appears in
the advisory and that the reported pattern count matches findings.length.

Refs #3464

* docs(#3523): document the read-injection scanner output contract

The scanner had no subsection under Security Hooks, only a one-line table
row. Documents its trigger events, severity thresholds, skip conditions,
rule ids, and the findings IR added alongside the advisory.

Refs #3464

* fix(#3523): bind every finding family to the advisory, freeze rule ids

Two review findings on the typed-IR commit.

The parity test filtered on MD-LINK- and so bound only one of the four
finding families to the rendered advisory; the other three were covered
only by the pattern count, which catches a length mismatch but not wrong
text. It now drives a payload producing all four families at once,
asserts all four are present so it cannot silently degrade, and checks
each one's expected rendering against an expectation table coded
independently of the hook's own mapper.

The three synthetic rule ids were written twice each — once at the push
site, once in renderFinding — so a rename at one site would fall through
the generic render branch with no signal. They are now a frozen RULE_IDS
constant referenced from both. No string value changed; the advisory
remains byte-identical across all six proof payloads.

Refs #3464

* chore: pin changeset pr field to #3548

---------

Co-authored-by: sim <sim@local>
2026-08-15 13:53:18 -04:00
Tom Boucher
fd2b97a52a fix(#3544): restore tilde form for at-refs in the global spec tree (#3551)
* fix(#3544): restore tilde form for at-refs in the global spec tree

A global claude install emitted @$HOME/.claude/gsd-core/references/*.md
in its workflows and references. $HOME does not expand in a Claude Code
@-import - only relative, absolute and ~ are documented, and a controlled
/context test confirmed a $HOME import loads nothing - so 54 includes
across 22 files silently resolved to nothing on a live install.

This is a divergence, not a new bug. #3133 already applies exactly this
correction to skill and command bodies through _applyRuntimeRewrites's
claude case; copyWithPathReplacement, the spec-tree emit path, never had
it. Both now call one exported helper, so the two surfaces cannot drift
apart again.

Deliberately narrower than changing computePathPrefix's return value:
shipped markdown also carries double-quoted "$HOME/.claude/..." shell
invocations, and ~ does not expand inside double quotes, so rewriting the
prefix wholesale would regress #1284. Only @-prefixed references move.

Refs #3544

* fix(#3544): derive the tilde restore from the resolved prefix

Three review findings, one batch.

The restore was hardcoded to the literal .claude directory, so a global
install with --config-dir pointing anywhere else silently no-opped and
reproduced the very defect this fixes. It now derives the tilde form from
the resolved prefix, which also closes the same latent gap in #3133's
original path since both call sites share the helper.

The @-anchor is quote-aware, so a double-quoted shell path is never
rewritten into a form the shell does not expand. Deliberately a lookbehind
rather than a line-start anchor: @-references are documented to work
mid-line, and anchoring would have traded a theoretical bug for a real one.

Found while testing the above: the bare-form rewrites re-matched their own
output whenever a config dir name extends .claude, emitting
.claude-work-work. Guarded with the same negative-lookahead convention
this file already uses to preserve .claude-plugin.

The tests prove the emitted form, never that the host resolves it - no CI
test can - and both the helper and the suite now say so, because an
undocumented verification boundary is how this defect stayed green for its
whole life.

Refs #3544

* test(#3544): acknowledge the tilde-restore emitted drift

The converter change moves 94 emitted paths that no source-file diff can
explain, which is exactly the case the per-PR ack fragment exists for.
Verified before acknowledging rather than after: both trees were built
from real installs and every one of the 211 changed lines across all 94
paths is @$HOME becoming @~, with nothing outside that single kind.

Nine spent entries were pruned from the #3151 and #2658 fragments. Those
paths moved again here, and two ack sources naming one path is a hard
duplicate error rather than last-wins, so the inert entries had to go
before this one could land. Both fragments retain their remaining
entries.

Refs #3544

* chore(#3544): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-15 09:31:59 -04:00
Tom Boucher
b7cca0363f fix(#3531): merge routing_tier_defaults over manifest tier defaults (#3539)
* test(#3531): failing-first suite for routing_tier_defaults manifest merge

* fix(#3531): merge routing_tier_defaults over manifest tier defaults

* docs(#3531): document routing_tier_defaults merge-over-built-ins semantics

* fix(#3531): correct test helper scope, update folded #443 expectations, guard merge keys

* test(#3531): pin tiers in effort-sync and surface-axis fixtures post-merge

* chore(#3531): backfill changeset pr number

* fix(#3531): correct rebase resolution — keep both 3531 and 3533 test blocks intact

* test(#3531): pin inherit/effort fixtures to the layer that reaches tiered agents

---------

Co-authored-by: sim <sim@local>
2026-08-15 08:19:35 -04:00
Tom Boucher
59e7a677fe fix(#3511): scope every phase-directory scan to the phase it belongs to (#3535) 2026-08-15 07:02:33 -04:00
Tom Boucher
3d17569d5b Merge pull request #3537 from open-gsd/feat/2873-cross-scope-shadowing 2026-08-15 07:02:03 -04:00
Tom Boucher
adb2d03ed8 Merge pull request #3540 from open-gsd/fix/3532-global-defaults-diagnostic 2026-08-15 07:01:21 -04:00
Tom Boucher
50d5368add fix(#3533): effort inherit — expressible, omitted at writers, never re-added (#3541) 2026-08-15 07:00:44 -04:00
sim
ace777dd56 fix(#3534): hermetic child env for fixture home; contain agent read to agents dir 2026-08-15 03:35:10 -04:00
sim
d1fe1c0cd2 test(#3534): failing-first suite for resolve-execution effective effort 2026-08-15 03:17:57 -04:00
sim
a280054040 fix(#3532): hermetic child GSD_HOME, typed-IR canaries, nested alias, list parity 2026-08-15 01:56:37 -04:00
sim
4fe072283d test(#3532): failing-first suite for shadowed global-defaults diagnostic 2026-08-15 01:38:52 -04:00
Tom Boucher
bc557f6876 chore(#3520): ratchet on effective exemptions, track unverified separately (#3529)
Phase 5 of #3464, following #3465, #3466, #3502 and #3508. Those cut the
ceiling 305 -> 278, made the rule accurate, and ended file-wide amnesty. This
one fixes the number itself.

scripts/lint-allow-test-rule-refs.cjs counted FILES CONTAINING MARKER TEXT.
Only 5 of those files carry a marker that actually suppresses a violation the
rule detects, across 10 sites. The ratcheted number was ~98% noise, which is
exactly why bumping it was frictionless: the metric was never coupled to the
thing it claimed to govern. That is the whole complaint this epic opened with,
stated precisely.

Verified directly rather than assumed: only eslint-rules/no-source-grep.cjs
functionally honors the marker. Four other rule files mention allow-test-rule
in prose only, and no-raw-rmsync-in-tests.cjs:24 explicitly states it does not
apply. So the large count was not legitimately large because several rules
share the annotation.

Now two numbers, only the first ratcheted:

  EFFECTIVE EXEMPTIONS -- markers that actually suppress a detected violation.
  10 sites across 5 files. Tightly ratcheted in both directions, as before:
  over the ceiling fails, and slack beyond grace fails.

  UNVERIFIED MARKERS -- marker-bearing files with no detectable violation. 273
  files. Reported and given a loose ceiling so the pool cannot silently
  balloon, but deliberately NOT tightly ratcheted, because shrinking it is a
  rule-coverage problem and not a delete-the-markers problem.

A file with at least one effective site counts as effective and is not also
counted as unverified; the two numbers never double-count.

Reporting ONLY the effective count was considered and rejected. It would say
five files and look excellent while being falsely reassuring, because "no
detectable violation" is not "no violation". This phase's own measurement found
two genuine source-greps that are unsuppressed AND undetected --
tests/security-prompt-injection.security.test.cjs:852 and
tests/check-update-config-dir.test.cjs:91 -- each reading a real shipped file
and text-searching it, invisible only because the path is bound to a separate
const the rule never resolves back to its literal. Markers guarding that class
count as zero-effective and would look vestigial. Trading a number that is too
big and meaningless for one that is too small and falsely reassuring is not
progress, so the script prints the known-limit caveat alongside the numbers and
the two undetected violations are filed separately rather than lost.

Single source of truth is structural, not a matter of discipline. The script
does not re-implement detection or the site-scoped adjacency predicate -- that
is the generative-fix-divergence class this repo has shipped before. The rule
now exports MAX_MARKER_LOOKAHEAD_LINES, MARKER_COMMENT_RE,
collectMarkerAndCommentLines and isSuppressedAt (extracted verbatim, no logic
change), plus a default-off neutralizeSuppression option so the counter can
enumerate every site through the real rule via ESLint's Linter API and then
classify each with the rule's own predicate, replicating reportUnlessSuppressed's
search-line-OR-read-line check exactly. Default rule behavior is byte-identical:
tests/eslint-rules.test.cjs passes 168/168 unchanged. A parity test asserts the
script's suppressed/not verdict equals the rule's own report/no-report outcome
for every site in a fixture corpus.

A real silent-failure bug surfaced and was fixed while building this: ESLint's
flat-config Linter reports "No matching configuration" and returns ZERO messages
for any filename resolving outside its cwd. That would have quietly
misclassified every sandboxed test fixture as having no violations -- a test
suite that passes while asserting nothing. Fixed by anchoring the Linter to the
tests dir, with a defensive throw if it ever recurs.

Two earlier claims of mine are corrected by this phase's measurement. Widening
the source-dir allowlist to include hooks/ -- the "fifth blind spot" recorded in
#3508 -- rescues ZERO sites; it is real in principle and has no practical
effect, because the hooks reads that exist are missed for other reasons
(.sh extension, identifier-indirection, dynamic filenames). And the #3508
correction that attributed those reads to the hooks/ gap rather than to variable
indirection was itself incomplete: both are independently sufficient, so fixing
either alone changes nothing. I accepted the reviewer's causal claim as
uncritically as I had made my own.

The unverified count is 273, not the ~289 in the phase design doc. That is
legitimate drift -- the baseline was measured at fba7c9032 and other merged work
has since removed markers. Left as measured rather than adjusted to match the
document.

Adversarial review found a BLOCKER in the first revision and it is fixed here.
The counter walked only tests/**/*.test.cjs, but the rule is registered on
tests/**/*.cjs -- every .cjs, not just test files -- plus scripts/**,
eslint-rules/**, bin/lib/**, pi/**, examples/**, gsd-core/bin/** and three
plugin globs. 35 non-.test.cjs files under tests/ were never walked, and
tests/helpers/live-command-registry.cjs:1 carries a real marker that appeared in
NEITHER reported number. A counter that undercounts is worse than the
meaningless one it replaces, because it will be trusted.

The scan set is now derived programmatically: the script dynamically imports
eslint.config.mjs and extracts the `files` globs from every config block that
enables local/no-source-grep. There is no hardcoded list to drift, which is the
same divergence class the parity test already guards.

Two further defects surfaced while fixing it. The silent-clean catch around
linter.verify() was worse than reported -- ESLint signals a parse error by
returning a message with fatal:true rather than throwing, so the original catch
would not even have fired for the common case. Both paths now throw with the
file path. That silent swallow was actively hiding a broken fixture in this
suite's own tests: 'no marker here\n' is not valid JS and the case only
"passed" because the parse failure was discarded. Fixed.

And once the scan widened, the raw-text marker scanner started matching this
tooling's own doc comments and RuleTester fixture strings, so marker extraction
moved to AST comment nodes. That corrected three long-standing FALSE POSITIVES:
allowlist entries for tests/eslint-rules.test.cjs that were never real markers,
only fixture payload. Allowlist 137 -> 135: three false positives pruned, one
real entry added for live-command-registry.

The headline numbers are coincidentally unchanged (10/10 effective across 5
files, 273/280 unverified) but the composition is corrected: one real file
gained, one phantom dropped. Verified by direct diff rather than inferred from
the totals matching.

The first remote run of this branch came back RED with 23 failures, all one
cause, and it is fixed here. Classification drove ESLint's flat-config Linter,
which resolves configuration relative to a cwd and refuses to lint any file
outside it. The test harness writes fixtures into an OS temp dir, so every
sandbox row hit "No matching configuration found" and tripped the defensive
throw. It surfaced only on the container because the repo lives at /work there
and the fixtures at /tmp, making the mismatch unmissable; a local run had
reported the suite green, which it was not.

Fixed by not depending on config *resolution* at all: the script now builds an
eslintrc-format Linter and registers the rule directly with defineRule, since it
already knows exactly which rule and options it wants. That removes the
cwd-ancestor constraint entirely and makes repo files and out-of-tree fixtures
classify identically. Verified out-of-tree explicitly, not just in-repo, because
the local temp path shape is what hid the bug the first time. Parse failures
still throw loudly -- that behavior is required and tested. Classifications are
unchanged for real repo files (10/10 effective across 5, 273/280 unverified,
0 live), which is the check that the config swap did not quietly alter results.

The second remote run cut the failures from 23 to 2, and the survivors were a
genuinely different and more interesting defect:
tests/packaging-shipped-scripts-require-only-shipped.test.cjs caught that
scripts/ SHIPS in the published package while eslint-rules/ does not, so
importing the rule for single-source-of-truth would MODULE_NOT_FOUND in a real
consumer's install. That test statically extracts require() calls, so hiding the
import inside a function would have dodged the check without fixing the problem.

Resolved along the grain of existing convention rather than by weakening
anything: package.json already excludes several repo-internal lint gates from
the shipped set via `!scripts/...`, including
`!scripts/lint-no-adhoc-regex-escape.cjs`, which is the same situation. This
gate is CI-only and has no meaning in a consumer install, so it joins them.
Verified with `npm pack --dry-run` that the .cjs is genuinely absent from the
tarball (its inert JSON config files remain, and carry no requires).

The third remote run failed on shard 3/3 across all three OSes with exit code
NULL and empty output -- the spawned gate was killed by a timeout, not failing
an assertion. Cause: replacing a raw text scan with a full ESLint Linter pass
over every file in every registered glob took the gate from 0.57s to ~7-12s,
and GitHub's runners are slower than the bench that had just passed it green.

Fixed by narrowing the work rather than raising the timeout to hide it. Both
numbers the gate computes are properties of files that CONTAIN a marker, so
only those (~294) need linting; the repo-wide byte walk that finds them stays,
since that was the undercount fix. Live violations in files carrying NO marker
are already enforced by npm run lint over exactly these globs, so re-detecting
them here was redundant. 2.87s now, from ~7s measured locally.

That narrowing changes what one reported number means, so the wording changed
with it: "live violations in marker-bearing files: 0 (unmarked files are
enforced separately by npm run lint)". A number that quietly covers less than
it reads is the failure this whole epic is about, so it is stated rather than
left implicit, and the test row that asserted the old broader behavior was
split -- an unmarked live violation now passes this gate (and is caught by
eslint), while a live violation in a marker-bearing file still fails it.

The repo-baseline test's timeout was also raised to 30s with a comment, since a
gate that legitimately takes ~3s must not sit at a timeout close to its own
runtime. Every other row keeps the shorter sandbox-scoped timeout.

Closes #3520

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:33:31 -04:00
sim
d37e594ec8 fix(#2873): write bidi codepoints as escapes, not literals
The invisible-Unicode scan flagged the compiled sanitizer: BIDI_RE in
install-shadow-report.cts carried literal U+202A-U+202E where its two
neighbouring regexes already used \u{...} escapes, so the module that
strips bidi controls was itself a carrier for them.

The prompt-injection-scan failure alongside it was the same defect rolling
up through the parent describe, not a second cause - verified by running
the scanner across every category it checks.

Test fixtures and property generators now name their codepoints (RLO, LRE,
PDI) instead of embedding invisible bytes, so a reviewer can see which
character is under test.

Refs #2873
2026-08-15 00:02:37 -04:00
sim
2d72577e07 test(#2873): align generated-doc counts and drop text-anchored assertions
The remote runner caught three families this branch caused. W028 moves the
generated health table to 35 rows / 32 rules, so gen-health-docs.test.cjs
is updated in both its assertion and its title - a title carrying the old
count is a test that lies about what it checks.

The three negative-proof rows no longer match a literal report substring.
They assert the typed signal instead: buildShadowReport reports
not_shadowed, renderShadowReport returns no lines, and stderr contains
none of those lines. That takes the new allow-test-rule annotations to
zero rather than lifting the ceiling to fit them.

health.md's growth is acknowledged at its existing key. A second fragment
on the same key is a duplicate-ack error, and the re-arm path only fires
on a reworded reason at the same key.

Refs #2873
2026-08-14 23:48:40 -04:00
sim
147856040b fix(#2873): close review findings across fences, sanitizer and docs
Isolated security review found resolveSpecRootReference's fence tracker
toggled on any delimiter, so a backtick fence could be closed by a tilde
one and an include in the gap was rewritten inside a code block. Fixed by
reusing scanFencedBlocks - the canonical engine already behind
stripFencedCode and extractFencedBlock - rather than carrying a fourth
copy of fence detection, which also closes the duplication the standards
review flagged.

sanitizeForRender now strips combining marks and zero-width characters
alongside the ANSI, control and bidi classes it already handled.

Adds the C, E and F matrix rows the spec review found missing, including
installer-level coverage that spawns the real install rather than calling
the report builder. Ships the how-to, the reference and command docs in
five locales, the changeset, the inventory and glossary entries, and
regenerates health.md for the new W028 rule.

Refs #2873
2026-08-14 23:48:39 -04:00
sim
2641e6cb67 feat(#2873): detect cross-scope shadowing and reach the local spec tree
4a - the detection floor. A shadowed install now reports which triggers are
shadowed and which scope wins, at install time and through a new W028
/gsd-health diagnostic. Exit codes are untouched: a shadowed install is a
warning, not a failure. Only triggers whose stem exists at BOTH scopes are
reported, so a global full profile beside a local core profile no longer
names local artifacts the user does not have.

4b - spec-root reachability, claude runtime and global scope only. The
winning global skill stops carrying a static workflow @-include and instead
resolves its spec at runtime: prefer the project-local copy, fall back to
the global one, stop if neither exists. Every other @-include stays static,
and the local emission is byte-identical. It runs after the staged-skills
rewrite pass, whose claude branch would otherwise mangle the literal tilde
path into an undocumented $HOME form.

Also fixed inline: readInstallManifest classified a top-level JSON array as
an installed v1 manifest, because typeof [] is object.

Refs #2873
2026-08-14 23:48:39 -04:00
sim
d79de3958a test(#2873): add failing-first cross-scope coexistence gate
Installs claude at both scopes into one sandbox HOME - the configuration
#2218 reports - and asserts the shadow report and the spec-root include the
global skill wins with. No production code: two of the four assertions are
expected RED at this commit, which is the TDD gate #2873 requires.

Refs #2873
2026-08-14 23:48:39 -04:00
Tom Boucher
d922469613 refactor(#3408): close the two known limits instead of recording them (#3524)
* refactor(#3408): close the two known limits instead of recording them

Both of these were flagged in review and written down as 'known limits' in a
PR body and an issue comment. CLAUDE.md is explicit that a note is not a fix
and is not surfacing — it is a silent defer. Recording them while closing the
epic was the pattern this epic exists to remove, performed on the epic itself.

syncAndPreserveStateMd and applyPostSyncPreservation each took eight
positional arguments, the last three optional, one of them an out-param. The
review's own wording was that 'a third consumer should trigger an
options-object refactor' — a deferral with a trigger condition nobody would
notice firing. Content and path stay positional; resync, authoritativeFm,
deriveProgressKeys and divergedFields move into a named
StatePreservationOptions. Every call site updated, with tsc as the proof none
was missed.

cmdStateCompletePhase's updated array carried both field labels and a section
name, worked around by a SECTION_ENTRIES Set that re-derived the distinction
by string matching. The kinds are now typed where they are produced and
flattened once at output.

Output contract unchanged: updated is still a flat string array with the same
entries in the same order.

Behavior-preservation was proven rather than asserted — the compiled lib was
built at 411196bc3 and post-fix, and the same fixtures run through each. Both
byte-identical, modulo the clock-driven last_updated.

* fix(#3408): update every non-typed call site, and make a wrong options call loud

The previous commit claimed 'tsc is the proof a site was not missed'. That was
wrong and I asserted it. tsc type-checks src/ only; the test call sites are
plain .cjs and are not checked at all. Fourteen tests failed with
divergedFields: [] because a positional resync boolean landed in the options
slot and every option came through undefined.

Nine stale call sites converted. Also caught: the drift guard suite's E2
fixture embedded the old call shape 'verbatim from src/milestone.cts' — a
fixture that mirrors production and had silently drifted from it.

The deeper defect is that the refactor itself introduced the failure shape
this epic exists to remove. An options-object parameter is silently
mis-consumable by any non-TypeScript caller: pass the wrong thing and the
function proceeds with every option undefined, returning a well-formed,
plausible, empty result. That is precisely ADR-3408's Context section,
reintroduced by the change meant to tidy the code up.

Both functions now assert their options argument is a non-null object and
throw STATE_PRESERVATION_OPTIONS_INVALID carrying the offending type, mirroring
throwUnwiredRow. A test pins that the guard fires on the exact mistake that
produced these fourteen failures.

Verified by probe rather than asserted: a correct options call returns the
expected divergedFields; a legacy positional call throws with the structured
code instead of silently returning empty.

* chore(#3408): drop the changeset — this PR has no user-facing impact

CONTRIBUTING.md: 'PRs with no user-facing impact (test refactors, lint config
changes, CI tweaks, formatting-only changes) can add the no-changelog label.'

Typing this Changed and then exempting it from the docs requirement would have
been wrong twice: it publishes a CHANGELOG entry under Changed when no command
output moves, and it uses a per-fragment exemption to paper over a type that
was wrong to begin with.

Both refactors are behavior-preserving, verified byte-identical against the
pre-change compiled lib. The one new throw guards a module-private seam in
src/state.cts that no external caller can reach.

* refactor(#3408): derive StatePreservationOptions, narrow the guard message

Two findings from the orthogonal reviews on the close-known-limits PR.

StatePreservationOptions repeated all four ReadModifyWriteOptions fields, differing only in resync being required, and divergedFields carried a second independently-worded docstring. It is now derived via Omit so the shared fields have one definition and cannot drift out of hand-sync.

assertStatePreservationOptions echoed JSON.stringify(options) into the thrown message text. The contract for that guard is a structured code plus the offending type, not the value; echoing the value would become a disclosure path if a caller ever passed user-derived data. Removed from the message; err.code and err.receivedType are unchanged, and the test asserts on those.

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:43:56 -04:00
Tom Boucher
8fc88f663d fix(#3210): gate unmet preconditions as blocking-human; cap blocker retries at needs_human (#3528)
* fix(#3210): gate unmet preconditions as blocking-human and cap blocker retries at needs_human

* chore(#3210): add changeset fragment for PR #3528

* fix(#3210): restore blocking-human carve-out and CRLF-safe split

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:01:30 -04:00
Tom Boucher
d2fa696a30 fix(#3479): treat absent default-true mempalace keys as enabled in every prose gate (#3527)
* fix(#3479): treat absent default-true mempalace keys as enabled in every prose gate

The #2982 absent-key fix (capture_artifacts === false) was applied to only
one of the sibling gates. Five more hand-written gates in the mempalace
skill/command mirrors and the curator agent still used positive presence
('when <key> is true'), silently skipping default-enabled behavior
(mirror_kg, diary_journal) whenever the key was absent from
.planning/config.json — inverted from the registry-declared defaults.

Corrected sites, each now disabled only on an explicit false:
- skills/gsd-mempalace-capture/SKILL.md step 3 (mirror_kg)
- commands/gsd/mempalace-capture.md step 3 (mirror_kg)
- skills/gsd-mempalace-recall/SKILL.md step 3 (mirror_kg)
- commands/gsd/mempalace-recall.md step 3 (mirror_kg)
- agents/gsd-mempalace-curator.md tasks 1+2 (diary_journal, mirror_kg)

Default-false keys (mempalace.enabled, cross_project_tunnels) keep their
positive-presence gates. New #3479 regression cases in
tests/mempalace-capture-gate-default.test.cjs lock each site's absent/explicit-false
boundary and add a registry-parity guard: no gate file may positively gate
any mempalace boolean whose registry-declared default is true.

* chore(#3479): acknowledge curator size growth from the gate rewording

* chore(#3479): add changeset fragment for PR #3527

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:01:15 -04:00
Tom Boucher
49b60070a0 fix(#3503): derive the code-review diff base from GSD's own commit scopes, not prose mentions (#3526)
* fix(#3503): derive the code-review diff base from GSD's own commit scopes, not prose mentions

The #2989/#3191 anchor ('[Pp]hase N' + POSIX boundary) still resolved the
phase diff base ~4 phases early on real repos: git log --grep searches full
commit bodies and tail -1 keeps the OLDEST match, so a single prose mention
anywhere in history (a planning commit forward-referencing the phase per
D-09, a doc commit using '### Phase N' as a format example) silently
captured the base — while GSD's own commits, which use conventional-commit
scopes (docs(phase-6):, feat(6-01):, docs(06):) and never contain the
literal 'Phase N', were matched by nothing. The wrong base inflated the
Tier-3 file-list fallback, the #2666 SUMMARY/diff union, the reviewer
agent's diff_base, and fallow's --changed-since scope, with no warning.

All three derivation sites (Tier-3 fallback, spawn_reviewer, fallow
structural pre-pass) now grep for the subject-line conventional-commit
phase scope under --extended-regexp, in lockstep per the #3191 contract:

  ^[[:alpha:]]+!?\((phase-)?(N|0N)(-[0-9]+)?\)!?:

PHASE_SCOPE_NUM accepts both padded and unpadded phase spellings because
workflows emit the unpadded roadmap number (docs(phase-6):) while
code-review greps the zero-padded PADDED_PHASE. The ^ anchor makes it a
subject-line match, so commit-body prose can never capture the base. The
POSIX-ERE portability rule (#3191, no \b), the fail-closed empty-result
warning, and the --files escape hatch are preserved: histories with no
scope-style commits yield no base instead of an arbitrary one.

Tests (tests/code-review-pipeline-regression.test.cjs): new Bug 6 (#3503)
block executes the SHIPPED bash from all three sites against a real git
fixture whose history carries every prose false-positive class from the
issue — red pre-fix (the prose-body commits captured the base at every
site), green post-fix. The Bug 5 (#3191) block is updated to the scope
anchor contract (its fixtures bound to the shipped text), and its T6
docs-parity guard now enforces the identical scope-anchored grep plus the
PHASE_SCOPE_NUM prep at every git-log site.

Emitted drift: 3503-diff-base-scope-anchor.json acks the deliberate
workflow growth; the spent 3191-unanchored-grep-sites.json fragment (its
code-review.md entry was consumed when #3191 merged) is pruned.

* chore(#3503): add changeset fragment for PR #3526

* fix(#3503): rebase onto next and correct the emitted-drift ack

Rebase onto origin/next@6badb839 (PR freshness: #3514/#3516 landed after
this branch was cut). Post-rebase the attribution gate classifies the two
source-path ack entries (gsd-core/workflows/code-review.md, structural-
pre-pass.md) as stale — source files present in the diff are identity-
attributed by the table, so only the emitted code-review.md basename
growth needs an acknowledgment. The fragment now names exactly that one
consumed entry.

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:01:00 -04:00
Tom Boucher
3893d1ff69 fix(#3518): pin uat_path to the phase's own UAT artifact via the shared phase-pinned resolver (#3525)
* fix(#3518): pin uat_path to the phase's own UAT artifact via the shared phase-pinned resolver

Both uat_path projectors in src/init.cts picked the phase's UAT file with a
bare .find() over unsorted readdir order — no phase-membership check, no
ordering — so a stray cross-phase 04-UAT.md in phase 03's directory could
become phase 03's uat_path, filesystem-dependently (creation order on APFS,
hash order on ext4/XFS): two machines on the same commit could emit different
uat_path values for the same phase.

Route both sites through a new resolveUatFile in src/verification.cts, the
UAT counterpart of #3357/#3492's resolveVerificationFile, sharing the exact
selection rule via one extracted core (resolvePhaseArtifactFile): the phase's
own <token>-UAT.md always wins; otherwise the alphabetically-first dashed
candidate (deterministic everywhere); a bare UAT.md only via allowBare when
no dashed candidate exists. resolveVerificationFile now delegates to the same
core — behavior byte-identical.

Guarded by: two end-to-end repro tests in tests/init.test.cjs (plan-phase and
phase-op, red on the pre-fix readdir pick), resolveUatFile contract anchors
and a src/-wide call-site guard in tests/verification-status.test.cjs.

* chore(#3518): add changeset fragment for PR #3525

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:00:43 -04:00
Tom Boucher
1b027298dc fix(#3481): resolve add-roadmap-evolution's phase from STATE.md, not a literal ? (#3522)
* fix(#3481): resolve add-roadmap-evolution's phase from STATE.md, not a literal `?`

`state add-roadmap-evolution` built its entry from the raw `--phase` flag
alone, so omitting the flag persisted `- Phase ?` even when STATE.md's own
frontmatter carried `current_phase` above the insertion point — the #3231
defect at a second call site. Roadmap-evolution entries are the permanent
trail explaining why the roadmap changed shape; `Phase ?` makes that trail
unattributable, and the command is mostly invoked from agents that do not
know to pass `--phase`.

The #3481 triage confirmed the #3231 sibling site (`add-decision`) was also
still unfixed on next — both PRs that attempted it (#3232, #3347) were closed
unmerged. This applies the #3347 treatment to both call sites:

- Extracts the write-path phase-resolution ladder `cmdStatePrune` already
  ran — frontmatter `current_phase` → body `Current Phase` field → prose
  `Phase: X of Y` scoped to `## Current Position` — into a shared
  `resolveCurrentPhaseId`, and routes `cmdStateAddRoadmapEvolution`,
  `cmdStateAddDecision`, and `cmdStatePrune` through it.
- Deliberately NOT routed through `resolveStatePhase` (#3208): its
  `matchCurrentPositionSection(body) ?? body` fallback widens the prose rung
  to the whole document when no `## Current Position` section exists, where
  the pipe-table fallback matches any historical `| Phase | N |` row (#1776).
  Read-path callers (snapshot/validate) report to a human; write-path callers
  persist durably, so they take the strict rung and render `?` instead of
  guessing.
- The resolved id is returned as written, never parsed to a number (`11-01`
  and `04.1` are real ids). Prune still parses its own integer cutoff, so its
  behavior is byte-identical.
- Explicit `--phase` still wins and its path is untouched — STATE.md is not
  even read. When no rung resolves, `?` is still written.

Tests: per-call-site coverage for both commands — omitted `--phase` resolves
(including a non-integer prose id), explicit `--phase` wins, and two
counter-tests pinning the degraded verdict (nothing resolvable → `?`, and a
historical `| Phase | 7 |` table row must NOT be adopted). Plus a static
guard sweeping src/*.cts for the raw `phase || '?'` placeholder shape so a
future call site cannot reintroduce the class.

Fixes #3481

* chore(#3481): add changeset fragment for PR #3522

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:00:26 -04:00
Tom Boucher
507db38404 fix(#3497): unescape double-quoted scalars on parse so round-trips stop doubling backslashes (#3521)
* fix(#3497): unescape double-quoted scalars on parse so round-trips stop doubling backslashes

* chore(#3497): add changeset fragment for PR #3521

---------

Co-authored-by: sim <sim@local>
2026-08-14 23:00:10 -04:00
Tom Boucher
6badb839a0 fix(#3514): deny internal fetch hosts; disclose unverified integrity (#3516)
* test(#3514): add failing-first denylist and integrity suites

* fix(#3514): deny internal fetch hosts; disclose unverified integrity

* docs(#3514): trust-model, glossary, and changeset entries

* fix(#3514): scope v6 checks to literals; exact pin kinds in prompt

* chore(#3514): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:34:29 -04:00
Tom Boucher
268ca7e32d fix(#3504): harden hook injection patterns and force-add guard (#3510)
* test(#3504): add failing-first parity, fail-closed, and bypass suites

* fix(#3504): harden hook injection patterns and force-add guard

* test(#3504): stage the scanner lib dependency in shared-hooks fixture

* chore(#3504): backfill changeset pr number

* test(#3504): build parity samples from fragments for the ci scan

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:19:35 -04:00
Tom Boucher
e57918a648 fix(#3515): disclose the intentional mcp unconfined posture (#3517)
* test(#3515): add failing-first unconfined-mcp notice suite

* fix(#3515): disclose the intentional mcp unconfined posture

* chore(#3515): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:19:04 -04:00
Tom Boucher
411196bc3a refactor(#3471): one enforcement point for the empty case, and reports that match the disk (#3519)
* refactor(#3471): one enforcement point for the empty case, and reports that match the disk

Implements ADR-3408 section 8.5 and section 8.4's residue (folded in when Phase
3 closed as subsumed). Four items, and two findings the design did not predict.

FINDING 1 — the guards could not simply be deleted, as the design instructed.
state sync and REGENERATE_STATE never run applyStatePreservation at all, so
those six conditions were their ONLY empty-field fallback. A baseline probe on
the unedited tree confirmed unconditional deletion drops current_phase,
current_phase_name, current_plan, stopped_at and paused_at from a blank-body
STATE.md on state sync — breaking the byte-identical requirement section 8.3
grants those two sanctioned-permanent exceptions. They are now GATED, not
deleted: on for the exceptions, off for the write seam, where an empty derived
value finally reaches the executor unmolested.

FINDING 2, the more serious one — there was a FOURTH encoding of this policy.
The pre-existing #2202 unknown-key carry-forward loop independently restored
the same six fields whenever derivedFm lacked the key, completely neutralizing
the fix. It is named nowhere in the ADR, the design, or three prior phases. It
was found only because a probe that should have passed did not: the first
attempt reported divergedFields: [] and silently restored both fields,
reproducing the exact bug this phase exists to close.

That is worth stating plainly. This epic's thesis is 'policy declared in one
table, enforcement hand-rolled per call site.' The final phase found one more
call site than anyone had counted — which is the fourth consecutive time a copy
count in this epic proved to be a lower bound.

Also: divergedFields could only observe fields the executor actively RESTORED,
by diffing postFm. A discard-to-empty is absent both before and after, so it
was invisible. A second pass now reports it, which is what makes section 8.5's
'preservation is visible' true for the delete-the-body-line case rather than
aspirational.

cmdPhaseComplete now reports what it preserved — #3374 was filed against that
command and its complaint was warnings: [], silence.

cmdStateJson's private third copy of the guards is routed onto the executor's
preserve-when-unchanged rule. A read is definitionally not a write, so the
#1230 delta is 'unchanged' and curated wins over a stale annotation.
shouldPreserveExistingProgress is a different rule and is untouched.

Report reconciliation is ONE shared helper across seven commands, not five
copies of fix(#3351)'s block. Five copies of a reconciliation is precisely the
shape this epic removes, and introducing it in the final phase would have been
a poor joke. Both untraced commands were traced rather than assumed:
cmdStatePlannedPhase matched cmdStateBeginPhase exactly; cmdStateCompletePhase
turned out to be a different legacy hand-rolled path reporting a mix of field
names AND a section name, where the naive helper would have dropped 'Current
Position' as a false negative every time.

* test(#3471): characterization coverage for one enforcement point and reconciled reports

Matrix sections A-E, asserted at the consumer's output per ADR-3180 Decision
4(b)/(c) — this phase owes Decision 5's outcome metric, the one the drift
guard's zero may never be reported without.

Three walls matter more than the new coverage:

  A2 is SIX separately named tests, one per gated guard, not one parameterised
  assertion over a list. A list is trivially shortened later; six named tests
  are not, and six guards is exactly where a field gets silently dropped.

  A6 pins what Phases 1-3 already fixed — non-empty stale body, delta
  unchanged, losing to fresher curated frontmatter, with the divergence
  reported. If A6 reddens, this phase broke the thing the epic was for.

  D1/D2 pin state sync byte-identical. The implementation had to GATE the six
  guards rather than delete them precisely because state sync has no executor,
  and a baseline probe showed unconditional deletion drops five fields.
  Nothing else in the suite would notice that regression.

E6 covers #3345's direction — a field preservation restored that the intent
never named IS reported. Nothing has ever tested that direction.

Assertions were empirically verified against the compiled lib and the real CLI
before being written, since the suite cannot be executed locally. That caught
two type bugs in the draft: fm.current_phase after a quoted-YAML round-trip is
the string '5', not the number 5.

E5 is recorded as structurally unreachable rather than weakened or faked. Those
four commands report body Title-Case labels, which cannot string-collide with a
frontmatter snake_case key the way cmdStatePatch's arbitrary field names can —
which is why fix(#3351) targeted only cmdStatePatch. Testing it directly would
need reconcileReportedFields exported from private scope; the helper is
exercised through E6 and all seven commands instead.

* docs(#3471): amend ADR-3408 section 8.5 — a fourth enforcement point, and guards that could not be deleted

Amendment 3. The contract held; two of section 8.5's own statements did not.

It said the six empty-only guards are DELETED. They cannot be. writeStateMd is
the sole path for both section 8.3 sanctioned-permanent exceptions and never
runs applyStatePreservation, so those guards were their only empty-field
fallback. A baseline probe on the unedited tree confirmed unconditional
deletion drops five fields from a blank-body STATE.md on state sync, breaking
the byte-identical guarantee section 8.3 grants it. They are gated instead.

It also mis-located cmdStateJson's guards, describing them as living in
syncStateFrontmatter. They were a separate private copy on the read path with
no delta check at all, so a stale body annotation always beat fresher curated
frontmatter in state.json — #3395's shape entirely outside the write seam.

THE FINDING: a fourth enforcement point nobody had counted. The pre-existing
#2202 unknown-key carry-forward loop independently restored the same six
fields, silently neutralizing the fix. It is named nowhere in this ADR, in the
phase design, or in three prior phases, and was found only because a probe that
should have passed did not.

Fourth consecutive time a copy count in this epic proved a lower bound: 2
write-seam bypasses became 4, three preservation encodings became four, and the
estimate was wrong every time. ADR-3180's standing rule has earned itself in
every phase — read the code, not the write-up.

Records the Row 2 decision (a discard-to-empty wins per the delta rule and is
reported, not silent — the sharpest Hyrum exposure in the epic), section 8.4's
residue landing as ONE shared reconcileReportedFields across seven commands
rather than five copies, and the parity assertion added because
FRONTMATTER_KEY_TO_BODY_LABEL was itself a second table that failed silently —
this epic's shape in miniature, in its final phase.

* fix(#3471): repair four regressions the checkpoint caught

Checkpoint returned 16 failures of 34389: six real regressions in pre-existing
tests, plus seven of my own test bugs.

My hypothesis was wrong and is recorded as such. I predicted the #2202
carry-forward skip was the cause, reasoning it had removed a load-bearing
fallback the way the six guards nearly were. It was not implicated in any of
the six. Three unrelated causes:

#2111 — current_phase came back undefined from milestone complete, which is
the epic's own defect class reintroduced by its final phase. Root cause is
Row 2 working exactly as designed: milestoneCompleteCore rewrites the body
Phase: line to a closure message, so current_phase's #1230 delta reads
CHANGED and the new rule correctly discards the curated value. The transition
never declared any intent to touch that field. Fixed by re-asserting
current_phase and current_phase_name through authoritativeFm — the existing
#2736 mechanism beginPhaseCore and completePhaseCore already use — rather
than by weakening Row 2, which A5 pins.

That interaction is worth naming: a rule that keys on 'did this write change
the body source' will fire on a transition that moves the body line for an
entirely unrelated reason. The design did not anticipate it.

#1264 / #3242 / the state.patch progress report — reconcileReportedFields
folded EVERY divergedFields entry into updated, including preserve-always
progress restores no caller asked about. Now scoped to preserve-when-unchanged
rows only.

#1162 / case-insensitive table fields — valueOf checked frontmatter before
body, so a lowercase table field name exact-matched the lowercase frontmatter
key sync always derives, comparing stale pre-sync body text against a
post-sync frontmatter enum. Flipped to body-first.

That last one is the SAME lesson as Phase 2's patchCore, recurring in a
different function two phases later: in this model the body is authoritative
and frontmatter is the projection, so a name that could mean either resolves
body-first. Twice now.

Test bugs: a stray unused parameter shifted every argument at six call sites,
so body arrived undefined; and A4 compared nested progress scalars against
numbers when extractFrontmatter returns raw YAML strings. The string-vs-number
YAML round-trip has now been caught three times in this phase alone.

* test(#3471): one helper for the progress coercion that bit four times

A2f failed on the string-vs-number YAML round-trip: extractFrontmatter returns
nested progress scalars as raw YAML strings, so a comparison against numeric
literals can never pass.

This is the FOURTH time this exact class has been caught in this phase — twice
during test authoring, once as A4 in the previous checkpoint, now as A2f.
Patching it a fourth time by hand would guarantee a fifth.

Added numericProgress() with a comment saying why it exists, and routed every
progress-reading assertion in the #3471 block through it. Swept the block:
C3 needed no change, because cmdStateJson's output already runs through
normalizeProgressNumbers.

Deliberately NOT shared with frontmatter.test.cjs's readPersistedProgress:
that one is path-based and re-reads from disk, while these assert on an
in-memory string that is never written. Sharing would have meant either a
disk round-trip these tests do not do, or duplicating half the helper — so
the coercion pattern is mirrored locally and the reason recorded, rather
than manufacturing a dependency to satisfy the letter of consolidation.

* chore(#3471): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
2026-08-14 21:06:01 -04:00
Tom Boucher
fba7c90327 chore(#3484): adr-0174 behavior carry-forward amendment and merge gate (#3507)
* chore(#3484): adr-0174 behavior carry-forward amendment and merge gate

* chore(#3484): regen example context index for new ruleset predicates

* chore(#3484): review fixes - amendment heading per contributor-standards, helper-based fixtures

---------

Co-authored-by: sim <sim@local>
2026-08-14 19:31:48 -04:00
Tom Boucher
ddf852873c fix(#3357): one phase-pinned resolver for verification-report discovery (#3513)
A phase directory can hold more than one `*-VERIFICATION.md` — an ad-hoc `03-CORRECTION-VERIFICATION.md` worksheet beside the real `03-VERIFICATION.md`. Discovery took the alphabetically-first match, so the worksheet won and the phase could report `missing` while a passing report sat next to it.

The issue named two copies. There were seven, in four grammars: two `.sort()[0]` sites in the verification module, three `.find()` over UNSORTED readdir order (phase status, and `verification_path` twice — filesystem-dependent, so two machines on one commit could disagree), and two in shell. All seven now route through one exported `resolveVerificationFile`; the shell copies via a new `verification resolve-file` verb rather than hand-rolling the rule an eighth time.

Fixing five of seven would have been worse than fixing none: the verify-work workflow is a WRITER that stamps `status: passed` onto the file it picks, so canonical-aware readers plus an alphabetical writer means the human_needed→passed canonicalization silently no-ops forever while the worksheet gets stamped. That divergence did not exist on next.

The first resolver was itself a regression — it preferred ANY canonically-shaped name over the phase's own report, so a stray cross-phase or sentinel-numbered file outranked it. The global-canonical preference was removed rather than narrowed; the rule is pinned to the phase token via `PHASE_NUMBER_TOKEN_SOURCE`, its existing owner.

Fixed in passing: the transition workflow's awk guarded on `NR==1` instead of `FNR==1`, so across a multi-file glob it armed only on the first file — a leading worksheet with no frontmatter blocked transition even when the canonical report passed. Also removed a U+00AD soft hyphen introduced earlier on this branch.

Five broader-grammar AGGREGATE scans are deliberately out of scope — a different defect class (phase-unscoped scanning), tracked as #3511.

Closes #3357

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:18:29 -04:00
Tom Boucher
c66b010052 chore(#3508): site-scoped allow-test-rule suppression (#3512)
Phase 4 of #3464, following #3465, #3466 and #3502. Those cut the ceiling
305 -> 278 and made the rule accurate. This closes the remaining structural
weakness: suppression was FILE-WIDE, so a single justified exemption silently
absolved every other source-grep in that file, forever, including ones added
later by someone else.

hasAllowAnnotation did comments.some(...) over the whole file and returned {}
early. A marker is now checked per report: a violation is suppressed only by a
marker on its own line, or on a line above it with nothing but blank lines and
other comments in between, bounded by MAX_MARKER_LOOKAHEAD_LINES = 8.

The bound is comment-purity rather than raw distance, and that distinction is
load-bearing: an intervening line of real code (a `test(...)` opener, say)
ends the window even when the marker is physically close. Chosen from the
actual placements in the affected files rather than picked a priori -- the
repo's convention puts several lines of prose rationale between the marker and
the code, so a tighter rule would have invalidated legitimate existing markers
and forced churn for no correctness gain.

Measured before writing any code, by running the real rule with the
suppression check neutralized across all 1194 files its globs match: 14
violation sites in 8 files, and ZERO in files carrying no marker -- so the
green build was legitimate, and the entire migration surface was those 14.

11 sites were mechanical: an existing marker already stated the right reason,
it just sat too far away. Those were relocated to their call sites with the
original #NNN citations preserved.

Three were orphans -- the file's markers were about an entirely different
concern and nobody had ever justified these reads. All three are fixed
BEHAVIORALLY, with no new markers:

  install-minimal-hooks.test.cjs:975 asserted
  src.includes('gsd-update-check') && src.includes('replace(') against
  bin/install.js. It now calls the exported stripStaleGsdHookBlocks() on a
  legacy TOML fixture and asserts the actual stripped output. This is the
  case this phase was opened around: it could be added with no review friction
  and stay invisible indefinitely under file-wide amnesty.

  config.test.cjs:1917 regex-tested src/init.cts for detectGitCreateTag. It now
  drives `init complete-milestone` and asserts the git_create_tag field.

  config-schema.property.test.cjs:1107 did the same for detectFallowConfig; it
  now drives `init code-review` and asserts fallow_enabled.

Each was proven RED against a broken production file and GREEN against the real
one, with src/init.cts and bin/install.js confirmed byte-identical afterwards.

Marker lines in the 8 files went 20 -> 24, against a filed expectation of
"must not increase" (projected 14). That projection was wrong and is corrected
on #3508 rather than met by deletion. It assumed every existing marker was a
distant blanket that site-scoping would consolidate. Some are already
site-adjacent and guard real source-greps the rule CANNOT detect -- verified in
install-minimal-hooks.test.cjs:2686-2757, where seven markers each sit directly
above a readFileSync(reloadScript) + .includes() pair reading
hooks/gsd-config-reload.js. Removing them to hit a number would have repeated
the Phase 1 mistake: deleting markers on "the rule doesn't fire" evidence when
the rule provably cannot see the violation.

An earlier revision of this commit message attributed that invisibility to the
#3502 dynamic-path blind spot, on the grounds that reloadScript is a variable.
Adversarial review caught that as a false causal claim and it is corrected
here. looksLikeSourcePath's hasSourceDir regex is
/['"](?:bin|lib|gsd-core|src)['"]/i, and those reads target hooks/ -- so a
fully literal path.join(ROOT,'hooks','gsd-config-reload.js') is equally
invisible. The variable indirection is irrelevant. This is a FIFTH, distinct
blind spot: the source-dir allowlist omits hooks/, which is a real shipped
production directory (eslint.config.mjs registers its own rule block for
hooks/**/*.js). Recorded in 40-design.md Known limits and left for a follow-on
phase -- widening the allowlist is unmeasured, and measuring before widening is
the discipline #3502 established. The conclusion was right; the stated
mechanism was not, and asserting an unverified cause is the error being
corrected.

The honest metric is not fewer markers. It is that every marker now sits
adjacent to the specific read it justifies instead of absolving a whole file.
Site-scoping turns one blanket marker covering N sites into N site markers by
design; the count rising is the mechanism working.

A second review finding is fixed here too. Suppression originally keyed only
off the text-search line, so a marker placed directly above the readFileSync()
call -- the intuitive place to annotate "this read is fine" -- did NOT suppress
when the search sat on the following line, because the read's own assignment
line breaks comment-purity. It failed safe (a loud error, never silent
suppression), but it was a trap contributors would hit, and it contradicted
this change's own claim that the placement rule would not force churn. A
violation is now suppressed by a marker adjacent to EITHER the search site or
the originating read. The violation is fundamentally the read+search pair, so
annotating either half is legitimate, and it stays strictly site-scoped -- the
decisive isolation row still holds.

17 RuleTester rows cover the new semantics. The decisive one asserts that a
marker adjacent to one violation does NOT suppress an unrelated violation
elsewhere in the same file -- exactly 1 error, reported at the second site.
Teeth-checked by reverting the predicate to file-wide, confirming that row and
two others flip pass->fail, then restoring. Two pre-existing RuleTester cases
that asserted the old file-wide semantics were corrected.

Compatibility held where it matters: 277 marker-bearing files have no
detectable violation at all, and site-scoping makes their markers no-ops rather
than errors. All stay green, untouched.

Ceiling unchanged at 278; lint-allow-test-rule-refs reports 278/278.

Deliberately not done here, and recorded for the follow-on phase: the same
measurement found only 8 of 285 marker-bearing files contain a detectable
violation. That suggests a large honest ceiling drop, but "the rule doesn't
fire" is the unsound oracle that forced the Phase 1 revert of 295 files, and
the rule still has documented blind spots -- as install-minimal-hooks itself
demonstrates above. It needs two independent signals agreeing, which is only
credible now that the rule is accurate. With suppression site-scoped, "an
effective exemption" is finally well-defined, which is what makes re-pointing
the ratchet at effective exemptions -- rather than at marker-text presence --
the natural next step.

Closes #3508

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:02:14 -04:00
Tom Boucher
7a02f98574 fix(#3493): confine key_links from:/to: to the project directory (#3506)
`cmdVerifyKeyLinks` resolved `from:` and `to:` with `path.join(cwd, <value>)` where the value comes verbatim from plan YAML. `path.join` normalizes `../` rather than rejecting it, so a plan travelling with a repository could name any file the process can read, and the command reports whether the link's `pattern` matched it — an arbitrary-file-read oracle reachable from `verify-phase`.

Both reads now go through `validatePath` in src/security.cts, the existing realpath-based confinement seam already used at 11 call sites. Not a missing capability — a bypassed one.

Two defects in that seam, found by adversarial review and fixed here because they affect all 11 callers:

1. A dangling in-project symlink escaped confinement. A link to an EXISTING outside path was refused (realpath lands outside) while a link to a MISSING outside path took the parent-resolution fallback and was accepted — an existence oracle for arbitrary absolute paths. lstat succeeds on a dangling link and throws ENOENT on a truly absent path; an unresolvable link is now refused. A symlink resolving inside the project is still accepted.

2. A canonicalized base was compared against an uncanonicalized path when a file and its parent were both missing, wrongly refusing legitimate in-project paths on any non-canonical cwd (every macOS temp dir). This was a live regression in this PR: the wave-pending classification (#1202) depends on the not-yet-created case. Resolution now walks up to the nearest existing ancestor.

Two adjacent aborts fixed: the from: read sat outside the per-link try, so a non-ENOENT errno killed the whole command; and an empty from: read the cwd directory, throwing EISDIR. Both now fail per-link.

The issue was filed as a fourth ADR-0174 consolidation loss. It is not one — validatePath/requireSafePath never went away, only the SDK's name for the concept did. This is an instance of epic #3473's F2 family. The ADR-0174 loss count is three.

Closes #3493

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:29:58 -04:00
Tom Boucher
e57e3b5d6e chore(#3502): widen no-source-grep, close four measured blind spots (#3505)
Phase 3 of #3464, following #3465 and #3466. Those phases cut the exemption
ceiling 305 -> 278 by removing vestigial markers and rewriting real
source-greps behaviorally. This one addresses why the ratchet was weak in the
first place: it counts markers, and markers correlated only loosely with
violations, because the rule's implementation was far narrower than its intent.

Four gaps, each measured against 833 files under tests/ before any code was
written:

  A  TEXT_METHODS omitted matchAll, split and replace, and never handled the
     regex-side form re.test(tracked) / /lit/.test(tracked) where the tracked
     value is the ARGUMENT rather than the callee object.
  B  The extension test was /\.(?:cjs|js|ts)/, which does not match .cts,
     .mts or .mjs. Under ADR-457 this repo's production modules live in
     src/**/*.cts, so the rule has been structurally blind to the entire
     TypeScript source surface since that migration. Highest-value fix here.
  C  Tracking stopped at one hop, so an intermediate transform
     (const b = strip(a); b.match(...)) escaped.
  D  Variables were tracked by NAME in a flat Set<string>, with no scope
     resolution, so a name reused across describe/test blocks was conflated.

D removes one verified false positive where an outer `const src =
readFileSync(...)` was cross-attributed to a shadowed arrow-function parameter
of the same name.

Deliberately NOT implemented: flagging reads whose path cannot be statically
resolved. Measured at 4255 sites across 255 files, 66 of them newly red, with a
6-of-6 false-positive rate in spot-checking -- every sampled site read a
markdown workflow or fixture doc through a path variable, not JS source. The
heuristic "no JS extension literal present" inverts to "not a source file" in
this codebase. Shipping it would have manufactured exactly the marker-spam
dynamic this epic exists to stop. The principled version needs real static
resolution (constant-folding path.join and template literals) and is left to a
future phase.

The widening surfaced two genuinely-invisible violations, handled on their
merits rather than uniformly:

  tests/verifier-behavior-unverified.test.cjs read src/verification.cts and
  regexed it for the VERIFIER_STATUSES array. Fixed BEHAVIORALLY with no
  marker: that constant is already exported, so the test now asserts the real
  runtime value -- strictly stronger, and immune to source formatting.
  Mutation-checked: injecting present_behavior_unverified into the exported
  array turns it red, restoring turns it green.

  tests/adr-index-gate.test.cjs scans src/plan-drift-guard.cts for
  docs/adr/<name>.md citations and asserts each cited ADR exists. Those
  citations live in COMMENTS, erased at compile time: no exported value, no
  runtime observable, and making it behavioral would mean contorting production
  code into exporting its own documentation citations. Irreducible, so it takes
  one marker, cited to #3502, stating exactly why. Documenting a real exemption
  beats leaving the violation invisible, which was the status quo.

Two defects in this branch's own work, both found by review and fixed here
rather than shipped:

  FALSE POSITIVE (adversarial review). Hop propagation walked every Identifier
  in a declarator init and treated any reference to a tracked variable as
  derivation, regardless of whether the derived VALUE still carried source
  text. So `const len = raw.length; /^\d+$/.test(len)` was reported as a
  source-grep. Propagation is now value-shape aware: it follows identity,
  string-returning string methods, split/join, template embedding, string
  concatenation, conditional branches and call arguments; it stops at .length,
  numeric methods (indexOf/search/charCodeAt), boolean methods
  (includes/startsWith/test), comparisons, negation, typeof, and
  Number/parseInt/Boolean coercions. Unrecognized shapes still propagate --
  the conservative default for a linter is a rarer false positive over a silent
  false negative, and that choice is documented inline. Seven RuleTester rows
  now cover this axis, which was previously untested.

  SUPER-QUADRATIC SCAN (security review). resolveVariable() resolved each
  identifier with two linear Array.find passes over scope.references and
  scope.variables, once per identifier walked -- O(vars-in-scope) per lookup.
  On a synthetic single-scope file of N consts each referencing ~20 priors:
  4.76s at N=3000 and 24.32s at N=6000 (~5.1x for 2x N). Replaced with a
  Map<IdentifierNode, Variable> built once per file, lazily, from the scope
  manager: 0.19s and 0.37s for the same inputs (~1.95x for 2x N, linear).
  Semantics unchanged. No measurable effect on the real repo either way, but
  this is exactly the bug class ADR-3212 / local/no-unbounded-quantifier
  exists to catch, and this repo has a prior incident where a rule written to
  catch complexity bugs shipped with one of its own.

The per-widening measurement had a flaw worth recording: each widening was
measured in isolation, so a site needing TWO at once appeared in neither
column, and the UNION column was dominated by the rejected dynamic-path noise
and never inspected for interactions. adr-index-gate needs both B (.cts) and A
(.matchAll) and was missed for exactly that reason. Real newly-red count was 2,
not the 1 predicted. Corrected on #3502 rather than quietly amended.

Marker-bearing files 277 -> 278 against an unchanged ceiling of 278. The
ceiling is NOT raised: the sole addition is the cited irreducible exemption.

27 RuleTester rows cover the change. The valid rows carry as much weight as the
invalid ones -- shadowed same-name bindings, sibling block scopes, .md and
.json literal reads, dynamic path variables, non-textual derivations, reads
never text-searched, and require() of a .cjs must all stay valid. A widening
that flagged those would be worse than the status quo, because it would push
contributors toward adding markers to silence noise.

Known limit, documented rather than papered over: a tracked value round-tripped
through an array or object literal and read back via destructuring is still not
tracked. Pre-existing, not introduced here, and deliberately not widened for --
closing it means tracking member identity, with its own false-positive surface.

Closes #3502

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:19:24 -04:00
Tom Boucher
e2f4c16d9e refactor(#3469): one composition for the STATE.md write seam (#3501)
* docs(#3469): amend ADR-3408 section 8.3 — the pipeline has sanctioned exceptions

Section 8.3 read 'Every STATE.md write applies the pipeline.' That is false by
design for two commands, and acting on it would have inverted a shipped
feature.

Preservation makes curated frontmatter win over a re-derived body value.
state sync exists to do the opposite — #905's 'body annotation beats existing
frontmatter when both are present'; it re-derives frontmatter FROM the body.
REGENERATE_STATE is a factory reset that rebuilds STATE.md from scratch.
Applying the pipeline to either would re-lock exactly what the command was
invoked to replace.

This issue's own scope line, inherited from the epic, said to route the direct
writeStateMd callers through the pipeline. For cmdStateSync that would have
shipped silently, with every gate green, because no test asserts that sync
LETS the body win. Caught by reading the helper's docstring and then verifying
the claim against the code — a stale comment had already misdirected this epic
once.

Both commands are now named in a closed exception list and are permanent
ratchet entries.

Consequence recorded rather than left to bite Phase 4: the 'drive the ratchet
to 0 and delete the file' target in this ADR and in #3471 is wrong. Two
entries are permanent, so the correct end state is 2, and the honest report is
'0 removable bypasses, 2 sanctioned'. A guard reaching 0 here would only do so
by having stopped looking at two real writers.

* refactor(#3469): one composition for the write seam, not one per caller

Implements ADR-3408 section 8.3 as amended.

syncAndPreserveStateMd is now the single composition of syncStateFrontmatter
and applyPostSyncPreservation. readModifyWriteStateMd and cmdPhaseComplete
both CALL it instead of each assembling the two steps themselves.
cmdPhaseComplete keeps its own writePlanningFileSet envelope — the
composition returns content, it does not take over the write, so STATE.md
still commits atomically with ROADMAP and REQUIREMENTS.

Assembling the stages at a call site is a re-derivation even when every step
calls an owner. Upstream's fix(#3374) routed cmdPhaseComplete through
applyPostSyncPreservation but left it calling syncStateFrontmatter directly
first, so the composition was duplicated and free to diverge with both guards
green. That is ADR-3180 Amendment 2's finding repeating on the write side.

cmdMilestoneComplete gains preservation. It wrote through writeStateMd, so it
got sync and no preservation — the identical shape #3374 reported for
phase.complete, and flagged upstream as a follow-up in the helper's own
docstring. This is that follow-up.

Divergence is now visible: preservation_warnings names each field restored
over a disagreeing derived value. Deliberately NOT named warnings —
cmdPhaseComplete already exposes warnings as a prose string array, and two
sibling commands carrying that name with different element types is
Generative Fix Divergence, the class this epic exists to remove.

patchCore stops running stateReplaceField over the whole document. One
observable consequence, intended per design row 9: a frontmatter-shaped patch
key with no body counterpart now reports failed instead of silently
succeeding, because the old whole-document match was literally hitting the
YAML line case-insensitively.

The guard closes Phase 1's DECLARED KNOWN GAP as promised rather than
re-deferring it: section 8.3(b) detection is tractable now the composition
exists. Scoped by two factors to avoid Phase 1's measured 29-to-1 false
positive rate — a variable field-name argument AND a content argument whose
nearest preceding assignment is not stripFrontmatter. Verified 0 findings and
0 false positives across all 33 call sites, plus 5 synthetic shapes. It also
detects the re-assembly shape above.

Ratchet: 4 entries to 2, both sanctioned-permanent. cmdStateSync's owner
changes from #3471 to sanctioned-permanent per Amendment 2 — routing it
through preservation would invert the #905 contract.

Also fixed inline rather than deferred: cmdMilestoneComplete's STATE.md read
now happens inside withStateLock. It previously read outside any lock before
writeStateMd took its own, leaving a TOCTOU window under concurrent writers.

* test(#3469): characterization coverage for the single write seam

Matrix sections A-E. Criterion 6 was amended by maintainer decision — all five
instances closed by point fixes while Phase 1 was in flight — so these are
characterization tests at the consumer's output per ADR-3180 Decision 4(b)/(c),
paired with the drift guard's count, never either alone.

Section C is the one that earns its keep. cmdStateSync is a sanctioned
permanent exception: state sync exists to re-derive frontmatter FROM the body,
so preservation there re-locks exactly what the command was invoked to
replace. C1 pins that the body wins; C4 pins that this phase left the command
byte-identical. Nothing else in the suite would notice if a future change made
sync start preserving, and the natural reading of 'one write seam' is to make
precisely that change.

Section E pins the guard's false-positive scoping. E4 (updateCore's
strip-then-replace) and E5 (sectionBody-scoped calls) must NOT be reported —
the naive detector measured 29 false positives to 1 true positive in Phase 1.
E7 is the inverse: a sanctioned-permanent entry disappearing must FAIL,
because a guard reaching zero here would only do so by having stopped looking
at two real writers.

Also corrects a stale test that asserted patchCore's old whole-document
behavior, which this phase deliberately changes.

One honest limitation, flagged rather than papered over: A1's 'byte-identical
to pre-refactor' cannot be diffed against real pre-refactor bytes from inside
the suite. It is implemented as the seeded fast-check property that
cmdPhaseComplete's composed output equals readModifyWriteStateMd's for the
same inputs — the strongest available proxy, not the literal claim.

* docs(#3469): refresh the seam glossary entry and add the changeset

Two spec-review gaps, both real.

CONTEXT.md's STATE.md Transition Module entry named three direct writeStateMd
callers including cmdMilestoneComplete. This phase routed that one through the
composition, so the line was false the moment the refactor landed.

Worth recording plainly: I wrote that sentence in Phase 0, correcting an
older stale pointer in it, and my own Phase 2 change invalidated it again
within the same epic. That is the exact drift this epic exists to remove,
demonstrated on the epic's own documentation — and it is why the entry now
ends by saying the whole-repo drift guard, not this line, is the authoritative
count.

The entry now records the composition (syncAndPreserveStateMd) and states that
exactly two direct callers remain, both SANCTIONED PERMANENT rather than debt.

Changeset: type Changed, because milestone complete's observable output moves.
Tier-2 per ADR-3180 Decision 3 — a stale body line no longer wins over fresher
frontmatter, and the command gains preservation_warnings. Docs requirement is
met by the ADR amendment already in this diff.

* test(#3469): register property-test temp-dir cleanup at creation time

Standards review, minor but real: the new fast-check property cleaned up its
temp dirs in a loop AFTER fc.assert returned. A genuine property failure
throws, so that line never ran and every dir from the failing run — including
all of fast-check's shrinking iterations — leaked.

The failure path is exactly when a littered machine hurts most, and a failing
property test is the case the test exists for.

Cleanup is now registered with t.after() at dir-creation time, so teardown
happens however the test exits. Not try/finally — CONTRIBUTING.md:356 bans it
inside test bodies, which is why the after-the-assertion shape existed in the
first place.

Swept the rest of the branch's test diff for the same shape; phase.test.cjs
already uses registered teardown and nothing else matched.

* fix(#3469): patchCore routes frontmatter writes instead of dropping them

Checkpoint returned 10 failures of 33880. One implementation defect, three
test defects, one stale test — all fixed, and the implementation defect is the
one that matters.

patchCore stripped frontmatter and then reconstructed it VERBATIM, applying no
patches to it. An arbitrary custom frontmatter key with no body counterpart and
no FIELD_CLASSIFICATION row — risk_level in the upstream fix(#3351) test —
therefore always reported failed and silently never wrote. It worked before,
via the old whole-document match on the raw YAML line.

That is a regression against this phase's own design row 9, which requires
frontmatter changes to ROUTE THROUGH the seam — still work, policy-governed —
not to stop working. Removing a capability is not routing it. An upstream test
caught it, which is the argument for running the checkpoint before believing
the refactor.

patchCore now partitions by frontmatter shape, decided structurally from the
parsed frontmatter's own keys rather than a naming heuristic:
  - classified keys still report failed — policy owns them and a raw patch may
    not bypass it;
  - unclassified keys apply to the frontmatter object and report updated —
    Phase 1's behavior-table row 19, a field with no row is not this contract's
    business;
  - body-shaped keys are unchanged.

The property 'failure' was my own test breaking the repo's Clock Seams rule.
The two paths agree byte-for-byte; the only difference was last_updated,
stamped from the wall clock on two invocations milliseconds apart, so it could
never pass. Time is now frozen with mock.timers across both — not by excluding
last_updated from the comparison, which would have silently stopped comparing
a field the composition writes.

B4's fixture could not discriminate: normalizeStateStatus maps any text
containing 'complete' to 'completed', and milestone complete's own new body
value derives to exactly that — which was also the fixture's stale value. The
stale value is now 'executing' so the assertion can tell 'body correctly won'
from 'stale survived'.

B5's fixture tripped a pre-existing unstarted-phase guard before reaching any
write-seam code; it now has the matching phase directory.

D9 asserted the old exempt set. readModifyWriteStateMd now calls one symbol
rather than assembling two, so it needs no exemption; syncAndPreserveStateMd
is the sole legitimate composition site.

* fix(#3469): patchCore resolves body-first, so the body wins a name collision

Re-verification returned 2 failures of 33880, both D4 — the hostile row for a
key that exists as BOTH a frontmatter key and a body field.

The partition checked frontmatter first, so 'status' — classified in
FIELD_CLASSIFICATION and also present as a body 'Status:' line — routed to the
frontmatter branch, was rejected as classified, and reported failed.

Wrong order. Patching 'status' means the body field, and upstream fix(#3351)
says so in its own comment: 'the legitimate working case for state.patch is
display-cased BODY fields — Status, Current Plan, Phase.' The body is
authoritative in this model; frontmatter is the projection. D4 asserted
exactly that and was right.

Resolution order is now body, then frontmatter:
  1. resolves to a body field -> apply to body, updated
  2. else an own key of the frontmatter:
       classified   -> failed  (policy owns it)
       unclassified -> apply to frontmatter, updated
  3. else -> failed

Verified by probe against the compiled lib for all four cases rather than
asserted: risk_level (frontmatter-only, unclassified) still lands;
current_phase still fails; display-cased Status unchanged; D4's lower-cased
status now lands via the body with the frontmatter untouched.

The current_phase case was the one that could have regressed silently, so its
fixture was read rather than assumed — D1's body carries 'Phase: 3 (alpha)'
and no 'Current Phase:' line, so body-first cannot reach it.

* chore(#3469): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
2026-08-14 16:04:09 -04:00
Tom Boucher
71180983a0 fix(#3423): standardize on <required_reading>, retire the files_to_read emit tag (#3432)
* fix(#3423): standardize on required_reading, retire files_to_read emit tag

* test(#3423): flip tag assertions, extend consistency guard to spawner surfaces

* fix(#3423): sweep capabilities fragments, regen registry+skills, anchor executor test

* chore(#3423): acknowledge tag-rename emitted ripples and workflow growth

* chore(#3423): broaden emitted-ripple acknowledgment to all embedders

* chore(#3423): settle emitted-drift acks post-rebase (merge 3004/1689-owned keys)

* chore(#3423): drop stale ripple acks, ack execute-phase growth

* chore(#3423): restore pristine 3004 fragment, keep only consumed appends

* chore(#3423): backfill changeset pr number

* chore(#3423): settle emitted-drift acks post-merge (move code-review-fix ripple into 3190, tag-rename ripples into 3191/3297)

* chore(#3423): re-arm 3324 ack for execute-phase.md tag-rename ripple

* fix(#3423): trim 8 bytes from execute-phase model note to hold ADR-857 margin, re-arm 3370 ack for net +4 growth

---------

Co-authored-by: sim <sim@local>
2026-08-14 16:03:48 -04:00
Tom Boucher
8a6d87538f test(#3466): replace 8 source-grep assertions with behavioral tests (#3500)
Phase 2 of #3464, following #3465. Rewrites every assertion that read a
shipped .cjs/.js file and text-searched it, so the file no longer needs an
allow-test-rule exemption. 6 of the 8 files are now marker-free; the ceiling
drops 285 -> 278 against a measured 277.

A source-grep passes when a STRING is present, not when the code WORKS. It
survives a refactor that keeps the string but breaks the behavior, and breaks
on a refactor that keeps the behavior but renames the string. Both failure
modes are silent about the thing the test claims to protect. That is the
anti-pattern ADR-456 and local/no-source-grep exist to prevent.

Rewrites, each against the real exported seam:

- discuss-mode: calls cmdInitPlanPhase() against a fixture whose config sets
  workflow.text_mode, asserts the value propagates to its emitted JSON.
- effort-surface-axis: runs review-lane invoke against a real project with a
  fake `claude` PATH shim, asserts the resolved --effort actually lands in the
  shim's captured argv.
- install-minimal-hooks: calls applySettingsJsonHooks() with a hook source
  missing, asserts it is neither registered nor silently registers wholesale,
  with sibling present hooks as the positive control.
- install: calls the exported inferPreferredRuntime({fs, env,
  preferredConfigDir}) via its injected fs seam, asserting 'kilo' from both
  the config-marker and env-var paths.
- opencode-permissions: spawns the real installer with a custom config dir,
  asserts the written opencode.json permission paths are anchored on it.
- repo-layout: spawns the installer for copilot local vs global, asserting
  AGENTS.md is written only in the local case.
- runtime-config-adapter-registry: stubs resolveInstallPlan and force-reloads
  install.js, asserting the runtime's artifact stops being written -- proving
  install.js genuinely routes through the registry.
- runtime-homes-descriptor-drive: calls buildAgentSkillsBlock() for cursor and
  claude against real fixture SKILL.md files, asserting each runtime's refs
  land under its own config dir and never the other's.

Every rewrite was mutation-checked before its marker was dropped. Because
node --test is not runnable locally in this repo, each assertion was
replicated in a standalone probe that requires the same module: run green
against the real file, then red against a deliberately broken one (text_mode
propagation deleted, existsSync guard removed, config dir hardcoded, !isGlobal
guard dropped, kilo branch removed, effort resolution bypassed, skills base
hardcoded back to .claude), then the production file restored and confirmed
byte-identical. An assertion that could not be made to fail would not have
shipped -- a behavioral test that passes regardless of correctness is strictly
worse than the source-grep it replaces, because it looks rigorous while
asserting nothing.

Three claims were checked rather than trusted. All three were wrong:

- repo-layout's own marker cited #1188 asserting the `!isGlobal` lexical scope
  was "unprovable at runtime". It is provable: the guard decides whether
  AGENTS.md is written, which is directly observable. Both directions verified.
- The triage for runtime-config-adapter-registry claimed its source-grep was
  redundant with the file's EXPECTED_TABLE tests, so deletion would be safe.
  Those tests only exercise resolveInstallPlan() directly and never load
  bin/install.js, so they do not cover it. A real behavioral assertion was
  written instead of deleting coverage.
- An earlier revision of this change dropped runtime-config-adapter-registry's
  two markers on the grounds that ESLint stayed silent without them. An
  adversarial review caught that this was wrong, and it is restored here. The
  file still genuinely source-greps bin/install.js at two sites; ESLint is
  silent only because no-source-grep's TEXT_METHODS omits matchAll. Dropping a
  marker because the linter cannot see the violation is exploiting the blind
  spot, not resolving it -- and it would go red the moment the rule is
  widened. Those two assertions are also irreducible: they assert that EVERY
  inline `runtime === '...'` branch in install.js names a registry-known
  runtime, and a branch naming an unregistered runtime would simply never
  execute, so no runtime observation can prove its absence. The markers now
  say so explicitly.

Two coverage gaps in no-source-grep surfaced while doing this, recorded on
#3464 rather than fixed here, since widening the rule is its own change with
its own blast radius:

- TEXT_METHODS omits matchAll, so a matchAll source-grep never trips the rule
  (the case above).
- The path test requires a literal quoted bin/lib/gsd-core/src segment and
  tracks the binding one hop, so a read through a dynamic path or an
  intermediate variable evades it. install-minimal-hooks' genuinely
  load-bearing read at line 975 is itself unmarked for a related reason.

install-minimal-hooks therefore keeps its markers too: its remaining real
source-grep of bin/install.js (the Codex legacy gsd-update-check migration
check, line 975) is outside this issue's 8 sites. It is the last blocker for
that file and is a clean follow-up.

Closes #3466

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:57:25 -04:00
Tom Boucher
895d9df96d fix(#3477): run untrusted key_links patterns on a linear-time engine (#3496)
`cmdVerifyKeyLinks` compiled `must_haves.key_links[].pattern` from plan frontmatter with `new RegExp()` and tested it against whole file contents, so a nested-quantifier pattern such as `(a+)+$` hung `verify-phase` indefinitely (CWE-1333). JavaScript has no regex-execution timeout.

Untrusted patterns now run on RE2 (re2js), whose match time is linear in input length — the class is closed by the engine, not by a heuristic screen. The screen lost in the ADR-0174 consolidation was deliberately NOT restored: it never worked, since `(a|a)*$`, `((a+))+$`, `(a+){2,}$` and `(a{1,3})+$` all evade it. A refused pattern's matcher returns false for every input, so it cannot report a match no matter what the caller does.

The engine is vendored at gsd-core/bin/lib/vendor/re2js.cjs because gsd-core/bin/** is copied into installed trees with no node_modules; runtime dependencies are unchanged. New ESLint rule local/no-external-require-in-bin enforces that invariant, which had been documented in a comment since the #3024/#2071 bug class and enforced nowhere.

Backreferences and look-around are unsupported by RE2 by construction — disclosed in a Changed changeset.

Closes #3477

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:34:36 -04:00
Tom Boucher
b946051a46 fix(#3498): escapeRegex falls back below Node 24 (no RegExp.escape) (#3499)
RegExp.escape is ES2026 (first shipped in Node 24); pattern.cts called it
unconditionally, and the build consumes the module
(scripts/gen-loop-host-contract.cjs), so npm run build failed on Node 22 and
the gsd-test linux-node22 lane could not reach run_tests.

Fix: prefer the built-in when present, else an in-file metachar escape —
still the sole owner of escaping (#3212 invariant; lint scope unchanged).
Builtin captured at module load so runtime mutation cannot flip the path.

Regression: tests/pattern.test.cjs section 4 — child-process probes neuter
RegExp.escape before/after require and assert match-behavior equivalence.

Co-authored-by: sim <sim@local>
2026-08-14 13:51:33 -04:00
Tom Boucher
1218d76d62 refactor(#3468): dispatch state preservation on the declared policy, not the field (#3495)
* test(#3468): add write-path drift guard, ratcheted at its measured baseline

Guard-first, per ADR-3180 Amendment 3's standing rule that a phase builds
and runs its guard BEFORE its scope is fixed, and states its copy count as
'N found by the guard', never 'N per the epic'.

Measured, not assumed:

  Axis 1 (policy dispatch, ADR-3408 section 8.1) — 7 violations, RED by
  design. 5 field-name-keyed getFieldClassification('literal') branches
  plus 2 declared FieldPreservation members with no executor at all
  (derive, clear). This is the fail-first evidence for the refactor.

  Axis 2 (write seam, section 8.3) — 4 bypasses, ratcheted. Epic #3408
  scoped this at two writers; the whole-repo scan found four, and one the
  epic named (patchCore) is not among them because it bypasses via
  stateReplaceField rather than the seam calls. Fourth consecutive time an
  epic's copy count proved a lower bound.

Two detectors were written and removed again before this commit, both
recorded in the file header rather than silently dropped:

  - A prompt-layer detector that reported 5 backticked prose mentions as
    drift. That is ADR-3180 Amendment 3's recorded false-positive class,
    and CONTRIBUTING.md already settles it: a backticked command reference
    is a mention. Now gated on inline-code spans.

  - A stateReplaceField co-occurrence detector for section 8.3(b). Measured
    at 29 false positives to 1 true positive — it matched the function's own
    definition and ~20 calls on frontmatter-free body slices. Banking 29
    non-defects to catch one is the 'ratchet as a parking lot' gaming route
    Decision 5 names, so it is a DECLARED KNOWN GAP owned by Phase 2
    (#3469), which both fixes it and makes its detection tractable.

* test(#3468): failing-first coverage for policy dispatch and the loud failure

Matrix sections A, B and C from 50-test-matrix.md.

Expected RED against this tree, confirmed by static trace rather than
assumed:

  B1, B2, B3 — an unwired declared preserve-when-unchanged row must throw
  with code STATE_PRESERVATION_UNWIRED_ROW and a structured .field. Today
  src/state-transition.cts:314 silently continues.

  A4 — a whitespace-only snapshot is restored today, because the guard is
  .length > 0. Required behavior is skip.

Everything else is characterization, locking in behavior the refactor must
preserve. C1 is table-driven over every FIELD_CLASSIFICATION key; C2 pins
current_phase_name's exact outputs as literals, because its row is being
reclassified preserve-always to preserve-when-unchanged as a
behavior-preserving change and nothing else would catch a drift. C3 is a
seeded fast-check property (seed 3468, 200 runs, replay data on failure).

A22 is deliberately NOT a behavioral test. Whether 'derive' has an explicit
executor is not observable through applyStatePreservation's public API — it
is a structural property, and the drift guard's unimplemented_policy axis is
what enforces it. That split is ADR-3408 Decision 5's own pairing: the lint
is the structural metric, the test is the outcome metric, and neither is
reported alone.

* refactor(#3468): dispatch preservation on the declared policy, not the field

Implements ADR-3408 sections 8.1, 8.2 and 8.6.

applyStatePreservation is now one loop over FIELD_CLASSIFICATION dispatching
on the row's preservation value, with four small executors — one per
FieldPreservation member. No branch is selected by field name. Zero
literal-argument getFieldClassification calls remain.

Behavior-preserving for 16 of 20 input classes. The four that change:

  - An unwired declared preserve-when-unchanged row now THROWS
    (code STATE_PRESERVATION_UNWIRED_ROW, structured .field) instead of
    silently continuing. This fires only on an internal invariant violation
    with both ends in our own source; a drifted, malformed or unparseable
    user STATE.md must never reach it, which is section 8.2's bright line
    and what test B8 proves through the real CLI.
  - derive gained an explicit no-op executor. That is what makes the throw
    decidable: 'policy says do nothing' is now distinguishable from 'nobody
    wired this'.
  - current_phase_name's row is corrected from preserve-always to
    preserve-when-unchanged. The row was wrong, not the code — it has always
    been delta-gated on the body Phase line, so preserve-always had two
    divergent implementations. Behavior is unchanged and test C2 pins it.
  - A whitespace-only snapshot is no longer restored; the check is trimmed.

clear is deleted from the FieldPreservation union — no row used it and no
executor existed. Speculative Generality: a policy invented for a need that
never arrived. Verified zero dependents.

The caller folds six dedicated pre/post parameters into one bodyDeltas map
keyed by field, so all seven preserve-when-unchanged rows travel one channel
instead of two. Two shapes for one kind of data is why the executor needed
per-field branches at all.

Also fixed, found while reviewing the refactor rather than deferred:

  - applyPreserveIfPlaceholder opened with a field-name literal test, which
    section 8.1 forbids outright. The executor is idempotent, so the test
    bought nothing. The drift guard could not see it, so Axis 1 is widened
    to catch field-variable comparisons against literals — the guard
    reported zero while a violation sat in the file it polices, which is
    Goodhart's gaming-by-indirection.
  - loadBaseline conflated an unreadable baseline with an absent one. A
    guard whose own diagnostic collapses two states into one identical
    result reproduces the exact failure shape this epic exists to remove.

* docs(#3468): record Phase 1 validation as ADR-3408 Amendment 1

Amendment 1 records what Phase 1 found, per ADR-3408 section 8's rule that a
behavior it does not state is not decided:

- preserve-always had TWO divergent implementations; current_phase_name's
  row was wrong and is reclassified, behavior unchanged.
- section 8.6 resolved: clear is deleted, zero dependents.
- the closed guard vocabulary is real and has exactly one true member,
  because stopped_at's scoping turned out to be caller-side extraction.
- copy count found by the guard: 4 write-seam bypasses where the epic
  scoped 2, and patchCore — one of the two it named — is not among them.
- two detectors built and removed again, with their measured false-positive
  rates, so nobody re-attempts them.
- a DECLARED KNOWN GAP for section 8.3(b), owned by Phase 2.
- Decision 5's anti-gaming list earned itself twice in one phase.

Also adds the changeset fragment.

* test(#3468): fix review findings — try/finally, stale clear allowlist, ratchet owners

Standards axis, both hard violations:

  - tests/state-write-path-drift-guard.test.cjs wrapped stdout/argv/exitCode
    restoration in try/finally inside the test body. CONTRIBUTING.md:356
    forbids it outright, and the correct t.after() pattern was already in
    use two lines up in the same test.

  - tests/state-transition.test.cjs still listed 'clear' as an allowed
    FieldPreservation value in the row-enumeration test AND the
    getFieldClassification property test, after this PR deleted it. A stale
    allowlist weakens the property's negative space — it would accept a
    resurrected clear row as valid.

Contract tension, resolved rather than left:

  ADR-3408 section 8.3 requires each ratchet entry carry the issue owning
  its removal. All four shipped with owner: null. The guard was right not to
  INVENT one, but the owners are known from the phase plan, so recording
  them is not inventing: phase.cts -> #3469, state.cts and milestone.cts ->
  #3471, health-diagnostic.cts -> sanctioned-permanent.

  Rather than a JSDoc caveat, --baseline now MERGES prior owner values on
  the (file, source) key, so a mechanical regeneration can no longer
  silently discard curated provenance. Verified by regenerating twice.

* fix(#3468): sanitize attacker-controlled fields on every guard output path

Isolated security review, MEDIUM, confidence 8/10.

findSeamBypasses and findPromptSeamUses built findings with an UNSANITIZED
`file`, while the co-located `source` on the same object was correctly
wrapped in sanitizeForReport. On a fork PR a filename is exactly as
attacker-controlled as a source fragment — a repo can legally track a
filename carrying C1 control bytes or bidi overrides.

The raw value reached two paths: --json stdout, and the COMMITTED baseline
JSON via buildBaselineEntries. JSON.stringify neutralizes C0 controls but
does NOT escape C1 (0x7f-0x9f) nor the bidi/zero-width range
sanitizeForReport exists to strip — which is the precise threat the guard's
own header names. Only the human formatter was safe.

Sanitization now happens at CONSTRUCTION, so every consumer inherits it
rather than each output path having to remember. The same defect was present
on `field` and `policy` and is fixed alongside. Double-sanitization in the
formatter is left in place, verified idempotent: escaped output is ASCII and
cannot re-match the control/bidi classes.

Also: the guard was not referenced anywhere in package.json, so nothing ran
it. A drift guard nobody runs is not a guard, and ADR-3408 Decision 5 assumes
it runs. Wired into lint:ci beside its sibling drift guards; it was already
green on this tree, so the chain stays green.

* chore(#3468): re-curate ratchet after an upstream rewording of a tracked bypass

The rebase onto origin/next turned the guard red on its first real day, which
is the ratchet working rather than a defect.

c90ae479f fix(#3350) reworded cmdPhaseComplete's syncStateFrontmatter call
onto one line and changed its third argument. Because entries are keyed on
(file, trimmed source text) rather than a line number, that single upstream
edit registered as BOTH a stale acknowledgment and an unrecorded site — the
two-sided signal the design intends, forcing a human to look rather than
letting a tracked bypass drift out of view.

The owner-preserving merge behaved exactly as designed: three owners survived
because their keys were unchanged, and phase.cts's dropped to null because its
source text is genuinely a different key. Re-curated to #3469, the phase that
owns its removal.

Note for Phase 2: c90ae479f is #3350's fix landing independently on next —
one of the two instances Phase 2 was scoped to drive fail-first. Surfaced to
the epic rather than absorbed silently.

* test(#3468): derive B1's fixture from the table so it cannot go stale

Checkpoint 2 came back with 2 failures of 33803, both B1:

  actual   'current_phase_name'
  expected 'current_plan'

The implementation was right and the test was stale. B1 hand-built a
bodyDeltas literal intending current_plan to be the ONLY unwired row, but it
also omitted status, stopped_at and current_phase_name — all three of which
became preserve-when-unchanged rows in THIS PR. Table order puts
current_phase_name first, so the throw correctly named it.

B1 now builds from neutralBodyDeltas() and deletes exactly one key, which is
what its own comment always claimed it did. A future table change can no
longer silently make it assert the wrong field.

Audited every other bodyDeltas literal in the file: four exist, all correct —
two enumerate all seven rows explicitly, two pass {} where the emptiness is
the point of the test. Roughly thirty other sites already derive from the
helper.

Also renames the local unchchangedChanged to lastActivityDescChangedDeltas.
A typo'd identifier that happens to work is still a Mysterious Name; noted
during research and fixed now that this change touches the file.

* chore(#3468): re-curate ratchet and fold the seam channel into the shared helper

The rebase onto be9329b10 fix(#3374) was a true semantic conflict, resolved
rather than handed back, because the resolution was determinable:

That PR extracted the post-sync preservation pass into a shared
applyPostSyncPreservation helper — which is ADR-3408 section 8.3, i.e. a
piece of Phase 2's own deliverable, landing upstream. Its structure is kept
wholesale; this branch's contribution is applied INSIDE it.

That combination had to be checked rather than assumed. Upstream's helper
wires only FOUR bodyDeltas keys and still passes status / stopped_at /
current_phase_name through six dedicated parameters. This branch reclassifies
current_phase_name to preserve-when-unchanged, deletes those six parameters
from StatePreservationInput, and makes an unwired declared row THROW. Taking
upstream's file as-is would therefore have thrown on EVERY STATE.md write.

The helper now wires all seven rows through the single channel. Verified
7-to-7 against FIELD_CLASSIFICATION, with a clean tsc — which is the real
proof the dedicated parameters are gone, since they no longer exist on the
input type.

The ratchet also caught the same phase.cts call being reworded a second time,
reporting it as both a stale acknowledgment and an unrecorded site. Re-curated
to #3469. Recording the tradeoff plainly: keying on (file, source text) means
an upstream reword of a tracked line needs re-curation, where keying on line
numbers would churn on every unrelated edit. ADR-3180 Decision 4(e) chose
source text deliberately, and the owner-preserving merge added earlier covers
the common case where the text is unchanged.

* chore(#3468): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
2026-08-14 13:25:30 -04:00
Tom Boucher
5b5d473e13 chore(#3465): remove 22 verified-vestigial allow-test-rule markers (#3494)
Phase 1 of #3464. Removes the `// allow-test-rule:` marker from 22 test files
where it is provably vestigial, and tightens the ratchet ceiling in
scripts/lint-allow-test-rule-refs.ceiling.json from 305 to 285.

Eligibility is decided by two independent AST discriminators, both
conservative (any doubt => keep):

(a) Read-target type. Every readFileSync/readFile call in the file resolves
    statically to a prose/config extension (.md/.json/.yml/.yaml/.toml/.txt),
    or the file performs no reads at all. Any read of a source extension
    (.cjs/.js/.mjs/.ts/.cts/.mts/.jsx/.tsx), any dynamic/unresolvable path,
    and any other extension all disqualify the file.

(b) Marker context. Every `allow-test-rule:` occurrence is a genuine comment
    node, never string- or template-literal payload. A marker that lives
    inside a RuleTester `code:` fixture is test DATA, not a suppression
    directive; stripping it corrupts the test. tests/eslint-rules.test.cjs is
    the one such fixture host and is deliberately untouched.

An earlier attempt at this phase classified markers by "strip it and see if
local/no-source-grep still passes" and was reverted in full before commit.
That oracle is unsound: the rule only fires on a literal .cjs/.js/.ts path
containing a quoted bin/lib/gsd-core/src segment, tracked one hop from the
binding, so files that genuinely source-grep real JavaScript pass it
silently -- tests/no-unbounded-spawn-allowlist.test.cjs (reads test sources
through a listTestFiles() walk) and tests/claude-imperative-reference.test.cjs
(matches bin/install.js through an intermediate variable) both cleared it
while being real source-greps. The rule's implementation is narrower than its
intent, so it cannot adjudicate whether an exemption is load-bearing.

Scope is limited to comment deletions: the diff over the test tree is 100%
line removals with zero insertions, and no executable line is altered.

On the ceiling value. The measured count at this HEAD is 283, so 285 leaves 2
slack -- deliberate, and well inside the documented grace band of 3. Pinning
the ceiling to the exact count makes this change effectively unmergeable: any
concurrent PR that lands one marker-bearing test file re-reds it. That race
fired twice while preparing this branch (once mid-rebase taking the count
304->305 on next, once between rebase and the verification run taking it
282->283), and it is the same race that broke next in #3461. A ceiling of
actual+2 preserves a merge window while still ratcheting 305 -> 285.

Known limit, disclosed rather than papered over: this clears 22 of 303
markers and does not reach #3464's trend-to-zero goal. Most of the remaining
markers sit on dynamic-path reads, commonly a hoisted `const p =
path.join(tmpDir, 'STATE.md')` whose target is prose but is unresolvable to
this classifier. A stricter one-hop const resolution would flip an estimated
95 more; that is deliberately left to a follow-up so it can be reviewed on
its own evidence.

Marker discovery reads bytes rather than shelling out to grep:
tests/security-prompt-injection.security.test.cjs carries a literal NUL byte
(an intentional injection fixture) that makes grep treat it as binary and skip
it, which is why the true marked-file count is 303 and not the 302 a shell
scan reports.

Closes #3465

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:10:49 -04:00