Compare commits

...

2 Commits

Author SHA1 Message Date
Tom Boucher
eb03ba3dd8 fix(2129): exclude 999.x backlog phases from next-phase and all_complete (#2135)
* test(2129): add failing tests for 999.x backlog phase exclusion

Bug A: phase complete reports 999.1 as next phase instead of 3
Bug B: init manager returns all_complete:false when only 999.x is incomplete

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

* fix(2129): exclude 999.x backlog phases from next-phase scan and all_complete check

In cmdPhaseComplete, backlog phases (999.x) on disk were picked as the
next phase when intervening milestone phases had no directory yet. Now
the filesystem scan skips any directory whose phase number starts with 999.

In cmdInitManager, all_complete compared completed count against the full
phase list including 999.x stubs, making it impossible to reach true when
backlog items existed. Now the check uses only non-backlog phases.

Closes #2129

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:50:25 -04:00
Tom Boucher
637daa831b fix(2130): anchor extractFrontmatter regex to file start (#2133)
* test(2130): add failing tests for frontmatter body --- sequence mis-parse

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

* fix(2130): anchor extractFrontmatter regex to file start, preventing body --- mis-parse

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:47:50 -04:00
6 changed files with 226 additions and 7 deletions

View File

@@ -42,11 +42,9 @@ function splitInlineArray(body) {
function extractFrontmatter(content) {
const frontmatter = {};
// Find ALL frontmatter blocks at the start of the file.
// If multiple blocks exist (corruption from CRLF mismatch), use the LAST one
// since it represents the most recent state sync.
const allBlocks = [...content.matchAll(/(?:^|\n)\s*---\r?\n([\s\S]+?)\r?\n---/g)];
const match = allBlocks.length > 0 ? allBlocks[allBlocks.length - 1] : null;
// Match frontmatter only at byte 0 — a `---` block later in the document
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
if (!match) return frontmatter;
const yaml = match[1];

View File

@@ -1104,7 +1104,9 @@ function cmdInitManager(cwd, raw) {
return true;
});
const completedCount = phases.filter(p => p.disk_status === 'complete').length;
// Exclude backlog phases (999.x) from completion accounting (#2129)
const nonBacklogPhases = phases.filter(p => !/^999(?:\.|$)/.test(p.number));
const completedCount = nonBacklogPhases.filter(p => p.disk_status === 'complete').length;
// Read manager flags from config (passthrough flags for each step)
// Validate: flags must be CLI-safe (only --flags, alphanumeric, hyphens, spaces)
@@ -1135,7 +1137,7 @@ function cmdInitManager(cwd, raw) {
in_progress_count: phases.filter(p => ['partial', 'planned', 'discussed', 'researched'].includes(p.disk_status)).length,
recommended_actions: filteredActions,
waiting_signal: waitingSignal,
all_complete: completedCount === phases.length && phases.length > 0,
all_complete: completedCount === nonBacklogPhases.length && nonBacklogPhases.length > 0,
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
roadmap_exists: true,
state_exists: true,

View File

@@ -838,9 +838,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
.sort((a, b) => comparePhaseNum(a, b));
// Find the next phase directory after current
// Skip backlog phases (999.x) — they are parked ideas, not sequential work (#2129)
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
if (dm) {
if (/^999(?:\.|$)/.test(dm[1])) continue;
if (comparePhaseNum(dm[1], phaseNum) > 0) {
nextPhaseNum = dm[1];
nextPhaseName = dm[2] || null;

View File

@@ -113,6 +113,104 @@ describe('extractFrontmatter', () => {
assert.strictEqual(result.second, 'two');
assert.strictEqual(result.third, 'three');
});
// ─── Bug #2130: body --- sequence mis-parse ──────────────────────────────
test('#2130: frontmatter at top with YAML example block in body — returns top frontmatter', () => {
const content = [
'---',
'name: my-agent',
'type: execute',
'---',
'',
'# Documentation',
'',
'Here is a YAML example:',
'',
'```yaml',
'---',
'key: value',
'other: stuff',
'---',
'```',
'',
'End of doc.',
].join('\n');
const result = extractFrontmatter(content);
assert.strictEqual(result.name, 'my-agent', 'should extract name from TOP frontmatter');
assert.strictEqual(result.type, 'execute', 'should extract type from TOP frontmatter');
assert.strictEqual(result.key, undefined, 'should NOT extract key from body YAML block');
assert.strictEqual(result.other, undefined, 'should NOT extract other from body YAML block');
});
test('#2130: frontmatter at top with horizontal rules in body — returns top frontmatter', () => {
const content = [
'---',
'title: My Doc',
'status: active',
'---',
'',
'# Section One',
'',
'Some text.',
'',
'---',
'',
'# Section Two',
'',
'More text.',
'',
'---',
'',
'# Section Three',
].join('\n');
const result = extractFrontmatter(content);
assert.strictEqual(result.title, 'My Doc', 'should extract title from TOP frontmatter');
assert.strictEqual(result.status, 'active', 'should extract status from TOP frontmatter');
});
test('#2130: body-only --- block with no frontmatter at byte 0 — returns empty', () => {
const content = [
'# My Document',
'',
'Some intro text.',
'',
'---',
'key: value',
'other: stuff',
'---',
'',
'End of doc.',
].join('\n');
const result = extractFrontmatter(content);
assert.deepStrictEqual(result, {}, 'should return empty object when --- block is not at byte 0');
});
test('#2130: valid frontmatter at byte 0 still works (regression guard)', () => {
const content = [
'---',
'phase: 01',
'plan: 03',
'type: execute',
'wave: 1',
'depends_on: ["01-01", "01-02"]',
'files_modified:',
' - src/auth.ts',
' - src/middleware.ts',
'autonomous: true',
'---',
'',
'# Plan body here',
].join('\n');
const result = extractFrontmatter(content);
assert.strictEqual(result.phase, '01');
assert.strictEqual(result.plan, '03');
assert.strictEqual(result.type, 'execute');
assert.strictEqual(result.wave, '1');
assert.deepStrictEqual(result.depends_on, ['01-01', '01-02']);
assert.deepStrictEqual(result.files_modified, ['src/auth.ts', 'src/middleware.ts']);
assert.strictEqual(result.autonomous, 'true');
});
});
// ─── reconstructFrontmatter ─────────────────────────────────────────────────

View File

@@ -531,4 +531,46 @@ describe('init manager', () => {
assert.strictEqual(output.response_language, undefined);
});
test('all_complete is true when non-backlog phases are complete and 999.x exists (#2129)', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Setup', complete: true },
{ number: '2', name: 'Core', complete: true },
{ number: '3', name: 'Polish', complete: true },
{ number: '999.1', name: 'Backlog idea' },
]);
// Scaffold completed phases on disk
scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 2, summaries: 2 });
scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 });
scaffoldPhase(tmpDir, 3, { slug: 'polish', plans: 1, summaries: 1 });
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_complete, true, 'all_complete should be true when only 999.x phases remain incomplete');
});
test('all_complete false with incomplete non-backlog phase still produces recommended_actions (#2129)', () => {
writeState(tmpDir);
writeRoadmap(tmpDir, [
{ number: '1', name: 'Setup', complete: true },
{ number: '2', name: 'Core', complete: true },
{ number: '3', name: 'Polish' },
{ number: '999.1', name: 'Backlog idea' },
]);
scaffoldPhase(tmpDir, 1, { slug: 'setup', plans: 1, summaries: 1 });
scaffoldPhase(tmpDir, 2, { slug: 'core', plans: 1, summaries: 1 });
// Phase 3 has no directory — should trigger discuss recommendation
const result = runGsdTools('init manager', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.all_complete, false, 'all_complete should be false with phase 3 incomplete');
assert.ok(output.recommended_actions.length > 0, 'recommended_actions should not be empty when non-backlog phases remain');
});
});

