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:
parent
9574c5796d
commit
1c0cca4f76
34 changed files with 2393 additions and 326 deletions
131
src/resources/extensions/gsd/auto-artifact-paths.ts
Normal file
131
src/resources/extensions/gsd/auto-artifact-paths.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -72,9 +72,11 @@ Then:
|
|||
- **Key links planned:** For every pair of artifacts that must connect, there is an explicit step that wires them.
|
||||
- **Scope sanity:** Target 2–5 steps and 3–8 files per task. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window.
|
||||
- **Feature completeness:** Every task produces real, user-facing progress — not just internal scaffolding.
|
||||
8. If planning produced structural decisions, append them to `.gsd/DECISIONS.md`
|
||||
9. {{commitInstruction}}
|
||||
10. If planning produced structural decisions, append them to `.gsd/DECISIONS.md`
|
||||
11. {{commitInstruction}}
|
||||
|
||||
The slice directory and tasks/ subdirectory already exist. Do NOT mkdir. All work stays in your working directory: `{{workingDirectory}}`.
|
||||
|
||||
**You MUST write the file `{{outputPath}}` before finishing.**
|
||||
|
||||
When done, say: "Slice {{sliceId}} planned."
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
94
src/resources/extensions/gsd/sync-lock.ts
Normal file
94
src/resources/extensions/gsd/sync-lock.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
135
src/resources/extensions/gsd/workflow-events.ts
Normal file
135
src/resources/extensions/gsd/workflow-events.ts
Normal 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 };
|
||||
}
|
||||
314
src/resources/extensions/gsd/workflow-manifest.ts
Normal file
314
src/resources/extensions/gsd/workflow-manifest.ts
Normal 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;
|
||||
}
|
||||
345
src/resources/extensions/gsd/workflow-migration.ts
Normal file
345
src/resources/extensions/gsd/workflow-migration.ts
Normal 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 };
|
||||
}
|
||||
423
src/resources/extensions/gsd/workflow-projections.ts
Normal file
423
src/resources/extensions/gsd/workflow-projections.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
473
src/resources/extensions/gsd/workflow-reconcile.ts
Normal file
473
src/resources/extensions/gsd/workflow-reconcile.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
57
src/resources/extensions/gsd/write-intercept.ts
Normal file
57
src/resources/extensions/gsd/write-intercept.ts
Normal 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)`;
|
||||
Loading…
Add table
Reference in a new issue