Commit Graph

40 Commits

Author SHA1 Message Date
Tom Boucher
bd5fcfceeb fix(#2125): route complete-phase resolver through canonical parser (review)
Orthogonal review surfaced that resolvePhaseIdForCompletePhase (state.cts) and
cmdStateCompletePhase's idempotency check still used an unanchored
/(\d+[A-Z]?(?:\.\d+)*)/i — even more permissive than the parseProsePhaseField
regex this phase fixes. Reachable corruption: after `milestone complete v0.5`,
`state complete-phase` (no --phase) mined "0.5" from the body line
"Phase: Milestone v0.5 complete" and rewrote STATE.md as "Phase 0.5 complete".

Both sites now delegate to phase-id.cts:parsePhaseFromProse (the same anchored
parser), so a milestone-closure line yields no token and the existing
"unable to resolve" guard fires instead of corrupting. Canonical tokens
(3, 03, 3A, 3.3, "3 of 5", "1 — Setup") are preserved unchanged.

Regression (tests/state.test.cjs, complete-phase suite): `state complete-phase`
on a "Milestone v0.5 complete" STATE.md now rejects and does not mine "0.5".
Demonstrated fail-first.

Refs #2125, #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:11:55 -04:00
Tom Boucher
e7ff7be1a4 fix(#2125): migrate state.cts prose parsing to canonical parser (drives #2111)
Phase 2 of epic #2121. state.cts:parseProsePhaseField now delegates to the
anchored phase-id.cts:parsePhaseFromProse (built in Phase 1), removing this
module's independent prose phase-id regex.

Drives #2111: `milestone complete vX.Y` no longer corrupts current_phase. The
body line "Phase: Milestone v0.5 complete" previously had "5" mined from it by
the unanchored regex; the anchored parser returns { phase: null }, so
syncStateFrontmatter's #905 guard preserves the real current_phase. This also
fixes the broader family the review surfaced — every milestone completion
(e.g. v1.0 -> "0") was silently corrupting current_phase, not just .5-versions.

Regression (tests/milestone.test.cjs, in the milestone-complete suite, #2111):
`milestone complete v0.5` on a project with current_phase: "19" now preserves
"19". Demonstrated fail-first end-to-end: reverting the migration reproduces
current_phase = "5".

Closes #2125
Refs #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:57:47 -04:00
Tom Boucher
80923244b1 fix(#2124): harden parsePhaseFromProse per orthogonal review (ReDoS + coercion)
Orthogonal security review of the Phase 1 surface found two issues; both fixed
and regression-tested:

- MEDIUM ReDoS: the name-extraction regexes /\(([^)]+)\)/ and
  /—\s*([^(\n]+?).../ backtrack O(n^2) on a crafted STATE.md field value with a
  long unterminated "(" / "—" run (reviewer measured ~38s at 320k chars).
  Length-bound both quantifiers to {1,200} -> linear (320k now ~100ms). A real
  phase name is far shorter than the cap.
- LOW: parsePhaseFromProse threw on non-string truthy input, unlike its three
  sibling #2121 functions. Coerce via String(value) up front.

The identical ReDoS regexes are copied verbatim from the pre-existing
state.cts:parseProsePhaseField; per the no-defer rule that surfaced defect is
fixed inline there too (Phase 2 / #2125 later supersedes that function by
delegating to the bounded phase-id.cts parser).

Adds a behavioral bound-guard regression test (a >200-char parenthetical is not
extracted) and a non-string-coercion test.

Refs #2124, #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:33:39 -04:00
Behruz Nassre Esfahani
12b35eeeaf fix(#1729): resolve phase headers with a pre-colon parenthetical tag (#1765)
* fix(#1729): resolve phase headers with a pre-colon parenthetical tag

A phase header may carry a parenthetical tag between the number and the
colon, e.g. `### Phase 26 (Cluster B): Title`. Every phase-header regex
built `Phase\s+<num>` immediately against the colon delimiter, so the
tagged phase was invisible: the resolver returned found:false and, just
as bad, the capture-all enumeration/parse paths (roadmap analyze,
milestone listing + milestone-scope filter, verify, init/import, state
total_phases, validate, the command router, preamble stripping, and the
phase-remove renumbering rewrite) silently dropped, miscounted, or
failed to renumber it — wrong phase_count, progress_percent, next_phase,
or corrupt numbering after a removal.

The fix tolerates the tag at the header seam. Parameterized resolver
sites compose the exported OPTIONAL_PHASE_TAG_SOURCE fragment; literal
enumeration sites inline its character-for-character mirror
`(?:\s*\([^)\n]*\))?`, placed immediately before the colon so it cannot
alter an existing match (optional, single-line, one paren pair, no
capture-group shift). In the renumber-on-removal rewrite the tag is
folded into the re-emitted suffix capture so it survives verbatim. Both
forms are documented to change together and a drift-guard test asserts
their behavioral equivalence over a header corpus.

Deliberately excluded: roadmap-upgrade.cts (legacy one-time migration),
where tolerating the tag would silently drop it on header rewrite — that
needs its own data-preserving treatment. Known boundaries left for
follow-up: checklist/bullet-style phase entries (`- [ ] Phase N (tag):`)
and a malformed space-before-colon variant, both pre-existing.

Validated empirically against the issue's reproduction: `roadmap
get-phase 26` resolves and `roadmap analyze` lists Phase 26 with the tag
excluded from the name (phase_count 2, next 26); an all-tagged versioned
roadmap now scopes correctly instead of falling back to a pass-all
filter. Regression coverage in tests/phase.test.cjs asserts resolver
parity (pre- vs post-colon), padding tolerance (#3537), decimal
sub-phases, no cross-phase false match, the shared seam, enumeration
coherence, renumber-preserves-tag, and seam/mirror drift. Full unit
suite green (7291 pass, 0 fail); eslint + regression-name +
resolution-provenance + changeset lints pass. Reviewed by Codex
(no critical/high; the two enumeration misses it surfaced are folded in).

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

* chore(#1729): add changeset for pre-colon phase-tag fix

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 11:55:44 -04:00
Behruz Nassre Esfahani
50ff7a8707 fix(#1776): scope prune phase resolution to ## Current Position (#1832)
* fix(#1776): scope prune phase resolution to ## Current Position

cmdStatePrune resolved the current phase by extracting the `Phase` field over
the WHOLE STATE.md body. stateExtractField's fallback chain ends in a pipe-table
match (`| Phase | N |`), so a STATE.md lacking a `Current Phase` field and a
prose `Phase:` line — but carrying an unrelated `Phase`-labelled table row (e.g.
a historical verification table) — resolved that stale table cell as the current
phase and computed a wrong cutoff (bailing "Only N phases" or pruning at a stale
boundary).

Resolve the phase via the same canonical chain buildStateFrontmatter uses —
frontmatter `current_phase` → `Current Phase` field → prose `Phase: X of Y` —
but scope ONLY the prose term to the `## Current Position` section via the
fence-aware locateCurrentPosition seam (new exported sliceCurrentPositionSection).
Frontmatter and the explicit `Current Phase` field stay document-wide (they are
unambiguous); the shared stateExtractField is not narrowed for any other caller.

Tests (folded into tests/state-prune.test.cjs): a stray `| Phase | 2 |` table
with the real phase in frontmatter no longer drives the cutoff (fail-first on
base); template-conformant STATE.md is unchanged; and a fast-check
boundary-containment property that a `| Phase | N |` row outside Current Position
never leaks into the scoped resolution.

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

* chore(#1776): add changeset for prune Current Position scoping

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-30 13:16:40 -04:00
Tom Boucher
bad2c76c5e feat(#1826): cmdStateRebuild CLI + dry-run + verbose + integration tests + docs (#1830)
Phase 2 of approved feature #1817. Wires the pure `rebuildCore` transition
(Phase 1, #1827) to the `gsd state rebuild` CLI subcommand per ADR-1817
§5 (heavy/manual counterpart to lightweight, auto-triggered `state sync`).

Source changes:
- src/state.cts: implement cmdStateRebuild. Locks via
  readModifyWriteStateMd (real path) or read-only (dry-run). Wires
  phaseInventoryProvider to a real .planning/phases/ disk scan (same
  canonical source buildStateFrontmatter uses). --dry-run emits a
  structured preview without writing. --verbose tees the audit-log
  entries to stderr (treated as data-only per ADR-1577).
- src/state-command-router.cts: register cmdStateRebuild in the
  StateModule interface + add the rebuild handler with --dry-run and
  --verbose flag parsing.
- src/command-aliases.cts: register the state.rebuild canonical +
  'state rebuild' alias + mutation=true (so the manifest covers the
  new subcommand for SDK parity / dispatch hub tests).

Tests (tests/state-rebuild-cli.test.cjs, 5 cases):
- state rebuild with no flags reconciles drifted body + drops orphan
  table rows + appends audit log (end-to-end #1, #2, audit log).
- state rebuild --dry-run computes the diff, writes nothing (criterion #5).
- state rebuild --verbose tees the log; audit-log section still written.
- Running rebuild twice on the just-rebuilt file is byte-identical
  (criterion #6 end-to-end).
- Missing STATE.md produces a clean 'STATE.md not found' message, no
  stack trace (CONTRIBUTING QA matrix).

Verified locally:
- node --test tests/state-rebuild-cli.test.cjs → 5/5 pass
- node --test tests/state-rebuild.test.cjs → 18/18 pass (Phase 1 regression)
- node --test tests/state-transition.test.cjs → 85/85 pass (ADR-1769 regression)

Docs (docs/COMMANDS.md): document `state rebuild [--dry-run] [--verbose]`
with the canonical-command block format used by `state sync` /
`state prune`.

Changeset (.changeset/1817-state-rebuild.md): type=Added, user-facing
description of the new subcommand (closes #1817 epic on merge).
2026-06-29 16:15:55 -04:00
Tom Boucher
38c2c1805e fix(#1761): skip conflated progress in state json read-path when milestone unbounded (#1818)
* fix(#1761): skip conflated progress in state json read-path when milestone unbounded

ADR-1769 Phase 7 (#1794) closed the state sync WRITE path — when a milestone
version is asserted in frontmatter but the ROADMAP has no versioned heading
for it, sync leaves Progress untouched. But the state json READ path rebuilds
progress via buildStateFrontmatter, whose roadmapPhaseCount loop counts phase
headings across the WHOLE document when extractCurrentMilestone can't bound
the milestone. state json therefore reported a conflated total_phases (sum of
sibling milestones) + a derived percent — exactly the value the sync guard
was added to prevent. (Repro from the issue: total_phases 8 = 4+4, percent 13.)

Mirror the cmdStateSync guard inside buildStateFrontmatter: when the asserted
milestone cannot be bounded to a versioned ROADMAP heading (the same
versionedHeading test the sync path uses), fall back to the on-disk
phase-dir count for total_phases and skip percent. Bounded milestones
(versioned ROADMAP, or no milestone asserted) are unchanged. The signal rides
on the existing _diskScanCache (new milestoneBounded field) so neither
extractCurrentMilestone's return contract nor its other callers change.

Regression: extend tests/bug-1761-state-sync-wrong-progress.test.cjs with the
read-path case (unbounded → no percent, no conflated total_phases) and a
bounded control (versioned ROADMAP → unchanged percent + total_phases).

* docs(#1761): add changeset fragment for state json read-path fix
2026-06-28 22:41:38 -04:00
Tom Boucher
21f0b4316f refactor(#1796): ADR-1769 Path A — finish STATE.md preservation consolidation (#1799)
Extract readModifyWriteStateMd's post-sync preservation block into a pure,
field-classification-table-driven applyStatePreservation in the STATE.md
Transition Module. progress / status / stopped_at now join current_phase_name
as table-governed (getFieldClassification), so a preservation-policy change is
a one-row table edit instead of a per-call-site patch.

This realizes the consolidation ADR-1769 / CONTEXT.md already claimed shipped
('Absorbs readModifyWriteStateMd post-sync preservation block') and routes the
#1264 preservation policy through the single field-classification table — the
bug class is now structurally guarded by the table, not just the call-site
shouldResync flag.

Behavior is byte-identical to the pre-amendment inline block (Hyrum-safe — the
15 readModifyWriteStateMd callers' observable preservation is unchanged):
- state/frontmatter/transition + bug regression suite: 847 pass
- phase/milestone/verify (other RMW consumers): 582 pass
- codex (gpt-5.5/high) adversarial review: CLEAN (58,564-case equiv sweep)

ADR-1769 amendment appended documenting #1796.

Closes #1796
2026-06-27 22:49:52 -04:00
Tom Boucher
79c69d443b refactor(#1793): ADR-1769 Phase 7 — sync, prune, update migrations (#1760, #1761) (#1794)
Migrate cmdStateSync, cmdStatePrune, cmdStateUpdate (state.cts) onto the
STATE.md Transition Module substrate and close the maintenance bug pair
#1760/#1761 (ADR-1769, epic #1769). Completes the substrate: all 10
lifecycle/maintenance transitions now route through transitionCore.

- Add {kind: 'sync'|'prune'|'update'} to StateTransitionIntent with syncCore,
  pruneCore, updateCore in src/state-transition.cts.
- updateCore: body-only single-field update (strip/reassemble), mirroring the
  pre-migration cmdStateUpdate contract.
- pruneCore: pure content→content section pruning (Decisions / Recently
  Completed / resolved Blockers / Performance Metrics rows at or below cutoff),
  byte-identical tokenizeHeadings splicing. Adapter owns currentPhase, dry-run,
  and STATE-ARCHIVE.md.
- syncCore: body writes (Total Plans in Phase, Progress bar, Last Activity)
  given disk-derived numbers. Adapter owns the disk scan + roadmap scope.
- #1760: cmdStatePrune now derives currentPhase from 'Current Phase' OR 'Phase'
  (the canonical template emits 'Phase: X of Y'), so prune engages on
  template-conformant STATE.md instead of bailing 'Only 0 phases'.
- #1761: cmdStateSync skips the Progress write (percent=null) when a milestone
  version is set in frontmatter but the ROADMAP has no versioned heading for it
  (milestone cannot be bounded). Projects without a milestone version are
  unaffected. Leaves progress untouched rather than silently writing fallback-
  derived wrong values.
- Regression: bug-1760 (prune engages on template field) + bug-1761 (sync
  leaves progress untouched when unbounded). ADR-1769 marked Accepted.

All 82 transition + 515 regression tests pass.

Closes #1793
2026-06-27 16:53:44 -04:00
Tom Boucher
064f63b299 refactor(#1791): ADR-1769 Phase 6 — patch migration + curated current_phase_name preserve (#1695) (#1792)
Migrate cmdStatePatch (state.cts) onto the STATE.md Transition Module substrate
and close the curated-field clobber bug class #1743/#1695 (ADR-1769, epic #1769).

- Add {kind: 'patch'} to StateTransitionIntent, with patchCore in
  src/state-transition.cts. Applies each caller-supplied {field:value} pair via
  stateReplaceField over the full content (body + frontmatter), tracking
  updated vs. failed. data.updated/data.failed mirror the CLI output shape.
- Collapse cmdStatePatch to a transitionCore dispatch. Field-name validation
  (security) and the resync-progress decision stay in the adapter.
- #1695/#1743 fix: extend the #1230 delta heuristic in readModifyWriteStateMd to
  the curated current_phase_name, table-driven via
  getFieldClassification('current_phase_name').preservation === 'preserve-always'.
  When a write does NOT change the body Phase: source line, the curated
  frontmatter value wins over syncStateFrontmatter's body re-derivation (which
  harvests a wrong parenthetical aside — #1695). begin/planned/complete-phase
  rewrite their body Phase line, so the delta does not fire for them and
  current_phase_name still advances.
- Regression: bug-1695-state-patch-clobbers-phase-name.test.cjs — unrelated
  patch preserves curated current_phase_name; patching the Phase source advances.

All 70 transition + 493 regression tests pass (state/phase/milestone + #905/#397/#3242 lineages).

Closes #1791
2026-06-27 16:21:28 -04:00
Tom Boucher
91704c9fcf refactor(#1786): ADR-1769 Phase 4 — plannedPhase + milestoneSwitch migration (#1788)
Migrate cmdStatePlannedPhase and cmdStateMilestoneSwitch (state.cts) onto the
STATE.md Transition Module substrate (ADR-1769, epic #1769).

- Add {kind: 'plannedPhase'} and {kind: 'milestoneSwitch'} to
  StateTransitionIntent, with plannedPhaseCore and milestoneSwitchCore in
  src/state-transition.cts (consulting the field-classification table).
- plannedPhaseCore owns the template-aware Status/Last Activity updates, Total
  Plans in Phase, Last Activity Description, and the Current Position section
  (via the inlined mutateCurrentPositionForAdvance twin). The adapter keeps the
  resync:false readModifyWriteStateMd wrapper (#500 RC1).
- milestoneSwitchCore owns the new-milestone reset: rebuilt frontmatter
  (milestone/name/status='planning'/zeroed progress) + Current Position body
  reset. The adapter keeps acquireStateLock + platformWriteSync (NOT
  readModifyWriteStateMd — milestoneSwitch rebuilds frontmatter directly).
- Collapse both callbacks to transitionCore dispatches. The now-dead
  updateCurrentPositionFields helper and KNOWN_STATUS_PATTERNS import are
  removed (their behavior lives in the transition module's
  mutateCurrentPositionForAdvance).
- Characterization tests pin the template-aware preserve-authored invariant,
  Total Plans, Last Activity narrative, Current Position update, and the full
  milestone reset (frontmatter + position body + gsd_state_version preserve +
  Accumulated Context preserve).

All 58 transition + 177 state + phase + bug-2630/bug-905 regression tests pass.

Closes #1786
2026-06-27 15:24:18 -04:00
Tom Boucher
1512e6f415 refactor(#1782): ADR-1769 Phase 2 — advancePlan migration onto Transition Module (#1783)
Migrates cmdStateAdvancePlan (~80-line RMW callback) onto the
transitionCore dispatch established in Phase 1 (#1775):

- src/state-transition.cts:
  - Add {kind: 'advancePlan'} to StateTransitionIntent union
  - Extend StateTransitionResult with optional data field for
    intent-specific output (advanced, currentPlan, totalPlans)
  - advancePlanCore: parses legacy + compound plan formats, handles
    advance vs phase-complete branching, strips frontmatter before
    body mutation (#1255 pattern — codex Phase 2 HIGH finding),
    uses stateReplaceFieldIfTemplate for template-default-aware
    field replacement (Knuth invariant), mutates ## Current Position
    section via mutateCurrentPositionForAdvance
  - mutateCurrentPositionForAdvance: inlined section mutation (avoids
    circular dep with state.cjs's updateCurrentPositionFields)

- src/state.cts:cmdStateAdvancePlan: collapsed to 30-line dispatch

- tests/state-transition.test.cjs: 5 characterization tests
  (advance, phase-complete, error, compound format, frontmatter #1255)

Codex gpt-5.5/high review: 1 HIGH blocking (frontmatter strip — fixed),
1 medium follow-up (StateTransitionResult.data shape discrimination —
noted for Phases 3-7).

gsd-test: 21893/21893 PASS (Linux docker).

Closes #1782
2026-06-27 14:22:14 -04:00
Tom Boucher
744bb7aaee refactor(#1771): ADR-1769 Phase 1 — STATE.md Transition Module substrate + beginPhase (#1775)
* refactor(#1771): ADR-1769 Phase 1 — STATE.md Transition Module substrate + beginPhase

Lands the Phase 1 substrate per ADR-1769:

- src/state-transition.cts (new Module):
  - Field-classification table (FieldClass enum + FIELD_CLASSIFICATION rows)
  - STATE_MD_SECTIONS constants block
  - Pure transitionCore(content, intent, deps) dispatch
  - beginPhase intent implementation (first-time + #3127 resume paths)

- src/state.cts:cmdStateBeginPhase — collapses ~190 lines to a thin
  dispatch onto transitionCore via readModifyWriteStateMd. The lock,
  no-op write guard, and #1230 post-sync delta heuristic stay in the
  RMW seam; the body-mutation policy moves to transitionCore.

- tests/state-transition.test.cjs (24 tests):
  - Substrate invariants (table enum, section constants)
  - Characterization: 6 first-time body field updates
  - Characterization: 5 #3127 idempotency-guard resume behaviors
  - Characterization: 3 Current Position section mutations
  - Characterization: Current focus body text line (#1104)
  - Property (RULESET.TESTS.property-based-testing): beginPhase status
    propagation + FIELD_CLASSIFICATION own-property contract
  - Resume Current Position mutation (preserves Plan/Phase/Status)

No external behavior change. Full state.test.cjs regression (177 tests)
plus bug-3127/#3242/#905/#948 pass. Property tests surfaced two
pre-existing quirks (state-document.cjs greedy \s* on whitespace-only
field values; Object.prototype method leakage on FIELD_CLASSIFICATION
lookups for strings like 'toString') — documented in test comments;
fix-out-of-scope for Phase 1.

Closes #1771

* refactor(#1771): ADR-1769 Phase 1 codex review corrections

Addresses 3 blocking findings from codex gpt-5.5/high review:

1. FIELD_CLASSIFICATION shape (state-transition.cts):
   - Was flat FieldClass enum (collapsed source + preservation)
   - Now two-column {source, preservation} rows per ADR-1769 §4
   - Added missing fields verified via Memtrace against
     buildStateFrontmatter (state.cts:1633-1653): gsd_state_version,
     last_updated, last_activity_desc, progress.{total_phases,
     completed_phases, total_plans, completed_plans, percent}
   - Field 2-7 preservation dispatch can now consult the table

2. Prototype-pollution hardening (state-transition.cts):
   - Table is now Object.freeze(Object.assign(Object.create(null), {...}))
   - getFieldClassification() helper uses Object.hasOwn; returns null
     for inherited prototype methods (toString/valueOf/__proto__)
   - Old code: FIELD_CLASSIFICATION['toString'] returned the function

3. STATE_MD_SECTIONS aligned to canonical template:
   - Verified against gsd-core/templates/state.md via Memtrace
   - Was: 8 entries including non-template sections (## Session,
     ## Decisions, ## Operator Next Steps, ## Session Log,
     ## Roadmap Evolution)
   - Now: 6 canonical top-level sections (## Project Reference,
     ## Current Position, ## Performance Metrics, ## Accumulated
     Context, ## Deferred Items, ## Session Continuity)

Also: beginPhase now consults getFieldClassification() per touched
field (codex finding: 'table not consulted by transitionCore').
Unknown fields raise immediately — adding a field without a table
row is caught at runtime.

Repo-hygiene catches from gsd-test (not node --test, which missed
these):
- gsd-core/bin/lib/state-transition.cjs added to eslint.config.mjs
  ignore list (ADR-457 tsc-generated)
- docs/INVENTORY-MANIFEST.json regenerated via
  node scripts/gen-inventory-manifest.cjs --write

Property test for Object.prototype leakage tightened to verify
getFieldClassification() returns null for toString/valueOf/__proto__.

Ref #1771

* fix(#1771): cast Object.create(null) to satisfy @typescript-eslint/no-unsafe-assignment

ESLint CI failed on src/state-transition.cts:72:14 — Object.create(null)
returns `any`, which leaked through Object.assign to the typed
`FIELD_CLASSIFICATION` declaration. Adding an explicit cast to
`Record<string, FieldClassification>` eliminates the unsafe-assignment
while preserving the null-prototype protection codex review recommended.

gsd-test: 21888/21888 PASS.

* fix(#1771): add 'see #1771' to allow-test-rule exemption per ADR-456

CI lint-allow-test-rule-refs failed: 'New allow-test-rule exemption
without an issue ref — add `see #NNN` per ADR-456'. Updated comment
on tests/state-transition.test.cjs to reference the Phase 1 issue.

* fix(#1771): remove unnecessary allow-test-rule exemption

The exemption was added speculatively. The test file does not use
readFileSync + .includes()/.match()/.startsWith() on source content —
it calls transitionCore() with string literals and verifies results
via stateExtractField() and array .includes() on the updated[] array.
No exemption needed.
2026-06-27 13:41:03 -04:00
Tom Boucher
871621c3c8 feat(#1740): require-fs-op-fallback production AST rule + Windows transient-lock retry (Phase 6) (#1742)
* feat(#1740): require-fs-op-fallback production AST rule + Windows transient-lock retry (Phase 6)

ADR-1703 Phase 6 of the cross-platform portability epic (#1702). Adds the
second production-code portability AST rule + the ADR-mandated glob expansion
to bin/install.js and scripts/build-hooks.js.

- eslint-rules/require-fs-op-fallback.cjs: flags an unguarded fs.rename /
  fs.renameSync (the atomic-publish primitive named first in
  DEFECT.WINDOWS-FS-OPS.symptom) that is NOT inside a try/catch whose handler
  references a transient errno ('EPERM'/'EBUSY'/'EACCES' or a *RETRY_ERRNOS
  set) AND NOT behind a Windows platform guard. A catch that silently swallows
  or cleans-up-and-rethrows without an errno check does NOT satisfy the
  defect's 'never silently swallow' clause. copyFile/unlink are deliberately
  not flagged (they are the fallback primitives per the defect's own
  fix-forward). Scope narrowed to rename per Phase 5's precision discipline;
  documented on #1740.

- src/shell-command-projection.cts: export retryRenameSync(from, to) — the
  drop-in bounded-retry helper over the existing atomicRenameWithRetry.

- 27 bare fs.renameSync sites across 11 modules routed through retryRenameSync
  (capability-lifecycle/lock/source, installer-migrations, milestone, phase,
  planning-workspace, roadmap-upgrade, runtime-hooks-surface, state,
  workstream). Idempotent on POSIX; resilient to AV/indexer transient locks
  on Windows.

- eslint.config.mjs: register rule at error on src/**/*.cts; new focused
  portability-rules block covering bin/install.js + scripts/build-hooks.js
  (ADR-1703 L124-126 glob expansion — both files are compliant: zero
  rename violations).

- tests: 15-case RuleTester suite; portability-rule-disable-ban extended
  (PROTECTED_RULES + scans bin/install.js/build-hooks.js with shebang
  handling); ci-test-scope portability-lint selection rule.

- CONTEXT.md DEFECT.WINDOWS-FS-OPS predicate rewritten to point at the rule;
  docs/contributing/cross-platform-portability-rules.md reference + how-to.

Closes #1740

* chore(#1740): backfill changeset pr:1742

* fix(#1740): tighten require-fs-op-fallback precision (codex review HIGH-1/HIGH-2)

Addresses two false-negative findings from the codex (gpt-5.5/high)
adversarial review of PR #1742:

HIGH-1 — a catch that REFERENCES a transient errno but only rethrows (no
retry/fallback) was marked compliant. The DEFECT.WINDOWS-FS-OPS fix-forward
requires retry, not just recognition. Fix: catchHandlerHasRetrySignal now
requires a loop `continue` backedge OR a `return <call>` delegation; a bare
rethrow is flagged. The misleading `/* retry logic */` valid test is replaced
with a real retry loop, and the rethrow-only shape is added as invalid.

HIGH-2 — the nested-try ancestor walk treated an OUTER errno-catch as
protecting the rename even when an INNER catch intercepted/swallowed the error
(the outer catch is unreachable). Fix: isInsideTransientErrnoTryCatch now stops
at the NEAREST enclosing TryStatement WITH A CATCH HANDLER whose block contains
the rename (try-finally is skipped — it doesn't catch); outer catches are no
longer consulted. The unsound nested-try valid test is converted to invalid,
and a try-finally-skipped valid case is added.

Verified: 17 RuleTester cases pass; zero new production violations (the 27
fixed sites use retryRenameSync; the real retry loops — atomicRenameWithRetry,
capability-ledger/consent, build-hooks — remain compliant via continue/errno);
lint:ci green; disable-ban + vocab-drift green.

---------

Co-authored-by: review-bot <review-bot@gsd>
2026-06-25 23:55:58 -04:00
Tom Boucher
c583bcc02c fix(#1659): dedup By-Phase rows across padded/unpadded phase numbers (#1663)
* fix(#1659): dedup By-Phase rows across padded/unpadded phase numbers

phaseRowPattern matched the phase number literally (escapeRegex(String(phaseNum))),
so a seeded zero-padded row '| 05 |' was not matched by 'phase complete 5' (pattern '| 5 |'),
producing a duplicate row that double-counted the phase. Canonicalize a numeric phase to
its integer form (Number('05')===Number('5')===5) and match with a 0* prefix so 5/05/005
all collapse to the same row in either direction. Regression folded into state.test.cjs:
seeded '| 05 |' + 'phase complete 5' yields exactly one phase-5 row. Non-numeric phase IDs
retain the literal escapeRegex match.

* chore(#1659): backfill changeset pr ref to 1663

* fix(#1659): add verification fixture to padded-dedup test under #1522 gate
2026-06-24 14:41:45 -04:00
Tom Boucher
ff161f2281 fix(#1582): derive phase-complete velocity from By-Phase table (idempotent) (#1655)
* fix(#1582): derive phase-complete velocity from By-Phase table (idempotent)

updatePerformanceMetricsSection blind-added summaryCount onto the prior velocity
total on every phase complete, so re-running phase complete on an already-complete
phase incremented the total each time (the sibling of #4, which fixed the Completed
Phases counter the same way). The velocity total is now derived as the sum of the
By-Phase table's Plans column AFTER the row upsert — re-completing a phase upserts
the same row, so the sum is stable; a hand-edited inflated total self-heals downward
to the true sum on the next completion. When the By-Phase table is absent the total
is left unchanged (no crash). Strengthens the misnamed 'idempotent' test (its comment
explicitly declined to assert velocity idempotency — the latent gap) and adds a
self-heal regression; corrects the #320 behavior-lock velocity assertion which had
encoded the blind-add (3 = 1+2 double-count) — the derived value is 2.

* chore(#1582): backfill changeset pr ref to 1655

* fix(#1582): velocity sum tolerates indented By-Phase rows (codex review)

Adversarial review (codex, gpt-5.5/high) flagged that byPhaseTablePattern's
data-row capture allows leading whitespace ([ \t]*\|), but the derive sum was
anchored at ^\| and would skip indented hand-edited/legacy rows — capturing them
in the table but silently undercounting. Align the sum regex (^\s*\|) with the
table capture's tolerance. Adds an indented-row regression. Two other codex
findings are pre-existing and out of scope: padded/unpadded phase dedup
(phaseRowPattern, identical in old code — derive yields the same value as the old
blind-add) and CRLF tables (the shared byPhaseTablePattern header requires bare
\n, so the upsert was already broken on CRLF; the fix changes stale-vs-
double-count, does not worsen it).

* fix(#1582): add verification fixtures to velocity tests under #1522 gate

Post-rebase onto next+#1548, the #1582 velocity tests (self-heal, indented-row) use
phase complete, which now fail-closes under #1522's canonical verification gate without a
passed *-VERIFICATION.md. Add writePassedVerification(tmpDir,'02-next','02') to both.
2026-06-24 14:36:01 -04:00
Tom Boucher
80607bec93 fix(#1658): make byPhaseTablePattern CRLF-tolerant on STATE.md tables (#1662)
* fix(#1658): make byPhaseTablePattern CRLF-tolerant on STATE.md tables

byPhaseTablePattern required a bare \n after the header and separator rows, so a
STATE.md with CRLF (\r\n) line endings (Windows, or hand-edited) had its By-Phase
table treated as absent: phase complete never upserted the row (and the velocity-from-
table derivation went stale). Make the header/separator terminators and the closing
lookahead CRLF-tolerant ([ \t]*\r?\n, (?=\r?\n|$)). Backward-compatible with LF.
Regression folded into tests/state.test.cjs: phase complete on a CRLF STATE.md upserts
the row and removes the placeholder. CONTRIBUTING's QA matrix lists Mixed CRLF/LF as a
required parser case.

* chore(#1658): backfill changeset pr ref to 1662
2026-06-24 13:52:47 -04:00
Behruz Nassre Esfahani
0271231910 test(#1514): add fast-check property + all-retired boundary for retired parser
Per review (test-standard items):
- Property test (RULESET.TESTS.property-based-testing): extractRetiredPhaseNumbers
  is the parsing core, so add a fast-check property — k of n checklist phases
  struck → exactly the k canonical keys returned, across randomized phase counts
  and numeric/zero-padded/project-code ID forms. Exposed via a `_`-prefixed test
  seam (mirrors the existing _setLockProbes seams), no public API surface added.
- Boundary (RULESET.TESTS.boundary-coverage): all-retired case (k === n) →
  total_phases 0, via state json.

No production behavior change; the exclusion logic is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:22:29 -07:00
Behruz Nassre Esfahani
c6edeb3eb3 Merge branch 'next' into fix/1514-retired-phase-total-phases 2026-06-22 17:13:25 -07:00
Behruz Nassre Esfahani
e3acdd89cb fix(#1514): exclude retired/folded phases from progress.total_phases
A retired/folded phase (struck through in ROADMAP, marked [x], with a
directory but no completion artifact) was counted in the total_phases
denominator via max(phaseDirs.length, roadmapPhaseCount), yet could never
satisfy the numerator (no SUMMARY → never "completed"), freezing shipped
milestones below 100% (e.g. 5/6 = 83%).

Both STATE counting paths now read the current-milestone ROADMAP scope and
exclude retired phases from BOTH the disk phase-dir set and the heading
count, so a retired phase counts toward neither denominator nor numerator:
  - buildStateFrontmatter (`state json`)
  - cmdStateSync (`state sync --verify` / rebuild) — previously re-derived the
    inflated denominator and reported "no drift", per the issue.

Retired detection (extractRetiredPhaseNumbers) is scoped to the lines that
canonically mark a phase retired — a checklist entry (`- [x] …`) or a phase
heading — and within those, only a struck span whose SUBJECT is the phase
(`~~**Phase 04: Delta**~~`). So struck prose, a struck goal line, and the fold
target ("folded into Phase 05") are not misread as retired.

Phase matching uses the canonical phase-id helpers (normalizePhaseName +
extractPhaseToken), so numeric, decimal, and project-code IDs (PROJ-42) match
consistently across ROADMAP tokens and on-disk dir names.

Scope boundaries (separate, pre-existing concerns left unchanged):
  - `roadmap analyze` (src/roadmap.cts) intentionally trusts the [x] checkbox
    (incl. externally-completed phases) — a different reporting surface.
  - cmdStateSync does not apply the milestone phase-dir filter (so 999.x /
    other-milestone dirs can still affect its count); that is the #1445 /
    milestone-filter axis, independent of retired phases.

Same counting family as #549 / #500 / #1445.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:29:05 -07:00
Dave
fc019b2688 fix(#1531): race-safe steal for the two core-path locks (PR #1532 review)
trek-e's review found the M1 PID-liveness backport dropped two pieces of
capability-lock.cts's steal-safety machinery, reopening the #500/#905/#1230
lost-update family:

- Empty-body window (state.cts): acquireStateLock creates the lock with O_EXCL
  and writes the pid in a separate writeSync; a lock observed in that gap has an
  empty body, reads as not-verified-live, and was stolen at age ~0 — robbing a
  holder mid-creation. Add a fresh-create floor scoped to the unverifiable-body
  case: an empty/unparseable body that is fresh is treated as mid-creation and is
  NOT stolen, while a COMPLETE dead-pid body is still stolen promptly (preserves
  the prompt-dead-steal contract). planning-workspace writes its body atomically
  (flag:'wx') so it has no empty-body window.

- Double-steal (both locks): the steal was a bare fs.unlinkSync with no identity
  re-confirm, so two waiters could both reclaim a dead holder and end up holding
  concurrently. Replace with an atomic renameSync (only one racer wins the inode)
  guarded by a (dev,ino,body) identity re-confirm immediately before the steal;
  body content is part of the identity to defeat inode reuse.

Tests (seam-driven, no wall-clock, each proven RED-before-GREEN):
- clock-seam: fresh empty-body lock is not stolen at age ~0; a racer-recreated
  live lock is not double-stolen (identity re-confirm). Adds a beforeSteal seam.
- planning-workspace: racer-recreated live lock is not double-stolen.
- Updated the two #1217 unlinkSync-failure tests to the renameSync steal path
  (the bounded-backoff/no-busy-spin guarantee is preserved and re-asserted).

Uncontended acquire path is byte-for-byte unchanged.

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
2026-06-21 23:22:17 -04:00
Dave
510f37661a fix(core): acquireStateLock must not leak fd + orphan lock on write error (M9)
Root cause: in acquireStateLock, once openSync(O_CREAT|O_EXCL) created the
lock file, the subsequent writeSync(pid)/closeSync were unguarded. A
recoverable errno (EAGAIN etc., in ACQUIRE_LOCK_RETRY_ERRNOS) made the catch
do checkBudgetAndSleep + continue WITHOUT closing the fd or unlinking the
just-created empty lock — leaking a descriptor every occurrence and stranding
a content-less lock (the #500/#905/#1230 STATE.md write-corruption family).

Fix: wrap writeSync/closeSync in an inner try that guardedly closeSync(fd) +
unlinkSync(lockPath) then re-throws to the existing outer catch (DRY errno
classification). Recoverable errno retries from a clean slate; a FATAL errno
(e.g. ENOSPC, not recoverable) still propagates after cleanup — not masked.
Mirrors the already-shipped capability-lock.cts:415-425 pattern.

Extends the M8 test seam with a one-shot simulateWriteError errno + an
onLoopIteration snapshot hook so the orphan-before-retry is deterministically
observable. New tests prove RED (orphan stranded / fatal leaves orphan)
before the cleanup and GREEN after.

Source of truth src/state.cts (ADR-457); bin/lib/state.cjs is generated.

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
2026-06-21 13:11:22 -04:00
Dave
2fe17cb87d fix(core): writeStateMd must scan inside the lock (M8)
Root cause: writeStateMd computed its frontmatter disk scan
(syncStateFrontmatter — the READ half of a read-modify-write) BEFORE
acquireStateLock, leaving a TOCTOU window. A concurrent writer that
committed a new PLAN/SUMMARY between our scan and our lock made writeStateMd
stamp stale progress counts (lost update — the #500/#905/#1230 family). The
atomic sibling readModifyWriteStateMd already scans inside its lock.

Fix: move _diskScanCache.delete + syncStateFrontmatter inside the
acquireStateLock-held try, before platformWriteSync. Byte-for-behaviour
identical for single-threaded callers — only the concurrent-writer window
closes. Adds an afterAcquire test seam (mirrors the M1 _setLockProbes seam)
to make the window deterministic; new test proves RED (stale count) before
the reorder and GREEN after.

Source of truth src/state.cts (ADR-457); bin/lib/state.cjs is generated.

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
2026-06-21 13:11:22 -04:00
Dave
fc9bd70aff fix: PID-liveness gate for the two core-path file locks (audit M1+M2)
The STATE.md write lock (acquireStateLock) and the .planning/ workspace lock
(withPlanningLock) stole contended locks on mtime age alone with no
process.kill(pid,0) liveness check, and mis-ordered stale-vs-wait so a
live-but-slow holder could be robbed mid-critical-section.

M1 (lost update / STATE.md corruption): a live writer whose critical section
ran past the stale threshold aged out and a waiter unlinked its lock and
acquired -> two writers in STATE.md's read-modify-write window. mtime is a
leaky proxy for "holder is alive"; it leaks under exactly the slow-holder
condition the lock guards against.

M2 (uncaught EEXIST): withPlanningLock's timeout fallback unconditionally
unlinked whatever lock existed (even a live holder's) and re-acquired OUTSIDE
any try -- a concurrent re-create raced a raw EEXIST out of the helper.

Fix backports capability-lock.cts's liveness gate (process.kill(pid,0) via a
_setLockProbes/_resetLockProbes test seam):
- acquireStateLock: steal when holder pid is DEAD (any age) OR age exceeds a
  deadman ceiling (60000ms, ABOVE maxWaitMs=30000) so a verified-live holder is
  never stolen within budget; garbage/legacy bodies stay recoverable.
- withPlanningLock: same gate in the EEXIST path (dead stolen promptly, live
  waited on); removed the unconditional force-steal -> clear timeout throw,
  which also closes M2 (no re-acquire outside try).

Uncontended path unchanged byte-for-behaviour; realClock + real process.kill
remain the defaults. Pid-reuse residual fails safe (waits/times out, never
corrupts) and recovers at the deadman ceiling.

Tests: TDD red->green via the clock + new pid-liveness probe seams (no
wall-clock; #453 deleted the race tests). 8 new behavioural tests across
tests/clock-seam.test.cjs and tests/planning-workspace.test.cjs; the prior
withPlanningLock timeout test rewritten to pin the no-force-steal contract.

Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
2026-06-21 13:11:22 -04:00
Tom Boucher
4719b36d41 fix(#1445,#1446): exclude 999.x backlog from milestone totals; allow total_phases downward correction (#1490)
* fix(#1445,#1446): exclude 999.x backlog phases from milestone totals; allow total_phases downward correction

#1445: deriveProgressFromRoadmap (phase-lifecycle.cts), the roadmapPhaseCount
loop (state.cts), and getMilestonePhaseFilter (roadmap-parser.cts) all now
skip phase tokens matching /^999\b/ — consistent with the existing init.cts
filter. 999.x backlog dirs are consequently excluded from phaseDirs too.

#1446: shouldPreserveExistingProgress (state-document.cts) no longer includes
total_phases in its ratchet check. total_phases always takes the freshly
derived value; only completed_phases, total_plans, and completed_plans retain
ratchet behaviour.

Regression tests added for both bugs.

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

* chore: add changeset for #1445/#1446 progress-backlog-exclusion-and-ratchet

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

* fix(#1445,#1446): rename test files to fix-NNN convention; fix changeset pr: null

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 13:37:32 -04:00
Tom Boucher
666d933e16 ci(#1401): add no-adhoc-markdown-parsing rule (fence-strip + section-collect) + grandfather burn-down (#1402)
Tightens the over-broad heading-walk detection: removes heading-walk
entirely and narrows fence-regex to require a multiline body ([\s\S]),
so single-line tests like /^```/ and /^###\s+/ are no longer flagged.
Grandfathers the 10 genuine section-collect sites across state.cts,
milestone.cts, audit.cts, and phase-lifecycle.cts with concrete reasons.
Adds 12 RuleTester tests (3 positive, 9 negative) to eslint-rules.test.cjs.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 20:14:12 -04:00
Tom Boucher
ce8bcb1b95 refactor(#1398): migrate state.cts section-collects onto markdown-sectionizer seam, byte-identical STATE.md (epic #1372 T6) (#1399)
* refactor(#1398): migrate state.cts section-collect regexes onto markdown-sectionizer seam (epic #1372 T6)

Replace ≈18 hand-rolled `/(heading)([\s\S]*?)(?=stop)/` regex splices in
state.cts with direct `tokenizeHeadings` calls that compute the exact
[bodyStart, stopOffset) span, preserving byte-identical STATE.md output.

Non-migratable site left in place: `cmdStateRecordMetric`'s metricsPattern
captures table-header rows in group 1 — not a standard heading+body shape.
Write orchestration (readModifyWriteStateMd / syncStateFrontmatter /
shouldPreserveExistingProgress / #952 no-op guard) is UNTOUCHED.

Verification: t6-headtohead.cjs head-to-head harness runs 25 ops across 6
STATE.md fixture variants (inline, trailing-blanks, CRLF, no-frontmatter,
nested-acc, post-milestone) against origin/next and reports 0 diffs.
No-op guard confirmed: record-session on recorded:false leaves STATE.md
byte-identical. All 150 state tests and 62 milestone/forensics tests pass.

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

* test(#1398): capture T6 state-section-splice characterization; drop throwaway harness

Remove scripts/t6-headtohead.cjs (committed throwaway HEAD-vs-origin/next
byte-compare harness). Capture its coverage as 23 behavioral characterization
tests appended to tests/state.test.cjs, exercising all migrated cmdState*
write-ops across 7 fixture variants (inline, trailing-blanks, CRLF,
no-frontmatter, nested-acc, no-current-pos, post-milestone). Includes the
#952 no-op guard (recorded:false + byte-unchanged assertion) and
CRLF/trailing-blanks edge-case coverage. Also removes the dead
spliceStateSection helper (defined but never called) that was surfacing as
an @typescript-eslint/no-unused-vars warning.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 17:56:17 -04:00
Tom Boucher
c20d741dc9 fix(#1316): preserve prose STATE phase names (#1351) 2026-06-16 15:11:23 -04:00
Tom Boucher
80014109c1 fix: preserve state patch progress counters (#1345) 2026-06-16 11:52:09 -04:00
Tom Boucher
645601a10d refactor(#1286): T4 — migrate 5 large destructure callers off the core spine (batch 3) (#1287)
Migrate the entire core surface of commands (~23 symbols), phase (~17),
roadmap, state, template to the leaf modules directly (behaviour-identical —
leaves are the objects core re-exports by reference). All 5 now import zero
core symbols and are removed from the allowlist (9 -> 4). Dropped a dead
`void replaceInCurrentMilestone` from phase.cts; stale core.cjs docstrings
fixed. core.cts re-exports untouched (serve the remaining 4 idiom-hard
files); teardown is T-final. No behaviour change.

Closes #1286

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:03:22 -04:00
Tom Boucher
00c447701d fix(#1257): update pipe-table Status/Phase/Plan cells in planned-phase + begin-phase (#1260)
* fix(#1257): update pipe-table Status/Phase/Plan cells in planned-phase + begin-phase

cmdStatePlannedPhase ran its body-field replacements on the full file content,
so the case-insensitive ^Status: pattern matched the YAML frontmatter `status:`
line before the body `| Status | … |` cell — the cell never advanced to
'Ready to execute' and syncStateFrontmatter re-derived the stale 'planning'
status (the #1230 delta heuristic preserved it). cmdStateBeginPhase had
pipe-table else-branches only for Status/Last activity (#1256), so the Current
Position `| Phase |` / `| Plan |` cells were left stale while a spurious inline
`Phase: N — EXECUTING` line was prepended.

Both handlers now strip frontmatter before body-field replacement and update the
pipe-table cells in place via stateReplaceField, matching inline-format
behaviour. Systemic residual of #1255 / #1256.

Adds 4 regression tests (#1257 block in tests/state.test.cjs) covering both
findings — RED before the fix, GREEN after; full state-area suite (437) and
local unit suite stay green.

Closes #1257

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

* chore(#1257): backfill changeset pr number (#1260)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:29:18 -04:00
Tom Boucher
2668fbbeb0 fix(#1255): advance frontmatter status for pipe-table STATE.md on begin/complete-phase (#1256)
* fix(#1255): advance frontmatter status for pipe-table STATE.md on begin/complete-phase

state begin-phase/complete-phase called stateReplaceField on the FULL file content, so its case-insensitive ^Status: plain-pattern matched the YAML frontmatter status: line first (no g flag) and never updated the body pipe-table Status cell; syncStateFrontmatter then re-derived the stale status from the unchanged body, freezing the frontmatter status. Fix: strip frontmatter before the body-field replacements (operate on body only), reassemble with frontmatter preserved, so the body Status cell updates and the frontmatter derives correctly — for inline AND pipe-table body formats. Also corrects the Current Position pipe-table else-branches (Status/Phase/Last-activity) to write bare, consistent cell values. Pipe-table Status is a supported body format (not rewritten to inline).

Closes #1255

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

* chore(#1255): add changeset for pipe-table state status fix (#1256)

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

* test(#1255): fold pipe-table regression into state.test.cjs + Windows-portable frontmatter regex

Per the 2026-06 audit, new tests/bug-NNNN-*.test.cjs files are banned (lint-regression-test-names) — folded the 7 #1255 regressions into tests/state.test.cjs and removed the standalone file + its lint-test-file-count allowlist entry. Also fixed the frontmatter assertions' /^---\n/ anchors to /^---\r?\n/ (windows-test-parity-guard frontmatterAnchorLiteralNewline).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:42:28 -04:00
Tom Boucher
6724581285 fix(#1230): preserve frontmatter status/stopped_at when a state write doesn't change the body source (#1252)
* fix(#1230): preserve frontmatter status/stopped_at when a state write doesn't change the body source

Adds a delta heuristic in `readModifyWriteStateMd`: snapshots the body
Status and Stopped At fields before the transform, then after
syncStateFrontmatter runs, restores the existing frontmatter values for
any field whose body source was not changed by this write. Integrates
cleanly with the existing !resync progress-restore block (computes postFm
once, applies both restorations, reconstructs frontmatter once).

Regression tests added to bug-905 test file (same theme, no new file).

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

* chore(#1230): add Fixed changeset fragment

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:51:53 -04:00
Tom Boucher
e97b5d6b7b fix(#1217): bound acquireStateLock retries on recoverable errno (no busy-spin) (#1236)
* fix(#1217): bound acquireStateLock retries on recoverable errno (no busy-spin)

The recoverable-errno branch (`ACQUIRE_LOCK_RETRY_ERRNOS`) previously called
`continue` directly, skipping both `clock.sleep()` and the 30 000 ms budget
check. A permanently-failing ENOENT (e.g. parent dir removed) would spin at
100% CPU forever with the event loop fully blocked, making the OS-level
`timeout` the only escape.

Fix: extract a `checkBudgetAndSleep(context)` helper and call it from BOTH
the recoverable-errno path and the EEXIST contention path so every retry is
bounded and backed off identically.

Regression tests added to tests/clock-seam.test.cjs:
- persistent ENOENT throws budget-exceeded error (clock must advance via sleep)
- transient ENOENT (2 retries then success) acquires lock normally

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

* chore(#1217): backfill changeset PR number (#1236)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:10:59 -04:00
Tom Boucher
3827054954 fix(#1156): support table-format STATE.md and insert missing roadmap plan rows (#1172)
* fix(#1162): support table-format STATE.md in state field read/replace

Extend stateExtractField and stateReplaceField in state-document.cts to
detect and operate on pipe-table rows (| Field | value |).  The separator
row | --- | --- | is excluded from matching.  updateCurrentPositionFields
in state.cts now falls through to stateReplaceField for table-format
Current Position sections when the inline Status:/Last activity: patterns
do not match.

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

* fix(#1163): insert missing plan checklist rows in roadmap update-plan-progress

cmdRoadmapUpdatePlanProgress now inserts `- [ ] NN-XX-PLAN.md` checkbox
rows under the phase Plans: line when no per-plan checkbox rows exist yet
(fresh template).  Rows are sorted ascending and any already-summarised
plans are immediately marked [x].  The planCountPattern is extended to
also match plain `Plans:` (in addition to bold `**Plans:**`) so plan
counts are updated in both template variants.  The existing-rows check
covers both top-level and indented checkbox forms to preserve idempotency.

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

* fix(#1163): fill partial plan-row gaps and scope insertion to active milestone

- Finding 1 (HIGH): replace all-or-nothing rowsAlreadyPresent guard with per-file
  set-difference so missing rows are inserted even when SOME plan rows already exist
- Finding 2 (MEDIUM): extend planCountPattern to recognise **Plans**: (canonical
  template form — bold word + outer colon) alongside **Plans:** and plain Plans:;
  use two-pattern fallback for row insertion to anchor under Plans: checklist header
  rather than the **Plans**: summary line
- Finding 3 (MEDIUM): scope row insertion to the active (post-</details>) milestone
  region so duplicate phase headings in archived sections never receive new rows
- Finding 4 (LOW): rename misleading "pipe-like content" test to honestly describe
  what it tests (multi-row table isolation); add out-of-scope escaped-pipe comment
- Remove now-unused anyCheckboxMatched variable (lint clean)
- Add 5 adversarial regression tests (pre-fix failures confirmed)

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

* fix(#1163): scope missing-plan detection to active milestone; preserve authored state fields in table format

- roadmap.cts: compute activeRegion (post-</details> slice) once and use it
  for BOTH missingPlans detection and row insertion, so archived <details>
  rows no longer suppress active-section inserts (Finding 1 code-review)
- roadmap.cts: change (Plans:) inner capture to non-capturing (?:Plans:)
  in insertRowsPatternA to prevent group-numbering shift (Finding 3)
- state.cts: mirror inline-branch preserve-authored guards onto table-format
  branches in updateCurrentPositionFields — Status table branch checks
  isInList/matchesPattern before replacing; Last Activity table branch
  checks isDateShape/inList, preserving executor-authored narrative prose
  (Finding 2 code-review)
- tests: add three failing-first regression tests confirming each finding

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

* test(#1162,#1163): fold table-format + plan-row regressions into owning module tests

Move bug-1162 cases into tests/state.test.cjs and bug-1163 cases into
tests/roadmap.test.cjs under named regression describe blocks; delete the
standalone bug-NNNN files and prune their allowlist entries.

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

* docs(#1156): add changeset fragment for table-format state + roadmap insert fix

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:56:18 -04:00
Tom Boucher
b431b1fab4 fix(#1140): implement state add-roadmap-evolution CJS handler (#1148)
* fix(#1140): implement state add-roadmap-evolution CJS handler

`query state.add-roadmap-evolution` was unreachable: the CJS state router
listed it in the `unsupported` map with a circular message ("...is SDK-only.
Use: gsd-tools query state.add-roadmap-evolution ...") and no CJS handler
existed after the SDK retirement (ADR-0174). Every `/gsd:phase insert` and
`/gsd:phase --edit` run hit a dead end recording Roadmap Evolution.

Re-implement `cmdStateAddRoadmapEvolution` in CJS (src/state.cts) and wire it
into the state router; remove the now-stale `unsupported` entry. The handler
appends a single-line bullet under `## Accumulated Context` → `### Roadmap
Evolution` (creating the subsection/section if missing, deduping identical
entries), scoping every lookup to the Accumulated Context body so a decoy
heading in an unrelated section is never targeted, and flattening multiline
notes to a single bullet. Section-boundary regexes mirror the sibling
add-decision/add-blocker handlers and preserve following sections on CRLF input.

Regression cases live in tests/state.test.cjs (per the no-new-bug-NNNN-files
policy) and cover the literal issue repro plus the CLI/parser QA matrix
(missing/empty/whitespace note, flag-shaped value, duplicate flags, hostile
shell metacharacters, Unicode, decoy section, CRLF, missing STATE.md).

Closes #1140

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

* chore(#1140): backfill changeset PR number (1148)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:27:02 -04:00
Tom Boucher
e837bfc1de fix(#1101): record-session updates ## Session Continuity in place, no duplicate block (#1113)
The reported symptom (recorded:false yet STATE.md frontmatter still mutated) was
already resolved on next by #944/#948. This fixes the residual: the DWIM
auto-create recognised only the canonical `## Session` heading, so a bootstrap
`## Session Continuity` section fell through to the append branch and produced a
second `## Session` block. Insert only the missing canonical fields after the
`## Session Continuity` heading — preserving the heading and any prose (e.g.
"Next recommended action") — and teach the snapshot / frontmatter readers to
recognise that heading (the optional ` Continuity` group still excludes
`## Session Continuity Archive`, preserving #2444 scoping).

Closes #1101

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:54:28 -04:00
Tom Boucher
46967baae8 fix(#948): guard STATE.md no-op writes; preserve milestone_name/stopped_at (closes #944) (#952)
* test(#948): add regression tests for no-op write guard and record-session auto-create (#944)

Red before fix: 11/15 tests fail. Green after: 15/15.
Covers zero-match patch byte-identity, milestone_name preservation,
stopped_at frontmatter-wins, record-session auto-create fallback, and
adversarial fixtures (CRLF, empty body, non-canonical labels).

Also registers bug-948-state-noop-write-guard.test.cjs in the state
bucket of lint-test-file-count.allowlist.json.

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

* fix(#948): guard STATE.md no-op writes; preserve milestone_name/stopped_at (closes #944)

Shared root cause: `readModifyWriteStateMd` wrote STATE.md unconditionally
even when the transform produced no change, and `syncStateFrontmatter`
re-derived frontmatter from the possibly-stale body on every write.

Three coordinated fixes in src/state.cts:

1. readModifyWriteStateMd: add no-op guard — when transform result ===
   input content, skip the write entirely (no platformWriteSync, no
   last_updated bump, no frontmatter re-derive). Fixes #948 zero-match
   phantom write and the #944 phantom last_updated bump.

2. syncStateFrontmatter: extend existing-frontmatter preserve logic —
   fall back to existingFm['milestone_name'] / existingFm['milestone']
   when the derived value is the template placeholder 'milestone'
   (getMilestoneInfo returns this literal when it cannot match the
   version in ROADMAP.md); prefer existingFm['stopped_at'] /
   existingFm['paused_at'] over a body-derived value (the frontmatter
   value, written by the canonical record-session path, wins over stale
   historical body lines). Mirrors the fallback already in cmdStateJson.

3. cmdStateRecordSession: when --stopped-at / --resume-file are supplied
   but body labels are absent, DWIM auto-create a canonical ## Session
   section (mirroring how add-decision / add-blocker / record-metric
   auto-create their sections). Never return a silent recorded:false when
   the caller supplied values.

SDK check: no sdk/src/state.ts exists in this repo (the comment in
cmdStateSnapshot references a sibling concern in the TypeScript SDK
codebase, which is a separate repo not present here).

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

* chore: add changeset for PR #952 (fix #948/#944)

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

* fix(#948): correct stopped_at preserve rule; adjust test for sync behaviour

The "always prefer frontmatter stopped_at" rule in syncStateFrontmatter
was too aggressive — it broke phase.complete which intentionally updates
stopped_at in the body and expects syncStateFrontmatter to pick it up.

The primary fix (no-op guard in readModifyWriteStateMd) already prevents
the stale-body-overwrites-frontmatter scenario from #948: the file is not
written when the transform produces no change, so syncStateFrontmatter
never runs on a zero-match patch. The body-derived value can only win when
an actual write occurs, which means the body was legitimately updated.

Reverted to the original #905 rule for stopped_at/paused_at: fall back to
existing frontmatter only when the derived value is absent (empty/null).

Also adjusted the sync-suite test to assert what state sync actually does
(milestone_name preservation) rather than a stopped_at-wins property that
state sync does not have by design.

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

* fix(#944): update existing session block in place (adversarial review)

HIGH finding: the DWIM auto-create in cmdStateRecordSession was appending
a second ## Session block unconditionally, even when one already existed
with non-canonical content (e.g. a markdown table). Both
buildStateFrontmatter and cmdStateSnapshot read only the FIRST ## Session
block via regex, so the newly-written Stopped at / Resume file values
landed in the second, invisible block — frontmatter stopped_at stayed
stale and state-snapshot returned nulls.

Fix: check for an existing ## Session heading. When one is present,
normalize that section in place by replacing its body with canonical
**Last session:** / **Stopped at:** / **Resume file:** bold-label lines.
Only append a brand-new section when NO ## Session heading exists.

LOW finding: the auto-create scaffold emits **Last session:** but
cmdStateSnapshot only matched **Last Date:**, so session.last_date was
null after auto-create despite a valid timestamp being written.

Fix: extend the lastDateMatch regex in cmdStateSnapshot to also accept
**Last session:** / Last session: (the form the scaffold writes).

Tests: 3 new tests added to bug-948-state-noop-write-guard.test.cjs that
confirmed failure against the previous HEAD and pass after this fix:
- exactly one ## Session block after record-session with non-canonical existing block
- state-snapshot sees correct stopped_at via first Session block (not a duplicate)
- state-snapshot session.last_date is non-null after auto-create on body-less file

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

* fix(#944): improve in-place section replace to cleanly remove old body content

The previous regex `/(^## Session[ \t]*$)([\s\S]*?)(?=\n^## |\n*$)/im`
with a lazy match consumed nothing after the heading, so old non-canonical
body content (e.g. table rows) remained after the new canonical lines.

While functionally correct (parsers found the canonical lines first in the
FIRST ## Session block), it left stale content in the section. Replace with
a negative-lookahead per-line pattern that consumes all content from the
heading up to (but not including) the next ## heading, producing a clean
section with only the canonical bold-label lines.

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:22:29 -04:00
Tom Boucher
d12809985e fix(#905): preserve STATE.md frontmatter scalars in syncStateFrontmatter (#907)
syncStateFrontmatter was silently dropping current_phase, current_phase_name,
current_plan, and progress when body annotations were absent (e.g. after an
agent or tool rewrote the body). These scalars can only be derived from body
annotations — when absent, buildStateFrontmatter returns nothing for those
keys. Added existingFm fallbacks mirroring the same pattern already applied in
cmdStateJson, so every writeStateMd call preserves the existing values instead
of stripping them. Also extended cmdStateJson with the same fallbacks for the
three non-progress scalars.

Adds regression test (7 cases) + lint-test-file-count allowlist entry.

Closes #905

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:52:43 -04:00
Tom Boucher
df04aae5e4 enhancement(#537): migrate all hand-written bin/lib/*.cjs to TypeScript source of truth (ADR-457) (#602)
* enhancement(#537): migrate code-review-flags to TS source of truth

Collapse the hand-written get-shit-done/bin/lib/code-review-flags.cjs to a
TypeScript source of truth (src/code-review-flags.cts), compiled by tsc to a
gitignored .cjs build artifact at the same path, per ADR-457 (build-at-publish).
Second module after the semver-compare pilot (#541).

Behaviour is preserved byte-for-behaviour (characterization test added in
tests/code-review-flags.test.cjs locks the parser quirks). Adds compile-time
type checking: CodeReviewFlags interface + CodeReviewWorkflow literal union.
The require() path is unchanged, so code-review.md and the bug-3727 test keep
working. The emitted .cjs is gitignored and eslint-ignored, mirroring the pilot.

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

* enhancement(#537): migrate 9 leaf bin/lib modules to TS source of truth

ADR-457 build-at-publish, batch 1 (pure leaf modules, 0 sibling-deps):
001-legacy-orphan-files, context-utilization, redaction, artifacts,
command-arg-projection, clock, ui-safety-gate, review-reviewer-selection,
clusters. Each moves to src/*.cts (strict TS, typed), compiled by tsc to a
gitignored .cjs at the same require() path; behaviour preserved byte-for-
behaviour. Adds src/node-globals.d.ts (minimal ambient shim; "types":[]).

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

* chore(#537): add @types/node, drop hand-rolled node-globals shim

ADR-457 migration infra: replace the temporary src/node-globals.d.ts ambient
shim with @types/node@22 + "types":["node"] in tsconfig.build.json. Unblocks
migrating the ~49 remaining bin/lib modules that use node:fs/path/os/
child_process. Build + full suite (3030 pass) + lint all green; no .cts type
changes were needed (real Node types matched the shim).

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

* enhancement(#537): migrate 9 more bin/lib modules to TS (batch 2)

ADR-457 build-at-publish. Clean leaves: installer-migration-report,
prompt-budget. Type-error-prone leaves (were tsconfig.lint-excluded; now
strict-typed and removed from that exclude list): secrets, phase-lifecycle,
workstream-name-policy, decisions, validate, schema-detect. Plus
runtime-name-policy. Strict type fixes narrow unknown->concrete domain types
(no any/ts-ignore); behaviour preserved. Full suite green, lint 0 errors.

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

* enhancement(#537): migrate runtime-slash to TS (cross-import proof)

ADR-457. First cross-module TS->TS import: src/runtime-slash.cts imports
./runtime-name-policy.cjs and tsc resolves the sibling .cts types under strict
(no declaration files; NodeNext .cjs->.cts mapping), emitting a correct
require("./runtime-name-policy.cjs"). Confirms the recipe for coupled modules,
which must be migrated in dependency order (leaves-up). Suite green, lint clean.

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

* enhancement(#537): migrate 10 more bin/lib modules to TS (batch 3)

ADR-457 build-at-publish, Wave-1 leaves: event, workstream-inventory-builder,
plan-scan, fallow-runner, project-root, installer-migration-authoring,
update-context, 000-first-time-baseline, runtime-homes, model-catalog. Strict
typing fixed real issues (narrowing unknown, qualified fs/path calls, removed
unnecessary casts); plan-scan/project-root/workstream-inventory-builder dropped
from tsconfig.lint exclude. Behaviour preserved; suite green, lint 0 errors.

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

* enhancement(#537): migrate 5 large Wave-1 leaves to TS (batch 4)

ADR-457 build-at-publish: configuration, state-document, shell-command-
projection (42 dependents), security, command-aliases. shell-command-
projection keeps a namespace child_process import for mock-intercept
testability. loadConfig/migrateOnDisk emit synchronously (every caller uses
them sync; the one awaited migrateOnDisk caller tolerates a non-Promise) —
full suite (3030 pass) confirms behaviour preserved. configuration/
state-document/command-aliases dropped from tsconfig.lint exclude. Also fixes
the malformed batch-3 changeset frontmatter (type/pr) that failed lint:docs.

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

* enhancement(#537): migrate 6 Wave-2 modules to TS (batch 5)

ADR-457 build-at-publish: config-schema, model-profiles,
002-codex-legacy-hooks-json, logger, active-workstream-store, adr-parser.
First batch importing already-migrated siblings (configuration, model-catalog,
shell-command-projection, redaction, security) via ./sibling.cjs specifiers.
Strict type narrowing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors.

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

* enhancement(#537): migrate 5 large Wave-2 modules to TS (batch 6)

ADR-457 build-at-publish: graphify, install-profiles, intel,
installer-migrations, worktree-safety. installer-migrations preserves its
dynamic require() loader for numbered migration modules (scoped lint
suppressions). Strict typing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors. Wave 2 complete.

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

* enhancement(#537): migrate Wave-3 modules to TS (batch 7)

ADR-457 build-at-publish: planning-workspace, runtime-artifact-layout,
command-routing-hub, drift. Uses `import x = require()` for export= siblings;
drift's lazy require of runtime-slash hoisted to a top-level import (verified
non-circular). Behaviour preserved; suite 3030 pass, lint 0 errors.

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

* enhancement(#537): migrate small Wave-4 modules to TS (batch 8)

ADR-457 build-at-publish: cjs-command-router-adapter, phase-command-router,
surface, roadmap-upgrade. Typed the hub router handler results as the HubResult
discriminated union; surface drops 4 genuinely-unused imports. Behaviour
preserved; suite 3030 pass, lint 0 errors.

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

* enhancement(#537): migrate core hub (2.5k LOC, 68 dependents) to TS (batch 9)

ADR-457 build-at-publish: get-shit-done/bin/lib/core.cjs -> src/core.cts,
preserving all 63 exports via export=. All sibling deps already migrated
(shell-command-projection, model-profiles, model-catalog, worktree-safety,
planning-workspace, project-root, configuration, config-schema). Strict types,
no any/ts-ignore; config-schema lazy require hoisted (non-circular). Behaviour
preserved (independently verified: core's shard 3030 pass / 0 fail).

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

* test(#537): make ESLint-coverage + test-sprawl checks migration-aware

#551 test hardcoded 12 now-migrated modules as "hand-written, must be linted";
that invariant is obsoleted by the ADR-457 migration. Rewrite it to a
filesystem-driven invariant that holds at every stage: a bin/lib/*.cjs must be
eslint-ignored IFF it has a src/*.cts source (tsc-generated), else linted
(covers package-identity, which has no TS source). Also eslint-ignore
config-types.cjs (has a src counterpart) and drop the redundant
tests/clock.test.cjs (clock already covered by clock-seam + bug-474 tests),
which tripped the lint-test-file-count ratchet.

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

* enhancement(#537): migrate 9 Wave-5 router/inventory modules to TS (batch 10)

ADR-457 build-at-publish: phases/verify/init/agent/task/validate/roadmap/state
command routers + workstream-inventory. Router handler results typed against
core's exported shapes; behaviour preserved (caught+fixed a --verify boolean
flag regression mid-migration). Full suite green across all shards (only the 4
local gpg-env changeset-notes failures remain; CI passes them).

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

* enhancement(#537): migrate 7 Wave-5 modules to TS (batch 11)

ADR-457 build-at-publish: gap-checker, docs, check-command-router, frontmatter,
learnings, gsd2-import, profile-pipeline. Behaviour preserved; full suite green
across all shards (only the 4 local gpg-env failures remain). Also broadens
atomic-write-coverage.test.cjs to accept the tsc-compiled namespace-import form
while still asserting platformWriteSync is called (safety guard intact).

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

* enhancement(#537): migrate config + profile-output to TS (batch 12)

ADR-457 build-at-publish: config (729 LOC), profile-output (1142 LOC). All
exports preserved; cmdMigrateConfig de-asynced (migrateOnDisk is sync, awaited
caller tolerates it). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures). Wave 5 complete.

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

* enhancement(#537): migrate 5 Wave-6 modules to TS (batch 13)

ADR-457 build-at-publish: template, uat, workstream, roadmap, audit. Behaviour
preserved (dead toPosixPath import dropped from audit; inline requires hoisted).
Suite green across all shards (only the 4 local gpg-env failures).

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

* enhancement(#537): migrate commands + state hubs to TS (batch 14)

ADR-457 build-at-publish: commands (1305 LOC), state (2074 LOC, 17 dependents).
All exports preserved; inner requires kept non-hoisted where load-order matters
(install.js, per-call security); acquireStateLock cast inlined to preserve the
err.code source token a structural test inspects. Behaviour preserved; suite
green across all shards (only the 4 local gpg-env failures). Wave 6 complete.

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

* enhancement(#537): migrate milestone to TS (batch 15a, hand-authored)

ADR-457 build-at-publish: milestone -> src/milestone.cts. Authored directly
(subagent capacity was unavailable). Also relaxes core.output()'s 3rd param to
optional, matching its real always-optional call contract (unblocks remaining
2-arg output callers). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures).

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

* enhancement(#537): migrate phase, verify, init to TS (batch 15, final modules)

ADR-457 build-at-publish, Wave 7 (the last hubs): phase (1608 LOC), verify
(1615), init (2113). Adds src/package-identity.d.cts so verify can import the
permanently value-baked package-identity.cjs under strict TS.

Fixes two regressions the migration introduced in verify: restore
cmdValidateHealth's `return result` (callers/tests read result.warnings — it is
NOT side-effect-only), and make the bug-3384 source-pattern test tolerant of the
tsc-compiled bracket-notation form of the git_list_failed->W020 branch (behaviour
intact). Full suite green across all shards (only the 4 local gpg-env failures);
lint 0 errors. All 86 migratable bin/lib modules are now TypeScript sources.

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

* chore(#537): finalize ADR-457 migration — retire tsconfig.lint.json

All hand-written bin/lib/*.cjs are now src/*.cts sources, so the checkJs
stopgap tsconfig.lint.json (unused; not wired into eslint, scripts, or CI) is
deleted per ADR-457's final step. Also gitignore the tsc-generated
config-types.cjs (was still committed) for consistency with every other
emitted artifact. package-identity.cjs stays value-baked (declared via
src/package-identity.d.cts). Suite green; #551 ESLint-coverage test green.

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

* fix(#537): add prepare script so unpacked/git installs build bin/lib artifacts

ADR-457 build-at-publish: bin/lib/*.cjs are now gitignored, built by tsc. The
prepack/prepublishOnly hooks cover `npm pack`/publish, but `npm install -g
<dir>` and git installs run the `prepare` lifecycle — which was missing — so the
unpacked install shipped without the compiled .cjs and failed at startup with
"Cannot find module './lib/core.cjs'" (caught by the smoke-unpacked CI job).
Add `prepare` mirroring prepublishOnly (build:lib + build:hooks). prepare does
NOT run for registry consumers (they get the pre-built tarball), only for
source/local/pack installs.

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

* fix(#537): make CI build/lockfile checks work with gitignored bin/lib artifacts

ADR-457 build-at-publish exposed two CI assumptions that bin/lib/*.cjs are
always present on disk:
- check:env's lockfile-sync ran `npm ci --dry-run`, which now triggers the
  `prepare` build (tsc) — but it runs before deps are installed, so tsc is
  absent and it misreported the lockfile as out of sync. Add --ignore-scripts
  (a lockfile check must not build).
- the lint-tests job installs with --ignore-scripts (no prepare build), but
  lint:skill-deps require()s the built install-profiles.cjs. Add an explicit
  `npm run build:lib` step after install.

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

* fix(#537): narrow prepare to build:lib only (unbreak packed-smoke pack step)

prepare running build:hooks emitted "✓ Copying ..." stdout during `npm pack`,
which the install-smoke "Pack root tarball" step captures into $GITHUB_OUTPUT —
breaking it with "Invalid format". build:lib (tsc) is silent on success and is
all the unpacked/source install needs (the smoke-unpacked assertions exercise
gsd-tools, i.e. bin/lib, and tolerate hook setup with `|| true`). Matches
prepack. build:hooks still runs on prepublishOnly for real publishes.

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

* fix(#537): wire Stryker mutation gate to build-at-publish layout

The gate scored 0.00 because it mutated changed bin/lib/*.cjs that (a) were
generated artifacts and (b) included modules with no coverage in the command's
test set. Rework: mutation.yml now derives changed COVERED modules from
src/*.cts and maps them to their built bin/lib/*.cjs; Stryker mutates those
built artifacts with a no-rebuild command (mutating src/*.cts + per-mutant tsc
was ~3x over the 30-min CI budget).

NOTE: with the gate now correctly measuring the covered modules, their actual
mutation score is 42.94% (< break 50) — a pre-existing test-coverage gap
(adr-parser/prompt-budget/etc.), not introduced by this behaviour-preserving
migration. Reaching 50 needs more tests, a threshold/scope change, or a waiver —
a maintainer decision.

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

* test(#537): raise mutation coverage of covered modules above the 50 gate

Adds focused example-based unit tests that kill surviving mutants in the two
lowest-scoring covered modules:
- tests/prompt-budget.unit.test.cjs (112 tests): 17.9% -> 97.9%
- tests/adr-parser.unit.test.cjs (205 tests): 44.7% -> 89.4%
Both wired into stryker.config.mjs's command. Fresh full run over the 6 covered
modules now scores 82.25% (>= break 50); every covered module is >= 68%.

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

* enhancement(#537,#609): parallelize mutation gate via dynamic per-module matrix

The serial Stryker run timed out at 30 min once the migration's added tests
made every mutant re-run ~300 tests. Replace it with a dynamic matrix so the
gate completes well under budget — folded into this PR (was tracked as #609)
because it's a prerequisite for this PR's mutation gate to pass.

- scripts/mutation-matrix.cjs: single source of truth (covered-module -> test
  files) computing changed covered modules from git diff -> {has_work, matrix}.
- mutation.yml: detect -> dynamic `matrix: fromJSON(...)` mutate job (one
  parallel shard per changed module, scoped via MUTATION_TEST_CMD to only that
  module's tests, 15-min/shard) -> summary job that KEEPS the legacy check name
  "Stryker mutation score (changed files only)" so branch protection is
  unchanged. Per-shard jobs report as "Stryker (<module>)".
- stryker.config.mjs: commandRunner.command reads MUTATION_TEST_CMD (falls back
  to the full command locally).

Closes #609.

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

* test(#537,#609): give each mutation shard ≥50% on its own tests; drop blacksmith note

Per-module sharding revealed that active-workstream-store (46.5%) and
frontmatter (7.4%) only cleared 50% in the old serial run via timeout-noise from
the bloated 300-test command; on their own tests they were below the gate. Add
focused unit tests:
- tests/active-workstream-store.unit.test.cjs (115 tests): 46.5% -> 81.9%
- tests/frontmatter.unit.test.cjs (165 tests): 7.4% -> 63.4%
Both wired into scripts/mutation-matrix.cjs (per-module test map) and
stryker.config.mjs DEFAULT_TEST_CMD. All 6 covered modules now clear break:50
with only their own tests (config-schema/context-utilization/prompt-budget/
adr-parser already did). Also removes the leftover blacksmith TODO comment —
GitHub-hosted runners only; speed comes from parallel per-module shards.

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

* test(#537,#609): strengthen prompt-budget tests to clear the gate on its own tests

prompt-budget scored 39.58% when mutation-tested with ONLY its own tests (the
way the per-module CI shard runs it) — an earlier ~98% reading was inflated by
accidentally running the full multi-module command. Add 96 targeted tests to
tests/prompt-budget.unit.test.cjs (exact note-template text, plan-truncation
arithmetic/percentages, drop-block strings, noteInjected/hardFailed booleans):
scoped score 39.58% -> 68.75% (>= break 50). All 6 covered modules now clear
the gate on their own tests.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:45:01 -04:00