The /gsd:new-milestone workflow Step 5 rewrote STATE.md's Current Position body but never touched the YAML frontmatter, so every downstream reader (state.json, getMilestoneInfo, progress bars) kept reporting the stale milestone until the first phase advance forced a resync. Asymmetric with milestone.complete, which uses readModifyWriteStateMdFull. Add a new `state milestone-switch` handler (both SDK and CJS) that atomically: - Stomps frontmatter milestone/milestone_name with caller-supplied values - Resets status to 'planning' and progress counters to zero - Rewrites the ## Current Position section to the new-milestone template - Preserves Accumulated Context (decisions, blockers, todos) Wire the workflow Step 5 to invoke `state.milestone-switch` instead of the manual body rewrite. Note the flag is `--milestone` not `--version`: gsd-tools reserves `--version` as a globally-invalid help flag. Red vitest in sdk/src/query/state-mutation.test.ts asserts the frontmatter reset. Regression guard via node:test in tests/bug-2630-*.test.cjs runs through gsd-tools end-to-end. Fixes #2630 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -483,6 +483,12 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
} else if (subcommand === 'prune') {
|
||||
const { 'keep-recent': keepRecent, 'dry-run': dryRun } = parseNamedArgs(args, ['keep-recent'], ['dry-run']);
|
||||
state.cmdStatePrune(cwd, { keepRecent: keepRecent || '3', dryRun: !!dryRun }, raw);
|
||||
} else if (subcommand === 'milestone-switch') {
|
||||
// Bug #2630: reset STATE.md frontmatter + Current Position for new milestone.
|
||||
// NB: the flag is `--milestone`, not `--version` — gsd-tools reserves
|
||||
// `--version` as a globally-invalid help flag (see NEVER_VALID_FLAGS above).
|
||||
const { milestone, name } = parseNamedArgs(args, ['milestone', 'name']);
|
||||
state.cmdStateMilestoneSwitch(cwd, milestone, name, raw);
|
||||
} else {
|
||||
state.cmdStateLoad(cwd, raw);
|
||||
}
|
||||
|
||||
@@ -1253,6 +1253,70 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) {
|
||||
output({ updated, phase: phaseNumber, plan_count: planCount }, raw, updated.length > 0 ? 'true' : 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug #2630: reset STATE.md for a new milestone cycle.
|
||||
* Stomps frontmatter milestone/milestone_name/status/progress AND rewrites
|
||||
* the Current Position body. Preserves Accumulated Context.
|
||||
* Symmetric with the SDK `stateMilestoneSwitch` handler.
|
||||
*/
|
||||
function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
||||
if (!version || !String(version).trim()) {
|
||||
output({ error: 'milestone required (--milestone <vX.Y>)' }, raw);
|
||||
return;
|
||||
}
|
||||
const resolvedName = (name && String(name).trim()) || 'milestone';
|
||||
const statePath = planningPaths(cwd).state;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
const resetPositionBody =
|
||||
`\nPhase: Not started (defining requirements)\n` +
|
||||
`Plan: —\n` +
|
||||
`Status: Defining requirements\n` +
|
||||
`Last activity: ${today} — Milestone ${version} started\n\n`;
|
||||
let newBody;
|
||||
if (positionPattern.test(body)) {
|
||||
newBody = body.replace(positionPattern, (_m, header) => `${header}${resetPositionBody}`);
|
||||
} else {
|
||||
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
||||
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
||||
}
|
||||
|
||||
const fm = {
|
||||
gsd_state_version: existingFm.gsd_state_version || '1.0',
|
||||
milestone: version,
|
||||
milestone_name: resolvedName,
|
||||
status: 'planning',
|
||||
last_updated: new Date().toISOString(),
|
||||
last_activity: today,
|
||||
progress: {
|
||||
total_phases: 0,
|
||||
completed_phases: 0,
|
||||
total_plans: 0,
|
||||
completed_plans: 0,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const yamlStr = reconstructFrontmatter(fm);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
atomicWriteFileSync(statePath, normalizeMd(assembled), 'utf-8');
|
||||
output(
|
||||
{ switched: true, version, name: resolvedName, status: 'planning' },
|
||||
raw,
|
||||
'true',
|
||||
);
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 1: Validate STATE.md against filesystem.
|
||||
* Returns { valid, warnings, drift } JSON.
|
||||
@@ -1644,6 +1708,7 @@ module.exports = {
|
||||
cmdStateValidate,
|
||||
cmdStateSync,
|
||||
cmdStatePrune,
|
||||
cmdStateMilestoneSwitch,
|
||||
cmdSignalWaiting,
|
||||
cmdSignalResume,
|
||||
};
|
||||
|
||||
@@ -173,6 +173,19 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
|
||||
## 5. Update STATE.md
|
||||
|
||||
Reset STATE.md frontmatter AND body atomically via the SDK. This writes the new
|
||||
milestone version/name into the YAML frontmatter, resets `status` to
|
||||
`planning`, zeroes `progress.*` counters, and rewrites the `## Current Position`
|
||||
section to the new-milestone template. Accumulated Context (decisions,
|
||||
blockers, todos) is preserved across the switch — symmetric with
|
||||
`milestone.complete`.
|
||||
|
||||
```bash
|
||||
gsd-sdk query state.milestone-switch --milestone "v[X.Y]" --name "[Name]"
|
||||
```
|
||||
|
||||
The resulting Current Position section looks like:
|
||||
|
||||
```markdown
|
||||
## Current Position
|
||||
|
||||
@@ -182,7 +195,11 @@ Status: Defining requirements
|
||||
Last activity: [today] — Milestone v[X.Y] started
|
||||
```
|
||||
|
||||
Keep Accumulated Context section from previous milestone.
|
||||
Bug #2630: a prior version of this workflow rewrote the Current Position body
|
||||
manually but left the frontmatter pointing at the previous milestone, so every
|
||||
downstream reader (`state.json`, `getMilestoneInfo`, progress bars) reported the
|
||||
stale milestone until the first phase advance forced a resync. Always use the
|
||||
SDK handler above — do not hand-edit STATE.md here.
|
||||
|
||||
## 6. Cleanup and Commit
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
stateRecordMetric, stateUpdateProgress, stateAddDecision,
|
||||
stateAddBlocker, stateResolveBlocker, stateRecordSession,
|
||||
stateSignalWaiting, stateSignalResume, stateValidate, stateSync, statePrune,
|
||||
stateMilestoneSwitch,
|
||||
} from './state-mutation.js';
|
||||
import {
|
||||
configSet, configSetModelProfile, configNewProject, configEnsureSection,
|
||||
@@ -133,6 +134,7 @@ export const QUERY_MUTATION_COMMANDS = new Set<string>([
|
||||
'state.signal-resume', 'state signal-resume',
|
||||
'state.sync', 'state sync',
|
||||
'state.prune', 'state prune',
|
||||
'state.milestone-switch', 'state milestone-switch',
|
||||
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate',
|
||||
'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section',
|
||||
'commit', 'check-commit', 'commit-to-subrepo',
|
||||
@@ -321,6 +323,8 @@ export function createRegistry(
|
||||
registry.register('state.validate', stateValidate);
|
||||
registry.register('state.sync', stateSync);
|
||||
registry.register('state.prune', statePrune);
|
||||
registry.register('state.milestone-switch', stateMilestoneSwitch);
|
||||
registry.register('state milestone-switch', stateMilestoneSwitch);
|
||||
registry.register('state signal-waiting', stateSignalWaiting);
|
||||
registry.register('state signal-resume', stateSignalResume);
|
||||
registry.register('state validate', stateValidate);
|
||||
|
||||
@@ -666,3 +666,108 @@ Resume file: None
|
||||
expect(Number(progress.percent)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stateMilestoneSwitch (#2630) ──────────────────────────────────────────
|
||||
|
||||
describe('stateMilestoneSwitch', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-milestone-switch-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes milestone/milestone_name/status into STATE.md frontmatter and resets progress on milestone switch', async () => {
|
||||
// Previous milestone shipped: STATE.md frontmatter points at v1.0 with
|
||||
// non-zero progress. ROADMAP.md now advertises the NEW milestone v1.1.
|
||||
// Regardless of what getMilestoneInfo derives from the old STATE.md
|
||||
// frontmatter, a milestone switch must stomp the frontmatter with the new
|
||||
// version/name and reset progress counters.
|
||||
const stateContent = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: Foundation
|
||||
status: completed
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 5
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 100
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 5 (Foundation) — COMPLETED
|
||||
Plan: 3 of 3
|
||||
Status: v1.0 milestone complete
|
||||
Last activity: 2026-04-20 -- v1.0 shipped
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
- [Phase 1]: Use Node 20
|
||||
`;
|
||||
const planningDir = join(tmpDir, '.planning');
|
||||
await mkdir(join(planningDir, 'phases'), { recursive: true });
|
||||
await writeFile(join(planningDir, 'STATE.md'), stateContent, 'utf-8');
|
||||
// ROADMAP advertises the new milestone
|
||||
await writeFile(
|
||||
join(planningDir, 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
|
||||
'utf-8',
|
||||
);
|
||||
await writeFile(join(planningDir, 'config.json'), '{}', 'utf-8');
|
||||
|
||||
const { stateMilestoneSwitch } = await import('./state-mutation.js');
|
||||
const result = await stateMilestoneSwitch(
|
||||
['--milestone', 'v1.1', '--name', 'Notifications'],
|
||||
tmpDir,
|
||||
);
|
||||
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.switched).toBe(true);
|
||||
expect(data.version).toBe('v1.1');
|
||||
expect(data.name).toBe('Notifications');
|
||||
|
||||
const after = await readFile(join(planningDir, 'STATE.md'), 'utf-8');
|
||||
const { extractFrontmatter } = await import('./frontmatter.js');
|
||||
const fm = extractFrontmatter(after);
|
||||
|
||||
// The heart of #2630 — frontmatter must reflect the NEW milestone.
|
||||
expect(fm.milestone).toBe('v1.1');
|
||||
expect(fm.milestone_name).toBe('Notifications');
|
||||
// Status resets to planning (Defining requirements phase).
|
||||
expect(fm.status).toBe('planning');
|
||||
// Progress counters reset for the new milestone (no phases executed yet).
|
||||
const progress = fm.progress as Record<string, unknown> | undefined;
|
||||
if (progress) {
|
||||
expect(Number(progress.completed_phases ?? 0)).toBe(0);
|
||||
expect(Number(progress.completed_plans ?? 0)).toBe(0);
|
||||
expect(Number(progress.percent ?? 0)).toBe(0);
|
||||
}
|
||||
|
||||
// Accumulated Context is preserved across the milestone switch.
|
||||
expect(after).toContain('[Phase 1]: Use Node 20');
|
||||
|
||||
// Current Position body is reset to the new milestone's starting state.
|
||||
expect(after).toMatch(/Status:\s*Defining requirements/);
|
||||
});
|
||||
|
||||
it('rejects missing --milestone', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8').catch(async () => {
|
||||
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
||||
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}', 'utf-8');
|
||||
});
|
||||
const { stateMilestoneSwitch } = await import('./state-mutation.js');
|
||||
const result = await stateMilestoneSwitch([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -982,6 +982,124 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir, workstre
|
||||
return { data: { updated, phase: phaseNumber, plan_count: planCount } };
|
||||
};
|
||||
|
||||
// ─── stateMilestoneSwitch (bug #2630) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Query handler for `state.milestone-switch` — resets STATE.md for a new
|
||||
* milestone cycle (bug #2630 regression guard).
|
||||
*
|
||||
* The `/gsd:new-milestone` workflow only rewrote STATE.md's body (Current
|
||||
* Position section). The YAML frontmatter (`milestone`, `milestone_name`,
|
||||
* `status`, `progress.*`) was never touched on a mid-flight switch, so queries
|
||||
* that read frontmatter (`state.json`, `getMilestoneInfo`, every handler that
|
||||
* calls `buildStateFrontmatter`) kept reporting the old milestone and stale
|
||||
* progress counters until the first phase advance forced a resync.
|
||||
*
|
||||
* This handler performs the reset atomically under the STATE.md lock:
|
||||
* - Stomps frontmatter milestone/milestone_name with the caller-supplied
|
||||
* values so `parseMilestoneFromState` reports the new milestone immediately.
|
||||
* - Resets `status` to `'planning'` (workflow is at "Defining requirements").
|
||||
* - Resets `progress` counters to zero (new milestone, nothing executed yet).
|
||||
* - Rewrites the `## Current Position` body to the new-milestone template so
|
||||
* subsequent body-derived field extraction stays consistent with frontmatter.
|
||||
* - Preserves Accumulated Context (decisions, todos, blockers) — symmetric
|
||||
* with `milestone.complete` which also keeps history.
|
||||
*
|
||||
* Args (named, matches gsd-tools style):
|
||||
* - `--version <vX.Y>` (required)
|
||||
* - `--name <milestone name>` (optional; defaults to 'milestone')
|
||||
*
|
||||
* Sibling CJS parity: `cmdInitNewMilestone` in `init.cjs` is read-only (like
|
||||
* the TS `initNewMilestone`). The workflow-level fix is to call
|
||||
* `state.milestone-switch` from `/gsd:new-milestone` Step 5 in place of the
|
||||
* manual body rewrite.
|
||||
*/
|
||||
export const stateMilestoneSwitch: QueryHandler = async (args, projectDir, workstream) => {
|
||||
// NOTE: the CLI flag is `--milestone` (not `--version`). gsd-tools reserves
|
||||
// `--version` as a globally-invalid help flag, so the workflow invokes this
|
||||
// handler with `--milestone vX.Y`. The internal variable is still `version`
|
||||
// because the value is a milestone version string.
|
||||
const parsed = parseNamedArgs(args, ['milestone', 'name']);
|
||||
const version = (parsed.milestone as string | null)?.trim();
|
||||
const name = ((parsed.name as string | null) ?? 'milestone').trim() || 'milestone';
|
||||
|
||||
if (!version) {
|
||||
return { data: { error: 'milestone required (--milestone <vX.Y>)' } };
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]!;
|
||||
const statePath = planningPaths(projectDir, workstream).state;
|
||||
const lockPath = await acquireStateLock(statePath);
|
||||
|
||||
try {
|
||||
let content = '';
|
||||
try {
|
||||
content = await readFile(statePath, 'utf-8');
|
||||
} catch { /* STATE.md may not exist yet */ }
|
||||
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
|
||||
// Reset Current Position section body so body-derived extraction stays
|
||||
// consistent with the new frontmatter.
|
||||
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
||||
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}`);
|
||||
} else {
|
||||
// Preserve any existing body but prepend a Current Position section.
|
||||
const preface = body.trim().length > 0 ? body : '# Project State\n';
|
||||
newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`;
|
||||
}
|
||||
|
||||
// Build fresh frontmatter explicitly — do NOT rely on buildStateFrontmatter
|
||||
// here, because getMilestoneInfo reads the ON-DISK STATE.md and would
|
||||
// return the OLD milestone until we write it first. This is the crux of
|
||||
// bug #2630: any sync-based approach races against the very file it is
|
||||
// about to rewrite.
|
||||
const fm: Record<string, unknown> = {
|
||||
gsd_state_version: '1.0',
|
||||
milestone: version,
|
||||
milestone_name: name,
|
||||
status: 'planning',
|
||||
last_updated: new Date().toISOString(),
|
||||
last_activity: today,
|
||||
progress: {
|
||||
total_phases: 0,
|
||||
completed_phases: 0,
|
||||
total_plans: 0,
|
||||
completed_plans: 0,
|
||||
percent: 0,
|
||||
},
|
||||
};
|
||||
// Preserve frontmatter-only fields the caller may still care about
|
||||
// (paused_at cleared deliberately — a new milestone is a fresh start).
|
||||
if (existingFm.gsd_state_version) {
|
||||
fm.gsd_state_version = existingFm.gsd_state_version;
|
||||
}
|
||||
|
||||
const yamlStr = reconstructFrontmatter(fm);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
await writeFile(statePath, normalizeMd(assembled), 'utf-8');
|
||||
|
||||
return {
|
||||
data: {
|
||||
switched: true,
|
||||
version,
|
||||
name,
|
||||
status: 'planning',
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await releaseStateLock(lockPath);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── parseNamedArgs (matches gsd-tools.cjs) ───────────────────────────────
|
||||
|
||||
function parseNamedArgs(
|
||||
|
||||
119
tests/bug-2630-state-frontmatter-milestone-switch.test.cjs
Normal file
119
tests/bug-2630-state-frontmatter-milestone-switch.test.cjs
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* GSD Tools Tests — Bug #2630
|
||||
*
|
||||
* Regression guard: `state milestone-switch` resets STATE.md YAML frontmatter
|
||||
* (milestone, milestone_name, status, progress.*) AND the `## Current Position`
|
||||
* body in a single atomic write. Prior to the fix, the `/gsd:new-milestone`
|
||||
* workflow rewrote the body but left the frontmatter pointing at the previous
|
||||
* milestone, so every downstream reader (state.json, getMilestoneInfo, etc.)
|
||||
* reported the stale milestone.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const STALE_STATE = `---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: Foundation
|
||||
status: completed
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 5
|
||||
total_plans: 12
|
||||
completed_plans: 12
|
||||
percent: 100
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 5 (Foundation) — COMPLETED
|
||||
Plan: 3 of 3
|
||||
Status: v1.0 milestone complete
|
||||
Last activity: 2026-04-20 -- v1.0 shipped
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
- [Phase 1]: Use Node 20
|
||||
`;
|
||||
|
||||
describe('state milestone-switch (#2630)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
STALE_STATE,
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## v1.1 Notifications\n\n### Phase 6: Notify\n',
|
||||
'utf-8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
'{}',
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes new milestone into frontmatter and resets progress + Current Position', () => {
|
||||
const result = runGsdTools(
|
||||
['state', 'milestone-switch', '--milestone', 'v1.1', '--name', 'Notifications'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.equal(result.success, true, result.error || result.output);
|
||||
|
||||
const after = fs.readFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Frontmatter reflects the NEW milestone — the core of bug #2630.
|
||||
assert.match(after, /^milestone:\s*v1\.1\s*$/m, 'frontmatter milestone not switched');
|
||||
assert.match(
|
||||
after,
|
||||
/^milestone_name:\s*Notifications\s*$/m,
|
||||
'frontmatter milestone_name not switched',
|
||||
);
|
||||
assert.match(after, /^status:\s*planning\s*$/m, 'status not reset to planning');
|
||||
// Progress counters reset to zero.
|
||||
assert.match(after, /^\s*completed_phases:\s*0\s*$/m, 'completed_phases not reset');
|
||||
assert.match(after, /^\s*completed_plans:\s*0\s*$/m, 'completed_plans not reset');
|
||||
assert.match(after, /^\s*percent:\s*0\s*$/m, 'percent not reset');
|
||||
|
||||
// Body Current Position reset to the new-milestone template.
|
||||
assert.match(after, /Status:\s*Defining requirements/, 'body Status not reset');
|
||||
assert.match(
|
||||
after,
|
||||
/Phase:\s*Not started \(defining requirements\)/,
|
||||
'body Phase not reset',
|
||||
);
|
||||
|
||||
// Accumulated Context is preserved.
|
||||
assert.match(after, /\[Phase 1\]:\s*Use Node 20/, 'Accumulated Context lost');
|
||||
});
|
||||
|
||||
test('rejects missing --milestone', () => {
|
||||
const result = runGsdTools(
|
||||
['state', 'milestone-switch', '--name', 'Something'],
|
||||
tmpDir,
|
||||
);
|
||||
// gsd-tools emits JSON with { error: ... } to stdout even on error paths.
|
||||
const combined = (result.output || '') + (result.error || '');
|
||||
assert.match(combined, /milestone required/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user