Commit Graph

4405 Commits

Author SHA1 Message Date
Tom Boucher
8e4ebb49e4 fix(#2128): address shared-seam review regressions (#557, action-scan, comment-strip)
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>
2026-07-10 11:12:45 -04:00
Tom Boucher
01b691fae8 fix(#2128): route <tag> scans through a hardened shared seam (root-cause ReDoS fix)
A convergence audit showed the `<tag>[\s\S]*?</tag>` lazy-scan ReDoS was pervasive
(a dozen+ bespoke copies across roadmap-parser/check-command-router/verify), each
a distinct quadratic vector on a large document with unclosed tags. Rather than
whack-a-mole, single-source them (maintainer-directed):

- markdown-sectionizer: extractTaggedBlocks now shares one ReDoS-safe
  `taggedBlockPattern` (stop-at-next-open, bounded optional attributes) and gains
  a `stripTaggedBlocks` companion for block removal.
- roadmap-parser: 3 `<details>` strips -> stripTaggedBlocks (behavior-identical —
  no <details> here carries attributes).
- verify: actionZones + both <task> loops + their nested <name>/<files>/gate/req
  extractions -> extractTaggedBlocks (behavior byte-equivalent, verified).
- check-command-router: the objective|tasks?|action alternation hardened in place
  (distinct multi-tag shape); HTML-comment strip gains a `$` fallback.

Every vector now linear (<3ms on 1.5MB adversarial); real content unchanged
(end-to-end verify/roadmap resolution + task extraction confirmed). The only
remaining `<!--…-->` scan (uat.cts:201) is anchored + non-global — one scan, safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:42:18 -04:00
Tom Boucher
2f6662d195 fix(#2128): bound the remaining lazy-scan ReDoS vectors (files_modified, Plans-count, <tag>)
A ReDoS-completeness audit surfaced a distinct class beyond the tag/bracket
clause: unbounded `[\s\S]*?` / `[^\]]*` lazy-scans searching for a literal
terminator that may never appear, driven quadratic by REPEATED structures in a
large PLAN.md/ROADMAP.md. Folded all 7 in at maintainer direction:

- files_modified `[^\]]*` -> `[^\]]{0,8000}` (commands.cts, verify.cts): 39.7s -> 0.9s.
- Plans-count `[\s\S]*?` -> section-local `(?:(?!\n#{1,4}\s)[\s\S])*?` — stops at the
  next heading (semantically correct: Plans: belongs to the phase's own section)
  (roadmap.cts x3, phase.cts): 36s -> 4ms.
- <tag> extraction `([\s\S]*?)` -> stop at the next same-tag opening
  `((?:(?!<tag>)[\s\S])*?)` (verify.cts x3, markdown-sectionizer.cts): ~6s -> 2ms.

Every vector is now linear (comprehensively re-measured); real content matches
(end-to-end `roadmap get-phase` still resolves Plans-counted phases). Pre-existing;
byte-behavior preserved for realistic inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:11:43 -04:00
Tom Boucher
b321bc04f4 fix(#2128): bound the sibling bracket-prefix clause — complete the ReDoS fix
Review caught that the prior commit bounded only the paren tag clause and left
the SIBLING bracket-prefix `(?:\[[^\]]+\]\s*)?` (same host regexes, before Phase)
UNBOUNDED — the identical quadratic reachable via a `[...]` run (measured ~16s at
1.7MB). Bound `[^\]]+`/`[^\]]*` -> {1,200}/{0,200} across all 19 phase/milestone
heading prefixes. Comprehensive re-measurement now shows EVERY vector linear
(bracket/paren/id/name/milestone all ~2-44ms at 2.45MB; bracket scaling
2k->2ms, 4k->5ms, 8k->10ms). Also: update the #1729 literal-mirror parity test
off its stale unbounded constant, and add limit-1 (199) boundary coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:44:49 -04:00
Tom Boucher
c1cd43a39f fix(#2128): bound the phase-tag clause to {0,200} — kill quadratic ReDoS
The canonical OPTIONAL_PHASE_TAG_SOURCE tag clause `(?:\s*\([^)\n]*\))?` (and its
inlined literal mirrors across 11 modules) had an UNBOUNDED body, making the
optional-group + /g header scan quadratic on adversarial ROADMAP.md/STATE.md — a
long run of `(` after a header ran ~18.8s at 1.7MB. Bound the body to {0,200} in
the constant AND every mirror in lockstep (the #1729 "both forms change together"
contract), so the scan is linear: the same 1.7MB input now resolves in ~9ms
(measured), while real tags (a handful of chars) still match and a 201-char tag
is rejected. Added a #2128 boundary regression to the #1729 suite.

Pre-existing (byte-identical before/after the Phase 4 migrations); folded in at
maintainer direction rather than deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:31:17 -04:00
Tom Boucher
a1de52d71b fix(#2128): sanctions must be a dedicated // comment line (decoy-proof)
Re-review found the `// phase-id-owner:` suppression treated a `//` embedded in a
string literal as a comment — help/doc text quoting the sanction syntax (the exact
string the scanner's own main() prints) would silently suppress a real
re-derivation. Require the marker to LEAD its own comment line (`^\s*//…`), so a
`//` inside a string or trailing a code line never counts. All 5 real sanctions
are already dedicated lines (scanRepo stays green); trailing same-line sanctions
are no longer honored — put the comment on the line directly above.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:24:49 -04:00
Tom Boucher
e2eaa5b046 fix(#2128): address review — migrate 9 mis-allowlisted sites, harden scanner + guards
Correctness review of the Phase 4 guard found the allowlist over-broad and the
scanner/guards evadable. Fixed all findings:

- Migrate 9 sites that were wrongly sanctioned: their regex is the PURE canonical
  token (`\d+[A-Z]?(?:\.\d+)*`, no variant), byte-identical to already-migrated
  siblings. The old justification argued against swapping to the extractPhaseToken()
  FUNCTION (behavior-risky) — but the guard only wants the same regex built from
  the SOURCE string (byte-equal, zero risk). Coverage is now 32 migrated / 5
  sanctioned, not the overstated 23 / 14 (audit.cts x3, uat.cts, init.cts x4,
  roadmap-upgrade.cts). Each conversion proven byte-equal (.source + .flags).
- Harden the drift detector: also catch the `[0-9]`-in-place-of-`\d` variant;
  document the accepted limits (cross-line split, semantic restructuring —
  covered by the identity guard + review, not a text scan).
- Sanction robustness: a `phase-id-owner:` marker now counts only inside a `//`
  comment (a bare substring in a string no longer suppresses a real flag), and
  the preceding-line window skips blank lines (an auto-formatter's blank line no
  longer reactivates the flag).
- roadmap-parser.cts:462 comment: corrected — that regex carries no /i flag, so
  its [A-Za-z] class does real case work (matches state.cts:1409's rationale).
- Identity guard: surface require failures instead of silently skipping, and
  floor coverage at >75% of consumer modules (inspects 156/157).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:14:15 -04:00
Tom Boucher
dfad3a7510 refactor(#2128): single-source 23 phase-token re-derivations; sanction 14 context-specific sites
Route 23 literal re-derivations of the canonical phase-number token through
phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (via new RegExp). Each conversion was
proven BYTE-IDENTICAL (old.source === new.source && old.flags === new.flags), so
the runtime regexes are unchanged — zero behavior change by construction.

The remaining 14 phase-token sites are genuine but context-specific and stay
literal with a `// phase-id-owner: <reason>` sanction: dir-name parses whose
dash-continuation semantics differ from extractPhaseToken, and the [A-Za-z]
case-variant / [.-] dot-or-dash separator forms that are not source-byte-equal
to the canonical token.

Scanner (`npm run check:phase-id-drift`) is now green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 08:49:36 -04:00
Tom Boucher
09be501eb7 feat(#2128): phase-id anti-divergence guard — canonical token source + drift scanner + guards
Phase 4 of epic #2121 (ADR-2121 Decision 7), closing the recurrence loop that
produced #2111 / #2114 / #2104: no module outside src/phase-id.cts may
re-implement phase-ID parsing without failing CI.

- phase-id.cts: add PHASE_NUMBER_TOKEN_SOURCE — the canonical phase-number-token
  grammar (\d+[A-Z]?(?:\.\d+)*) for enumeration/scan call sites, the ANY-phase
  counterpart to phaseMarkdownRegexSource(n)'s known-number lookup. Extend-only
  (never touches normalizePhaseName; blast radius 79 fns / CRITICAL).
- scripts/lint-phase-id-drift.cjs: pure findPhaseIdRegexDrift(text) + scanRepo(root),
  wired to `npm run check:phase-id-drift`. Flags a literal re-derivation of the
  canonical token (both /\d/ and new-RegExp `\\d` escaping, plus the [A-Za-z] and
  [.-] near-variants) anywhere in src/** outside phase-id.cts, unless sanctioned
  with `// phase-id-owner: <reason>`. Narrow by design: bare \d+, digits-only
  captures, \w ids, status-message text and pipe-tables are not flagged.
- tests/phase-id-drift-guard.test.cjs: fail-first drift cases (AC1) + live
  scanRepo(ROOT) zero-drift (AC3) + identity guard — phase-id.cjs exports the
  complete locked surface and no consumer re-exports a divergent copy (AC2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 08:49:36 -04:00
Tom Boucher
3283ce5ae7 Merge pull request #2139 from open-gsd/chore/2126-migrate-roadmap-lookup
chore(#2121): route roadmap.cts through shared lookup sources (drives #2114) — Phase 3
2026-07-10 07:55:00 -04:00
Tom Boucher
067bbac8fa docs(changeset): backfill PR number (#2139)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 07:41:36 -04:00
Tom Boucher
a22602e276 docs(#2126): correct malformed_roadmap prior-behavior note (re-review)
Re-review found the test comment + changeset prose inaccurately claimed a bare
query "always" surfaced malformed_roadmap. Empirically, on origin/next a
project-code-prefixed checklist entry was a silent {found:false} for BOTH query
forms — the prefixed pass discarded its malformed candidate and the bare regex
could not match the PROJ- prefix at all. The unified 3-source lookup newly grants
the diagnostic to both forms; correct the prose to say so. No logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:07:00 -04:00
Tom Boucher
b85996e95e docs(changeset): note malformed_roadmap parity for prefixed queries (#2114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:53:01 -04:00
Tom Boucher
16b61d437f test(#2126): resolve review findings — fold dedup, harness fidelity, malformed-parity lock
Adversarial review of the Phase 3 branch surfaced three verified defects; fix
all three in place (no defer):

- install-runtime-artifacts.test.cjs: finish the fold-triplication dedup started
  earlier (only enh-1511 had been collapsed). 11 B1-batch __foldDescribe blocks
  were byte-identical triplicates (~5.9k lines, ~49% of the file), tripling the
  subprocess-spawning installer suites under --test-concurrency — the same
  starvation that produced the temp-dir races this branch fixes. Byte-identity
  verified per block before removal; 230 distinct test/it titles preserved
  (origin/next: 230 -> 230), interleaved B3/B5/B6 singletons untouched.
- config-get-default.test.cjs: make runExpectError faithful to production. The
  throwing process.exit seam was caught by cmdConfigGet's "No config.json"
  guard and reclassified into a spurious 2nd error() with the wrong reason
  (CONFIG_PARSE_FAILED). Drive io.setJsonErrorMode + carry the original message
  on the sentinel so the guard re-throws (single fire), assert exitCount===1,
  and strengthen both probes to assert the typed reason (CONFIG_NO_FILE /
  CONFIG_KEY_NOT_FOUND).
- roadmap.test.cjs: lock the #2121/#2114 malformed_roadmap parity — a
  project-code-prefixed query against a checklist-only roadmap now surfaces the
  same diagnostic a bare query always did (fails on prior silent-empty behavior).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:53:00 -04:00
Tom Boucher
6e7e3111fb test(#2126): fix #1259 real-eslint CPU starvation — lint a non-type-aware .cjs clean target
The prohibition-enforcement real-runner tests linted src/clock.cts (a .cts) as
their clean target. Under eslint.config.mjs's type-aware block for src/**/*.cts
(recommendedTypeChecked + parserOptions.project: tsconfig.build.json), each eslint
spawn loaded the WHOLE tsconfig.build.json program (~2s, CPU-heavy). The
real-runner tests spawn eslint repeatedly; under --test-concurrency those
full-program type-checks oversubscribed the bench CPU and blew the 60s subprocess
bound -> fail-closed (intermittent, load-dependent — passed 24241/24241 in an
earlier run, failed here).

Root fix (not a retry/timeout bandaid; measured projectService = no faster since
a single-file .cts lint still loads type info): add tests/_ff_lint_clean.cjs, a
KNOWN-CLEAN lint-scoped .cjs companion to _ff_lint_violation.cjs, with a
flat-config block enabling local/no-source-grep so the clean pass stays
non-vacuous. Repoint the 6 src/clock.cts real-runner usages (5 targets + the FF-02
toothless violationFixture) at it. Each spawn is now ~0.8s non-type-aware (no
whole-program load) — starvation removed. All 6 tests' semantics verified
in-process (SF-01 greens; toothless/fail-closed stay unverified); full-repo
`eslint .` green.

Refs #2126, #1259

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:43:18 -04:00
Tom Boucher
119702ff29 test(#2126): fix os.tmpdir() cross-file race + dedup folds surfaced by gsd-test (no-defer)
Phase 3's gsd-test surfaced 8 pre-existing test-isolation races (in #2090's test
files now on next). Per CLAUDE.md's no-defer rule these are fixed inline in the
current change. Root-caused via /qa-test-architect — all bad-test (the
rewrite-engine production code is race-free):

- install-runtime-artifacts.test.cjs: the "rmSync when readFileSync throws" test
  diffed the SHARED os.tmpdir() for gsd-cmd-rewrites-* dirs and force-deleted any
  new one with no ownership check. Under --test-concurrency it deleted a sibling
  test file's LIVE tempDir mid-copy (the #1575 "ENOENT .../graphify.md") and
  misattributed it as its own leak. Fixed: capture the exact tempDir THIS call
  creates (fs.mkdtempSync monkeypatch, restored in finally) and assert only on
  that — never sweep/delete the shared os.tmpdir(). Also deduped the enh-1511
  block the #1969 consolidation folded in 3x byte-identically (#1970/#1974/#1975)
  down to 1 copy; 308 unique test titles unchanged (verified).
- issue-1575-agent-descriptor-parity.test.cjs: a missing }); nested the M2
  'cursor attribution' test inside the per-runtime loop so it ran 7x (widening
  the tempDir window). Fixed the brace -> runs once as a describe sibling.
- config-get-default.test.cjs: local run()/runRaw() spawned node via
  execFileSync with a fixed 5s timeout and no retry -> ETIMEDOUT under Docker
  load. Redesigned to call cmdConfigGet in-process (fs.writeSync fd-capture +
  process.exit sentinel, both restored in finally) — no subprocess, no wall clock.
- runtime-artifact-conversion.cts: fixed the stale "No production caller today"
  JSDoc on rewriteStagedCommandBodies (real callers: applySurface,
  createRuntimeArtifactInstallPlan) — the false doc invited the bad test.

Refs #2126, #2090

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:43:17 -04:00
Tom Boucher
74e77974af docs(changeset): Fixed fragment for #2114 (pr:0 to backfill)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:43:17 -04:00
Tom Boucher
61f3cafc70 fix(#2126): route roadmap.cts CLI resolvers through shared lookup sources (drives #2114)
Phase 3 of epic #2121. cmdRoadmapGetPhase and getRoadmapPhaseWithFallback now
iterate the shared roadmapPhaseLookupSources (exact -> numeric -> prefix-tolerant,
owned by phase-id.cts since Phase 1) instead of a hand-rolled 2-source lookup, so
all three roadmap resolvers share one resolution contract.

Drives #2114: `roadmap get-phase <bare-N>` now resolves a drifted
`### Phase AB-29:` heading (matching getRoadmapPhaseInternal / init.phase-op),
previously EMPTY from the CLI. The malformed_roadmap checklist-fallback and the
milestone-then-full precedence are preserved (a milestone checklist never blocks
a full-roadmap header match).

Behavior reversal (approved in-session): a bare query now also resolves a
*drifted-only* prefixed heading when no bare sibling exists, reversing the #3599
counter-test's expectation. #3599's real anti-steal intent (a bare sibling wins
over a distinct prefixed one) is preserved by the exact->numeric->prefix-tolerant
ordering and re-asserted in the updated test; a new #2114 block covers the
drifted-only case. Fail-first demonstrated.

Closes #2126
Refs #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:43:17 -04:00
Tom Boucher
b9c8ea143b Merge pull request #2134 from open-gsd/feat/2091-hermes-eos-migration
feat(#2091): migrate Hermes onto EoS imperative adapter + extensionEvents dialect (ADR-1239)
2026-07-09 23:37:27 -04:00
Tom Boucher
07dcd85c98 fix(#2091): regenerate capability-registry from clean origin/next (includes cline hostBehaviors from #2090)
The previous registry was generated from a stale main-repo state that was
missing cline's hostBehaviors (merged in #2090). CI's lint:generated-sync
detected the staleness. Regenerated from clean origin/next + hermes changes.
2026-07-09 23:19:44 -04:00
Tom Boucher
4048d90860 fix(#2091): remove unused import + sync capability-registry for CI lint 2026-07-09 22:26:13 -04:00
Tom Boucher
ca652ac30b docs(changeset): backfill pr 2134 for #2091 2026-07-09 21:57:21 -04:00
Tom Boucher
d10f9c675e test(#2091): update closed-vocab assertions for hermes extensionEvents dialect 2026-07-09 21:21:49 -04:00
Tom Boucher
f152e9a069 feat(#2091): migrate hermes onto EoS imperative adapter + extensionEvents dialect (ADR-1239)
- Fold 7 hardcoded isHermes/runtime === 'hermes' branches in bin/install.js into
  descriptor-driven _hostBehaviors lookups (skillFrontmatterVersion,
  skillsManifestPrefix, trackCategoryDescription, writeCategoryDescription,
  reportSkillsCount, legacyCommandsGsdCleanup, brandingRewrites)
- Add runtime.hostBehaviors block to capabilities/hermes/capability.json
- Register EXTENSION_EVENT_SURFACES.hermes (13 real plugin hook events) in
  src/host-integration.cts — replaces the borrowed hookEvents:'claude' 6-event
  surface that silently never fired on Hermes
- Add extensionEvents:'hermes' to the descriptor
- Add 'hermes' to VALID_EXTENSION_EVENTS in capability-validator.cjs
- Regenerate capability-registry.cjs
- Tests: hermes-imperative-reference (negotiation, axes, fail-closed, source-guard),
  hermes-dispatch-upgrade (dispatch posture, degradation, fail-closed)
- Changeset + docs update
2026-07-09 20:47:35 -04:00
Tom Boucher
2abc8ec9e4 Merge pull request #2132 from open-gsd/feat/2090-cline-eos-migration
feat(#2090): migrate Cline onto EoS imperative adapter + beforeTool/createAgentModel upgrades (ADR-1239)
2026-07-09 20:34:18 -04:00
Tom Boucher
63cf332169 docs(changeset): backfill pr 2132 for #2090 2026-07-09 20:13:46 -04:00
Tom Boucher
f80505aca3 fix(#2090): restore src/state.cts to origin/next (unrelated contamination) 2026-07-09 19:41:31 -04:00
Tom Boucher
fdfff96d52 fix(#2090): restore unrelated files accidentally deleted/modified by subagent
The implementation subagent cross-contaminated the branch with changes from
PR #2121 (phase-identifier parsing consolidation):
- Deleted docs/adr/2121-phase-identifier-parsing-consolidation.md (restored)
- Deleted src/phase-id.cts (restored)
- Deleted tests/phase-id.test.cjs (restored)
- Modified src/roadmap-parser.cts (restored to origin/next)
- Modified src/state.cts (restored to origin/next)

None of these are related to the Cline EoS migration.
2026-07-09 19:40:58 -04:00
Tom Boucher
68535d3011 fix(#2090): correct beforeTool test to match parity-faithful path-key contract 2026-07-09 19:38:27 -04:00
Tom Boucher
9e7f80ea69 fix(#2090): resolve lint — drop unnecessary type assertion + unused vars 2026-07-09 19:38:27 -04:00
Tom Boucher
f76f117ff9 docs(#2090): cline host-integration migration status + changeset 2026-07-09 19:38:27 -04:00
Tom Boucher
760eb71b6b feat(#2090): migrate cline onto imperative adapter + beforeTool/createAgentModel upgrades
Fold all hardcoded runtime === 'cline' / isCline branches in bin/install.js
into descriptor-driven runtime.hostBehaviors lookups (reapplyCommand,
frontmatterDialect, skipSharedHooksInstall, localTargetIsProjectRoot,
clineRulesSurface, localCommandsViaRules). Add cline-sdk-binding adapter
(ADR-1239 Phase D): UPGRADE 1 re-implements the .clinerules/hooks/PreToolUse
guard as a real AgentPlugin.hooks.beforeTool handler (fail-open, same
semantics); UPGRADE 2 wires DefaultGateway.createAgentModel params from
model_overrides/model_profile_overrides resolution (modelMode: active).
Install output is byte-identical (golden parity asserted for cline +
claude/cursor/codex/opencode).
2026-07-09 19:38:27 -04:00
Tom Boucher
a9f9c130ef test(#2090): add cline EoS migration test scaffolding (red) 2026-07-09 19:38:27 -04:00
Tom Boucher
941d3a323b Merge pull request #2131 from open-gsd/chore/2125-migrate-state-prose
fix(#2125): migrate state.cts prose parsing to canonical parser (drives #2111)
2026-07-09 19:36:55 -04:00
Tom Boucher
f50ad1d7a6 Merge remote-tracking branch 'origin/next' into chore/2125-migrate-state-prose 2026-07-09 19:10:31 -04:00
Tom Boucher
0757cdaa9c Merge pull request #2109 from open-gsd/fix/2073-antigravity-reviewer-block
fix(#2073): harden agy reviewer block (arg overflow, 404, pre-session stall)
2026-07-09 19:07:46 -04:00
Tom Boucher
fea63c65e5 docs(changeset): backfill pr 2131 for #2111
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:55:58 -04:00
Tom Boucher
ae1bd14691 Merge branch 'next' into fix/2073-antigravity-reviewer-block 2026-07-09 18:55:02 -04:00
Tom Boucher
b32ecd1b87 docs(changeset): Fixed fragment for #2111 (pr:0 to backfill)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:13:03 -04:00
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
960c3eaa15 Merge pull request #2129 from open-gsd/chore/2124-phase-id-canonical-surface
chore(#2124): build canonical phase-id.cts surface (Phase 1 of #2121)
2026-07-09 17:42:00 -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
Tom Boucher
bf25e0b435 chore(#2124): build canonical phase-id.cts surface (Phase 1 of #2121)
Add the ADR-2121-locked canonical functions to src/phase-id.cts. No consumer
behavior changes — Phases 2-4 migrate the divergent call sites against them.

- parsePhaseFromProse: anchored prose parser. A phase is returned only when the
  STATE.md "Phase:" field VALUE begins with a phase token, so
  "Milestone v0.5 complete" yields { phase: null } instead of "5" (the #2111
  root cause: the old unanchored \b(\d+..)\b mined the minor-version digit).
  Name extraction (parenthetical / em-dash tail, minus status words) unchanged.
- stripConfiguredProjectCodePrefix / isForeignPrefixedPhaseQuery: config-aware
  prefix policy. A foreign prefix (MEM-01 when the configured code is LKML) is
  preserved rather than collapsed to a bare numeric phase — the #2104 fix's
  canonical home (consumed later, outside this epic's critical path).
- roadmapPhaseLookupSources: moved from roadmap-parser.cts so phase-id.cts is
  the single owner of the exact -> numeric -> prefix-tolerant ordering.
  roadmap-parser.cts now imports it (behavior-identical); its two now-unused
  imports (phaseMarkdownRegexSourceExact, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE)
  are dropped.

Tests: subject-named suites in tests/phase-id.test.cjs covering the ADR
boundary set (v0.5, v1.0, MEM-01, AB-29, bare 29, zero-padded 029) plus two
fast-check properties: the #2111 "Milestone vX.Y complete never yields a phase"
invariant and a parse/normalize property.

Extend-never-mutate: the 12 pre-existing phase-id.cts exports are unchanged.

Closes #2124
Refs #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:13:06 -04:00
Tom Boucher
d8782f772a Merge pull request #2123 from open-gsd/docs/2121-phase-id-parsing-adr
docs(#2121): add ADR-2121 phase-identifier parsing consolidation (Phase 0)
2026-07-09 15:50:01 -04:00
Tom Boucher
12d50093e1 docs(#2121): add ADR-2121 phase-identifier parsing consolidation (Phase 0)
Phase 0 of the #2121 epic — an ADR-only PR that LOCKS the contract Phases
1-4 execute against. No production code lands here.

Locks:
- phase-id.cts as the single canonical owner of phase-identifier parsing.
- New pure exports Phase 1 adds: parsePhaseFromProse (anchored; fixes the
  #2111 "Milestone v0.5 complete -> 5" class), stripConfiguredProjectCodePrefix
  / isForeignPrefixedPhaseQuery (config-aware; the #2104 fix's home),
  and roadmapPhaseLookupSources moved in as sole owner of the 3-source
  ordering (fixes the #2114 2-vs-3-source divergence).
- Extend-never-mutate on the 12 existing exports (normalizePhaseName has a
  CRITICAL 84-symbol / 20-caller blast radius) — Hyrum's Law.
- The exact exact->numeric->prefix-tolerant lookup ordering.
- A behavioral anti-divergence contract: reference-identity guard +
  scripts/lint-phase-id-drift.cjs scanner, modeled on the repo's proven
  capability-precedence-parity / package-identity-drift patterns.

#2104 remains blocked on PR #2105 and off this epic's critical path.

Adds the docs/adr/README.md index row. Docs-only; no changeset required
(no-changelog).

Closes #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 15:09:53 -04:00
Tom Boucher
d0745a0e91 Merge pull request #2120 from open-gsd/feat/2089-cursor-eos-migration
feat(#2089): migrate Cursor onto EoS imperative adapter + hook-bus/dispatch upgrades (ADR-1239)
2026-07-09 14:17:23 -04:00
Tom Boucher
39116ba001 docs(changeset): backfill pr 2120 for #2089 2026-07-09 13:59:48 -04:00
Tom Boucher
c25b212c62 revert: restore gsd-cursor-pre-tool.js dead imports (golden parity)
Reverts the LOW-severity dead-import removal (require('fs')/require('path'))
that changed the file hash and broke 9 golden-install-parity fixtures. The
golden test computes per-runtime hashes of installed hook files; regenerating
all 9 fixtures for a cosmetic cleanup is disproportionate. Dead imports are
harmless (Node caches built-in requires) — noted as a follow-up nit.
2026-07-09 13:39:24 -04:00
Tom Boucher
45f3a2a5f9 fix(#2089): wire adapter into install path + address all review findings
MEDIUM fixes (code review):
- Wire resolveManagedHookEvents + resolveHookScripts + buildHookBusEntries
  from imperative-hook-bus.cts into writeCursorHooksJson — the install path
  is now truly descriptor-driven (reads hostBehaviors.managedHookEvents),
  not a hardcoded constant that happens to match the descriptor. bin/install.js
  passes the descriptor list via opts.managedHookEvents.
- buildHookBusEntries is now consumed (was dead code); entry-building is no
  longer duplicated inline.
- Remove try/finally from cursor-hook-bus-upgrade.test.cjs test bodies
  (violated CONTRIBUTING.md L342; redundant with t.after cleanup).

LOW fixes:
- Remove dead require('fs')/require('path') from gsd-cursor-pre-tool.js
- Fix resolveManagedHookEvents docstring (all-invalid fallback behavior)
- Add src/runtime-hooks-surface.cts to the AC2 source-guard file list

Security review: no CRITICAL/HIGH/MEDIUM findings (3 LOW are pre-existing
#777 baseline patterns, not regressions).
2026-07-09 13:17:04 -04:00