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>
This commit is contained in:
Tom Boucher
2026-08-01 00:02:41 -04:00
committed by GitHub
parent 9ac0dfad58
commit 73418c516f
3 changed files with 268 additions and 3 deletions

View File

@@ -1307,6 +1307,31 @@ function matchSessionSection(body: string): string | null {
return section ? section.body : null;
}
/**
* Match the "Current Position" section body from a STATE.md body. #2956: this
* is the Phase analogue of matchSessionSection. `Phase` canonically lives under
* `## Current Position` (gsd-core/templates/state.md), so — like Stopped At /
* Paused At under `## Session` — it must be extracted from THAT section, not
* from the first `Phase:` / `**Phase:**` line anywhere in the body. Without the
* scope, a historical `Phase:` line in an archive section silently overwrites
* `current_phase` on every write, and because `current_phase` is routing input
* for gsd-progress / --next the rewind routes work to the wrong phase.
*
* Level-flexible: the canonical template uses an h2 `## Current Position`, the
* bootstrap template an h3 `### Current Position` (templates/state.md). Both
* must match — mirroring how matchSessionSection recognises `## Session` and
* `## Session Continuity`. Exact 'current position' text match (case-insensitive)
* excludes unrelated headings. Built on the same `collectSection` seam as
* matchSessionSection, so it inherits that seam's CRLF tolerance (#2444 fix).
* Returns the section body, or null (caller falls back to full-body search).
*/
function matchCurrentPositionSection(body: string): string | null {
const isCurrentPosition = (h: HeadingToken): boolean =>
(h.level === 2 || h.level === 3) && h.text.trim().toLowerCase() === 'current position';
const section = collectSection(body, isCurrentPosition, { levelBounded: true });
return section ? section.body : null;
}
/**
* #2567: prevent a stale archive "Last activity:" line from overwriting a
* newer frontmatter value. `stateExtractField` matches the first body
@@ -1392,7 +1417,14 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void {
};
// Extract basic fields — frontmatter keys take precedence over body
const prosePhase = parseProsePhaseField(stateExtractField(body, 'Phase'));
// #2956: scope `Phase` extraction to ## Current Position so a historical
// Phase: / **Phase:** line in an archive section cannot overwrite the current
// value. Phase canonically lives in ## Current Position (templates/state.md),
// so it is scopeable exactly like Stopped At under ## Session. Fall back to
// full-body search only when no ## Current Position section exists, so files
// with no section heading keep their current behaviour.
const currentPositionScope = matchCurrentPositionSection(body) ?? body;
const prosePhase = parseProsePhaseField(stateExtractField(currentPositionScope, 'Phase'));
const currentPhase = fmScalar('current_phase') ?? stateExtractField(body, 'Current Phase') ?? prosePhase.phase;
const currentPhaseName = fmScalar('current_phase_name') ?? stateExtractField(body, 'Current Phase Name') ?? prosePhase.name;
const totalPhasesRaw = fmScalar('total_phases') ?? stateExtractField(body, 'Total Phases');
@@ -1404,7 +1436,12 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void {
const proseLastActivity = parseProseLastActivityField(rawLastActivity);
const lastActivity = fmScalar('last_activity') ?? proseLastActivity.date ?? rawLastActivity;
const lastActivityDesc = fmScalar('last_activity_desc') ?? stateExtractField(body, 'Last Activity Description') ?? proseLastActivity.description;
const pausedAt = fmScalar('paused_at') ?? stateExtractField(body, 'Paused At');
// #2956: Paused At canonically lives in ## Session (see the comment above
// preferNewerLastActivity and the write seam in buildStateFrontmatter). The
// write seam already scopes it to ## Session; this read seam must agree, so a
// stale "Paused At:" in a Session Continuity Archive cannot win here either.
const sessionScope = matchSessionSection(body) ?? body;
const pausedAt = fmScalar('paused_at') ?? stateExtractField(sessionScope, 'Paused At');
// Parse numeric fields
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
@@ -1552,7 +1589,14 @@ function extractRetiredPhaseNumbers(scope: string): Set<string> {
* reliably via `state json` instead of fragile regex parsing.
*/
function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Record<string, unknown> {
const prosePhase = parseProsePhaseField(stateExtractField(bodyContent, 'Phase'));
// #2956: scope `Phase` extraction to ## Current Position (mirrors the read
// path in cmdStateSnapshot and the Stopped At / Paused At ## Session scoping
// below). Phase canonically lives in ## Current Position (templates/state.md);
// without the scope, a historical Phase: / **Phase:** line in an archive
// section overwrites current_phase here, and the next read surfaces it. Fall
// back to full-body search when no ## Current Position section exists.
const currentPositionScope = matchCurrentPositionSection(bodyContent) ?? bodyContent;
const prosePhase = parseProsePhaseField(stateExtractField(currentPositionScope, 'Phase'));
const currentPhase = stateExtractField(bodyContent, 'Current Phase') ?? prosePhase.phase;
const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name') ?? prosePhase.name;
const currentPlan = stateExtractField(bodyContent, 'Current Plan');