test(S02/T02): Implement DB-backed gsd_plan_slice and gsd_plan_task han…

- .gsd/milestones/M001/slices/S02/S02-PLAN.md
- src/resources/extensions/gsd/tools/plan-slice.ts
- src/resources/extensions/gsd/tools/plan-task.ts
- src/resources/extensions/gsd/bootstrap/db-tools.ts
- src/resources/extensions/gsd/gsd-db.ts
- src/resources/extensions/gsd/tests/plan-slice.test.ts
- src/resources/extensions/gsd/tests/plan-task.test.ts
This commit is contained in:
TÂCHES 2026-03-23 10:05:11 -06:00
parent 752b26d542
commit a380b8ed77
9 changed files with 883 additions and 1 deletions

View file

@ -20,6 +20,7 @@
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts`
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/markdown-renderer.test.ts src/resources/extensions/gsd/tests/auto-recovery.test.ts src/resources/extensions/gsd/tests/prompt-contracts.test.ts --test-name-pattern="plan-slice|plan-task|renderPlanFromDb|renderTaskPlanFromDb|task plan|DB-backed planning"`
- `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts --test-name-pattern="validation failed|render failed|cache|missing parent"`
## Observability / Diagnostics
@ -44,7 +45,7 @@ Im splitting this into three tasks because there are three distinct failure b
- Do: Implement `renderPlanFromDb()` and `renderTaskPlanFromDb()` using existing DB query helpers, emit slice/task markdown that preserves `parsePlan()` and `parseTaskPlanFile()` expectations, include conservative task-plan frontmatter (`estimated_steps`, `estimated_files`, `skills_used`), and add tests that prove rendered slice plans plus task plan files satisfy `verifyExpectedArtifact("plan-slice", ...)`.
- Verify: `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/markdown-renderer.test.ts src/resources/extensions/gsd/tests/auto-recovery.test.ts --test-name-pattern="renderPlanFromDb|renderTaskPlanFromDb|plan-slice|task plan"`
- Done when: DB rows can be rendered into `S##-PLAN.md` and `tasks/T##-PLAN.md` files that parse cleanly and pass the existing plan-slice runtime artifact checks.
- [ ] **T02: Implement and register gsd_plan_slice and gsd_plan_task** `est:1.5h`
- [x] **T02: Implement and register gsd_plan_slice and gsd_plan_task** `est:1.5h`
- Why: This delivers the actual S02 capability: flat DB-backed planning tools for slices and tasks that write structured planning state, render truthful markdown, and clear stale caches after success.
- Files: `src/resources/extensions/gsd/tools/plan-slice.ts`, `src/resources/extensions/gsd/tools/plan-task.ts`, `src/resources/extensions/gsd/bootstrap/db-tools.ts`, `src/resources/extensions/gsd/gsd-db.ts`, `src/resources/extensions/gsd/tests/plan-slice.test.ts`, `src/resources/extensions/gsd/tests/plan-task.test.ts`
- Do: Follow the S01 handler pattern exactly for both tools, add any missing DB upsert/query helpers needed to populate task planning fields and retrieve slice/task planning state, register canonical tools plus aliases in `db-tools.ts`, and test validation, missing-parent rejection, transactional DB writes, render-failure handling, idempotent reruns, and observable cache invalidation.

View file

