From a380b8ed77340d43801ecffe165f3166428a7a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 23 Mar 2026 10:05:11 -0600 Subject: [PATCH] =?UTF-8?q?test(S02/T02):=20Implement=20DB-backed=20gsd=5F?= =?UTF-8?q?plan=5Fslice=20and=20gsd=5Fplan=5Ftask=20han=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .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 --- .gsd/milestones/M001/slices/S02/S02-PLAN.md | 3 +- .../M001/slices/S02/tasks/T01-VERIFY.json | 18 ++ .../M001/slices/S02/tasks/T02-SUMMARY.md | 60 ++++++ .../extensions/gsd/bootstrap/db-tools.ts | 148 ++++++++++++++ src/resources/extensions/gsd/gsd-db.ts | 29 +++ .../extensions/gsd/tests/plan-slice.test.ts | 178 +++++++++++++++++ .../extensions/gsd/tests/plan-task.test.ts | 145 ++++++++++++++ .../extensions/gsd/tools/plan-slice.ts | 189 ++++++++++++++++++ .../extensions/gsd/tools/plan-task.ts | 114 +++++++++++ 9 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 .gsd/milestones/M001/slices/S02/tasks/T01-VERIFY.json create mode 100644 .gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md create mode 100644 src/resources/extensions/gsd/tests/plan-slice.test.ts create mode 100644 src/resources/extensions/gsd/tests/plan-task.test.ts create mode 100644 src/resources/extensions/gsd/tools/plan-slice.ts create mode 100644 src/resources/extensions/gsd/tools/plan-task.ts diff --git a/.gsd/milestones/M001/slices/S02/S02-PLAN.md b/.gsd/milestones/M001/slices/S02/S02-PLAN.md index 856404f42..2688998cc 100644 --- a/.gsd/milestones/M001/slices/S02/S02-PLAN.md +++ b/.gsd/milestones/M001/slices/S02/S02-PLAN.md @@ -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 @@ I’m 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. diff --git a/.gsd/milestones/M001/slices/S02/tasks/T01-VERIFY.json b/.gsd/milestones/M001/slices/S02/tasks/T01-VERIFY.json new file mode 100644 index 000000000..f41f48982 --- /dev/null +++ b/.gsd/milestones/M001/slices/S02/tasks/T01-VERIFY.json @@ -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 +} diff --git a/.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md b/.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md new file mode 100644 index 000000000..6cd7e67b3 --- /dev/null +++ b/.gsd/milestones/M001/slices/S02/tasks/T02-SUMMARY.md @@ -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 plan’s 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 task’s 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 task’s 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` diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 1b361dbca..4a1d73779 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -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) => { diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index c13aa7f2a..e62f96ca5 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -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): 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; diff --git a/src/resources/extensions/gsd/tests/plan-slice.test.ts b/src/resources/extensions/gsd/tests/plan-slice.test.ts new file mode 100644 index 000000000..a6be17f0e --- /dev/null +++ b/src/resources/extensions/gsd/tests/plan-slice.test.ts @@ -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); + } +}); diff --git a/src/resources/extensions/gsd/tests/plan-task.test.ts b/src/resources/extensions/gsd/tests/plan-task.test.ts new file mode 100644 index 000000000..d09532b20 --- /dev/null +++ b/src/resources/extensions/gsd/tests/plan-task.test.ts @@ -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); + } +}); diff --git a/src/resources/extensions/gsd/tools/plan-slice.ts b/src/resources/extensions/gsd/tools/plan-slice.ts new file mode 100644 index 000000000..1b4c49cdf --- /dev/null +++ b/src/resources/extensions/gsd/tools/plan-slice.ts @@ -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(); + return value.map((entry, index) => { + if (!entry || typeof entry !== "object") { + throw new Error(`tasks[${index}] must be an object`); + } + const obj = entry as Record; + 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 { + 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}` }; + } +} diff --git a/src/resources/extensions/gsd/tools/plan-task.ts b/src/resources/extensions/gsd/tools/plan-task.ts new file mode 100644 index 000000000..bd57dd500 --- /dev/null +++ b/src/resources/extensions/gsd/tools/plan-task.ts @@ -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 { + 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}` }; + } +}