* chore(#2143): prohibition-with-teeth + migrate remaining table sites — Phase 4
Phase 4 of epic #2143 (ADR-2143 §7). Completes the markdown table/mutation
consolidation by (a) giving the ad-hoc-parsing prohibition teeth and (b)
migrating the last ad-hoc table sites onto the shared seam.
- src/markdown-table.cts: new formatting-preserving `updateTableCell` primitive
(self-contained, ragged-row-tolerant header/delimiter/cell-range scan; splices
only the target cell's raw span, preserving all other bytes incl. padding/CRLF;
no-op-preserves-padding when a transformer returns the current value). Exports
splitTableRow/isDelimiterRow/findTableStartOffset for tolerant reuse.
- eslint-rules/no-adhoc-markdown-parsing.cjs: TABLE-REGEX detector extended to
`new RegExp(<literal|static-template>)`; new `.replace()`-mutation detector for
roadmap/state/content receivers with a table/section-shaped pattern.
- scripts/lint-table-schema-drift.cjs (wired into lint:ci): fails if a TABLE_SCHEMA
header drifts from its authored table; tests import its logic (single source).
- Migrated onto the seam (behaviour-preserving vs pre-Phase-4 HEAD, verified
byte-diff old-vs-new): roadmap.cts cmdRoadmapUpdatePlanProgress, phase.cts
cmdPhaseComplete + traceability, milestone.cts cmdRequirementsMarkComplete,
uat.cts read path, state.cts metrics/decisions/By-Phase.
- Incidental correctness gains from the migration: a decoy table can no longer
swallow a phase-progress update (## Progress scoping); a ragged neighbouring
row no longer silently aborts an edit; completing integer phase N no longer
touches a decimal sub-phase N.x row; record-metric no longer drops trailing
section content or duplicates the ## Performance Metrics section.
- Kept justified allow-adhoc-markdown markers only where genuinely not a table
(security.cts <|role|> token) or a loose non-GFM section (uat human-verify).
Two orthogonal isolated reviews (correctness/adversarial + security) passed;
correctness found 4 behaviour regressions in the first migration pass, all fixed
and re-verified byte-identical-or-better vs OLD.
Surfaced for maintainer (pre-existing, ambiguous domain logic, NOT changed here):
templates/state.md places a By-Phase table under ## Performance Metrics while
cmdStateRecordMetric assumes a Plan|Duration|Tasks|Files table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): match traceability row by first-cell value, not Requirement header
Phase 4's migration matched the REQUIREMENTS.md traceability row by a column
literally named `Requirement` (`row['Requirement']`), but real tables head that
column `REQ-ID`. The by-name lookup found nothing, so `phase complete` and
`requirements mark-complete` left the Status cell `Pending` (regressed #2769 /
#2203, caught by gsd-test — 8 failures, both node 22/24).
- src/phase.cts, src/milestone.cts: match the row by its FIRST cell's value
(the requirement-ID column) regardless of that column's HEADER name, via
`Object.values(row)[0]` (updateTableCell builds the record in header order).
This mirrors OLD's first-cell `\|\s*<id>\s*\|` anchor, restoring header-name
independence while keeping the seam.
- src/milestone.cts hasTable: broadened from `Requirement`-only to also
recognize `Requirement ID` / `REQ-ID` / `REQ ID` headers, kept in sync with
the now-positional rowMatch/hasRow so a REQ-ID-headed table participates in
the ADR-2143 §6 write-set and the #2140 table_unmatched drift check (it was
silently omitted before — a checkbox-only partial reconcile against a REQ-ID
table could report as fully reconciled). The `Requirement`-headed path is
byte-identical to OLD.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#2143): replace stale structural milestone guards with behavioural suite
The `milestone.cjs regex global state fix` block was a source-structure guard
(allow-test-rule: structural-regression-guard) — it readFileSync'd the compiled
milestone.cjs and asserted removed regex idioms (`tablePattern.test`,
`afterTable !== reqContent`, `doneTable = new RegExp(...)`). Phase 4's migration
deleted those regexes (table update is now updateTableCell), making the
assertions obsolete. Per the Test Cleanup rule, replace them in-PR with a
behavioural suite driving the compiled CLI:
- multi-ID mark-complete flips all IDs (guards the lastIndex/global-state class),
- Pending->Complete flip under both `REQ-ID` and `Requirement` headers (#2769),
- idempotent already_complete detection with no corruption,
- REQ-ID-headed table participates in write_set (traceability entry, applied),
- REQ-ID-headed table trips #2140 table_unmatched drift on a missing row.
Pruned the now-nonexistent structural-regression-guard entry from the
lint-allow-test-rule-refs allowlist (the source-text-is-the-product entry for
the same file remains valid).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(changeset): backfill PR number 2253
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): record-metric targets its own metrics table, not By-Phase velocity
`state record-metric` appended its per-plan row (`| Phase 1 P1 | 5min | 3 tasks |
4 files |`) into the FIRST table under `## Performance Metrics` — which on a real
template-derived STATE.md is the By-Phase velocity table `| Phase | Plans | Total
| Avg/Plan |`, polluting it on EVERY plan completion (execute-plan.md:414 is a
per-plan call). The command's own metrics table is `| Plan | Duration | Tasks |
Files |`, which the template does not ship, so the row never reached it; the
scaffold branch also emitted a wrong `| Phase | Plan | Duration | Notes |` header
matching neither the row nor the canonical table.
Pre-existing (predates Phase 4); surfaced while migrating this site and fixed here
per no-defer, on the user's explicit go-ahead.
- src/state.cts cmdStateRecordMetric: locate the metrics table by its own header
shape (`Plan|Duration|Tasks|Files`, via splitTableRow/isDelimiterRow) rather
than "first table in the section". When the section exists but has no metrics
table (only the By-Phase table), self-heal by appending a fresh **Per-Plan
Metrics:** table to the END of the section body — By-Phase table, Recent Trend
and footer preserved verbatim, no duplicate `## Performance Metrics` heading,
created stays false. Absent-section scaffold header corrected to the canonical
`| Plan | Duration | Tasks | Files |`. Ragged-tolerance + None-yet preserved.
- Not touching templates/state.md (golden-install-parity hashed) — record-metric
self-creates the table on first use instead.
Failing-first regression test (tests/state.test.cjs) demonstrates the By-Phase
pollution on the pre-fix build, then green after. Verified: no pollution, self-
heal idempotency, both-tables isolation, content/heading preservation, flags,
None-yet, corrected scaffold header (23-check adversarial harness + all existing
record-metric scenarios).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#2143): deleteSection seam primitive (level-bounded whole-section removal)
ADR-2143 §4 shipped withSection/collectSection (replace a section BODY) but no
way to DELETE a section (heading + body). Phase 4 suppressed the phase-remove
section delete instead of building it. deleteSection(content, predicate, opts)
locates the section via the collectSection machinery and splices out from the
heading's start offset to the next same-or-higher-level heading — so a level-3
`### Phase N` delete stops at a following level-2 `## Progress`, never past it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): phase remove no longer deletes ## Progress on last-phase removal
updateRoadmapAfterPhaseRemoval deleted a `### Phase N` detail section with a
greedy raw regex whose lazy scan, on the LAST phase, ran to EOF and destroyed
the following `## Progress` heading and its entire tracking table — silent data
loss, uncovered by tests (removal tests only exercised a middle phase). Migrated
onto the new deleteSection seam (level-bounded, stops at `## Progress`); dropped
the allow-adhoc-markdown SECTION-DELETION suppression. Failing-first regression
(tests/phase.test.cjs) removes the LAST phase and asserts the ## Progress heading
+ table survive; middle-phase removal is byte-identical.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(#2143): deleteTableRow seam primitive (row removal, ragged-tolerant)
Sibling of updateTableCell: locates the first GFM table, matches a DATA row by
predicate (ragged-tolerant record build, header order), and splices out that
row's whole line preserving every other byte. Returns {ok:false,reason} on no
table / no match. Enables migrating the phase-remove Progress-table row delete
off its ad-hoc regex (ADR-2143 §7 — the "future row-delete seam" Phase 4 punted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): phase remove deletes the Progress row via deleteTableRow
The Progress-table row delete used a whole-document regex with two defects:
(a) `\.?\s` required whitespace after the phase number, so a COMPACT row
`|2|Beta|` was never deleted (stale row left behind); (b) unscoped — it could
strike a row in a different table (e.g. an earlier `| Phase | Requirements |`
table). Migrated onto deleteTableRow, scoped to the `## Progress` section
(mirrors deriveProgressFromRoadmap), matching the row by first-cell phase number
(integer zero-pad-insensitive; decimal exact; removing `2` never touches `2.5`).
Both allow-adhoc-markdown suppressions removed. New behavioural tests: compact
unpadded row deleted; padded byte-parity on the surviving rows (their ordinal
correctly renumbers via the pre-existing renumber block).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): deleteTableRow leaves no dangling newline on last EOL-less row
Deleting the final row of a table with no trailing EOL sliced from the row's
start to end-of-string, stranding the newline that terminated the previous line.
Back rowStart over the preceding \r?\n in that branch so the table ends cleanly.
(Caught by the primitive's own unit test on gsd-test; local scenario checks
missed the no-trailing-EOL edge.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): migrate read-only section-collects onto collectSection
Six hand-rolled `## Section` read-extract regexes replaced by the collectSection
seam (behaviour-preserving; extracted bodies feed the same downstream parsers):
state.cts matchSessionSection (## Session / ## Session Continuity) + ## Blockers,
smart-entry.cts ## Blockers, audit.cts ## Current Focus + ## Open Questions.
Removes 6 allow-adhoc-markdown "pending #1372" suppressions. Incidental fix: the
old Session regex `## Session[ \t]*\n` silently failed on a CRLF `## Session\r\n`
heading (Windows STATE.md), nulling all session fields; collectSection is
CRLF-safe, so session state now resolves on Windows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): fence-safe state-transition section writes + dedup stripFrontmatter
- milestoneCompleteCore's `## Current Position` and `## Operator Next Steps`
section resets used fence-blind raw regexes that a fenced `##` inside the body
could truncate/mis-target (#2130/#2067/#2080 class). Migrated onto a
fence-aware tokenizeHeadings-based helper (resetSectionVerbatim) that is
byte-identical to the old output on the canonical path (9/9 fixtures) and
correctly ignores a fenced fake heading (proven robustness gain).
- mutateCurrentPositionFirstTime: hand-rolled locate+splice → collectSection +
replaceSection (byte-parity).
- stripFrontmatter was inlined byte-identically in state.cts AND
state-transition.cts; hoisted the single canonical copy into frontmatter.cts
(both call sites now import it) + unit tests — eliminates the divergence risk
per CLAUDE.md "Generative Fix Divergence". Removes 3 allow-adhoc-markdown /
#1372 markers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): name-address By-Phase sum + uat parse, eslint recall hole, catches
- state.cts By-Phase "Total plans completed" sum: positional 2nd-cell regex →
name-addressed splitTableRow read (correct on a reordered header, where the
old code silently summed the wrong column). Marker removed.
- uat.cts parseVerificationItems: loose pipe regex → splitTableRow within the
existing table/numbered/bullet union scan (item list byte-identical; does NOT
reintroduce the reverted strict-parseMarkdownTable item-drop). Marker removed.
- eslint no-adhoc-markdown-parsing: close the `new RegExp(identifier)` recall
hole — resolve a const-declared table-shaped regex identifier (mirrors the
.replace() detector) + RuleTester cases; param/call args stay out (boundary).
- commands.cts: delete a lying comment that claimed the scaffold date "stays on
raw UTC / deferred" — #2136 already moved it to realClock.localToday().
- Empty catches (classified, not blind-swept): removed 4 dead try/catch;
fixed 3 error-hiding (phase-insert decimal-dir I/O collision now fails loud;
phase-remove rename partial-failure surfaced; milestone-archive true count via
finally); left best-effort swallows with justification comments.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): extractFencedBlock seam + migrate api-coverage named fence
parseCoverageMatrix extracted its ```coverage fenced block with an ad-hoc regex
(the last real allow-adhoc-markdown suppression). Added extractFencedBlock to the
markdown-sectionizer seam (reuses stripFencedCode's CommonMark fence engine —
info-string match, ~~~/backtick, nesting, indent) and migrated onto it; byte-
parity on the parsed CoverageMatrix across 8 fixtures. Only security.cts:367
(a genuine `<|role|>` protocol-token false-positive, not a GFM table) remains
marked in src/ — the "prohibition with teeth" goal (nothing grandfathered but a
true FP) is met.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): By-Phase row insert is name-addressed (insertTableRow seam)
updatePerformanceMetricsSection's INSERT-new-row branch located the By-Phase
table with a canonical-column-order-only regex + a hardcoded positional row
literal, so on a reordered header it silently inserted nothing — inconsistent
with the now name-addressed UPDATE and SUM halves of the same function. Added
insertTableRow (markdown-table seam sibling of updateTableCell/deleteTableRow:
name-addressed, header-order-agnostic, EOL-preserving) and migrated the branch
onto it, mapping By-Phase values by column NAME. Canonical-order output is
byte-identical; a reordered header now inserts a correctly-mapped row; a
pre-existing CRLF mixed-EOL splice glitch is incidentally fixed. Retired the
now-dead byPhaseTablePattern const.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): phase-list checkbox flip via updateBullet seam
Added updateBullet (markdown-sectionizer): a fence-aware, offset-tracked
single-bullet write primitive (GFM 1–4-space marker tolerance) — the write
counterpart to read-only iterateBullets. Migrated mutateMilestonePhase's
phase-list checkbox flip (`- [ ] Phase N …` → `- [x] … (completed <date>)`)
off its whole-slice regex onto it, same milestone-slice scope + clock seam.
Byte-identical across simple / idempotent / metachar-title / double-space /
CRLF scenarios.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): scope the Progress-ordinal renumber to ## Progress via seam
phase remove's integer-renumber decremented Progress-table phase ordinals with a
whole-document `content.replace(/(\|\s*)(\d+)(\.\s)/g, …)` — unscoped, so it also
rewrote any `| N. …` cell in an unrelated/decoy table (same class as the batch-2
row-delete scoping bug). Migrated onto updateTableCell, scoped to the ## Progress
section, decrementing each affected row's leading phase ordinal by column name.
Byte-identical on canonical Progress tables + multi-row + decimal-sibling cases;
a decoy `| 3. … |` row before ## Progress is now correctly left untouched. The
sibling heading / checkbox-bullet / PLAN.md-filename / Depends-on-prose renumbers
are not GFM-table mutations (outside ADR-2143's table/section mandate) — left as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2143): review fixes — scope traceability write, restore Current Position H3-stop
Adversarial review of the remediation (BLOCK verdict) — all 9 findings fixed:
- F1 (BLOCKER): requirements mark-complete / phase complete flipped the checkbox
but NOT the traceability row on the shipped template, because updateTableCell
bound to the FIRST table (## Out of Scope, no Status column) instead of the
## Traceability table — the #2140 silent-divergence class, re-introduced by the
seam migration and missed by tests (fixtures had Traceability first). Scoped
the write + hasRow probe to the ## Traceability section slice (updateTraceability
Cell helper) in milestone.cts + phase.cts. Failing-first tests on the
Out-of-Scope-before-Traceability layout; the #2769 first-cell match preserved.
- F2 (MAJOR): mutateCurrentPositionFirstTime restored to locateCurrentPosition
(STOP_H2_PLUS) — collectSection's default H2-stop swallowed a level-3 subsection
and the field regexes clobbered it (#2130 class).
- F3/F8: Progress-ordinal renumber re-escapes via escapeCell + keys padding
recovery by row index (was de-escaping `\|` and losing padding on dup values).
- F4: insertTableRow escapes cell values internally.
- F5: updateBullet accepts a tab after the marker (`[ \t]{1,4}`).
- F7: resetSectionVerbatim consumes CRLF blank lines (byte-parity on CRLF).
- F6/F9: corrected two misleading comments.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(changeset): data-loss + CRLF-session user-facing fixes (#2253)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#2143): de-flake the G10 windsurf ReDoS-guard wall-clock assertion
The G10 test asserted `elapsedMs < 1000` for a 200k-char payload — a wall-clock
assertion (CLAUDE.md: never assert on wall-clock time) that flaked on a loaded
node24 bench at ~1.1s. It was redundant: runHook's spawnSync `timeout: 10000`
already SIGKILLs a catastrophic-backtracking hook, so the exit-0 assertion is the
real ReDoS guard. Removed the timing assertion; kept exit-0 + documented the
subprocess-timeout mechanism. Surfaced (not caused) by this branch's gsd-test
runs loading the bench; unrelated to the markdown-parsing changes but fixed in
place per the no-flaky-tests rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#2143): fail-loud Result + per-surface write-set contract — Phase 3
Phase 3 of epic #2143 (ADR-2143 §5/§6). The three target bugs (#2140, #2112,
#2118) were already fixed tactically on next; this introduces the reusable
structural contracts and rewires the primary #2140 site onto them.
- src/write-set.cts (new): the parse `Result<T> = {ok,value|reason}` (§5) and the
per-surface write-set (`WriteOutcome {surface, applied, requirement?}`,
`WriteSet`, `writeSetComplete`) (§6). markdown-table.cts now imports + re-exports
`Result` from here (single source; distinct from command-routing-hub's Result).
- requirements mark-complete (src/milestone.cts): returns a PER-REQUIREMENT,
per-surface write-set; `write_set_complete` is true only if every surface of
every requirement applied — structurally forbidding the #2140 OR-into-one-flag
masking, including across a multi-ID batch (adversarial-review regression).
Pre-existing output fields unchanged (behaviour-preserving; #2140 already fixed).
- deriveProgressFromRoadmap (src/phase-lifecycle.cts): removed the vestigial
null-swallowing try/catch (findTableWithColumns never throws) — ADR §5 no-swallow;
RoadmapProgress return contract unchanged.
- commit --files (#2112) and milestone complete --dry-run (#2118) left as-is
(single-surface commit / pre-mutation preview — not genuine multi-surface writes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#2244): backfill changeset PR number (#2251)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#2143): bounded-mutation seam (withSection/withPhaseSection) + phase.cts migration — Phase 2
Phase 2 of epic #2143 (ADR-2143 §4): add a bounded-mutation primitive so a
per-phase ROADMAP edit is structurally confined to that phase's own section,
and migrate the phase-scoped mutation sites in `phase.cts` onto it.
- `src/markdown-sectionizer.cts`: `withSection(content, target, edit, opts?)` —
resolves a section via `collectSection` and applies `edit` to ONLY that
section's body, re-serialising via `replaceSection`. The edit callback sees
only the section body, so any regex it runs is physically confined.
- `src/roadmap-parser.cts`: `withPhaseSection(content, phaseId, edit)` —
resolves a phase's `### Phase N` detail-section heading via the #2121
phase-id source and delegates to `withSection`. Heading match is anchored to
the heading start (a sibling phase whose title mentions the number is not
hijacked) and bounds at the next ATX heading of any level (`levelBounded:false`).
- `src/phase.cts`: `mutateMilestonePhase`'s plan-count and per-plan-checkbox
writes now route through `withPhaseSection` — structurally retiring the
#2130 / #2067 / #2080 boundary-crossing class for these sites. The phase-LIST
checkbox is intentionally left milestone-slice-scoped (it lives outside any
`### Phase N` detail section). Cross-phase renumbering is untouched.
- Property test (fast-check): editing phase k leaves every sibling section
byte-identical; regression tests for title-collision + mixed heading depth.
Behaviour-preserving (verified by old-vs-new differential runs on real
fixtures). Extend-never-mutate (ADR-2143 §2). Registration: CONTEXT.md +
docs/INVENTORY.md export lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#2243): backfill changeset PR number (#2250)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#2143): markdown table model + schema registry + fail-loud pilot — Phase 1
Phase 1 of epic #2143 (ADR-2143): consolidate markdown table parsing onto a
canonical seam and migrate the pilot reader.
- Add src/markdown-table.cts: parseMarkdownTable (GFM tables -> typed
{columns, rows} addressed by column NAME; ragged rows are typed parse
errors, not silent), a single-source TABLE_SCHEMAS registry
(RoadmapProgress / RequirementsTraceability / QuickTasks / Security, with
variants under one id), matchTableSchema, and findTableBySchema. Result<T>
is scoped to this seam (distinct from the dispatch Result).
- Migrate deriveProgressFromRoadmap (src/phase-lifecycle.cts) off the
position-anchored regex to name-based resolution via the seam — fixes#2137
(the 5-column milestone-grouped Progress table previously returned all-null).
- Add a schema-backed `gsd-tools quick-tasks-append` subcommand and route
fast.md's log_to_state through it, retiring the inline `awk NF-2` column
arithmetic — fixes#2133 (addresses #2012, #2119). Cell values are escaped
(| and newlines) and the STATE.md read-modify-write is atomic under
readModifyWriteStateMd (lost-update race, cf. #500/#905/#1230).
- Writer/reader/template parity test guards TABLE_SCHEMAS against drift
(ADR-2143 §3 Generative-Fix-Divergence).
Registration: .gitignore, eslint.config.mjs, docs/INVENTORY.md +
INVENTORY-MANIFEST.json, CONTEXT.md glossary, docs/CLI-TOOLS.md.
Behaviour-preserving for the canonical 4-column Progress table; the named
bugs are driven fail-first. Extend-never-mutate (ADR-2143 §2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(#2242): backfill changeset PR number (#2248)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2242): escape backslash before pipe in markdown-table cell escaping
CodeQL js/incomplete-sanitization (high): escapeCell escaped | -> \| but not
the backslash itself. Now escapes \ -> \\ before | -> \|, and splitTableRow
unescapes both \\ -> \ and \| -> | symmetrically so cell values (incl.
literal backslashes) round-trip exactly. Added backslash round-trip tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2242): read ROADMAP Progress table by column name — supersede #2168 ad-hoc scan
Rebase reconciliation with #2168 (the tactical #2137 fix that marked itself
"pending #2143"). deriveProgressFromRoadmap now resolves the Progress table via
a new seam helper findTableWithColumns (first table whose header is a superset of
Phase/Plans Complete/Status/Completed, any order, extra columns ignored) and reads
cells by NAME — order/injection-invariant per ADR-2143 §3 — instead of the exact
TABLE_SCHEMAS match. This satisfies #2168's column-invariance property test while
staying seam-based and preserving its `## Progress` scoping (#2012/#1445).
Ragged Progress tables now resolve to null (ADR-2143 fail-loud); updated the stale
state.test.cjs assertion that predated the Phase-1 migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Replace every open-coded separator translation across the installer/hooks
source with named, tested seams in shell-command-projection.cts (the platform
seam), removing all hardcoded `/`+`\` from path handling:
- toPosixPath(p) — this machine's native path → POSIX (running-OS relative;
for local filesystem paths).
- toNativePath(p) — POSIX → native (collapses the win32 `/\//g,'\\'` ternary).
- posixNormalize(p)— unconditional `\`→`/`, OS-independent; for emitting paths
to a POSIX/bash TARGET (which may differ from the running
OS) and for parsing mixed-separator input.
core-utils.toPosixPath now delegates to the seam, so its 20+ existing consumers
resolve to one implementation; no duplicate helper.
- ~47 sites across runtime-hooks-surface, runtime-artifact-conversion,
runtime-artifact-install-plan, drift, init, worktree-safety,
installer-migrations, installer-migration-authoring, install-engine, surface,
verify, runtime-artifact-layout, schema-detect, check-command-router.
- Closes the latent POSIX-literal-backslash corruption class (the regex form
corrupts a POSIX path containing a literal backslash; split(path.sep) does not).
- New unit + fast-check property tests for all three helpers.
Closes#2246
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New statusline.show_git config (default false). When enabled, a git
segment renders after the directory: current branch plus compact
work-state markers (+staged ~unstaged ?untracked ↑ahead ↓behind, or ✓
when clean and in sync), e.g. " │ main+2~1?3".
One git status --porcelain=v2 --branch spawn per render via execFileSync
with a fixed argument array (no shell), a 1.5s timeout, and the
workspace dir passed with -C. Fails silently — segment absent outside a
repo, without git, or on timeout. Default output is unchanged when the
flag is absent.
Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg
* feat(#2161): opt-in absolute token count on the statusline context meter
New statusline.show_context_tokens config (default false). When enabled,
the context meter shows the absolute token total after the percentage,
e.g. "████░░░░░░ 46% (156k)" — summing input, cache-creation, cache-read,
and output tokens from context_window.current_usage (matching /context).
Default output is byte-for-byte unchanged when the flag is absent or
false. The .planning config is now read once per render and shared with
the last-command/position block instead of being re-read.
Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg
* docs(#2161): changeset fragment for PR #2174
* fix(#2161): review fixes — k-to-M threshold, boundary tests, changeset format
- formatTokens promotes to the M branch when k-rounding reaches 1000
(999,500-999,999 rendered "1000k" instead of "1.0M")
- boundary tests at 999499/999500/999999/1000000/1000001
- Number() guards on the four usage fields (silent string-concat gap)
- changeset body ends with the (#2161) citation per house convention
Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg
* fix(#2161): round-2 review fixes — config-set coverage, precision claim, exports style
- config-set accept/reject tests for statusline.show_context_tokens
(mirrors the post-planning-gaps precedent the issue scope names)
- changeset + docs no longer claim parity with /context: the suffix sums
four fields while the meter %% derives from used_percentage (three), so
the figures can diverge slightly
- module.exports one entry per line
Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg
* test: regenerate golden-install-parity fixtures for the statusline hook change
Claude-Session: https://claude.ai/code/session_01Hme55Pvq6BhpgwBcyC5HAg
---------
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
* fix(#2206): strip trailing slashes in isGitIgnored to avoid CRLF check-ignore quirk
isGitIgnored was called with a trailing slash (`.planning/`) in config-loader.
git check-ignore has a longstanding quirk: a CRLF .gitignore with blank lines
falsely reports any path WITH a trailing slash as ignored. This silently set
commit_docs=false on Windows repos (where CRLF .gitignore is the norm), skipping
all planning-doc commits. Normalize trailing slashes inside isGitIgnored so every
call site is protected.
Closes#2206
* docs(#2206): add changeset fragment
* docs(#2206): backfill PR number
* fix(#2203): traceability parser matches REQ-IDs in any column, not just the first
The traceability table-row parser required the REQ-ID in the first column
(`^| REQ-ID |`). A table that leads with a status column (e.g. `| ☐ | REQ-01 |`) matched zero rows, so phase complete warned every body REQ-ID was missing.
Match REQ-IDs in any pipe-delimited cell (drop the ^ anchor).
Closes#2203
* docs(#2203): add changeset fragment
* docs(#2203): backfill PR number
* fix(#2202): preserve unknown frontmatter keys in syncStateFrontmatter
syncStateFrontmatter rebuilds frontmatter from a fixed schema, dropping any
custom/unknown key on every mutating verb. Before reconstruction, merge any
existing frontmatter key the schema does not own. Schema keys still win.
Closes#2202
* docs(#2202): add changeset fragment
* docs(#2202): backfill PR number
* fix(#2202): add regression test + remove redundant type assertion
- tests/state.test.cjs: behavioral regression test asserting custom/unknown
STATE.md frontmatter keys survive a mutating verb (they were silently dropped
before the syncStateFrontmatter carry-forward).
- src/state.cts: drop the unnecessary `as Record<string, unknown>` assertion
that tripped @typescript-eslint/no-unnecessary-type-assertion (the lint-tests
gate failure).
Refs #2202
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address @trek-e's review (Option 2, interim tactical fix) on PR #2168:
- Add a fast-check property test (tests/derive-progress.property.test.cjs)
asserting deriveProgressFromRoadmap's derived counts are invariant to
header column ORDER and COUNT — shuffle the header cells and inject
unrelated columns, assert counts match the data. This pins the parser's
core new capability (header-name lookup vs. the old position-locked regex).
Named derive-progress (not phase-lifecycle) so it isn't greedily attributed
to the shorter `phase` prod prefix under lint-test-file-count; like the other
*.property.test.cjs files it maps to no module and is cap-exempt.
- Add boundary regression tests in state.test.cjs: header+separator only
(0 rows -> all-null), exactly 1 row, and ragged rows (extra/short cells,
handled without throwing).
- Mark the adhoc Progress-table scan as pending the ADR-2143
parseMarkdownTable/TABLE_SCHEMAS seam (re-point pending #1372 -> #2143).
- Fix the changeset lead-in to the required bold form.
Per the maintainer, reader/writer parity and CONTEXT.md updates are
explicitly out of scope for this stopgap — Phase 1 (ADR-2143) owns them.
Validated: property test 200 runs green; state.test.cjs #2137 block 8/8;
eslint clean; lint-test-file-count green; full `npm test` exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#2200): scope phase-complete roadmap writes to the current milestone
runPhaseCompleteTransaction's roadmap mutators ran unanchored / un-milestone-
scoped / first-match over the whole ROADMAP: the phase-checkbox flip could check
a bullet inside a backticked prose literal or an earlier Backlog entry instead of
the closing phase's bullet (a transposition), and the Plans-count writer could
bind to a same-numbered phase in a shipped milestone.
- Add currentMilestoneRawRanges(content, cwd) to roadmap-parser.cts: raw
[start,end) offsets of the active milestone's region(s) (primary section + the
optional Phase Details section), mirroring extractCurrentMilestone's selection.
- Line-anchor the phase checkbox pattern (^ + m flag) so an inline / backticked
prose literal cannot match.
- Apply the phase-checkbox flip, the Plans-count write, and the per-plan checkbox
flips ONLY within the current milestone window(s) via a mutateMilestonePhase
helper (splice later windows first so offsets stay stable). Fall back to whole-
content mutation when there is no versioned active milestone (prior behaviour).
The Progress-table writer stays as-is (already scoped to ## Progress, #2012).
Closes#2200
* docs(#2200): add changeset fragment
* docs(#2200): backfill PR number in changeset fragment
* fix(2112): scope commit to --files pathspec, not entire index
cmdCommit/cmdCommitToSubrepo/cmdPrSubrepo staged exactly the files
named in --files but then ran a bare 'git commit' with no pathspec,
absorbing anything else in the index into a commit whose message
described only the named files (#2112).
Fix: append '-- ...stagedPaths' to the commit args when the caller
declared a scope. Three guards are load-bearing:
- stagedPaths (not filesToStage) excludes skipped missing files (#2014)
- explicitFiles gate keeps the default .planning/ path byte-identical
- MERGE_HEAD check via 'git rev-parse' falls back to bare commit during merge
- --amend is left without pathspec (different operation)
cmdPrSubrepo pathspec uses changedFiles (old+new for renames) so the
full rename is captured atomically.
Also fixes workflow markdown in spec-phase.md and add-tests.md.
All-files-missing now short-circuits to nothing_to_commit instead of
absorbing the entire index under a message describing files that
were not committed.
* docs(changeset): backfill PR number (#2148)
* test: update golden-install-parity fixtures for workflow markdown changes (#2112)
* test: update golden fixtures + workflow baselines for #2112 changes
- claude-local.json golden fixture (now generated via gen script)
- workflow-size-baseline.json (add-tests.md +16, spec-phase.md +42 bytes)
- Extended gen-golden-install-parity-zcode.cjs to also regenerate the
claude local-layout fixture
* fix(2118): honor --dry-run in milestone complete with zero-mutation preview
milestone complete treated --dry-run as a no-op: the flag was neither
parsed nor rejected, so a caller who expected a preview instead
triggered the full destructive mutation (archive phases → move audit
artifacts → rewrite STATE.md) with no way to back out.
Fix (option 2 from the issue): add dryRun to MilestoneCompleteOptions,
parse --dry-run in the dispatcher, and return a JSON preview plan
(would_archive, would_update) after the read-only stats gathering but
before any mutations. Also gated platformEnsureDir on !dryRun so the
archive directory is not created during preview.
3 regression tests: no-mutation happy path, --no-archive-phases combo,
and --force bypass combo.
* docs(changeset): backfill PR number (#2155)
* chore(#2118): regenerate pi/qwen golden fixtures after next merge
The milestone --dry-run fix changes gsd-core/bin/gsd-tools.cjs; qwen.json
(missed at authoring) and pi.json (added on next, never carried the fix)
were the only two runtime fixtures still holding the pre-fix hash. All
other runtimes already reflect the change. Regenerated via the sanctioned
gen-golden-install-parity script.
* fix(#2118): surface accomplishments in dry-run preview; fix --dry-run --raw
Orthogonal review findings on the --dry-run preview:
- The preview omitted the already-computed accomplishments (the primary
MILESTONES.md content a real run writes); surface it as a top-level field,
mirroring the real-run result.
- `--dry-run --raw` discarded the structured payload and printed the literal
string "dry-run"; drop the raw-value arg so --raw emits the full preview
JSON, matching the real-run output() call.
Adds tests: --dry-run --raw is parseable JSON, preview includes accomplishments,
and --dry-run --force is proven zero-mutation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2201): accept --phase N flag in the phase verb family (complete, list-plans)
The phase family router treated the first positional (args[2]) as the phase
number, so `phase complete --phase 12` passed the literal '--phase' as the phase
→ 'Phase --phase not found'. The state family already accepted --phase N. Now
complete and list-plans accept --phase N (and --phase=N) as well as the bare
positional; unrecognized flags yield a usage error naming the accepted form.
Closes#2201
* docs(#2201): add changeset fragment
* docs(#2201): backfill PR number
* test(#2199): cover bullet/em-dash ROADMAP phase resolution + milestone count
Adds the bullet-only ROADMAP fixture the suite lacked: an all-bullet em-dash
ROADMAP resolves each phase (no Phase null), colon/en-dash/hyphen bullet
separators all resolve, mixed heading + bullet forms coexist, and the milestone
phase-count counts bullet-form phases instead of collapsing to zero.
* fix(#2199): accept bullet/em-dash phase entries in roadmap lookup + milestone filter
Roadmap phase lookup (findRoadmapPhaseInContent) matched only ATX headings
against a colon-required pattern, so a bullet/checkbox entry like
`- [ ] **Phase N — name**` — which the bundled roadmapper emits in bullet-house-
style ROADMAPs — resolved found:false and `Phase null` was written into STATE.md.
The milestone phase-filter built its phase set from headings only, so a bullet-
only ROADMAP collapsed to a zero-count pass-all filter and progress denominators
broke.
- Add a shared bullet-phase-line pattern (separator: em-dash/en-dash/hyphen/colon).
- findRoadmapPhaseInContent: on a heading-match miss, fall back to the bullet line
for the requested phase; return found:true with the captured name.
- getMilestonePhaseFilter: also scan bullet lines into the milestone phase set.
Closes#2199
* docs(#2199): add changeset fragment
* fix(#2199): bullet phase lookup as last resort + consolidate test (review)
Two corrections to the initial fix:
1. Regression — the bullet fallback inside findRoadmapPhaseInContent was too
eager: it returned a bullet match from the scoped (current-milestone) content
before the caller tried the full-content heading path, so a phase whose
Requirements live in a Phase Details heading (after the active-milestone
section) got a bullet-line section with no Requirements → phase_req_ids null
(broke 3 init tests). Restructure: findRoadmapPhaseInContent is heading-only
again; a separate findRoadmapBulletPhaseInContent runs in getRoadmapPhaseInternal
ONLY after scoped + full heading lookup fails, so a heading with a Requirements
section always wins.
2. lint-test-file-count — the standalone fix-2199-roadmap-bullet-phase.test.cjs
collided with the over-cap 'roadmap' module (FAIL_NOVEL_FILES). Consolidate
the regression into the existing tests/roadmap-parser.test.cjs (its natural
home, under the 2-file cap).
* test(#2199): assert heading-in-full beats bullet-in-scoped (review L3)
The exact first-attempt regression: a phase has a bullet in the active-milestone
scope but its heading (carrying Requirements) lives in a Phase Details section
outside that scope. Pin that the heading section wins over the bullet line so
req_ids resolve and the eager-bullet bug cannot return.
* docs(#2199): backfill PR number in changeset fragment
cmdStateUpdateProgress ran its Progress: patterns against the raw STATE.md
content (frontmatter included). With /i+/m the first match was the YAML
frontmatter `progress:` key, not the body line — so the frontmatter block was
mangled (\s* crossed the newline) while the body line stayed stale, and the
next STATE.md write re-derived percent from that stale line, silently reverting
the update. \2: the .* also discarded any agent-authored descriptive suffix.
- Match against the frontmatter-stripped body only (reusing stripFrontmatter);
reconstruct content as fmPrefix + modified body so the frontmatter is untouched.
- Swap only the machine segment ([bar] NN% or bare NN%), preserving any suffix.
- updated stays false when the body has no Progress: line (no false success from
a frontmatter progress: key).
Closes#2177
normalizeNodePath had hardcoded branches for macOS Intel (/usr/local/Cellar) and
Apple Silicon (/opt/homebrew/Cellar) but none for Linuxbrew
(/home/linuxbrew/.linuxbrew/Cellar). After `brew upgrade node` on Linux, the
version-pinned Cellar path baked into managed hook commands 404'd, and re-running
the installer did not repair it — normalizeNodePath returned the pinned path
unchanged, so the 'already normalized' skip-rewrite check no-op'd.
Generalize to one branch: match <prefix>/Cellar/node(<@ver>)?/<ver>/bin/node and
rewrite to <prefix>/bin/node, deriving <prefix> from the path itself. This covers
macOS Intel, Apple Silicon, Linuxbrew, and any custom HOMEBREW_PREFIX — the same
failure class as #977 (fnm) and #1619 (mise), now closed for Homebrew on every
platform.
Closes#2185
Review found the hint value (yes|no) matched prefixes ('nope'/'not' read as
'no') and the hint regex was unanchored, so a mid-line prose mention like 'see
**UI hint**: no above' was treated as the authoritative metadata line. Line-
anchor (^ + m flag) so only a real hint line counts; word-boundary on the value
so nope/not do not mean no. Adds coverage for hint:yes over a pure-backend body
and the nope fall-through.
checkUiPresence ran UI_TOKENS (which includes the bare token 'UI') over the raw
phase-section text, so GSD's own '**UI hint**: no' metadata line matched the 'UI'
token and reported hasUI=true — blocking non-frontend phases that explicitly
declare themselves non-frontend via the documented convention (the plan-phase
UI-SPEC gate fired under the default workflow.ui_safety_gate=true).
- An explicit '**UI hint**: yes|no' line is now authoritative (mirrors how
progress.md / new-project.md already parse it via 'UI hint.*yes'). hint:no ->
hasUI=false; hint:yes -> hasUI=true.
- Any '**UI hint**:' line is stripped before token-sniffing, so a hint without a
recognised yes/no cannot false-positive on the bare 'UI' token.
- With no hint line, behaviour is unchanged (token-sniffing on the rest).
Closes#2150
hasRow keyed on a bare '| ID |' which could match the ID as the first cell of a
non-traceability table elsewhere in REQUIREMENTS.md, suppressing a real
table_unmatched signal. Require a second cell ('| ID | <phase> |') so only a
traceability-row shape counts as a row.
cmdRequirementsMarkComplete OR-ed its two write surfaces (the - [ ] checkbox and
the | ID | ... | Pending | traceability row) into one 'found' flag. When the
checkbox matched and no table row did, it reported the same 'updated: true,
marked_complete: [ID]' payload as a full reconcile — while the traceability row
stayed Pending. The already_complete branch used OR (checkbox done OR row done),
so re-running on the half-written file classified the drift as already_complete
and moved on. audit-milestone (which reads the table) still saw Pending.
- Track the two surfaces separately (checkboxHit / tableHit).
- Add a 'table_unmatched' bucket: an ID whose checkbox reconciled (this run or
before) but whose traceability table has no row for it — only when a table
actually exists (a table-less REQUIREMENTS.md is legitimate).
- Fix the already_complete predicate: fully reconciled requires the row Complete,
OR the checkbox done with NO table — a [x] checkbox with an absent/Pending row
is partial, not done.
- A table-less REQUIREMENTS.md stays a clean success (the OR's legitimate use).
The invariant: a not-fully-applied requirement no longer returns a payload
indistinguishable from a fully-applied one.
Closes#2140
The UTC-slice anti-pattern (deriving a calendar day a human reads by slicing a
UTC instant) remained in several operator-facing sites beyond the original
seamed last_activity set. Eliminate it everywhere a human reads the value as a
calendar day — do not leave known bad code in place:
- commands.cts cmdTodoComplete + cmdScaffold: completion/scaffold dates.
- gsd2-import.cts: migrated STATE.md 'Last activity' / 'Last session'.
- template.cts: plan frontmatter 'completed:' date.
- verify.cts: health --repair session-log date + '(Backfilled: <date>)' header.
- workstream.cts: workstream-create 'Last Activity' / 'created' + archive dirname.
- init.cts: JSON-bundle 'date' (-> localToday) + 'timestamp' (-> nowIso) at all
three sites; drop the now-dead 'const now = new Date()' in cmdInitTodos /
cmdInitMapCodebase (cmdInitQuick keeps it for the local branch-id derivation).
- state.cts: prune-archive '## Pruned <date>' header.
- state-transition.cts: the 7 seamed last_activity writes (prior commit).
No realClock.today() / .clock.today() / raw new Date().toISOString().split('T')
operator-facing sites remain in src/. Rebuilds the tracked
bin/lib/state-transition.cjs artifact to match.
clock.today() derived the calendar day by slicing a UTC ISO instant
(nowIso().split('T')[0]), so in any negative-UTC-offset zone during UTC's early
hours last_activity named a day the operator had not reached yet — ahead of
last_updated's local date, written by the same call.
- Add Clock.localToday(): host-local YYYY-MM-DD via getMonth/getDate/
getFullYear, honoring the same GSD_NOW_MS pin as today()/nowIso().
- Route operator-facing date-only fields through localToday(): last_activity
(state-transition ×7, state.cts), roadmap 'completed <date>' (phase.cts,
roadmap.cts), milestone completion date (milestone.cts), todo/scaffold
completion (commands.cts — now threaded through the realClock seam).
- Leave today() (UTC) as the source for internal/cosmetic stamps.
Closes#2136
getMilestoneInfo's `##` heading regex was unanchored (no `^`/`m`), so it
matched a `##` quoted mid-line inside a Milestones bullet and captured a
delimiter-led fragment into milestone_name — clobbering the curated name on
every phase transition.
roadmap-parser.cts (load-bearing):
- Consult the 🚧 name-bearing marker FIRST (reorder; it already existed but was
shadowed by a spuriously-successful heading match).
- Anchor the `##` regex to line start (`^` + `m` flag) so a heading quoted
in backticks/prose can no longer match.
- stripLeadingDelimiter removes a leading em/en-dash/colon/hyphen run that
.trim() cannot (the `## vX.Y — Name` convention).
state.cts (defense in depth):
- Widen the #948 preserve guard from 'derived equals placeholder' to
'derived does not look like a name' (non-empty, not placeholder, not
punctuation-led), so a future bad derive preserves the curated name instead
of silently overwriting it.
Closes#2135
scanEntropyAnomalies + shannonEntropy were dead exports with zero
production callers — the live hooks (gsd-prompt-guard.js,
gsd-read-injection-scanner.js) inline their own pattern subsets for
hook independence and never called these functions.
Changes:
- Remove scanEntropyAnomalies + shannonEntropy from src/security.cts
- Remove scanEntropyAnomalies test block from tests/security.test.cjs
- Correct REQ-SCAN-INJ-02/-03 in FEATURES.md (EN/zh-CN/ja-JP) to
describe what actually runs live (injection patterns, invisible
Unicode) vs CI-only (base64-decode, codebase scan)
- Correct docs/security/baseline.md §2.4 to clarify live hooks inline
patterns, not import from security.cts
- Add regression test asserting the corrected contract
- scanForInjection retained: it serves as the CI codebase-scanner engine
VS Code is a net-new EoS runtime that — unlike every prior migration — is NOT
CLI-installed (Marketplace/VSIX extension). It has zero runtime==='vscode'
branches in bin/install.js and stays that way (regression-guarded); it is driven
entirely through the negotiated imperative Host-Integration adapter.
Registry + validator (the hard part):
- capabilities/vscode/capability.json (role:runtime): full hostIntegration block
(imperative / palette / active vscode.lm model / engine hook bus /
sandboxed-storage / mcp transport / sandboxed-web runtime; dispatch nested,
maxDepth 5 per VS Code's documented subagent depth).
- capability-validator.cjs extended so a role:runtime capability can legitimately
declare "extension-distributed, no config directory": new configHome.kind:'none'
+ installSurface:'none' (+ GATE-A pairing + the parity maps), with localConfigDir
and configHome.name made conditional on kind!=='none'. All 18 runtimes still
validate; getDirName returns a distinct sentinel (not '.claude') for a no-config
runtime.
- The add-a-registry-runtime tax: NON_INSTALLABLE_RUNTIMES exemption in the
runtime-flags drift guard, vscode added to global-config-home SPECIAL_CASED,
EXPECTED_PROFILES.vscode='ide', and the config-adapter/derivation/pin-count
guards updated. No golden-install fixture, model-catalog, or CONFIGURATION rows
(vscode never enters allRuntimes).
Dispatch + extension surface:
- Fixed vscode/extension.js's createHub()-no-args bug (every dispatch was
UnknownCommand, masked by a vacuous reachability test) — now reuses the shared
dispatchGsdCommand subprocess-shim (Node/desktop); the reachability test is
tightened to assert real dispatch.
- Promoted the #1933 host binding to a shipped vscode/host-binding.js; activate()
now composes the model/hookBus/stateIO seams through it. Corrected the model
seam to VS Code's real API (vscode.lm.selectChatModels() -> model.sendRequest();
vscode.lm.sendRequest does not exist) so the binding actually composes on real
desktop VS Code instead of throwing.
- New vscode/browser.js Web Extension entry with ZERO Node APIs (the engine's
config/capability loading is Node-bound, so the web entry registers the surface
and directs full dispatch to the native MCP server — honestly documented).
- UPGRADE 1: GSD skills as native Language Model Tools (contributes.languageModelTools
+ vscode.lm.registerTool), invoke() dispatching through the hub.
- UPGRADE 2: native subagent dispatch wired onto #runSubagent /
chat.subagents.allowInvocationsFromSubagents (fail-soft on API availability,
maxDepth 5 enforced).
- vscode/package.json: browser entry, engines.vscode ^1.105, chatParticipants +
languageModelTools contributions; fixed a stale activationPoints->activationEvents
manifest key. Added "vscode" to the package files array.
Docs (## vscode matrix section) + changeset (Added).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Net-new EoS/pi installable runtime — purely additive (no prior runtime==='pi'
branches). pi is a bun-runtime programmatic-CLI whose /gsd command is registered
by a native ExtensionAPI extension and dispatches through the embedded engine.
Stage 1 (install plumbing):
- capabilities/pi/capability.json: full hostIntegration descriptor (imperative /
slash-programmatic / active-model / native-extension / bun) + hostBehaviors
{nativePlugin, pluginOnlyInstall}.
- --pi flag + interactive-menu renumber (All 17->18); pi added to RUNTIME_FLAG_IDS,
RUNTIME_LABELS, RUNTIME_META, allRuntimes/runtimeMap, model-catalog defaults.
- Install mirrors OpenCode: pi installs the gsd.cjs extension + the shared engine
payload (gsd-core + scripts + config markers) + the shared hooks bundle (spawned
by the extension at lifecycle events, like OpenCode's plugin). pluginOnlyInstall
EXCLUDES declarative command/agent/skill markdown, which pi has no host-read
surface for (its /gsd is programmatic). _installNativePluginIfDeclared (extracted
from the opencode-family path) copies pi/gsd.cjs -> ~/.pi/agent/extensions/gsd.cjs
(global) / .pi/extensions/ (local). pi added to package.json files.
- Golden: new pi.json (320 files: extension + engine + 27-file hooks bundle, no
markdown); the 16 other fixtures + claude-local change only by the shared
model-catalog hash line.
Stage 2 (real dispatch + upgrades):
- Shared dispatchGsdCommand() (shell-command-projection): bounded, no-throw
subprocess-shim to gsd-tools.cjs (the only full-surface dispatch path; no
in-process full-hub factory exists). Fixes pi/gsd.cjs's createHub()-no-args bug
(every dispatch was UnknownCommand) AND the identical bug in mcp-server.cts's
gsd_invoke_command, which a vacuous unknown-family-only test had masked (now has
a real dispatch regression test).
- pi/gsd.cjs: /gsd handler now (args, ctx) - tokenizes (quote-aware, via the
shipped hooks/lib/git-cmd.js) + dispatches real family/subcommand (not hardcoded
query/help); gsd_invoke gets a TypeBox (JSON-schema-fallback) parameters schema +
consumes params; getArgumentCompletions; before_provider_request active-model
steering (fail-open on null resolution); functional session_start /
before_agent_start / session_before_compact hook bridges (spawn the shipped GSD
hook scripts).
- EXTENSION_EVENT_SURFACES.pi expanded from ['tool_call'] to the full 30-event
vocabulary.
Docs (host-integration matrix + how-to) + changeset (Added).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold all 10 residual isWindsurf branches in bin/install.js onto descriptor-driven
hostBehaviors (byte-parity — no fold changes any install output):
- 2 dead destructures dropped (uninstall, finishInstall); the dead
`else if (isWindsurf)` legacy agent-loop arm removed (windsurf ∈
_DESCRIPTOR_AGENTS_RUNTIMES → unreachable).
- skipSharedHooksInstall:true folds the two `!isWindsurf` shared-hooks exclusions.
- legacyDevinSkillsCleanup:true folds the `.devin`→`.windsurf` one-time cleanup gate.
- installsCommandBodiesForWorkflowDelegation:true folds the #1629 command-body copy
(workflow-delegation target — load-bearing; local-install verified intact).
- verificationStyle:"windsurf-workflows" folds the workflow-count report.
- Corrected stale _LEGACY_SCAN_SUBDIR_NAMES + hooks-json manifest comments (cursor + windsurf).
Zero live runtime==='windsurf'/isWindsurf branches remain across bin/install.js,
install-engine.cts, surface.cts, runtime-artifact-conversion.cts (AC2 guard scans all four).
UPGRADE (Cascade hook bus): wire GSD's write/command safety guards into Windsurf's
native hook bus. New hooksSurface 'windsurf-hooks-json' (VALID_HOOKS_SURFACES 7→8, GATE A
profile-marker-only allowlist, the HooksSurface union) + writeWindsurfHooksJson
(Cursor-templated, Cascade's flat {hooks:{<event>:[{command}]}} shape) writing
.windsurf/hooks.json with two BLOCKING pre-hooks:
- pre_write_code → gsd-windsurf-pre-write.js: blocks writes to a file outside the
active git worktree / into .git internals.
- pre_run_command → gsd-windsurf-pre-command.js: conservative destructive-command
deny-list (rm -rf of root/home incl. sudo/env/path-prefixed forms; fork bombs;
force-push refspec forms — HEAD:main, +main, --force/-f — to main/master/next).
Both use Cascade's protocol (stdin JSON, exit 2 + stderr to block, exit 0 to allow,
fail-open on error/timeout). Tokenize-based classifier (no catastrophic-backtracking regex;
4096-char cap) with the fail-closed false-positives fixed post-review.
The 4 advisory GSD guards + pre_mcp_tool_use + 5 post_* logging events are deliberately
NOT wired: Cascade has no context-injection channel for advisory hooks and GSD has no MCP
guard — porting them would be non-functional padding (documented; codebuddy #2098 / copilot
#2099 faithful-subset precedent). extendedHookEvents stays [].
Golden: the 2 guard scripts ship in the shared hook bundle (HOOKS_TO_COPY + the shared
managed-hooks-registry), exactly like cursor's 6 gsd-cursor-*.js scripts — so the 8
shared-bundle runtimes' fixtures gain the 2 inert windsurf scripts + the registry hash
(functionally inert for non-windsurf; the established cursor pattern). No install-output
change beyond that (the folds are byte-parity; skip-bundle runtimes untouched). New scripts
registered in managed-hooks-registry + build-hooks + INVENTORY. Tests: declarative-reference-
windsurf (adapter/axes/fail-closed + AC2 guard) + windsurf-hooks-bridge (live exit-2 blocking
+ allow/fail-open + ReDoS-bound + writer/reconcile/remove idempotency); VALID_HOOKS_SURFACES
pin updated to 8. Matrix hookBus delta + changeset (Changed). capability-registry regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold Copilot's residual runtime-literal branches onto descriptor-driven
hostBehaviors. Several issue premises were inaccurate (verified via research)
and deliberately NOT followed: writesSharedSettings/"legacy exclusion list"
(per-runtime descriptor data, copilot's false is correct); RUNTIME_CONTENT_DISPATCH.copilot
+ installSurface==='copilot-instructions' (already descriptor-driven);
extendedHookEvents (closed Claude/Gemini enum — hooks extended in code instead);
reapply ternary (already folded, kimi #2095).
Real folds (all byte-parity — golden byte-identical for every runtime):
- src/install-engine.cts + src/surface.cts: the two `.agent.md` filename cutovers
(_copyStaged + _syncGsdDir) unified onto hostBehaviors.agentFileExtension via a
new exported agentFileExtensionFor() accessor (kills the two-mechanism divergence).
- src/runtime-artifact-conversion.cts: applyAgentPathRewrites' copilot skip →
hostBehaviors.noPathRewrite:true (antigravity #2096 precedent).
- bin/install.js uninstall: the two isCopilot cleanup branches → installSurface===
'copilot-instructions' gate (symmetric with install-time).
- bin/install.js: `!isCopilot` in the two skipSharedHooksInstall checks →
hostBehaviors.skipSharedHooksInstall:true (copilot has no shared gsd-*.js hooks).
- bin/install.js: three dead legacy inline-agent-loop isCopilot refs removed
(copilot ∈ _DESCRIPTOR_AGENTS_RUNTIMES → unreachable; byte-parity proven by clean
golden + real reachable-runtime install diffs). isCopilot dropped from 4 destructures.
Zero live `runtime==='copilot'`/`isCopilot` branches remain in bin/install.js,
install-engine.cts, surface.cts, or runtime-artifact-conversion.cts (AC2 guard scans all four).
UPGRADE 1 (multi-event hook bus): buildCopilotHookConfig() now emits preToolUse/
postToolUse/userPromptSubmitted/sessionEnd advisory handlers alongside sessionStart
(static inline bash/powershell — deterministic, golden-trackable). Only copilot.json's
gsd-session.json hash changes.
UPGRADE 2 (background dispatch): surfaced via the negotiated contract only —
dispatch.background:true exceeds the declarative-cli baseline and survives negotiation
with no downgrade warning. NO .agent.md frontmatter field (copilot has none). MCP
companion out of scope (AC4 names only 2 upgrades).
Tests: declarative-reference-copilot (adapter/axes/fail-closed + AC2 4-file source-grep
guard) + copilot-upgrades (live 5-event hook wiring; dispatch.background negotiation).
Matrix EoS note + how-to; changeset (Changed). capability-registry regenerated.
Incidental flaky-test RE-ARCHITECTURE (no-defer, maintainer-directed):
tests/opencode-review-reconstruction.property.test.cjs spawned ~600 synchronous
execFileSync('jq') subprocesses (numRuns:200 × 3 fast-check properties, one jq per
generated stream); a single jq freezing on a contended macos-22 CI runner hung the whole
unit-test chunk to its 600s kill (this PR's CI). --test-force-exit can't interrupt a
synchronous execFileSync, so the cure is to stop spawning per case, not just time-bound
it. Re-architected to run the SHIPPED jq program over the whole fast-check corpus in ONE
jq process: each generated stream is one compact-JSON array per line in a temp file,
`jq -c <PROGRAM>` (no -s) applies PROGRAM to each array (`.` == the array, exactly what
production's `jq -rs <file>` sees after slurping) and emits one result per line —
empirically byte-identical to the per-stream form across embedded-newline/empty/quote/
unicode/null-drop cases, and file-input (like production) so there's no stdin pipe to
deadlock on large I/O. ~600 spawns → 6; coverage unchanged (200-case corpus per property,
deterministic seeds) plus explicit boundary/diagnostic example batches. Still property-
tests the real shipped jq (no JS reimplementation). Per-call jq timeout retained as a
belt-and-suspenders bound.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold augment's runtime-literal conversion branches onto descriptor-driven
hostBehaviors and delete dead code:
- Site A (_applyRuntimeRewrites case 'augment'): the 4 ~/.augment dot-dir
regexes now derive from getDirName('augment') via escapeRegExp (byte-
identical; getDirName('augment')==='.augment') — no runtime literal.
- Site B (applyRuntimeContentRewritesForCommandsInPlace): the
`if (runtime==='augment')` markdown-converter branch now reads
runtime.hostBehaviors.commandBodyConverter and dispatches through a local
COMMAND_BODY_CONVERTERS map (degrade-closed on unknown/absent name).
- Deleted dead `claudeToAugmentTools` map (zero refs; orphaned by ADR-1508
single-sourcing) and the unreachable `else if (isAugment)` agent-conversion
branch (augment ∈ _DESCRIPTOR_AGENTS_RUNTIMES → gated out upstream).
- Incidental orphan cleanup (no-defer): removed the equally-unreachable
`else if (isTrae)` agent-conversion arm left behind by trae's already-merged
migration #2094 (trae ∈ _DESCRIPTOR_AGENTS_RUNTIMES, same upstream gate).
copilot/windsurf/codebuddy arms are removed by their own pending migrations.
UPGRADE 3 (transport:mcp): register the GSD companion MCP server in Augment's
settings.json under mcpServers.gsd (Augment hosts MCP in settings.json, not a
standalone file). mergeGsdMcpServerIntoSettings mutates the in-memory settings
object finishInstall already writes (gated on hostBehaviors.mcpCompanion===
'settings-json'); non-destructive + idempotent; symmetric uninstall removal.
settings.json is golden-excluded, so no golden change. UPGRADE 1 (named/
background dispatch) + UPGRADE 2 (settings-json hook bus, Claude dialect)
were already live in production — this adds tests exercising both.
Golden: byte-identical for all 16 runtimes (folds preserve regex behavior;
MCP lives in golden-excluded settings.json) — verified by a real double-install
tree diff. Tests: declarative-reference-augment (adapter/axes/fail-closed/
undocumented-sub-axes + source-grep guard scoped to conversion-logic branches)
+ augment-upgrades (dispatch negotiation, hook-bus live install, MCP add/
idempotent/preserve/uninstall). Matrix + connect-gsd-mcp-server + a stale
install-on-your-runtime hook-ownership claim corrected; changeset (Changed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deriveProgressFromRoadmap read the `## Progress` table with two
4-column-only regexes, so every project past its v1.0 milestone — which
uses the 5-column milestone-grouped shape the same template ships
(| Phase | Milestone | Plans Complete | Status | Completed |) — parsed to
{completedPhases: null, totalPhases: null, totalPlans: null}. Because the
body is wrapped in a bare try/catch the failure was silent, and
phase.complete flipped the ROADMAP row to Complete but never advanced
STATE's progress percent / completed-phase count.
Parse the table by header instead: locate the `## Progress` section
(line-anchored h2, mirroring the writer's #2012 scoping so an earlier
Phase/Status/Completed-shaped table can't hijack it), read the header row
to find the Status, Completed, and plans columns by name, then index each
data row positionally. One code path now handles both the 4-column
greenfield and 5-column milestone-grouped shapes — the same understanding
the writer (cmdPhaseComplete, with its `cells.length === 5` branch)
already had. Preserves the 999.x backlog exclusion, M/N plan summing, and
the prior contract that a count of 0 is reported as null.
Regression tests fold into tests/state.test.cjs: the template's own
5-column Progress block parses non-null, both shapes derive identically,
999.x stays excluded in the 5-column shape, and decoy tables (an earlier
## Retrospective table and an ### Progress h3 subheading) do not hijack
the scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold all runtime==='kimi'/isKimi logic branches into descriptor-driven
hostBehaviors (localInstallDeferred, verificationStyle, agentManifestStyle,
reapplyCommand, doneBannerStyle) + add 'kimi' to _DESCRIPTOR_AGENTS_RUNTIMES.
Kimi's skills/kimi-agents dispatch was already descriptor-driven (converter-by-
name + kimi-agents kind). Zero isKimi/runtime==='kimi' branches remain.
UPGRADE 1 (native hook bus): new hooksSurface 'kimi-hooks-toml' + a marker-
delimited config.toml [[hooks]] emitter (buildKimiHooksTomlBlock/writeKimiHooksToml
in runtime-hooks-surface.cts; resolveKimiHooksTomlDir in runtime-homes.cts).
GSD's lifecycle hooks now wire into Kimi's native ~/.kimi/config.toml (Context7-
confirmed path) at SessionStart/PreToolUse/Stop/PreCompact/SubagentStart/
SubagentStop — kimi becomes a hooks/ consumer (the 3 && !isKimi exclusion guards
removed). config.toml holds absolute install paths so it's golden-excluded via
an exact relative-path (.kimi/config.toml), not a basename (which would blind
Codex's config.toml). New hooksSurface value added to the closed enum in
capability-validator + runtime-config-adapter-registry.
UPGRADE 2 (background dispatch): flip dispatch.backgroundDispatch true (Kimi's
Agent tool takes run_in_background; root agent already gets the Agent tool), so
negotiation no longer flattens dispatch. subagentToolkit stays 'undocumented'
per AC (coder/explore/plan have distinct tool policies).
MCP transport explicitly deferred (no installer-driven MCP for any runtime).
Golden: only kimi.json changes (hooks/ scripts now installed); all 15 others +
claude-local byte-identical (kilo/zcode keep their own exclusions). Tests:
kimi-imperative-reference (adapter/axes/fail-closed/hostBehaviors + source-grep
guard) + kimi-upgrades (config.toml [[hooks]] SessionStart + marker idempotency
+ backgroundDispatch negotiation). CONTEXT.md glossary + matrix + how-to updated;
changeset (Added).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold trae logic branches into descriptor-driven reads: skipSharedHooksInstall
gates (dropped && !isTrae), and the case 'trae' path-rewrite arm now computes
the self-alias from the descriptor-driven dirName (.trae). Dead isTrae bindings
removed from uninstall/writeManifest/finishInstall. trae's skills dispatch was
already descriptor-driven (converter-by-name). RUNTIME_CONTENT_DISPATCH.trae is
left as a runtime-keyed table registration (its regex/callback rewrites can't be
a byte-identical descriptor map — matches cursor/windsurf/cline). trae stays in
RUNTIME_FLAG_IDS: isTrae still gates the agents-converter selection (agents out
of scope; removal gated on the cross-runtime agents-dispatch migration).
Byte-identical golden parity for all 16 runtimes.
UPGRADE: SOLO stage/trigger metadata — emitted Trae SKILL.md now carries
stage: workflow (descriptor-gated via hostBehaviors.soloStageMetadata) so
Trae's SOLO Agent can auto-invoke GSD skills at the corresponding stage. Field
shape is best-effort/inferred (Trae publishes no formal schema). trae.json
golden regenerated.
Tests: trae-imperative-reference (adapter/axes/fail-closed shouldFlattenDispatch
+ no runtime==='trae' source-grep, isTrae exempted for agents) + trae-upgrades
(stage: workflow on installed SKILL.md, descriptor-gated). Matrix note +
changeset added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold remaining isKilo logic branches into descriptor-driven reads:
finishPermissionWriter (uninstall cleanup), skipSharedHooksInstall (hooks
copy), and a skills converter-name registry (the artifactLayout.converter
field is now load-bearing, not decorative). frontmatterDialect stays the
documented dispatch key for frontmatter (no descriptor field for it). Dead
isKilo destructure bindings removed. Byte-identical golden parity for all 16
runtimes (opencode, which shares kilo's combined-family path, verified clean).
UPGRADE 1 (hook bus): install .kilo/plugins/gsd-core.js native plugin +
extensionEvents:"kilo" + EXTENSION_EVENT_SURFACES.kilo (OpenCode-fork bus).
UPGRADE 2 (active model): populate runtimeTierDefaults.kilo + thread
modelOverride through convertClaudeToKiloFrontmatter — model no longer stripped
from agents. UPGRADE 3 (MCP): document the gsd-core MCP companion under kilo's
mcp config key. UPGRADE 4 (named dispatch): agents/*.md mode:subagent roster is
the Task-tool dispatch surface (tested); subagentToolkit stays 'undocumented'
per AC so dispatch degrades to 'degraded' by design.
Model-catalog single-source edit ripples the shared model-catalog.json hash
into all 16 golden fixtures (expected). Inline defect fixes (no-defer): stale-
bake-guard resolveAgentDir 'agent'->'agents' (was a silent no-op for opencode/
codex), hardcoded 'Removed OpenCode plugin' uninstall log -> generic, and the
connect-gsd-mcp-server.md OpenCode mcpServers->mcp doc error.
Tests: kilo-imperative-reference (adapter/axes/fail-closed/degradation/
hostBehaviors + widened isKilo source-grep across 4 modules) + kilo-upgrades
(plugin parity+load, model-override converter, agents dispatch surface, MCP
doc). Matrix + how-to + config docs updated; changeset added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the live ui-phase producer path for the UI-consideration probe (Phase 2,
WIRE-01). Two small exports on the Phase-1 adapter — proposeElements (the
propose-then-confirm view of detected kinds + applicable categories) and
autoResolve (the deterministic --auto floor that never dismisses and never
auto-backstops an unclassified item, #1110) — plus a post-verification
'## 9.5 UI-Consideration Probe' step in ui-phase.md mirroring spec-phase 5.5's
RUNTIME_DIR shim + fatal-invoke/malformed-report/zero-applicable fail-closed
guards, propose-then-confirm (the partial-cue recall mitigation), and the
'## UI Considerations' write-back in the shipped plan-phase.md:921 lift format.
autoResolve is the CODE floor; the covered-upgrade stays workflow prose (the
two-layer --auto). Un-upgraded backstops route to insufficient_spec ->
human_needed at verify, never a silent pass (#1154).
Tests: +8 typed (proposeElements shape/determinism, autoResolve never-dismiss,
partial-cue strict-subset) — structured-value only. ui-phase.md size baseline
ratcheted 15477->24447 (under DEFAULT cap). The plan-phase.md PRE_PHASE6 ceiling
stays RED pending #1852 (unchanged from Phase 1).
Claude-Session: https://claude.ai/code/session_01BKt4hgNZwXSeJYJtYAQUSS
Third probe-core adapter on the UI element/state axis, mirroring edge-probe.
Closed 8-id shape-rooted UI_TAXONOMY + element-cue relevance filter
(UI_CUES -> classifyElement -> applicableCategories); unclassified fail-loud
soft-signal (#1110); fail-closed on invalid authored element kinds. All
lifecycle/merge/validation delegated to probe-core verbatim (no fork). Item
question carried in the shared Item.probe field. LIFT-01 proven at the
probe-core primitive level (projectTruths/dispositionForUnverifiableTruth):
backstop considerations route to insufficient_spec, never a silent pass.
Tests assert typed returns off the built .cjs (no source-grep).
#2056 fixed the foreign-prefix collapse for init plan-phase only.
The identical defect remained in three sibling commands that called
findPhaseInternal/getRoadmapPhaseInternal without the guard:
- cmdInitExecutePhase
- cmdInitVerifyWork
- cmdInitPhaseOp
Extracted the #2056 guard into shared helpers (guardedFindPhase /
guardedGetRoadmapPhase) and routed all four init commands through them.
Deleted the local parsePhasePrefix/isForeignPrefixedPhaseQuery copies in
init.cts — the canonical export from phase-id.cts is now used directly,
eliminating the drift risk flagged by both reviewers.
Added 5 regression tests (3 reject + 2 accept-branch) mirroring the
#2056 plan-phase tests.
Final convergence review found the shared <tag> seam introduced 3 behavior
regressions; fixed all + locked with tests:
- #557 REGRESSION: stripTaggedBlocks's attribute-tolerance stripped `<details open>`
(the ACTIVE-milestone marker) that the old `<details>`-only regex preserved. The
seam now takes `allowAttributes` (default false) — details/decisions strip is
attr-INTOLERANT (preserves `<details open>`); only `<task type="…">` opts in.
Regression test added to roadmap-parser + markdown-sectionizer suites.
- verify.cts actionZones (negative-grep-echo security scan): reverted to a bounded
to-first-close scan `<action>([\s\S]{0,20000}?)</action>` so a grep-echo trick
can't hide behind an unterminated inner <action> (the seam's stop-at-next-open
would drop it). ReDoS-safe via the cap.
- check-command-router HTML-comment strip: `(?:-->|$)` fallback wiped to EOF
(fail-closed spurious gate block) — replaced with stop-at-next-open so an
unclosed `<!--` leaves downstream tags intact.
- Updated the extractTaggedBlocks nested-tag tests to the new (stop-at-next-open)
behavior: `<x><x>inner</x></x>` -> ['inner'].
All vectors still linear; every fix verified in-process.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>