@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M001/S02/T01",
"timestamp": 1774281533617,
"passed": false,
"discoverySource": "package-json",
"checks": [
{
"command": "npm run test",
"exitCode": 1,
"durationMs": 11123,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,60 @@
---
id: T02
parent: S02
milestone: M001
key_files:
- .gsd/milestones/M001/slices/S02/S02-PLAN.md
- src/resources/extensions/gsd/tools/plan-slice.ts
- src/resources/extensions/gsd/tools/plan-task.ts
- src/resources/extensions/gsd/bootstrap/db-tools.ts
- src/resources/extensions/gsd/gsd-db.ts
- src/resources/extensions/gsd/tests/plan-slice.test.ts
- src/resources/extensions/gsd/tests/plan-task.test.ts
key_decisions:
- Slice/task planning writes use dedicated `upsertTaskPlanning()` updates layered on top of `insertTask()` seed rows so rerunning planning does not erase execution/completion fields stored on existing tasks.
- `handlePlanSlice()` follows a DB-first flow that writes slice/task planning rows transactionally, then renders the slice plan plus all task-plan files; cache invalidation remains post-render only, and observability is proven through parse-visible file state rather than internal spies.
- `handlePlanTask()` creates a pending task row only when absent, then updates planning fields and renders the task plan artifact, preserving idempotence for reruns against existing tasks.
duration: ""
verification_result: passed
completed_at: 2026-03-23T16:05:04.223Z
blocker_discovered: false
---
# T02: Implement DB-backed gsd_plan_slice and gsd_plan_task handlers with registrations and regression tests
**Implement DB-backed gsd_plan_slice and gsd_plan_task handlers with registrations and regression tests**
## What Happened
Implemented the DB-backed slice/task planning write path for S02. I first verified the local contracts in `plan-milestone.ts`, `db-tools.ts`, `gsd-db.ts`, `markdown-renderer.ts`, and the existing renderer/handler tests, then patched the slice plans verification section with an explicit diagnostic check because the pre-flight called that gap out. Added `src/resources/extensions/gsd/tools/plan-slice.ts` and `src/resources/extensions/gsd/tools/plan-task.ts`, each mirroring the S01 pattern: flat validation, parent-slice existence checks, DB writes, renderer invocation, and cache invalidation only after successful render. In `gsd-db.ts` I added `upsertTaskPlanning()` and extended the planning record shape with optional title support so planning reruns update task planning fields without overwriting completion metadata. In `src/resources/extensions/gsd/bootstrap/db-tools.ts` I registered canonical `gsd_plan_slice` and `gsd_plan_task` tools plus aliases `gsd_slice_plan` and `gsd_task_plan`, with DB-availability checks and structured handler result payloads. Finally, I added focused regression suites in `src/resources/extensions/gsd/tests/plan-slice.test.ts` and `src/resources/extensions/gsd/tests/plan-task.test.ts` covering validation failures, missing-parent rejection, successful DB-backed renders, render-failure behavior, idempotent reruns, and parse-visible cache refresh behavior via reparsed plan artifacts.
## Verification
Verified the new handlers with the tasks targeted resolver-harness command for `plan-slice.test.ts` and `plan-task.test.ts`; all validation, parent-check, render-failure, idempotence, and parse-visible cache refresh assertions passed. Then ran the tasks second verification command against `plan-slice.test.ts`, `plan-task.test.ts`, and `markdown-renderer.test.ts` filtered to cache/idempotence/render-failure coverage; it passed and preserved truthful stale-render diagnostics on stderr. Finally ran the broader slice-level verification command including `markdown-renderer.test.ts`, `auto-recovery.test.ts`, and `prompt-contracts.test.ts` filtered to plan-slice/plan-task and DB-backed planning coverage; it passed, confirming the new handlers coexist with existing renderer/recovery/prompt contracts.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts` | 0 | ✅ pass | 180ms |
| 2 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts src/resources/extensions/gsd/tests/markdown-renderer.test.ts --test-name-pattern="cache|idempotent|render failed|validation failed|plan-slice|plan-task"` | 0 | ✅ pass | 228ms |
| 3 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/plan-slice.test.ts src/resources/extensions/gsd/tests/plan-task.test.ts src/resources/extensions/gsd/tests/markdown-renderer.test.ts src/resources/extensions/gsd/tests/auto-recovery.test.ts src/resources/extensions/gsd/tests/prompt-contracts.test.ts --test-name-pattern="plan-slice|plan-task|renderPlanFromDb|renderTaskPlanFromDb|task plan|DB-backed planning"` | 0 | ✅ pass | 731ms |
## Deviations
Updated `.gsd/milestones/M001/slices/S02/S02-PLAN.md` with an explicit diagnostic verification command to satisfy the task pre-flight requirement. The implementation reused the existing DB schema and renderer contracts already present locally, so no broader replan was needed. I also added a narrow `upsertTaskPlanning()` DB helper instead of changing `insertTask()` semantics, because planning reruns must not clobber completion-state fields.
## Known Issues
None.
## Files Created/Modified
- `.gsd/milestones/M001/slices/S02/S02-PLAN.md`
- `src/resources/extensions/gsd/tools/plan-slice.ts`
- `src/resources/extensions/gsd/tools/plan-task.ts`
- `src/resources/extensions/gsd/bootstrap/db-tools.ts`
- `src/resources/extensions/gsd/gsd-db.ts`
- `src/resources/extensions/gsd/tests/plan-slice.test.ts`
- `src/resources/extensions/gsd/tests/plan-task.test.ts`

View file

@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@gsd/pi-coding-agent";
import { findMilestoneIds, nextMilestoneId, claimReservedId, getReservedMilestoneIds } from "../guided-flow.js";
import { loadEffectiveGSDPreferences } from "../preferences.js";
import { ensureDbOpen } from "./dynamic-tools.js";
import { StringEnum } from "@gsd/pi-ai";
/**
* Register an alias tool that shares the same execute function as its canonical counterpart.
@ -382,6 +383,153 @@ export function registerDbTools(pi: ExtensionAPI): void {
pi.registerTool(planMilestoneTool);
registerAlias(pi, planMilestoneTool, "gsd_milestone_plan", "gsd_plan_milestone");
// ─── gsd_plan_slice (gsd_slice_plan alias) ─────────────────────────────
const planSliceExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot plan slice." }],
details: { operation: "plan_slice", error: "db_unavailable" } as any,
};
}
try {
const { handlePlanSlice } = await import("../tools/plan-slice.js");
const result = await handlePlanSlice(params, process.cwd());
if ("error" in result) {
return {
content: [{ type: "text" as const, text: `Error planning slice: ${result.error}` }],
details: { operation: "plan_slice", error: result.error } as any,
};
}
return {
content: [{ type: "text" as const, text: `Planned slice ${result.sliceId} (${result.milestoneId})` }],
details: {
operation: "plan_slice",
milestoneId: result.milestoneId,
sliceId: result.sliceId,
planPath: result.planPath,
taskPlanPaths: result.taskPlanPaths,
} as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: plan_slice tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error planning slice: ${msg}` }],
details: { operation: "plan_slice", error: msg } as any,
};
}
};
const planSliceTool = {
name: "gsd_plan_slice",
label: "Plan Slice",
description:
"Write slice planning state to the GSD database, render S##-PLAN.md plus task PLAN artifacts from DB, and clear caches after a successful render.",
promptSnippet: "Plan a slice via DB write + PLAN render + cache invalidation",
promptGuidelines: [
"Use gsd_plan_slice for slice planning instead of writing S##-PLAN.md or task PLAN files directly.",
"Keep parameters flat and provide the full slice planning payload, including tasks.",
"The tool validates input, requires an existing parent slice, writes slice/task planning data, renders PLAN.md and task plan files from DB, and clears both state and parse caches after success.",
"Use the canonical name gsd_plan_slice; gsd_slice_plan is only an alias.",
],
parameters: Type.Object({
milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }),
sliceId: Type.String({ description: "Slice ID (e.g. S01)" }),
goal: Type.String({ description: "Slice goal" }),
successCriteria: Type.String({ description: "Slice success criteria block" }),
proofLevel: Type.String({ description: "Slice proof level" }),
integrationClosure: Type.String({ description: "Slice integration closure" }),
observabilityImpact: Type.String({ description: "Slice observability impact" }),
tasks: Type.Array(Type.Object({
taskId: Type.String({ description: "Task ID (e.g. T01)" }),
title: Type.String({ description: "Task title" }),
description: Type.String({ description: "Task description / steps block" }),
estimate: Type.String({ description: "Task estimate string" }),
files: Type.Array(Type.String(), { description: "Files likely touched" }),
verify: Type.String({ description: "Verification command or block" }),
inputs: Type.Array(Type.String(), { description: "Input files or references" }),
expectedOutput: Type.Array(Type.String(), { description: "Expected output files or artifacts" }),
observabilityImpact: Type.Optional(Type.String({ description: "Task observability impact" })),
}), { description: "Planned tasks for the slice" }),
}),
execute: planSliceExecute,
};
pi.registerTool(planSliceTool);
registerAlias(pi, planSliceTool, "gsd_slice_plan", "gsd_plan_slice");
// ─── gsd_plan_task (gsd_task_plan alias) ───────────────────────────────
const planTaskExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot plan task." }],
details: { operation: "plan_task", error: "db_unavailable" } as any,
};
}
try {
const { handlePlanTask } = await import("../tools/plan-task.js");
const result = await handlePlanTask(params, process.cwd());
if ("error" in result) {
return {
content: [{ type: "text" as const, text: `Error planning task: ${result.error}` }],
details: { operation: "plan_task", error: result.error } as any,
};
}
return {
content: [{ type: "text" as const, text: `Planned task ${result.taskId} (${result.sliceId}/${result.milestoneId})` }],
details: {
operation: "plan_task",
milestoneId: result.milestoneId,
sliceId: result.sliceId,
taskId: result.taskId,
taskPlanPath: result.taskPlanPath,
} as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`gsd-db: plan_task tool failed: ${msg}\n`);
return {
content: [{ type: "text" as const, text: `Error planning task: ${msg}` }],
details: { operation: "plan_task", error: msg } as any,
};
}
};
const planTaskTool = {
name: "gsd_plan_task",
label: "Plan Task",
description:
"Write task planning state to the GSD database, render tasks/T##-PLAN.md from DB, and clear caches after a successful render.",
promptSnippet: "Plan a task via DB write + task PLAN render + cache invalidation",
promptGuidelines: [
"Use gsd_plan_task for task planning instead of writing tasks/T##-PLAN.md directly.",
"Keep parameters flat and provide the full task planning payload.",
"The tool validates input, requires an existing parent slice, writes task planning data, renders the task PLAN file from DB, and clears both state and parse caches after success.",
"Use the canonical name gsd_plan_task; gsd_task_plan is only an alias.",
],
parameters: Type.Object({
milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }),
sliceId: Type.String({ description: "Slice ID (e.g. S01)" }),
taskId: Type.String({ description: "Task ID (e.g. T01)" }),
title: Type.String({ description: "Task title" }),
description: Type.String({ description: "Task description / steps block" }),
estimate: Type.String({ description: "Task estimate string" }),
files: Type.Array(Type.String(), { description: "Files likely touched" }),
verify: Type.String({ description: "Verification command or block" }),
inputs: Type.Array(Type.String(), { description: "Input files or references" }),
expectedOutput: Type.Array(Type.String(), { description: "Expected output files or artifacts" }),
observabilityImpact: Type.Optional(Type.String({ description: "Task observability impact" })),
}),
execute: planTaskExecute,
};
pi.registerTool(planTaskTool);
registerAlias(pi, planTaskTool, "gsd_task_plan", "gsd_plan_task");
// ─── gsd_task_complete (gsd_complete_task alias) ────────────────────────
const taskCompleteExecute = async (_toolCallId: any, params: any, _signal: any, _onUpdate: any, _ctx: any) => {

View file

@ -877,6 +877,7 @@ export interface SlicePlanningRecord {
}
export interface TaskPlanningRecord {
title?: string;
description: string;
estimate: string;
files: string[];
@ -1087,6 +1088,34 @@ export function updateTaskStatus(milestoneId: string, sliceId: string, taskId: s
});
}
export function upsertTaskPlanning(milestoneId: string, sliceId: string, taskId: string, planning: Partial<TaskPlanningRecord>): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb.prepare(
`UPDATE tasks SET
title = COALESCE(:title, title),
description = COALESCE(:description, description),
estimate = COALESCE(:estimate, estimate),
files = COALESCE(:files, files),
verify = COALESCE(:verify, verify),
inputs = COALESCE(:inputs, inputs),
expected_output = COALESCE(:expected_output, expected_output),
observability_impact = COALESCE(:observability_impact, observability_impact)
WHERE milestone_id = :milestone_id AND slice_id = :slice_id AND id = :id`,
).run({
":milestone_id": milestoneId,
":slice_id": sliceId,
":id": taskId,
":title": planning.title ?? null,
":description": planning.description ?? null,
":estimate": planning.estimate ?? null,
":files": planning.files ? JSON.stringify(planning.files) : null,
":verify": planning.verify ?? null,
":inputs": planning.inputs ? JSON.stringify(planning.inputs) : null,
":expected_output": planning.expectedOutput ? JSON.stringify(planning.expectedOutput) : null,
":observability_impact": planning.observabilityImpact ?? null,
});
}
export interface SliceRow {
milestone_id: string;
id: string;

View file

@ -0,0 +1,178 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { openDatabase, closeDatabase, insertMilestone, insertSlice, getSlice, getSliceTasks, getTask } from '../gsd-db.ts';
import { handlePlanSlice } from '../tools/plan-slice.ts';
import { parsePlan, parseTaskPlanFile } from '../files.ts';
function makeTmpBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-plan-slice-'));
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks'), { recursive: true });
return base;
}
function cleanup(base: string): void {
try { closeDatabase(); } catch { /* noop */ }
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
}
function seedParentSlice(): void {
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.' });
}
function validParams() {
return {
milestoneId: 'M001',
sliceId: 'S02',
goal: 'Persist slice planning through the DB.',
successCriteria: '- Slice plan renders from DB\n- Task plan files are regenerated',
proofLevel: 'integration',
integrationClosure: 'Planning handlers now write DB rows and render plan artifacts.',
observabilityImpact: '- Validation failures return structured errors\n- Cache invalidation is proven by parse-visible state updates',
tasks: [
{
taskId: 'T01',
title: 'Write slice handler',
description: 'Implement the slice planning handler.',
estimate: '45m',
files: ['src/resources/extensions/gsd/tools/plan-slice.ts'],
verify: 'node --test src/resources/extensions/gsd/tests/plan-slice.test.ts',
inputs: ['src/resources/extensions/gsd/tools/plan-milestone.ts'],
expectedOutput: ['src/resources/extensions/gsd/tools/plan-slice.ts'],
observabilityImpact: 'Tests exercise cache invalidation and render failure paths.',
},
{
taskId: 'T02',
title: 'Write task handler',
description: 'Implement the task planning handler.',
estimate: '30m',
files: ['src/resources/extensions/gsd/tools/plan-task.ts'],
verify: 'node --test src/resources/extensions/gsd/tests/plan-task.test.ts',
inputs: ['src/resources/extensions/gsd/tools/plan-task.ts'],
expectedOutput: ['src/resources/extensions/gsd/tests/plan-task.test.ts'],
observabilityImpact: 'Task-plan renders remain parse-compatible.',
},
],
};
}
test('handlePlanSlice writes slice/task planning state and renders plan artifacts', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParentSlice();
const result = await handlePlanSlice(validParams(), base);
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
const slice = getSlice('M001', 'S02');
assert.ok(slice);
assert.equal(slice?.goal, 'Persist slice planning through the DB.');
assert.equal(slice?.proof_level, 'integration');
const tasks = getSliceTasks('M001', 'S02');
assert.equal(tasks.length, 2);
assert.equal(tasks[0]?.title, 'Write slice handler');
assert.equal(tasks[0]?.description, 'Implement the slice planning handler.');
assert.equal(tasks[1]?.estimate, '30m');
const planPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md');
assert.ok(existsSync(planPath), 'slice plan should be rendered to disk');
const parsedPlan = parsePlan(readFileSync(planPath, 'utf-8'));
assert.equal(parsedPlan.goal, 'Persist slice planning through the DB.');
assert.equal(parsedPlan.tasks.length, 2);
assert.equal(parsedPlan.tasks[0]?.id, 'T01');
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T01-PLAN.md');
assert.ok(existsSync(taskPlanPath), 'task plan should be rendered to disk');
const taskPlan = parseTaskPlanFile(readFileSync(taskPlanPath, 'utf-8'));
assert.deepEqual(taskPlan.frontmatter.skills_used, []);
} finally {
cleanup(base);
}
});
test('handlePlanSlice rejects invalid payloads', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParentSlice();
const result = await handlePlanSlice({ ...validParams(), tasks: [] }, base);
assert.ok('error' in result);
assert.match(result.error, /validation failed: tasks must be a non-empty array/);
} finally {
cleanup(base);
}
});
test('handlePlanSlice rejects missing parent slice', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
const result = await handlePlanSlice(validParams(), base);
assert.ok('error' in result);
assert.match(result.error, /missing parent slice: M001\/S02/);
} finally {
cleanup(base);
}
});
test('handlePlanSlice surfaces render failures without changing parse-visible task-plan state for the failing task', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParentSlice();
const failingTaskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T01-PLAN.md');
writeFileSync(failingTaskPlanPath, '---\nestimated_steps: 1\nestimated_files: 1\nskills_used: []\n---\n\n# T01: Cached task\n', 'utf-8');
rmSync(failingTaskPlanPath, { force: true });
mkdirSync(failingTaskPlanPath, { recursive: true });
const result = await handlePlanSlice(validParams(), base);
assert.ok('error' in result);
assert.match(result.error, /render failed:/);
assert.ok(existsSync(failingTaskPlanPath), 'failing task plan path should remain the blocking directory');
assert.equal(getTask('M001', 'S02', 'T01')?.description, 'Implement the slice planning handler.');
} finally {
cleanup(base);
}
});
test('handlePlanSlice reruns idempotently and refreshes parse-visible state', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParentSlice();
writeFileSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md'), '# S02: Cached\n\n**Goal:** old value\n\n## Tasks\n\n- [ ] **T01: Cached task**\n', 'utf-8');
const first = await handlePlanSlice(validParams(), base);
assert.ok(!('error' in first));
const second = await handlePlanSlice({
...validParams(),
goal: 'Updated goal from rerun.',
tasks: [
{ ...validParams().tasks[0], description: 'Updated slice handler description.' },
validParams().tasks[1],
],
}, base);
assert.ok(!('error' in second));
const parsedAfter = parsePlan(readFileSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md'), 'utf-8'));
assert.equal(parsedAfter.goal, 'Updated goal from rerun.');
const task = getTask('M001', 'S02', 'T01');
assert.equal(task?.description, 'Updated slice handler description.');
} finally {
cleanup(base);
}
});

