feat(gsd): single-writer state engine v2 — discipline layer on DB architecture

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).
This commit is contained in:
Jeremy McSpadden 2026-03-24 23:40:02 -05:00 committed by Lex Christopherson
parent 9574c5796d
commit 1c0cca4f76
34 changed files with 2393 additions and 326 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown> {
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";

View file

@ -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<string, unknown>,
) => Promise<void>;
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<string, unknown>,
) => void;
recordOutcome: (unitType: string, tier: string, success: boolean) => void;
writeLock: (
lockBase: string,
unitType: string,
unitId: string,
completedCount: number,
sessionFile?: string,
) => void;
captureAvailableSkills: () => void;

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void> {
// ── 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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 25 steps and 38 files per task. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window.
- **Feature completeness:** Every task produces real, user-facing progress — not just internal scaffolding.
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."

View file

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

View file

@ -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<GSDState> {
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 });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
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<WorkflowEvent, "hash">,
): 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 };
}

View file

@ -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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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;
}

View file

@ -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/<ID>/ 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 };
}

View file

@ -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<void> {
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<void> {
// 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;
}
}

View file

@ -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<string, WorkflowEvent[]>();
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<string, WorkflowEvent[]>();
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<string, unknown> = {};
const nextLine = lines[i + 1];
if (nextLine) {
const paramsMatch = nextLine.trim().match(/^params:\s+(.+)$/);
if (paramsMatch) {
try {
params = JSON.parse(paramsMatch[1]!) as Record<string, unknown>;
} 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);
}
}

View file

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