fix: dispatch plan-slice when task plan files are missing (#909) (#923)

When a slice plan (S03-PLAN.md) was pre-created during roadmapping
but plan-slice never ran to generate per-task files (tasks/T01-PLAN.md),
deriveState returned 'executing' phase. execute-task then failed because
the task plan didn't exist, creating an infinite restart loop.

Fix: In deriveState, when the tasks directory exists but has zero .md
files and the slice plan references tasks, return 'planning' phase
instead of 'executing'. This causes plan-slice to dispatch and generate
the missing task plans.

Tests updated: 6 test files that create synthetic state fixtures now
include a stub task plan file so their 'executing' phase assertions
remain valid.
This commit is contained in:
Tom Boucher 2026-03-17 15:59:34 -04:00 committed by GitHub
parent e0420f5981
commit 7869312769
7 changed files with 49 additions and 1 deletions

View file

@ -26,6 +26,7 @@ import {
resolveSlicePath,
resolveSliceFile,
resolveTaskFile,
resolveTasksDir,
resolveGsdRootFile,
gsdRoot,
} from './paths.js';
@ -34,6 +35,7 @@ import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
import { join, resolve } from 'path';
import { existsSync, readdirSync } from 'node:fs';
import { debugCount, debugTime } from './debug-logger.js';
// ─── Query Functions ───────────────────────────────────────────────────────
@ -573,6 +575,34 @@ async function _deriveStateImpl(basePath: string): Promise<GSDState> {
title: activeTaskEntry.title,
};
// ── Task plan file check (#909) ──────────────────────────────────────
// The slice plan may reference tasks but per-task plan files may be
// missing — e.g. when the slice plan was pre-created during roadmapping.
// If the tasks dir exists but has literally zero files (empty dir from
// mkdir), fall back to planning so plan-slice generates task plans.
const tasksDir = resolveTasksDir(basePath, activeMilestone.id, activeSlice.id);
if (tasksDir && existsSync(tasksDir) && slicePlan.tasks.length > 0) {
const allFiles = readdirSync(tasksDir).filter(f => f.endsWith(".md"));
if (allFiles.length === 0) {
return {
activeMilestone,
activeSlice,
activeTask: null,
phase: 'planning',
recentDecisions: [],
blockers: [],
nextAction: `Task plan files missing for ${activeSlice.id}. Run plan-slice to generate task plans.`,
registry,
requirements,
progress: {
milestones: milestoneProgress,
slices: sliceProgress,
tasks: taskProgress,
},
};
}
}
// ── Blocker detection: scan completed task summaries ──────────────────
// If any completed task has blocker_discovered: true and no REPLAN.md
// exists yet, transition to replanning-slice instead of executing.

View file

@ -103,6 +103,7 @@ async function main(): Promise<void> {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
writeFile(base, 'REQUIREMENTS.md', REQUIREMENTS_CONTENT);
// Derive state from files only (no DB)
@ -166,6 +167,7 @@ async function main(): Promise<void> {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
// No DB open — isDbAvailable() is false
assertTrue(!isDbAvailable(), 'fallback: DB is not available');
@ -189,6 +191,7 @@ async function main(): Promise<void> {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
// Open DB but insert nothing — empty artifacts table
openDatabase(':memory:');
@ -219,6 +222,7 @@ async function main(): Promise<void> {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
writeFile(base, 'REQUIREMENTS.md', REQUIREMENTS_CONTENT);
// Open DB but only insert the roadmap — plan and requirements missing from DB
@ -348,6 +352,7 @@ async function main(): Promise<void> {
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
writeFile(base, 'milestones/M001/slices/S01/tasks/.gitkeep', '');
writeFile(base, 'milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01 Plan');
openDatabase(':memory:');
insertArtifactRow('milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT, {

View file

@ -45,6 +45,7 @@ function writeContext(base: string, mid: string, frontmatter: string): void {
function writeSlicePlan(base: string, mid: string, sid: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(join(dir, 'tasks'), { recursive: true });
writeFileSync(join(dir, "tasks", "T01-PLAN.md"), "# T01 Plan\n");
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
}

View file

@ -45,6 +45,7 @@ function writeRoadmap(base: string, mid: string, content: string): void {
function writePlan(base: string, mid: string, sid: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(join(dir, 'tasks'), { recursive: true });
writeFileSync(join(dir, "tasks", "T01-PLAN.md"), "# T01 Plan\n");
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
}

View file

@ -22,8 +22,17 @@ function writeRoadmap(base: string, mid: string, content: string): void {
function writePlan(base: string, mid: string, sid: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(join(dir, 'tasks'), { recursive: true });
const tasksDir = join(dir, 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
// Create stub task plan files for any tasks in the plan content (#909)
// so deriveState doesn't fall back to planning phase.
const taskMatches = content.matchAll(/\*\*(T\d+):/g);
for (const m of taskMatches) {
const tid = m[1];
const planPath = join(tasksDir, `${tid}-PLAN.md`);
writeFileSync(planPath, `# ${tid} Plan\n\nTask plan stub for testing.\n`);
}
}
function writeContinue(base: string, mid: string, sid: string, content: string): void {

View file

@ -42,6 +42,7 @@ function writeRoadmap(base: string, mid: string, content: string): void {
function writePlan(base: string, mid: string, sid: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(join(dir, 'tasks'), { recursive: true });
writeFileSync(join(dir, "tasks", "T01-PLAN.md"), "# T01 Plan\n");
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
}

View file

@ -40,6 +40,7 @@ function writeRoadmap(base: string, mid: string, content: string): void {
function writePlan(base: string, mid: string, sid: string, content: string): void {
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
mkdirSync(join(dir, 'tasks'), { recursive: true });
writeFileSync(join(dir, "tasks", "T01-PLAN.md"), "# T01 Plan\n");
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
}