View file

@ -0,0 +1,145 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, getTask } from '../gsd-db.ts';
import { handlePlanTask } from '../tools/plan-task.ts';
import { parseTaskPlanFile } from '../files.ts';
function makeTmpBase(): string {
const base = mkdtempSync(join(tmpdir(), 'gsd-plan-task-'));
mkdirSync(join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks'), { recursive: true });
return base;
}
function cleanup(base: string): void {
try { closeDatabase(); } catch { /* noop */ }
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
}
function seedParent(): void {
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
insertSlice({ id: 'S02', milestoneId: 'M001', title: 'Planning slice', status: 'pending', demo: 'Rendered plans exist.' });
}
function validParams() {
return {
milestoneId: 'M001',
sliceId: 'S02',
taskId: 'T02',
title: 'Write task handler',
description: 'Implement the DB-backed task planning handler.',
estimate: '30m',
files: ['src/resources/extensions/gsd/tools/plan-task.ts'],
verify: 'node --test src/resources/extensions/gsd/tests/plan-task.test.ts',
inputs: ['src/resources/extensions/gsd/tools/plan-task.ts'],
expectedOutput: ['src/resources/extensions/gsd/tests/plan-task.test.ts'],
observabilityImpact: 'Tests exercise validation, render failure, and cache refresh behavior.',
};
}
test('handlePlanTask writes planning state and renders task plan', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParent();
const result = await handlePlanTask(validParams(), base);
assert.ok(!('error' in result), `unexpected error: ${'error' in result ? result.error : ''}`);
const task = getTask('M001', 'S02', 'T02');
assert.ok(task);
assert.equal(task?.title, 'Write task handler');
assert.equal(task?.description, 'Implement the DB-backed task planning handler.');
assert.equal(task?.estimate, '30m');
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T02-PLAN.md');
assert.ok(existsSync(taskPlanPath), 'task plan should be rendered to disk');
const taskPlan = parseTaskPlanFile(readFileSync(taskPlanPath, 'utf-8'));
assert.equal(taskPlan.frontmatter.estimated_files, 1);
assert.deepEqual(taskPlan.frontmatter.skills_used, []);
} finally {
cleanup(base);
}
});
test('handlePlanTask rejects invalid payloads', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParent();
const result = await handlePlanTask({ ...validParams(), files: [''] }, base);
assert.ok('error' in result);
assert.match(result.error, /validation failed: files must contain only non-empty strings/);
} finally {
cleanup(base);
}
});
test('handlePlanTask rejects missing parent slice', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
insertMilestone({ id: 'M001', title: 'Milestone', status: 'active' });
const result = await handlePlanTask(validParams(), base);
assert.ok('error' in result);
assert.match(result.error, /missing parent slice: M001\/S02/);
} finally {
cleanup(base);
}
});
test('handlePlanTask surfaces render failures without changing parse-visible task plan state', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParent();
insertTask({ id: 'T02', sliceId: 'S02', milestoneId: 'M001', title: 'Cached task', status: 'pending' });
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T02-PLAN.md');
writeFileSync(taskPlanPath, '---\nestimated_steps: 1\nestimated_files: 1\nskills_used: []\n---\n\n# T02: Cached task\n', 'utf-8');
rmSync(taskPlanPath, { force: true });
mkdirSync(taskPlanPath, { recursive: true });
const result = await handlePlanTask(validParams(), base);
assert.ok('error' in result);
assert.match(result.error, /render failed:/);
} finally {
cleanup(base);
}
});
test('handlePlanTask reruns idempotently and refreshes parse-visible state', async () => {
const base = makeTmpBase();
openDatabase(join(base, '.gsd', 'gsd.db'));
try {
seedParent();
const taskPlanPath = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'tasks', 'T02-PLAN.md');
writeFileSync(taskPlanPath, '---\nestimated_steps: 1\nestimated_files: 1\nskills_used: []\n---\n\n# T02: Cached task\n', 'utf-8');
const first = await handlePlanTask(validParams(), base);
assert.ok(!('error' in first));
const second = await handlePlanTask({
...validParams(),
description: 'Updated task handler description.',
estimate: '1h',
}, base);
assert.ok(!('error' in second));
const task = getTask('M001', 'S02', 'T02');
assert.equal(task?.description, 'Updated task handler description.');
assert.equal(task?.estimate, '1h');
const parsed = parseTaskPlanFile(readFileSync(taskPlanPath, 'utf-8'));
assert.equal(parsed.frontmatter.estimated_steps, 1);
assert.match(readFileSync(taskPlanPath, 'utf-8'), /Updated task handler description\./);
} finally {
cleanup(base);
}
});

