Migrate the STATE.md write path inside cmdMilestoneComplete (milestone.cts) onto the STATE.md Transition Module substrate (ADR-1769, epic #1769). - Add {kind: 'milestoneComplete'} to StateTransitionIntent, with milestoneCompleteCore in src/state-transition.cts (consulting the field-classification table). Owns the closure write: Status ('<version> milestone complete'), Last Activity, Last Activity Description, a Current Position reset to 'Awaiting next milestone', and an Operator Next Steps reset pointing at the next-milestone command. - Collapse the inline STATE.md transform in cmdMilestoneComplete to a transitionCore dispatch. The adapter retains writeStateMd (lock + steady-state syncStateFrontmatter post-sync) and resolves the runtime-specific next-milestone slash command, injecting it via intent.nextMilestoneCommand. - The two section resets carry their pre-seam allow-adhoc-markdown waivers (regex semantics pinned by existing tests; pending collectSection #1372). - realClock.today() is byte-identical to milestone.cts's local today (both new Date().toISOString().split('T')[0]). - Characterization tests pin field updates, both section resets (replace + insert paths), and frontmatter #1255 parity. All 66 transition + milestone + state tests pass. Closes #1789
This commit is contained in:
@@ -212,6 +212,6 @@ alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure
|
||||
| 2 | `advancePlan` | #1782 | — |
|
||||
| 3 | `completePhase` + `phase.cts:1770` | #1784 | — |
|
||||
| 4 | `plannedPhase` + `milestoneSwitch` | #1786 | — |
|
||||
| 5 | `milestoneComplete` + `milestone.cts:352` | TBD | — |
|
||||
| 5 | `milestoneComplete` + `milestone.cts:352` | #1789 | — |
|
||||
| 6 | `patch` | TBD | #1743, #1695 |
|
||||
| 7 | `sync`, `prune`, `update` | TBD | #1760, #1761 |
|
||||
|
||||
@@ -123,6 +123,8 @@ function transitionCore(content, intent, deps) {
|
||||
return plannedPhaseCore(content, intent, deps);
|
||||
case 'milestoneSwitch':
|
||||
return milestoneSwitchCore(content, intent, deps);
|
||||
case 'milestoneComplete':
|
||||
return milestoneCompleteCore(content, intent, deps);
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -814,3 +816,91 @@ function milestoneSwitchCore(content, intent, deps) {
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
return { content: assembled, updated };
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
// milestoneComplete — intent implementation (Phase 5)
|
||||
// ----------------------------------------------------------------------------
|
||||
/**
|
||||
* Apply a `milestoneComplete` transition to STATE.md content.
|
||||
*
|
||||
* Migrates the STATE.md write path inside `cmdMilestoneComplete` (milestone.cts)
|
||||
* onto the substrate. Owns the closure write: Status (`<version> milestone
|
||||
* complete`), Last Activity, Last Activity Description, a ## Current Position
|
||||
* reset to the "Awaiting next milestone" state, and a ## Operator Next Steps
|
||||
* reset pointing at the next-milestone command.
|
||||
*
|
||||
* The adapter (`cmdMilestoneComplete`) retains `writeStateMd` (the writer that
|
||||
* owns the lock + steady-state syncStateFrontmatter post-sync) and resolves the
|
||||
* runtime-specific next-milestone slash command, injecting it via
|
||||
* `intent.nextMilestoneCommand` so the core stays pure.
|
||||
*
|
||||
* The two section resets use raw regex (with the pre-seam `allow-adhoc-markdown`
|
||||
* waivers carried from milestone.cts) rather than tokenizeHeadings because the
|
||||
* `## Operator Next Steps` section is non-canonical (not in STATE_MD_SECTIONS)
|
||||
* and the existing behavior + its tests pin the exact regex semantics. A future
|
||||
* collectSection migration (#1372) can swap both to section primitives.
|
||||
*
|
||||
* Behavior is byte-for-byte with the pre-migration milestone.cts:314-353 block.
|
||||
*/
|
||||
function milestoneCompleteCore(content, intent, deps) {
|
||||
const updated = [];
|
||||
const today = deps.clock.today();
|
||||
const version = intent.version;
|
||||
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
|
||||
const cls = getFieldClassification(fmKey);
|
||||
if (cls === null) {
|
||||
throw new Error(`transitionCore milestoneComplete: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
|
||||
`add a row per ADR-1769 §4 before touching it.`);
|
||||
}
|
||||
}
|
||||
// #1255: body-field replacements operate on body only.
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
let body = stripFrontmatter(content);
|
||||
const reassemble = (b) => hasFrontmatter
|
||||
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${b}`
|
||||
: b;
|
||||
// Status — `<version> milestone complete`.
|
||||
const statusAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Status', null, `${version} milestone complete`);
|
||||
if (statusAfter !== body) {
|
||||
body = statusAfter;
|
||||
updated.push('Status');
|
||||
}
|
||||
// Last Activity.
|
||||
const lastActivityAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity', 'Last activity', today);
|
||||
if (lastActivityAfter !== body) {
|
||||
body = lastActivityAfter;
|
||||
updated.push('Last Activity');
|
||||
}
|
||||
// Last Activity Description.
|
||||
const ladAfter = (0, state_document_cjs_1.stateReplaceFieldWithFallback)(body, 'Last Activity Description', null, `${version} milestone completed and archived`);
|
||||
if (ladAfter !== body) {
|
||||
body = ladAfter;
|
||||
updated.push('Last Activity Description');
|
||||
}
|
||||
// ## Current Position reset — stop resume/progress flows pointing at closed
|
||||
// execution instructions. allow-adhoc-markdown: pre-seam section write-modify;
|
||||
// pending collectSection migration #1372.
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const closedPositionBody = `\nPhase: Milestone ${version} complete\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Awaiting next milestone\n` +
|
||||
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
|
||||
if (positionPattern.test(body)) {
|
||||
body = body.replace(positionPattern, (_m, header) => `${header}${closedPositionBody}`);
|
||||
}
|
||||
else {
|
||||
body = `${body.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
|
||||
}
|
||||
updated.push('Current Position');
|
||||
// ## Operator Next Steps — normalize stale tails that can persist after close.
|
||||
// allow-adhoc-markdown: pre-seam section write-modify; pending collectSection migration #1372.
|
||||
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
if (operatorPattern.test(body)) {
|
||||
body = body.replace(operatorPattern, `$1\n- Start the next milestone with ${intent.nextMilestoneCommand}\n\n`);
|
||||
}
|
||||
else {
|
||||
body = `${body.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${intent.nextMilestoneCommand}\n`;
|
||||
}
|
||||
updated.push('Operator Next Steps');
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import frontmatterMod = require('./frontmatter.cjs');
|
||||
import stateMod = require('./state.cjs');
|
||||
import { platformWriteSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { realClock } from './clock.cjs';
|
||||
import { transitionCore } from './state-transition.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
@@ -30,7 +32,7 @@ import coreUtilsMod = require('./core-utils.cjs');
|
||||
const { extractOneLinerFromBody } = coreUtilsMod;
|
||||
const { planningPaths } = planningWorkspace;
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
const { writeStateMd, stateReplaceFieldWithFallback } = stateMod;
|
||||
const { writeStateMd } = stateMod;
|
||||
|
||||
interface MilestoneCompleteOptions {
|
||||
name?: string;
|
||||
@@ -311,45 +313,25 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
||||
}
|
||||
|
||||
// Update STATE.md — keep frontmatter/body semantically aligned after closure
|
||||
// Update STATE.md — keep frontmatter/body semantically aligned after closure.
|
||||
// ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
|
||||
// write (Status, Last Activity, Last Activity Description, Current Position
|
||||
// reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
|
||||
// src/state-transition.cts, backed by the field-classification table. The
|
||||
// runtime-specific next-milestone slash command is resolved here and injected
|
||||
// via the intent so the core stays pure. writeStateMd still owns the lock and
|
||||
// the steady-state syncStateFrontmatter post-sync.
|
||||
if (fs.existsSync(statePath)) {
|
||||
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
|
||||
stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, `${version} milestone complete`);
|
||||
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
|
||||
stateContent = stateReplaceFieldWithFallback(
|
||||
stateContent,
|
||||
'Last Activity Description',
|
||||
null,
|
||||
`${version} milestone completed and archived`,
|
||||
const result = transitionCore(
|
||||
fs.readFileSync(statePath, 'utf-8'),
|
||||
{
|
||||
kind: 'milestoneComplete',
|
||||
version,
|
||||
nextMilestoneCommand: formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string,
|
||||
},
|
||||
{ clock: realClock, progressProvider: () => null },
|
||||
);
|
||||
|
||||
// Reset Current Position narrative so resume/progress flows do not keep
|
||||
// pointing at closed-phase execution instructions.
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372
|
||||
const closedPositionBody =
|
||||
`\nPhase: Milestone ${version} complete\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Awaiting next milestone\n` +
|
||||
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
|
||||
if (positionPattern.test(stateContent)) {
|
||||
stateContent = stateContent.replace(positionPattern, (_m, header: string) => `${header}${closedPositionBody}`);
|
||||
} else {
|
||||
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
|
||||
}
|
||||
|
||||
// Normalize operator-next-step tails that can become stale after close.
|
||||
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify in milestone.cts; pending collectSection migration #1372
|
||||
if (operatorPattern.test(stateContent)) {
|
||||
stateContent = stateContent.replace(
|
||||
operatorPattern,
|
||||
`$1\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n\n`,
|
||||
);
|
||||
} else {
|
||||
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n`;
|
||||
}
|
||||
|
||||
writeStateMd(statePath, stateContent, cwd);
|
||||
writeStateMd(statePath, result.content, cwd);
|
||||
}
|
||||
|
||||
// Archive phase directories if requested
|
||||
|
||||
@@ -173,8 +173,14 @@ export type StateTransitionIntent =
|
||||
summaryCount: number;
|
||||
}
|
||||
| { kind: 'plannedPhase'; phaseNumber: string | number; planCount: number | null }
|
||||
| { kind: 'milestoneSwitch'; version: string; name: string };
|
||||
// Phases 5–7 add the remaining intent kinds to this discriminated union.
|
||||
| { kind: 'milestoneSwitch'; version: string; name: string }
|
||||
| {
|
||||
kind: 'milestoneComplete';
|
||||
version: string;
|
||||
/** Resolved runtime slash command for the Operator Next Steps hint (e.g. '/gsd:new-milestone'). */
|
||||
nextMilestoneCommand: string;
|
||||
};
|
||||
// Phases 6–7 add the remaining intent kinds to this discriminated union.
|
||||
|
||||
export type StateTransitionResult = {
|
||||
content: string;
|
||||
@@ -213,6 +219,8 @@ export function transitionCore(
|
||||
return plannedPhaseCore(content, intent, deps);
|
||||
case 'milestoneSwitch':
|
||||
return milestoneSwitchCore(content, intent, deps);
|
||||
case 'milestoneComplete':
|
||||
return milestoneCompleteCore(content, intent, deps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,3 +1018,113 @@ function milestoneSwitchCore(
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
return { content: assembled, updated };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// milestoneComplete — intent implementation (Phase 5)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a `milestoneComplete` transition to STATE.md content.
|
||||
*
|
||||
* Migrates the STATE.md write path inside `cmdMilestoneComplete` (milestone.cts)
|
||||
* onto the substrate. Owns the closure write: Status (`<version> milestone
|
||||
* complete`), Last Activity, Last Activity Description, a ## Current Position
|
||||
* reset to the "Awaiting next milestone" state, and a ## Operator Next Steps
|
||||
* reset pointing at the next-milestone command.
|
||||
*
|
||||
* The adapter (`cmdMilestoneComplete`) retains `writeStateMd` (the writer that
|
||||
* owns the lock + steady-state syncStateFrontmatter post-sync) and resolves the
|
||||
* runtime-specific next-milestone slash command, injecting it via
|
||||
* `intent.nextMilestoneCommand` so the core stays pure.
|
||||
*
|
||||
* The two section resets use raw regex (with the pre-seam `allow-adhoc-markdown`
|
||||
* waivers carried from milestone.cts) rather than tokenizeHeadings because the
|
||||
* `## Operator Next Steps` section is non-canonical (not in STATE_MD_SECTIONS)
|
||||
* and the existing behavior + its tests pin the exact regex semantics. A future
|
||||
* collectSection migration (#1372) can swap both to section primitives.
|
||||
*
|
||||
* Behavior is byte-for-byte with the pre-migration milestone.cts:314-353 block.
|
||||
*/
|
||||
function milestoneCompleteCore(
|
||||
content: string,
|
||||
intent: { kind: 'milestoneComplete'; version: string; nextMilestoneCommand: string },
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const updated: string[] = [];
|
||||
const today = deps.clock.today();
|
||||
const version = intent.version;
|
||||
|
||||
for (const fmKey of ['status', 'last_activity', 'last_activity_desc']) {
|
||||
const cls = getFieldClassification(fmKey);
|
||||
if (cls === null) {
|
||||
throw new Error(
|
||||
`transitionCore milestoneComplete: frontmatter key ${JSON.stringify(fmKey)} is not in FIELD_CLASSIFICATION; ` +
|
||||
`add a row per ADR-1769 §4 before touching it.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// #1255: body-field replacements operate on body only.
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
let body = stripFrontmatter(content);
|
||||
const reassemble = (b: string): string =>
|
||||
hasFrontmatter
|
||||
? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${b}`
|
||||
: b;
|
||||
|
||||
// Status — `<version> milestone complete`.
|
||||
const statusAfter = stateReplaceFieldWithFallback(body, 'Status', null, `${version} milestone complete`);
|
||||
if (statusAfter !== body) {
|
||||
body = statusAfter;
|
||||
updated.push('Status');
|
||||
}
|
||||
|
||||
// Last Activity.
|
||||
const lastActivityAfter = stateReplaceFieldWithFallback(body, 'Last Activity', 'Last activity', today);
|
||||
if (lastActivityAfter !== body) {
|
||||
body = lastActivityAfter;
|
||||
updated.push('Last Activity');
|
||||
}
|
||||
|
||||
// Last Activity Description.
|
||||
const ladAfter = stateReplaceFieldWithFallback(
|
||||
body,
|
||||
'Last Activity Description',
|
||||
null,
|
||||
`${version} milestone completed and archived`,
|
||||
);
|
||||
if (ladAfter !== body) {
|
||||
body = ladAfter;
|
||||
updated.push('Last Activity Description');
|
||||
}
|
||||
|
||||
// ## Current Position reset — stop resume/progress flows pointing at closed
|
||||
// execution instructions.
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify carried from milestone.cts; pending collectSection migration #1372
|
||||
const closedPositionBody =
|
||||
`\nPhase: Milestone ${version} complete\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Awaiting next milestone\n` +
|
||||
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
|
||||
if (positionPattern.test(body)) {
|
||||
body = body.replace(positionPattern, (_m, header: string) => `${header}${closedPositionBody}`);
|
||||
} else {
|
||||
body = `${body.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
|
||||
}
|
||||
updated.push('Current Position');
|
||||
|
||||
// ## Operator Next Steps — normalize stale tails that can persist after close.
|
||||
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i; // allow-adhoc-markdown: pre-seam section write-modify carried from milestone.cts; pending collectSection migration #1372
|
||||
if (operatorPattern.test(body)) {
|
||||
body = body.replace(
|
||||
operatorPattern,
|
||||
`$1\n- Start the next milestone with ${intent.nextMilestoneCommand}\n\n`,
|
||||
);
|
||||
} else {
|
||||
body = `${body.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${intent.nextMilestoneCommand}\n`;
|
||||
}
|
||||
updated.push('Operator Next Steps');
|
||||
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
|
||||
@@ -915,3 +915,120 @@ describe('ADR-1769 Phase 4: milestoneSwitch transition — milestone reset', ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ADR-1769 Phase 5: milestoneComplete
|
||||
|
||||
describe('ADR-1769 Phase 5: milestoneComplete transition — closure write', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
const intent = { kind: 'milestoneComplete', version: 'v1.0', nextMilestoneCommand: '/gsd:new-milestone' };
|
||||
|
||||
function preCloseBody() {
|
||||
return [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Status:** Executing Phase 5',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Last Activity Description:** mid-flight',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 5 — EXECUTING',
|
||||
'Plan: 2 of 3',
|
||||
'Status: Executing Phase 5',
|
||||
'Last activity: 2026-06-20 — running',
|
||||
'',
|
||||
'## Operator Next Steps',
|
||||
'',
|
||||
'- Re-run /gsd:complete-milestone v1.0',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
test('Status becomes "<version> milestone complete"', () => {
|
||||
const result = transitionCore(preCloseBody(), intent, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Status'), 'v1.0 milestone complete');
|
||||
assert.ok(result.updated.includes('Status'));
|
||||
});
|
||||
|
||||
test('Last Activity is refreshed to clock.today()', () => {
|
||||
const result = transitionCore(preCloseBody(), intent, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
||||
});
|
||||
|
||||
test('Last Activity Description carries the archived narrative', () => {
|
||||
const result = transitionCore(preCloseBody(), intent, deps);
|
||||
assert.strictEqual(
|
||||
stateExtractField(result.content, 'Last Activity Description'),
|
||||
'v1.0 milestone completed and archived',
|
||||
);
|
||||
});
|
||||
|
||||
test('Current Position resets to "Awaiting next milestone" with archived narrative', () => {
|
||||
const result = transitionCore(preCloseBody(), intent, deps);
|
||||
assert.ok(/Phase: Milestone v1\.0 complete/.test(result.content));
|
||||
assert.ok(/Status: Awaiting next milestone/.test(result.content));
|
||||
assert.ok(/Last activity: 2026-06-27 — Milestone v1\.0 completed and archived/.test(result.content));
|
||||
assert.ok(result.updated.includes('Current Position'));
|
||||
});
|
||||
|
||||
test('Operator Next Steps is rewritten to point at the next-milestone command', () => {
|
||||
const result = transitionCore(preCloseBody(), intent, deps);
|
||||
assert.ok(/## Operator Next Steps/.test(result.content));
|
||||
assert.ok(/- Start the next milestone with \/gsd:new-milestone/.test(result.content));
|
||||
// The stale prior instruction must be gone.
|
||||
assert.ok(!/Re-run \/gsd:complete-milestone/.test(result.content),
|
||||
'stale Operator Next Steps tail must be replaced');
|
||||
});
|
||||
|
||||
test('Operator Next Steps section is inserted when absent', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Status:** Executing Phase 5',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Last Activity Description:** mid',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 5 — EXECUTING',
|
||||
'Status: Executing Phase 5',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, intent, deps);
|
||||
assert.ok(/## Operator Next Steps/.test(result.content));
|
||||
assert.ok(/- Start the next milestone with \/gsd:new-milestone/.test(result.content));
|
||||
});
|
||||
|
||||
test('Current Position section is inserted when absent', () => {
|
||||
const input = '# Project State\n\n**Status:** Executing\n**Last Activity:** 2026-06-20\n';
|
||||
const result = transitionCore(input, intent, deps);
|
||||
assert.ok(/## Current Position/.test(result.content));
|
||||
assert.ok(/Status: Awaiting next milestone/.test(result.content));
|
||||
});
|
||||
|
||||
test('frontmatter is preserved across the closure write (#1255)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'status: executing',
|
||||
'milestone: v1.0',
|
||||
'---',
|
||||
'',
|
||||
'# Project State',
|
||||
'',
|
||||
'**Status:** Executing Phase 5',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Last Activity Description:** mid',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 5 — EXECUTING',
|
||||
'Status: Executing Phase 5',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, intent, deps);
|
||||
// Body Status must be the closure value, not the YAML status key.
|
||||
assert.strictEqual(stateExtractField(result.content, 'Status'), 'v1.0 milestone complete');
|
||||
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content), 'frontmatter block preserved');
|
||||
assert.ok(/^milestone: v1\.0/m.test(result.content), 'frontmatter milestone preserved');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user