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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user