From e837bfc1de0b505d0117ede7438768678b4c27e1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 12 Jun 2026 15:54:28 -0400 Subject: [PATCH] fix(#1101): record-session updates ## Session Continuity in place, no duplicate block (#1113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported symptom (recorded:false yet STATE.md frontmatter still mutated) was already resolved on next by #944/#948. This fixes the residual: the DWIM auto-create recognised only the canonical `## Session` heading, so a bootstrap `## Session Continuity` section fell through to the append branch and produced a second `## Session` block. Insert only the missing canonical fields after the `## Session Continuity` heading — preserving the heading and any prose (e.g. "Next recommended action") — and teach the snapshot / frontmatter readers to recognise that heading (the optional ` Continuity` group still excludes `## Session Continuity Archive`, preserving #2444 scoping). Closes #1101 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- ...cord-session-session-continuity-heading.md | 5 + src/state.cts | 62 +++++- tests/bug-948-state-noop-write-guard.test.cjs | 200 ++++++++++++++++++ 3 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 .changeset/1101-record-session-session-continuity-heading.md diff --git a/.changeset/1101-record-session-session-continuity-heading.md b/.changeset/1101-record-session-session-continuity-heading.md new file mode 100644 index 000000000..28e478f9c --- /dev/null +++ b/.changeset/1101-record-session-session-continuity-heading.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1113 +--- +**`state record-session` updates an existing `## Session Continuity` section in place instead of appending a duplicate `## Session` block** — on a freshly bootstrapped project (workstream / gsd2-import / new-project templates all emit `## Session Continuity`), the auto-create path recognised only the normalized `## Session` heading, so it appended a second session block. It now inserts only the missing canonical fields after the `## Session Continuity` heading, preserving the heading and any existing prose, and the snapshot / frontmatter readers recognise that heading. (The originally reported `recorded:false`-yet-mutated symptom was already resolved by #944/#948.) (#1101) diff --git a/src/state.cts b/src/state.cts index b7edac8c3..113b88792 100644 --- a/src/state.cts +++ b/src/state.cts @@ -773,7 +773,9 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions, // newly-written Stopped at / Resume file end up in the second (invisible) block. // Fix: when a `## Session` heading already exists, normalize THAT block in place // (insert / replace canonical bold-label lines within the existing section). - // Only append a brand-new section when NO `## Session` heading exists at all. + // A `## Session Continuity` heading (bootstrap shape) is handled additively — + // missing canonical fields are inserted while the heading and any prose are + // preserved (#1101). Only append a brand-new section when NEITHER heading exists. const callerSuppliedValues = !!(options.stopped_at || (options.resume_file !== undefined && options.resume_file !== null)); const needsStoppedAt = options.stopped_at && !updated.includes('Stopped At'); const needsResumeFile = options.resume_file !== undefined && options.resume_file !== null && !updated.includes('Resume File'); @@ -785,10 +787,15 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions, : 'None'; const stoppedAtValue = options.stopped_at || 'None'; - // Determine whether a ## Session heading already exists in the body. - const existingSessionHeading = /^## Session\s*$/im.test(content); + // Determine whether a session heading already exists in the body. The + // canonical normalized form is `## Session`; the bootstrap templates + // (workstream.cts, gsd2-import.cts, templates/state.md) instead emit + // `## Session Continuity`. Treat each separately so we never append a + // duplicate section alongside an existing one. + const existingCanonicalSession = /^## Session[ \t]*$/im.test(content); + const existingSessionContinuity = /^## Session Continuity[ \t]*$/im.test(content); - if (existingSessionHeading) { + if (existingCanonicalSession) { // Normalize in place: replace the ENTIRE BODY of the existing ## Session // section (heading + all content up to the next ## heading or EOF) with // canonical bold-label lines. The negative-lookahead per-line pattern @@ -807,8 +814,30 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions, '', ].join('\n'), ); + } else if (existingSessionContinuity) { + // #1101: a `## Session Continuity` section already exists (bootstrap + // shape). Previously this fell through to the append branch and created + // a SECOND `## Session` block — a duplicate. Instead, insert only the + // canonical fields that are still missing, right after the heading, + // preserving the `## Session Continuity` heading and ALL existing lines + // (e.g. prose like "Next recommended action"). Fields already updated in + // place above (needs* false) are not re-inserted. A function replacement + // is used so `$`-bearing caller values are inserted literally (#3454). + const linesToInsert: string[] = []; + if (needsLastSession) linesToInsert.push(`**Last session:** ${now}`); + if (needsStoppedAt) linesToInsert.push(`**Stopped at:** ${stoppedAtValue}`); + if (needsResumeFile) linesToInsert.push(`**Resume file:** ${resumeValue}`); + if (linesToInsert.length > 0) { + // Case-insensitive to match the `existingSessionContinuity` detection + // above (#1101 review F3) — otherwise a lowercase heading would detect + // but no-op the insert while still reporting the fields as updated. + content = content.replace( + /^(## Session Continuity[ \t]*\n)/im, + (_m, heading: string) => heading + linesToInsert.join('\n') + '\n', + ); + } } else { - // No ## Session heading exists at all — append a new canonical section. + // No session heading exists at all — append a new canonical section. const scaffold = [ '', '## Session', @@ -840,6 +869,21 @@ function cmdStateRecordSession(cwd: string, options: StateRecordSessionOptions, } } +/** + * Match the session section body from a STATE.md body. #1101: recognise the + * bootstrap `## Session Continuity` heading but PREFER the normalized `## Session` + * block when both exist (legacy duplicate files), so the reader agrees with the + * writer (which updates `## Session` first). `(?:^|\n)` line-anchors (kept out of + * `/m` so `$` stays end-of-string for the `(?=\n##|$)` section boundary), which + * excludes an h3 `### Session Continuity`; the trailing-` Archive` boundary still + * excludes `## Session Continuity Archive` (preserving the #2444 scoping). + * Returns the match whose group 1 is the section body, or null. + */ +function matchSessionSection(body: string): RegExpMatchArray | null { + return body.match(/(?:^|\n)##[ \t]*Session[ \t]*\n([\s\S]*?)(?=\n##|$)/i) + || body.match(/(?:^|\n)##[ \t]*Session Continuity[ \t]*\n([\s\S]*?)(?=\n##|$)/i); +} + function cmdStateSnapshot(cwd: string, raw: boolean): void { const statePath = planningPaths(cwd).state; @@ -922,7 +966,9 @@ function cmdStateSnapshot(cwd: string, raw: boolean): void { resume_file: null, }; - const sessionMatch = body.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + // #1101: prefer the canonical `## Session` block, falling back to the bootstrap + // `## Session Continuity` heading. See matchSessionSection for the anchoring. + const sessionMatch = matchSessionSection(body); if (sessionMatch) { const sessionSection = sessionMatch[1]; // Accept both `**Last Date:**` (canonical template form) and `**Last session:**` @@ -980,7 +1026,9 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re // historical "Stopped at:" prose elsewhere in the body (e.g. in a // Session Continuity Archive section) never overwrites the current value. // Fall back to full-body search only when no ## Session section exists. - const sessionSectionMatch = bodyContent.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + // #1101: prefer the canonical `## Session` block, falling back to the bootstrap + // `## Session Continuity` heading. See matchSessionSection for the anchoring. + const sessionSectionMatch = matchSessionSection(bodyContent); const sessionBodyScope = sessionSectionMatch ? sessionSectionMatch[1] : bodyContent; const stoppedAt = stateExtractField(sessionBodyScope, 'Stopped At') || stateExtractField(sessionBodyScope, 'Stopped at'); const pausedAt = stateExtractField(bodyContent, 'Paused At'); diff --git a/tests/bug-948-state-noop-write-guard.test.cjs b/tests/bug-948-state-noop-write-guard.test.cjs index c2c4ae246..a73ab2d02 100644 --- a/tests/bug-948-state-noop-write-guard.test.cjs +++ b/tests/bug-948-state-noop-write-guard.test.cjs @@ -696,3 +696,203 @@ describe('#944 adversarial: existing ## Session heading must be updated in place ); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Bug #1101: record-session on a `## Session Continuity` bootstrap section must +// update IN PLACE, not append a duplicate `## Session` block. +// +// The reported symptom (recorded:false + frontmatter still mutated) is already +// fixed by #944/#948. The residual: the DWIM auto-create recognised only the +// canonical `## Session` heading, so a bootstrap `## Session Continuity` section +// (workstream.cts, gsd2-import.cts, templates/state.md) fell through to the +// append branch and produced a SECOND `## Session` block. The fix inserts the +// missing canonical fields into the existing `## Session Continuity` section, +// preserving the heading and any prose, and teaches the snapshot / frontmatter +// readers to recognise that heading. +// ───────────────────────────────────────────────────────────────────────────── + +describe('#1101: record-session updates ## Session Continuity in place (no duplicate block)', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + /** Workstream bootstrap shape: bold Stopped At/Resume File, no Last session. */ + function buildWorkstreamContinuity() { + return [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'milestone_name: Foundation', + 'status: executing', + 'last_updated: 2026-01-01T00:00:00.000Z', + '---', + '', + '# State: example', + '', + '## Session Continuity', + '**Stopped At:** N/A', + '**Resume File:** None', + '', + ].join('\n'); + } + + test('workstream Session Continuity is updated in place — no duplicate ## Session appended', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildWorkstreamContinuity()); + + const PINNED_MS = Date.parse('2026-06-12T12:00:00.000Z'); + const result = runGsdTools( + 'state record-session --stopped-at "Phase 1 context gathered" --resume-file ".planning/phases/01/01-CONTEXT.md"', + tmpDir, + { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) }, + ); + assert.ok(result.success, `record-session should exit 0: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.recorded, true, 'recorded must be true when values are supplied'); + + const after = fs.readFileSync(statePath, 'utf-8'); + // No duplicate bare `## Session` heading appended (the only heading stays + // `## Session Continuity`). + assert.ok(!/^## Session[ \t]*$/m.test(after), + `must not append a duplicate bare "## Session" block; got:\n${after}`); + assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1, + `exactly one Session-family heading must remain; got:\n${after}`); + // Missing canonical field inserted; supplied values present. + assert.ok(after.includes('**Last session:**'), 'Last session field must be inserted'); + assert.ok(after.includes('Phase 1 context gathered'), '--stopped-at value must be present'); + assert.ok(after.includes('.planning/phases/01/01-CONTEXT.md'), '--resume-file value must be present'); + // The frontmatter reader recognises `## Session Continuity` and derives stopped_at. + const fm = parseFrontmatter(after); + assert.strictEqual(fm.stopped_at, 'Phase 1 context gathered', + 'frontmatter stopped_at must be derived from the ## Session Continuity section'); + // The cmdStateSnapshot reader (separate code path) must also resolve it. + const snap = runGsdTools('state-snapshot', tmpDir); + assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`); + const snapshot = JSON.parse(snap.output); + assert.strictEqual(snapshot.session.stopped_at, 'Phase 1 context gathered', + 'state-snapshot must read stopped_at from the ## Session Continuity section'); + }); + + test('prose under ## Session Continuity is preserved (no data loss)', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const withProse = [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'milestone_name: Foundation', + 'status: executing', + 'last_updated: 2026-01-01T00:00:00.000Z', + '---', + '', + '# State: example', + '', + '## Session Continuity', + '', + '**Next recommended action:** keep-me-intact', + '', + ].join('\n'); + fs.writeFileSync(statePath, withProse); + + const PINNED_MS = Date.parse('2026-06-12T13:00:00.000Z'); + const result = runGsdTools( + 'state record-session --stopped-at "Phase 2 done" --resume-file "none.md"', + tmpDir, + { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) }, + ); + assert.ok(result.success, `record-session should exit 0: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.recorded, true, 'recorded must be true'); + + const after = fs.readFileSync(statePath, 'utf-8'); + assert.ok(after.includes('**Next recommended action:** keep-me-intact'), + `existing prose must be preserved (no data loss); got:\n${after}`); + assert.ok(!/^## Session[ \t]*$/m.test(after), + 'must not append a duplicate bare "## Session" block'); + assert.ok(after.includes('Phase 2 done'), '--stopped-at value must be present'); + const fm = parseFrontmatter(after); + assert.strictEqual(fm.stopped_at, 'Phase 2 done', + 'frontmatter stopped_at must be derived from the ## Session Continuity section'); + }); + + test('canonical ## Session block path is unchanged (no regression)', () => { + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, buildStateMdWithCanonicalSessionSection()); + + const PINNED_MS = Date.parse('2026-06-12T14:00:00.000Z'); + const result = runGsdTools( + 'state record-session --stopped-at "Phase 9, Plan 9" --resume-file "r.md"', + tmpDir, + { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) }, + ); + assert.ok(result.success, `record-session should exit 0: ${result.error}`); + const after = fs.readFileSync(statePath, 'utf-8'); + assert.strictEqual((after.match(/^## Session\b/gm) || []).length, 1, + 'canonical ## Session block must remain single'); + assert.ok(after.includes('Phase 9, Plan 9'), 'stopped-at value updated in canonical block'); + }); + + test('legacy duplicate file: reader PREFERS canonical ## Session over ## Session Continuity (F1)', () => { + // A file created by the OLD bug: a stale `## Session Continuity` first, then an + // appended fresh `## Session`. The snapshot reader must read the canonical + // `## Session` (fresh), matching the writer, not the stale Continuity block. + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const duplicate = [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'milestone_name: Foundation', + 'status: executing', + 'last_updated: 2026-01-01T00:00:00.000Z', + '---', + '', + '# State: example', + '', + '## Session Continuity', + '**Stopped At:** STALE-continuity-value', + '**Resume File:** None', + '', + '## Session', + '', + '**Last session:** 2026-06-12T10:00:00.000Z', + '**Stopped at:** FRESH-canonical-value', + '**Resume file:** r.md', + '', + ].join('\n'); + fs.writeFileSync(statePath, duplicate); + + const snap = runGsdTools('state-snapshot', tmpDir); + assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`); + const snapshot = JSON.parse(snap.output); + assert.strictEqual(snapshot.session.stopped_at, 'FRESH-canonical-value', + 'reader must prefer the canonical ## Session block over the stale ## Session Continuity'); + }); + + test('h3 ### Session Continuity is NOT read as the session section (F4)', () => { + // The reader is line-anchored to `^## `, so an h3 subsection must not be picked + // up as the session section. + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + const h3Only = [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'milestone_name: Foundation', + 'status: executing', + 'last_updated: 2026-01-01T00:00:00.000Z', + '---', + '', + '# State: example', + '', + '### Session Continuity', + '**Last session:** 2026-06-12T10:00:00.000Z', + '**Stopped at:** h3-should-not-be-session', + '', + ].join('\n'); + fs.writeFileSync(statePath, h3Only); + + const snap = runGsdTools('state-snapshot', tmpDir); + assert.ok(snap.success, `state-snapshot should exit 0: ${snap.error}`); + const snapshot = JSON.parse(snap.output); + assert.strictEqual(snapshot.session.last_date, null, + 'an h3 ### Session Continuity must not be treated as the ## Session section'); + }); +});