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

@@ -67,6 +67,8 @@ phase: 09-foundation
plan: 03
wave: 2
autonomous: true
depends_on:
- 09-01
---
<objective>
@@ -304,4 +306,203 @@ describe('phasePlanIndex', () => {
expect(data.error).toBe('Phase not found');
expect(data.plans).toEqual([]);
});
// ── #3266 regression tests ─────────────────────────────────────────────
it('#3266: wave 0 round-trip — plan with wave: 0 lands in waves["0"] with PlanInfo.wave === 0', async () => {
const phase11 = join(tmpDir, '.planning', 'phases', '11-wave-zero');
await mkdir(phase11, { recursive: true });
await writeFile(join(phase11, '11-01-PLAN.md'), [
'---',
'phase: 11',
'plan: 01',
'wave: 0',
'autonomous: true',
'depends_on: []',
'---',
'<objective>',
'Bootstrap step.',
'</objective>',
].join('\n'));
const result = await phasePlanIndex(['11'], tmpDir);
const data = result.data as Record<string, unknown>;
const plans = data.plans as Array<Record<string, unknown>>;
const waves = data.waves as Record<string, string[]>;
const plan = plans.find(p => p.id === '11-01');
expect(plan).toBeDefined();
// wave must be 0, not coerced to 1
expect(plan!.wave).toBe(0);
// bucketed under "0"
expect(waves['0']).toContain('11-01');
expect(waves['1']).toBeUndefined();
});
it('#3266: DAG topological grouping — B depends_on A → B lands in a later bucket', async () => {
const phase12 = join(tmpDir, '.planning', 'phases', '12-dag');
await mkdir(phase12, { recursive: true });
await writeFile(join(phase12, '12-01-PLAN.md'), [
'---',
'phase: 12',
'plan: 01',
'wave: 1',
'autonomous: true',
'depends_on: []',
'---',
'<objective>',
'Plan A — no deps.',
'</objective>',
].join('\n'));
await writeFile(join(phase12, '12-02-PLAN.md'), [
'---',
'phase: 12',
'plan: 02',
'wave: 1',
'autonomous: true',
'depends_on:',
' - 12-01',
'---',
'<objective>',
'Plan B — depends on A.',
'</objective>',
].join('\n'));
const result = await phasePlanIndex(['12'], tmpDir);
const data = result.data as Record<string, unknown>;
const plans = data.plans as Array<Record<string, unknown>>;
const waves = data.waves as Record<string, string[]>;
const planA = plans.find(p => p.id === '12-01');
const planB = plans.find(p => p.id === '12-02');
expect(planA).toBeDefined();
expect(planB).toBeDefined();
// A must be in an earlier bucket than B
expect(planA!.wave).toBeLessThan(planB!.wave as number);
// Structurally: A in wave 1, B in wave 2 (1-indexed, no wave:0 declared)
expect(waves['1']).toContain('12-01');
expect(waves['2']).toContain('12-02');
// depends_on field populated on PlanInfo
expect(planB!.depends_on).toEqual(['12-01']);
expect(planA!.depends_on).toEqual([]);
});
it('#3266: declared-vs-computed mismatch surfaces a warning in the result', async () => {
const phase13 = join(tmpDir, '.planning', 'phases', '13-mismatch');
await mkdir(phase13, { recursive: true });
await writeFile(join(phase13, '13-01-PLAN.md'), [
'---',
'phase: 13',
'plan: 01',
'wave: 1',
'autonomous: true',
'depends_on: []',
'---',
'<objective>Plan A.</objective>',
].join('\n'));
// B claims wave: 1 but depends on A → topo says wave 2
await writeFile(join(phase13, '13-02-PLAN.md'), [
'---',
'phase: 13',
'plan: 02',
'wave: 1',
'autonomous: true',
'depends_on:',
' - 13-01',
'---',
'<objective>Plan B — wrong wave declaration.</objective>',
].join('\n'));
const result = await phasePlanIndex(['13'], tmpDir);
const data = result.data as Record<string, unknown>;
const warnings = data.warnings as string[] | undefined;
expect(warnings).toBeDefined();
expect(Array.isArray(warnings)).toBe(true);
expect(warnings!.length).toBeGreaterThan(0);
// Warning must name the plan ID and both wave numbers
const w = warnings![0];
expect(w).toContain('13-02');
expect(w).toContain('1'); // declared
expect(w).toContain('2'); // computed
});
it('#3266: cycle detection throws GSDError naming the cycle nodes', async () => {
const phase14 = join(tmpDir, '.planning', 'phases', '14-cycle');
await mkdir(phase14, { recursive: true });
// A → B → A (cycle)
await writeFile(join(phase14, '14-01-PLAN.md'), [
'---',
'phase: 14',
'plan: 01',
'wave: 1',
'autonomous: true',
'depends_on:',
' - 14-02',
'---',
'<objective>Plan A depends on B.</objective>',
].join('\n'));
await writeFile(join(phase14, '14-02-PLAN.md'), [
'---',
'phase: 14',
'plan: 02',
'wave: 2',
'autonomous: true',
'depends_on:',
' - 14-01',
'---',
'<objective>Plan B depends on A.</objective>',
].join('\n'));
let thrownError: unknown;
try {
await phasePlanIndex(['14'], tmpDir);
} catch (err) {
thrownError = err;
}
expect(thrownError).toBeInstanceOf(GSDError);
const msg = (thrownError as GSDError).message;
// Message must mention cycle and name the nodes
expect(msg).toContain('cycle');
expect(msg).toMatch(/14-0[12]/);
});
it('#3266: PlanInfo.depends_on is populated from frontmatter', async () => {
const phase15 = join(tmpDir, '.planning', 'phases', '15-deps-field');
await mkdir(phase15, { recursive: true });
await writeFile(join(phase15, '15-01-PLAN.md'), [
'---',
'phase: 15',
'plan: 01',
'wave: 1',
'autonomous: true',
'depends_on: []',
'---',
'<objective>Plan A.</objective>',
].join('\n'));
await writeFile(join(phase15, '15-02-PLAN.md'), [
'---',
'phase: 15',
'plan: 02',
'wave: 2',
'autonomous: true',
'depends_on:',
' - 15-01',
'---',
'<objective>Plan B.</objective>',
].join('\n'));
const result = await phasePlanIndex(['15'], tmpDir);
const data = result.data as Record<string, unknown>;
const plans = data.plans as Array<Record<string, unknown>>;
const planA = plans.find(p => p.id === '15-01');
const planB = plans.find(p => p.id === '15-02');
expect(planA!.depends_on).toEqual([]);
expect(planB!.depends_on).toEqual(['15-01']);
});
});

