refactor(#1398): migrate state.cts section-collects onto markdown-sectionizer seam, byte-identical STATE.md (epic #1372 T6) (#1399)

* refactor(#1398): migrate state.cts section-collect regexes onto markdown-sectionizer seam (epic #1372 T6)

Replace ≈18 hand-rolled `/(heading)([\s\S]*?)(?=stop)/` regex splices in
state.cts with direct `tokenizeHeadings` calls that compute the exact
[bodyStart, stopOffset) span, preserving byte-identical STATE.md output.

Non-migratable site left in place: `cmdStateRecordMetric`'s metricsPattern
captures table-header rows in group 1 — not a standard heading+body shape.
Write orchestration (readModifyWriteStateMd / syncStateFrontmatter /
shouldPreserveExistingProgress / #952 no-op guard) is UNTOUCHED.

Verification: t6-headtohead.cjs head-to-head harness runs 25 ops across 6
STATE.md fixture variants (inline, trailing-blanks, CRLF, no-frontmatter,
nested-acc, post-milestone) against origin/next and reports 0 diffs.
No-op guard confirmed: record-session on recorded:false leaves STATE.md
byte-identical. All 150 state tests and 62 milestone/forensics tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#1398): capture T6 state-section-splice characterization; drop throwaway harness

Remove scripts/t6-headtohead.cjs (committed throwaway HEAD-vs-origin/next
byte-compare harness). Capture its coverage as 23 behavioral characterization
tests appended to tests/state.test.cjs, exercising all migrated cmdState*
write-ops across 7 fixture variants (inline, trailing-blanks, CRLF,
no-frontmatter, nested-acc, no-current-pos, post-milestone). Includes the
#952 no-op guard (recorded:false + byte-unchanged assertion) and
CRLF/trailing-blanks edge-case coverage. Also removes the dead
spliceStateSection helper (defined but never called) that was surfacing as
an @typescript-eslint/no-unused-vars warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 17:56:17 -04:00
committed by GitHub
parent 308c7be481
commit ce8bcb1b95
2 changed files with 1111 additions and 182 deletions

View File

@@ -41,6 +41,7 @@ import {
KNOWN_STATUS_PATTERNS,
stateReplaceFieldIfTemplate,
} from './state-document.cjs';
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -154,6 +155,16 @@ process.on('exit', () => {
// Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match).
const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i;
// ─── ADR-1372 T6: seam-based section splice helper ───────────────────────────
// Shared stop predicates corresponding to the regex lookaheads used in state.cts:
// STOP_H2_PLUS : (?=\n##|$) — stops at any heading with level ≥ 2
// STOP_H2_H3 : (?=\n###?|\n##[^#]|$) — stops at level 2 or 3
// STOP_H2_ONLY : (?=\n##[^#]|$) — stops at level 2 only
const STOP_H2_PLUS = (lv: number): boolean => lv >= 2;
const STOP_H2_H3 = (lv: number): boolean => lv === 2 || lv === 3;
const STOP_H2_ONLY = (lv: number): boolean => lv === 2;
function cmdStateLoad(cwd: string, raw: boolean): void {
const config = loadConfig(cwd);
const planDir = planningPaths(cwd).planning;
@@ -368,11 +379,26 @@ function stateReplaceFieldWithFallback(content: string, primary: string, fallbac
* Fixes #1365: advance-plan could not update Status/Last activity after begin-phase.
*/
function updateCurrentPositionFields(content: string, fields: { status?: string; lastActivity?: string; plan?: string }): string {
const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const posMatch = content.match(posPattern);
if (!posMatch) return content;
// ADR-1372 T6: locate ## Current Position using tokenizeHeadings, extract the
// untrimmed body span, apply field edits, then splice the modified body back in.
// Stop predicate mirrors (?=\n##|$): any heading with level ≥ 2.
const headings = tokenizeHeadings(content);
const posIdx = headings.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
if (posIdx === -1) return content;
let posBody = posMatch[2];
const posHeading = headings[posIdx];
const lines = content.split('\n');
const posHeadingLine = lines[posHeading.line - 1];
const posBodyStart = posHeading.offset + posHeadingLine.length + 1;
let posBodyEnd = content.length;
for (let j = posIdx + 1; j < headings.length; j++) {
if (STOP_H2_PLUS(headings[j].level)) {
posBodyEnd = headings[j].offset - 1;
break;
}
}
let posBody = content.slice(posBodyStart, posBodyEnd);
const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status'];
const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity'];
@@ -442,7 +468,8 @@ function updateCurrentPositionFields(content: string, fields: { status?: string;
}
}
return content.replace(posPattern, () => `${posMatch[1]}${posBody}`);
// Splice the modified body back in place of the original untrimmed span.
return content.slice(0, posBodyStart) + posBody + content.slice(posBodyEnd);
}
function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
@@ -656,17 +683,32 @@ function cmdStateAddDecision(cwd: string, options: StateAddDecisionOptions, raw:
let created = false;
readModifyWriteStateMd(statePath, (content) => {
// Find Decisions section (various heading patterns)
const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
// ADR-1372 T6: find Decisions section via tokenizeHeadings; stop at level 2 or 3.
// Mirrors /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
const decisionsPred = (lv: number, text: string): boolean =>
(lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text);
const sectionBody = (() => {
const hs = tokenizeHeadings(content);
const i = hs.findIndex(h => decisionsPred(h.level, h.text));
if (i === -1) return null;
const h = hs[i];
const ls = content.split('\n');
const hl = ls[h.line - 1];
const bs = h.offset + hl.length + 1;
let se = content.length;
for (let j = i + 1; j < hs.length; j++) {
if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; }
}
return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) };
})();
if (match) {
let sectionBody = match[2];
if (sectionBody !== null) {
let newBody = sectionBody.body;
// Remove placeholders
sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
newBody = newBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
newBody = newBody.trimEnd() + '\n' + entry + '\n';
_added = true;
return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
return content.slice(0, sectionBody.bodyStart) + newBody + content.slice(sectionBody.bodyEnd);
}
// Section absent — DWIM: auto-create canonical ## Decisions scaffold,
@@ -709,15 +751,31 @@ function cmdStateAddBlocker(cwd: string, text: string | StateAddBlockerOptions,
let created = false;
readModifyWriteStateMd(statePath, (content) => {
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
// ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
// Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
const blockersPred = (lv: number, text: string): boolean =>
(lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(text);
const sectionSpan = (() => {
const hs = tokenizeHeadings(content);
const i = hs.findIndex(h => blockersPred(h.level, h.text));
if (i === -1) return null;
const h = hs[i];
const ls = content.split('\n');
const hl = ls[h.line - 1];
const bs = h.offset + hl.length + 1;
let se = content.length;
for (let j = i + 1; j < hs.length; j++) {
if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; }
}
return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) };
})();
if (match) {
let sectionBody = match[2];
if (sectionSpan !== null) {
let sectionBody = sectionSpan.body;
sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
_added = true;
return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`);
return content.slice(0, sectionSpan.bodyStart) + sectionBody + content.slice(sectionSpan.bodyEnd);
}
// Section absent — DWIM: auto-create canonical ### Blockers scaffold.
@@ -777,20 +835,45 @@ function cmdStateAddRoadmapEvolution(cwd: string, options: StateAddRoadmapEvolut
// Section boundaries mirror the sibling handlers (add-decision/add-blocker):
// a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed,
// so following sections are preserved without data loss (see the CRLF test).
//
// ADR-1372 T6: accPattern and subPattern migrated to tokenizeHeadings.
// accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i
// → stop at level 2 only (STOP_H2_ONLY)
// subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i
// → applied to accBody; stop at level 2 or 3 (STOP_H2_H3)
readModifyWriteStateMd(statePath, (content) => {
const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i;
const accMatch = content.match(accPattern);
// Locate ## Accumulated Context and extract its untrimmed body span.
const accHs = tokenizeHeadings(content);
const accIdx = accHs.findIndex(h => h.level === 2 && /^accumulated\s+context$/i.test(h.text));
if (accIdx !== -1) {
const accH = accHs[accIdx];
const contentLines = content.split('\n');
const accHL = contentLines[accH.line - 1];
const accBodyStart = accH.offset + accHL.length + 1;
let accBodyEnd = content.length;
for (let j = accIdx + 1; j < accHs.length; j++) {
if (STOP_H2_ONLY(accHs[j].level)) { accBodyEnd = accHs[j].offset - 1; break; }
}
const accBody = content.slice(accBodyStart, accBodyEnd);
if (accMatch) {
const accHeader = accMatch[1];
const accBody = accMatch[2];
// Find `### Roadmap Evolution` WITHIN the Accumulated Context body only.
// Bounded by the next h3/h2 or the end of the section body.
const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i;
const subMatch = accBody.match(subPattern);
// tokenizeHeadings is applied to accBody to scope the search.
// Stop predicate mirrors (?=\n###?|$): level 2 or 3.
const subHs = tokenizeHeadings(accBody);
const subIdx = subHs.findIndex(h => h.level === 3 && /^roadmap\s+evolution$/i.test(h.text));
if (subIdx !== -1) {
const subH = subHs[subIdx];
const accLines = accBody.split('\n');
const subHL = accLines[subH.line - 1];
const subBodyStart = subH.offset + subHL.length + 1;
let subBodyEnd = accBody.length;
for (let j = subIdx + 1; j < subHs.length; j++) {
if (STOP_H2_H3(subHs[j].level)) { subBodyEnd = subHs[j].offset - 1; break; }
}
let subBody = accBody.slice(subBodyStart, subBodyEnd);
if (subMatch) {
let subBody = subMatch[2];
// Dedupe: exact (trimmed) line already present is a no-op replay.
if (subBody.split('\n').some((line) => line.trim() === entry.trim())) {
duplicate = true;
@@ -798,15 +881,16 @@ function cmdStateAddRoadmapEvolution(cwd: string, options: StateAddRoadmapEvolut
}
subBody = subBody.replace(/None yet\.?\s*\n?/gi, '');
subBody = subBody.trimEnd() + '\n' + entry + '\n';
const newAccBody = accBody.replace(subPattern, (_m, header: string) => `${header}${subBody}`);
return content.replace(accPattern, () => `${accHeader}${newAccBody}`);
// Splice subBody into accBody, then splice newAccBody into content.
const newAccBody = accBody.slice(0, subBodyStart) + subBody + accBody.slice(subBodyEnd);
return content.slice(0, accBodyStart) + newAccBody + content.slice(accBodyEnd);
}
// Subsection missing — append it at the end of the Accumulated Context body.
subsectionCreated = true;
const trimmedAcc = accBody.trimEnd();
const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`;
return content.replace(accPattern, () => `${accHeader}${block}`);
return content.slice(0, accBodyStart) + block + content.slice(accBodyEnd);
}
// No `## Accumulated Context` — DWIM: create both at end of file.
@@ -843,27 +927,35 @@ function cmdStateResolveBlocker(cwd: string, text: string, raw: boolean): void {
let resolved = false;
readModifyWriteStateMd(statePath, (content) => {
const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const match = content.match(sectionPattern);
// ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3.
// Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i
const hs = tokenizeHeadings(content);
const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text));
if (i === -1) return content;
if (match) {
const sectionBody = match[2];
const lines = sectionBody.split('\n');
const filtered = lines.filter(line => {
if (!line.startsWith('- ')) return true;
return !line.toLowerCase().includes(text.toLowerCase());
});
let newBody = filtered.join('\n');
// If section is now empty, add placeholder
if (!newBody.trim() || !newBody.includes('- ')) {
newBody = 'None\n';
}
resolved = true;
return content.replace(sectionPattern, (_match, header: string) => `${header}${newBody}`);
const h = hs[i];
const ls = content.split('\n');
const hl = ls[h.line - 1];
const bs = h.offset + hl.length + 1;
let se = content.length;
for (let j = i + 1; j < hs.length; j++) {
if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; }
}
return content;
const sectionBody = content.slice(bs, se);
const lines = sectionBody.split('\n');
const filtered = lines.filter(line => {
if (!line.startsWith('- ')) return true;
return !line.toLowerCase().includes(text.toLowerCase());
});
let newBody = filtered.join('\n');
// If section is now empty, add placeholder
if (!newBody.trim() || !newBody.includes('- ')) {
newBody = 'None\n';
}
resolved = true;
return content.slice(0, bs) + newBody + content.slice(se);
}, cwd);
if (resolved) {
@@ -1880,11 +1972,20 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
}
// Update ## Current Position section (#1104, #1365)
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = body.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
// Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
const posHs = tokenizeHeadings(body);
const posIdx = posHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
if (posIdx !== -1) {
const posH = posHs[posIdx];
const bodyLines = body.split('\n');
const posHL = bodyLines[posH.line - 1];
const posBodyStart = posH.offset + posHL.length + 1;
let posBodyEnd = body.length;
for (let j = posIdx + 1; j < posHs.length; j++) {
if (STOP_H2_PLUS(posHs[j].level)) { posBodyEnd = posHs[j].offset - 1; break; }
}
let posBody = body.slice(posBodyStart, posBodyEnd);
// Update or insert Phase line
const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`;
@@ -1934,21 +2035,29 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
if (replaced !== null) posBody = replaced;
}
body = body.replace(positionPattern, () => `${header}${posBody}`);
body = body.slice(0, posBodyStart) + posBody + body.slice(posBodyEnd);
updated.push('Current Position');
}
} else {
// Resume path: only update Last activity timestamp in Current Position
// (do not touch Plan:, stopped_at, progress.percent, or plan counter)
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = body.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
const posHsR = tokenizeHeadings(body);
const posIdxR = posHsR.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
if (posIdxR !== -1) {
const posHR = posHsR[posIdxR];
const bodyLinesR = body.split('\n');
const posHLR = bodyLinesR[posHR.line - 1];
const posBodyStartR = posHR.offset + posHLR.length + 1;
let posBodyEndR = body.length;
for (let j = posIdxR + 1; j < posHsR.length; j++) {
if (STOP_H2_PLUS(posHsR[j].level)) { posBodyEndR = posHsR[j].offset - 1; break; }
}
let posBody = body.slice(posBodyStartR, posBodyEndR);
const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity);
body = body.replace(positionPattern, () => `${header}${posBody}`);
body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
updated.push('Last activity (resume)');
} else {
// Pipe-table format in Current Position (#1255)
@@ -1956,7 +2065,7 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName
?? stateReplaceField(posBody, 'Last activity', resumeActivity);
if (replaced !== null) {
posBody = replaced;
body = body.replace(positionPattern, () => `${header}${posBody}`);
body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR);
updated.push('Last activity (resume)');
}
}
@@ -2146,15 +2255,26 @@ function cmdStateMilestoneSwitch(cwd: string, version: string | undefined, name:
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
const body = stripFrontmatter(content);
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
// ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection.
// Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2.
const resetPositionBody =
`\nPhase: Not started (defining requirements)\n` +
`Plan: —\n` +
`Status: Defining requirements\n` +
`Last activity: ${today} — Milestone ${version} started\n\n`;
let newBody: string;
if (positionPattern.test(body)) {
newBody = body.replace(positionPattern, (_m, header: string) => `${header}${resetPositionBody}`);
const msPosHs = tokenizeHeadings(body);
const msPosIdx = msPosHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
if (msPosIdx !== -1) {
const msPosH = msPosHs[msPosIdx];
const msBodyLines = body.split('\n');
const msPosHL = msBodyLines[msPosH.line - 1];
const msPosBodyStart = msPosH.offset + msPosHL.length + 1;
let msPosBodyEnd = body.length;
for (let j = msPosIdx + 1; j < msPosHs.length; j++) {
if (STOP_H2_PLUS(msPosHs[j].level)) { msPosBodyEnd = msPosHs[j].offset - 1; break; }
}
newBody = body.slice(0, msPosBodyStart) + resetPositionBody + body.slice(msPosBodyEnd);
} else {
const preface = body.trim().length > 0 ? body : '# Project State\n';
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
@@ -2434,102 +2554,109 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v
// Shared pruning logic applied to both dry-run and real passes.
// Returns { newContent, archivedSections }.
// ADR-1372 T6: all four inline section-collect regexes replaced with
// tokenizeHeadings + untrimmed-span splicing for byte-identical writes.
function prunePass(content: string): { newContent: string; archivedSections: PrunedSection[] } {
const sections: PrunedSection[] = [];
// Prune Decisions section: entries like "- [Phase N]: ..."
const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const decMatch = content.match(decisionPattern);
if (decMatch) {
const lines = decMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
}
// Helper: locate a heading matching pred, extract untrimmed body [bs, se),
// apply transform, and splice back. Returns updated content.
// All prune-section patterns stop at level 2 or 3 (STOP_H2_H3).
function pruneSectionSpan(
c: string,
pred: (lv: number, text: string) => boolean,
transform: (body: string) => { keep: string[]; archive: string[] },
sectionName: string,
): string {
const hs = tokenizeHeadings(c);
const i = hs.findIndex(h => pred(h.level, h.text));
if (i === -1) return c;
const h = hs[i];
const ls = c.split('\n');
const hl = ls[h.line - 1];
const bs = h.offset + hl.length + 1;
let se = c.length;
for (let j = i + 1; j < hs.length; j++) {
if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; }
}
const body = c.slice(bs, se);
const { keep, archive } = transform(body);
if (archive.length > 0) {
sections.push({ section: 'Decisions', count: archive.length, lines: archive });
content = content.replace(decisionPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
sections.push({ section: sectionName, count: archive.length, lines: archive });
return c.slice(0, bs) + keep.join('\n') + c.slice(se);
}
return c;
}
// Prune Recently Completed section: entries mentioning phase numbers
const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const recMatch = content.match(recentPattern);
if (recMatch) {
const lines = recMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
// Prune Decisions section: entries like "- [Phase N]: ..."
content = pruneSectionSpan(
content,
(lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); }
}
}
if (archive.length > 0) {
sections.push({ section: 'Recently Completed', count: archive.length, lines: archive });
content = content.replace(recentPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
return { keep, archive };
},
'Decisions',
);
// Prune Recently Completed section: entries mentioning phase numbers
content = pruneSectionSpan(
content,
(lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); }
}
return { keep, archive };
},
'Recently Completed',
);
// Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~
// or "[RESOLVED]" prefix) with a phase reference older than cutoff
const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const blockersMatch = content.match(blockersPattern);
if (blockersMatch) {
const lines = blockersMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of lines) {
const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) {
archive.push(line);
} else {
keep.push(line);
content = pruneSectionSpan(
content,
(lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line);
const phaseMatch = line.match(/Phase\s+(\d+)/i);
if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); }
}
}
if (archive.length > 0) {
sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive });
content = content.replace(blockersPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
return { keep, archive };
},
'Blockers (resolved)',
);
// Prune Performance Metrics table rows: keep only rows for phases > cutoff.
// Preserves header rows (| Phase | ... and |---|...) and any prose around the table.
const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
const metricsMatch = content.match(metricsPattern);
if (metricsMatch) {
const sectionLines = metricsMatch[2].split('\n');
const keep: string[] = [];
const archive: string[] = [];
for (const line of sectionLines) {
// Table data row: starts with | followed by a number (phase)
const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
if (tableRowMatch) {
const rowPhase = parseInt(tableRowMatch[1], 10);
if (rowPhase <= cutoff) {
archive.push(line);
content = pruneSectionSpan(
content,
(lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text),
(body) => {
const keep: string[] = [], archive: string[] = [];
for (const line of body.split('\n')) {
// Table data row: starts with | followed by a number (phase)
const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/);
if (tableRowMatch) {
const rowPhase = parseInt(tableRowMatch[1], 10);
if (rowPhase <= cutoff) { archive.push(line); } else { keep.push(line); }
} else {
// Header row, separator row, or prose — always keep
keep.push(line);
}
} else {
// Header row, separator row, or prose — always keep
keep.push(line);
}
}
if (archive.length > 0) {
sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive });
content = content.replace(metricsPattern, (_m, header: string) => `${header}${keep.join('\n')}`);
}
}
return { keep, archive };
},
'Performance Metrics',
);
return { newContent: content, archivedSections: sections };
}
@@ -2664,48 +2791,59 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
if (result) { body = result; updated.push('Last Activity Description'); }
// Update ## Current Position section
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
const positionMatch = body.match(positionPattern);
if (positionMatch) {
const header = positionMatch[1];
let posBody = positionMatch[2];
// ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2.
// Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i
{
const cpHs = tokenizeHeadings(body);
const cpIdx = cpHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text));
if (cpIdx !== -1) {
const cpH = cpHs[cpIdx];
const cpBodyLines = body.split('\n');
const cpHL = cpBodyLines[cpH.line - 1];
const cpBodyStart = cpH.offset + cpHL.length + 1;
let cpBodyEnd = body.length;
for (let j = cpIdx + 1; j < cpHs.length; j++) {
if (STOP_H2_PLUS(cpHs[j].level)) { cpBodyEnd = cpHs[j].offset - 1; break; }
}
let posBody = body.slice(cpBodyStart, cpBodyEnd);
// Update Phase line to show COMPLETE
const newPhase = `Phase: ${currentPhase} — COMPLETE`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
} else {
// Pipe-table format in Current Position (#1255)
// Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
const replaced = stateReplaceField(posBody, 'Phase', `${currentPhase} — COMPLETE`);
if (replaced !== null) posBody = replaced;
// Update Phase line to show COMPLETE
const newPhase = `Phase: ${currentPhase} — COMPLETE`;
if (/^Phase:/m.test(posBody)) {
posBody = posBody.replace(/^Phase:.*$/m, newPhase);
} else {
// Pipe-table format in Current Position (#1255)
// Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label.
const replaced = stateReplaceField(posBody, 'Phase', `${currentPhase} — COMPLETE`);
if (replaced !== null) posBody = replaced;
}
// Update Status line if present
const newStatus = `Status: Phase ${currentPhase} complete`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
} else {
// Pipe-table format in Current Position (#1255)
const replaced = stateReplaceField(posBody, 'Status', `Phase ${currentPhase} complete`);
if (replaced !== null) posBody = replaced;
}
// Update Last activity line if present
const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
} else {
// Pipe-table format in Current Position (#1255)
// Value must match the inline branch (date + narrative), not bare date.
const activityValue = `${today} — Phase ${currentPhase} marked complete`;
const replaced = stateReplaceField(posBody, 'Last Activity', activityValue)
?? stateReplaceField(posBody, 'Last activity', activityValue);
if (replaced !== null) posBody = replaced;
}
body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd);
updated.push('Current Position');
}
// Update Status line if present
const newStatus = `Status: Phase ${currentPhase} complete`;
if (/^Status:/m.test(posBody)) {
posBody = posBody.replace(/^Status:.*$/m, newStatus);
} else {
// Pipe-table format in Current Position (#1255)
const replaced = stateReplaceField(posBody, 'Status', `Phase ${currentPhase} complete`);
if (replaced !== null) posBody = replaced;
}
// Update Last activity line if present
const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`;
if (/^Last activity:/im.test(posBody)) {
posBody = posBody.replace(/^Last activity:.*$/im, newActivity);
} else {
// Pipe-table format in Current Position (#1255)
// Value must match the inline branch (date + narrative), not bare date.
const activityValue = `${today} — Phase ${currentPhase} marked complete`;
const replaced = stateReplaceField(posBody, 'Last Activity', activityValue)
?? stateReplaceField(posBody, 'Last activity', activityValue);
if (replaced !== null) posBody = replaced;
}
body = body.replace(positionPattern, () => `${header}${posBody}`);
updated.push('Current Position');
}
return reassemble(body);

View File

@@ -4166,3 +4166,794 @@ describe('#1257 — planned-phase and begin-phase pipe-table regressions', () =>
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// T6 section-splice characterization tests (ADR-1372 / #1398)
//
// Covers the migrated cmdState* write-ops across a matrix of fixture variants:
// inline (standard frontmatter + inline section text)
// trailing-blanks (sections with extra blank lines)
// CRLF (Windows line endings)
// no-frontmatter (bare body only)
// nested-acc (Accumulated Context with Session Notes subsection)
// no-current-pos (absent Current Position section)
// post-milestone (fresh milestone, prior progress=100%)
// ─────────────────────────────────────────────────────────────────────────────
describe('T6 section-splice characterization — record-session', () => {
// Fixtures used across these tests
const STATE_WITH_SESSION = [
'---',
"gsd_state_version: '1.0'",
'milestone: v1.0',
'milestone_name: TestMilestone',
'status: executing',
"last_updated: '2026-01-01T00:00:00.000Z'",
"last_activity: '2026-01-01'",
'---',
'',
'# Project State',
'',
'## Session',
'',
'**Last session:** 2026-01-01T00:00:00.000Z',
'**Stopped at:** None',
'**Resume file:** None',
'',
].join('\n');
const STATE_NO_SESSION_LABELS = [
'# Project State',
'',
'**Current focus:** Phase 2',
'',
'## Current Position',
'',
'Phase: 2',
'Plan: 1 of 4',
'Status: Executing Phase 2',
'Last Activity: 2026-01-01',
'',
'## Decisions Made',
'',
'- [Phase 1]: Chose PostgreSQL',
'',
].join('\n');
test('record-session no-op: no session fields → recorded:false, STATE.md byte-unchanged', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_SESSION_LABELS);
const result = runGsdTools(['state', 'record-session'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, false, 'recorded must be false when no session fields exist');
// milestone_name must NOT be trampled (#952 no-op guard)
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(after, STATE_NO_SESSION_LABELS, 'STATE.md must be byte-unchanged on no-op');
} finally {
cleanup(d);
}
});
test('record-session --stopped-at updates Stopped at field in Session section', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_SESSION);
const result = runGsdTools(['state', 'record-session', '--stopped-at', '14.3'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true, 'recorded must be true when session fields found');
assert.ok(Array.isArray(output.updated), 'updated must be an array');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('**Stopped at:** 14.3'), 'Stopped at field must be updated to 14.3');
// milestone_name must be preserved (not trampled)
assert.ok(after.includes('milestone_name: TestMilestone'), 'milestone_name must be preserved');
} finally {
cleanup(d);
}
});
test('record-session --resume-file updates Resume file field in Session section', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_SESSION);
const result = runGsdTools(['state', 'record-session', '--resume-file', 'plan-3.md'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.recorded, true, 'recorded must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('**Resume file:** plan-3.md'), 'Resume file field must be updated to plan-3.md');
} finally {
cleanup(d);
}
});
});
describe('T6 section-splice characterization — add-decision', () => {
const STATE_INLINE = [
'---',
"gsd_state_version: '1.0'",
'milestone: v1.0',
'milestone_name: TestMilestone',
'status: executing',
'---',
'',
'# Project State',
'',
'## Decisions Made',
'',
'- [Phase 1]: Use Node.js for tooling',
'',
'### Blockers',
'',
'None yet.',
'',
].join('\n');
const STATE_TRAILING_BLANKS = [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1',
'',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
'',
'## Decisions Made',
'',
'- [Phase 1]: First decision',
'',
'',
'### Blockers',
'',
'- Bug in auth service',
'',
].join('\n');
const STATE_NO_DECISIONS = [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'---',
'',
'# Project State',
'',
'### Blockers',
'',
'None.',
'',
].join('\n');
test('add-decision appends to existing Decisions Made section (inline fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE);
const result = runGsdTools(['state', 'add-decision', '--phase', '2', '--summary', 'Use Docker for builds'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- [Phase 2]: Use Docker for builds'), 'new decision entry must be present');
assert.ok(after.includes('- [Phase 1]: Use Node.js for tooling'), 'existing decision must be preserved');
} finally {
cleanup(d);
}
});
test('add-decision appends to Decisions Made section with trailing blank lines (trailing-blanks fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_TRAILING_BLANKS);
const result = runGsdTools(['state', 'add-decision', '--phase', '3', '--summary', 'Add monitoring'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- [Phase 3]: Add monitoring'), 'new decision must be present');
assert.ok(after.includes('- [Phase 1]: First decision'), 'original decision must be preserved');
} finally {
cleanup(d);
}
});
test('add-decision creates Decisions section when absent (DWIM)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_DECISIONS);
const result = runGsdTools(['state', 'add-decision', '--phase', '1', '--summary', 'Use Node.js'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- [Phase 1]: Use Node.js'), 'decision must be present even when section was absent');
} finally {
cleanup(d);
}
});
});
describe('T6 section-splice characterization — add-blocker', () => {
const STATE_INLINE_WITH_BLOCKERS = [
'---',
"gsd_state_version: '1.0'",
'status: executing',
'---',
'',
'# Project State',
'',
'## Decisions Made',
'',
'- [Phase 1]: Use Node.js for tooling',
'',
'### Blockers',
'',
'None yet.',
'',
].join('\n');
const STATE_CRLF = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Current Position\r\n\r\nStatus: Executing Phase 2\r\nLast Activity: 2026-01-01\r\n\r\n### Blockers\r\n\r\nNone.\r\n';
const STATE_NO_BLOCKERS_SECTION = [
'# Project State',
'',
'## Current Position',
'',
'Phase: 2',
'',
].join('\n');
test('add-blocker appends to existing Blockers section (inline fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_WITH_BLOCKERS);
const result = runGsdTools(['state', 'add-blocker', '--text', 'Flaky CI on Windows'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
assert.strictEqual(output.blocker, 'Flaky CI on Windows', 'blocker text must match');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- Flaky CI on Windows'), 'blocker entry must be present');
} finally {
cleanup(d);
}
});
test('add-blocker appends to existing Blockers section (CRLF fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF);
const result = runGsdTools(['state', 'add-blocker', '--text', 'NFS mount issue'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('NFS mount issue'), 'blocker entry must be present in CRLF file');
} finally {
cleanup(d);
}
});
test('add-blocker creates Blockers section when absent (DWIM)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_BLOCKERS_SECTION);
const result = runGsdTools(['state', 'add-blocker', '--text', 'Build pipeline broken'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('Build pipeline broken'), 'blocker must be present even when section was absent');
} finally {
cleanup(d);
}
});
});
describe('T6 section-splice characterization — resolve-blocker', () => {
const STATE_WITH_BLOCKERS = [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'---',
'',
'# Project State',
'',
'## Decisions Made',
'',
'- [Phase 1]: First decision',
'',
'',
'### Blockers',
'',
'- Bug in auth service',
'- Another blocker',
'',
'### Recently Completed',
'',
'- Phase 1 Plan 1',
'',
].join('\n');
test('resolve-blocker removes target blocker, preserves others (trailing-blanks fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_BLOCKERS);
const result = runGsdTools(['state', 'resolve-blocker', '--text', 'Bug in auth service'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.resolved, true, 'resolved must be true');
assert.strictEqual(output.blocker, 'Bug in auth service', 'resolved blocker text must match');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(!after.includes('- Bug in auth service'), 'resolved blocker must be removed');
assert.ok(after.includes('Another blocker'), 'unrelated blocker must be preserved');
} finally {
cleanup(d);
}
});
});
describe('T6 section-splice characterization — add-roadmap-evolution', () => {
const STATE_NESTED_ACC = [
'---',
"gsd_state_version: '1.0'",
'status: executing',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 3',
'Status: Executing Phase 3',
'',
'## Decisions Made',
'',
'- [Phase 1]: Use TypeScript',
'- [Phase 2]: Use Jest',
'',
'### Blockers',
'',
'None.',
'',
'## Accumulated Context',
'',
'Some context text here.',
'',
'### Roadmap Evolution',
'',
'- Phase 1 added: Initial planning',
'- Phase 2 changed: Scope updated',
'',
'### Session Notes',
'',
'Some notes.',
'',
'## Session',
'',
'**Last session:** 2026-01-01T00:00:00.000Z',
'**Stopped at:** None',
'**Resume file:** None',
'',
].join('\n');
const STATE_INLINE_WITH_ROAD_EVO = [
'---',
"gsd_state_version: '1.0'",
'status: executing',
'---',
'',
'# Project State',
'',
'## Accumulated Context',
'',
'### Roadmap Evolution',
'',
'None yet.',
'',
].join('\n');
const STATE_NO_ACC_SECTION = [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'---',
'',
'# Project State',
'',
'### Blockers',
'',
'None.',
'',
].join('\n');
const STATE_CRLF_ROAD = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Accumulated Context\r\n\r\n### Roadmap Evolution\r\n\r\n- Phase 1 added: Initial migration\r\n';
const STATE_POST_MILESTONE = [
'---',
"gsd_state_version: '1.0'",
'milestone: v2.0',
'milestone_name: NextMilestone',
'status: planning',
'progress:',
' total_phases: 4',
' completed_phases: 4',
' total_plans: 12',
' completed_plans: 12',
' percent: 100',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: Not started (defining requirements)',
'Plan: —',
'Status: Defining requirements',
'Last activity: 2026-01-15 — Milestone v2.0 started',
'',
'## Accumulated Context',
'',
'### Roadmap Evolution',
'',
'- Phase 1 complete after Phase 1: Migration done',
'',
].join('\n');
test('add-roadmap-evolution appends to existing Roadmap Evolution subsection (nested-acc fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NESTED_ACC);
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '4', '--action', 'added', '--note', 'New API endpoint'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
assert.ok(output.entry.includes('Phase 4 added'), 'entry must reference phase 4 added');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- Phase 4 added: New API endpoint'), 'new entry must be present');
assert.ok(after.includes('- Phase 1 added: Initial planning'), 'existing entries must be preserved');
assert.ok(after.includes('- Phase 2 changed: Scope updated'), 'second existing entry must be preserved');
// Session Notes subsection must be preserved (not consumed by splice)
assert.ok(after.includes('### Session Notes'), 'Session Notes subsection must be preserved');
} finally {
cleanup(d);
}
});
test('add-roadmap-evolution creates Roadmap Evolution subsection when absent but acc section present', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_WITH_ROAD_EVO);
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '3', '--action', 'changed', '--note', 'Scope updated significantly'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- Phase 3 changed: Scope updated significantly'), 'new entry must be present');
} finally {
cleanup(d);
}
});
test('add-roadmap-evolution creates Accumulated Context and subsection when both absent (DWIM)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_ACC_SECTION);
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '1', '--action', 'added', '--note', 'Initial setup'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- Phase 1 added: Initial setup'), 'new entry must be present');
assert.ok(after.includes('### Roadmap Evolution'), 'Roadmap Evolution subsection must be created');
} finally {
cleanup(d);
}
});
test('add-roadmap-evolution appends to Roadmap Evolution in CRLF file', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF_ROAD);
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '2', '--action', 'changed', '--note', 'CRLF test case'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- Phase 2 changed: CRLF test case'), 'new CRLF entry must be present');
assert.ok(after.includes('- Phase 1 added: Initial migration'), 'existing CRLF entry must be preserved');
} finally {
cleanup(d);
}
});
test('add-roadmap-evolution appends to Roadmap Evolution in post-milestone STATE.md', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_POST_MILESTONE);
const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '2', '--action', 'added', '--note', 'New phase inserted'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.added, true, 'added must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.includes('- Phase 2 added: New phase inserted'), 'new entry must be present');
assert.ok(after.includes('- Phase 1 complete after Phase 1: Migration done'), 'prior entry must be preserved');
// Frontmatter milestone_name must NOT be trampled
assert.ok(after.includes('milestone_name: NextMilestone'), 'milestone_name must be preserved');
} finally {
cleanup(d);
}
});
});
describe('T6 section-splice characterization — begin-phase', () => {
const STATE_INLINE_POS = [
'---',
"gsd_state_version: '1.0'",
'milestone: v1.0',
'milestone_name: TestMilestone',
'status: executing',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 (Setup)',
'Plan: 2 of 3',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'Last activity: 2026-01-01',
'',
].join('\n');
const STATE_NO_FRONTMATTER_POS = [
'# Project State',
'',
'## Current Position',
'',
'Phase: 2',
'Plan: 1 of 4',
'Status: Executing Phase 2',
'Last Activity: 2026-01-01',
'',
].join('\n');
const STATE_NO_CURRENT_POS = [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'---',
'',
'# Project State',
'',
'## Decisions Made',
'',
'- [Phase 1]: First decision',
'',
'### Blockers',
'',
'None.',
'',
].join('\n');
test('begin-phase updates Current Position and status (inline fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_POS);
const result = runGsdTools(['state', 'begin-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.updated), 'updated must be an array');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
// Phase line must reflect new phase
assert.ok(/Phase:\s+2/.test(after), 'Phase line must reference phase 2');
// Plan counter must reset to 1 of 4
assert.ok(/Plan:\s+1 of 4/.test(after), 'Plan line must be reset to 1 of 4');
// Frontmatter status must be executing
assert.ok(/^status:\s+executing/m.test(after), 'frontmatter status must be executing');
} finally {
cleanup(d);
}
});
test('begin-phase updates Current Position without frontmatter (no-frontmatter fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_FRONTMATTER_POS);
const result = runGsdTools(['state', 'begin-phase', '--phase', '3', '--plans', '2'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.updated), 'updated must be an array');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(/Phase:\s+3/.test(after), 'Phase line must reference phase 3');
} finally {
cleanup(d);
}
});
test('begin-phase handles absent Current Position section gracefully', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_CURRENT_POS);
const result = runGsdTools(['state', 'begin-phase', '--phase', '1', '--name', 'Setup', '--plans', '3'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Expect phase, phase_name and plan_count in response even if fields weren't updated
assert.strictEqual(output.phase, '1', 'phase must be reported in response');
assert.strictEqual(output.plan_count, 3, 'plan_count must be reported in response');
} finally {
cleanup(d);
}
});
});
describe('T6 section-splice characterization — complete-phase', () => {
const STATE_INLINE_EXEC = [
'---',
"gsd_state_version: '1.0'",
'status: executing',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 (Setup)',
'Plan: 2 of 3',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
].join('\n');
const STATE_TRAILING_BLANKS_EXEC = [
'---',
"gsd_state_version: '1.0'",
'status: planning',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1',
'',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
'',
'## Decisions Made',
'',
'- [Phase 1]: First decision',
'',
].join('\n');
const STATE_CRLF_EXEC = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Current Position\r\n\r\nStatus: Executing Phase 2\r\nLast Activity: 2026-01-01\r\n';
test('complete-phase marks current phase complete and sets frontmatter status (inline fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_EXEC);
const result = runGsdTools(['state', 'complete-phase'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.updated), 'updated must be an array');
assert.ok(output.updated.includes('Status'), 'Status must be in updated list');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed');
assert.ok(/Status:\s+Phase\s+1\s+complete/i.test(after), 'body Status field must reflect phase complete');
} finally {
cleanup(d);
}
});
test('complete-phase works correctly with trailing blank lines in Current Position (trailing-blanks fixture)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_TRAILING_BLANKS_EXEC);
const result = runGsdTools(['state', 'complete-phase'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.updated), 'updated must be an array');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed');
} finally {
cleanup(d);
}
});
test('complete-phase works correctly on CRLF STATE.md', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF_EXEC);
const result = runGsdTools(['state', 'complete-phase', '--phase', '2'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.updated), 'updated must be an array');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed after CRLF complete-phase');
} finally {
cleanup(d);
}
});
});
describe('T6 section-splice characterization — milestone-switch', () => {
const STATE_INLINE_MS = [
'---',
"gsd_state_version: '1.0'",
'milestone: v1.0',
'milestone_name: OldMilestone',
'status: executing',
'---',
'',
'# Project State',
'',
'## Current Position',
'',
'Phase: 1 (Setup)',
'Plan: 1 of 3',
'Status: Executing Phase 1',
'',
].join('\n');
const STATE_NO_POSITION_MS = [
'---',
"gsd_state_version: '1.0'",
'milestone: v1.0',
'milestone_name: OldMilestone',
'status: planning',
'---',
'',
'# Project State',
'',
'## Decisions Made',
'',
'- [Phase 1]: First decision',
'',
'### Blockers',
'',
'None.',
'',
].join('\n');
test('milestone-switch updates milestone and milestone_name in frontmatter (position present)', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_MS);
const result = runGsdTools(['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'NextMilestone'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.switched, true, 'switched must be true');
assert.strictEqual(output.version, 'v2.0', 'version must be v2.0');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(/^milestone:\s+v2\.0/m.test(after), 'frontmatter milestone must be v2.0');
assert.ok(/^milestone_name:\s+NextMilestone/m.test(after), 'frontmatter milestone_name must be NextMilestone');
assert.ok(/^status:\s+planning/m.test(after), 'frontmatter status must be reset to planning on milestone switch');
} finally {
cleanup(d);
}
});
test('milestone-switch updates frontmatter when Current Position is absent', () => {
const d = createTempProject();
try {
fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_POSITION_MS);
const result = runGsdTools(['state', 'milestone-switch', '--milestone', 'v3.0'], d);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.switched, true, 'switched must be true');
const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8');
assert.ok(/^milestone:\s+v3\.0/m.test(after), 'frontmatter milestone must be v3.0');
} finally {
cleanup(d);
}
});
});