cfb37ccdfc64092fbb4069faaf95b1f4d9fe5966
66 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
654b2cc10c |
refactor(#3149): bind planningPaths once in cmdStateLoad
The debug_dir change introduced a second planningPaths(cwd) call in the same function. Bind the struct once and read both .planning and .debug from it; emitted output is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2bead6ca1d |
feat(#3149): add dedicated init.debug entry point for /gsd:debug
/gsd:debug was one of the last workflows with no cmdInit* of its own: its Step 0 made three separate round-trips (state.load, resolve-model gsd-debugger, config-get workflow.tdd_mode) to assemble one context. Because no debug-scoped fact was computed at any entry point, ADR-1671 admission gate (2) could never be satisfied for debug — an applicability atom naming such a fact would evaluate FALSE forever and silently exclude its section. Adds cmdInitDebug (init.debug), registers it in the init router and the command-alias table, and collapses debug.md Step 0 to one call. Every field resolves through the same primitive the call it replaces used: loadConfig for commit_docs, withProjectRoot for response_language (#2402), planningPaths for debug_dir, resolveModelInternal for debugger_model, and the existing Boolean(workflow.tdd_mode) idiom for tdd_mode. PlanningPaths gains a debug field so state.load and init.debug share ONE debug-directory expression rather than two kept in sync by hand. state.load keeps emitting debug_dir: it is a shipped query surface with its own test anchor, so narrowing it would break unseen consumers for no gain. No WHEN_VOCABULARY atom and no gsd:section marker: gate (1), a consuming section of at least 400 bytes, belongs to the change that adds the section. Closes #3149 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
80ec0791eb |
fix(#3052): preserve frontmatter last_activity_desc on same-date body prose conflict (#3140)
* fix(#3052): preserve frontmatter last_activity_desc on same-date body prose conflict preferNewerLastActivity only preserved last_activity_desc when the derived date was OLDER than the existing frontmatter date. When the dates matched (same-date), the derived body prose (potentially stale) overwrote the authoritative frontmatter desc. Fix: when derDate === exDate, also preserve the frontmatter desc. * chore(#3052): backfill changeset PR number 3140 --------- Co-authored-by: sim <sim@local> |
||
|
|
0e6fa2e2cf |
enhance(#3118): close the dead injectables and the shell projection follow-on — Wave 4 (#3124)
* test(#3118): failing-first coverage for the dead injectables and the shell projection Adds the counter-tests Wave 4 closes against, before any fix: - antigravityWatermark had zero test references. The four existing tests that look like watermark coverage hand the fallback a literal mark and never call the producer, so nothing pinned whether a real run's mark is correct. Covers all six branches plus the non-object cache classes. - Pins the fail-open: a transcript read that throws reports lines:0, indistinguishable from a genuinely empty transcript, and the consumer then replays a previous run's review as this run's. - Pins the export-line escaping across the repair, persist and win32 bash lanes, including the parity assertion that they must not diverge. - sliceCurrentPositionSection: empty-vs-absent, fenced heading, second occurrence, H3, CRLF. - Proves deps.progressProvider is inert by supplying a throwing stub to all ten transition intents. Verification through the remote runner only. Refs #3118 * fix(#3118): distinguish an unreadable transcript from an empty one antigravityWatermark's final read can throw on a transcript that indisputably exists. It returned lines:0, which is the same value a genuinely empty transcript produces, so the caller could not tell the two apart. antigravityTranscriptFallback derives its skip from that count. A mark of {convId:'c1', lines:0} for a conversation that pre-dates the run makes it skip nothing and return the last PLANNER_RESPONSE in a transcript written before this run started — a previous review presented as this one's, which is exactly what the function's own 'never stale' docstring promises cannot happen. The unreadable case now sets unreadable:true and the fallback declines for a same-conv-id unreadable mark. An absent or empty transcript is untouched: those genuinely have zero prior lines. * fix(#3118): escape the export line for the file it lands in, not the echo Three lanes emit export PATH="<dir>:$PATH". repair escaped it with escapePosixDoubleQuoted; persist and the win32 Git Bash lane escaped it with escapeSingleQuotedShellLiteral instead. The single-quoting is correct for the echo, so nothing runs when the user pastes the command. But the bytes appended to ~/.bashrc are the export line itself, and inside double quotes in an rc file a $(...) or a backtick in the directory name is command substitution that runs on every new shell. Those characters are legal in a path on both POSIX and Windows, so the path was reachable. projectPathExportLine is now the single source of that line and escapes for its final rc-file context; each lane still applies its own transport escaping on top. fish keeps the single-quote escaper — its value really does stay single-quoted. The cmd.exe lane interpolated into a cmd double-quoted string with no cmd-level escaping, so a quote closed the region and &cmd& ran. A quote is reserved on Windows and cannot appear in a real path, so there is no correct command to suggest: the win32 lanes now fail closed for one. Metacharacter-free paths render byte-identically on every lane. * fix(#3118): drop a stray carriage return and a deps field nobody reads locateCurrentPosition subtracted a fixed one byte to exclude the newline before the next heading, which assumes LF. On a CRLF document the slice kept an unpaired trailing carriage return. It now walks back over the newline and over a preceding carriage return if there is one. StateTransitionDeps also required a progressProvider that 33 sites supplied and no site ever called. A required field nothing reads widens the module's interface without changing its implementation, which is the shape epic #3051 cites as its reason for refusing blanket injection. Removed along with the ProgressRecord alias that existed only as its return type; state-document.cts's unrelated interface of the same name is untouched. * fix(#3118): stop an empty span duplicating bytes, and name the empty results Three findings from the isolated review pass. locateCurrentPosition could return end < start when the section was empty and the next heading followed with no blank line between. Every mutator splices with slice(0,start) + body + slice(end), so an inverted span duplicated the region between them — a blank line silently inserted into STATE.md on every transition, two bytes on CRLF. The span is now clamped, and an empty section is a zero-length span, which is what it always meant. The win32 fail-closed path left the installer printing 'Add it with one of:' with nothing under it. An empty shellActions folded two different facts together, so projectPathActionProjection now carries a frozen PATH_ACTION_REASON and the installer branches on it. Two empty results with different causes staying distinguishable is the subject of the epic this belongs to. fish_add_path parses a leading dash as an option, so a directory named -v printed 'No paths to add' instead of being added. Verified against fish 4.8.1: the end-of-options separator fixes it. Replaces the console-prose test the second fix first arrived with — a regex over captured stdout is what CONTRIBUTING prohibits, and the typed reason is the surface it asks for instead. * fix(#3118): escape TOML control characters, and stop a test name overstating Five findings from the two review axes. escapeTomlDoubleQuotedString escaped only backslash and quote. TOML basic strings also require U+0000-U+0008, U+000A-U+001F and U+007F to be escaped, so a value carrying a raw newline or NUL wrote a config.toml no parser accepts — rejecting the whole file, not just that value. Four of its call sites write real config. Tab stays raw; the grammar exempts it. The byte-identity test claimed every lane was unchanged for an ordinary path, which is false: fish now takes the end-of-options separator on every path, not only hostile ones. Renamed, and the one intended delta now has its own named test instead of hiding inside a claim that read as broader than it was. Also: exact-equality assertions in place of substring checks that could pass on a subtly wrong escape, newline and null-byte cases for all five quoting primitives, and a temp dir registered with t.after so it is removed when an assertion fails. * docs(#3118): add the changeset fragments * fix(#3118): degrade instead of throwing on a null conversation cache A cache file whose whole content is the literal null — what a truncated or zeroed write leaves behind — made both antigravityWatermark and antigravityTranscriptFallback throw. JSON.parse('null') succeeds, so the try/catch wrapping the parse never fired, and resolveConvId then called hasOwnProperty on null. Both functions advertise the opposite; the existing test next to them is named 'a missing cache or transcript degrades to empty, never throws'. Parsing successfully is not the same fact as the payload being usable, and a guard that only wraps the parse cannot tell them apart. resolveConvId is now total for any non-object input, so one guard covers both callers. Caught by the null case in this wave's own cache matrix. * test(#3118): correct a stale fish expectation and a parity comparison The pre-existing 'POSIX persist mode escapes single quotes' test pinned fish_add_path without the end-of-options separator this wave adds, so it asserted behavior that is no longer correct. A repo-wide scan found one such hardcoded expectation; every other site derives its expectation from the projection. The new parity test compared the token from a POSIX path against the win32 lane, which posix-normalizes its input first — two different inputs, so the tokens differed for a reason that had nothing to do with the parity it claims to check. It now derives the win32 expectation from the same input the lane receives. * docs(#3118): reword a comment the injection scanner reads as an instruction The scanner pattern act\s+as\s+(?:a|an|the)\s+ carries no word boundary, so 'the same fact as the payload' matched on the tail of 'fact'. Reworded per the documented remedy for this collision. The missing boundary is a scanner defect rather than a prose problem — any contributor writing 'fact as the' trips it — but the pattern is gate plumbing, which the sibling epic owns, so it is surfaced rather than changed here. * chore(#3118): backfill changeset pr number to 3124 * chore(#3118): backfill changeset pr number to 3124 * fix(#2784): make the negation scan single-pass and index it correctly Three defects in the negation suppression added by #3127, all in one block, none of which had a test. The pair scan was verbs.some(nouns.some(...)) with a slice and a split per pair, so it grew cubically with clause length: 1.1ms before that PR and 8462ms after, on 800 verb+noun pairs in one clause. api-coverage's property test generates documents large enough to reach the runner's 600s file cap, which is why it hangs as 'fail 0, cancelled 1' rather than failing an assertion. Every (verb, noun) window is a subset of the single widest one, so one scan of that window answers the same question in a linear pass. Verified equivalent against the old predicate over 20,000 generated clauses. Both checks also subtracted clause.start from offsets that collectTerm- Matches already returns clause-local. The first clause on a line has start 0 so it worked there and nowhere else: later clauses went negative, and slice reads a negative index from the end, so suppression silently examined unrelated text. The comment claimed 'without any API integration' was suppressed. It is not — the qualifier sits outside the two-word lookback and the noun precedes the verb. Widening the window would trade a false positive that costs one declaration line for a false negative that slips a real integration past a blocking gate, so the behavior stands and the comment now says so. Pinned by a test. The qualifier sets were also rebuilt for every line of every document. |
||
|
|
8fb6681a1e |
fix(#3017): scope state write disk scan to stored milestone (#3105)
* fix(#3017): scope state write's disk scan to stored milestone buildStateFrontmatter called getMilestonePhaseFilter(cwd) WITHOUT the stored milestone version, so it auto-derived from ROADMAP.md — and when getMilestoneInfo mis-bound (the stored milestone had no matching non-✅ heading), it picked a confidently-wrong milestone and clobbered the stored value + rewrote progress with whole-project counts on every state.* write. Pass the stored milestone from STATE.md frontmatter through to buildStateFrontmatter and use it as the explicit versionOverride for getMilestonePhaseFilter. When the stored milestone is available, the filter scopes to it instead of auto-deriving. * chore(#3017): backfill changeset PR number 3105 --------- Co-authored-by: sim <sim@local> |
||
|
|
53ea8e0664 |
fix(#3057): make a guard's failure distinguishable from its benign result — Wave 1 (#3088)
* fix(#3057): refuse the write when the duplicate scan cannot complete writeManifest documents itself as a fail-closed duplicate guard: if any existing manifest shares plan_id with a different, non-terminal job_id it must refuse, because dispatching again would duplicate the external job. It could not honour that. The scan reads every sibling manifest looking for the duplicate, and an unreadable or unparseable sibling was `continue`d past. If the corrupt file was the one holding the live duplicate, the scan found nothing and a duplicate external job dispatched. The asymmetry is what gives it away: a malformed TARGET refused with malformed_existing because clobbering is unacceptable, while a malformed SIBLING was skipped — yet siblings are the only thing the duplicate check reads. Adds a scan_incomplete verdict that refuses and names the offending file, so an operator can quarantine or repair it. Fail-closed alone would let one stale corrupt manifest wedge every dispatch for that planning dir permanently; naming the file is what makes refusing survivable. malformed_existing is untouched, so the target/sibling distinction stays visible. The docstring is updated — it previously stated a rule the function did not keep. memFs() gains an optional failReads map so these branches are reachable at all; they had zero coverage because the fake could not express a per-file read fault. The signature is additive and every existing caller is unchanged. The regression is proved by a pair, not a single test. A control writes a readable sibling holding a genuine non-terminal duplicate and asserts duplicate_plan_id, establishing the scenario is real; the regression then makes that same path unreadable and asserts scan_incomplete. A first draft of this test used a corrupt-JSON fixture containing no plan_id at all while its comment claimed otherwise — it duplicated the unparseable-sibling case and proved nothing, which is the defect class this phase exists to remove. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3057): make a guard's failure distinguishable from its benign result Wave 1 of the negative-space backfill: the branches where a guard that could not verify something reported the same value it reports when everything is fine. That indistinguishability is the defect; every fix here makes the two states tellable apart, and every test proves it with a pair — one for the failure, one for the benign case. A single test cannot establish that two states are distinguishable, which is the whole property being fixed. state.cts phaseInventoryProvider returned null for both a real disk-scan failure and a genuinely empty phases dir, so `state rebuild` could report success while phase-table reconciliation never ran. It now returns a discriminated result and the CLI surfaces phase_inventory_scan_failed plus a reason. The reason field turned out never to have been wired into the emitted JSON at all — it existed only as an internal variable — so a test could only assert on the operator-facing note. It is a real field now. state.cts treated an unreadable lock body the same as an empty one, applying the 1-second stealable floor. A lock we cannot read is not a lock we know is stale; an unreadable body is now held to the deadman ceiling like a live holder. verification.cts findStaleVerificationSummary returned null on any fs, scan or clock failure — meaning "not stale". It now returns a discriminated StaleCheckResult and the caller records that the check was indeterminate. git-base-branch resolveBaseBranch returned 'main' both when no candidate branch existed and when every git tier timed out. A diagnostics variant now reports whether the answer was verified, and the CLI writes an unverified-fallback note to stderr. The stdout contract five workflows parse is untouched. worktree-safety snapshotWorktreeInventory left exists:true when statSync threw, so a guard that could not check reported the worktree present; exists is now tri-state and a stat failure surfaces as an 'unverified' finding. planWorktreePrune reported 'no_worktrees' for a parse failure, which is not the same as an empty list — and it drives a prune. It now reports 'parse_failed'. Fixing the inventory change exposed a second fail-open in verify.cts: the validate-health consumer silently dropped findings whose kind it did not recognise, so the new kind would have vanished. That is closed too — worth noting that the survey enumerated producers of degraded verdicts, not consumers that discard them. worktree-base-ref and state-transition gain the distinguishing signal without changing what they do: headAbsenceVerified, and a phase-inventory scan meta. Whether those guards should ACT differently is a product question this change does not answer, and both are flagged rather than quietly settled. rescueSummaryArtifacts is left alone: rescuing on an uncertain cat-file is deliberate per #2556. It now has tests proving it, and a recorded negative finding — git cat-file -e returns 128 for both "absent from HEAD" and a fatal error, so "uncertain" and "certain-and-fine" are not separable at the git level. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): assert typed values, not rendered text Ten assertions in the rebuild CLI suite matched substrings of produced output — STATE.md body fields, a markdown table row, an audit-log heading, and JSON keys read as text. CONTRIBUTING prohibits that: if the code under test produces text, the test asserts on its structured surface instead. No production surface had to be built. Every one already existed and was already compiled into bin/lib: stateExtractField for body fields, parseMarkdownTable for the phase table, collectSection for the audit-log section, and result.data.log — already a typed RebuildLogEntry[]. The tests were matching rendered text sitting next to the structured data. One of those assertions was passing for the wrong reason. `stdout.includes ('rebuilt')` matched the JSON KEY name, not a value: the dry-run path emits `mutated` and the real path emits `rebuilt`, so it would have passed whether the value was true or false. It now asserts the value. external-job's refusal already had to name the offending file — that naming is why the fail-closed variant is survivable rather than a permanent wedge — but the tests proved it by substring of a prose message. The failure result now carries offendingPath as its own field and the tests assert it by value. The human message is unchanged; operators read it. Array membership is left alone. `phaseIds.includes('99')` and `result.updated.includes('Completed Phases')` are membership checks on real arrays, not text matching, and converting them would weaken nothing and clarify nothing. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): execute acquireStateLock instead of grepping its source The non-EEXIST lock test asserted on the TEXT of the built .cjs and never called acquireStateLock. It carried an allow-test-rule: architectural-invariant exemption to permit that. A source grep proves a literal is present in a file, not that the behaviour works — it is weaker than a liveness test, which at least runs the code, and it was the only coverage the fatal-errno path had. Replaced with tests that inject the errno through fs and assert what actually happens: a fatal EACCES propagates out of acquireStateLock with zero backoff sleeps, while EAGAIN/EINTR/EINVAL/EIO/ENOENT/ESTALE/EPERM/EBUSY retry once and succeed. The exemption is removed and its allowlist entry with it. One old assertion is deliberately not carried over: it checked the retryable errnos were expressed as a Set rather than an inline literal. That is a shape check with no runtime signature; the behavioural tests fail if the code reverts to the old inline check, which is the regression it was really guarding. The #3057 lock-body tests move into that same file rather than a new one, which is what lint-test-file-count asks for and puts every acquireStateLock test in one place. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3057): surface an indeterminate staleness check to its callers An isolated review caught an inconsistency inside this wave. Two of the three "add the distinguishing signal" fixes wire through to something a user sees: git base-branch writes an unverified-fallback diagnostic to stderr, and an unverifiable worktree surfaces as a W020 finding. The third set staleCheckIndeterminate on readVerificationStatus's result and nothing read it. A signal nobody consumes leaves the fail-open exactly as silent as before: the staleness check could fail and the operator saw precisely what they would see if the answer were genuinely "not stale". That is the defect this issue exists to remove, so it is not defensible as scaffolding when its two siblings in the same change already wire through. All five callers now surface it, each through the channel it already had rather than a mechanism imposed uniformly: phase complete adds it to its existing warnings array and, on the blocked path, as an additive note on the error text; init and roadmap carry it as a field on output they already emit; the UAT report carries it without ever gating passed/blockers; workstream inventory takes an injectable writeDiagnostic mirroring the git base-branch idiom, because its return shape had nowhere to hang a per-phase field without rippling the builder's types. The routing decision is unchanged everywhere. What changes is only that a caller and an operator can now tell a failed check from a completed one. That diagnostic carries structured meta rather than being asserted by regex — the default still writes only the human message to stderr, but tests assert phaseDir and reason by value. Two earlier assertions in this branch were converted the same way; this was the last raw-text assertion left. Also records a scope correction: the completePhaseCore guards now compare stateReplaceField's result to the body instead of testing truthiness, so a field whose substitution produced identical text no longer reports as updated. That is a real behaviour fix, not the signal-only change this file was described as carrying, and its tests cover both the changed and unchanged cases. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): bound two heavy subprocesses for a loaded bench, not an idle one The remote matrix surfaced three failures unrelated to this branch's changes. All were bad tests, and a re-run would have hidden every one of them. The reviewer-flags parse block bounded bash -> node -> a full gsd-tools cold start at 5 seconds. On a bench running thirty thousand tests in parallel that is not a hang, it is a busy machine. Raised to 30s, matching the convention sibling suites already use for script invocations, with a comment saying what the budget covers so nobody tightens it back. Two further copies of the same 5-second spawn in the same file had the identical defect and are raised too — they were not in the failure report, but they will be next time. The fragment-propagation test bounded npm run regen:derived — a full build plus eight generators, the heaviest subprocess in the suite — at five minutes, and node22 was killed near the end. The captured output proves it: every generator had written its files and gen:install-tree had emitted all fifteen runtimes before the kill. Raised to fifteen minutes. That failure read as `null !== 0`, which says nothing. status null means killed, not a non-zero exit, and the two want different responses: one is a timeout to size correctly, the other is a real build break. The assertion now distinguishes them and names the signal. Neither test's assertions were weakened and no retry was added. A retry here would suppress exactly the signal the timeout exists to produce. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): capture fd 1 through the mock tracker, not a raw reassignment The phase suite reported zero test results on both lanes while running for five and a half minutes and exiting 1. No assertion text, no stderr, four events for the whole file: enqueue, start, dequeue, complete. That shape is not a failing assertion — it is the runner being unable to read the child at all, because it parses its event stream from the child's stdout. The cause was the capture helper reassigning fs.writeSync directly. Proven rather than assumed: a standalone probe patched fs.writeSync and called process.stdout.write, and the interception fired only when fd 1 resolved to a FILE, not when it was a pipe. The remote runner captures the event stream to a file, so a helper that was invisible against a pipe swallowed the reporter's own output on the bench. That is also why the two sibling suites wired the same way in this change pass cleanly — they use the mock tracker, the seam io.test.cjs established for this exact function. The helper now uses t.mock.method with an explicit restore after each call, so teardown belongs to node:test rather than a second hand-rolled implementation, and the interception cannot outlive the one synchronous call it wraps even if that call throws. Ten call sites thread the test context through; three test callbacks gained the parameter they lacked. The three B3 tests are untouched — same assertions, same fault injection. Only how the context reaches the helper changed. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3057): capture phase-complete output from a subprocess, not fd 1 Two attempts to make in-process fd-1 interception safe both failed on the bench. The suite reported zero test results on either lane while exiting 1 — four events for the whole file — because the runner parses its event stream from the child's stdout, and process.stdout.write routes through fs.writeSync whenever fd 1 resolves to a file, which is how the runner captures. Patching that seam anywhere in a file can therefore destroy the file's own reporting, and tightening the window only moved the runtime from 326s to 125s without recovering a single event. So the interception is gone rather than tuned. The helper now spawns gsd-tools as a real subprocess and reads stdout the way the OS already gives it to us, which is what the rest of the suite does. It asserts the command succeeded before parsing, so a genuine failure can no longer present as a JSON parse error. The two fault-injecting tests could not survive that move as written: a subprocess cannot see a mock installed in the parent. Instead of reinstating the interception they now produce the fault on disk — the summary artifact is created as a dangling symlink, so the staleness check's real statSync throws inside the child. That is a more honest fixture than a mock in any case, since it is a condition a user's tree can actually be in. Skipped on Windows, matching the existing symlink precedent in the write-guard suite. Three further call sites turned out to depend on parent-process writeFileSync mocks the subprocess could not see. Those call the CJS function directly, which is what they always wanted — they never needed stdout at all. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3057): one name for one signal, one encoding for one distinction Standards review found four things this branch introduced, all of them inconsistencies with itself rather than with the repo. One upstream bit reached its consumers under three names — verification_stale_check_indeterminate in two modules, the same value with "stale" dropped in a third, and stderr only in the fourth. Standardised on the long name wherever it is a field. The workstream inventory keeps its stderr channel, since its return shape has nowhere to hang a per-phase field without rippling the builder's types, but it now says the same word for the same thing. worktree-safety encoded one three-way distinction two ways in a single file: a named union for a finding's kind, and boolean|null for an inventory entry's existence. The second is now a named union too. Two assertions matched human prose because the blocked and non-blocked completion paths carried no typed field for the signal. Both now assert typed values. The first round of this fix added the field but left the regex beside it, which is the banned pattern sitting next to its own replacement; the second removed it and added an assertion on the reason enum so nothing was lost. The remaining two were reasoned away before being fixed, and both reasons were bad. "No typed surface exists" is the condition CONTRIBUTING says to fix by adding one — it took three lines. "The file already does this dozens of times" is not licence to add instance number thirty-one; a convention that violates a documented rule is debt, not precedent. Vocabulary differing across DIFFERENT modules is left alone: CONTEXT.md rejects a single shared result envelope, so per-module shapes are precedented, and a baseline smell does not outrank a documented standard. A census of every line this branch adds to a test file now finds no regex or substring assertion on produced prose: 87 strictEqual, 25 ok (all non-empty or shape guards), 12 equal, 3 throws (all typed err.code predicates), 3 deepStrictEqual, 2 notStrictEqual. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3057): backfill changeset pr number to 3088 --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
137f3fbb9c |
fix(#2562): scope workstream progress/status to the current milestone (#2588)
* fix(#2562): scope workstream progress/status to the current milestone `workstream progress` / `workstream status` / `workstream list` share one derivation that could report a workstream's CURRENT milestone as "milestone complete" / 100% while phases in that milestone were unstarted, in progress, or failing verification. Three coupled defects: 1. The shipped signal was project-lifetime, not milestone-scoped: workstreamMilestoneShipped() returned true if ANY *-ROADMAP.md snapshot existed or "SHIPPED" appeared anywhere in ROADMAP.md. Every prior shipped milestone leaves a permanent collapsed <summary>✅ … SHIPPED</summary> block, so any post-v1.0 workstream was pinned to "milestone complete" forever (over-correction from #1913). 2. The denominator dropped declared-but-unscaffolded phases, and completed PRIOR-milestone phase directories inflated the numerator, letting progress_percent round to 100 while real work remained. 3. Phase completeness ignored the VERIFICATION verdict — SUMMARY >= PLAN count alone marked a phase complete even with a human_needed verdict. Fix: derive both numerator and denominator from artifacts scoped to the current milestone. The current version comes from the workstream STATE.md `milestone:` field (ROADMAP in-progress markers can be stale); the ROADMAP `## Progress` table maps every phase — including dirless ones — to its milestone, and the matching set is both the denominator and the directory membership filter. The shipped signal now requires the CURRENT version's archived ROADMAP snapshot (REQUIREMENTS snapshots are not accepted; they can be written at milestone start) or the current milestone's own line marked shipped. Phases with an explicit failing verdict (gaps_found/human_needed) count as in_progress; missing/unknown/stale are left untouched so verifier-disabled projects do not regress to never-complete. Greenfield roadmaps with no versioned Progress table, and projects whose current version cannot be determined, keep the prior behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#2562): add changeset for workstream milestone-scoping fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#2562): parse the Progress table via findTableWithColumns The ad-hoc pipe-table regex tripped the local/no-adhoc-markdown-parsing ESLint rule. Use the canonical markdown-table helper instead: the milestone-grouped RoadmapProgress variant is located by its required `Phase` + `Milestone` columns and cells are addressed by column NAME, so the parser tolerates column reordering and injected columns. The `flat` variant (no Milestone column) yields no attribution, which is the intended fallback to legacy counting. Behaviour is unchanged: verified against a real multi-workstream project (same status/percent/phase and plan counts before and after). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2562): count table-only phases in the unscoped denominator Addresses the reporter's repro detail: a phase declared as a `## Progress` table row with no `### Phase N` heading is missed by countRoadmapPhases EVEN WHEN other headings exist — the heading regex counts 1 for a "1 heading + 1 table-only" roadmap — not just in the zero-heading fallback path. Milestone scoping did not cover this, because a flat Progress table (no Milestone column) carries no per-phase attribution, so greenfield and single-milestone projects kept the old heading-only denominator and the declared phase silently vanished from it. When milestone scoping cannot engage, the denominator is now the union of the Progress table's declared phase numbers and the phase directories, so neither source can shrink it. Verified against the reporter's minimal fixture (phase 1: 1 PLAN + 1 SUMMARY + gaps_found; phase 2: table row only, no heading, no dir), which now reports 0/2 at 0% across all four table/STATE permutations instead of 1/1 at 100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2562): attribute dir-only sub-phases to their parent's milestone A sub-phase directory inserted mid-milestone (e.g. `30.1-…` under a table-declared phase 30) usually has no ROADMAP Progress-table row, so it had no milestone attribution and was scoped out of the rollup entirely — its completed work was invisible and it could never hold the percentage below 100. It now inherits its parent phase's milestone and joins BOTH sides of the calculation. Both sides is the load-bearing part: adding it to the numerator alone would let completed_phases exceed a denominator that never counted it, cap back to 100% via Math.min, and reintroduce exactly the defect this issue reports. A regression test pins that failure mode (all declared phases complete + an in-progress dir-only sub-phase → 75%, not 100%). Attribution is deliberately one-directional: a sub-phase counts only when its PARENT is in the current milestone, so a follow-up created in a later milestone under an older parent is excluded rather than misattributed — conservative (under-count) rather than falsely inflating. Verified on a real project: the reported workstream moves from 2/6 (33%) to 3/7 (43%), the 3/7 being the honest figure — a completed sub-phase that was previously invisible now counts, and so does its plan total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#2562): describe the denominator + sub-phase fixes in the changeset The fragment was written at the first commit and only covered the three original defects. Bring it up to date with what actually ships: the table-only-phase denominator union (heading-only counting dropped a declared phase even when other headings existed) and sub-phase milestone inheritance across both sides of the calculation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#2562): promote the canonical phase-key surface to phase-id state.cts kept `phaseKeyFromToken`/`phaseKeyFromDir` private, so every other module that had to compare two independently-derived phase references — a ROADMAP table cell against a phase directory, say — wrote its own regex. That is the defect class #2562 reports: a padded `01` and an unpadded `1-slug` land in different key spaces and the comparison silently yields nothing. Move the pair to the phase-id owner module and add `phaseKeyFromProse` (for ROADMAP/STATE prose, markdown emphasis stripped) and `parentPhaseKey` (a sub-phase's parent). state.cts imports them; its call sites are unchanged. * fix(#2562): own milestone-shipped detection and accept a workstream scope Three changes to the module that owns milestone parsing, so its consumers stop reimplementing it: - `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark THIS milestone shipped" from heading and `<summary>` lines only. A bullet that merely names the version (`- [x] 03-01: ship the v2.0 login endpoint`) is prose about a phase, not a milestone verdict. The version token is boundary-matched with `(?![\w.-])` — `\b` does not bound it, since `.` is a non-word character, so a shipped `v2.0.1` heading would otherwise close `v2.0`. - `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional trailing workstream name and thread it to `planningDir(cwd, ws)`. A caller iterating workstreams cannot set `GSD_WORKSTREAM` per iteration, which is what the existing resolution falls back to. Omitted, resolution is unchanged. - `getMilestonePhaseFilter` exposes `versionScoped`, true only when the phase set really is one milestone's. On an unversioned roadmap `phaseCount` spans the project's lifetime and must not be read as a current-milestone denominator. The closed/active milestone-marker patterns were kept in three byte-identical copies; they are hoisted to module scope as one `isClosedMilestoneHeading`. * fix(#2562): derive membership and denominator from one phase-key space The milestone scoping added earlier in this PR derived the ROADMAP table key and the phase-directory key with two different regexes, and dropped rows it could not attribute. Each of those was another way to reproduce the symptom this issue reports — a rollup contradicting its own `phases[]` listing: - a padded `| 01. … |` row never matched a `1-slug` directory (and a bespoke `^0*(\d+…)` never matched `PROJ-05-…` at all), so phases fell out of the milestone entirely and the percentage collapsed or pinned; - a blank or malformed Milestone cell deleted the phase from BOTH sides, letting an unstarted phase vanish and the remainder round to 100%; - shipped detection scanned bullets, so any checkmarked line naming the version closed the milestone; - the numerator counted per-directory while the denominator counted distinct phases, so a stale same-numbered directory (Bug #2445's scenario) pushed `completed_phases` past the denominator, where `Math.min` capped it to 100% and hid the unstarted phase. Both sides now key off the phase-id owner module (`phaseKeyFromDir` / `phaseKeyFromProse`), directory membership additionally consults `getMilestonePhaseFilter` when that filter is genuinely version-scoped, and the denominator is the union of the roadmap's declarations with the member directories' keys — so `completed_phases <= denominator` holds by construction. The Builder asserts it and throws; the `Math.min` cap survives only on the legacy unscoped path, where the denominator is a heading count that cannot bound the numerator. An unattributable row degrades over-inclusively (kept, never dropped), matching the degrade direction roadmap-parser already commits to. * test(#2562): boundary coverage for each milestone-scoping reproduction One test per way the scoping could still report "milestone complete"/100% while phases are incomplete: zero-padded rows vs padded dirs (and the mirror), project-code-prefixed dirs, a blank/malformed Milestone cell, a checkmarked bullet naming the version, a shipped `v2.0.1` heading against a current `v2.0`, and a stale same-numbered directory. Plus the current milestone's own shipped heading (the signal must survive the boundary fix), the Builder's numerator-above-denominator throw, a parity check that every non-`passed` verifier status blocks completeness, and a guard that scoping reads the workstream's ROADMAP rather than the project root's. Reverting only `src/` reddens six of them. * docs(#2562): record the milestone-scoped semantics and its consumer impact CONTEXT.md: the Workstream Inventory Module's completion fields now describe the current milestone, not the workstream's lifetime; phase-id owns the canonical phase-key surface; roadmap-parser owns milestone shipped/active classification and takes an optional workstream scope. Changeset: name the behaviour change explicitly — `roadmap_phase_count`, `completed_phases` and `progress_percent` change meaning with no schema signal, and `getOtherActiveWorkstreamInventories` filters on the derived status, so consumers see real movement. * fix(#2562): collapse every zero-padding spelling to one phase key A property test over the key surface — table cell and directory decorated INDEPENDENTLY, which is the point — found a divergence neither review named: `padStart(2, '0')` is a no-op once the input is already ≥2 characters, so `5` normalised to `05` while `005` stayed `005`. A `| 5. … |` row and a `005-slug` directory therefore never compared equal, which is the same failure mode as the padded-vs-unpadded blocker, one level down. The strip belongs in `phaseKeyFromToken`, not in `normalizePhaseName`: applying it to the latter regressed multi-decimal leading-zero plan IDs (`001.10-PLAN.md` capture + wave assignment), which rely on its verbatim rendering. Confining it to the key surface fixes the comparison and leaves rendering untouched. Also tightens `isMilestoneShippedInRoadmap`'s patterns to anchored, complementary character classes so an untrusted ROADMAP cannot drive backtracking, and makes the project-code test discriminating — it previously passed pre-fix, because an unresolvable key collapsed scoping to the whole roadmap and happened to land on the same number. It now carries a prior-milestone directory that a collapse would wrongly admit. * fix(#2562): prefer the milestone-attributing Progress table; pin the seams Three gaps the earlier self-check missed: - Both RoadmapProgress variants carry a `Plans Complete` column, so probing it first picked a FLAT table appearing earlier in the document over the milestone-grouped one that actually carries the attribution. Every row came back unattributed, was treated as current-milestone, and silently re-admitted prior-milestone phases. The attributing shape is probed first; flipping the order reddens the new test. - `lint-phase-id-drift` exempts phase-id.cts by design, so it is silent on `phaseKeyFromToken`'s own segment strip by construction — not evidence. Its interaction with `stripProjectCodePrefix` (which runs AFTER) is pinned across project codes and hyphenated ids, including the pre-existing `M1-46-6` vs `M1-46-6-rs` asymmetry, which is `extractPhaseToken`'s #2043/#2232 slug-word rule and not something to "fix" by accident. - `listWorkstreamInventories` loops every workstream with no try/catch, so a REACHABLE Builder-invariant throw would take down `workstream list`/`status`/ `progress` for all of them. The invariant test only exercised the pure Builder with hand-built inputs. A test now drives `inspectWorkstream` over every adversarial shape at once (prior-milestone dirs, three colliding duplicates, a dirless declaration, an unattributed row, a project-code prefix, a dir-only sub-phase) and asserts it does not throw and the invariant holds — so the throw stays a contract assertion for external callers, not a runtime path. Also covers the active-marker-wins rule (`## v2.0 — 🚧 IN PROGRESS … ✅` must not mark shipped), which nothing exercised. * fix(#2562): scope a declared-but-empty current milestone instead of falling back to history The review's open MAJOR. `STATE.md`'s `milestone:` field updates the moment `/gsd-new-milestone` writes the heading, while the `## Progress` table and phase sections land later. In that window nothing attributes a phase to the current milestone, `scoped` went false, and the fallback counted the project's ENTIRE phase history as both numerator and denominator — a milestone with zero work done reported 100% off its predecessors'. That is #2562's own symptom reached by a different precondition, and none of the 16 tests covered it. Reproduced first, four ROADMAP shapes, at `inspectWorkstream` rather than the Builder — the Builder takes the scoping decision as an input, so a Builder-level test proves it honours a flag, not that the derivation sets it. Three of the four reported 2/2 100% with no phase of the current milestone begun. Which signal witnesses the state depends on the ROADMAP's shape, and no single one covers all three: - `## v3.0` exists but declares no phases. `getMilestonePhaseFilter` DOES locate the section and sets `versionScoped`, then the zero-phase pass-all degrade resets it to false — erasing the only evidence the milestone exists. Neither existing flag survives that path, so this adds `versionSectionFound`, set beside `versionScoped` and deliberately preserved through the degrade. - No section for this version at all, in a roadmap that versions its others — the existing `missingExplicitVersion`, already exposed and tested. - Unversioned headings, but a Progress table attributing every row elsewhere: neither filter flag fires and the table is the only witness. A ROADMAP that attributes NO versions anywhere matches none of them, which is the point. Its rows parse with `version: null`, land in `currentMilestoneKeys`, and never reach the new branch. `readCurrentMilestoneVersion` returns a non-null version for very nearly every project (`getMilestoneInfo` defaults to `v1.0`), so keying off `currentVersion` alone would have zeroed out every free-form legacy project — the condition looks fussy for that reason. A test pins it. Within an empty milestone, membership inverts: a directory belongs unless another milestone's row claims it. Excluding everything would have dropped a phase scaffolded before the roadmap caught up from BOTH sides of the rollup, and hiding real work is the same class of defect as inventing it — this codebase degrades over-inclusive, never under. Scoping is now stated by the caller (`milestoneScoped`) rather than inferred from `currentMilestonePhaseCount > 0`. That inference was the root cause: it cannot represent a milestone that is scoped AND legitimately zero-phase, so the Builder read "no phases yet" as "no scoping" and reopened the whole-history path. The count-derived value stays the default for callers that say nothing. A regression test also pins that a zero denominator does not trip the Builder's `completed_phases <= denominator` throw, since `listWorkstreamInventories` has no try/catch and a crash on every freshly-declared milestone would be worse than a wrong percentage. The changeset and CONTEXT.md no longer claim membership is derived in "ONE" / "a SINGLE" phase-key space. `getMilestonePhaseFilter` still runs its own `normalizePhaseIdSegments`; the signals are OR'd so a divergence can only widen membership, but two normalisers coexist and the docs now say so. * fix(#2562): cross-validate the shipped marker against the milestone's artifacts `status: "milestone complete"` was asserted from the shipped marker alone, so a single payload could report it beside `progress_percent: 67` — this issue's own symptom, reached through `status` rather than the percentage. The marker is now a claim checked against the milestone's own artifacts, and the two signals are checked at DIFFERENT strengths because one check cannot serve both. A `heading` marker (operator-typed, live ROADMAP) is refused on a short completion ratio, which also catches phases declared but never scaffolded. A `snapshot` marker is NOT ratio-gated: `milestone complete` moves the milestone's phase dirs into `milestones/<version>-phases/` (milestone.cts:755-762) while copying — never truncating — the live ROADMAP (:671-674), so a correctly archived milestone reads 0/N by construction and a ratio gate would strip `milestone complete` from every archived milestone. It is refused instead when an in-milestone phase dir is still live and unfinished, reachable because `milestone complete` does not advance STATE's `milestone:` field (state-transition.cts:1335 vs :1224). `legacy` stays ungated — only reachable when scoping is off. A refused marker does not fall through to a STATE field claiming the same thing; against contradicting artifacts neither source may report completion. The refusal surfaces as `milestone_shipped_unverified` rather than staying silent, distinct from `status_conflict` (derived-vs-field only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * test(#2562): pin both marker strengths, the archived guard, and the owner modules workstream-inventory: four tests, all four red against the prior src and green with it. A live-ROADMAP SHIPPED heading over an incomplete milestone is refused; an archived snapshot SURVIVES its phase dirs being moved out (the regression the obvious single ratio-gate would cause — swapping the snapshot branch to that gate reddens this AND the pre-existing `CURRENT-version snapshot marks the milestone complete` at :321); an archived snapshot is refused once a phase is reopened under it; and a refused marker is not re-asserted by a STATE field claiming the same. roadmap-parser: `isMilestoneShippedInRoadmap` gets unit coverage at its owner module rather than only through the inventory that consumes it, plus two characterisation tests for `getMilestonePhaseFilter`'s legacy call surface — omitting the new trailing `ws` param is indistinguishable from `undefined`/`null`, and the `GSD_WORKSTREAM` env fallback still resolves. These characterise the call surface; they do not stand in for coverage of its individual callers. phase-id: the `phaseKeyFrom*` / `parentPhaseKey` one-key-space contract, incl. a property that padding a directory number never changes its key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * docs(#2562): record the two-strength shipped cross-check + milestone_shipped_unverified Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * fix(#2562): refuse an archived snapshot on a DIRTY archive, not just a live-unfinished dir The snapshot arm checked `liveIncompletePhases > 0`, which misses the shape @davesienkowski reproduced: a COMPLETE live dir beside a phase declared in the Progress table with no directory. Nothing is live-and-unfinished, the marker sails through, and `cmdWorkstreamProgress` returns `{"status":"milestone complete","progress_percent":50}` — the reported symptom verbatim, from one payload. Reproduced at 483a3ba30 before changing anything. His diagnosis is the right one and better than mine: an in-milestone directory outliving the archive means the archive is not CLEAN, and once that is true the completion ratio is meaningful again. So the check is the conjunction — any live in-milestone dir AND `completedPhases < effectivePhaseCount`. That strictly subsumes the old predicate (an incomplete member dir is in the denominator and not the numerator, so the ratio is always short when one exists) and leaves the clean-archive guard green, since a clean archive has no live dirs at all. Also corrects the module comment: the `scoped &&` prefix ungates all three signals, not just `legacy`. That is correct behaviour — unscoped, the denominator is the whole-roadmap count and membership is everything, so there is no current-milestone artifact set to check a current-milestone claim against — but the comment claimed otherwise. And the `milestone.cts` citations were ~28 lines stale after the rebase; they are now :700-702 (copy) and :783-790 (move), re-verified against this head. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * fix(#2562): project milestone_shipped_unverified from list, status and progress The inventory carried the field and every renderer dropped it — `workstream.cts` was not in this PR's diff at all — so at the CLI a refused marker looked exactly like no marker: a fallback `status` and nothing saying one was seen and rejected. That is the silent collapse this issue is about, reintroduced one layer up, and it made the changeset's "visible rather than silent" claim false at every surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * test(#2562): pin the dirty-archive shape and the CLI projection Five tests, all five red against the prior src and green with it. The reviewer's repro at the builder: an archived snapshot with a COMPLETE live dir beside a dirless declared phase must be refused, and status must not contradict the percentage. Four at the CLI via runGsdTools, the surface that was dropping the field rather than the builder that already had it: `workstream progress`/`status`/`list` each project `milestone_shipped_unverified: true` for that workstream, and a clean archive still reports `false` with `status: "milestone complete"`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * docs(#2562): correct the snapshot check, the scoped-only caveat and the CLI claim Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
f0bb0787c9 |
fix(#2640): report truthful state_updated + keep progress frontmatter in sync after phase remove (#2974)
* test(#2640): add regression for state_updated false positive + stale progress Three cases: (1) state_updated reflects actual content change, not just file existence; (2) progress.total_phases resync'd even when the body lacks 'Total Phases:' (the no-op guard was skipping syncStateFrontmatter); (3) state_updated is false when STATE.md doesn't exist. * fix(#2640): report truthful state_updated + force frontmatter resync Two defects in cmdPhaseRemove: 1. state_updated was fs.existsSync(statePath) — trivially true, since the file existed before and readModifyWriteStateMd never deletes it. Now captures the boolean return from readModifyWriteStateMd (changed from void to boolean: true when content was written, false on no-op). 2. progress.* frontmatter stayed stale when the body lacked 'Total Phases:' or 'of N' — readModifyWriteStateMd's no-op guard (#948) skipped syncStateFrontmatter when the body transform was unchanged. Now the transform forces a body diff when a phase was actually removed, so the guard passes and syncStateFrontmatter rebuilds progress.* from the post-deletion disk/ROADMAP state. * fix(#2640): address review — gate forced-diff on targetDir, strengthen assertions Two MAJOR findings from isolated adversarial review: 1. Forced-diff injected a spurious 'Total Phases:' line even when no directory was removed (targetDir === null). Now gated on targetDir !== null. 2. Test #2 asserted 'not 3' instead of '2' — would pass for any wrong count. Now asserts exact value. Test #1 strengthened to assert body Total Phases and frontmatter total_phases both equal 1. * chore(#2640): add changeset fragment * chore(#2640): backfill changeset PR number 2974 --------- Co-authored-by: sim <sim@local> |
||
|
|
73418c516f |
fix(#2956): scope Phase extraction to ## Current Position (3rd gen of #2444/#2567) (#2961)
* test(#2956): fail-first regressions for Phase scoped to ## Current Position Third generation of #2444 / #2567. Stopped At / Paused At were scoped to ## Session; Phase (canonically in ## Current Position per templates/state.md) was left unscoped, so a historical Phase: / **Phase:** line in an archive section overwrites current_phase on every write. Since current_phase is routing input for gsd-progress / --next, the rewind routes work to the wrong phase. Six failing-first regressions + one round-trip: - shape B: bold **Phase:** 19 archive BELOW the section - shape C: plain archive Phase: 19 ABOVE the section - bootstrap h3 ### Current Position variant - CRLF variant - Phase token in decisions prose (over-broad-fix guard) - Paused At read-path parity with the write seam (## Session) - write-then-read round trip stays at 22 (read/write agreement) Folded into tests/state.test.cjs (lint:regression-test-names bans a new tests/bug-NNNN-*.test.cjs file). * fix(#2956): scope Phase extraction to ## Current Position at both seams Third generation of #2444 / #2567. Stopped At / Paused At were scoped to ## Session by those fixes; Phase (canonically in ## Current Position per templates/state.md) was left unscoped, so a historical Phase: / **Phase:** line in an archive section silently overwrote current_phase on every write. Because current_phase is routing input for gsd-progress / --next, the rewind routes work to the wrong phase. Fix mirrors the proven #2444 seam exactly: - new matchCurrentPositionSection helper (collectSection-based, CRLF-tolerant, level-flexible for the bootstrap ### Current Position h3 variant), sited next to matchSessionSection. - read path (cmdStateSnapshot): extract Phase from matchCurrentPositionSection ?? body. Also scope Paused At to matchSessionSection ?? body so the read seam agrees with the write seam (which already scoped Paused At to ## Session). - write path (buildStateFrontmatter): extract Phase from matchCurrentPositionSection ?? bodyContent. stateExtractField itself is untouched (its bold/plain precedence is load-bearing for other fields — the #3265 test depends on it), and preferNewerLastActivity is untouched (Last Activity has no canonical section; its date-direction guard is deliberate). Fall back to full-body when no ## Current Position section exists so files without the heading keep current behaviour. * chore(#2956): add changeset fragment (pr:0 placeholder, backfill after PR) * test(#2956): make round-trip test actually trigger the write-path resync The write-then-read round-trip test used 'state update Status "Executing"' on a fixture with no Status field, so the update was a no-op (updated:false) and no frontmatter resync ran through buildStateFrontmatter — the assertion on the written frontmatter then failed not because the fix is wrong, but because no write happened. Add a **Status:** field so the update performs a real field update (updated:true) and forces the resync. Verified locally: pre-fix this writes current_phase:19 (the archive value); post-fix it writes 22. The code fix is correct (5 of 7 RED tests passed; the 2 failures were this defective test). This is the 'fix the bad test' half of the TDD-loop rule. * chore(#2956): backfill changeset PR number 2961 --------- Co-authored-by: sim <sim@local> |
||
|
|
cd5b8643ae |
fix(#2828): state sync reports correct total_phases on a flat unmilestoned roadmap (#2892)
* fix+test(#2828): total_phases uses roadmap count on flat unmilestoned roadmap The read-path disk-scan cache fell back to phaseDirs.length (1) when milestoneBounded was false, even though roadmapPhaseCount (6) was correct for a flat roadmap (no sibling milestones to conflate). Use roadmapPhaseCount as the floor when > 0, matching the write-path (cmdStateSync) which already did this. The milestoneBounded flag still flows to milestoneUnbounded for the percent-skip (#1761 guard preserved). Regression test asserts state-sync writes progress.total_phases:6 for a flat 6-phase roadmap + 1 phase dir. * chore(#2828): changeset fragment * test(#2828): add negative-space coverage (Math.max floor mutant + no-roadmap fallback) — review findings The 6-phase test alone couldn't kill a Math.max-dropping mutant (1<6). Add: a 3-phase-dir/2-roadmap-phase case proving Math.max(dirs,count) floor; a no-roadmap case proving phaseDirs.length fallback. * fix(#2828): refine — distinguish flat unmilestoned from milestoned-unbounded (preserve #1761) The first-pass fix (roadmapPhaseCount > 0 always) re-broke #1761: a milestoned- unbounded roadmap (asserted milestone not among existing version headings) conflated sibling milestones (8 = 4+4). Refine with a hasMilestoneSectioning discriminator: ^#{2,3}(?!Phase) detects non-Phase h2/h3 milestone section headings. A FLAT roadmap (only ### Phase headings + a # title) has none → safe to use roadmapPhaseCount; a SECTIONED-but-unbounded roadmap has them → fall back to phaseDirs.length (#1761). Verified both cases locally (flat→6, sectioned-unbounded→1). * test(#2828): remove two fragile negative-space tests (phase-dir scanner internals) The Math.max-floor and no-roadmap tests made assumptions about the phase-dir scanner's internals (which dirs count as 'realized') that didn't hold. The core regression test (6-phase flat → total_phases:6) plus the existing #1761 conflation tests (which the refined fix preserves) provide sufficient coverage. * chore(#2828): backfill changeset PR number 2892 * fix(#2828): replace ReDoS-prone regex in regression test with line-by-line parse CodeQL flagged the nested-quantifier regex (`(?:[ \t]+\w+:.+\r?\n?)*?`) in tests/issue-2828-flat-roadmap-total-phases.test.cjs as a high-severity catastrophic-backtracking risk. Rewrite the STATE.md progress.total_phases extraction as a ReDoS-safe line-by-line block walk. --------- Co-authored-by: Test <test@example.com> |
||
|
|
82ca13f5a5 |
fix(#2736): write current_phase_name from the transition intent, not the lossy prose round-trip (#2821)
* fix(#2736): intent-first current_phase_name on transitions; dash-first prose precedence Primary: completePhase (adapter) and beginPhase (via readModifyWriteStateMd options) pass the intent-held display name to syncStateFrontmatter as an authoritative override, applied after every derive/preserve/carry-forward step — so the lossy prose round-trip can never destroy a name the transition just resolved. Names containing a parenthetical (`Closer-ruling measurement (D1a)`) now land in frontmatter verbatim instead of collapsing to the parenthetical (`D1a`). Secondary (#1695 AC #3 residual): parsePhaseFromProse prefers the em-dash name when it is a genuine name (not a status keyword, not a `Milestone:` tail), else falls back to the parenthetical — satisfying both first-party writer shapes (`N — Name (aside)` and `N (Name) — EXECUTING`). Still lossy for paren-containing names, which is why the intent-first override is the primary fix. plannedPhase carries no name in its intent, so it is naturally out of scope. Fixes #2736 * docs(changeset): backfill PR number for #2736 fragment * fix(#2736): drop an unnecessary type assertion on result.data StateTransitionResult.data is already `Record<string, unknown> | undefined`, so the cast was a no-op and tripped @typescript-eslint/no-unnecessary-type- assertion (CI lint-tests red on the first push; every test lane was green). --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
6229f0e55c |
fix(#2701): reject NUL-corrupted plan/state artifacts at the validator entry points (#2829)
* test(#2701): failing-first regression for NUL-corrupted plan/state validators * fix(#2701): reject NUL-corrupted plan/state artifacts at the validator entry points * fix(#2701): seed STATE.md in test (writeState); add NUL-path guards to validate/verify for parity (review) * docs(changeset): #2701 validators reject NUL-corrupted artifacts * docs(changeset): backfill #2701 PR number to 2829 |
||
|
|
9a76ca6783 |
fix(#1882): distinguish unterminated frontmatter from absent frontmatter (#2712)
* fix(#1882): distinguish unterminated frontmatter from absent frontmatter
extractFrontmatter returned {} both for a document with no frontmatter and for
one whose fence was opened and never closed, so a file truncated mid-write was
byte-identical to a legitimate no-metadata file. Verified live through
`gsd-tools frontmatter get`: both printed {} with exit 0 and nothing on stderr.
Per ADR-1411's "corrupt is not absent" amendment the {} return is preserved
exactly -- no caller may break -- and the cause is surfaced out-of-band as a
deduplicated, unconditional stderr diagnostic. That mechanism lands as a shared
leaf module rather than a per-site copy because three sibling findings in the
same epic need it identically; four hand-rolled copies of one behaviour is the
generative-fix-divergence defect class.
The discriminator is deliberately not "opened but never closed". A Markdown
document whose first line is a thematic break takes that exact branch, so
flagging on the missing fence alone reports corruption on good Markdown -- the
failure mode this class of check has shipped with before. The unterminated
region is instead run through extractFrontmatter's own parser (extracted as
parseYamlRegion so the probe and the real parse can never diverge) and reported
only when it yields at least one key.
Also folds an inline defect found while working: src/config-loader.cts carried
two NUL bytes in the JSDoc added by this epic's Phase 1 (
|
||
|
|
ec681e3c21 |
fix(#2567): scope Paused At to ## Session + guard Last Activity date regression (#2660)
* test(#2567): add failing regression for stale state field overwrites buildStateFrontmatter extracts Last Activity and Paused At from the full STATE.md body via stateExtractField, which matches the first 'Field:' line anywhere. Historical archive sections containing stale field-shaped lines silently overwrite the correct frontmatter value on every sync, and because the poisoning line stays in the body it regresses on the next write. Same divergence class as Bug #2444 (which scoped Stopped At to ## Session but did not propagate). Failing-first: all three tests reproduce the bug on unmodified next (verified via the dedicated red run on the test-only commit). * fix(#2567): scope Paused At to ## Session + guard Last Activity date Two complementary fixes for the stale-archive-overwrites-frontmatter bug class, chosen per field semantics: - Paused At is a session field: scope extraction to ## Session (via the existing matchSessionSection helper), exactly mirroring the #2444 fix for Stopped At. A stale 'Paused At:' line in an archive section can no longer win over the current value. Falls back to full body when no ## Session. - Last Activity has no single canonical section (it appears in the preamble, ## Configuration, and ## Current Position across STATE.md layouts), so a section scope cannot reliably exclude archive copies. Instead guard the information-losing direction: when the body-derived date is OLDER than the existing frontmatter date, keep the existing value and description (preferNewerLastActivity). Applied at both the write seam (syncStateFrontmatter) and the read seam (cmdStateJson) so they agree. Non-date values pass through unchanged. A first attempt scoped ALL current-state fields to the body preamble, but that broke STATE.md variants where the fields legitimately live inside ## Configuration / ## Current Position (regressed 4 frontmatter.test.cjs suites). This minimal fix targets only the two fields the issue names. * docs(#2567): add changeset fragment for stale state field overwrite fix * docs(#2567): backfill PR number in changeset fragment |
||
|
|
2bcfaa2e27 |
fix(#2400): warn on planned-phase no-op + sync progress.total_plans (#2552)
* fix(#2400): warn on planned-phase no-op + sync progress.total_plans Bug A: When STATE.md Current Position has no recognized labels (narrative prose), emit a warning field so the workflow detects the no-op instead of continuing with stale state. Bug B: Sync progress.total_plans in the YAML frontmatter when a plan count is provided, preventing contradictory state between frontmatter (0) and body (actual count). This writes the explicitly-provided count, not a re-derivation from disk (#500 safe). Closes #2400 * docs(#2400): backfill changeset PR number (2552) |
||
|
|
04a0eb8d63 |
fix(#2450): CRLF-tolerant session-section rewrite + no-op-detection guard (#2482)
* fix(#2444): re-resolve body-parser to 2.3.0 in lockfile (GHSA-v422-hmwv-36x6) GHSA-v422-hmwv-36x6 (body-parser DoS via invalid limit value, low severity, published 2026-07-20T23:23:26Z) made tests/npm-integrity-gate.test.cjs (#3588: root workspace production tree has no advisories) fail any subsequent npm audit --omit=dev. The advisory affects body-parser >=2.0.0 <2.3.0 pulled transitively via @anthropic-ai/claude-agent-sdk -> @modelcontextprotocol/sdk -> express -> body-parser@2.2.2. express@5.2.1 already declares body-parser as ^2.2.1, so 2.3.0 is a valid re-resolution within express's own compatibility range — no override needed. Regenerated the lockfile via 'npm audit fix --omit=dev' which re-resolves transitive deps within their declared ranges; package.json is unchanged. Verified: npm audit --omit=dev reports 0/0/0/0/0 advisories; body-parser now reads as 2.3.0 in 'npm ls body-parser --omit=dev'. * test(#2450): failing-first CRLF regression for record-session insert path cmdStateRecordSession's section-rewrite regexes (src/state.cts:1166, :1195) used literal \\n which cannot match CRLF STATE.md delimiters. The detector regex (CRLF-tolerant via $ under /m) entered the rewrite branch, the writer regex silently no-op'd, but updated.push(...)/ sessionCreated=true ran unconditionally. Result: caller reported recorded:true with 'Resume File' in updated, but the field was never written to disk. With core.autocrlf=input, the CRLF working-tree file produces no git diff, so the bug was invisible. Adds three regression tests covering all three rewrite paths: - CRLF STATE.md with ## Session and Resume file absent - CRLF STATE.md with ## Session and Stopped at absent - CRLF STATE.md with ## Session Continuity (bootstrap shape) Each asserts the field IS on disk (the bug discriminator: the pre-fix command's JSON output looked identical to a successful write). * fix(#2450): CRLF-tolerant session-section rewrite + no-op-detection guard Two regexes in cmdStateRecordSession used literal \\n which cannot match CRLF STATE.md, silently no-op'ing the section rewrite while the CRLF- tolerant detector above entered the branch. The reporter's exact repro: on a CRLF STATE.md with one canonical session field absent, the command returned recorded:true + updated:['Resume File'] but the field was never written to disk. Three changes: 1. src/state.cts:1166 (canonical ## Session rewrite regex): \\n -> \\r?\\n 2. src/state.cts:1195 (## Session Continuity insert regex): \\n -> \\r?\\n 3. Defensive invariant (#2450 class fix per reporter's suggestion): track whether the chosen branch's replace actually matched via callback flag. Only set sessionCreated=true and push to updated when rewriteMatched. Unreachable post-fix, but fail-loud is the right posture for a silent- success gate. If a future drift between the detector and writer regexes reintroduces the asymmetry, the caller will not see false updated entries. Same canonical CRLF-tolerant form already in use at check-command-router.cts :205 (extractPlanDesignatedSections). Same bug class previously fixed in #1658, #1668, #2206, #2449. * fix(#2450): address review followups + add changeset Code-review + security-review both flagged the unreachable else at the Session Continuity branch (defaulted rewriteMatched=true in dead code, re-arming the bug class for future drift). Removed the else; the remaining code path leaves rewriteMatched=false if linesToInsert is empty, preserving the fail-loud posture. Added scope-limitation doc to the rewriteMatched gate: it covers the INSERT path only, not the earlier in-place stateReplaceField successes (which DID land on disk and correctly push to updated unconditionally). Tests: - Normalized STATE_CRLF_SESSION_MISSING_RESUME fixture to match the canonical 6-key frontmatter of STATE_WITH_SESSION (code-review I2). - Added mixed-ending test (LF frontmatter + CRLF body) to close CONTRIBUTING.md:490 'Mixed CRLF/LF newlines' requirement (I1). Added Fixed changeset (code-review H1). * docs(changeset): backfill PR number to 2482 |
||
|
|
a54feb4216 |
fix(#2440): per-counter progress ratchet — total_plans always takes derived value (#2468)
* fix(#2440): per-counter progress ratchet — total_plans always takes derived value Two sites fixed (targeted — existing body-only write tests preserved): Site A — read path: shouldPreserveExistingProgress (state-document.cts:167) removed total_plans from the all-or-nothing ratchet check. It now joins total_phases as an always-derived counter. Only completed_phases and completed_plans keep ratchet behaviour (they are monotonic). This fixes gsd-tools query state.json reporting stale total_plans when a curated completed_plans triggers the ratchet. Site B — write path: applyStatePreservation (state-transition.cts:162) gained a deriveProgressKeys opt-in flag. When true (passed by cmdStatePlannedPhase only), total_plans and total_phases take the derived (post-sync) value instead of the wholesale curated restore. When false (the default — state.update, state.patch), the existing #3242 wholesale protection stays fully in force. This fixes the state planned-phase verb writing a stale total_plans. The opt-in approach preserves all 8 existing #3242/#1264/#500 body-only write tests that assert wholesale progress preservation during non- progress updates. Tests: - tests/state.test.cjs: 4 unit tests for shouldPreserveExistingProgress (total_plans upward/downward/equality + completed_plans ratchet active). - tests/state-transition.test.cjs: 2 #2440 regression tests for deriveProgressKeys=true (total_plans takes derived; boundary at equality). The existing !resync wholesale-restore test stays unchanged (default behavior preserved). References: #2440; #1446 (total_phases read-path fix — same principle); #3242 Bug A (body-only preservation — protection preserved via the opt-in gate); ADR-1769 (applyStatePreservation table-driven preservation). * chore(#2440): backfill pr:2468 in .changeset/mellow-eagles-chatter.md |
||
|
|
1a46bc068a |
fix(#2376): emit absolute subagent-facing paths from init/state, convert workflow literals (#2428)
* fix(#2376): emit absolute subagent-facing init/state paths Make init.* and state.* path fields absolute rather than cwd-relative so subagent prompts resolve correctly regardless of working directory. Adds intel_dir/conflicts_path/requirements_path/roadmap_path/state_path to cmdInitIngestDocs, an absolute debug_dir to cmdStateLoad, and replaces bare .planning/... literals in 12 workflow Agent() prompt blocks with the absolute init-JSON path fields. Includes decoy-cwd regression tests and realpath'd tmpdir fixtures for macOS. Squashed rebase of the #2376 commit series onto a fresh origin/next (previous merge ee25543a1 was against a now-stale next). * chore(#2376): add changeset * chore(#2376): regenerate golden fixtures + workflow size baseline Regenerated after rebasing the absolute-path fix onto current next (picks up #2351's run-with-timeout content in execute-phase.md too). * fix(#2376): trim execute-phase.md redundancy to stay under the size margin * chore(#2376): regenerate golden/size baseline after rebase onto next |
||
|
|
c1885df9e5 |
chore(#2143): prohibition-with-teeth + migrate remaining ad-hoc table sites — Phase 4 (final) (#2253)
* 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> |
||
|
|
7ccf57200d |
fix(#2202): preserve unknown frontmatter keys in syncStateFrontmatter (#2233)
* 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> |
||
|
|
e8533b875d |
fix(#2177): match the body Progress: line, preserve the descriptive suffix
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 |
||
|
|
58b20c31f8 |
fix(#2136): migrate ALL operator-facing date sites to localToday (anti-pattern elimination)
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.
|
||
|
|
71edb27fe1 |
fix(#2136): route operator-facing date fields through local clock day
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
|
||
|
|
3bc53c8116 |
fix(#2135): anchor milestone heading regex + strip delimiter, widen preserve guard
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 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
12b35eeeaf |
fix(#1729): resolve phase headers with a pre-colon parenthetical tag (#1765)
* fix(#1729): resolve phase headers with a pre-colon parenthetical tag A phase header may carry a parenthetical tag between the number and the colon, e.g. `### Phase 26 (Cluster B): Title`. Every phase-header regex built `Phase\s+<num>` immediately against the colon delimiter, so the tagged phase was invisible: the resolver returned found:false and, just as bad, the capture-all enumeration/parse paths (roadmap analyze, milestone listing + milestone-scope filter, verify, init/import, state total_phases, validate, the command router, preamble stripping, and the phase-remove renumbering rewrite) silently dropped, miscounted, or failed to renumber it — wrong phase_count, progress_percent, next_phase, or corrupt numbering after a removal. The fix tolerates the tag at the header seam. Parameterized resolver sites compose the exported OPTIONAL_PHASE_TAG_SOURCE fragment; literal enumeration sites inline its character-for-character mirror `(?:\s*\([^)\n]*\))?`, placed immediately before the colon so it cannot alter an existing match (optional, single-line, one paren pair, no capture-group shift). In the renumber-on-removal rewrite the tag is folded into the re-emitted suffix capture so it survives verbatim. Both forms are documented to change together and a drift-guard test asserts their behavioral equivalence over a header corpus. Deliberately excluded: roadmap-upgrade.cts (legacy one-time migration), where tolerating the tag would silently drop it on header rewrite — that needs its own data-preserving treatment. Known boundaries left for follow-up: checklist/bullet-style phase entries (`- [ ] Phase N (tag):`) and a malformed space-before-colon variant, both pre-existing. Validated empirically against the issue's reproduction: `roadmap get-phase 26` resolves and `roadmap analyze` lists Phase 26 with the tag excluded from the name (phase_count 2, next 26); an all-tagged versioned roadmap now scopes correctly instead of falling back to a pass-all filter. Regression coverage in tests/phase.test.cjs asserts resolver parity (pre- vs post-colon), padding tolerance (#3537), decimal sub-phases, no cross-phase false match, the shared seam, enumeration coherence, renumber-preserves-tag, and seam/mirror drift. Full unit suite green (7291 pass, 0 fail); eslint + regression-name + resolution-provenance + changeset lints pass. Reviewed by Codex (no critical/high; the two enumeration misses it surfaced are folded in). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1729): add changeset for pre-colon phase-tag fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
50ff7a8707 |
fix(#1776): scope prune phase resolution to ## Current Position (#1832)
* fix(#1776): scope prune phase resolution to ## Current Position cmdStatePrune resolved the current phase by extracting the `Phase` field over the WHOLE STATE.md body. stateExtractField's fallback chain ends in a pipe-table match (`| Phase | N |`), so a STATE.md lacking a `Current Phase` field and a prose `Phase:` line — but carrying an unrelated `Phase`-labelled table row (e.g. a historical verification table) — resolved that stale table cell as the current phase and computed a wrong cutoff (bailing "Only N phases" or pruning at a stale boundary). Resolve the phase via the same canonical chain buildStateFrontmatter uses — frontmatter `current_phase` → `Current Phase` field → prose `Phase: X of Y` — but scope ONLY the prose term to the `## Current Position` section via the fence-aware locateCurrentPosition seam (new exported sliceCurrentPositionSection). Frontmatter and the explicit `Current Phase` field stay document-wide (they are unambiguous); the shared stateExtractField is not narrowed for any other caller. Tests (folded into tests/state-prune.test.cjs): a stray `| Phase | 2 |` table with the real phase in frontmatter no longer drives the cutoff (fail-first on base); template-conformant STATE.md is unchanged; and a fast-check boundary-containment property that a `| Phase | N |` row outside Current Position never leaks into the scoped resolution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1776): add changeset for prune Current Position scoping Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> |
||
|
|
bad2c76c5e |
feat(#1826): cmdStateRebuild CLI + dry-run + verbose + integration tests + docs (#1830)
Phase 2 of approved feature #1817. Wires the pure `rebuildCore` transition (Phase 1, #1827) to the `gsd state rebuild` CLI subcommand per ADR-1817 §5 (heavy/manual counterpart to lightweight, auto-triggered `state sync`). Source changes: - src/state.cts: implement cmdStateRebuild. Locks via readModifyWriteStateMd (real path) or read-only (dry-run). Wires phaseInventoryProvider to a real .planning/phases/ disk scan (same canonical source buildStateFrontmatter uses). --dry-run emits a structured preview without writing. --verbose tees the audit-log entries to stderr (treated as data-only per ADR-1577). - src/state-command-router.cts: register cmdStateRebuild in the StateModule interface + add the rebuild handler with --dry-run and --verbose flag parsing. - src/command-aliases.cts: register the state.rebuild canonical + 'state rebuild' alias + mutation=true (so the manifest covers the new subcommand for SDK parity / dispatch hub tests). Tests (tests/state-rebuild-cli.test.cjs, 5 cases): - state rebuild with no flags reconciles drifted body + drops orphan table rows + appends audit log (end-to-end #1, #2, audit log). - state rebuild --dry-run computes the diff, writes nothing (criterion #5). - state rebuild --verbose tees the log; audit-log section still written. - Running rebuild twice on the just-rebuilt file is byte-identical (criterion #6 end-to-end). - Missing STATE.md produces a clean 'STATE.md not found' message, no stack trace (CONTRIBUTING QA matrix). Verified locally: - node --test tests/state-rebuild-cli.test.cjs → 5/5 pass - node --test tests/state-rebuild.test.cjs → 18/18 pass (Phase 1 regression) - node --test tests/state-transition.test.cjs → 85/85 pass (ADR-1769 regression) Docs (docs/COMMANDS.md): document `state rebuild [--dry-run] [--verbose]` with the canonical-command block format used by `state sync` / `state prune`. Changeset (.changeset/1817-state-rebuild.md): type=Added, user-facing description of the new subcommand (closes #1817 epic on merge). |
||
|
|
38c2c1805e |
fix(#1761): skip conflated progress in state json read-path when milestone unbounded (#1818)
* fix(#1761): skip conflated progress in state json read-path when milestone unbounded ADR-1769 Phase 7 (#1794) closed the state sync WRITE path — when a milestone version is asserted in frontmatter but the ROADMAP has no versioned heading for it, sync leaves Progress untouched. But the state json READ path rebuilds progress via buildStateFrontmatter, whose roadmapPhaseCount loop counts phase headings across the WHOLE document when extractCurrentMilestone can't bound the milestone. state json therefore reported a conflated total_phases (sum of sibling milestones) + a derived percent — exactly the value the sync guard was added to prevent. (Repro from the issue: total_phases 8 = 4+4, percent 13.) Mirror the cmdStateSync guard inside buildStateFrontmatter: when the asserted milestone cannot be bounded to a versioned ROADMAP heading (the same versionedHeading test the sync path uses), fall back to the on-disk phase-dir count for total_phases and skip percent. Bounded milestones (versioned ROADMAP, or no milestone asserted) are unchanged. The signal rides on the existing _diskScanCache (new milestoneBounded field) so neither extractCurrentMilestone's return contract nor its other callers change. Regression: extend tests/bug-1761-state-sync-wrong-progress.test.cjs with the read-path case (unbounded → no percent, no conflated total_phases) and a bounded control (versioned ROADMAP → unchanged percent + total_phases). * docs(#1761): add changeset fragment for state json read-path fix |
||
|
|
21f0b4316f |
refactor(#1796): ADR-1769 Path A — finish STATE.md preservation consolidation (#1799)
Extract readModifyWriteStateMd's post-sync preservation block into a pure,
field-classification-table-driven applyStatePreservation in the STATE.md
Transition Module. progress / status / stopped_at now join current_phase_name
as table-governed (getFieldClassification), so a preservation-policy change is
a one-row table edit instead of a per-call-site patch.
This realizes the consolidation ADR-1769 / CONTEXT.md already claimed shipped
('Absorbs readModifyWriteStateMd post-sync preservation block') and routes the
#1264 preservation policy through the single field-classification table — the
bug class is now structurally guarded by the table, not just the call-site
shouldResync flag.
Behavior is byte-identical to the pre-amendment inline block (Hyrum-safe — the
15 readModifyWriteStateMd callers' observable preservation is unchanged):
- state/frontmatter/transition + bug regression suite: 847 pass
- phase/milestone/verify (other RMW consumers): 582 pass
- codex (gpt-5.5/high) adversarial review: CLEAN (58,564-case equiv sweep)
ADR-1769 amendment appended documenting #1796.
Closes #1796
|
||
|
|
79c69d443b |
refactor(#1793): ADR-1769 Phase 7 — sync, prune, update migrations (#1760, #1761) (#1794)
Migrate cmdStateSync, cmdStatePrune, cmdStateUpdate (state.cts) onto the STATE.md Transition Module substrate and close the maintenance bug pair #1760/#1761 (ADR-1769, epic #1769). Completes the substrate: all 10 lifecycle/maintenance transitions now route through transitionCore. - Add {kind: 'sync'|'prune'|'update'} to StateTransitionIntent with syncCore, pruneCore, updateCore in src/state-transition.cts. - updateCore: body-only single-field update (strip/reassemble), mirroring the pre-migration cmdStateUpdate contract. - pruneCore: pure content→content section pruning (Decisions / Recently Completed / resolved Blockers / Performance Metrics rows at or below cutoff), byte-identical tokenizeHeadings splicing. Adapter owns currentPhase, dry-run, and STATE-ARCHIVE.md. - syncCore: body writes (Total Plans in Phase, Progress bar, Last Activity) given disk-derived numbers. Adapter owns the disk scan + roadmap scope. - #1760: cmdStatePrune now derives currentPhase from 'Current Phase' OR 'Phase' (the canonical template emits 'Phase: X of Y'), so prune engages on template-conformant STATE.md instead of bailing 'Only 0 phases'. - #1761: cmdStateSync skips the Progress write (percent=null) when a milestone version is set in frontmatter but the ROADMAP has no versioned heading for it (milestone cannot be bounded). Projects without a milestone version are unaffected. Leaves progress untouched rather than silently writing fallback- derived wrong values. - Regression: bug-1760 (prune engages on template field) + bug-1761 (sync leaves progress untouched when unbounded). ADR-1769 marked Accepted. All 82 transition + 515 regression tests pass. Closes #1793 |
||
|
|
064f63b299 |
refactor(#1791): ADR-1769 Phase 6 — patch migration + curated current_phase_name preserve (#1695) (#1792)
Migrate cmdStatePatch (state.cts) onto the STATE.md Transition Module substrate and close the curated-field clobber bug class #1743/#1695 (ADR-1769, epic #1769). - Add {kind: 'patch'} to StateTransitionIntent, with patchCore in src/state-transition.cts. Applies each caller-supplied {field:value} pair via stateReplaceField over the full content (body + frontmatter), tracking updated vs. failed. data.updated/data.failed mirror the CLI output shape. - Collapse cmdStatePatch to a transitionCore dispatch. Field-name validation (security) and the resync-progress decision stay in the adapter. - #1695/#1743 fix: extend the #1230 delta heuristic in readModifyWriteStateMd to the curated current_phase_name, table-driven via getFieldClassification('current_phase_name').preservation === 'preserve-always'. When a write does NOT change the body Phase: source line, the curated frontmatter value wins over syncStateFrontmatter's body re-derivation (which harvests a wrong parenthetical aside — #1695). begin/planned/complete-phase rewrite their body Phase line, so the delta does not fire for them and current_phase_name still advances. - Regression: bug-1695-state-patch-clobbers-phase-name.test.cjs — unrelated patch preserves curated current_phase_name; patching the Phase source advances. All 70 transition + 493 regression tests pass (state/phase/milestone + #905/#397/#3242 lineages). Closes #1791 |
||
|
|
91704c9fcf |
refactor(#1786): ADR-1769 Phase 4 — plannedPhase + milestoneSwitch migration (#1788)
Migrate cmdStatePlannedPhase and cmdStateMilestoneSwitch (state.cts) onto the STATE.md Transition Module substrate (ADR-1769, epic #1769). - Add {kind: 'plannedPhase'} and {kind: 'milestoneSwitch'} to StateTransitionIntent, with plannedPhaseCore and milestoneSwitchCore in src/state-transition.cts (consulting the field-classification table). - plannedPhaseCore owns the template-aware Status/Last Activity updates, Total Plans in Phase, Last Activity Description, and the Current Position section (via the inlined mutateCurrentPositionForAdvance twin). The adapter keeps the resync:false readModifyWriteStateMd wrapper (#500 RC1). - milestoneSwitchCore owns the new-milestone reset: rebuilt frontmatter (milestone/name/status='planning'/zeroed progress) + Current Position body reset. The adapter keeps acquireStateLock + platformWriteSync (NOT readModifyWriteStateMd — milestoneSwitch rebuilds frontmatter directly). - Collapse both callbacks to transitionCore dispatches. The now-dead updateCurrentPositionFields helper and KNOWN_STATUS_PATTERNS import are removed (their behavior lives in the transition module's mutateCurrentPositionForAdvance). - Characterization tests pin the template-aware preserve-authored invariant, Total Plans, Last Activity narrative, Current Position update, and the full milestone reset (frontmatter + position body + gsd_state_version preserve + Accumulated Context preserve). All 58 transition + 177 state + phase + bug-2630/bug-905 regression tests pass. Closes #1786 |
||
|
|
1512e6f415 |
refactor(#1782): ADR-1769 Phase 2 — advancePlan migration onto Transition Module (#1783)
Migrates cmdStateAdvancePlan (~80-line RMW callback) onto the transitionCore dispatch established in Phase 1 (#1775): - src/state-transition.cts: - Add {kind: 'advancePlan'} to StateTransitionIntent union - Extend StateTransitionResult with optional data field for intent-specific output (advanced, currentPlan, totalPlans) - advancePlanCore: parses legacy + compound plan formats, handles advance vs phase-complete branching, strips frontmatter before body mutation (#1255 pattern — codex Phase 2 HIGH finding), uses stateReplaceFieldIfTemplate for template-default-aware field replacement (Knuth invariant), mutates ## Current Position section via mutateCurrentPositionForAdvance - mutateCurrentPositionForAdvance: inlined section mutation (avoids circular dep with state.cjs's updateCurrentPositionFields) - src/state.cts:cmdStateAdvancePlan: collapsed to 30-line dispatch - tests/state-transition.test.cjs: 5 characterization tests (advance, phase-complete, error, compound format, frontmatter #1255) Codex gpt-5.5/high review: 1 HIGH blocking (frontmatter strip — fixed), 1 medium follow-up (StateTransitionResult.data shape discrimination — noted for Phases 3-7). gsd-test: 21893/21893 PASS (Linux docker). Closes #1782 |
||
|
|
744bb7aaee |
refactor(#1771): ADR-1769 Phase 1 — STATE.md Transition Module substrate + beginPhase (#1775)
* refactor(#1771): ADR-1769 Phase 1 — STATE.md Transition Module substrate + beginPhase Lands the Phase 1 substrate per ADR-1769: - src/state-transition.cts (new Module): - Field-classification table (FieldClass enum + FIELD_CLASSIFICATION rows) - STATE_MD_SECTIONS constants block - Pure transitionCore(content, intent, deps) dispatch - beginPhase intent implementation (first-time + #3127 resume paths) - src/state.cts:cmdStateBeginPhase — collapses ~190 lines to a thin dispatch onto transitionCore via readModifyWriteStateMd. The lock, no-op write guard, and #1230 post-sync delta heuristic stay in the RMW seam; the body-mutation policy moves to transitionCore. - tests/state-transition.test.cjs (24 tests): - Substrate invariants (table enum, section constants) - Characterization: 6 first-time body field updates - Characterization: 5 #3127 idempotency-guard resume behaviors - Characterization: 3 Current Position section mutations - Characterization: Current focus body text line (#1104) - Property (RULESET.TESTS.property-based-testing): beginPhase status propagation + FIELD_CLASSIFICATION own-property contract - Resume Current Position mutation (preserves Plan/Phase/Status) No external behavior change. Full state.test.cjs regression (177 tests) plus bug-3127/#3242/#905/#948 pass. Property tests surfaced two pre-existing quirks (state-document.cjs greedy \s* on whitespace-only field values; Object.prototype method leakage on FIELD_CLASSIFICATION lookups for strings like 'toString') — documented in test comments; fix-out-of-scope for Phase 1. Closes #1771 * refactor(#1771): ADR-1769 Phase 1 codex review corrections Addresses 3 blocking findings from codex gpt-5.5/high review: 1. FIELD_CLASSIFICATION shape (state-transition.cts): - Was flat FieldClass enum (collapsed source + preservation) - Now two-column {source, preservation} rows per ADR-1769 §4 - Added missing fields verified via Memtrace against buildStateFrontmatter (state.cts:1633-1653): gsd_state_version, last_updated, last_activity_desc, progress.{total_phases, completed_phases, total_plans, completed_plans, percent} - Field 2-7 preservation dispatch can now consult the table 2. Prototype-pollution hardening (state-transition.cts): - Table is now Object.freeze(Object.assign(Object.create(null), {...})) - getFieldClassification() helper uses Object.hasOwn; returns null for inherited prototype methods (toString/valueOf/__proto__) - Old code: FIELD_CLASSIFICATION['toString'] returned the function 3. STATE_MD_SECTIONS aligned to canonical template: - Verified against gsd-core/templates/state.md via Memtrace - Was: 8 entries including non-template sections (## Session, ## Decisions, ## Operator Next Steps, ## Session Log, ## Roadmap Evolution) - Now: 6 canonical top-level sections (## Project Reference, ## Current Position, ## Performance Metrics, ## Accumulated Context, ## Deferred Items, ## Session Continuity) Also: beginPhase now consults getFieldClassification() per touched field (codex finding: 'table not consulted by transitionCore'). Unknown fields raise immediately — adding a field without a table row is caught at runtime. Repo-hygiene catches from gsd-test (not node --test, which missed these): - gsd-core/bin/lib/state-transition.cjs added to eslint.config.mjs ignore list (ADR-457 tsc-generated) - docs/INVENTORY-MANIFEST.json regenerated via node scripts/gen-inventory-manifest.cjs --write Property test for Object.prototype leakage tightened to verify getFieldClassification() returns null for toString/valueOf/__proto__. Ref #1771 * fix(#1771): cast Object.create(null) to satisfy @typescript-eslint/no-unsafe-assignment ESLint CI failed on src/state-transition.cts:72:14 — Object.create(null) returns `any`, which leaked through Object.assign to the typed `FIELD_CLASSIFICATION` declaration. Adding an explicit cast to `Record<string, FieldClassification>` eliminates the unsafe-assignment while preserving the null-prototype protection codex review recommended. gsd-test: 21888/21888 PASS. * fix(#1771): add 'see #1771' to allow-test-rule exemption per ADR-456 CI lint-allow-test-rule-refs failed: 'New allow-test-rule exemption without an issue ref — add `see #NNN` per ADR-456'. Updated comment on tests/state-transition.test.cjs to reference the Phase 1 issue. * fix(#1771): remove unnecessary allow-test-rule exemption The exemption was added speculatively. The test file does not use readFileSync + .includes()/.match()/.startsWith() on source content — it calls transitionCore() with string literals and verifies results via stateExtractField() and array .includes() on the updated[] array. No exemption needed. |
||
|
|
871621c3c8 |
feat(#1740): require-fs-op-fallback production AST rule + Windows transient-lock retry (Phase 6) (#1742)
* feat(#1740): require-fs-op-fallback production AST rule + Windows transient-lock retry (Phase 6) ADR-1703 Phase 6 of the cross-platform portability epic (#1702). Adds the second production-code portability AST rule + the ADR-mandated glob expansion to bin/install.js and scripts/build-hooks.js. - eslint-rules/require-fs-op-fallback.cjs: flags an unguarded fs.rename / fs.renameSync (the atomic-publish primitive named first in DEFECT.WINDOWS-FS-OPS.symptom) that is NOT inside a try/catch whose handler references a transient errno ('EPERM'/'EBUSY'/'EACCES' or a *RETRY_ERRNOS set) AND NOT behind a Windows platform guard. A catch that silently swallows or cleans-up-and-rethrows without an errno check does NOT satisfy the defect's 'never silently swallow' clause. copyFile/unlink are deliberately not flagged (they are the fallback primitives per the defect's own fix-forward). Scope narrowed to rename per Phase 5's precision discipline; documented on #1740. - src/shell-command-projection.cts: export retryRenameSync(from, to) — the drop-in bounded-retry helper over the existing atomicRenameWithRetry. - 27 bare fs.renameSync sites across 11 modules routed through retryRenameSync (capability-lifecycle/lock/source, installer-migrations, milestone, phase, planning-workspace, roadmap-upgrade, runtime-hooks-surface, state, workstream). Idempotent on POSIX; resilient to AV/indexer transient locks on Windows. - eslint.config.mjs: register rule at error on src/**/*.cts; new focused portability-rules block covering bin/install.js + scripts/build-hooks.js (ADR-1703 L124-126 glob expansion — both files are compliant: zero rename violations). - tests: 15-case RuleTester suite; portability-rule-disable-ban extended (PROTECTED_RULES + scans bin/install.js/build-hooks.js with shebang handling); ci-test-scope portability-lint selection rule. - CONTEXT.md DEFECT.WINDOWS-FS-OPS predicate rewritten to point at the rule; docs/contributing/cross-platform-portability-rules.md reference + how-to. Closes #1740 * chore(#1740): backfill changeset pr:1742 * fix(#1740): tighten require-fs-op-fallback precision (codex review HIGH-1/HIGH-2) Addresses two false-negative findings from the codex (gpt-5.5/high) adversarial review of PR #1742: HIGH-1 — a catch that REFERENCES a transient errno but only rethrows (no retry/fallback) was marked compliant. The DEFECT.WINDOWS-FS-OPS fix-forward requires retry, not just recognition. Fix: catchHandlerHasRetrySignal now requires a loop `continue` backedge OR a `return <call>` delegation; a bare rethrow is flagged. The misleading `/* retry logic */` valid test is replaced with a real retry loop, and the rethrow-only shape is added as invalid. HIGH-2 — the nested-try ancestor walk treated an OUTER errno-catch as protecting the rename even when an INNER catch intercepted/swallowed the error (the outer catch is unreachable). Fix: isInsideTransientErrnoTryCatch now stops at the NEAREST enclosing TryStatement WITH A CATCH HANDLER whose block contains the rename (try-finally is skipped — it doesn't catch); outer catches are no longer consulted. The unsound nested-try valid test is converted to invalid, and a try-finally-skipped valid case is added. Verified: 17 RuleTester cases pass; zero new production violations (the 27 fixed sites use retryRenameSync; the real retry loops — atomicRenameWithRetry, capability-ledger/consent, build-hooks — remain compliant via continue/errno); lint:ci green; disable-ban + vocab-drift green. --------- Co-authored-by: review-bot <review-bot@gsd> |
||
|
|
c583bcc02c |
fix(#1659): dedup By-Phase rows across padded/unpadded phase numbers (#1663)
* fix(#1659): dedup By-Phase rows across padded/unpadded phase numbers phaseRowPattern matched the phase number literally (escapeRegex(String(phaseNum))), so a seeded zero-padded row '| 05 |' was not matched by 'phase complete 5' (pattern '| 5 |'), producing a duplicate row that double-counted the phase. Canonicalize a numeric phase to its integer form (Number('05')===Number('5')===5) and match with a 0* prefix so 5/05/005 all collapse to the same row in either direction. Regression folded into state.test.cjs: seeded '| 05 |' + 'phase complete 5' yields exactly one phase-5 row. Non-numeric phase IDs retain the literal escapeRegex match. * chore(#1659): backfill changeset pr ref to 1663 * fix(#1659): add verification fixture to padded-dedup test under #1522 gate |
||
|
|
ff161f2281 |
fix(#1582): derive phase-complete velocity from By-Phase table (idempotent) (#1655)
* fix(#1582): derive phase-complete velocity from By-Phase table (idempotent) updatePerformanceMetricsSection blind-added summaryCount onto the prior velocity total on every phase complete, so re-running phase complete on an already-complete phase incremented the total each time (the sibling of #4, which fixed the Completed Phases counter the same way). The velocity total is now derived as the sum of the By-Phase table's Plans column AFTER the row upsert — re-completing a phase upserts the same row, so the sum is stable; a hand-edited inflated total self-heals downward to the true sum on the next completion. When the By-Phase table is absent the total is left unchanged (no crash). Strengthens the misnamed 'idempotent' test (its comment explicitly declined to assert velocity idempotency — the latent gap) and adds a self-heal regression; corrects the #320 behavior-lock velocity assertion which had encoded the blind-add (3 = 1+2 double-count) — the derived value is 2. * chore(#1582): backfill changeset pr ref to 1655 * fix(#1582): velocity sum tolerates indented By-Phase rows (codex review) Adversarial review (codex, gpt-5.5/high) flagged that byPhaseTablePattern's data-row capture allows leading whitespace ([ \t]*\|), but the derive sum was anchored at ^\| and would skip indented hand-edited/legacy rows — capturing them in the table but silently undercounting. Align the sum regex (^\s*\|) with the table capture's tolerance. Adds an indented-row regression. Two other codex findings are pre-existing and out of scope: padded/unpadded phase dedup (phaseRowPattern, identical in old code — derive yields the same value as the old blind-add) and CRLF tables (the shared byPhaseTablePattern header requires bare \n, so the upsert was already broken on CRLF; the fix changes stale-vs- double-count, does not worsen it). * fix(#1582): add verification fixtures to velocity tests under #1522 gate Post-rebase onto next+#1548, the #1582 velocity tests (self-heal, indented-row) use phase complete, which now fail-closes under #1522's canonical verification gate without a passed *-VERIFICATION.md. Add writePassedVerification(tmpDir,'02-next','02') to both. |
||
|
|
80607bec93 |
fix(#1658): make byPhaseTablePattern CRLF-tolerant on STATE.md tables (#1662)
* fix(#1658): make byPhaseTablePattern CRLF-tolerant on STATE.md tables byPhaseTablePattern required a bare \n after the header and separator rows, so a STATE.md with CRLF (\r\n) line endings (Windows, or hand-edited) had its By-Phase table treated as absent: phase complete never upserted the row (and the velocity-from- table derivation went stale). Make the header/separator terminators and the closing lookahead CRLF-tolerant ([ \t]*\r?\n, (?=\r?\n|$)). Backward-compatible with LF. Regression folded into tests/state.test.cjs: phase complete on a CRLF STATE.md upserts the row and removes the placeholder. CONTRIBUTING's QA matrix lists Mixed CRLF/LF as a required parser case. * chore(#1658): backfill changeset pr ref to 1662 |
||
|
|
0271231910 |
test(#1514): add fast-check property + all-retired boundary for retired parser
Per review (test-standard items): - Property test (RULESET.TESTS.property-based-testing): extractRetiredPhaseNumbers is the parsing core, so add a fast-check property — k of n checklist phases struck → exactly the k canonical keys returned, across randomized phase counts and numeric/zero-padded/project-code ID forms. Exposed via a `_`-prefixed test seam (mirrors the existing _setLockProbes seams), no public API surface added. - Boundary (RULESET.TESTS.boundary-coverage): all-retired case (k === n) → total_phases 0, via state json. No production behavior change; the exclusion logic is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c6edeb3eb3 | Merge branch 'next' into fix/1514-retired-phase-total-phases | ||
|
|
e3acdd89cb |
fix(#1514): exclude retired/folded phases from progress.total_phases
A retired/folded phase (struck through in ROADMAP, marked [x], with a
directory but no completion artifact) was counted in the total_phases
denominator via max(phaseDirs.length, roadmapPhaseCount), yet could never
satisfy the numerator (no SUMMARY → never "completed"), freezing shipped
milestones below 100% (e.g. 5/6 = 83%).
Both STATE counting paths now read the current-milestone ROADMAP scope and
exclude retired phases from BOTH the disk phase-dir set and the heading
count, so a retired phase counts toward neither denominator nor numerator:
- buildStateFrontmatter (`state json`)
- cmdStateSync (`state sync --verify` / rebuild) — previously re-derived the
inflated denominator and reported "no drift", per the issue.
Retired detection (extractRetiredPhaseNumbers) is scoped to the lines that
canonically mark a phase retired — a checklist entry (`- [x] …`) or a phase
heading — and within those, only a struck span whose SUBJECT is the phase
(`~~**Phase 04: Delta**~~`). So struck prose, a struck goal line, and the fold
target ("folded into Phase 05") are not misread as retired.
Phase matching uses the canonical phase-id helpers (normalizePhaseName +
extractPhaseToken), so numeric, decimal, and project-code IDs (PROJ-42) match
consistently across ROADMAP tokens and on-disk dir names.
Scope boundaries (separate, pre-existing concerns left unchanged):
- `roadmap analyze` (src/roadmap.cts) intentionally trusts the [x] checkbox
(incl. externally-completed phases) — a different reporting surface.
- cmdStateSync does not apply the milestone phase-dir filter (so 999.x /
other-milestone dirs can still affect its count); that is the #1445 /
milestone-filter axis, independent of retired phases.
Same counting family as #549 / #500 / #1445.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
fc019b2688 |
fix(#1531): race-safe steal for the two core-path locks (PR #1532 review)
trek-e's review found the M1 PID-liveness backport dropped two pieces of capability-lock.cts's steal-safety machinery, reopening the #500/#905/#1230 lost-update family: - Empty-body window (state.cts): acquireStateLock creates the lock with O_EXCL and writes the pid in a separate writeSync; a lock observed in that gap has an empty body, reads as not-verified-live, and was stolen at age ~0 — robbing a holder mid-creation. Add a fresh-create floor scoped to the unverifiable-body case: an empty/unparseable body that is fresh is treated as mid-creation and is NOT stolen, while a COMPLETE dead-pid body is still stolen promptly (preserves the prompt-dead-steal contract). planning-workspace writes its body atomically (flag:'wx') so it has no empty-body window. - Double-steal (both locks): the steal was a bare fs.unlinkSync with no identity re-confirm, so two waiters could both reclaim a dead holder and end up holding concurrently. Replace with an atomic renameSync (only one racer wins the inode) guarded by a (dev,ino,body) identity re-confirm immediately before the steal; body content is part of the identity to defeat inode reuse. Tests (seam-driven, no wall-clock, each proven RED-before-GREEN): - clock-seam: fresh empty-body lock is not stolen at age ~0; a racer-recreated live lock is not double-stolen (identity re-confirm). Adds a beforeSteal seam. - planning-workspace: racer-recreated live lock is not double-stolen. - Updated the two #1217 unlinkSync-failure tests to the renameSync steal path (the bounded-backoff/no-busy-spin guarantee is preserved and re-asserted). Uncontended acquire path is byte-for-byte unchanged. Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz |
||
|
|
510f37661a |
fix(core): acquireStateLock must not leak fd + orphan lock on write error (M9)
Root cause: in acquireStateLock, once openSync(O_CREAT|O_EXCL) created the lock file, the subsequent writeSync(pid)/closeSync were unguarded. A recoverable errno (EAGAIN etc., in ACQUIRE_LOCK_RETRY_ERRNOS) made the catch do checkBudgetAndSleep + continue WITHOUT closing the fd or unlinking the just-created empty lock — leaking a descriptor every occurrence and stranding a content-less lock (the #500/#905/#1230 STATE.md write-corruption family). Fix: wrap writeSync/closeSync in an inner try that guardedly closeSync(fd) + unlinkSync(lockPath) then re-throws to the existing outer catch (DRY errno classification). Recoverable errno retries from a clean slate; a FATAL errno (e.g. ENOSPC, not recoverable) still propagates after cleanup — not masked. Mirrors the already-shipped capability-lock.cts:415-425 pattern. Extends the M8 test seam with a one-shot simulateWriteError errno + an onLoopIteration snapshot hook so the orphan-before-retry is deterministically observable. New tests prove RED (orphan stranded / fatal leaves orphan) before the cleanup and GREEN after. Source of truth src/state.cts (ADR-457); bin/lib/state.cjs is generated. Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz |
||
|
|
2fe17cb87d |
fix(core): writeStateMd must scan inside the lock (M8)
Root cause: writeStateMd computed its frontmatter disk scan (syncStateFrontmatter — the READ half of a read-modify-write) BEFORE acquireStateLock, leaving a TOCTOU window. A concurrent writer that committed a new PLAN/SUMMARY between our scan and our lock made writeStateMd stamp stale progress counts (lost update — the #500/#905/#1230 family). The atomic sibling readModifyWriteStateMd already scans inside its lock. Fix: move _diskScanCache.delete + syncStateFrontmatter inside the acquireStateLock-held try, before platformWriteSync. Byte-for-behaviour identical for single-threaded callers — only the concurrent-writer window closes. Adds an afterAcquire test seam (mirrors the M1 _setLockProbes seam) to make the window deterministic; new test proves RED (stale count) before the reorder and GREEN after. Source of truth src/state.cts (ADR-457); bin/lib/state.cjs is generated. Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz |
||
|
|
fc9bd70aff |
fix: PID-liveness gate for the two core-path file locks (audit M1+M2)
The STATE.md write lock (acquireStateLock) and the .planning/ workspace lock (withPlanningLock) stole contended locks on mtime age alone with no process.kill(pid,0) liveness check, and mis-ordered stale-vs-wait so a live-but-slow holder could be robbed mid-critical-section. M1 (lost update / STATE.md corruption): a live writer whose critical section ran past the stale threshold aged out and a waiter unlinked its lock and acquired -> two writers in STATE.md's read-modify-write window. mtime is a leaky proxy for "holder is alive"; it leaks under exactly the slow-holder condition the lock guards against. M2 (uncaught EEXIST): withPlanningLock's timeout fallback unconditionally unlinked whatever lock existed (even a live holder's) and re-acquired OUTSIDE any try -- a concurrent re-create raced a raw EEXIST out of the helper. Fix backports capability-lock.cts's liveness gate (process.kill(pid,0) via a _setLockProbes/_resetLockProbes test seam): - acquireStateLock: steal when holder pid is DEAD (any age) OR age exceeds a deadman ceiling (60000ms, ABOVE maxWaitMs=30000) so a verified-live holder is never stolen within budget; garbage/legacy bodies stay recoverable. - withPlanningLock: same gate in the EEXIST path (dead stolen promptly, live waited on); removed the unconditional force-steal -> clear timeout throw, which also closes M2 (no re-acquire outside try). Uncontended path unchanged byte-for-behaviour; realClock + real process.kill remain the defaults. Pid-reuse residual fails safe (waits/times out, never corrupts) and recovers at the deadman ceiling. Tests: TDD red->green via the clock + new pid-liveness probe seams (no wall-clock; #453 deleted the race tests). 8 new behavioural tests across tests/clock-seam.test.cjs and tests/planning-workspace.test.cjs; the prior withPlanningLock timeout test rewritten to pin the no-force-steal contract. Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz |