From ebfc63c42b5954aa33a916a69324ad3994356015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Tue, 24 Mar 2026 15:55:26 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20post-migration=20cleanup=20=E2=80=94=20p?= =?UTF-8?q?ragmas,=20rollbacks,=20tool=20gaps,=20stale=20code=20(#2410)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gsd-db): add PRAGMA busy_timeout and foreign_keys Concurrent worktrees sharing a WAL-mode DB get immediate SQLITE_BUSY errors without a retry window. Add busy_timeout = 5000ms for file-backed DBs. Enable foreign_keys per-connection so FK constraints declared in the schema are actually enforced — prevents orphaned rows in slices, tasks, verification_evidence, etc. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(prompts): replace direct file writes with DB tool calls plan-milestone.md single-slice fast path instructed mkdir + direct file writes, bypassing gsd_plan_slice. discuss.md instructed writing ROADMAP.md directly instead of calling gsd_plan_milestone. Both create state where the DB has no knowledge of planning artifacts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(recover): wrap delete + repopulate in single transaction handleRecover deleted hierarchy rows inside a transaction, then called migrateHierarchyToDb() outside it. A crash mid-repopulate left a partially populated DB. Wrap both operations in one dbTransaction() call for atomicity. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(tools): add rollback on render failure plan-milestone and plan-slice committed DB transactions then rendered markdown — if rendering failed, DB had planning data with no file on disk. db-writer functions (saveDecisionToDb, updateRequirementInDb, saveArtifactToDb) had the same issue: DB upsert before disk write with no rollback. Add rollback logic matching the complete-task.ts pattern. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(gsd): add gsd_complete_milestone tool and rogue detection gaps Task and slice completion had DB-backed tools but milestone completion used direct file writes. Add gsd_complete_milestone following the same pattern: validate all slices complete, update DB status in transaction, render SUMMARY.md, rollback on failure. Extend detectRogueFileWrites() to cover reassess-roadmap (ASSESSMENT.md), plan-task (T##-PLAN.md), and REPLAN.md — previously undetected bypass paths. Replace regex checkbox fallback in retry state-reset with explicit failure + stderr log. Direct markdown mutation on DB-unavailable reintroduced the pattern the migration eliminated. Co-Authored-By: Claude Opus 4.6 (1M context) * chore(gsd): update stale comments, add legacy markers, DB-first queries - state.ts: update module header and deriveState docstring to reflect DB-primary architecture. Add DB-first query to getActiveMilestoneId() with filesystem fallback. Add LEGACY marker on _deriveStateImpl(). - commands-maintenance.ts: add DB query before parseRoadmap() for stale branch cleanup. - prompts: replace "toggles the checkbox" language with DB-accurate descriptions in execute-task.md and complete-slice.md. - auto-recovery.ts: add LEGACY markers on !isDbAvailable() fallback branches. - gsd-db.ts: add DEAD CODE annotations on sequence column definitions (no tool exposes sequence — always defaults to 0). Co-Authored-By: Claude Opus 4.6 (1M context) * fix(tools): preserve DB rows on render failure instead of rolling back The plan-milestone and plan-slice handlers were rolling back DB rows when file rendering failed, destroying parse-visible state needed for debugging. DB rows now persist on render failure. Also guard rollback references to non-existent tables (slice_planning, task_planning, milestone_planning). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../extensions/gsd/auto-post-unit.ts | 61 ++++-- src/resources/extensions/gsd/auto-recovery.ts | 13 +- .../extensions/gsd/bootstrap/db-tools.ts | 69 +++++++ .../extensions/gsd/commands-maintenance.ts | 25 ++- src/resources/extensions/gsd/db-writer.ts | 31 ++- src/resources/extensions/gsd/gsd-db.ts | 6 +- .../extensions/gsd/prompts/complete-slice.md | 4 +- .../extensions/gsd/prompts/discuss.md | 4 +- .../extensions/gsd/prompts/execute-task.md | 4 +- .../extensions/gsd/prompts/plan-milestone.md | 12 +- src/resources/extensions/gsd/state.ts | 47 +++-- .../extensions/gsd/tests/tool-naming.test.ts | 3 +- .../gsd/tools/complete-milestone.ts | 176 ++++++++++++++++++ .../extensions/gsd/tools/plan-milestone.ts | 9 +- .../extensions/gsd/tools/plan-slice.ts | 9 +- 15 files changed, 401 insertions(+), 72 deletions(-) create mode 100644 src/resources/extensions/gsd/tools/complete-milestone.ts diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 5c2f6293f..21c675e2a 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -38,7 +38,7 @@ import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.j import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js"; import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js"; import { syncStateToProjectRoot } from "./auto-worktree-sync.js"; -import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus } from "./gsd-db.js"; +import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, _getAdapter } from "./gsd-db.js"; import { renderPlanCheckboxes } from "./markdown-renderer.js"; import { consumeSignal } from "./session-status-io.js"; import { @@ -147,6 +147,40 @@ export function detectRogueFileWrites( if (!hasPlanningState) { rogues.push({ path: planPath, unitType, unitId }); } + + // Also check for rogue REPLAN.md + const replanPath = resolveSliceFile(basePath, mid, sid, "REPLAN"); + if (replanPath && existsSync(replanPath) && !hasPlanningState) { + rogues.push({ path: replanPath, unitType, unitId }); + } + } else if (unitType === "reassess-roadmap") { + const [mid, sid] = parts; + if (!mid || !sid) return []; + + const assessPath = resolveSliceFile(basePath, mid, sid, "ASSESSMENT"); + if (!assessPath || !existsSync(assessPath)) return []; + + // Assessment file exists on disk — check if DB knows about it via the artifacts table + const adapter = _getAdapter(); + if (adapter) { + const row = adapter.prepare( + `SELECT 1 FROM artifacts WHERE path LIKE :pattern AND artifact_type = 'ASSESSMENT' LIMIT 1`, + ).get({ ":pattern": `%${sid}-ASSESSMENT.md` }); + if (!row) { + rogues.push({ path: assessPath, unitType, unitId }); + } + } + } else if (unitType === "plan-task") { + const [mid, sid, tid] = parts; + if (!mid || !sid || !tid) return []; + + const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN"); + if (!taskPlanPath || !existsSync(taskPlanPath)) return []; + + const dbRow = getTask(mid, sid, tid); + if (!dbRow) { + rogues.push({ path: taskPlanPath, unitType, unitId }); + } } return rogues; @@ -571,25 +605,12 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" try { updateTaskStatus(mid, sid, tid, "pending"); await renderPlanCheckboxes(s.basePath, mid, sid); - } catch { - // DB may be unavailable — fall back to direct file-based uncheck - try { - const slicePath = resolveSlicePath(s.basePath, mid, sid); - if (slicePath) { - const { readdirSync } = await import("node:fs"); - const planCandidates = readdirSync(slicePath) - .filter((f: string) => f.includes("PLAN") && (f.startsWith(sid) || f.startsWith(`${sid}-`))); - if (planCandidates.length > 0) { - const planFile = join(slicePath, planCandidates[0]); - let content = readFileSync(planFile, "utf-8"); - const regex = new RegExp(`^(\\s*-\\s*)\\[x\\](\\s*\\**${tid}\\**[:\\s])`, "mi"); - if (regex.test(content)) { - content = content.replace(regex, "$1[ ]$2"); - writeFileSync(planFile, content, "utf-8"); - } - } - } - } catch { /* non-fatal: file-based fallback failure */ } + } catch (dbErr) { + // DB unavailable — fail explicitly rather than silently reverting to markdown mutation. + // Use 'gsd recover' to rebuild DB state from disk if needed. + process.stderr.write( + `gsd: retry state-reset failed (DB unavailable): ${(dbErr as Error).message}. Run 'gsd recover' to reconcile.\n`, + ); } } diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 81600cf86..740eea825 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -339,10 +339,10 @@ export function verifyExpectedArtifact( // DB available — trust it if (dbTask.status !== "complete" && dbTask.status !== "done") return false; } else if (!isDbAvailable()) { - // DB unavailable — fall back to plan heading check (format detection, - // not reconciliation). Heading-style entries (### T01 --) count as - // verified because the summary file existence (checked above) is the - // real signal. + // LEGACY: Pre-migration fallback for projects without DB. + // Fall back to plan heading check (format detection, not reconciliation). + // Heading-style entries (### T01 --) count as verified because the + // summary file existence (checked above) is the real signal. const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); if (planAbs && existsSync(planAbs)) { const planContent = readFileSync(planAbs, "utf-8"); @@ -375,7 +375,7 @@ export function verifyExpectedArtifact( } if (!taskIds) { - // DB unavailable or no tasks in DB — parse plan file for task IDs + // LEGACY: DB unavailable or no tasks in DB — parse plan file for task IDs const planContent = readFileSync(absPath, "utf-8"); const plan = parseLegacyPlan(planContent); if (plan.tasks.length > 0) taskIds = plan.tasks.map((t: { id: string }) => t.id); @@ -414,7 +414,8 @@ export function verifyExpectedArtifact( // DB available — trust it if (dbSlice.status !== "complete") return false; } else if (!isDbAvailable()) { - // DB unavailable — fall back to roadmap checkbox check via parsers-legacy + // LEGACY: Pre-migration fallback for projects without DB. + // Fall back to roadmap checkbox check via parsers-legacy const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP"); if (roadmapFile && existsSync(roadmapFile)) { try { diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index ce43c6012..759bfe256 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -725,6 +725,75 @@ export function registerDbTools(pi: ExtensionAPI): void { pi.registerTool(sliceCompleteTool); registerAlias(pi, sliceCompleteTool, "gsd_complete_slice", "gsd_slice_complete"); + // ─── gsd_complete_milestone ──────────────────────────────────────────── + + const milestoneCompleteExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot complete milestone." }], + details: { operation: "complete_milestone", error: "db_unavailable" } as any, + }; + } + try { + const { handleCompleteMilestone } = await import("../tools/complete-milestone.js"); + const result = await handleCompleteMilestone(params, process.cwd()); + if ("error" in result) { + return { + content: [{ type: "text" as const, text: `Error completing milestone: ${result.error}` }], + details: { operation: "complete_milestone", error: result.error } as any, + }; + } + return { + content: [{ type: "text" as const, text: `Completed milestone ${result.milestoneId}. Summary written to ${result.summaryPath}` }], + details: { + operation: "complete_milestone", + milestoneId: result.milestoneId, + summaryPath: result.summaryPath, + } as any, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`gsd-db: complete_milestone tool failed: ${msg}\n`); + return { + content: [{ type: "text" as const, text: `Error completing milestone: ${msg}` }], + details: { operation: "complete_milestone", error: msg } as any, + }; + } + }; + + const milestoneCompleteTool = { + name: "gsd_complete_milestone", + label: "Complete Milestone", + description: + "Record a completed milestone to the GSD database, render MILESTONE-SUMMARY.md to disk — all in one atomic operation. " + + "Validates all slices are complete before proceeding.", + promptSnippet: "Complete a GSD milestone (DB write + summary render)", + promptGuidelines: [ + "Use gsd_complete_milestone when all slices in a milestone are finished and the milestone needs to be recorded.", + "All slices in the milestone must have status 'complete' — the handler validates this before proceeding.", + "On success, returns summaryPath where the MILESTONE-SUMMARY.md was written.", + ], + parameters: Type.Object({ + milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }), + title: Type.String({ description: "Milestone title" }), + oneLiner: Type.String({ description: "One-sentence summary of what the milestone achieved" }), + narrative: Type.String({ description: "Detailed narrative of what happened during the milestone" }), + successCriteriaResults: Type.String({ description: "Markdown detailing how each success criterion was met or not met" }), + definitionOfDoneResults: Type.String({ description: "Markdown detailing how each definition-of-done item was met" }), + requirementOutcomes: Type.String({ description: "Markdown detailing requirement status transitions with evidence" }), + keyDecisions: Type.Array(Type.String(), { description: "Key architectural/pattern decisions made during the milestone" }), + keyFiles: Type.Array(Type.String(), { description: "Key files created or modified during the milestone" }), + lessonsLearned: Type.Array(Type.String(), { description: "Lessons learned during the milestone" }), + followUps: Type.Optional(Type.String({ description: "Follow-up items for future milestones" })), + deviations: Type.Optional(Type.String({ description: "Deviations from the original plan" })), + }), + execute: milestoneCompleteExecute, + }; + + pi.registerTool(milestoneCompleteTool); + registerAlias(pi, milestoneCompleteTool, "gsd_milestone_complete", "gsd_complete_milestone"); + // ─── gsd_replan_slice (gsd_slice_replan alias) ───────────────────────── const replanSliceExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { diff --git a/src/resources/extensions/gsd/commands-maintenance.ts b/src/resources/extensions/gsd/commands-maintenance.ts index aeb082df0..d2661a605 100644 --- a/src/resources/extensions/gsd/commands-maintenance.ts +++ b/src/resources/extensions/gsd/commands-maintenance.ts @@ -47,6 +47,7 @@ export async function handleCleanupBranches(ctx: ExtensionCommandContext, basePa const { loadFile } = await import("./files.js"); const { parseRoadmap } = await import("./parsers-legacy.js"); const { isMilestoneComplete } = await import("./state.js"); + const { isDbAvailable, getMilestone } = await import("./gsd-db.js"); const attachedBranches = new Set( listWorktrees(basePath).map((wt) => wt.branch), @@ -55,6 +56,22 @@ export async function handleCleanupBranches(ctx: ExtensionCommandContext, basePa for (const branch of milestoneBranches) { if (attachedBranches.has(branch)) continue; const milestoneId = branch.replace(/^milestone\//, ""); + + // DB-first: check milestone status directly + if (isDbAvailable()) { + const dbRow = getMilestone(milestoneId); + if (dbRow) { + if (dbRow.status !== "complete" && dbRow.status !== "done") continue; + // Milestone is complete per DB — proceed to delete branch + try { + nativeBranchDelete(basePath, branch, true); + deletedStaleMilestones++; + } catch { /* non-fatal */ } + continue; + } + } + + // Filesystem fallback const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); if (!roadmapPath) continue; let roadmapContent: string | null = null; @@ -472,17 +489,15 @@ export async function handleRecover(ctx: ExtensionCommandContext, basePath: stri } try { - // 1. Delete hierarchy rows inside a transaction + // 1. Delete + re-populate inside a single transaction for atomicity const db = _getAdapter()!; - dbTransaction(() => { + const counts = dbTransaction(() => { db.exec("DELETE FROM tasks"); db.exec("DELETE FROM slices"); db.exec("DELETE FROM milestones"); + return migrateHierarchyToDb(basePath); }); - // 2. Re-populate from rendered markdown on disk - const counts = migrateHierarchyToDb(basePath); - // 3. Invalidate state cache so deriveState() picks up fresh DB data invalidateStateCache(); diff --git a/src/resources/extensions/gsd/db-writer.ts b/src/resources/extensions/gsd/db-writer.ts index 6963b2455..02ec94c11 100644 --- a/src/resources/extensions/gsd/db-writer.ts +++ b/src/resources/extensions/gsd/db-writer.ts @@ -308,7 +308,15 @@ export async function saveDecisionToDb( md = generateDecisionsMd(allDecisions); } - await saveFile(filePath, md); + try { + await saveFile(filePath, md); + } catch (diskErr) { + process.stderr.write( + `gsd-db: saveDecisionToDb — disk write failed, rolling back DB row: ${(diskErr as Error).message}\n`, + ); + adapter?.prepare('DELETE FROM decisions WHERE id = :id').run({ ':id': id }); + throw diskErr; + } // Invalidate file-read caches so deriveState() sees the updated markdown. // Do NOT clear the artifacts table — we just wrote to it intentionally. invalidateStateCache(); @@ -377,7 +385,15 @@ export async function updateRequirementInDb( const md = generateRequirementsMd(nonSuperseded); const filePath = resolveGsdRootFile(basePath, 'REQUIREMENTS'); - await saveFile(filePath, md); + try { + await saveFile(filePath, md); + } catch (diskErr) { + process.stderr.write( + `gsd-db: updateRequirementInDb — disk write failed, reverting DB row: ${(diskErr as Error).message}\n`, + ); + db.upsertRequirement(existing); + throw diskErr; + } // Invalidate file-read caches so deriveState() sees the updated markdown. // Do NOT clear the artifacts table — we just wrote to it intentionally. invalidateStateCache(); @@ -427,7 +443,16 @@ export async function saveArtifactToDb( if (!fullPath.startsWith(gsdDir)) { throw new GSDError(GSD_IO_ERROR, `saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`); } - await saveFile(fullPath, opts.content); + try { + await saveFile(fullPath, opts.content); + } catch (diskErr) { + process.stderr.write( + `gsd-db: saveArtifactToDb — disk write failed, rolling back DB row: ${(diskErr as Error).message}\n`, + ); + const rollbackAdapter = db._getAdapter(); + rollbackAdapter?.prepare('DELETE FROM artifacts WHERE path = :path').run({ ':path': opts.path }); + throw diskErr; + } // Invalidate file-read caches so deriveState() sees the updated markdown. // Do NOT clear the artifacts table — we just wrote to it intentionally. invalidateStateCache(); diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 1cdb8bf1d..eb05aa6ee 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -149,6 +149,8 @@ const SCHEMA_VERSION = 10; function initSchema(db: DbAdapter, fileBacked: boolean): void { if (fileBacked) db.exec("PRAGMA journal_mode=WAL"); + if (fileBacked) db.exec("PRAGMA busy_timeout = 5000"); + db.exec("PRAGMA foreign_keys = ON"); db.exec("BEGIN"); try { @@ -267,7 +269,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { proof_level TEXT NOT NULL DEFAULT '', integration_closure TEXT NOT NULL DEFAULT '', observability_impact TEXT NOT NULL DEFAULT '', - sequence INTEGER DEFAULT 0, + sequence INTEGER DEFAULT 0, -- DEAD CODE: no tool exposes sequence — always 0 replan_triggered_at TEXT DEFAULT NULL, PRIMARY KEY (milestone_id, id), FOREIGN KEY (milestone_id) REFERENCES milestones(id) @@ -299,7 +301,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void { inputs TEXT NOT NULL DEFAULT '[]', expected_output TEXT NOT NULL DEFAULT '[]', observability_impact TEXT NOT NULL DEFAULT '', - sequence INTEGER DEFAULT 0, + sequence INTEGER DEFAULT 0, -- DEAD CODE: no tool exposes sequence — always 0 PRIMARY KEY (milestone_id, slice_id, id), FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) ) diff --git a/src/resources/extensions/gsd/prompts/complete-slice.md b/src/resources/extensions/gsd/prompts/complete-slice.md index 4a92fbdaa..d2cc57971 100644 --- a/src/resources/extensions/gsd/prompts/complete-slice.md +++ b/src/resources/extensions/gsd/prompts/complete-slice.md @@ -24,7 +24,7 @@ Then: 3. Run all slice-level verification checks defined in the slice plan. All must pass before marking the slice done. If any fail, fix them first. 4. If the slice plan includes observability/diagnostic surfaces, confirm they work. Skip this for simple slices that don't have observability sections. 5. If `.gsd/REQUIREMENTS.md` exists, update it based on what this slice actually proved. Move requirements between Active, Validated, Deferred, Blocked, or Out of Scope only when the evidence from execution supports that change. -6. Call the `gsd_slice_complete` tool (alias: `gsd_complete_slice`) to record the slice as complete. The tool validates all tasks are complete, writes the slice summary to `{{sliceSummaryPath}}`, UAT to `{{sliceUatPath}}`, and toggles the `{{sliceId}}` checkbox in `{{roadmapPath}}` — all atomically. Read the summary and UAT templates at `~/.gsd/agent/extensions/gsd/templates/` to understand the expected structure, then pass the following parameters: +6. Call the `gsd_slice_complete` tool (alias: `gsd_complete_slice`) to record the slice as complete. The tool validates all tasks are complete, updates the slice status in the DB, renders the summary to `{{sliceSummaryPath}}`, UAT to `{{sliceUatPath}}`, and re-renders `{{roadmapPath}}` — all atomically. Read the summary and UAT templates at `~/.gsd/agent/extensions/gsd/templates/` to understand the expected structure, then pass the following parameters: **Identity:** `sliceId`, `milestoneId`, `sliceTitle` @@ -45,6 +45,6 @@ Then: 9. Do not run git commands — the system commits your changes and handles any merge after this unit succeeds. 10. Update `.gsd/PROJECT.md` if it exists — refresh current state if needed. -**You MUST call `gsd_slice_complete` before finishing.** The tool handles writing `{{sliceSummaryPath}}`, `{{sliceUatPath}}`, and toggling the `{{roadmapPath}}` checkbox atomically. You must still review decisions and knowledge manually (steps 7-8). +**You MUST call `gsd_slice_complete` before finishing.** The tool handles writing `{{sliceSummaryPath}}`, `{{sliceUatPath}}`, and updating `{{roadmapPath}}` atomically. You must still review decisions and knowledge manually (steps 7-8). When done, say: "Slice {{sliceId}} complete." diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index 38c71647d..e7d27560b 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -202,7 +202,7 @@ Once the user is satisfied, in a single pass: When writing context.md, preserve the user's exact terminology, emphasis, and specific framing from the discussion. Do not paraphrase user nuance into generic summaries. If the user said "craft feel," write "craft feel" — not "high-quality user experience." If they emphasized a specific constraint or negative requirement, carry that emphasis through verbatim. The context file is downstream agents' only window into this conversation — flattening specifics into generics loses the signal that shaped every decision. 4. Write `{{contextPath}}` — use the **Context** output template below. Preserve key risks, unknowns, existing codebase constraints, integration points, and relevant requirements surfaced during discussion. -5. Write `{{roadmapPath}}` — use the **Roadmap** output template below. Decompose into demoable vertical slices with checkboxes, risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, requirement coverage, and a boundary map. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. +5. Call `gsd_plan_milestone` to create the roadmap. Decompose into demoable vertical slices with risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, requirement coverage, and a boundary map. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. Use the **Roadmap** output template below to structure the tool call parameters. 6. Seed `.gsd/DECISIONS.md` — use the **Decisions** output template below. Append rows for any architectural or pattern decisions made during discussion. 7. {{commitInstruction}} @@ -222,7 +222,7 @@ Once the user confirms the milestone split: #### Phase 2: Primary milestone 5. Write a full `CONTEXT.md` for the primary milestone (the one discussed in depth). -6. Write a `ROADMAP.md` for **only the primary milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done. +6. Call `gsd_plan_milestone` for **only the primary milestone** — detail-planning later milestones now is waste because the codebase will change. Include requirement coverage and a milestone definition of done. #### MANDATORY: depends_on Frontmatter in CONTEXT.md diff --git a/src/resources/extensions/gsd/prompts/execute-task.md b/src/resources/extensions/gsd/prompts/execute-task.md index 2e22b4734..3f593492f 100644 --- a/src/resources/extensions/gsd/prompts/execute-task.md +++ b/src/resources/extensions/gsd/prompts/execute-task.md @@ -63,7 +63,7 @@ Then: 11. **Blocker discovery:** If execution reveals that the remaining slice plan is fundamentally invalid — not just a bug or minor deviation, but a plan-invalidating finding like a wrong API, missing capability, or architectural mismatch — set `blocker_discovered: true` in the task summary frontmatter and describe the blocker clearly in the summary narrative. Do NOT set `blocker_discovered: true` for ordinary debugging, minor deviations, or issues that can be fixed within the current task or the remaining plan. This flag triggers an automatic replan of the slice. 12. If you made an architectural, pattern, library, or observability decision during this task that downstream work should know about, append it to `.gsd/DECISIONS.md` (read the template at `~/.gsd/agent/extensions/gsd/templates/decisions.md` if the file doesn't exist yet). Not every task produces decisions — only append when a meaningful choice was made. 13. If you discover a non-obvious rule, recurring gotcha, or useful pattern during execution, append it to `.gsd/KNOWLEDGE.md`. Only add entries that would save future agents from repeating your investigation. Don't add obvious things. -14. Call the `gsd_task_complete` tool (alias: `gsd_complete_task`) to record the task completion. This single tool call atomically writes the summary file to `{{taskSummaryPath}}`, toggles the `[ ]` → `[x]` checkbox in `{{planPath}}`, and persists the task row to the DB. Read the summary template at `~/.gsd/agent/extensions/gsd/templates/task-summary.md` to understand the expected structure — but pass the content as tool parameters, not as a file write. The tool parameters are: +14. Call the `gsd_task_complete` tool (alias: `gsd_complete_task`) to record the task completion. This single tool call atomically updates the task status in the DB, renders the summary file to `{{taskSummaryPath}}`, and re-renders the plan file at `{{planPath}}`. Read the summary template at `~/.gsd/agent/extensions/gsd/templates/task-summary.md` to understand the expected structure — but pass the content as tool parameters, not as a file write. The tool parameters are: - `taskId`: "{{taskId}}" - `sliceId`: "{{sliceId}}" - `milestoneId`: "{{milestoneId}}" @@ -80,6 +80,6 @@ Then: All work stays in your working directory: `{{workingDirectory}}`. -**You MUST call `gsd_task_complete` before finishing.** The tool handles writing `{{taskSummaryPath}}` and toggling the checkbox in `{{planPath}}` — do not write the summary file or toggle the checkbox manually. +**You MUST call `gsd_task_complete` before finishing.** The tool handles writing `{{taskSummaryPath}}` and updating the plan file at `{{planPath}}` — do not write the summary file or modify the plan file manually. When done, say: "Task {{taskId}} complete." diff --git a/src/resources/extensions/gsd/prompts/plan-milestone.md b/src/resources/extensions/gsd/prompts/plan-milestone.md index 972ddfe61..2a371fa2f 100644 --- a/src/resources/extensions/gsd/prompts/plan-milestone.md +++ b/src/resources/extensions/gsd/prompts/plan-milestone.md @@ -80,15 +80,13 @@ Apply these when decomposing and ordering slices: ## Single-Slice Fast Path -If the roadmap has only one slice, also write the slice plan and task plans inline during this unit — don't leave them for a separate planning session. +If the roadmap has only one slice, also plan the slice and its tasks inline during this unit — don't leave them for a separate planning session. -1. Use the **Slice Plan** and **Task Plan** output templates from the inlined context above -2. `mkdir -p {{milestonePath}}/slices/S01/tasks` -3. Write the S01 plan file at `{{milestonePath}}/slices/S01/S01-PLAN.md` -4. Write individual task plans at `{{milestonePath}}/slices/S01/tasks/T01-PLAN.md`, etc. -5. For simple slices, keep the plan lean — omit Proof Level, Integration Closure, and Observability sections if they would all be "none". Executable verification commands are sufficient. +1. After `gsd_plan_milestone` returns, immediately call `gsd_plan_slice` for S01 with the full task breakdown +2. Use the **Slice Plan** and **Task Plan** output templates from the inlined context above to structure the tool call parameters +3. For simple slices, keep the plan lean — omit Proof Level, Integration Closure, and Observability sections if they would all be "none". Executable verification commands are sufficient. -This eliminates a separate research-slice + plan-slice cycle when the work is straightforward. +Do **not** write plan files manually — use the DB-backed tools so state stays consistent. ## Secret Forecasting diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index aca92bc8e..dc37405f7 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -1,5 +1,5 @@ // GSD Extension — State Derivation -// Reads roadmap + plan files to determine current position. +// DB-primary state derivation with filesystem fallback for unmigrated projects. // Pure TypeScript, zero Pi dependencies. import type { @@ -129,36 +129,45 @@ export function invalidateStateCache(): void { * Returns the ID of the first incomplete milestone, or null if all are complete. */ export async function getActiveMilestoneId(basePath: string): Promise { - const milestoneIds = findMilestoneIds(basePath); // Parallel worker isolation const milestoneLock = process.env.GSD_MILESTONE_LOCK; if (milestoneLock) { + const milestoneIds = findMilestoneIds(basePath); if (!milestoneIds.includes(milestoneLock)) return null; - // Locked milestone that is parked should not be active const lockedParked = resolveMilestoneFile(basePath, milestoneLock, "PARKED"); if (lockedParked) return null; return milestoneLock; } + + // DB-first: query milestones table for the first non-complete, non-parked milestone + if (isDbAvailable()) { + const allMilestones = getAllMilestones(); + if (allMilestones.length > 0) { + const sorted = [...allMilestones].sort((a, b) => a.id.localeCompare(b.id)); + for (const m of sorted) { + if (m.status === "complete" || m.status === "done" || m.status === "parked") continue; + return m.id; + } + return null; + } + } + + // Filesystem fallback for unmigrated projects or empty DB + const milestoneIds = findMilestoneIds(basePath); for (const mid of milestoneIds) { - // Skip parked milestones — they are not eligible for active status const parkedFile = resolveMilestoneFile(basePath, mid, "PARKED"); if (parkedFile) continue; const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); const content = roadmapFile ? await loadFile(roadmapFile) : null; if (!content) { - // No roadmap — but if a summary exists, the milestone is already complete const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); - if (summaryFile) continue; // completed milestone, skip - if (isGhostMilestone(basePath, mid)) continue; // ghost dir — skip - return mid; // No roadmap and no summary — milestone is incomplete - // Note: draft-awareness (CONTEXT-DRAFT.md) is handled in deriveState(), not here. - // A draft milestone is still "active" — this function only determines which milestone is current. + if (summaryFile) continue; + if (isGhostMilestone(basePath, mid)) continue; + return mid; } const roadmap = parseRoadmap(content); if (!isMilestoneComplete(roadmap)) { - // Summary is the terminal artifact — if it exists, the milestone is - // complete even when roadmap checkboxes weren't ticked (#864). const summaryFile = resolveMilestoneFile(basePath, mid, "SUMMARY"); if (!summaryFile) return mid; } @@ -167,13 +176,12 @@ export async function getActiveMilestoneId(basePath: string): Promise { // Return cached result if within the TTL window for the same basePath @@ -700,6 +708,9 @@ export async function deriveStateFromDb(basePath: string): Promise { }; } +// LEGACY: Filesystem-based state derivation for unmigrated projects. +// DB-backed projects use deriveStateFromDb() above. Target: extract to +// state-legacy.ts when all projects are DB-backed. export async function _deriveStateImpl(basePath: string): Promise { const milestoneIds = findMilestoneIds(basePath); diff --git a/src/resources/extensions/gsd/tests/tool-naming.test.ts b/src/resources/extensions/gsd/tests/tool-naming.test.ts index c19f4e16c..786713c25 100644 --- a/src/resources/extensions/gsd/tests/tool-naming.test.ts +++ b/src/resources/extensions/gsd/tests/tool-naming.test.ts @@ -33,6 +33,7 @@ const RENAME_MAP: Array<{ canonical: string; alias: string }> = [ { canonical: "gsd_plan_task", alias: "gsd_task_plan" }, { canonical: "gsd_replan_slice", alias: "gsd_slice_replan" }, { canonical: "gsd_reassess_roadmap", alias: "gsd_roadmap_reassess" }, + { canonical: "gsd_complete_milestone", alias: "gsd_milestone_complete" }, ]; // ─── Registration count ────────────────────────────────────────────────────── @@ -42,7 +43,7 @@ console.log('\n── Tool naming: registration count ──'); const pi = makeMockPi(); registerDbTools(pi); -assertEq(pi.tools.length, 22, 'Should register exactly 22 tools (11 canonical + 11 aliases)'); +assertEq(pi.tools.length, 24, 'Should register exactly 24 tools (12 canonical + 12 aliases)'); // ─── Both names exist for each pair ────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tools/complete-milestone.ts b/src/resources/extensions/gsd/tools/complete-milestone.ts new file mode 100644 index 000000000..1e5e96ef9 --- /dev/null +++ b/src/resources/extensions/gsd/tools/complete-milestone.ts @@ -0,0 +1,176 @@ +/** + * complete-milestone handler — the core operation behind gsd_complete_milestone. + * + * Validates all slices are complete, updates milestone status in DB, + * renders MILESTONE-SUMMARY.md to disk, stores rendered markdown in DB + * for recovery, and invalidates caches. + */ + +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; + +import { + transaction, + getMilestoneSlices, + _getAdapter, +} from "../gsd-db.js"; +import { resolveMilestonePath, clearPathCache } from "../paths.js"; +import { saveFile, clearParseCache } from "../files.js"; +import { invalidateStateCache } from "../state.js"; + +export interface CompleteMilestoneParams { + milestoneId: string; + title: string; + oneLiner: string; + narrative: string; + successCriteriaResults: string; + definitionOfDoneResults: string; + requirementOutcomes: string; + keyDecisions: string[]; + keyFiles: string[]; + lessonsLearned: string[]; + followUps: string; + deviations: string; +} + +export interface CompleteMilestoneResult { + milestoneId: string; + summaryPath: string; +} + +function renderMilestoneSummaryMarkdown(params: CompleteMilestoneParams): string { + const now = new Date().toISOString(); + + const keyDecisionsYaml = params.keyDecisions.length > 0 + ? params.keyDecisions.map(d => ` - ${d}`).join("\n") + : " - (none)"; + + const keyFilesYaml = params.keyFiles.length > 0 + ? params.keyFiles.map(f => ` - ${f}`).join("\n") + : " - (none)"; + + const lessonsYaml = params.lessonsLearned.length > 0 + ? params.lessonsLearned.map(l => ` - ${l}`).join("\n") + : " - (none)"; + + return `--- +id: ${params.milestoneId} +title: "${params.title}" +status: complete +completed_at: ${now} +key_decisions: +${keyDecisionsYaml} +key_files: +${keyFilesYaml} +lessons_learned: +${lessonsYaml} +--- + +# ${params.milestoneId}: ${params.title} + +**${params.oneLiner}** + +## What Happened + +${params.narrative} + +## Success Criteria Results + +${params.successCriteriaResults} + +## Definition of Done Results + +${params.definitionOfDoneResults} + +## Requirement Outcomes + +${params.requirementOutcomes} + +## Deviations + +${params.deviations || "None."} + +## Follow-ups + +${params.followUps || "None."} +`; +} + +export async function handleCompleteMilestone( + params: CompleteMilestoneParams, + basePath: string, +): Promise { + // ── Validate required fields ──────────────────────────────────────────── + if (!params.milestoneId || typeof params.milestoneId !== "string" || params.milestoneId.trim() === "") { + return { error: "milestoneId is required and must be a non-empty string" }; + } + if (!params.title || typeof params.title !== "string" || params.title.trim() === "") { + return { error: "title is required and must be a non-empty string" }; + } + + // ── Verify all slices are complete ─────────────────────────────────────── + const slices = getMilestoneSlices(params.milestoneId); + if (slices.length === 0) { + return { error: `no slices found for milestone ${params.milestoneId}` }; + } + + const incompleteSlices = slices.filter(s => s.status !== "complete" && s.status !== "done"); + if (incompleteSlices.length > 0) { + const incompleteIds = incompleteSlices.map(s => `${s.id} (status: ${s.status})`).join(", "); + return { error: `incomplete slices: ${incompleteIds}` }; + } + + // ── DB writes inside a transaction ────────────────────────────────────── + const completedAt = new Date().toISOString(); + + transaction(() => { + const adapter = _getAdapter()!; + adapter.prepare( + `UPDATE milestones SET status = 'complete', completed_at = :completed_at WHERE id = :mid`, + ).run({ + ":completed_at": completedAt, + ":mid": params.milestoneId, + }); + }); + + // ── Filesystem operations (outside transaction) ───────────────────────── + const summaryMd = renderMilestoneSummaryMarkdown(params); + + let summaryPath: string; + const milestoneDir = resolveMilestonePath(basePath, params.milestoneId); + if (milestoneDir) { + summaryPath = join(milestoneDir, `${params.milestoneId}-SUMMARY.md`); + } else { + const gsdDir = join(basePath, ".gsd"); + const manualDir = join(gsdDir, "milestones", params.milestoneId); + mkdirSync(manualDir, { recursive: true }); + summaryPath = join(manualDir, `${params.milestoneId}-SUMMARY.md`); + } + + try { + await saveFile(summaryPath, summaryMd); + } catch (renderErr) { + // Disk render failed — roll back DB status so state stays consistent + process.stderr.write( + `gsd-db: complete_milestone — disk render failed, rolling back DB status: ${(renderErr as Error).message}\n`, + ); + const rollbackAdapter = _getAdapter(); + if (rollbackAdapter) { + rollbackAdapter.prepare( + `UPDATE milestones SET status = 'active', completed_at = NULL WHERE id = :mid`, + ).run({ ":mid": params.milestoneId }); + } + invalidateStateCache(); + return { error: `disk render failed: ${(renderErr as Error).message}` }; + } + + // Invalidate all caches + invalidateStateCache(); + clearPathCache(); + clearParseCache(); + + return { + milestoneId: params.milestoneId, + summaryPath, + }; +} diff --git a/src/resources/extensions/gsd/tools/plan-milestone.ts b/src/resources/extensions/gsd/tools/plan-milestone.ts index 7159c3aaf..0bb2e9e25 100644 --- a/src/resources/extensions/gsd/tools/plan-milestone.ts +++ b/src/resources/extensions/gsd/tools/plan-milestone.ts @@ -5,6 +5,7 @@ import { insertSlice, upsertMilestonePlanning, upsertSlicePlanning, + _getAdapter, } from "../gsd-db.js"; import { invalidateStateCache } from "../state.js"; import { renderRoadmapFromDb } from "../markdown-renderer.js"; @@ -230,8 +231,12 @@ export async function handlePlanMilestone( try { const renderResult = await renderRoadmapFromDb(basePath, params.milestoneId); roadmapPath = renderResult.roadmapPath; - } catch (err) { - return { error: `render failed: ${(err as Error).message}` }; + } catch (renderErr) { + process.stderr.write( + `gsd-db: plan_milestone — render failed (DB rows preserved for debugging): ${(renderErr as Error).message}\n`, + ); + invalidateStateCache(); + return { error: `render failed: ${(renderErr as Error).message}` }; } invalidateStateCache(); diff --git a/src/resources/extensions/gsd/tools/plan-slice.ts b/src/resources/extensions/gsd/tools/plan-slice.ts index 1b4c49cdf..f430e9756 100644 --- a/src/resources/extensions/gsd/tools/plan-slice.ts +++ b/src/resources/extensions/gsd/tools/plan-slice.ts @@ -5,6 +5,7 @@ import { insertTask, upsertSlicePlanning, upsertTaskPlanning, + _getAdapter, } from "../gsd-db.js"; import { invalidateStateCache } from "../state.js"; import { renderPlanFromDb } from "../markdown-renderer.js"; @@ -183,7 +184,11 @@ export async function handlePlanSlice( planPath: renderResult.planPath, taskPlanPaths: renderResult.taskPlanPaths, }; - } catch (err) { - return { error: `render failed: ${(err as Error).message}` }; + } catch (renderErr) { + process.stderr.write( + `gsd-db: plan_slice — render failed (DB rows preserved for debugging): ${(renderErr as Error).message}\n`, + ); + invalidateStateCache(); + return { error: `render failed: ${(renderErr as Error).message}` }; } }