View File

@@ -288,10 +288,20 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
})
);
const plans: Array<Record<string, unknown>> = [];
const waves: Record<string, string[]> = {};
const incomplete: string[] = [];
let hasCheckpoints = false;
// ── Pass 1: parse each plan file ─────────────────────────────────────────
interface RawPlan {
id: string;
declaredWave: number | null;
dependsOn: string[];
autonomous: boolean;
objective: string | null;
filesModified: string[];
taskCount: number;
hasSummary: boolean;
}
const rawPlans: RawPlan[] = [];
for (const planFile of planFiles) {
// For named plans (01-01-PLAN.md): strip suffix to get '01-01'
@@ -306,8 +316,20 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
const taskCount = xmlTasks.length || mdTasks.length;
// Parse wave as integer
const wave = parseInt(String(fm.wave), 10) || 1;
// Parse wave as integer — use nullish handling so wave: 0 is preserved.
// parseInt returns NaN for missing/non-numeric values; fall back to null
// (meaning "no declared wave") so downstream can apply the topo default.
const parsedWave = parseInt(String(fm.wave), 10);
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
// Parse depends_on — normalise to string[]
let dependsOn: string[] = [];
const fmDeps = fm['depends_on'] as string | string[] | undefined;
if (Array.isArray(fmDeps)) {
dependsOn = fmDeps.map(String);
} else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
dependsOn = [fmDeps];
}
// Parse autonomous (default true if not specified)
let autonomous = true;
@@ -315,10 +337,6 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
autonomous = fm.autonomous === 'true' || fm.autonomous === true;
}
if (!autonomous) {
hasCheckpoints = true;
}
// Parse files_modified
let filesModified: string[] = [];
const fmFiles = (fm['files_modified'] || fm['files-modified']) as string | string[] | undefined;
@@ -327,37 +345,144 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
}
const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
if (!hasSummary) {
incomplete.push(planId);
}
const plan = {
rawPlans.push({
id: planId,
wave,
declaredWave,
dependsOn,
autonomous,
objective: extractObjective(content) || (fm.objective as string) || null,
files_modified: filesModified,
task_count: taskCount,
has_summary: hasSummary,
filesModified,
taskCount,
hasSummary,
});
}
// ── Pass 2: topological level assignment via depends_on DAG ──────────────
// Build a map from plan ID → RawPlan for fast lookup.
// Deps that reference plans outside this phase are silently ignored (treated
// as already-satisfied external deps — the plan becomes a source node).
const planMap = new Map<string, RawPlan>(rawPlans.map(p => [p.id, p]));
// Secondary index: canonical prefix → full plan ID, so depends_on: ['03-01'] resolves
// to '03-01-auth-hardening-PLAN.md'-derived ID '03-01-auth-hardening' (k015).
const canonicalToId = new Map<string, string>(rawPlans.map(p => [extractCanonicalPlanId(p.id), p.id]));
// Kahn's algorithm — compute in-degree and adjacency for plans in this phase only.
const level = new Map<string, number>();
const inDeg = new Map<string, number>();
const adj = new Map<string, string[]>(); // dep → [dependents]
for (const p of rawPlans) {
if (!inDeg.has(p.id)) inDeg.set(p.id, 0);
if (!adj.has(p.id)) adj.set(p.id, []);
for (const dep of p.dependsOn) {
// Accept both full-stem ('03-01-auth-hardening') and canonical-prefix ('03-01') forms.
const resolvedDep = planMap.has(dep) ? dep : canonicalToId.get(dep);
if (!resolvedDep) continue; // external dep — ignore
if (!adj.has(resolvedDep)) adj.set(resolvedDep, []);
adj.get(resolvedDep)!.push(p.id);
inDeg.set(p.id, (inDeg.get(p.id) ?? 0) + 1);
}
}
// Start with nodes that have no in-phase dependencies.
const queue: string[] = [];
for (const p of rawPlans) {
if ((inDeg.get(p.id) ?? 0) === 0) {
queue.push(p.id);
level.set(p.id, 0);
}
}
let visited = 0;
while (queue.length > 0) {
const cur = queue.shift()!;
visited++;
const curLevel = level.get(cur)!;
for (const dep of (adj.get(cur) ?? [])) {
const newLevel = curLevel + 1;
if (newLevel > (level.get(dep) ?? -1)) {
level.set(dep, newLevel);
}
inDeg.set(dep, inDeg.get(dep)! - 1);
if (inDeg.get(dep) === 0) {
queue.push(dep);
}
}
}
// Cycle detection — any node not visited has a cycle.
if (visited < rawPlans.length) {
const cycleNodes = rawPlans.filter(p => !level.has(p.id)).map(p => p.id);
throw new GSDError(
`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`,
ErrorClassification.Execution,
);
}
// ── Pass 3: determine lowest bucket key and build output ─────────────────
// If any plan has declared wave: 0, the lowest level maps to "0"; otherwise "1".
const anyWaveZero = rawPlans.some(p => p.declaredWave === 0);
const levelOffset = anyWaveZero ? 0 : 1;
const plans: Array<Record<string, unknown>> = [];
const waves: Record<string, string[]> = {};
const incomplete: string[] = [];
let hasCheckpoints = false;
const warnings: string[] = [];
for (const raw of rawPlans) {
if (!raw.autonomous) {
hasCheckpoints = true;
}
if (!raw.hasSummary) {
incomplete.push(raw.id);
}
// Computed wave = topological level + offset (so lowest level → 0 or 1).
const computedWave = (level.get(raw.id) ?? 0) + levelOffset;
// The effective wave used for bucketing is always the computed topo level.
// If the plan declared a wave that disagrees, emit a non-fatal warning.
const effectiveWave = computedWave;
if (raw.declaredWave !== null && raw.declaredWave !== computedWave) {
warnings.push(
`Plan ${raw.id}: declared wave: ${raw.declaredWave} but depends_on DAG places it in wave ${computedWave}`,
);
}
const plan: Record<string, unknown> = {
id: raw.id,
wave: effectiveWave,
depends_on: raw.dependsOn,
autonomous: raw.autonomous,
objective: raw.objective,
files_modified: raw.filesModified,
task_count: raw.taskCount,
has_summary: raw.hasSummary,
};
plans.push(plan);
// Group by wave
const waveKey = String(wave);
const waveKey = String(effectiveWave);
if (!waves[waveKey]) {
waves[waveKey] = [];
}
waves[waveKey].push(planId);
waves[waveKey].push(raw.id);
}
return {
data: {
phase: normalized,
plans,
waves,
incomplete,
has_checkpoints: hasCheckpoints,
},
const result: Record<string, unknown> = {
phase: normalized,
plans,
waves,
incomplete,
has_checkpoints: hasCheckpoints,
};
if (warnings.length > 0) {
result['warnings'] = warnings;
}
return { data: result };
};