* test(#948): add regression tests for no-op write guard and record-session auto-create (#944)
Red before fix: 11/15 tests fail. Green after: 15/15.
Covers zero-match patch byte-identity, milestone_name preservation,
stopped_at frontmatter-wins, record-session auto-create fallback, and
adversarial fixtures (CRLF, empty body, non-canonical labels).
Also registers bug-948-state-noop-write-guard.test.cjs in the state
bucket of lint-test-file-count.allowlist.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#948): guard STATE.md no-op writes; preserve milestone_name/stopped_at (closes#944)
Shared root cause: `readModifyWriteStateMd` wrote STATE.md unconditionally
even when the transform produced no change, and `syncStateFrontmatter`
re-derived frontmatter from the possibly-stale body on every write.
Three coordinated fixes in src/state.cts:
1. readModifyWriteStateMd: add no-op guard — when transform result ===
input content, skip the write entirely (no platformWriteSync, no
last_updated bump, no frontmatter re-derive). Fixes#948 zero-match
phantom write and the #944 phantom last_updated bump.
2. syncStateFrontmatter: extend existing-frontmatter preserve logic —
fall back to existingFm['milestone_name'] / existingFm['milestone']
when the derived value is the template placeholder 'milestone'
(getMilestoneInfo returns this literal when it cannot match the
version in ROADMAP.md); prefer existingFm['stopped_at'] /
existingFm['paused_at'] over a body-derived value (the frontmatter
value, written by the canonical record-session path, wins over stale
historical body lines). Mirrors the fallback already in cmdStateJson.
3. cmdStateRecordSession: when --stopped-at / --resume-file are supplied
but body labels are absent, DWIM auto-create a canonical ## Session
section (mirroring how add-decision / add-blocker / record-metric
auto-create their sections). Never return a silent recorded:false when
the caller supplied values.
SDK check: no sdk/src/state.ts exists in this repo (the comment in
cmdStateSnapshot references a sibling concern in the TypeScript SDK
codebase, which is a separate repo not present here).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: add changeset for PR #952 (fix #948/#944)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#948): correct stopped_at preserve rule; adjust test for sync behaviour
The "always prefer frontmatter stopped_at" rule in syncStateFrontmatter
was too aggressive — it broke phase.complete which intentionally updates
stopped_at in the body and expects syncStateFrontmatter to pick it up.
The primary fix (no-op guard in readModifyWriteStateMd) already prevents
the stale-body-overwrites-frontmatter scenario from #948: the file is not
written when the transform produces no change, so syncStateFrontmatter
never runs on a zero-match patch. The body-derived value can only win when
an actual write occurs, which means the body was legitimately updated.
Reverted to the original #905 rule for stopped_at/paused_at: fall back to
existing frontmatter only when the derived value is absent (empty/null).
Also adjusted the sync-suite test to assert what state sync actually does
(milestone_name preservation) rather than a stopped_at-wins property that
state sync does not have by design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#944): update existing session block in place (adversarial review)
HIGH finding: the DWIM auto-create in cmdStateRecordSession was appending
a second ## Session block unconditionally, even when one already existed
with non-canonical content (e.g. a markdown table). Both
buildStateFrontmatter and cmdStateSnapshot read only the FIRST ## Session
block via regex, so the newly-written Stopped at / Resume file values
landed in the second, invisible block — frontmatter stopped_at stayed
stale and state-snapshot returned nulls.
Fix: check for an existing ## Session heading. When one is present,
normalize that section in place by replacing its body with canonical
**Last session:** / **Stopped at:** / **Resume file:** bold-label lines.
Only append a brand-new section when NO ## Session heading exists.
LOW finding: the auto-create scaffold emits **Last session:** but
cmdStateSnapshot only matched **Last Date:**, so session.last_date was
null after auto-create despite a valid timestamp being written.
Fix: extend the lastDateMatch regex in cmdStateSnapshot to also accept
**Last session:** / Last session: (the form the scaffold writes).
Tests: 3 new tests added to bug-948-state-noop-write-guard.test.cjs that
confirmed failure against the previous HEAD and pass after this fix:
- exactly one ## Session block after record-session with non-canonical existing block
- state-snapshot sees correct stopped_at via first Session block (not a duplicate)
- state-snapshot session.last_date is non-null after auto-create on body-less file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#944): improve in-place section replace to cleanly remove old body content
The previous regex `/(^## Session[ \t]*$)([\s\S]*?)(?=\n^## |\n*$)/im`
with a lazy match consumed nothing after the heading, so old non-canonical
body content (e.g. table rows) remained after the new canonical lines.
While functionally correct (parsers found the canonical lines first in the
FIRST ## Session block), it left stale content in the section. Replace with
a negative-lookahead per-line pattern that consumes all content from the
heading up to (but not including) the next ## heading, producing a clean
section with only the canonical bold-label lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>