View file

@ -0,0 +1,189 @@
import { clearParseCache } from "../files.js";
import {
transaction,
getSlice,
insertTask,
upsertSlicePlanning,
upsertTaskPlanning,
} from "../gsd-db.js";
import { invalidateStateCache } from "../state.js";
import { renderPlanFromDb } from "../markdown-renderer.js";
export interface PlanSliceTaskInput {
taskId: string;
title: string;
description: string;
estimate: string;
files: string[];
verify: string;
inputs: string[];
expectedOutput: string[];
observabilityImpact?: string;
}
export interface PlanSliceParams {
milestoneId: string;
sliceId: string;
goal: string;
successCriteria: string;
proofLevel: string;
integrationClosure: string;
observabilityImpact: string;
tasks: PlanSliceTaskInput[];
}
export interface PlanSliceResult {
milestoneId: string;
sliceId: string;
planPath: string;
taskPlanPaths: string[];
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validateStringArray(value: unknown, field: string): string[] {
if (!Array.isArray(value)) {
throw new Error(`${field} must be an array`);
}
if (value.some((item) => !isNonEmptyString(item))) {
throw new Error(`${field} must contain only non-empty strings`);
}
return value;
}
function validateTasks(value: unknown): PlanSliceTaskInput[] {
if (!Array.isArray(value) || value.length === 0) {
throw new Error("tasks must be a non-empty array");
}
const seen = new Set<string>();
return value.map((entry, index) => {
if (!entry || typeof entry !== "object") {
throw new Error(`tasks[${index}] must be an object`);
}
const obj = entry as Record<string, unknown>;
const taskId = obj.taskId;
const title = obj.title;
const description = obj.description;
const estimate = obj.estimate;
const files = obj.files;
const verify = obj.verify;
const inputs = obj.inputs;
const expectedOutput = obj.expectedOutput;
const observabilityImpact = obj.observabilityImpact;
if (!isNonEmptyString(taskId)) throw new Error(`tasks[${index}].taskId must be a non-empty string`);
if (seen.has(taskId)) throw new Error(`tasks[${index}].taskId must be unique`);
seen.add(taskId);
if (!isNonEmptyString(title)) throw new Error(`tasks[${index}].title must be a non-empty string`);
if (!isNonEmptyString(description)) throw new Error(`tasks[${index}].description must be a non-empty string`);
if (!isNonEmptyString(estimate)) throw new Error(`tasks[${index}].estimate must be a non-empty string`);
if (!Array.isArray(files) || files.some((item) => !isNonEmptyString(item))) {
throw new Error(`tasks[${index}].files must be an array of non-empty strings`);
}
if (!isNonEmptyString(verify)) throw new Error(`tasks[${index}].verify must be a non-empty string`);
if (!Array.isArray(inputs) || inputs.some((item) => !isNonEmptyString(item))) {
throw new Error(`tasks[${index}].inputs must be an array of non-empty strings`);
}
if (!Array.isArray(expectedOutput) || expectedOutput.some((item) => !isNonEmptyString(item))) {
throw new Error(`tasks[${index}].expectedOutput must be an array of non-empty strings`);
}
if (observabilityImpact !== undefined && !isNonEmptyString(observabilityImpact)) {
throw new Error(`tasks[${index}].observabilityImpact must be a non-empty string when provided`);
}
return {
taskId,
title,
description,
estimate,
files,
verify,
inputs,
expectedOutput,
observabilityImpact: typeof observabilityImpact === "string" ? observabilityImpact : "",
};
});
}
function validateParams(params: PlanSliceParams): PlanSliceParams {
if (!isNonEmptyString(params?.milestoneId)) throw new Error("milestoneId is required");
if (!isNonEmptyString(params?.sliceId)) throw new Error("sliceId is required");
if (!isNonEmptyString(params?.goal)) throw new Error("goal is required");
if (!isNonEmptyString(params?.successCriteria)) throw new Error("successCriteria is required");
if (!isNonEmptyString(params?.proofLevel)) throw new Error("proofLevel is required");
if (!isNonEmptyString(params?.integrationClosure)) throw new Error("integrationClosure is required");
if (!isNonEmptyString(params?.observabilityImpact)) throw new Error("observabilityImpact is required");
return {
...params,
tasks: validateTasks(params.tasks),
};
}
export async function handlePlanSlice(
rawParams: PlanSliceParams,
basePath: string,
): Promise<PlanSliceResult | { error: string }> {
let params: PlanSliceParams;
try {
params = validateParams(rawParams);
} catch (err) {
return { error: `validation failed: ${(err as Error).message}` };
}
const parentSlice = getSlice(params.milestoneId, params.sliceId);
if (!parentSlice) {
return { error: `missing parent slice: ${params.milestoneId}/${params.sliceId}` };
}
try {
transaction(() => {
upsertSlicePlanning(params.milestoneId, params.sliceId, {
goal: params.goal,
successCriteria: params.successCriteria,
proofLevel: params.proofLevel,
integrationClosure: params.integrationClosure,
observabilityImpact: params.observabilityImpact,
});
for (const task of params.tasks) {
insertTask({
id: task.taskId,
sliceId: params.sliceId,
milestoneId: params.milestoneId,
title: task.title,
status: "pending",
});
upsertTaskPlanning(params.milestoneId, params.sliceId, task.taskId, {
title: task.title,
description: task.description,
estimate: task.estimate,
files: task.files,
verify: task.verify,
inputs: task.inputs,
expectedOutput: task.expectedOutput,
observabilityImpact: task.observabilityImpact ?? "",
});
}
});
} catch (err) {
return { error: `db write failed: ${(err as Error).message}` };
}
try {
const renderResult = await renderPlanFromDb(basePath, params.milestoneId, params.sliceId);
invalidateStateCache();
clearParseCache();
return {
milestoneId: params.milestoneId,
sliceId: params.sliceId,
planPath: renderResult.planPath,
taskPlanPaths: renderResult.taskPlanPaths,
};
} catch (err) {
return { error: `render failed: ${(err as Error).message}` };
}
}

View file

@ -0,0 +1,114 @@
import { clearParseCache } from "../files.js";
import { getSlice, getTask, insertTask, upsertTaskPlanning } from "../gsd-db.js";
import { invalidateStateCache } from "../state.js";
import { renderTaskPlanFromDb } from "../markdown-renderer.js";
export interface PlanTaskParams {
milestoneId: string;
sliceId: string;
taskId: string;
title: string;
description: string;
estimate: string;
files: string[];
verify: string;
inputs: string[];
expectedOutput: string[];
observabilityImpact?: string;
}
export interface PlanTaskResult {
milestoneId: string;
sliceId: string;
taskId: string;
taskPlanPath: string;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function validateStringArray(value: unknown, field: string): string[] {
if (!Array.isArray(value)) {
throw new Error(`${field} must be an array`);
}
if (value.some((item) => !isNonEmptyString(item))) {
throw new Error(`${field} must contain only non-empty strings`);
}
return value;
}
function validateParams(params: PlanTaskParams): PlanTaskParams {
if (!isNonEmptyString(params?.milestoneId)) throw new Error("milestoneId is required");
if (!isNonEmptyString(params?.sliceId)) throw new Error("sliceId is required");
if (!isNonEmptyString(params?.taskId)) throw new Error("taskId is required");
if (!isNonEmptyString(params?.title)) throw new Error("title is required");
if (!isNonEmptyString(params?.description)) throw new Error("description is required");
if (!isNonEmptyString(params?.estimate)) throw new Error("estimate is required");
if (!isNonEmptyString(params?.verify)) throw new Error("verify is required");
if (params.observabilityImpact !== undefined && !isNonEmptyString(params.observabilityImpact)) {
throw new Error("observabilityImpact must be a non-empty string when provided");
}
return {
...params,
files: validateStringArray(params.files, "files"),
inputs: validateStringArray(params.inputs, "inputs"),
expectedOutput: validateStringArray(params.expectedOutput, "expectedOutput"),
};
}
export async function handlePlanTask(
rawParams: PlanTaskParams,
basePath: string,
): Promise<PlanTaskResult | { error: string }> {
let params: PlanTaskParams;
try {
params = validateParams(rawParams);
} catch (err) {
return { error: `validation failed: ${(err as Error).message}` };
}
const parentSlice = getSlice(params.milestoneId, params.sliceId);
if (!parentSlice) {
return { error: `missing parent slice: ${params.milestoneId}/${params.sliceId}` };
}
try {
if (!getTask(params.milestoneId, params.sliceId, params.taskId)) {
insertTask({
id: params.taskId,
sliceId: params.sliceId,
milestoneId: params.milestoneId,
title: params.title,
status: "pending",
});
}
upsertTaskPlanning(params.milestoneId, params.sliceId, params.taskId, {
title: params.title,
description: params.description,
estimate: params.estimate,
files: params.files,
verify: params.verify,
inputs: params.inputs,
expectedOutput: params.expectedOutput,
observabilityImpact: params.observabilityImpact ?? "",
});
} catch (err) {
return { error: `db write failed: ${(err as Error).message}` };
}
try {
const renderResult = await renderTaskPlanFromDb(basePath, params.milestoneId, params.sliceId, params.taskId);
invalidateStateCache();
clearParseCache();
return {
milestoneId: params.milestoneId,
sliceId: params.sliceId,
taskId: params.taskId,
taskPlanPath: renderResult.taskPlanPath,
};
} catch (err) {
return { error: `render failed: ${(err as Error).message}` };
}
}