View File

@@ -2330,6 +2330,83 @@ describe('phase complete updates Performance Metrics', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase complete — backlog phase (999.x) exclusion (#2129)
// ─────────────────────────────────────────────────────────────────────────────
describe('phase complete excludes 999.x backlog from next-phase (#2129)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('next phase skips 999.x backlog dirs and falls back to roadmap', () => {
// ROADMAP defines phases 1, 2, 3 and a backlog 999.1
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [ ] Phase 1: Setup',
'- [ ] Phase 2: Core',
'- [ ] Phase 3: Polish',
'- [ ] Phase 999.1: Backlog idea',
'',
'### Phase 1: Setup',
'**Goal:** Initial setup',
'',
'### Phase 2: Core',
'**Goal:** Build core',
'',
'### Phase 3: Polish',
'**Goal:** Polish everything',
'',
'### Phase 999.1: Backlog idea',
'**Goal:** Parked idea',
].join('\n')
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'# State',
'',
'**Current Phase:** 02',
'**Status:** In progress',
'**Current Plan:** 02-01',
'**Last Activity:** 2025-01-01',
'**Last Activity Description:** Working',
].join('\n')
);
// Phase 1 and 2 exist on disk, phase 3 does NOT exist yet, 999.1 DOES exist
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
const p2 = path.join(tmpDir, '.planning', 'phases', '02-core');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '# Summary');
// Backlog stub on disk — this is what triggers the bug
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-idea'), { recursive: true });
const result = runGsdTools('phase complete 2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// Should find phase 3 from roadmap, NOT 999.1 from filesystem
assert.strictEqual(output.next_phase, '3', 'next_phase should be 3, not 999.1');
assert.strictEqual(output.is_last_phase, false, 'should not be last phase');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// milestone complete command
// ─────────────────────────────────────────────────────────────────────────────