From 651b77bf5fb247c447b9bd3bd3e9980691fde694 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Tue, 24 Mar 2026 09:52:23 -0600 Subject: [PATCH] fix(gsd): prevent planning data loss from destructive upsert and post-unit re-import (#2370) insertTask() used INSERT OR REPLACE which in SQLite does DELETE + INSERT, zeroing planning columns (description, estimate, inputs, expected_output) when callers like handleCompleteTask didn't pass them. Changed to ON CONFLICT ... DO UPDATE SET with CASE/NULLIF preservation for planning columns. Removed post-unit migrateFromMarkdown hook that re-imported a lossy markdown subset after every auto-mode unit, overwriting DB planning data. Startup migration in auto-start.ts and dynamic-tools.ts remains. Removed vestigial "MUST write file" prompt instructions that conflict with the DB-backed tool workflow. Removed Steps section duplication in task plan renderer that re-rendered description as garbled bullets. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/gsd/auto-post-unit.ts | 10 ------- src/resources/extensions/gsd/gsd-db.ts | 26 +++++++++++++++++-- .../extensions/gsd/markdown-renderer.ts | 11 -------- .../extensions/gsd/prompts/plan-milestone.md | 2 -- .../extensions/gsd/prompts/plan-slice.md | 9 +++---- .../gsd/prompts/reassess-roadmap.md | 6 ++--- .../extensions/gsd/prompts/replan-slice.md | 6 ++--- 7 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index c7c4a654d..5c2f6293f 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -524,16 +524,6 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"continue" | "step-wizard" | "stopped"> { const { s, ctx, pi, buildSnapshotOpts, lockBase, stopAuto, pauseAuto, updateProgressWidget } = pctx; - // ── DB dual-write ── - if (isDbAvailable()) { - try { - const { migrateFromMarkdown } = await import("./md-importer.js"); - migrateFromMarkdown(s.basePath); - } catch (err) { - process.stderr.write(`gsd-db: re-import failed: ${(err as Error).message}\n`); - } - } - // ── Post-unit hooks ── if (s.currentUnit && !s.stepMode) { const hookUnit = checkPostUnitHooks(s.currentUnit.type, s.currentUnit.id, s.basePath); diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index abebb95dd..898905202 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -1061,7 +1061,7 @@ export function insertTask(t: { }): void { if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open"); currentDb.prepare( - `INSERT OR REPLACE INTO tasks ( + `INSERT INTO tasks ( milestone_id, slice_id, id, title, status, one_liner, narrative, verification_result, duration, completed_at, blocker_discovered, deviations, known_issues, key_files, key_decisions, full_summary_md, @@ -1071,7 +1071,29 @@ export function insertTask(t: { :verification_result, :duration, :completed_at, :blocker_discovered, :deviations, :known_issues, :key_files, :key_decisions, :full_summary_md, :description, :estimate, :files, :verify, :inputs, :expected_output, :observability_impact, :sequence - )`, + ) + ON CONFLICT(milestone_id, slice_id, id) DO UPDATE SET + title = CASE WHEN NULLIF(:title, '') IS NOT NULL THEN :title ELSE tasks.title END, + status = :status, + one_liner = :one_liner, + narrative = :narrative, + verification_result = :verification_result, + duration = :duration, + completed_at = :completed_at, + blocker_discovered = :blocker_discovered, + deviations = :deviations, + known_issues = :known_issues, + key_files = :key_files, + key_decisions = :key_decisions, + full_summary_md = :full_summary_md, + description = CASE WHEN NULLIF(:description, '') IS NOT NULL THEN :description ELSE tasks.description END, + estimate = CASE WHEN NULLIF(:estimate, '') IS NOT NULL THEN :estimate ELSE tasks.estimate END, + files = CASE WHEN NULLIF(:files, '[]') IS NOT NULL THEN :files ELSE tasks.files END, + verify = CASE WHEN NULLIF(:verify, '') IS NOT NULL THEN :verify ELSE tasks.verify END, + inputs = CASE WHEN NULLIF(:inputs, '[]') IS NOT NULL THEN :inputs ELSE tasks.inputs END, + expected_output = CASE WHEN NULLIF(:expected_output, '[]') IS NOT NULL THEN :expected_output ELSE tasks.expected_output END, + observability_impact = CASE WHEN NULLIF(:observability_impact, '') IS NOT NULL THEN :observability_impact ELSE tasks.observability_impact END, + sequence = :sequence`, ).run({ ":milestone_id": t.milestoneId, ":slice_id": t.sliceId, diff --git a/src/resources/extensions/gsd/markdown-renderer.ts b/src/resources/extensions/gsd/markdown-renderer.ts index 6e7b7ac23..567882335 100644 --- a/src/resources/extensions/gsd/markdown-renderer.ts +++ b/src/resources/extensions/gsd/markdown-renderer.ts @@ -213,17 +213,6 @@ function renderTaskPlanMarkdown(task: TaskRow): string { lines.push(""); } - lines.push("## Steps"); - lines.push(""); - if (task.description.trim()) { - for (const paragraph of task.description.split(/\n+/).map((line) => line.trim()).filter(Boolean)) { - lines.push(`- ${paragraph}`); - } - } else { - lines.push("- Implement the planned task work."); - } - lines.push(""); - lines.push("## Inputs"); lines.push(""); if (task.inputs.length > 0) { diff --git a/src/resources/extensions/gsd/prompts/plan-milestone.md b/src/resources/extensions/gsd/prompts/plan-milestone.md index 339ff629d..972ddfe61 100644 --- a/src/resources/extensions/gsd/prompts/plan-milestone.md +++ b/src/resources/extensions/gsd/prompts/plan-milestone.md @@ -107,6 +107,4 @@ If this milestone requires any external API keys or secrets: If this milestone does not require any external API keys or secrets, skip this step entirely — do not create an empty manifest. -**You MUST write the file `{{outputPath}}` before finishing.** - When done, say: "Milestone {{milestoneId}} planned." diff --git a/src/resources/extensions/gsd/prompts/plan-slice.md b/src/resources/extensions/gsd/prompts/plan-slice.md index 18d6abaec..3c05f993a 100644 --- a/src/resources/extensions/gsd/prompts/plan-slice.md +++ b/src/resources/extensions/gsd/prompts/plan-slice.md @@ -64,8 +64,7 @@ Then: - **Inputs and Expected Output must list concrete backtick-wrapped file paths** (e.g. `` `src/types.ts` ``). These are machine-parsed to derive task dependencies — vague prose without paths breaks parallel execution. Every task must have at least one output file path. - Observability Impact section **only if the task touches runtime boundaries, async flows, or error paths** — omit it otherwise 6. **Persist planning state through DB-backed tools.** Call `gsd_plan_slice` with the full slice planning payload (goal, demo, must-haves, verification, tasks, and metadata). Then call `gsd_plan_task` for each task to persist its planning fields. These tools write to the DB and render `{{outputPath}}` and `{{slicePath}}/tasks/T##-PLAN.md` files automatically. Do **not** rely on direct `PLAN.md` writes as the source of truth; the DB-backed tools are the canonical write path for slice and task planning state. -7. If `gsd_plan_slice` / `gsd_plan_task` are unavailable (tool not registered), fall back to writing `{{outputPath}}` and task plan files directly — but treat this as a degraded path, not the default. -8. **Self-audit the plan.** Walk through each check — if any fail, fix the plan files before moving on: +7. **Self-audit the plan.** Walk through each check — if any fail, fix the plan files before moving on: - **Completion semantics:** If every task were completed exactly as written, the slice goal/demo should actually be true. - **Requirement coverage:** Every must-have in the slice maps to at least one task. No must-have is orphaned. If `REQUIREMENTS.md` exists, every Active requirement this slice owns maps to at least one task. - **Task completeness:** Every task has steps, must-haves, verification, inputs, and expected output — none are blank or vague. Inputs and Expected Output list backtick-wrapped file paths, not prose descriptions. @@ -73,11 +72,9 @@ Then: - **Key links planned:** For every pair of artifacts that must connect, there is an explicit step that wires them. - **Scope sanity:** Target 2–5 steps and 3–8 files per task. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window. - **Feature completeness:** Every task produces real, user-facing progress — not just internal scaffolding. -9. If planning produced structural decisions, append them to `.gsd/DECISIONS.md` -10. {{commitInstruction}} +8. If planning produced structural decisions, append them to `.gsd/DECISIONS.md` +9. {{commitInstruction}} The slice directory and tasks/ subdirectory already exist. Do NOT mkdir. All work stays in your working directory: `{{workingDirectory}}`. -**You MUST write the file `{{outputPath}}` before finishing.** - When done, say: "Slice {{sliceId}} planned." diff --git a/src/resources/extensions/gsd/prompts/reassess-roadmap.md b/src/resources/extensions/gsd/prompts/reassess-roadmap.md index b56e58aa1..b59932c6a 100644 --- a/src/resources/extensions/gsd/prompts/reassess-roadmap.md +++ b/src/resources/extensions/gsd/prompts/reassess-roadmap.md @@ -54,12 +54,10 @@ Write `{{assessmentPath}}` with a brief confirmation that roadmap coverage still **If changes are needed:** -1. **Canonical write path — use `gsd_reassess_roadmap`:** If the `gsd_reassess_roadmap` tool is available, use it to persist the assessment and apply roadmap changes. Pass: `milestoneId`, `completedSliceId`, `verdict` (e.g. "roadmap-adjusted"), `assessment` (text explaining the decision), and `sliceChanges` with `modified` (array of sliceId, title, risk, depends, demo), `added` (same shape), `removed` (array of slice ID strings). The tool structurally enforces preservation of completed slices, writes the assessment to the DB, re-renders ROADMAP.md, and renders ASSESSMENT.md. Skip step 2 if this tool succeeds. -2. **Degraded fallback — direct file writes:** If the `gsd_reassess_roadmap` tool is not available, rewrite the remaining (unchecked) slices in `{{roadmapPath}}` directly. Do **not** bypass state with manual roadmap-only edits when `gsd_reassess_roadmap` is available. Keep completed slices exactly as they are (`[x]`). Update the boundary map for changed slices. Update the proof strategy if risks changed. Update requirement coverage if ownership or scope changed. +1. **Persist changes through `gsd_reassess_roadmap`.** Pass: `milestoneId`, `completedSliceId`, `verdict` (e.g. "roadmap-adjusted"), `assessment` (text explaining the decision), and `sliceChanges` with `modified` (array of sliceId, title, risk, depends, demo), `added` (same shape), `removed` (array of slice ID strings). The tool structurally enforces preservation of completed slices, writes the assessment to the DB, re-renders ROADMAP.md, and renders ASSESSMENT.md. Skip step 2 when this tool succeeds. +2. **Degraded fallback — direct file writes:** If `gsd_reassess_roadmap` is not available, rewrite the remaining (unchecked) slices in `{{roadmapPath}}` directly. Keep completed slices exactly as they are (`[x]`). Update the boundary map for changed slices. Update the proof strategy if risks changed. Update requirement coverage if ownership or scope changed. 3. Write `{{assessmentPath}}` explaining what changed and why — keep it brief and concrete. 4. If `.gsd/REQUIREMENTS.md` exists and requirement ownership or status changed, update it. 5. {{commitInstruction}} -**You MUST write the file `{{assessmentPath}}` before finishing.** - When done, say: "Roadmap reassessed." diff --git a/src/resources/extensions/gsd/prompts/replan-slice.md b/src/resources/extensions/gsd/prompts/replan-slice.md index 47e8de7ff..3185ce02f 100644 --- a/src/resources/extensions/gsd/prompts/replan-slice.md +++ b/src/resources/extensions/gsd/prompts/replan-slice.md @@ -32,8 +32,8 @@ Consider these captures when rewriting the remaining tasks — they represent th 1. Read the blocker task summary carefully. Understand exactly what was discovered and why it blocks the current plan. 2. Analyze the remaining `[ ]` tasks in the slice plan. Determine which are still valid, which need modification, and which should be replaced. -3. **Canonical write path — use `gsd_replan_slice`:** If the `gsd_replan_slice` tool is available, use it with the following parameters: `milestoneId`, `sliceId`, `blockerTaskId`, `blockerDescription`, `whatChanged`, `updatedTasks` (array of task objects with taskId, title, description, estimate, files, verify, inputs, expectedOutput), `removedTaskIds` (array of task ID strings). This is the canonical write path — it structurally enforces preservation of completed tasks, writes replan history to the DB, re-renders PLAN.md, and renders REPLAN.md. Skip steps 4–5 if this tool succeeds. -4. **Degraded fallback — direct file writes:** If the `gsd_replan_slice` tool is not available, fall back to writing files directly. Write `{{replanPath}}` documenting: +3. **Persist replan state through `gsd_replan_slice`.** Call it with the following parameters: `milestoneId`, `sliceId`, `blockerTaskId`, `blockerDescription`, `whatChanged`, `updatedTasks` (array of task objects with taskId, title, description, estimate, files, verify, inputs, expectedOutput), `removedTaskIds` (array of task ID strings). The tool structurally enforces preservation of completed tasks, writes replan history to the DB, re-renders PLAN.md, and renders REPLAN.md. Skip steps 4–5 when this tool succeeds. +4. **Degraded fallback — direct file writes:** If `gsd_replan_slice` is not available, fall back to writing files directly. Write `{{replanPath}}` documenting: - What blocker was discovered and in which task - What changed in the plan and why - Which incomplete tasks were modified, added, or removed @@ -47,6 +47,4 @@ Consider these captures when rewriting the remaining tasks — they represent th 6. If any incomplete task had a `T0x-PLAN.md`, remove or rewrite it to match the new task description. 7. Do not commit manually — the system auto-commits your changes after this unit completes. -**You MUST write `{{replanPath}}` and the updated slice plan before finishing.** - When done, say: "Slice {{sliceId}} replanned."