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) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-24 09:52:23 -06:00
parent ef9a38c802
commit 651b77bf5f
7 changed files with 31 additions and 39 deletions

View file

@ -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);

View file

@ -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,

View file

@ -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) {

View file

@ -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."

View file

@ -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 25 steps and 38 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."

View file

@ -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."

View file

@ -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 45 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 45 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."