From 1c0cca4f765aa4920f1a97c7ae014930c82a3e18 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 24 Mar 2026 23:40:02 -0500 Subject: [PATCH] =?UTF-8?q?feat(gsd):=20single-writer=20state=20engine=20v?= =?UTF-8?q?2=20=E2=80=94=20discipline=20layer=20on=20DB=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the single-writer state architecture from PRs #2288–#2293 onto the current upstream codebase (schema v10, polymorphic engine). Original PRs were based on a pre-v5 schema with incompatible column names and predated the WorkflowEngine interface refactor. New files: - workflow-events.ts: append-only event log (.gsd/event-log.jsonl) - workflow-manifest.ts: full DB snapshot after every mutation (crash recovery) - workflow-projections.ts: renders PLAN/ROADMAP/SUMMARY/STATE.md from DB - workflow-migration.ts: migrates legacy markdown projects into DB - workflow-reconcile.ts: event log replay for diverged worktrees - workflow-logger.ts: structured error/warning accumulation - sync-lock.ts: advisory lock for concurrent worktree syncs - write-intercept.ts: blocks direct writes to STATE.md - auto-artifact-paths.ts: central artifact path registry Modified: - All 8 tool handlers (complete-task, complete-slice, plan-slice, etc.) now wrap mutations in atomic transactions + emit event log + write manifest + regenerate markdown projections after every command - state.ts: telemetry counters for DB vs filesystem derivation paths - register-hooks.ts: write-intercept wired into tool_call hook - doctor.ts/doctor-checks.ts/doctor-types.ts: engine health checks, fixable:false on completion-state issues, removed placeholder stubs - auto.ts + supporting files: removed completedUnits tracking globally, removed unit-runtime record reads/writes, removed inline doctor runs - auto-post-unit.ts: detectRogueFileWrites (6 unit types), removed doctor health tracking block, added regenerateIfMissing on retry - 3 prompts updated to use gsd_* tool API instead of direct file edits ADR-004: GSD had multiple writers racing to edit the same markdown files concurrently, causing race conditions, stale reads, and corrupt state. The single-writer discipline layer makes markdown files derived artifacts (generated from DB after every command) rather than authoritative sources. Supersedes closed PRs: #2288, #2289, #2290, #2291, #2292, #2293 AI assistance: implemented with Claude Code (GSD/Claude). --- .../extensions/gsd/auto-artifact-paths.ts | 131 +++++ .../extensions/gsd/auto-dashboard.ts | 1 - .../extensions/gsd/auto-post-unit.ts | 131 +---- src/resources/extensions/gsd/auto-start.ts | 2 - src/resources/extensions/gsd/auto.ts | 87 +--- .../extensions/gsd/auto/loop-deps.ts | 19 - src/resources/extensions/gsd/auto/phases.ts | 32 +- src/resources/extensions/gsd/auto/session.ts | 18 - .../gsd/bootstrap/register-hooks.ts | 9 + .../extensions/gsd/crash-recovery.ts | 6 +- src/resources/extensions/gsd/doctor-checks.ts | 180 ++++++- src/resources/extensions/gsd/doctor-types.ts | 8 +- src/resources/extensions/gsd/doctor.ts | 5 +- .../extensions/gsd/parallel-orchestrator.ts | 26 +- .../extensions/gsd/prompts/complete-slice.md | 31 +- .../extensions/gsd/prompts/execute-task.md | 20 +- .../extensions/gsd/prompts/plan-slice.md | 6 +- src/resources/extensions/gsd/session-lock.ts | 4 - src/resources/extensions/gsd/state.ts | 8 + src/resources/extensions/gsd/sync-lock.ts | 94 ++++ .../gsd/tools/complete-milestone.ts | 19 + .../extensions/gsd/tools/complete-slice.ts | 19 + .../extensions/gsd/tools/complete-task.ts | 19 + .../extensions/gsd/tools/plan-milestone.ts | 19 + .../extensions/gsd/tools/plan-slice.ts | 20 + .../extensions/gsd/tools/plan-task.ts | 20 + .../extensions/gsd/tools/reassess-roadmap.ts | 19 + .../extensions/gsd/tools/replan-slice.ts | 19 + .../extensions/gsd/workflow-events.ts | 135 +++++ .../extensions/gsd/workflow-manifest.ts | 314 ++++++++++++ .../extensions/gsd/workflow-migration.ts | 345 +++++++++++++ .../extensions/gsd/workflow-projections.ts | 423 ++++++++++++++++ .../extensions/gsd/workflow-reconcile.ts | 473 ++++++++++++++++++ .../extensions/gsd/write-intercept.ts | 57 +++ 34 files changed, 2393 insertions(+), 326 deletions(-) create mode 100644 src/resources/extensions/gsd/auto-artifact-paths.ts create mode 100644 src/resources/extensions/gsd/sync-lock.ts create mode 100644 src/resources/extensions/gsd/workflow-events.ts create mode 100644 src/resources/extensions/gsd/workflow-manifest.ts create mode 100644 src/resources/extensions/gsd/workflow-migration.ts create mode 100644 src/resources/extensions/gsd/workflow-projections.ts create mode 100644 src/resources/extensions/gsd/workflow-reconcile.ts create mode 100644 src/resources/extensions/gsd/write-intercept.ts diff --git a/src/resources/extensions/gsd/auto-artifact-paths.ts b/src/resources/extensions/gsd/auto-artifact-paths.ts new file mode 100644 index 000000000..c296ad94a --- /dev/null +++ b/src/resources/extensions/gsd/auto-artifact-paths.ts @@ -0,0 +1,131 @@ +// GSD Auto-mode — Artifact Path Resolution +// +// resolveExpectedArtifactPath and diagnoseExpectedArtifact moved here from +// auto-recovery.ts (Phase 5 dead-code cleanup). The artifact verification +// function was removed entirely — callers now query WorkflowEngine directly. + +import { + resolveMilestonePath, + resolveSlicePath, + relMilestoneFile, + relSliceFile, + buildMilestoneFileName, + buildSliceFileName, + buildTaskFileName, +} from "./paths.js"; +import { join } from "node:path"; + +/** + * Resolve the expected artifact for a unit to an absolute path. + */ +export function resolveExpectedArtifactPath( + unitType: string, + unitId: string, + base: string, +): string | null { + const parts = unitId.split("/"); + const mid = parts[0]!; + const sid = parts[1]; + switch (unitType) { + case "discuss-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "CONTEXT")) : null; + } + case "research-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null; + } + case "plan-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null; + } + case "research-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null; + } + case "plan-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null; + } + case "reassess-roadmap": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null; + } + case "run-uat": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null; + } + case "execute-task": { + const tid = parts[2]; + const dir = resolveSlicePath(base, mid, sid!); + return dir && tid + ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) + : null; + } + case "complete-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null; + } + case "validate-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "VALIDATION")) : null; + } + case "complete-milestone": { + const dir = resolveMilestonePath(base, mid); + return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null; + } + case "replan-slice": { + const dir = resolveSlicePath(base, mid, sid!); + return dir ? join(dir, buildSliceFileName(sid!, "REPLAN")) : null; + } + case "rewrite-docs": + return null; + case "reactive-execute": + // Reactive execute produces multiple task summaries — verified separately + return null; + default: + return null; + } +} + +export function diagnoseExpectedArtifact( + unitType: string, + unitId: string, + base: string, +): string | null { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + switch (unitType) { + case "discuss-milestone": + return `${relMilestoneFile(base, mid!, "CONTEXT")} (milestone context from discussion)`; + case "research-milestone": + return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`; + case "plan-milestone": + return `${relMilestoneFile(base, mid!, "ROADMAP")} (milestone roadmap)`; + case "research-slice": + return `${relSliceFile(base, mid!, sid!, "RESEARCH")} (slice research)`; + case "plan-slice": + return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`; + case "execute-task": { + const tid = parts[2]; + return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`; + } + case "complete-slice": + return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary + UAT written`; + case "replan-slice": + return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`; + case "rewrite-docs": + return "Active overrides resolved in .gsd/OVERRIDES.md + plan documents updated"; + case "reassess-roadmap": + return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`; + case "run-uat": + return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`; + case "validate-milestone": + return `${relMilestoneFile(base, mid!, "VALIDATION")} (milestone validation report)`; + case "complete-milestone": + return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`; + default: + return null; + } +} diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 4db561cd5..e926f8253 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -48,7 +48,6 @@ export interface AutoDashboardData { startTime: number; elapsed: number; currentUnit: { type: string; id: string; startedAt: number } | null; - completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[]; basePath: string; /** Running cost and token totals from metrics ledger */ totalCost: number; diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 21c675e2a..bd21addbf 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -17,12 +17,10 @@ import { loadFile, parseSummary, resolveAllOverrides } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; import { resolveSliceFile, - resolveSlicePath, resolveTaskFile, resolveMilestoneFile, resolveTasksDir, buildTaskFileName, - gsdRoot, } from "./paths.js"; import { invalidateAllCaches } from "./cache.js"; import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js"; @@ -34,9 +32,7 @@ import { verifyExpectedArtifact, resolveExpectedArtifactPath, } from "./auto-recovery.js"; -import { writeUnitRuntimeRecord, clearUnitRuntimeRecord } from "./unit-runtime.js"; -import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js"; -import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js"; +import { regenerateIfMissing } from "./workflow-projections.js"; import { syncStateToProjectRoot } from "./auto-worktree-sync.js"; import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, _getAdapter } from "./gsd-db.js"; import { renderPlanCheckboxes } from "./markdown-renderer.js"; @@ -57,9 +53,8 @@ import { unitVerb, hideFooter, } from "./auto-dashboard.js"; -import { existsSync, unlinkSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { atomicWriteSync } from "./atomic-write.js"; import { _resetHasChangesCache } from "./native-git-bridge.js"; // ─── Rogue File Detection ────────────────────────────────────────────────── @@ -186,13 +181,8 @@ export function detectRogueFileWrites( return rogues; } -/** Throttle STATE.md rebuilds — at most once per 30 seconds */ -const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; - export interface PreVerificationOpts { skipSettleDelay?: boolean; - skipDoctor?: boolean; - skipStateRebuild?: boolean; skipWorktreeSync?: boolean; } @@ -306,78 +296,6 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV debugLog("postUnit", { phase: "github-sync", error: String(e) }); } - // Doctor: fix mechanical bookkeeping (skipped for lightweight sidecars) - if (!opts?.skipDoctor) try { - const scopeParts = s.currentUnit.id.split("/").slice(0, 2); - const doctorScope = scopeParts.join("/"); - const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]); - const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const; - const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); - // Human-readable fix notification with details - if (report.fixesApplied.length > 0) { - const fixSummary = report.fixesApplied.length <= 2 - ? report.fixesApplied.join("; ") - : `${report.fixesApplied[0]}; +${report.fixesApplied.length - 1} more`; - ctx.ui.notify(`Doctor: ${fixSummary}`, "info"); - } - - // Proactive health tracking — filter to current milestone to avoid - // cross-milestone stale errors inflating the escalation counter - const currentMilestoneId = s.currentUnit.id.split("/")[0]; - const milestoneIssues = currentMilestoneId - ? report.issues.filter(i => - i.unitId === currentMilestoneId || - i.unitId.startsWith(`${currentMilestoneId}/`)) - : report.issues; - const summary = summarizeDoctorIssues(milestoneIssues); - // Pass issue details + scope for real-time visibility in the progress widget - const issueDetails = milestoneIssues - .filter(i => i.severity === "error" || i.severity === "warning") - .map(i => ({ code: i.code, message: i.message, severity: i.severity, unitId: i.unitId })); - recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length, issueDetails, report.fixesApplied, doctorScope); - - // Check if we should escalate to LLM-assisted heal - if (summary.errors > 0) { - const unresolvedErrors = milestoneIssues - .filter(i => i.severity === "error" && !i.fixable) - .map(i => ({ code: i.code, message: i.message, unitId: i.unitId })); - const escalation = checkHealEscalation(summary.errors, unresolvedErrors); - if (escalation.shouldEscalate) { - ctx.ui.notify( - `Doctor heal escalation: ${escalation.reason}. Dispatching LLM-assisted heal.`, - "warning", - ); - try { - const { formatDoctorIssuesForPrompt, formatDoctorReport } = await import("./doctor.js"); - const { dispatchDoctorHeal } = await import("./commands-handlers.js"); - const actionable = report.issues.filter(i => i.severity === "error"); - const reportText = formatDoctorReport(report, { scope: doctorScope, includeWarnings: true }); - const structuredIssues = formatDoctorIssuesForPrompt(actionable); - dispatchDoctorHeal(pi, doctorScope, reportText, structuredIssues); - return "dispatched"; - } catch (e) { - debugLog("postUnit", { phase: "doctor-heal-dispatch", error: String(e) }); - } - } - } - } catch (e) { - debugLog("postUnit", { phase: "doctor", error: String(e) }); - } - - // Throttled STATE.md rebuild (skipped for lightweight sidecars) - if (!opts?.skipStateRebuild) { - const now = Date.now(); - if (now - s.lastStateRebuildAt >= STATE_REBUILD_MIN_INTERVAL_MS) { - try { - await rebuildState(s.basePath); - s.lastStateRebuildAt = now; - autoCommitCurrentBranch(s.basePath, "state-rebuild", s.currentUnit.id); - } catch (e) { - debugLog("postUnit", { phase: "state-rebuild", error: String(e) }); - } - } - } - // Prune dead bg-shell processes try { const { pruneDeadProcesses } = await import("../bg-shell/process-manager.js"); @@ -503,6 +421,27 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV debugLog("postUnit", { phase: "artifact-verify", error: String(e) }); } + // If verification failed, attempt to regenerate missing projection files + // from DB data before giving up (e.g. research-slice produces PLAN from engine). + if (!triggerArtifactVerified) { + try { + const parts = s.currentUnit.id.split("/"); + const [mid, sid] = parts; + if (mid && sid) { + const regenerated = regenerateIfMissing(s.basePath, mid, sid, "PLAN"); + if (regenerated) { + // Re-check after regeneration + triggerArtifactVerified = verifyExpectedArtifact(s.currentUnit.type, s.currentUnit.id, s.basePath); + if (triggerArtifactVerified) { + invalidateAllCaches(); + } + } + } + } catch (e) { + debugLog("postUnit", { phase: "regenerate-projection", error: String(e) }); + } + } + // When artifact verification fails for a unit type that has a known expected // artifact, return "retry" so the caller re-dispatches with failure context // instead of blindly re-dispatching the same unit (#1571). @@ -526,17 +465,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV } } } else { - // Hook unit completed — finalize its runtime record - try { - writeUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, { - phase: "finalized", - progressCount: 1, - lastProgressKind: "hook-completed", - }); - clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id); - } catch (e) { - debugLog("postUnit", { phase: "hook-finalize", error: String(e) }); - } + // Hook unit completed — no additional processing needed } } @@ -625,17 +554,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" } } - // 3. Remove from s.completedUnits and flush to completed-units.json - s.completedUnits = s.completedUnits.filter( - u => !(u.type === trigger.unitType && u.id === trigger.unitId), - ); - try { - const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json"); - const keys = s.completedUnits.map(u => `${u.type}/${u.id}`); - atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2)); - } catch { /* non-fatal: disk flush failure */ } - - // 4. Delete the retry_on artifact (e.g. NEEDS-REWORK.md) + // 3. Delete the retry_on artifact (e.g. NEEDS-REWORK.md) if (trigger.retryArtifact) { const retryArtifactPath = resolveHookArtifactPath(s.basePath, trigger.unitId, trigger.retryArtifact); if (existsSync(retryArtifactPath)) { diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 4963f962c..64571710e 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -494,7 +494,6 @@ export async function bootstrapAutoSession( }); s.autoStartTime = Date.now(); s.resourceVersionOnStart = readResourceVersion(); - s.completedUnits = []; s.pendingQuickTasks = []; s.currentUnit = null; s.currentMilestoneId = state.activeMilestone?.id ?? null; @@ -624,7 +623,6 @@ export async function bootstrapAutoSession( lockBase(), "starting", s.currentMilestoneId ?? "unknown", - 0, ); writeLock(lockBase(), "starting", s.currentMilestoneId ?? "unknown", 0); diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 71676aa53..b701aaa05 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -52,12 +52,6 @@ import { updateSessionLock, } from "./session-lock.js"; import type { SessionLockStatus } from "./session-lock.js"; -import { - clearUnitRuntimeRecord, - inspectExecuteTaskDurability, - readUnitRuntimeRecord, - writeUnitRuntimeRecord, -} from "./unit-runtime.js"; import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences, @@ -81,7 +75,6 @@ import { } from "./auto-tool-tracking.js"; import { closeoutUnit } from "./auto-unit-closeout.js"; import { recoverTimedOutUnit } from "./auto-timeout-recovery.js"; -import { selfHealRuntimeRecords } from "./auto-recovery.js"; import { selectAndApplyModel, resolveModelId } from "./auto-model-selection.js"; import { syncProjectRootToWorktree, @@ -155,10 +148,6 @@ import { pruneQueueOrder } from "./queue-order.js"; import { debugLog, isDebugEnabled, writeDebugSummary } from "./debug-logger.js"; import { - resolveExpectedArtifactPath, - verifyExpectedArtifact, - writeBlockerPlaceholder, - diagnoseExpectedArtifact, buildLoopRemediationSteps, reconcileMergeState, } from "./auto-recovery.js"; @@ -213,7 +202,6 @@ import { NEW_SESSION_TIMEOUT_MS, } from "./auto/session.js"; import type { - CompletedUnit, CurrentUnit, UnitRouting, StartModel, @@ -225,7 +213,6 @@ export { NEW_SESSION_TIMEOUT_MS, } from "./auto/session.js"; export type { - CompletedUnit, CurrentUnit, UnitRouting, StartModel, @@ -335,7 +322,7 @@ export function getAutoDashboardData(): AutoDashboardData { ? (s.autoStartTime > 0 ? Date.now() - s.autoStartTime : 0) : 0, currentUnit: s.currentUnit ? { ...s.currentUnit } : null, - completedUnits: [...s.completedUnits], + completedUnits: [], basePath: s.basePath, totalCost: totals?.cost ?? 0, totalTokens: totals?.tokens.total ?? 0, @@ -447,7 +434,6 @@ export function checkRemoteAutoSession(projectRoot: string): { unitType?: string; unitId?: string; startedAt?: string; - completedUnits?: number; } { const lock = readCrashLock(projectRoot); if (!lock) return { running: false }; @@ -463,7 +449,6 @@ export function checkRemoteAutoSession(projectRoot: string): { unitType: lock.unitType, unitId: lock.unitId, startedAt: lock.startedAt, - completedUnits: lock.completedUnits, }; } @@ -491,23 +476,19 @@ function clearUnitTimeout(): void { clearInFlightTools(); } -/** Build snapshot metric opts, enriching with continueHereFired from the runtime record. */ +/** Build snapshot metric opts. */ function buildSnapshotOpts( - unitType: string, - unitId: string, + _unitType: string, + _unitId: string, ): { continueHereFired?: boolean; promptCharCount?: number; baselineCharCount?: number; } & Record { - const runtime = s.currentUnit - ? readUnitRuntimeRecord(s.basePath, unitType, unitId) - : null; return { promptCharCount: s.lastPromptCharCount, baselineCharCount: s.lastBaselineCharCount, ...(s.currentUnitRouting ?? {}), - ...(runtime?.continueHereFired ? { continueHereFired: true } : {}), }; } @@ -848,11 +829,6 @@ export async function pauseAuto( } catch { // Non-fatal — best-effort closeout on pause } - try { - clearUnitRuntimeRecord(s.basePath, s.currentUnit.type, s.currentUnit.id); - } catch { - // Non-fatal - } s.currentUnit = null; } @@ -993,9 +969,6 @@ function buildLoopDeps(): LoopDeps { getMainBranch, // Unit closeout + runtime records closeoutUnit, - verifyExpectedArtifact, - clearUnitRuntimeRecord, - writeUnitRuntimeRecord, recordOutcome, writeLock, captureAvailableSkills, @@ -1168,15 +1141,6 @@ export async function startAuto( } invalidateAllCaches(); - // Clean stale runtime records left from the paused session - try { - await selfHealRuntimeRecords(s.basePath, ctx); - } catch (e) { - debugLog("resume-self-heal-runtime-failed", { - error: e instanceof Error ? e.message : String(e), - }); - } - if (s.pausedSessionFile) { const activityDir = join(gsdRoot(s.basePath), "activity"); const recovery = synthesizeCrashRecovery( @@ -1200,19 +1164,15 @@ export async function startAuto( lockBase(), "resuming", s.currentMilestoneId ?? "unknown", - s.completedUnits.length, ); writeLock( lockBase(), "resuming", s.currentMilestoneId ?? "unknown", - s.completedUnits.length, + 0, ); logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); - // Clear orphaned runtime records from prior process deaths before entering the loop - await selfHealRuntimeRecords(s.basePath, ctx); - await autoLoop(ctx, pi, s, buildLoopDeps()); cleanupAfterLoopExit(ctx); return; @@ -1244,9 +1204,6 @@ export async function startAuto( } logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, requestedStepMode ? "Step-mode started." : "Auto-mode started.", "progress"); - // Clear orphaned runtime records from prior process deaths before entering the loop - await selfHealRuntimeRecords(s.basePath, ctx); - // Dispatch the first unit await autoLoop(ctx, pi, s, buildLoopDeps()); cleanupAfterLoopExit(ctx); @@ -1387,7 +1344,6 @@ export async function dispatchHookUnit( s.basePath = targetBasePath; s.autoStartTime = Date.now(); s.currentUnit = null; - s.completedUnits = []; s.pendingQuickTasks = []; } @@ -1412,21 +1368,6 @@ export async function dispatchHookUnit( startedAt: hookStartedAt, }; - writeUnitRuntimeRecord( - s.basePath, - hookUnitType, - triggerUnitId, - hookStartedAt, - { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: hookStartedAt, - progressCount: 0, - lastProgressKind: "dispatch", - }, - ); - if (hookModel) { const availableModels = ctx.modelRegistry.getAvailable(); const match = resolveModelId(hookModel, availableModels, ctx.model?.provider); @@ -1450,7 +1391,7 @@ export async function dispatchHookUnit( lockBase(), hookUnitType, triggerUnitId, - s.completedUnits.length, + 0, sessionFile, ); @@ -1460,18 +1401,6 @@ export async function dispatchHookUnit( s.unitTimeoutHandle = setTimeout(async () => { s.unitTimeoutHandle = null; if (!s.active) return; - if (s.currentUnit) { - writeUnitRuntimeRecord( - s.basePath, - hookUnitType, - triggerUnitId, - hookStartedAt, - { - phase: "timeout", - timeoutAt: Date.now(), - }, - ); - } ctx.ui.notify( `Hook ${hookName} exceeded ${supervisor.hard_timeout_minutes ?? 30}min timeout. Pausing auto-mode.`, "warning", @@ -1503,8 +1432,6 @@ export { dispatchDirectPhase } from "./auto-direct-dispatch.js"; // Re-export recovery functions for external consumers export { - resolveExpectedArtifactPath, - verifyExpectedArtifact, - writeBlockerPlaceholder, buildLoopRemediationSteps, } from "./auto-recovery.js"; +export { resolveExpectedArtifactPath } from "./auto-artifact-paths.js"; diff --git a/src/resources/extensions/gsd/auto/loop-deps.ts b/src/resources/extensions/gsd/auto/loop-deps.ts index 98dcf747d..6a9ae6eae 100644 --- a/src/resources/extensions/gsd/auto/loop-deps.ts +++ b/src/resources/extensions/gsd/auto/loop-deps.ts @@ -80,7 +80,6 @@ export interface LoopDeps { basePath: string, unitType: string, unitId: string, - completedUnits: number, sessionFile?: string, ) => void; handleLostSessionLock: ( @@ -179,29 +178,11 @@ export interface LoopDeps { startedAt: number, opts?: CloseoutOptions & Record, ) => Promise; - verifyExpectedArtifact: ( - unitType: string, - unitId: string, - basePath: string, - ) => boolean; - clearUnitRuntimeRecord: ( - basePath: string, - unitType: string, - unitId: string, - ) => void; - writeUnitRuntimeRecord: ( - basePath: string, - unitType: string, - unitId: string, - startedAt: number, - record: Record, - ) => void; recordOutcome: (unitType: string, tier: string, success: boolean) => void; writeLock: ( lockBase: string, unitType: string, unitId: string, - completedCount: number, sessionFile?: string, ) => void; captureAvailableSkills: () => void; diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 33514bc26..e02861c65 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -24,8 +24,6 @@ import { import { detectStuck } from "./detect-stuck.js"; import { runUnit } from "./run-unit.js"; import { debugLog } from "../debug-logger.js"; -import { gsdRoot } from "../paths.js"; -import { atomicWriteSync } from "../atomic-write.js"; import { PROJECT_FILES } from "../detection.js"; import { MergeConflictError } from "../git-service.js"; import { join } from "node:path"; @@ -1001,7 +999,6 @@ export async function runUnitPhase( deps.lockBase(), unitType, unitId, - s.completedUnits.length, ); debugLog("autoLoop", { @@ -1032,14 +1029,12 @@ export async function runUnitPhase( deps.lockBase(), unitType, unitId, - s.completedUnits.length, sessionFile, ); deps.writeLock( deps.lockBase(), unitType, unitId, - s.completedUnits.length, sessionFile, ); @@ -1103,8 +1098,8 @@ export async function runUnitPhase( `${unitType} ${unitId} completed with 0 tool calls — hallucinated summary, will retry`, "warning", ); - // Do NOT add to completedUnits — fall through to next iteration - // where dispatch will re-derive and re-dispatch this task. + // Fall through to next iteration where dispatch will re-derive + // and re-dispatch this task. return { action: "next", data: { unitStartedAt: s.currentUnit.startedAt } }; } } @@ -1123,25 +1118,6 @@ export async function runUnitPhase( skipArtifactVerification || deps.verifyExpectedArtifact(unitType, unitId, s.basePath); if (artifactVerified) { - s.completedUnits.push({ - type: unitType, - id: unitId, - startedAt: s.currentUnit.startedAt, - finishedAt: Date.now(), - }); - if (s.completedUnits.length > 200) { - s.completedUnits = s.completedUnits.slice(-200); - } - // Flush completed-units to disk so the record survives crashes - try { - const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json"); - const keys = s.completedUnits.map((u) => `${u.type}/${u.id}`); - atomicWriteSync(completedKeysPath, JSON.stringify(keys, null, 2)); - } catch (e) { - logWarning("engine", "Failed to flush completed-units to disk", { error: String(e) }); - } - - deps.clearUnitRuntimeRecord(s.basePath, unitType, unitId); s.unitDispatchCount.delete(`${unitType}/${unitId}`); s.unitRecoveryCount.delete(`${unitType}/${unitId}`); } @@ -1186,8 +1162,8 @@ export async function runFinalize( // Sidecar items use lightweight pre-verification opts const preVerificationOpts: PreVerificationOpts | undefined = sidecarItem ? sidecarItem.kind === "hook" - ? { skipSettleDelay: true, skipDoctor: true, skipStateRebuild: true, skipWorktreeSync: true } - : { skipSettleDelay: true, skipStateRebuild: true } + ? { skipSettleDelay: true, skipWorktreeSync: true } + : { skipSettleDelay: true } : undefined; const preResult = await deps.postUnitPreVerification(postUnitCtx, preVerificationOpts); if (preResult === "dispatched") { diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 16b94f2e1..e5afeb98a 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -23,13 +23,6 @@ import type { BudgetAlertLevel } from "../auto-budget.js"; // ─── Exported Types ────────────────────────────────────────────────────────── -export interface CompletedUnit { - type: string; - id: string; - startedAt: number; - finishedAt: number; -} - export interface CurrentUnit { type: string; id: string; @@ -106,7 +99,6 @@ export class AutoSession { // ── Current unit ───────────────────────────────────────────────────────── currentUnit: CurrentUnit | null = null; currentUnitRouting: UnitRouting | null = null; - completedUnits: CompletedUnit[] = []; currentMilestoneId: string | null = null; // ── Model state ────────────────────────────────────────────────────────── @@ -160,14 +152,6 @@ export class AutoSession { return this.originalBasePath || this.basePath; } - completeCurrentUnit(): CompletedUnit | null { - if (!this.currentUnit) return null; - const done: CompletedUnit = { ...this.currentUnit, finishedAt: Date.now() }; - this.completedUnits.push(done); - this.currentUnit = null; - return done; - } - reset(): void { this.clearTimers(); @@ -193,7 +177,6 @@ export class AutoSession { // Unit this.currentUnit = null; this.currentUnitRouting = null; - this.completedUnits = []; this.currentMilestoneId = null; // Model @@ -234,7 +217,6 @@ export class AutoSession { activeRunDir: this.activeRunDir, currentMilestoneId: this.currentMilestoneId, currentUnit: this.currentUnit, - completedUnits: this.completedUnits.length, unitDispatchCount: Object.fromEntries(this.unitDispatchCount), }; } diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 0faa9563f..40fdedc93 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -7,6 +7,7 @@ import { buildMilestoneFileName, resolveMilestonePath, resolveSliceFile, resolve import { buildBeforeAgentStartResult } from "./system-context.js"; import { handleAgentEnd } from "./agent-end-recovery.js"; import { clearDiscussionFlowState, isDepthVerified, isQueuePhaseActive, markDepthVerified, resetWriteGateState, shouldBlockContextWrite } from "./write-gate.js"; +import { isBlockedStateFile } from "../write-intercept.js"; import { getDiscussionMilestoneId } from "../guided-flow.js"; import { loadToolApiKeys } from "../commands-config.js"; import { loadFile, saveFile, formatContinue } from "../files.js"; @@ -136,6 +137,14 @@ export function registerHooks(pi: ExtensionAPI): void { } if (!isToolCallEventType("write", event)) return; + + // Block direct writes to authoritative .gsd/ state files (single-writer engine) + const filePath = event.input.path; + if (isBlockedStateFile(filePath)) { + const { basename } = await import("node:path"); + return { block: true, reason: `Direct writes to ${basename(filePath)} are blocked. Use the gsd_* tool API instead.` }; + } + const result = shouldBlockContextWrite( event.toolName, event.input.path, diff --git a/src/resources/extensions/gsd/crash-recovery.ts b/src/resources/extensions/gsd/crash-recovery.ts index 8db786026..1186d5ed8 100644 --- a/src/resources/extensions/gsd/crash-recovery.ts +++ b/src/resources/extensions/gsd/crash-recovery.ts @@ -23,7 +23,6 @@ export interface LockData { unitType: string; unitId: string; unitStartedAt: string; - completedUnits: number; /** Path to the pi session JSONL file that was active when this unit started. */ sessionFile?: string; } @@ -37,7 +36,6 @@ export function writeLock( basePath: string, unitType: string, unitId: string, - completedUnits: number, sessionFile?: string, ): void { try { @@ -47,7 +45,6 @@ export function writeLock( unitType, unitId, unitStartedAt: new Date().toISOString(), - completedUnits, sessionFile, }; const lp = lockPath(basePath); @@ -102,12 +99,11 @@ export function formatCrashInfo(lock: LockData): string { `Previous auto-mode session was interrupted.`, ` Was executing: ${lock.unitType} (${lock.unitId})`, ` Started at: ${lock.unitStartedAt}`, - ` Units completed before crash: ${lock.completedUnits}`, ` PID: ${lock.pid}`, ]; // Add recovery guidance based on what was happening when it crashed - if (lock.unitType === "starting" && lock.unitId === "bootstrap" && lock.completedUnits === 0) { + if (lock.unitType === "starting" && lock.unitId === "bootstrap") { lines.push(`No work was lost. Run /gsd auto to restart.`); } else if (lock.unitType.includes("research") || lock.unitType.includes("plan")) { lines.push(`The ${lock.unitType} unit may be incomplete. Run /gsd auto to re-run it.`); diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index 0b0d05033..4a30fd6bc 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -5,7 +5,7 @@ import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js"; import { readRepoMeta, externalProjectsRoot, cleanNumberedGsdVariants } from "./repo-identity.js"; import { loadFile } from "./files.js"; import { parseRoadmap as parseLegacyRoadmap } from "./parsers-legacy.js"; -import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; +import { isDbAvailable, _getAdapter, getMilestoneSlices } from "./gsd-db.js"; import { resolveMilestoneFile, milestonesDir, gsdRoot, resolveGsdRootFile, relGsdRootFile } from "./paths.js"; import { deriveState, isMilestoneComplete } from "./state.js"; import { saveFile } from "./files.js"; @@ -19,6 +19,8 @@ import { getAllWorktreeHealth } from "./worktree-health.js"; import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js"; import { recoverFailedMigration } from "./migrate-external.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; +import { readEvents } from "./workflow-events.js"; +import { renderAllProjections } from "./workflow-projections.js"; export async function checkGitHealth( basePath: string, @@ -1111,3 +1113,179 @@ export async function checkGlobalHealth( // Non-fatal — global health check must not block per-project doctor } } + +// ── Engine Health Checks ──────────────────────────────────────────────────── +// DB constraint violation detection and projection drift checks. + +export async function checkEngineHealth( + basePath: string, + issues: DoctorIssue[], + fixesApplied: string[], +): Promise { + // ── DB constraint violation detection (full doctor only, not pre-dispatch per D-10) ── + try { + if (isDbAvailable()) { + const adapter = _getAdapter()!; + + // a. Orphaned tasks (task.slice_id points to non-existent slice) + try { + const orphanedTasks = adapter + .prepare( + `SELECT t.id, t.slice_id, t.milestone_id + FROM tasks t + LEFT JOIN slices s ON t.milestone_id = s.milestone_id AND t.slice_id = s.id + WHERE s.id IS NULL`, + ) + .all() as Array<{ id: string; slice_id: string; milestone_id: string }>; + + for (const row of orphanedTasks) { + issues.push({ + severity: "error", + code: "db_orphaned_task", + scope: "task", + unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, + message: `Task ${row.id} references slice ${row.slice_id} in milestone ${row.milestone_id} but no such slice exists in the database`, + fixable: false, + }); + } + } catch { + // Non-fatal — orphaned task check failed + } + + // b. Orphaned slices (slice.milestone_id points to non-existent milestone) + try { + const orphanedSlices = adapter + .prepare( + `SELECT s.id, s.milestone_id + FROM slices s + LEFT JOIN milestones m ON s.milestone_id = m.id + WHERE m.id IS NULL`, + ) + .all() as Array<{ id: string; milestone_id: string }>; + + for (const row of orphanedSlices) { + issues.push({ + severity: "error", + code: "db_orphaned_slice", + scope: "slice", + unitId: `${row.milestone_id}/${row.id}`, + message: `Slice ${row.id} references milestone ${row.milestone_id} but no such milestone exists in the database`, + fixable: false, + }); + } + } catch { + // Non-fatal — orphaned slice check failed + } + + // c. Tasks marked complete without summaries + try { + const doneTasks = adapter + .prepare( + `SELECT id, slice_id, milestone_id FROM tasks + WHERE status = 'done' AND (summary IS NULL OR summary = '')`, + ) + .all() as Array<{ id: string; slice_id: string; milestone_id: string }>; + + for (const row of doneTasks) { + issues.push({ + severity: "warning", + code: "db_done_task_no_summary", + scope: "task", + unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, + message: `Task ${row.id} is marked done but has no summary in the database`, + fixable: false, + }); + } + } catch { + // Non-fatal — done-task-no-summary check failed + } + + // d. Duplicate entity IDs (safety check) + try { + const dupMilestones = adapter + .prepare("SELECT id, COUNT(*) as cnt FROM milestones GROUP BY id HAVING cnt > 1") + .all() as Array<{ id: string; cnt: number }>; + for (const row of dupMilestones) { + issues.push({ + severity: "error", + code: "db_duplicate_id", + scope: "milestone", + unitId: row.id, + message: `Duplicate milestone ID "${row.id}" appears ${row.cnt} times in the database`, + fixable: false, + }); + } + + const dupSlices = adapter + .prepare("SELECT id, milestone_id, COUNT(*) as cnt FROM slices GROUP BY id, milestone_id HAVING cnt > 1") + .all() as Array<{ id: string; milestone_id: string; cnt: number }>; + for (const row of dupSlices) { + issues.push({ + severity: "error", + code: "db_duplicate_id", + scope: "slice", + unitId: `${row.milestone_id}/${row.id}`, + message: `Duplicate slice ID "${row.id}" in milestone ${row.milestone_id} appears ${row.cnt} times`, + fixable: false, + }); + } + + const dupTasks = adapter + .prepare("SELECT id, slice_id, milestone_id, COUNT(*) as cnt FROM tasks GROUP BY id, slice_id, milestone_id HAVING cnt > 1") + .all() as Array<{ id: string; slice_id: string; milestone_id: string; cnt: number }>; + for (const row of dupTasks) { + issues.push({ + severity: "error", + code: "db_duplicate_id", + scope: "task", + unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`, + message: `Duplicate task ID "${row.id}" in slice ${row.slice_id} appears ${row.cnt} times`, + fixable: false, + }); + } + } catch { + // Non-fatal — duplicate ID check failed + } + } + } catch { + // Non-fatal — DB constraint checks failed entirely + } + + // ── Projection drift detection ────────────────────────────────────────── + // If the DB is available, check whether markdown projections are stale + // relative to the event log and re-render them. + try { + if (isDbAvailable()) { + const eventLogPath = join(basePath, ".gsd", "event-log.jsonl"); + const events = readEvents(eventLogPath); + if (events.length > 0) { + const lastEventTs = new Date(events[events.length - 1]!.ts).getTime(); + const state = await deriveState(basePath); + for (const milestone of state.registry) { + if (milestone.status === "complete") continue; + const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP"); + if (!roadmapPath || !existsSync(roadmapPath)) { + try { + await renderAllProjections(basePath, milestone.id); + fixesApplied.push(`re-rendered missing projections for ${milestone.id}`); + } catch { + // Non-fatal — projection re-render failed + } + continue; + } + const projectionMtime = statSync(roadmapPath).mtimeMs; + if (lastEventTs > projectionMtime) { + try { + await renderAllProjections(basePath, milestone.id); + fixesApplied.push(`re-rendered stale projections for ${milestone.id}`); + } catch { + // Non-fatal — projection re-render failed + } + } + } + } + } + } catch { + // Non-fatal — projection drift check must never block doctor + } +} diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index 95ea0e70b..864e8f8fa 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -70,7 +70,13 @@ export type DoctorIssueCode = | "large_planning_file" // Slow environment checks (opt-in via --build / --test flags) | "env_build" - | "env_test"; + | "env_test" + // Engine health checks (Phase 4) + | "db_orphaned_task" + | "db_orphaned_slice" + | "db_done_task_no_summary" + | "db_duplicate_id" + | "projection_drift"; /** * Issue codes that represent global or completion-critical state. diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index f723edd0a..445278977 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -12,7 +12,7 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences. import type { DoctorIssue, DoctorIssueCode, DoctorReport } from "./doctor-types.js"; import { GLOBAL_STATE_CODES } from "./doctor-types.js"; import type { RoadmapSliceEntry } from "./types.js"; -import { checkGitHealth, checkRuntimeHealth, checkGlobalHealth } from "./doctor-checks.js"; +import { checkGitHealth, checkRuntimeHealth, checkGlobalHealth, checkEngineHealth } from "./doctor-checks.js"; import { checkEnvironmentHealth } from "./doctor-environment.js"; import { runProviderChecks } from "./doctor-providers.js"; @@ -382,6 +382,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; }); const envMs = Date.now() - t0env; + // Engine health checks — DB constraints and projection drift + await checkEngineHealth(basePath, issues, fixesApplied); + const milestonesPath = milestonesDir(basePath); if (!existsSync(milestonesPath)) { const report: DoctorReport = { ok: issues.every(i => i.severity !== "error"), basePath, issues, fixesApplied, timing: { git: gitMs, runtime: runtimeMs, environment: envMs, gsdState: 0 } }; diff --git a/src/resources/extensions/gsd/parallel-orchestrator.ts b/src/resources/extensions/gsd/parallel-orchestrator.ts index d2b71be22..a574444d8 100644 --- a/src/resources/extensions/gsd/parallel-orchestrator.ts +++ b/src/resources/extensions/gsd/parallel-orchestrator.ts @@ -52,7 +52,6 @@ export interface WorkerInfo { worktreePath: string; startedAt: number; state: "running" | "paused" | "stopped" | "error"; - completedUnits: number; cost: number; cleanup?: () => void; } @@ -83,7 +82,6 @@ export interface PersistedState { worktreePath: string; startedAt: number; state: "running" | "paused" | "stopped" | "error"; - completedUnits: number; cost: number; }>; totalCost: number; @@ -114,7 +112,6 @@ export function persistState(basePath: string): void { worktreePath: w.worktreePath, startedAt: w.startedAt, state: w.state, - completedUnits: w.completedUnits, cost: w.cost, })), totalCost: state.totalCost, @@ -226,7 +223,6 @@ function restoreRuntimeState(basePath: string): boolean { worktreePath: diskStatus?.worktreePath ?? w.worktreePath, startedAt: w.startedAt, state: diskStatus?.state ?? w.state, - completedUnits: diskStatus?.completedUnits ?? w.completedUnits, cost: diskStatus?.cost ?? w.cost, }); } @@ -261,7 +257,6 @@ function restoreRuntimeState(basePath: string): boolean { worktreePath: status.worktreePath, startedAt: status.startedAt, state: status.state, - completedUnits: status.completedUnits, cost: status.cost, }); state.totalCost += status.cost; @@ -389,7 +384,6 @@ export async function startParallel( worktreePath: w.worktreePath, startedAt: w.startedAt, state: "running", - completedUnits: w.completedUnits, cost: w.cost, }); adopted.push(w.milestoneId); @@ -440,7 +434,6 @@ export async function startParallel( worktreePath: wtPath, startedAt: now, state: "running", - completedUnits: 0, cost: 0, }; @@ -602,7 +595,7 @@ export function spawnWorker( pid: worker.pid, state: "running", currentUnit: null, - completedUnits: worker.completedUnits, + completedUnits: 0, cost: worker.cost, lastHeartbeat: Date.now(), startedAt: worker.startedAt, @@ -645,7 +638,7 @@ export function spawnWorker( pid: w.pid, state: w.state, currentUnit: null, - completedUnits: w.completedUnits, + completedUnits: 0, cost: w.cost, lastHeartbeat: Date.now(), startedAt: w.startedAt, @@ -727,14 +720,6 @@ function processWorkerLine(basePath: string, milestoneId: string, line: string): } } - // Track completed units (each message_end from assistant = progress) - if (msg.role === "assistant") { - const worker = state.workers.get(milestoneId); - if (worker) { - worker.completedUnits++; - } - } - // Update session status file so dashboard sees live cost const worker = state.workers.get(milestoneId); if (worker) { @@ -743,7 +728,7 @@ function processWorkerLine(basePath: string, milestoneId: string, line: string): pid: worker.pid, state: worker.state, currentUnit: null, - completedUnits: worker.completedUnits, + completedUnits: 0, cost: worker.cost, lastHeartbeat: Date.now(), startedAt: worker.startedAt, @@ -762,7 +747,7 @@ function processWorkerLine(basePath: string, milestoneId: string, line: string): pid: worker.pid, state: worker.state, currentUnit: null, - completedUnits: worker.completedUnits, + completedUnits: 0, cost: worker.cost, lastHeartbeat: Date.now(), startedAt: worker.startedAt, @@ -930,14 +915,13 @@ export function refreshWorkerStatuses( if (!isPidAlive(worker.pid)) { worker.cleanup?.(); worker.cleanup = undefined; - worker.state = worker.completedUnits > 0 ? "stopped" : "error"; + worker.state = "error"; worker.process = null; } continue; } worker.state = diskStatus.state; - worker.completedUnits = diskStatus.completedUnits; worker.cost = diskStatus.cost; worker.pid = diskStatus.pid; } diff --git a/src/resources/extensions/gsd/prompts/complete-slice.md b/src/resources/extensions/gsd/prompts/complete-slice.md index d2cc57971..6047d8e2a 100644 --- a/src/resources/extensions/gsd/prompts/complete-slice.md +++ b/src/resources/extensions/gsd/prompts/complete-slice.md @@ -23,28 +23,15 @@ Then: 2. {{skillActivation}} 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, 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: +5. If this slice produced evidence that a requirement changed status (Active → Validated, Active → Deferred, etc.), call `gsd_save_decision` with scope="requirement", decision="{requirement-id}", choice="{new-status}", rationale="{evidence}". Do NOT write `.gsd/REQUIREMENTS.md` directly — the engine renders it from the database. +6. Write `{{sliceSummaryPath}}` (compress all task summaries). +7. Write `{{sliceUatPath}}` — a concrete UAT script with real test cases derived from the slice plan and task summaries. Include preconditions, numbered steps with expected outcomes, and edge cases. This must NOT be a placeholder or generic template — tailor every test case to what this slice actually built. +8. Review task summaries for `key_decisions`. Append any significant decisions to `.gsd/DECISIONS.md` if missing. +9. Review task summaries for patterns, gotchas, or non-obvious lessons learned. If any would save future agents from repeating investigation or hitting the same issues, append them to `.gsd/KNOWLEDGE.md`. Only add entries that are genuinely useful — don't pad with obvious observations. +10. Call `gsd_complete_slice` with milestone_id, slice_id, the slice summary, and the UAT result. Do NOT manually mark the roadmap checkbox — the tool writes to the DB and renders the ROADMAP.md projection automatically. +11. Do not run git commands — the system commits your changes and handles any merge after this unit succeeds. +12. Update `.gsd/PROJECT.md` if it exists — refresh current state if needed. - **Identity:** `sliceId`, `milestoneId`, `sliceTitle` - - **Narrative:** `oneLiner` (one-line summary of what the slice accomplished), `narrative` (detailed account of what happened across all tasks), `verification` (what was verified and how), `deviations` (deviations from plan, or "None."), `knownLimitations` (gaps or limitations, or "None."), `followUps` (follow-up work discovered, or "None.") - - **Files:** `keyFiles` (array of key file paths), `filesModified` (array of `{path, description}` objects for all files changed) - - **Requirements:** `requirementsAdvanced` (array of `{id, how}`), `requirementsValidated` (array of `{id, proof}`), `requirementsInvalidated` (array of `{id, what}`), `requirementsSurfaced` (array of new requirement strings) - - **Patterns & decisions:** `keyDecisions` (array of decision strings), `patternsEstablished` (array), `observabilitySurfaces` (array) - - **Dependencies:** `provides` (what this slice provides downstream), `affects` (downstream slice IDs affected), `requires` (array of `{slice, provides}` for upstream dependencies consumed), `drillDownPaths` (paths to task summaries) - - **UAT content:** `uatContent` — the UAT markdown body. This must be a concrete UAT script with real test cases derived from the slice plan and task summaries. Include preconditions, numbered steps with expected outcomes, and edge cases. This must NOT be a placeholder or generic template — tailor every test case to what this slice actually built. The tool writes it to `{{sliceUatPath}}`. - -7. Review task summaries for `key_decisions`. Append any significant decisions to `.gsd/DECISIONS.md` if missing. -8. Review task summaries for patterns, gotchas, or non-obvious lessons learned. If any would save future agents from repeating investigation or hitting the same issues, append them to `.gsd/KNOWLEDGE.md`. Only add entries that are genuinely useful — don't pad with obvious observations. -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 updating `{{roadmapPath}}` atomically. You must still review decisions and knowledge manually (steps 7-8). +**You MUST do ALL THREE before finishing: (1) write `{{sliceSummaryPath}}`, (2) write `{{sliceUatPath}}`, (3) call `gsd_complete_slice`. The unit will not be marked complete if any of these are missing.** When done, say: "Slice {{sliceId}} complete." diff --git a/src/resources/extensions/gsd/prompts/execute-task.md b/src/resources/extensions/gsd/prompts/execute-task.md index 3f593492f..1ca99e25f 100644 --- a/src/resources/extensions/gsd/prompts/execute-task.md +++ b/src/resources/extensions/gsd/prompts/execute-task.md @@ -63,23 +63,13 @@ 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 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}}" - - `oneLiner`: One-line summary of what was accomplished (becomes the commit message) - - `narrative`: Detailed narrative of what happened during the task - - `verification`: What was verified and how — commands run, tests passed, behavior confirmed - - `deviations`: Deviations from the task plan, or "None." - - `knownIssues`: Known issues discovered but not fixed, or "None." - - `keyFiles`: Array of key files created or modified - - `keyDecisions`: Array of key decisions made during this task - - `blockerDiscovered`: Whether a plan-invalidating blocker was discovered (boolean) - - `verificationEvidence`: Array of `{ command, exitCode, verdict, durationMs }` objects from the verification gate -15. Do not run git commands — the system reads your task summary after completion and creates a meaningful commit from it (type inferred from title, message from your one-liner, key files from frontmatter). Write a clear, specific one-liner in the summary — it becomes the commit message. +14. Read the template at `~/.gsd/agent/extensions/gsd/templates/task-summary.md` +15. Write `{{taskSummaryPath}}` +16. Call `gsd_complete_task` with milestone_id, slice_id, task_id, and a summary of what was accomplished. This is your final required step — do NOT manually edit PLAN.md checkboxes. The tool marks the task complete, updates the DB, and renders PLAN.md automatically. +17. Do not run git commands — the system reads your task summary after completion and creates a meaningful commit from it (type inferred from title, message from your one-liner, key files from frontmatter). Write a clear, specific one-liner in the summary — it becomes the commit message. All work stays in your working directory: `{{workingDirectory}}`. -**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. +**You MUST call `gsd_complete_task` AND write `{{taskSummaryPath}}` before finishing.** When done, say: "Task {{taskId}} complete." diff --git a/src/resources/extensions/gsd/prompts/plan-slice.md b/src/resources/extensions/gsd/prompts/plan-slice.md index 7e6721c48..a97840d58 100644 --- a/src/resources/extensions/gsd/prompts/plan-slice.md +++ b/src/resources/extensions/gsd/prompts/plan-slice.md @@ -72,9 +72,11 @@ 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. -8. If planning produced structural decisions, append them to `.gsd/DECISIONS.md` -9. {{commitInstruction}} +10. If planning produced structural decisions, append them to `.gsd/DECISIONS.md` +11. {{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/session-lock.ts b/src/resources/extensions/gsd/session-lock.ts index dc19f86c4..e77c8bd7a 100644 --- a/src/resources/extensions/gsd/session-lock.ts +++ b/src/resources/extensions/gsd/session-lock.ts @@ -32,7 +32,6 @@ export interface SessionLockData { unitType: string; unitId: string; unitStartedAt: string; - completedUnits: number; sessionFile?: string; } @@ -205,7 +204,6 @@ export function acquireSessionLock(basePath: string): SessionLockResult { unitType: "starting", unitId: "bootstrap", unitStartedAt: new Date().toISOString(), - completedUnits: 0, }; let lockfile: typeof import("proper-lockfile"); @@ -379,7 +377,6 @@ export function updateSessionLock( basePath: string, unitType: string, unitId: string, - completedUnits: number, sessionFile?: string, ): void { if (_lockedPath !== basePath && _lockedPath !== null) return; @@ -392,7 +389,6 @@ export function updateSessionLock( unitType, unitId, unitStartedAt: new Date().toISOString(), - completedUnits, sessionFile, }; atomicWriteSync(lp, JSON.stringify(data, null, 2)); diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 32d2d50e0..4a7180c29 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -118,6 +118,11 @@ interface StateCache { const CACHE_TTL_MS = 100; let _stateCache: StateCache | null = null; +// ── Telemetry counters for derive-path observability ──────────────────────── +let _telemetry = { dbDeriveCount: 0, markdownDeriveCount: 0 }; +export function getDeriveTelemetry() { return { ..._telemetry }; } +export function resetDeriveTelemetry() { _telemetry = { dbDeriveCount: 0, markdownDeriveCount: 0 }; } + /** * Invalidate the deriveState() cache. Call this whenever planning files on disk * may have changed (unit completion, merges, file writes). @@ -204,12 +209,15 @@ export async function deriveState(basePath: string): Promise { const stopDbTimer = debugTime("derive-state-db"); result = await deriveStateFromDb(basePath); stopDbTimer({ phase: result.phase, milestone: result.activeMilestone?.id }); + _telemetry.dbDeriveCount++; } else { // DB open but empty hierarchy tables — pre-migration project, use filesystem result = await _deriveStateImpl(basePath); + _telemetry.markdownDeriveCount++; } } else { result = await _deriveStateImpl(basePath); + _telemetry.markdownDeriveCount++; } stopTimer({ phase: result.phase, milestone: result.activeMilestone?.id }); diff --git a/src/resources/extensions/gsd/sync-lock.ts b/src/resources/extensions/gsd/sync-lock.ts new file mode 100644 index 000000000..168a336a6 --- /dev/null +++ b/src/resources/extensions/gsd/sync-lock.ts @@ -0,0 +1,94 @@ +// GSD Extension — Advisory Sync Lock +// Prevents concurrent worktree syncs from colliding via a simple file lock. +// Stale locks (mtime > 60s) are auto-overridden. Lock acquisition waits up +// to 5 seconds then skips non-fatally. + +import { existsSync, statSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteSync } from "./atomic-write.js"; + +const STALE_THRESHOLD_MS = 60_000; // 60 seconds +const DEFAULT_TIMEOUT_MS = 5_000; // 5 seconds +const SPIN_INTERVAL_MS = 100; // 100ms polling interval + +// SharedArrayBuffer for synchronous sleep via Atomics.wait +const SLEEP_BUFFER = new SharedArrayBuffer(4); +const SLEEP_VIEW = new Int32Array(SLEEP_BUFFER); + +function lockFilePath(basePath: string): string { + return join(basePath, ".gsd", "sync.lock"); +} + +function sleepSync(ms: number): void { + Atomics.wait(SLEEP_VIEW, 0, 0, ms); +} + +/** + * Acquire an advisory sync lock for the given basePath. + * Returns { acquired: true } on success, { acquired: false } after timeout. + * + * - Creates lock file at {basePath}/.gsd/sync.lock with JSON { pid, acquired_at } + * - If lock exists and mtime > 60s (stale), overrides it + * - If lock exists and not stale, spins up to timeoutMs before giving up + */ +export function acquireSyncLock( + basePath: string, + timeoutMs: number = DEFAULT_TIMEOUT_MS, +): { acquired: boolean } { + const lp = lockFilePath(basePath); + const deadline = Date.now() + timeoutMs; + + while (true) { + // Check if lock file exists + if (existsSync(lp)) { + // Check staleness + try { + const stat = statSync(lp); + const age = Date.now() - stat.mtimeMs; + if (age > STALE_THRESHOLD_MS) { + // Stale lock — override it + try { unlinkSync(lp); } catch { /* race: already removed */ } + } else { + // Lock is held and not stale — wait or give up + if (Date.now() >= deadline) { + return { acquired: false }; + } + sleepSync(SPIN_INTERVAL_MS); + continue; + } + } catch { + // stat failed (file removed between exists check and stat) — try to acquire + } + } + + // Lock file does not exist (or was just removed) — try to write it + try { + const lockData = { + pid: process.pid, + acquired_at: new Date().toISOString(), + }; + atomicWriteSync(lp, JSON.stringify(lockData, null, 2)); + return { acquired: true }; + } catch { + // Write failed (race condition with another process) — retry or give up + if (Date.now() >= deadline) { + return { acquired: false }; + } + sleepSync(SPIN_INTERVAL_MS); + } + } +} + +/** + * Release the advisory sync lock. No-op if lock file does not exist. + */ +export function releaseSyncLock(basePath: string): void { + const lp = lockFilePath(basePath); + try { + if (existsSync(lp)) { + unlinkSync(lp); + } + } catch { + // Non-fatal — lock may have been released by another process + } +} diff --git a/src/resources/extensions/gsd/tools/complete-milestone.ts b/src/resources/extensions/gsd/tools/complete-milestone.ts index 1e5e96ef9..ae27f4a37 100644 --- a/src/resources/extensions/gsd/tools/complete-milestone.ts +++ b/src/resources/extensions/gsd/tools/complete-milestone.ts @@ -17,6 +17,9 @@ import { import { resolveMilestonePath, clearPathCache } from "../paths.js"; import { saveFile, clearParseCache } from "../files.js"; import { invalidateStateCache } from "../state.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; export interface CompleteMilestoneParams { milestoneId: string; @@ -169,6 +172,22 @@ export async function handleCompleteMilestone( clearPathCache(); clearParseCache(); + // ── Post-mutation hook: projections, manifest, event log ─────────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "complete-milestone", + params: { milestoneId: params.milestoneId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: complete-milestone post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { milestoneId: params.milestoneId, summaryPath, diff --git a/src/resources/extensions/gsd/tools/complete-slice.ts b/src/resources/extensions/gsd/tools/complete-slice.ts index fd6009a42..6f0c92d28 100644 --- a/src/resources/extensions/gsd/tools/complete-slice.ts +++ b/src/resources/extensions/gsd/tools/complete-slice.ts @@ -23,6 +23,9 @@ import { resolveSliceFile, resolveSlicePath, clearPathCache } from "../paths.js" import { saveFile, clearParseCache } from "../files.js"; import { invalidateStateCache } from "../state.js"; import { renderRoadmapCheckboxes } from "../markdown-renderer.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; export interface CompleteSliceResult { sliceId: string; @@ -291,6 +294,22 @@ export async function handleCompleteSlice( clearPathCache(); clearParseCache(); + // ── Post-mutation hook: projections, manifest, event log ─────────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "complete-slice", + params: { milestoneId: params.milestoneId, sliceId: params.sliceId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: complete-slice post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { sliceId: params.sliceId, milestoneId: params.milestoneId, diff --git a/src/resources/extensions/gsd/tools/complete-task.ts b/src/resources/extensions/gsd/tools/complete-task.ts index 859b21c36..e20366edc 100644 --- a/src/resources/extensions/gsd/tools/complete-task.ts +++ b/src/resources/extensions/gsd/tools/complete-task.ts @@ -23,6 +23,9 @@ import { resolveSliceFile, resolveTasksDir, clearPathCache } from "../paths.js"; import { saveFile, clearParseCache } from "../files.js"; import { invalidateStateCache } from "../state.js"; import { renderPlanCheckboxes } from "../markdown-renderer.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; export interface CompleteTaskResult { taskId: string; @@ -236,6 +239,22 @@ export async function handleCompleteTask( clearPathCache(); clearParseCache(); + // ── Post-mutation hook: projections, manifest, event log ─────────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "complete-task", + params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: complete-task post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { taskId: params.taskId, sliceId: params.sliceId, diff --git a/src/resources/extensions/gsd/tools/plan-milestone.ts b/src/resources/extensions/gsd/tools/plan-milestone.ts index 0bb2e9e25..c9d536c03 100644 --- a/src/resources/extensions/gsd/tools/plan-milestone.ts +++ b/src/resources/extensions/gsd/tools/plan-milestone.ts @@ -9,6 +9,9 @@ import { } from "../gsd-db.js"; import { invalidateStateCache } from "../state.js"; import { renderRoadmapFromDb } from "../markdown-renderer.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; export interface PlanMilestoneSliceInput { sliceId: string; @@ -242,6 +245,22 @@ export async function handlePlanMilestone( invalidateStateCache(); clearParseCache(); + // ── Post-mutation hook: projections, manifest, event log ─────────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "plan-milestone", + params: { milestoneId: params.milestoneId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: plan-milestone post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { milestoneId: params.milestoneId, roadmapPath, diff --git a/src/resources/extensions/gsd/tools/plan-slice.ts b/src/resources/extensions/gsd/tools/plan-slice.ts index 2a9d648eb..d46be8d6d 100644 --- a/src/resources/extensions/gsd/tools/plan-slice.ts +++ b/src/resources/extensions/gsd/tools/plan-slice.ts @@ -9,6 +9,9 @@ import { } from "../gsd-db.js"; import { invalidateStateCache } from "../state.js"; import { renderPlanFromDb } from "../markdown-renderer.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; export interface PlanSliceTaskInput { taskId: string; @@ -180,6 +183,23 @@ export async function handlePlanSlice( const renderResult = await renderPlanFromDb(basePath, params.milestoneId, params.sliceId); invalidateStateCache(); clearParseCache(); + + // ── Post-mutation hook: projections, manifest, event log ───────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "plan-slice", + params: { milestoneId: params.milestoneId, sliceId: params.sliceId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: plan-slice post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { milestoneId: params.milestoneId, sliceId: params.sliceId, diff --git a/src/resources/extensions/gsd/tools/plan-task.ts b/src/resources/extensions/gsd/tools/plan-task.ts index 7d91a49e8..429115212 100644 --- a/src/resources/extensions/gsd/tools/plan-task.ts +++ b/src/resources/extensions/gsd/tools/plan-task.ts @@ -2,6 +2,9 @@ import { clearParseCache } from "../files.js"; import { transaction, getSlice, getTask, insertTask, upsertTaskPlanning } from "../gsd-db.js"; import { invalidateStateCache } from "../state.js"; import { renderTaskPlanFromDb } from "../markdown-renderer.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; export interface PlanTaskParams { milestoneId: string; @@ -106,6 +109,23 @@ export async function handlePlanTask( const renderResult = await renderTaskPlanFromDb(basePath, params.milestoneId, params.sliceId, params.taskId); invalidateStateCache(); clearParseCache(); + + // ── Post-mutation hook: projections, manifest, event log ───────────── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "plan-task", + params: { milestoneId: params.milestoneId, sliceId: params.sliceId, taskId: params.taskId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: plan-task post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { milestoneId: params.milestoneId, sliceId: params.sliceId, diff --git a/src/resources/extensions/gsd/tools/reassess-roadmap.ts b/src/resources/extensions/gsd/tools/reassess-roadmap.ts index e395afe64..b4f61e2a8 100644 --- a/src/resources/extensions/gsd/tools/reassess-roadmap.ts +++ b/src/resources/extensions/gsd/tools/reassess-roadmap.ts @@ -10,6 +10,9 @@ import { } from "../gsd-db.js"; import { invalidateStateCache } from "../state.js"; import { renderRoadmapFromDb, renderAssessmentFromDb } from "../markdown-renderer.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; import { join } from "node:path"; export interface SliceChangeInput { @@ -191,6 +194,22 @@ export async function handleReassessRoadmap( invalidateStateCache(); clearParseCache(); + // ── Post-mutation hook: projections, manifest, event log ───── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "reassess-roadmap", + params: { milestoneId: params.milestoneId, completedSliceId: params.completedSliceId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: reassess-roadmap post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { milestoneId: params.milestoneId, completedSliceId: params.completedSliceId, diff --git a/src/resources/extensions/gsd/tools/replan-slice.ts b/src/resources/extensions/gsd/tools/replan-slice.ts index 1e103327e..e68a9e501 100644 --- a/src/resources/extensions/gsd/tools/replan-slice.ts +++ b/src/resources/extensions/gsd/tools/replan-slice.ts @@ -11,6 +11,9 @@ import { } from "../gsd-db.js"; import { invalidateStateCache } from "../state.js"; import { renderPlanFromDb, renderReplanFromDb } from "../markdown-renderer.js"; +import { renderAllProjections } from "../workflow-projections.js"; +import { writeManifest } from "../workflow-manifest.js"; +import { appendEvent } from "../workflow-events.js"; export interface ReplanSliceTaskInput { taskId: string; @@ -183,6 +186,22 @@ export async function handleReplanSlice( invalidateStateCache(); clearParseCache(); + // ── Post-mutation hook: projections, manifest, event log ───── + try { + await renderAllProjections(basePath, params.milestoneId); + writeManifest(basePath); + appendEvent(basePath, { + cmd: "replan-slice", + params: { milestoneId: params.milestoneId, sliceId: params.sliceId, blockerTaskId: params.blockerTaskId }, + ts: new Date().toISOString(), + actor: "agent", + }); + } catch (hookErr) { + process.stderr.write( + `gsd: replan-slice post-mutation hook warning: ${(hookErr as Error).message}\n`, + ); + } + return { milestoneId: params.milestoneId, sliceId: params.sliceId, diff --git a/src/resources/extensions/gsd/workflow-events.ts b/src/resources/extensions/gsd/workflow-events.ts new file mode 100644 index 000000000..3ba08a430 --- /dev/null +++ b/src/resources/extensions/gsd/workflow-events.ts @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; +import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteSync } from "./atomic-write.js"; + +// ─── Event Types ───────────────────────────────────────────────────────── + +export interface WorkflowEvent { + cmd: string; // e.g. "complete_task" + params: Record; + ts: string; // ISO 8601 + hash: string; // content hash (hex, 16 chars) + actor: "agent" | "system"; +} + +// ─── appendEvent ───────────────────────────────────────────────────────── + +/** + * Append one event to .gsd/event-log.jsonl. + * Computes a content hash from cmd+params (deterministic, independent of ts/actor). + * Creates .gsd directory if needed. + */ +export function appendEvent( + basePath: string, + event: Omit, +): void { + const hash = createHash("sha256") + .update(JSON.stringify({ cmd: event.cmd, params: event.params })) + .digest("hex") + .slice(0, 16); + + const fullEvent: WorkflowEvent = { ...event, hash }; + const dir = join(basePath, ".gsd"); + mkdirSync(dir, { recursive: true }); + appendFileSync(join(dir, "event-log.jsonl"), JSON.stringify(fullEvent) + "\n", "utf-8"); +} + +// ─── readEvents ────────────────────────────────────────────────────────── + +/** + * Read all events from a JSONL file. + * Returns empty array if file doesn't exist. + * Corrupted lines are skipped with stderr warning. + */ +export function readEvents(logPath: string): WorkflowEvent[] { + if (!existsSync(logPath)) { + return []; + } + + const content = readFileSync(logPath, "utf-8"); + const lines = content.split("\n").filter((l) => l.length > 0); + const events: WorkflowEvent[] = []; + + for (const line of lines) { + try { + events.push(JSON.parse(line) as WorkflowEvent); + } catch { + process.stderr.write(`workflow-events: skipping corrupted event line: ${line.slice(0, 80)}\n`); + } + } + + return events; +} + +// ─── findForkPoint ─────────────────────────────────────────────────────── + +/** + * Find the index of the last common event between two logs by comparing hashes. + * Returns -1 if the first events differ (completely diverged). + * If one log is a prefix of the other, returns length of shorter - 1. + */ +export function findForkPoint( + logA: WorkflowEvent[], + logB: WorkflowEvent[], +): number { + const minLen = Math.min(logA.length, logB.length); + let lastCommon = -1; + + for (let i = 0; i < minLen; i++) { + if (logA[i]!.hash === logB[i]!.hash) { + lastCommon = i; + } else { + break; + } + } + + return lastCommon; +} + +// ─── compactMilestoneEvents ───────────────────────────────────────────────── + +/** + * Archive a milestone's events from the active log to a separate file. + * Active log retains only events from other milestones. + * Archived file is kept on disk for forensics. + * + * @param basePath - Project root (parent of .gsd/) + * @param milestoneId - The milestone whose events should be archived + * @returns { archived: number } — count of events moved to archive + */ +export function compactMilestoneEvents( + basePath: string, + milestoneId: string, +): { archived: number } { + const logPath = join(basePath, ".gsd", "event-log.jsonl"); + const archivePath = join(basePath, ".gsd", `event-log-${milestoneId}.jsonl.archived`); + + const allEvents = readEvents(logPath); + const toArchive = allEvents.filter( + (e) => (e.params as { milestoneId?: string }).milestoneId === milestoneId, + ); + const remaining = allEvents.filter( + (e) => (e.params as { milestoneId?: string }).milestoneId !== milestoneId, + ); + + if (toArchive.length === 0) { + return { archived: 0 }; + } + + // Write archived events to .jsonl.archived file (crash-safe) + atomicWriteSync( + archivePath, + toArchive.map((e) => JSON.stringify(e)).join("\n") + "\n", + ); + + // Truncate active log to remaining events only + atomicWriteSync( + logPath, + remaining.length > 0 + ? remaining.map((e) => JSON.stringify(e)).join("\n") + "\n" + : "", + ); + + return { archived: toArchive.length }; +} diff --git a/src/resources/extensions/gsd/workflow-manifest.ts b/src/resources/extensions/gsd/workflow-manifest.ts new file mode 100644 index 000000000..ef3a51b6f --- /dev/null +++ b/src/resources/extensions/gsd/workflow-manifest.ts @@ -0,0 +1,314 @@ +import { + _getAdapter, + transaction, + type MilestoneRow, + type SliceRow, + type TaskRow, +} from "./gsd-db.js"; +import type { Decision } from "./types.js"; +import { atomicWriteSync } from "./atomic-write.js"; +import { readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Manifest Types ────────────────────────────────────────────────────── + +export interface VerificationEvidenceRow { + id: number; + task_id: string; + slice_id: string; + milestone_id: string; + command: string; + exit_code: number | null; + verdict: string; + duration_ms: number | null; + created_at: string; +} + +export interface StateManifest { + version: 1; + exported_at: string; // ISO 8601 + milestones: MilestoneRow[]; + slices: SliceRow[]; + tasks: TaskRow[]; + decisions: Decision[]; + verification_evidence: VerificationEvidenceRow[]; +} + +// ─── helpers ───────────────────────────────────────────────────────────── + +function requireDb() { + const db = _getAdapter(); + if (!db) throw new Error("workflow-manifest: No database open"); + return db; +} + +// ─── snapshotState ─────────────────────────────────────────────────────── + +/** + * Capture complete DB state as a StateManifest. + * Reads all rows from milestones, slices, tasks, decisions, verification_evidence. + * + * Note: rows returned from raw queries are plain objects with TEXT columns for + * JSON arrays. We parse them into typed Row objects using the same logic as + * gsd-db helper functions. + */ +export function snapshotState(): StateManifest { + const db = requireDb(); + + const rawMilestones = db.prepare("SELECT * FROM milestones ORDER BY id").all() as Record[]; + const milestones: MilestoneRow[] = rawMilestones.map((r) => ({ + id: r["id"] as string, + title: r["title"] as string, + status: r["status"] as string, + depends_on: JSON.parse((r["depends_on"] as string) || "[]"), + created_at: r["created_at"] as string, + completed_at: (r["completed_at"] as string) ?? null, + vision: (r["vision"] as string) ?? "", + success_criteria: JSON.parse((r["success_criteria"] as string) || "[]"), + key_risks: JSON.parse((r["key_risks"] as string) || "[]"), + proof_strategy: JSON.parse((r["proof_strategy"] as string) || "[]"), + verification_contract: (r["verification_contract"] as string) ?? "", + verification_integration: (r["verification_integration"] as string) ?? "", + verification_operational: (r["verification_operational"] as string) ?? "", + verification_uat: (r["verification_uat"] as string) ?? "", + definition_of_done: JSON.parse((r["definition_of_done"] as string) || "[]"), + requirement_coverage: (r["requirement_coverage"] as string) ?? "", + boundary_map_markdown: (r["boundary_map_markdown"] as string) ?? "", + })); + + const rawSlices = db.prepare("SELECT * FROM slices ORDER BY milestone_id, sequence, id").all() as Record[]; + const slices: SliceRow[] = rawSlices.map((r) => ({ + milestone_id: r["milestone_id"] as string, + id: r["id"] as string, + title: r["title"] as string, + status: r["status"] as string, + risk: r["risk"] as string, + depends: JSON.parse((r["depends"] as string) || "[]"), + demo: (r["demo"] as string) ?? "", + created_at: r["created_at"] as string, + completed_at: (r["completed_at"] as string) ?? null, + full_summary_md: (r["full_summary_md"] as string) ?? "", + full_uat_md: (r["full_uat_md"] as string) ?? "", + goal: (r["goal"] as string) ?? "", + success_criteria: (r["success_criteria"] as string) ?? "", + proof_level: (r["proof_level"] as string) ?? "", + integration_closure: (r["integration_closure"] as string) ?? "", + observability_impact: (r["observability_impact"] as string) ?? "", + sequence: (r["sequence"] as number) ?? 0, + replan_triggered_at: (r["replan_triggered_at"] as string) ?? null, + })); + + const rawTasks = db.prepare("SELECT * FROM tasks ORDER BY milestone_id, slice_id, sequence, id").all() as Record[]; + const tasks: TaskRow[] = rawTasks.map((r) => ({ + milestone_id: r["milestone_id"] as string, + slice_id: r["slice_id"] as string, + id: r["id"] as string, + title: r["title"] as string, + status: r["status"] as string, + one_liner: (r["one_liner"] as string) ?? "", + narrative: (r["narrative"] as string) ?? "", + verification_result: (r["verification_result"] as string) ?? "", + duration: (r["duration"] as string) ?? "", + completed_at: (r["completed_at"] as string) ?? null, + blocker_discovered: (r["blocker_discovered"] as number) === 1, + deviations: (r["deviations"] as string) ?? "", + known_issues: (r["known_issues"] as string) ?? "", + key_files: JSON.parse((r["key_files"] as string) || "[]"), + key_decisions: JSON.parse((r["key_decisions"] as string) || "[]"), + full_summary_md: (r["full_summary_md"] as string) ?? "", + description: (r["description"] as string) ?? "", + estimate: (r["estimate"] as string) ?? "", + files: JSON.parse((r["files"] as string) || "[]"), + verify: (r["verify"] as string) ?? "", + inputs: JSON.parse((r["inputs"] as string) || "[]"), + expected_output: JSON.parse((r["expected_output"] as string) || "[]"), + observability_impact: (r["observability_impact"] as string) ?? "", + sequence: (r["sequence"] as number) ?? 0, + })); + + const rawDecisions = db.prepare("SELECT * FROM decisions ORDER BY seq").all() as Record[]; + const decisions: Decision[] = rawDecisions.map((r) => ({ + seq: r["seq"] as number, + id: r["id"] as string, + when_context: (r["when_context"] as string) ?? "", + scope: (r["scope"] as string) ?? "", + decision: (r["decision"] as string) ?? "", + choice: (r["choice"] as string) ?? "", + rationale: (r["rationale"] as string) ?? "", + revisable: (r["revisable"] as string) ?? "", + made_by: (r["made_by"] as string as Decision["made_by"]) ?? "agent", + superseded_by: (r["superseded_by"] as string) ?? null, + })); + + const rawEvidence = db.prepare("SELECT * FROM verification_evidence ORDER BY id").all() as Record[]; + const verification_evidence: VerificationEvidenceRow[] = rawEvidence.map((r) => ({ + id: r["id"] as number, + task_id: r["task_id"] as string, + slice_id: r["slice_id"] as string, + milestone_id: r["milestone_id"] as string, + command: r["command"] as string, + exit_code: (r["exit_code"] as number) ?? null, + verdict: (r["verdict"] as string) ?? "", + duration_ms: (r["duration_ms"] as number) ?? null, + created_at: r["created_at"] as string, + })); + + return { + version: 1, + exported_at: new Date().toISOString(), + milestones, + slices, + tasks, + decisions, + verification_evidence, + }; +} + +// ─── restore ───────────────────────────────────────────────────────────── + +/** + * Atomically replace all workflow state from a manifest. + * Runs inside a transaction — if any insert fails, no tables are modified. + * Only touches engine tables + decisions. Does NOT modify artifacts or memories. + */ +function restore(manifest: StateManifest): void { + const db = requireDb(); + + transaction(() => { + // Clear engine tables (order matters for foreign-key-like consistency) + db.exec("DELETE FROM verification_evidence"); + db.exec("DELETE FROM tasks"); + db.exec("DELETE FROM slices"); + db.exec("DELETE FROM milestones"); + db.exec("DELETE FROM decisions WHERE 1=1"); + + // Restore milestones + const msStmt = db.prepare( + `INSERT INTO milestones (id, title, status, depends_on, created_at, completed_at, + vision, success_criteria, key_risks, proof_strategy, + verification_contract, verification_integration, verification_operational, verification_uat, + definition_of_done, requirement_coverage, boundary_map_markdown) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const m of manifest.milestones) { + msStmt.run( + m.id, m.title, m.status, + JSON.stringify(m.depends_on), m.created_at, m.completed_at, + m.vision, JSON.stringify(m.success_criteria), JSON.stringify(m.key_risks), + JSON.stringify(m.proof_strategy), + m.verification_contract, m.verification_integration, m.verification_operational, m.verification_uat, + JSON.stringify(m.definition_of_done), m.requirement_coverage, m.boundary_map_markdown, + ); + } + + // Restore slices + const slStmt = db.prepare( + `INSERT INTO slices (milestone_id, id, title, status, risk, depends, demo, + created_at, completed_at, full_summary_md, full_uat_md, + goal, success_criteria, proof_level, integration_closure, observability_impact, + sequence, replan_triggered_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const s of manifest.slices) { + slStmt.run( + s.milestone_id, s.id, s.title, s.status, s.risk, + JSON.stringify(s.depends), s.demo, + s.created_at, s.completed_at, s.full_summary_md, s.full_uat_md, + s.goal, s.success_criteria, s.proof_level, s.integration_closure, s.observability_impact, + s.sequence, s.replan_triggered_at, + ); + } + + // Restore tasks + const tkStmt = db.prepare( + `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, description, estimate, files, verify, + inputs, expected_output, observability_impact, sequence) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const t of manifest.tasks) { + tkStmt.run( + t.milestone_id, t.slice_id, t.id, t.title, t.status, + t.one_liner, t.narrative, t.verification_result, t.duration, t.completed_at, + t.blocker_discovered ? 1 : 0, t.deviations, t.known_issues, + JSON.stringify(t.key_files), JSON.stringify(t.key_decisions), + t.full_summary_md, t.description, t.estimate, JSON.stringify(t.files), t.verify, + JSON.stringify(t.inputs), JSON.stringify(t.expected_output), + t.observability_impact, t.sequence, + ); + } + + // Restore decisions + const dcStmt = db.prepare( + `INSERT INTO decisions (seq, id, when_context, scope, decision, choice, rationale, revisable, made_by, superseded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const d of manifest.decisions) { + dcStmt.run(d.seq, d.id, d.when_context, d.scope, d.decision, d.choice, d.rationale, d.revisable, d.made_by, d.superseded_by); + } + + // Restore verification evidence + const evStmt = db.prepare( + `INSERT INTO verification_evidence (task_id, slice_id, milestone_id, command, exit_code, verdict, duration_ms, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const e of manifest.verification_evidence) { + evStmt.run(e.task_id, e.slice_id, e.milestone_id, e.command, e.exit_code, e.verdict, e.duration_ms, e.created_at); + } + }); +} + +// ─── writeManifest ─────────────────────────────────────────────────────── + +/** + * Write current DB state to .gsd/state-manifest.json via atomicWriteSync. + * Uses JSON.stringify with 2-space indent for git three-way merge friendliness. + */ +export function writeManifest(basePath: string): void { + const manifest = snapshotState(); + const json = JSON.stringify(manifest, null, 2); + const dir = join(basePath, ".gsd"); + mkdirSync(dir, { recursive: true }); + atomicWriteSync(join(dir, "state-manifest.json"), json); +} + +// ─── readManifest ──────────────────────────────────────────────────────── + +/** + * Read state-manifest.json and return parsed manifest, or null if not found. + */ +export function readManifest(basePath: string): StateManifest | null { + const manifestPath = join(basePath, ".gsd", "state-manifest.json"); + + if (!existsSync(manifestPath)) { + return null; + } + + const raw = readFileSync(manifestPath, "utf-8"); + const parsed = JSON.parse(raw) as StateManifest; + + if (parsed.version !== 1) { + throw new Error(`Unsupported manifest version: ${parsed.version}`); + } + + return parsed; +} + +// ─── bootstrapFromManifest ────────────────────────────────────────────── + +/** + * Read state-manifest.json and restore DB state from it. + * Returns true if bootstrap succeeded, false if manifest file doesn't exist. + */ +export function bootstrapFromManifest(basePath: string): boolean { + const manifest = readManifest(basePath); + + if (!manifest) { + return false; + } + + restore(manifest); + return true; +} diff --git a/src/resources/extensions/gsd/workflow-migration.ts b/src/resources/extensions/gsd/workflow-migration.ts new file mode 100644 index 000000000..4c8a9f071 --- /dev/null +++ b/src/resources/extensions/gsd/workflow-migration.ts @@ -0,0 +1,345 @@ +// GSD Extension — Legacy Markdown to Engine Migration +// Converts legacy markdown-only projects to engine state by parsing +// existing ROADMAP.md, *-PLAN.md, and *-SUMMARY.md files. +// Populates data into the already-existing v10 schema tables. + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { _getAdapter, transaction } from "./gsd-db.js"; +import { parseRoadmap, parsePlan } from "./parsers-legacy.js"; + +// ─── needsAutoMigration ─────────────────────────────────────────────────── + +/** + * Returns true when engine tables are empty AND a .gsd/milestones/ directory + * with markdown files exists — signals that this is a legacy project that needs + * one-time migration from markdown to engine state. + */ +export function needsAutoMigration(basePath: string): boolean { + const db = _getAdapter(); + if (!db) return false; + + // If milestones table already has rows, migration already done + try { + const row = db.prepare("SELECT COUNT(*) as cnt FROM milestones").get(); + if (row && (row["cnt"] as number) > 0) return false; + } catch { + // Table might not exist yet — that's fine, we can still migrate + return false; + } + + // Check if .gsd/milestones/ directory exists + const milestonesDir = join(basePath, ".gsd", "milestones"); + if (!existsSync(milestonesDir)) return false; + + return true; +} + +// ─── migrateFromMarkdown ────────────────────────────────────────────────── + +/** + * Migrate legacy markdown-only .gsd/ projects to engine DB state. + * Reads .gsd/milestones// directories and parses ROADMAP.md, *-PLAN.md + * files. All inserts are wrapped in a transaction. + * + * This function only INSERTs data into the already-existing v10 schema tables + * (milestones, slices, tasks). It does NOT create tables or run migrations. + * + * Handles all directory shapes: + * - No DB: caller is responsible for openDatabase + initSchema before calling + * - Stale DB (empty tables): inserts succeed normally + * - No markdown at all: returns early with stderr message + * - Orphaned summary files: logs warning, skips without crash + */ +export function migrateFromMarkdown(basePath: string): void { + const db = _getAdapter(); + if (!db) { + process.stderr.write("workflow-migration: no database connection, cannot migrate\n"); + return; + } + + const milestonesDir = join(basePath, ".gsd", "milestones"); + if (!existsSync(milestonesDir)) { + process.stderr.write("workflow-migration: no .gsd/milestones/ directory found, nothing to migrate\n"); + return; + } + + // Discover milestone directories (any directory at the top level of milestones/) + let milestoneDirs: string[]; + try { + milestoneDirs = readdirSync(milestonesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { + process.stderr.write("workflow-migration: failed to read milestones directory\n"); + return; + } + + if (milestoneDirs.length === 0) { + process.stderr.write("workflow-migration: no milestone directories found in .gsd/milestones/\n"); + return; + } + + // Collect all data before the transaction + const migratedMilestoneIds: string[] = []; + + interface MilestoneInsert { + id: string; + title: string; + status: string; + } + + interface SliceInsert { + id: string; + milestoneId: string; + title: string; + status: string; + risk: string; + sequence: number; + forceDone: boolean; + } + + interface TaskInsert { + id: string; + sliceId: string; + milestoneId: string; + title: string; + status: string; + sequence: number; + } + + const milestoneInserts: MilestoneInsert[] = []; + const sliceInserts: SliceInsert[] = []; + const taskInserts: TaskInsert[] = []; + + for (const mId of milestoneDirs) { + const mDir = join(milestonesDir, mId); + + // Determine milestone status: done if a milestone-level SUMMARY.md exists + const milestoneSummaryPath = join(mDir, "SUMMARY.md"); + const milestoneDone = existsSync(milestoneSummaryPath); + const milestoneStatus = milestoneDone ? "done" : "active"; + + // Parse ROADMAP.md for slices list + const roadmapPath = join(mDir, "ROADMAP.md"); + let roadmapSlices: Array<{ id: string; title: string; done: boolean; risk: string }> = []; + + if (existsSync(roadmapPath)) { + try { + const roadmapContent = readFileSync(roadmapPath, "utf-8"); + const roadmap = parseRoadmap(roadmapContent); + + // Extract milestone title from roadmap + const mTitle = roadmap.title || mId; + + milestoneInserts.push({ id: mId, title: mTitle, status: milestoneStatus }); + + roadmapSlices = roadmap.slices.map(s => ({ + id: s.id, + title: s.title, + done: s.done, + risk: s.risk || "low", + })); + } catch (err) { + process.stderr.write(`workflow-migration: failed to parse ROADMAP.md for ${mId}: ${(err as Error).message}\n`); + // Still add milestone with ID as title + milestoneInserts.push({ id: mId, title: mId, status: milestoneStatus }); + } + } else { + // No ROADMAP.md — add milestone entry anyway using directory name + milestoneInserts.push({ id: mId, title: mId, status: milestoneStatus }); + } + + migratedMilestoneIds.push(mId); + + // Collect slices from ROADMAP + their tasks from PLAN files + const knownSliceIds = new Set(roadmapSlices.map(s => s.id)); + + for (let sIdx = 0; sIdx < roadmapSlices.length; sIdx++) { + const slice = roadmapSlices[sIdx]; + // Per Pitfall #5: if milestone is done, force all child slices to done + const sliceStatus = milestoneDone ? "done" : (slice.done ? "done" : "pending"); + + sliceInserts.push({ + id: slice.id, + milestoneId: mId, + title: slice.title, + status: sliceStatus, + risk: slice.risk, + sequence: sIdx, + forceDone: milestoneDone, + }); + + // Read *-PLAN.md for this slice + const planPath = join(mDir, `${slice.id}-PLAN.md`); + if (existsSync(planPath)) { + try { + const planContent = readFileSync(planPath, "utf-8"); + const plan = parsePlan(planContent); + + for (let tIdx = 0; tIdx < plan.tasks.length; tIdx++) { + const task = plan.tasks[tIdx]; + // Per Pitfall #5: if milestone is done, force all tasks to done + const taskStatus = milestoneDone ? "done" : (task.done ? "done" : "pending"); + taskInserts.push({ + id: task.id, + sliceId: slice.id, + milestoneId: mId, + title: task.title, + status: taskStatus, + sequence: tIdx, + }); + } + } catch (err) { + process.stderr.write(`workflow-migration: failed to parse ${slice.id}-PLAN.md for ${mId}: ${(err as Error).message}\n`); + } + } + } + + // Check for orphaned summary files (summary for a slice not in ROADMAP) + try { + const files = readdirSync(mDir); + const summaryFiles = files.filter(f => f.endsWith("-SUMMARY.md") && f !== "SUMMARY.md"); + for (const summaryFile of summaryFiles) { + const sliceId = summaryFile.replace("-SUMMARY.md", ""); + if (!knownSliceIds.has(sliceId)) { + process.stderr.write(`workflow-migration: orphaned summary file ${summaryFile} in ${mId} (slice not found in ROADMAP.md), skipping\n`); + } + } + } catch { + // Non-fatal + } + } + + // Execute all inserts atomically + const now = new Date().toISOString(); + if (migratedMilestoneIds.length === 0) { + process.stderr.write("workflow-migration: no milestones collected, nothing to insert\n"); + return; + } + + const placeholders = migratedMilestoneIds.map(() => "?").join(","); + transaction(() => { + // Clear existing data to handle stale DB shape (DELETE ... IN (...)) + db.prepare(`DELETE FROM tasks WHERE milestone_id IN (${placeholders})`).run(...migratedMilestoneIds); + db.prepare(`DELETE FROM slices WHERE milestone_id IN (${placeholders})`).run(...migratedMilestoneIds); + db.prepare(`DELETE FROM milestones WHERE id IN (${placeholders})`).run(...migratedMilestoneIds); + + // Insert milestones + const insertMilestone = db.prepare("INSERT INTO milestones (id, title, status, created_at) VALUES (?, ?, ?, ?)"); + for (const m of milestoneInserts) { + insertMilestone.run(m.id, m.title, m.status, now); + } + + // Insert slices (using v10 column names: depends, sequence) + const insertSlice = db.prepare( + "INSERT INTO slices (id, milestone_id, title, status, risk, depends, sequence, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + for (const s of sliceInserts) { + insertSlice.run(s.id, s.milestoneId, s.title, s.status, s.risk, "[]", s.sequence, now); + } + + // Insert tasks (using v10 column names: sequence, blocker_discovered, full_summary_md) + const insertTask = db.prepare( + "INSERT INTO tasks (id, slice_id, milestone_id, title, description, status, estimate, files, sequence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + for (const t of taskInserts) { + insertTask.run(t.id, t.sliceId, t.milestoneId, t.title, "", t.status, "", "[]", t.sequence); + } + }); +} + +// ─── validateMigration ──────────────────────────────────────────────────── + +/** + * D-14: Validate that engine state matches what markdown parsers report. + * Compares milestone count, slice count, task count, and status distributions. + * Logs each discrepancy to stderr but does NOT throw. + * Returns array of discrepancy strings (empty = clean migration). + */ +export function validateMigration(basePath: string): { discrepancies: string[] } { + const db = _getAdapter(); + if (!db) { + return { discrepancies: ["No database connection for validation"] }; + } + + const discrepancies: string[] = []; + + // Get engine counts + const engMilestones = db.prepare("SELECT COUNT(*) as cnt FROM milestones").get(); + const engSlices = db.prepare("SELECT COUNT(*) as cnt FROM slices").get(); + const engTasks = db.prepare("SELECT COUNT(*) as cnt FROM tasks").get(); + + const engineMilestoneCount = engMilestones ? (engMilestones["cnt"] as number) : 0; + const engineSliceCount = engSlices ? (engSlices["cnt"] as number) : 0; + const engineTaskCount = engTasks ? (engTasks["cnt"] as number) : 0; + + // Count from markdown + const milestonesDir = join(basePath, ".gsd", "milestones"); + if (!existsSync(milestonesDir)) { + return { discrepancies }; + } + + let mdMilestoneCount = 0; + let mdSliceCount = 0; + let mdTaskCount = 0; + + try { + const milestoneDirs = readdirSync(milestonesDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + + mdMilestoneCount = milestoneDirs.length; + + for (const mId of milestoneDirs) { + const mDir = join(milestonesDir, mId); + const roadmapPath = join(mDir, "ROADMAP.md"); + + if (existsSync(roadmapPath)) { + try { + const content = readFileSync(roadmapPath, "utf-8"); + const roadmap = parseRoadmap(content); + mdSliceCount += roadmap.slices.length; + + for (const slice of roadmap.slices) { + const planPath = join(mDir, `${slice.id}-PLAN.md`); + if (existsSync(planPath)) { + try { + const planContent = readFileSync(planPath, "utf-8"); + const plan = parsePlan(planContent); + mdTaskCount += plan.tasks.length; + } catch { + // Skip unreadable plan + } + } + } + } catch { + // Skip unreadable roadmap + } + } + } + } catch { + return { discrepancies: ["Failed to read markdown for validation"] }; + } + + // Compare counts + if (engineMilestoneCount !== mdMilestoneCount) { + const msg = `Milestone count mismatch: engine=${engineMilestoneCount}, markdown=${mdMilestoneCount}`; + discrepancies.push(msg); + process.stderr.write(`workflow-migration: ${msg}\n`); + } + + if (engineSliceCount !== mdSliceCount) { + const msg = `Slice count mismatch: engine=${engineSliceCount}, markdown=${mdSliceCount}`; + discrepancies.push(msg); + process.stderr.write(`workflow-migration: ${msg}\n`); + } + + if (engineTaskCount !== mdTaskCount) { + const msg = `Task count mismatch: engine=${engineTaskCount}, markdown=${mdTaskCount}`; + discrepancies.push(msg); + process.stderr.write(`workflow-migration: ${msg}\n`); + } + + return { discrepancies }; +} diff --git a/src/resources/extensions/gsd/workflow-projections.ts b/src/resources/extensions/gsd/workflow-projections.ts new file mode 100644 index 000000000..3f1afe35a --- /dev/null +++ b/src/resources/extensions/gsd/workflow-projections.ts @@ -0,0 +1,423 @@ +// GSD Extension — Projection Renderers (DB -> Markdown) +// Renders PLAN.md, ROADMAP.md, SUMMARY.md, and STATE.md from database rows. +// Projections are read-only views of engine state (Layer 3 of the architecture). + +import { + _getAdapter, + isDbAvailable, + getAllMilestones, + getMilestone, + getMilestoneSlices, + getSliceTasks, +} from "./gsd-db.js"; +import type { MilestoneRow, SliceRow, TaskRow } from "./gsd-db.js"; +import { atomicWriteSync } from "./atomic-write.js"; +import { join } from "node:path"; +import { mkdirSync, existsSync } from "node:fs"; +import { logWarning } from "./workflow-logger.js"; +import { deriveState } from "./state.js"; +import type { GSDState } from "./types.js"; + +// ─── PLAN.md Projection ────────────────────────────────────────────────── + +/** + * Render PLAN.md content from a slice row and its task rows. + * Pure function — no side effects. + */ +export function renderPlanContent(sliceRow: SliceRow, taskRows: TaskRow[]): string { + const lines: string[] = []; + + lines.push(`# ${sliceRow.id}: ${sliceRow.title}`); + lines.push(""); + lines.push(`**Goal:** ${sliceRow.goal || sliceRow.full_summary_md || "TBD"}`); + lines.push(`**Demo:** After this: ${sliceRow.demo || sliceRow.full_uat_md || "TBD"}`); + lines.push(""); + lines.push("## Tasks"); + + for (const task of taskRows) { + const checkbox = task.status === "done" ? "[x]" : "[ ]"; + lines.push(`- ${checkbox} **${task.id}:** ${task.title} \u2014 ${task.description}`); + + // Estimate subline (always present if non-empty) + if (task.estimate) { + lines.push(` - Estimate: ${task.estimate}`); + } + + // Files subline (only if non-empty array) + if (task.files && task.files.length > 0) { + lines.push(` - Files: ${task.files.join(", ")}`); + } + + // Verify subline (only if non-null) + if (task.verify) { + lines.push(` - Verify: ${task.verify}`); + } + + // Duration subline (only if recorded) + if (task.duration) { + lines.push(` - Duration: ${task.duration}`); + } + + // Blocker subline (if discovered) + if (task.blocker_discovered && task.known_issues) { + lines.push(` - Blocker: ${task.known_issues}`); + } + } + + lines.push(""); + return lines.join("\n"); +} + +/** + * Render PLAN.md projection to disk for a specific slice. + * Queries DB via helper functions, renders content, writes via atomicWriteSync. + */ +export function renderPlanProjection(basePath: string, milestoneId: string, sliceId: string): void { + const sliceRows = getMilestoneSlices(milestoneId); + const sliceRow = sliceRows.find(s => s.id === sliceId); + if (!sliceRow) return; + + const taskRows = getSliceTasks(milestoneId, sliceId); + + const content = renderPlanContent(sliceRow, taskRows); + const dir = join(basePath, ".gsd", "milestones", milestoneId, "slices", sliceId); + mkdirSync(dir, { recursive: true }); + atomicWriteSync(join(dir, `${sliceId}-PLAN.md`), content); +} + +// ─── ROADMAP.md Projection ─────────────────────────────────────────────── + +/** + * Render ROADMAP.md content from a milestone row and its slice rows. + * Pure function — no side effects. + */ +export function renderRoadmapContent(milestoneRow: MilestoneRow, sliceRows: SliceRow[]): string { + const lines: string[] = []; + + lines.push(`# ${milestoneRow.id}: ${milestoneRow.title}`); + lines.push(""); + lines.push("## Vision"); + lines.push(milestoneRow.vision || milestoneRow.title || "TBD"); + lines.push(""); + lines.push("## Slice Overview"); + lines.push("| ID | Slice | Risk | Depends | Done | After this |"); + lines.push("|----|-------|------|---------|------|------------|"); + + for (const slice of sliceRows) { + const done = slice.status === "done" ? "\u2705" : "\u2B1C"; + + // depends is already parsed to string[] by rowToSlice + let depends = "\u2014"; + if (slice.depends && slice.depends.length > 0) { + depends = slice.depends.join(", "); + } + + const risk = (slice.risk || "low").toLowerCase(); + const demo = slice.demo || slice.full_uat_md || "TBD"; + + lines.push(`| ${slice.id} | ${slice.title} | ${risk} | ${depends} | ${done} | ${demo} |`); + } + + lines.push(""); + return lines.join("\n"); +} + +/** + * Render ROADMAP.md projection to disk for a specific milestone. + * Queries DB via helper functions, renders content, writes via atomicWriteSync. + */ +export function renderRoadmapProjection(basePath: string, milestoneId: string): void { + const milestoneRow = getMilestone(milestoneId); + if (!milestoneRow) return; + + const sliceRows = getMilestoneSlices(milestoneId); + + const content = renderRoadmapContent(milestoneRow, sliceRows); + const dir = join(basePath, ".gsd", "milestones", milestoneId); + mkdirSync(dir, { recursive: true }); + atomicWriteSync(join(dir, `${milestoneId}-ROADMAP.md`), content); +} + +// ─── SUMMARY.md Projection ────────────────────────────────────────────── + +/** + * Render SUMMARY.md content from a task row. + * Pure function — no side effects. + */ +export function renderSummaryContent(taskRow: TaskRow, sliceId: string, milestoneId: string): string { + const lines: string[] = []; + + // Frontmatter + lines.push("---"); + lines.push(`id: ${taskRow.id}`); + lines.push(`parent: ${sliceId}`); + lines.push(`milestone: ${milestoneId}`); + lines.push("provides: []"); + lines.push("requires: []"); + lines.push("affects: []"); + + // key_files is already parsed to string[] + if (taskRow.key_files && taskRow.key_files.length > 0) { + lines.push(`key_files: [${taskRow.key_files.map(f => `"${f}"`).join(", ")}]`); + } else { + lines.push("key_files: []"); + } + + // key_decisions is already parsed to string[] + if (taskRow.key_decisions && taskRow.key_decisions.length > 0) { + lines.push(`key_decisions: [${taskRow.key_decisions.map(d => `"${d}"`).join(", ")}]`); + } else { + lines.push("key_decisions: []"); + } + + lines.push("patterns_established: []"); + lines.push("drill_down_paths: []"); + lines.push("observability_surfaces: []"); + lines.push(`duration: "${taskRow.duration || ""}"`); + lines.push(`verification_result: "${taskRow.verification_result || ""}"`); + lines.push(`completed_at: ${taskRow.completed_at || ""}`); + lines.push(`blocker_discovered: ${taskRow.blocker_discovered ? "true" : "false"}`); + lines.push("---"); + lines.push(""); + lines.push(`# ${taskRow.id}: ${taskRow.title}`); + lines.push(""); + + // One-liner (if present) + if (taskRow.one_liner) { + lines.push(`> ${taskRow.one_liner}`); + lines.push(""); + } + + lines.push("## What Happened"); + lines.push(taskRow.full_summary_md || taskRow.narrative || "No summary recorded."); + lines.push(""); + + // Deviations (if present) + if (taskRow.deviations) { + lines.push("## Deviations"); + lines.push(taskRow.deviations); + lines.push(""); + } + + // Known issues (if present) + if (taskRow.known_issues) { + lines.push("## Known Issues"); + lines.push(taskRow.known_issues); + lines.push(""); + } + + return lines.join("\n"); +} + +/** + * Render SUMMARY.md projection to disk for a specific task. + * Queries DB via helper functions, renders content, writes via atomicWriteSync. + */ +export function renderSummaryProjection(basePath: string, milestoneId: string, sliceId: string, taskId: string): void { + const taskRows = getSliceTasks(milestoneId, sliceId); + const taskRow = taskRows.find(t => t.id === taskId); + if (!taskRow) return; + + const content = renderSummaryContent(taskRow, sliceId, milestoneId); + const dir = join(basePath, ".gsd", "milestones", milestoneId, "slices", sliceId, "tasks"); + mkdirSync(dir, { recursive: true }); + atomicWriteSync(join(dir, `${taskId}-SUMMARY.md`), content); +} + +// ─── STATE.md Projection ──────────────────────────────────────────────── + +/** + * Render STATE.md content from GSDState. + * Matches the buildStateMarkdown output format from doctor.ts exactly. + * Pure function — no side effects. + */ +export function renderStateContent(state: GSDState): string { + const lines: string[] = []; + lines.push("# GSD State", ""); + + const activeMilestone = state.activeMilestone + ? `${state.activeMilestone.id}: ${state.activeMilestone.title}` + : "None"; + const activeSlice = state.activeSlice + ? `${state.activeSlice.id}: ${state.activeSlice.title}` + : "None"; + + lines.push(`**Active Milestone:** ${activeMilestone}`); + lines.push(`**Active Slice:** ${activeSlice}`); + lines.push(`**Phase:** ${state.phase}`); + if (state.requirements) { + lines.push(`**Requirements Status:** ${state.requirements.active} active \u00b7 ${state.requirements.validated} validated \u00b7 ${state.requirements.deferred} deferred \u00b7 ${state.requirements.outOfScope} out of scope`); + } + lines.push(""); + lines.push("## Milestone Registry"); + + for (const entry of state.registry) { + const glyph = entry.status === "complete" ? "\u2705" : entry.status === "active" ? "\uD83D\uDD04" : entry.status === "parked" ? "\u23F8\uFE0F" : "\u2B1C"; + lines.push(`- ${glyph} **${entry.id}:** ${entry.title}`); + } + + lines.push(""); + lines.push("## Recent Decisions"); + if (state.recentDecisions.length > 0) { + for (const decision of state.recentDecisions) lines.push(`- ${decision}`); + } else { + lines.push("- None recorded"); + } + + lines.push(""); + lines.push("## Blockers"); + if (state.blockers.length > 0) { + for (const blocker of state.blockers) lines.push(`- ${blocker}`); + } else { + lines.push("- None"); + } + + lines.push(""); + lines.push("## Next Action"); + lines.push(state.nextAction || "None"); + lines.push(""); + + return lines.join("\n"); +} + +/** + * Render STATE.md projection to disk. + * Derives state from DB, renders content, writes via atomicWriteSync. + */ +export async function renderStateProjection(basePath: string): Promise { + try { + if (!isDbAvailable()) return; + // Probe DB handle — adapter may be set but underlying handle closed + const adapter = _getAdapter(); + if (!adapter) return; + try { adapter.prepare("SELECT 1").get(); } catch { return; } + const state = await deriveState(basePath); + const content = renderStateContent(state); + const dir = join(basePath, ".gsd"); + mkdirSync(dir, { recursive: true }); + atomicWriteSync(join(dir, "STATE.md"), content); + } catch (err) { + logWarning("projection", `renderStateProjection failed: ${(err as Error).message}`); + } +} + +// ─── renderAllProjections ─────────────────────────────────────────────── + +/** + * Regenerate all projection files for a milestone from DB state. + * All calls are wrapped in try/catch — projection failure is non-fatal per D-02. + */ +export async function renderAllProjections(basePath: string, milestoneId: string): Promise { + // Render ROADMAP.md for the milestone + try { + renderRoadmapProjection(basePath, milestoneId); + } catch (err) { + console.error(`[projections] renderRoadmapProjection failed for ${milestoneId}:`, err); + } + + // Query all slices for this milestone + const sliceRows = getMilestoneSlices(milestoneId); + + for (const slice of sliceRows) { + // Render PLAN.md for each slice + try { + renderPlanProjection(basePath, milestoneId, slice.id); + } catch (err) { + console.error(`[projections] renderPlanProjection failed for ${milestoneId}/${slice.id}:`, err); + } + + // Render SUMMARY.md for each completed task + const taskRows = getSliceTasks(milestoneId, slice.id); + const doneTasks = taskRows.filter(t => t.status === "done"); + + for (const task of doneTasks) { + try { + renderSummaryProjection(basePath, milestoneId, slice.id, task.id); + } catch (err) { + console.error(`[projections] renderSummaryProjection failed for ${milestoneId}/${slice.id}/${task.id}:`, err); + } + } + } + + // Render STATE.md + try { + await renderStateProjection(basePath); + } catch (err) { + console.error("[projections] renderStateProjection failed:", err); + } +} + +// ─── regenerateIfMissing ──────────────────────────────────────────────── + +/** + * Check if a projection file exists on disk. If missing, regenerate it from DB. + * Returns true if the file was regenerated, false if it already existed. + * Satisfies PROJ-05 (corrupted/deleted projections regenerate on demand). + */ +export function regenerateIfMissing( + basePath: string, + milestoneId: string, + sliceId: string, + fileType: "PLAN" | "ROADMAP" | "SUMMARY" | "STATE", +): boolean { + let filePath: string; + + switch (fileType) { + case "PLAN": + filePath = join(basePath, ".gsd", "milestones", milestoneId, "slices", sliceId, `${sliceId}-PLAN.md`); + break; + case "ROADMAP": + filePath = join(basePath, ".gsd", "milestones", milestoneId, `${milestoneId}-ROADMAP.md`); + break; + case "SUMMARY": + // For SUMMARY, we regenerate all task summaries in the slice + filePath = join(basePath, ".gsd", "milestones", milestoneId, "slices", sliceId, "tasks"); + break; + case "STATE": + filePath = join(basePath, ".gsd", "STATE.md"); + break; + } + + if (fileType === "SUMMARY") { + // Special handling: check if the tasks directory exists and has summary files + if (!existsSync(filePath)) { + // Regenerate all task summaries for this slice + const taskRows = getSliceTasks(milestoneId, sliceId); + const doneTasks = taskRows.filter(t => t.status === "done"); + for (const task of doneTasks) { + try { + renderSummaryProjection(basePath, milestoneId, sliceId, task.id); + } catch (err) { + console.error(`[projections] regenerateIfMissing SUMMARY failed for ${task.id}:`, err); + } + } + return doneTasks.length > 0; + } + return false; + } + + if (existsSync(filePath)) { + return false; + } + + // Regenerate the missing file + try { + switch (fileType) { + case "PLAN": + renderPlanProjection(basePath, milestoneId, sliceId); + break; + case "ROADMAP": + renderRoadmapProjection(basePath, milestoneId); + break; + case "STATE": + // renderStateProjection is async but regenerateIfMissing is sync. + // Fire-and-forget the async render; STATE.md will appear shortly. + void renderStateProjection(basePath); + break; + } + return true; + } catch (err) { + console.error(`[projections] regenerateIfMissing ${fileType} failed:`, err); + return false; + } +} diff --git a/src/resources/extensions/gsd/workflow-reconcile.ts b/src/resources/extensions/gsd/workflow-reconcile.ts new file mode 100644 index 000000000..c93998f7e --- /dev/null +++ b/src/resources/extensions/gsd/workflow-reconcile.ts @@ -0,0 +1,473 @@ +import { join } from "node:path"; +import { mkdirSync, existsSync, readFileSync, unlinkSync } from "node:fs"; +import { readEvents, findForkPoint, appendEvent } from "./workflow-events.js"; +import type { WorkflowEvent } from "./workflow-events.js"; +import { + updateTaskStatus, + updateSliceStatus, + insertVerificationEvidence, + upsertDecision, + openDatabase, +} from "./gsd-db.js"; +import { writeManifest } from "./workflow-manifest.js"; +import { atomicWriteSync } from "./atomic-write.js"; + +// ─── Public Types ───────────────────────────────────────────────────────────── + +export interface ConflictEntry { + entityType: string; + entityId: string; + mainSideEvents: WorkflowEvent[]; + worktreeSideEvents: WorkflowEvent[]; +} + +export interface ReconcileResult { + autoMerged: number; + conflicts: ConflictEntry[]; +} + +// ─── replayEvents ───────────────────────────────────────────────────────────── + +/** + * Replay a list of WorkflowEvents by dispatching each to the appropriate + * gsd-db function. This replaces the old engine.replayAll() pattern with + * direct DB calls. + */ +function replayEvents(events: WorkflowEvent[]): void { + for (const event of events) { + const p = event.params; + switch (event.cmd) { + case "complete_task": { + const milestoneId = p["milestoneId"] as string; + const sliceId = p["sliceId"] as string; + const taskId = p["taskId"] as string; + updateTaskStatus(milestoneId, sliceId, taskId, "done", event.ts); + break; + } + case "start_task": { + const milestoneId = p["milestoneId"] as string; + const sliceId = p["sliceId"] as string; + const taskId = p["taskId"] as string; + updateTaskStatus(milestoneId, sliceId, taskId, "in-progress"); + break; + } + case "report_blocker": { + // report_blocker marks the task with blocker_discovered = 1 + // The DB helper updateTaskStatus doesn't handle blockers, + // so we just update status to "blocked" as a best-effort replay. + const milestoneId = p["milestoneId"] as string; + const sliceId = p["sliceId"] as string; + const taskId = p["taskId"] as string; + updateTaskStatus(milestoneId, sliceId, taskId, "blocked"); + break; + } + case "record_verification": { + const milestoneId = p["milestoneId"] as string; + const sliceId = p["sliceId"] as string; + const taskId = p["taskId"] as string; + insertVerificationEvidence({ + taskId, + sliceId, + milestoneId, + command: (p["command"] as string) ?? "", + exitCode: (p["exitCode"] as number) ?? 0, + verdict: (p["verdict"] as string) ?? "", + durationMs: (p["durationMs"] as number) ?? 0, + }); + break; + } + case "complete_slice": { + const milestoneId = p["milestoneId"] as string; + const sliceId = p["sliceId"] as string; + updateSliceStatus(milestoneId, sliceId, "done", event.ts); + break; + } + case "plan_slice": { + // plan_slice events are informational — slice should already exist. + // No DB mutation needed during replay (the slice was inserted at plan time). + break; + } + case "save_decision": { + upsertDecision({ + id: (p["id"] as string) ?? `${p["scope"]}:${p["decision"]}`, + when_context: (p["when_context"] as string) ?? (p["whenContext"] as string) ?? "", + scope: (p["scope"] as string) ?? "", + decision: (p["decision"] as string) ?? "", + choice: (p["choice"] as string) ?? "", + rationale: (p["rationale"] as string) ?? "", + revisable: (p["revisable"] as string) ?? "yes", + made_by: ((p["made_by"] as string) ?? (p["madeBy"] as string) ?? "agent") as "agent", + superseded_by: (p["superseded_by"] as string) ?? (p["supersededBy"] as string) ?? null, + }); + break; + } + default: + // Unknown commands are silently skipped during replay + break; + } + } +} + +// ─── extractEntityKey ───────────────────────────────────────────────────────── + +/** + * Map a WorkflowEvent command to its affected entity type and ID. + * Returns null for commands that don't touch a named entity + * (e.g. unknown or future cmds). + */ +export function extractEntityKey( + event: WorkflowEvent, +): { type: string; id: string } | null { + const p = event.params; + + switch (event.cmd) { + case "complete_task": + case "start_task": + case "report_blocker": + case "record_verification": + return typeof p["taskId"] === "string" + ? { type: "task", id: p["taskId"] } + : null; + + case "complete_slice": + return typeof p["sliceId"] === "string" + ? { type: "slice", id: p["sliceId"] } + : null; + + case "plan_slice": + return typeof p["sliceId"] === "string" + ? { type: "slice_plan", id: p["sliceId"] } + : null; + + case "save_decision": + if (typeof p["scope"] === "string" && typeof p["decision"] === "string") { + return { type: "decision", id: `${p["scope"]}:${p["decision"]}` }; + } + return null; + + default: + return null; + } +} + +// ─── detectConflicts ────────────────────────────────────────────────────────── + +/** + * Compare two sets of diverged events. Returns conflict entries for any + * entity touched by both sides. + * + * Entity-level granularity: if both sides touched task T01 (with any cmd), + * that is one conflict regardless of field-level differences. + */ +export function detectConflicts( + mainDiverged: WorkflowEvent[], + wtDiverged: WorkflowEvent[], +): ConflictEntry[] { + // Group each side's events by entity key + const mainByEntity = new Map(); + for (const event of mainDiverged) { + const key = extractEntityKey(event); + if (!key) continue; + const bucket = mainByEntity.get(`${key.type}:${key.id}`) ?? []; + bucket.push(event); + mainByEntity.set(`${key.type}:${key.id}`, bucket); + } + + const wtByEntity = new Map(); + for (const event of wtDiverged) { + const key = extractEntityKey(event); + if (!key) continue; + const bucket = wtByEntity.get(`${key.type}:${key.id}`) ?? []; + bucket.push(event); + wtByEntity.set(`${key.type}:${key.id}`, bucket); + } + + // Find entities touched by both sides + const conflicts: ConflictEntry[] = []; + for (const [entityKey, mainEvents] of mainByEntity) { + const wtEvents = wtByEntity.get(entityKey); + if (!wtEvents) continue; + + const colonIdx = entityKey.indexOf(":"); + const entityType = entityKey.slice(0, colonIdx); + const entityId = entityKey.slice(colonIdx + 1); + + conflicts.push({ + entityType, + entityId, + mainSideEvents: mainEvents, + worktreeSideEvents: wtEvents, + }); + } + + return conflicts; +} + +// ─── writeConflictsFile ─────────────────────────────────────────────────────── + +/** + * Write a human-readable CONFLICTS.md to basePath/.gsd/CONFLICTS.md. + * Lists each conflict with both sides' event payloads and resolution instructions. + */ +export function writeConflictsFile( + basePath: string, + conflicts: ConflictEntry[], + worktreePath: string, +): void { + const timestamp = new Date().toISOString(); + const lines: string[] = [ + `# Merge Conflicts — ${timestamp}`, + "", + `Conflicts detected merging worktree \`${worktreePath}\` into \`${basePath}\`.`, + `Run \`gsd resolve-conflict\` to resolve each conflict.`, + "", + ]; + + conflicts.forEach((conflict, idx) => { + lines.push(`## Conflict ${idx + 1}: ${conflict.entityType} ${conflict.entityId}`); + lines.push(""); + lines.push("**Main side events:**"); + for (const event of conflict.mainSideEvents) { + lines.push(`- ${event.cmd} at ${event.ts} (hash: ${event.hash})`); + lines.push(` params: ${JSON.stringify(event.params)}`); + } + lines.push(""); + lines.push("**Worktree side events:**"); + for (const event of conflict.worktreeSideEvents) { + lines.push(`- ${event.cmd} at ${event.ts} (hash: ${event.hash})`); + lines.push(` params: ${JSON.stringify(event.params)}`); + } + lines.push(""); + lines.push(`**Resolve with:** \`gsd resolve-conflict --entity ${conflict.entityType}:${conflict.entityId} --pick [main|worktree]\``); + lines.push(""); + }); + + const content = lines.join("\n"); + const dir = join(basePath, ".gsd"); + mkdirSync(dir, { recursive: true }); + atomicWriteSync(join(dir, "CONFLICTS.md"), content); +} + +// ─── reconcileWorktreeLogs ──────────────────────────────────────────────────── + +/** + * Event-log-based reconciliation algorithm: + * + * 1. Read both event logs + * 2. Find fork point (last common event by hash) + * 3. Slice diverged sets from each side + * 4. If no divergence on either side → return autoMerged: 0, conflicts: [] + * 5. detectConflicts() — if any, writeConflictsFile + return early (D-04 all-or-nothing) + * 6. If clean: sort merged = mainDiverged + wtDiverged by timestamp, replayAll + * 7. Write merged event log (base + merged in timestamp order) + * 8. writeManifest + * 9. Return { autoMerged: merged.length, conflicts: [] } + */ +export function reconcileWorktreeLogs( + mainBasePath: string, + worktreeBasePath: string, +): ReconcileResult { + // Step 1: Read both logs + const mainLogPath = join(mainBasePath, ".gsd", "event-log.jsonl"); + const wtLogPath = join(worktreeBasePath, ".gsd", "event-log.jsonl"); + + const mainEvents = readEvents(mainLogPath); + const wtEvents = readEvents(wtLogPath); + + // Step 2: Find fork point + const forkPoint = findForkPoint(mainEvents, wtEvents); + + // Step 3: Slice diverged sets + const mainDiverged = mainEvents.slice(forkPoint + 1); + const wtDiverged = wtEvents.slice(forkPoint + 1); + + // Step 4: No divergence on either side + if (mainDiverged.length === 0 && wtDiverged.length === 0) { + return { autoMerged: 0, conflicts: [] }; + } + + // Step 5: Detect conflicts (entity-level) + const conflicts = detectConflicts(mainDiverged, wtDiverged); + if (conflicts.length > 0) { + // D-04: atomic all-or-nothing — block entire merge + writeConflictsFile(mainBasePath, conflicts, worktreeBasePath); + process.stderr.write( + `[gsd] reconcile: ${conflicts.length} conflict(s) detected — see ${join(mainBasePath, ".gsd", "CONFLICTS.md")}\n`, + ); + return { autoMerged: 0, conflicts }; + } + + // Step 6: Clean merge — sort by timestamp and replay + const merged = [...mainDiverged, ...wtDiverged].sort((a, b) => + a.ts.localeCompare(b.ts), + ); + + // Ensure DB is open for main base path + openDatabase(join(mainBasePath, ".gsd", "gsd.db")); + replayEvents(merged); + + // Step 7: Write merged event log (base + merged in timestamp order) + // CRITICAL (Pitfall #2): After replay, explicitly write the merged event log. + const baseEvents = mainEvents.slice(0, forkPoint + 1); + const mergedLog = baseEvents.concat(merged); + const logContent = mergedLog.map((e) => JSON.stringify(e)).join("\n") + (mergedLog.length > 0 ? "\n" : ""); + mkdirSync(join(mainBasePath, ".gsd"), { recursive: true }); + atomicWriteSync(join(mainBasePath, ".gsd", "event-log.jsonl"), logContent); + + // Step 8: Write manifest + try { + writeManifest(mainBasePath); + } catch (err) { + process.stderr.write( + `[gsd] reconcile: manifest write failed (non-fatal): ${(err as Error).message}\n`, + ); + } + + // Step 9: Return result + return { autoMerged: merged.length, conflicts: [] }; +} + +// ─── Conflict Resolution (D-06) ───────────────────────────────────────────── + +/** + * Parse CONFLICTS.md and return structured ConflictEntry[]. + * Returns empty array when CONFLICTS.md does not exist. + * + * Parses the format written by writeConflictsFile: + * ## Conflict N: {entityType} {entityId} + * **Main side events:** + * - {cmd} at {ts} (hash: {hash}) + * params: {JSON} + * **Worktree side events:** + * - {cmd} at {ts} (hash: {hash}) + * params: {JSON} + */ +export function listConflicts(basePath: string): ConflictEntry[] { + const conflictsPath = join(basePath, ".gsd", "CONFLICTS.md"); + if (!existsSync(conflictsPath)) return []; + + const content = readFileSync(conflictsPath, "utf-8"); + const conflicts: ConflictEntry[] = []; + + // Split into per-conflict sections on "## Conflict N:" headings + const sections = content.split(/^## Conflict \d+:/m).slice(1); + + for (const section of sections) { + // Extract entity type and id from first line: " {entityType} {entityId}" + const headingMatch = section.match(/^\s+(\S+)\s+(\S+)/); + if (!headingMatch) continue; + const entityType = headingMatch[1]!; + const entityId = headingMatch[2]!; + + // Split into main/worktree blocks + const mainMatch = section.split("**Main side events:**")[1]; + const wtMatch = mainMatch?.split("**Worktree side events:**"); + + const mainBlock = wtMatch?.[0] ?? ""; + const wtBlock = wtMatch?.[1] ?? ""; + + const mainSideEvents = parseEventBlock(mainBlock); + const worktreeSideEvents = parseEventBlock(wtBlock); + + conflicts.push({ entityType, entityId, mainSideEvents, worktreeSideEvents }); + } + + return conflicts; +} + +/** + * Parse a block of event lines from CONFLICTS.md into WorkflowEvent[]. + * Each event spans two lines: + * - {cmd} at {ts} (hash: {hash}) + * params: {JSON} + */ +function parseEventBlock(block: string): WorkflowEvent[] { + const events: WorkflowEvent[] = []; + // Find lines starting with "- " (event lines) + const lines = block.split("\n"); + let i = 0; + while (i < lines.length) { + const line = lines[i]!.trim(); + if (line.startsWith("- ")) { + // Parse: - {cmd} at {ts} (hash: {hash}) + const eventMatch = line.match(/^-\s+(\S+)\s+at\s+(\S+)\s+\(hash:\s+(\S+)\)$/); + if (eventMatch) { + const cmd = eventMatch[1]!; + const ts = eventMatch[2]!; + const hash = eventMatch[3]!; + + // Next line: " params: {JSON}" + let params: Record = {}; + const nextLine = lines[i + 1]; + if (nextLine) { + const paramsMatch = nextLine.trim().match(/^params:\s+(.+)$/); + if (paramsMatch) { + try { + params = JSON.parse(paramsMatch[1]!) as Record; + } catch { + // Keep empty params on parse error + } + i++; // consume params line + } + } + + events.push({ cmd, params, ts, hash, actor: "agent" }); + } + } + i++; + } + return events; +} + +/** + * Resolve a single conflict by picking one side's events. + * Replays the picked events through the DB helpers, appends them to the event log, + * and updates or removes CONFLICTS.md. + */ +export function resolveConflict( + basePath: string, + entityKey: string, // e.g. "task:T01" + pick: "main" | "worktree", +): void { + const conflicts = listConflicts(basePath); + const colonIdx = entityKey.indexOf(":"); + const entityType = entityKey.slice(0, colonIdx); + const entityId = entityKey.slice(colonIdx + 1); + + const idx = conflicts.findIndex((c) => c.entityType === entityType && c.entityId === entityId); + if (idx === -1) throw new Error(`No conflict found for entity ${entityKey}`); + + const conflict = conflicts[idx]!; + const eventsToReplay = pick === "main" ? conflict.mainSideEvents : conflict.worktreeSideEvents; + + // Replay resolved events through the DB (updates DB state) + openDatabase(join(basePath, ".gsd", "gsd.db")); + replayEvents(eventsToReplay); + + // Append resolved events to the event log + for (const event of eventsToReplay) { + appendEvent(basePath, { cmd: event.cmd, params: event.params, ts: event.ts, actor: event.actor }); + } + + // Remove resolved conflict from list + conflicts.splice(idx, 1); + + // Update or remove CONFLICTS.md + if (conflicts.length === 0) { + removeConflictsFile(basePath); + } else { + // Re-write CONFLICTS.md with remaining conflicts (worktreePath unknown — use empty string) + writeConflictsFile(basePath, conflicts, ""); + } +} + +/** + * Remove CONFLICTS.md — called when all conflicts are resolved. + * No-op if CONFLICTS.md does not exist. + */ +export function removeConflictsFile(basePath: string): void { + const conflictsPath = join(basePath, ".gsd", "CONFLICTS.md"); + if (existsSync(conflictsPath)) { + unlinkSync(conflictsPath); + } +} diff --git a/src/resources/extensions/gsd/write-intercept.ts b/src/resources/extensions/gsd/write-intercept.ts new file mode 100644 index 000000000..63b648f2b --- /dev/null +++ b/src/resources/extensions/gsd/write-intercept.ts @@ -0,0 +1,57 @@ +// GSD Extension — Write Intercept for Agent State File Blocks +// Detects agent attempts to write authoritative state files and returns +// an error directing the agent to use the engine tool API instead. + +import { realpathSync } from "node:fs"; + +/** + * Patterns matching authoritative .gsd/ state files that agents must NOT write directly. + * + * Only STATE.md is blocked — it is purely engine-rendered from DB state. + * All other .gsd/ files are agent-authored content that agents create and + * update during discuss, plan, and execute phases: + * - REQUIREMENTS.md — agents create during discuss, read during planning + * - PROJECT.md — agents create during discuss, update at milestone close + * - ROADMAP.md / PLAN.md — agents create during planning, engine renders checkboxes + * - SUMMARY.md, KNOWLEDGE.md, CONTEXT.md — non-authoritative content + */ +const BLOCKED_PATTERNS: RegExp[] = [ + // STATE.md is the only purely engine-rendered file + /[/\\]\.gsd[/\\]STATE\.md$/, + // Also match resolved symlink paths under ~/.gsd/projects/ (Pitfall #6) + /[/\\]\.gsd[/\\]projects[/\\][^/\\]+[/\\]STATE\.md$/, +]; + +/** + * Tests whether the given file path matches a blocked authoritative .gsd/ state file. + * Also attempts to resolve symlinks (realpathSync) to catch Pitfall #6 (symlinked .gsd paths). + */ +export function isBlockedStateFile(filePath: string): boolean { + if (matchesBlockedPattern(filePath)) return true; + + // Also try resolved symlink path — file may not exist yet, so wrap in try/catch + try { + const resolved = realpathSync(filePath); + if (resolved !== filePath && matchesBlockedPattern(resolved)) return true; + } catch { + // File doesn't exist yet — that's fine, path matching is enough + } + + return false; +} + +function matchesBlockedPattern(path: string): boolean { + return BLOCKED_PATTERNS.some((pattern) => pattern.test(path)); +} + +/** + * Error message returned when an agent attempts to directly write an authoritative .gsd/ state file. + * Directs the agent to use engine tool calls instead. + */ +export const BLOCKED_WRITE_ERROR = `Error: Direct writes to .gsd/ state files are blocked. Use engine tool calls instead: +- To complete a task: call gsd_complete_task(milestone_id, slice_id, task_id, summary) +- To complete a slice: call gsd_complete_slice(milestone_id, slice_id, summary, uat_result) +- To save a decision: call gsd_save_decision(scope, decision, choice, rationale) +- To start a task: call gsd_start_task(milestone_id, slice_id, task_id) +- To record verification: call gsd_record_verification(milestone_id, slice_id, task_id, evidence) +- To report a blocker: call gsd_report_blocker(milestone_id, slice_id, task_id, description)`;