fix(3266): preserve wave 0 and bucket plans by depends_on DAG in phase-plan-index (#3276)

* fix(3266): preserve wave 0 and bucket plans by depends_on DAG in phase-plan-index

Fixes two cooperating bugs in the phase-plan-index builder:

1. Wave 0 collapse: `parseInt(...) || 1` coerced parsed value `0` to `1` due to
   JS falsy default. Fixed with `Number.isNaN` guard.
2. depends_on ignored: wave-bucketing used only the `wave:` frontmatter field.
   Now replaced with Kahn's topological-level algorithm over `depends_on`:
   source nodes (no in-phase deps) → lowest level; each plan's level = max(deps'
   levels) + 1.  Declared `wave:` that disagrees with computed level emits a
   non-fatal warning on the result.  Cycle detection throws GSDError.

`PlanInfo` gains `depends_on: string[]`. `PhasePlanIndex` gains `warnings?: string[]`.
Both TS (`sdk/src/query/phase.ts`) and CJS twin (`get-shit-done/bin/lib/phase.cjs`)
fixed identically.

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

* chore: add changeset for #3276

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

* fix(phase): resolve depends_on against canonical plan id (#3276 CR)

Build a secondary `canonicalToId` index alongside `planMap` so that a
dependency declared as '03-01' resolves to a descriptive plan stored
under '03-01-auth-hardening', preventing silent wave-ordering failures.
Applied at both DAG construction sites in phase.cjs and the SDK's
phase.ts (k014 parity). Regression test added.

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-05-09 00:25:05 -04:00
committed by GitHub
parent 8bc255c266
commit 8d5f509edf
7 changed files with 576 additions and 79 deletions

View File

@@ -129,7 +129,7 @@ describe('execute-phase docs: user-facing wave flag', () => {
});
describe('phase-plan-index: wave grouping behavior', () => {
test('phase-plan-index groups plans by wave frontmatter field', () => {
test('phase-plan-index groups plans by wave (DAG-bucketing: P002 depends on P001)', () => {
// allow-test-rule: behavioral — calls gsd-tools and asserts structured output
const fs = require('fs');
const path = require('path');
@@ -138,12 +138,13 @@ describe('phase-plan-index: wave grouping behavior', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-alpha');
fs.mkdirSync(phaseDir, { recursive: true });
// Wave 1 plan
// Wave 1 plan — no dependencies
fs.writeFileSync(path.join(phaseDir, 'P001-PLAN.md'), [
'---',
'wave: 1',
'objective: First wave task',
'autonomous: true',
'depends_on: []',
'---',
'',
'# Plan 001',
@@ -153,12 +154,14 @@ describe('phase-plan-index: wave grouping behavior', () => {
'<task>Do the thing</task>',
].join('\n'));
// Wave 2 plan
// Wave 2 plan — depends on P001 so DAG places it in level 1 → wave 2
fs.writeFileSync(path.join(phaseDir, 'P002-PLAN.md'), [
'---',
'wave: 2',
'objective: Second wave task',
'autonomous: true',
'depends_on:',
' - P001',
'---',
'',
'# Plan 002',
@@ -185,6 +188,8 @@ describe('phase-plan-index: wave grouping behavior', () => {
assert.ok(p002, 'P002 should be in plans array');
assert.equal(p001.wave, 1, 'P001 should have wave=1');
assert.equal(p002.wave, 2, 'P002 should have wave=2');
// No mismatch warning: declared wave 2 matches topo level 2
assert.strictEqual(data.warnings, undefined, 'no warnings when declared wave matches DAG');
} finally {
cleanup(tmpDir);
}

View File

@@ -393,44 +393,52 @@ files-modified: [prisma/schema.prisma, src/lib/db.ts]
assert.strictEqual(output.plans[0].has_summary, false, 'no summary yet');
});
test('groups multiple plans by wave', () => {
test('groups multiple plans by wave (DAG-bucketing: 03-03 depends on 03-01 and 03-02)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(
path.join(phaseDir, '03-01-PLAN.md'),
`---
wave: 1
autonomous: true
objective: Database setup
---
## Task 1: Schema
`
[
'---',
'wave: 1',
'autonomous: true',
'objective: Database setup',
'depends_on: []',
'---',
'',
'## Task 1: Schema',
].join('\n')
);
fs.writeFileSync(
path.join(phaseDir, '03-02-PLAN.md'),
`---
wave: 1
autonomous: true
objective: Auth setup
---
## Task 1: JWT
`
[
'---',
'wave: 1',
'autonomous: true',
'objective: Auth setup',
'depends_on: []',
'---',
'',
'## Task 1: JWT',
].join('\n')
);
fs.writeFileSync(
path.join(phaseDir, '03-03-PLAN.md'),
`---
wave: 2
autonomous: false
objective: API routes
---
## Task 1: Routes
`
[
'---',
'wave: 2',
'autonomous: false',
'objective: API routes',
'depends_on:',
' - 03-01',
' - 03-02',
'---',
'',
'## Task 1: Routes',
].join('\n')
);
const result = runGsdTools('phase-plan-index 03', tmpDir);
@@ -440,6 +448,8 @@ objective: API routes
assert.strictEqual(output.plans.length, 3, 'should have 3 plans');
assert.deepStrictEqual(output.waves['1'], ['03-01', '03-02'], 'wave 1 has 2 plans');
assert.deepStrictEqual(output.waves['2'], ['03-03'], 'wave 2 has 1 plan');
// No mismatch warning: declared wave 2 matches topo level 2
assert.strictEqual(output.warnings, undefined, 'no warnings when declared wave matches DAG');
});
test('detects incomplete plans (no matching summary)', () => {
@@ -477,6 +487,42 @@ objective: API routes
assert.deepStrictEqual(output.incomplete, [], 'plan should not be marked incomplete');
});
// #3266 CR — depends_on canonical-id mismatch: a plan named
// '03-01-auth-hardening-PLAN.md' is stored with id '03-01-auth-hardening',
// but a dependency declared as '03-01' was never resolving to it, silently
// putting the dependent plan in the same wave as its prerequisite.
test('depends_on short canonical prefix resolves against descriptive plan filename (#3266)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
// Plan 01: descriptive filename — id becomes '03-01-auth-hardening'
fs.writeFileSync(
path.join(phaseDir, '03-01-auth-hardening-PLAN.md'),
`---\nwave: 1\n---\n## Task 1\n`,
);
// Plan 02: depends on the canonical prefix '03-01' (not the full stem)
fs.writeFileSync(
path.join(phaseDir, '03-02-followup-PLAN.md'),
`---\ndepends_on:\n - '03-01'\n---\n## Task 1\n`,
);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const waves = output.waves;
// Plan 01 must be in an earlier wave than plan 02
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-01')));
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-02')));
assert.ok(wave01 !== undefined, 'plan 03-01-auth-hardening should appear in waves');
assert.ok(wave02 !== undefined, 'plan 03-02-followup should appear in waves');
assert.ok(
Number(wave01) < Number(wave02),
`03-02 must be in a later wave than 03-01 (got wave01=${wave01}, wave02=${wave02})`,
);
});
test('detects checkpoints (autonomous: false)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });