Merge branch 'main' into refine/consolidate-verdict-parsing

This commit is contained in:
TÂCHES 2026-03-25 23:02:14 -06:00 committed by GitHub
commit 91e7bb7d92
67 changed files with 3126 additions and 2195 deletions

View file

@ -467,10 +467,9 @@
| gsd/index.ts | GSD Workflow | Main GSD extension bootstrap and registration |
| gsd/auto.ts | Auto Engine | Automatic workflow execution and loop management |
| gsd/auto-dashboard.ts | Auto Engine, Web Mode | Real-time dashboard for auto-run progress |
| gsd/auto-worktree.ts | Auto Engine, Worktree | Automatic worktree creation and branch management |
| gsd/auto-worktree.ts | Auto Engine, Worktree | Worktree lifecycle, state sync, resource staleness, stale escape |
| gsd/auto-recovery.ts | Auto Engine | Recovery for crashed/stalled workflows |
| gsd/auto-start.ts | Auto Engine | Initialization sequence for automatic execution |
| gsd/auto-worktree-sync.ts | Auto Engine, Worktree | State sync between worktrees and main |
| gsd/auto-model-selection.ts | Auto Engine, Model System | Intelligent LLM model routing |
| gsd/auto-direct-dispatch.ts | Auto Engine | Direct command dispatching without planning |
| gsd/auto-dispatch.ts | Auto Engine | Task queueing and priority-based dispatch |

View file

@ -80,6 +80,9 @@ export function resolveExpectedArtifactPath(
}
case "rewrite-docs":
return null;
case "gate-evaluate":
// Gate evaluate writes to DB quality_gates table — verified via state derivation
return null;
case "reactive-execute":
// Reactive execute produces multiple task summaries — verified separately
return null;

View file

@ -151,6 +151,8 @@ export function describeNextUnit(state: GSDState): { label: string; description:
return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." };
case "completing-milestone":
return { label: "Complete milestone", description: "Write milestone summary." };
case "evaluating-gates":
return { label: `Evaluate gates for ${sid}: ${sTitle}`, description: "Parallel quality gate assessment before execution." };
default:
return { label: "Continue", description: "Execute the next step." };
}

View file

@ -11,8 +11,9 @@
import type { GSDState } from "./types.js";
import type { GSDPreferences } from "./preferences.js";
import { loadFile, loadActiveOverrides } from "./files.js";
import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js";
import type { UatType } from "./files.js";
import { loadFile, extractUatType, loadActiveOverrides } from "./files.js";
import { isDbAvailable, getMilestoneSlices, getPendingGates, markAllGatesOmitted } from "./gsd-db.js";
import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
import { extractUatType } from "./files.js";
@ -44,6 +45,7 @@ import {
buildReassessRoadmapPrompt,
buildRewriteDocsPrompt,
buildReactiveExecutePrompt,
buildGateEvaluatePrompt,
checkNeedsReassessment,
checkNeedsRunUat,
} from "./auto-prompts.js";
@ -333,6 +335,38 @@ export const DISPATCH_RULES: DispatchRule[] = [
};
},
},
{
name: "evaluating-gates → gate-evaluate",
match: async ({ state, mid, midTitle, basePath, prefs }) => {
if (state.phase !== "evaluating-gates") return null;
if (!state.activeSlice) return missingSliceStop(mid, state.phase);
const sid = state.activeSlice.id;
const sTitle = state.activeSlice.title;
// Gate evaluation is opt-in via preferences
const gateConfig = prefs?.gate_evaluation;
if (!gateConfig?.enabled) {
markAllGatesOmitted(mid, sid);
return { action: "skip" };
}
const pending = getPendingGates(mid, sid, "slice");
if (pending.length === 0) return { action: "skip" };
return {
action: "dispatch",
unitType: "gate-evaluate",
unitId: `${mid}/${sid}/gates+${pending.map(g => g.gate_id).join(",")}`,
prompt: await buildGateEvaluatePrompt(
mid,
midTitle,
sid,
sTitle,
basePath,
),
};
},
},
{
name: "replanning-slice → replan-slice",
match: async ({ state, mid, midTitle, basePath }) => {

View file

@ -33,7 +33,7 @@ import {
resolveExpectedArtifactPath,
} from "./auto-recovery.js";
import { regenerateIfMissing } from "./workflow-projections.js";
import { syncStateToProjectRoot } from "./auto-worktree-sync.js";
import { syncStateToProjectRoot } from "./auto-worktree.js";
import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, _getAdapter } from "./gsd-db.js";
import { renderPlanCheckboxes } from "./markdown-renderer.js";
import { consumeSignal } from "./session-status-io.js";

View file

@ -24,6 +24,7 @@ import { getLoadedSkills, type Skill } from "@gsd/pi-coding-agent";
import { join, basename } from "node:path";
import { existsSync } from "node:fs";
import { computeBudgets, resolveExecutorContextWindow, truncateAtSectionBoundary } from "./context-budget.js";
import { getPendingGates } from "./gsd-db.js";
import { formatDecisionsCompact, formatRequirementsCompact } from "./structured-data-formatter.js";
// ─── Preamble Cap ─────────────────────────────────────────────────────────────
@ -1662,6 +1663,96 @@ export async function buildReactiveExecutePrompt(
});
}
// ─── Gate Evaluation ──────────────────────────────────────────────────────
const GATE_QUESTIONS: Record<string, { question: string; guidance: string }> = {
Q3: {
question: "How can this be exploited?",
guidance: [
"Identify abuse scenarios: parameter tampering, replay attacks, privilege escalation.",
"Map data exposure risks: PII, tokens, secrets accessible through this slice.",
"Define input trust boundaries: untrusted user input reaching DB, API, or filesystem.",
"If none apply, return verdict 'omitted' with rationale explaining why.",
].join("\n"),
},
Q4: {
question: "What existing promises does this break?",
guidance: [
"List which existing requirements (R001, R003, etc.) are touched by this slice.",
"Identify what must be re-tested after shipping.",
"Flag decisions that should be revisited given the new scope.",
"If no existing requirements are affected, return verdict 'omitted'.",
].join("\n"),
},
};
export async function buildGateEvaluatePrompt(
mid: string, midTitle: string, sid: string, sTitle: string,
base: string,
): Promise<string> {
const pending = getPendingGates(mid, sid, "slice");
// Load the slice plan for context
const planFile = resolveSliceFile(base, mid, sid, "PLAN");
const planContent = planFile ? (await loadFile(planFile)) ?? "(plan file empty)" : "(plan file not found)";
// Build per-gate subagent prompts
const subagentSections: string[] = [];
const gateListLines: string[] = [];
for (const gate of pending) {
const meta = GATE_QUESTIONS[gate.gate_id];
if (!meta) continue;
gateListLines.push(`- **${gate.gate_id}**: ${meta.question}`);
const subPrompt = [
`You are evaluating quality gate **${gate.gate_id}** for slice ${sid} (${sTitle}).`,
"",
`## Question: ${meta.question}`,
"",
meta.guidance,
"",
"## Slice Plan",
"",
planContent,
"",
"## Instructions",
"",
"Analyze the slice plan above and answer the gate question.",
`Call the \`gsd_save_gate_result\` tool with:`,
`- \`milestoneId\`: "${mid}"`,
`- \`sliceId\`: "${sid}"`,
`- \`gateId\`: "${gate.gate_id}"`,
"- `verdict`: \"pass\" (no concerns), \"flag\" (concerns found), or \"omitted\" (not applicable)",
"- `rationale`: one-sentence justification",
"- `findings`: detailed markdown findings (or empty if omitted)",
].join("\n");
subagentSections.push([
`### ${gate.gate_id}: ${meta.question}`,
"",
"Use this as the prompt for a `subagent` call:",
"",
"```",
subPrompt,
"```",
].join("\n"));
}
return loadPrompt("gate-evaluate", {
workingDirectory: base,
milestoneId: mid,
milestoneTitle: midTitle,
sliceId: sid,
sliceTitle: sTitle,
slicePlanContent: planContent,
gateCount: String(pending.length),
gateList: gateListLines.join("\n"),
subagentPrompts: subagentSections.join("\n\n---\n\n"),
});
}
export async function buildRewriteDocsPrompt(
mid: string, midTitle: string,
activeSlice: { id: string; title: string } | null,

View file

@ -10,7 +10,6 @@
import type { ExtensionContext } from "@gsd/pi-coding-agent";
import { parseUnitId } from "./unit-id.js";
import { atomicWriteSync } from "./atomic-write.js";
import { clearUnitRuntimeRecord } from "./unit-runtime.js";
import { clearParseCache } from "./files.js";
import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
import { isDbAvailable, getTask, getSlice, getSliceTasks } from "./gsd-db.js";
@ -24,18 +23,13 @@ import {
nativeResetHard,
} from "./native-git-bridge.js";
import {
resolveMilestonePath,
resolveSlicePath,
resolveSliceFile,
resolveTasksDir,
resolveTaskFiles,
relMilestoneFile,
relSliceFile,
relSlicePath,
relTaskFile,
buildMilestoneFileName,
buildSliceFileName,
buildTaskFileName,
resolveMilestoneFile,
clearPathCache,
resolveGsdRootFile,
@ -49,82 +43,16 @@ import {
} from "node:fs";
import { execFileSync } from "node:child_process";
import { dirname, join } from "node:path";
import {
resolveExpectedArtifactPath,
diagnoseExpectedArtifact,
} from "./auto-artifact-paths.js";
// Re-export so existing consumers of auto-recovery.ts keep working.
export { resolveExpectedArtifactPath, diagnoseExpectedArtifact };
// ─── Artifact Resolution & Verification ───────────────────────────────────────
/**
* 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")) : 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;
}
}
/**
* Check whether a milestone produced implementation artifacts (non-`.gsd/` files)
* in the git history. Uses `git log --name-only` to inspect all commits on the
@ -302,6 +230,35 @@ export function verifyExpectedArtifact(
return true;
}
// Gate-evaluate: verify that each dispatched gate has been resolved in the DB.
// The unitId encodes the batch: "{mid}/{sid}/gates+Q3,Q4"
if (unitType === "gate-evaluate") {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
const batchPart = parts[2]; // "gates+Q3,Q4"
if (!mid || !sid || !batchPart) return false;
const plusIdx = batchPart.indexOf("+");
if (plusIdx === -1) return true; // no specific gates encoded — pass
const gateIds = batchPart.slice(plusIdx + 1).split(",").filter(Boolean);
if (gateIds.length === 0) return true;
try {
const { getPendingGates: getPending } = require("./gsd-db.js");
const pending = getPending(mid, sid, "slice");
const pendingIds = new Set(pending.map((g: any) => g.gate_id));
// All dispatched gates must no longer be pending
for (const gid of gateIds) {
if (pendingIds.has(gid)) return false;
}
} catch {
// DB unavailable — treat as verified to avoid blocking
}
return true;
}
const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
// For unit types with no verifiable artifact (null path), the parent directory
// is missing on disk — treat as stale completion state so the key gets evicted (#313).
@ -471,50 +428,37 @@ export function writeBlockerPlaceholder(
return diagnoseExpectedArtifact(unitType, unitId, base);
}
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`;
// ─── Merge State Reconciliation ───────────────────────────────────────────────
/**
* Best-effort abort of a pending merge/squash and hard-reset to HEAD.
* Handles both real merges (MERGE_HEAD) and squash merges (SQUASH_MSG).
*/
function abortAndResetMerge(
basePath: string,
hasMergeHead: boolean,
squashMsgPath: string,
): void {
if (hasMergeHead) {
try {
nativeMergeAbort(basePath);
} catch {
/* best-effort */
}
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")} (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;
} else if (squashMsgPath) {
try {
unlinkSync(squashMsgPath);
} catch {
/* best-effort */
}
}
try {
nativeResetHard(basePath);
} catch {
/* best-effort */
}
}
// ─── Merge State Reconciliation ───────────────────────────────────────────────
/**
* Detect leftover merge state from a prior session and reconcile it.
* If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
@ -571,24 +515,7 @@ export function reconcileMergeState(
}
}
if (!resolved) {
if (hasMergeHead) {
try {
nativeMergeAbort(basePath);
} catch {
/* best-effort */
}
} else if (hasSquashMsg) {
try {
unlinkSync(squashMsgPath);
} catch {
/* best-effort */
}
}
try {
nativeResetHard(basePath);
} catch {
/* best-effort */
}
abortAndResetMerge(basePath, hasMergeHead, squashMsgPath);
ctx.ui.notify(
"Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.",
"warning",
@ -596,24 +523,7 @@ export function reconcileMergeState(
}
} else {
// Code conflicts present — abort and reset
if (hasMergeHead) {
try {
nativeMergeAbort(basePath);
} catch {
/* best-effort */
}
} else if (hasSquashMsg) {
try {
unlinkSync(squashMsgPath);
} catch {
/* best-effort */
}
}
try {
nativeResetHard(basePath);
} catch {
/* best-effort */
}
abortAndResetMerge(basePath, hasMergeHead, squashMsgPath);
ctx.ui.notify(
"Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.",
"warning",
@ -623,50 +533,6 @@ export function reconcileMergeState(
return true;
}
// ─── Self-Heal Runtime Records ────────────────────────────────────────────────
/**
* Self-heal: scan runtime records in .gsd/ and clear stale ones.
* Clears dispatched records older than 1 hour (process crashed before
* completing the unit). deriveState() handles re-derivation no need
* for completion key persistence here.
*/
export async function selfHealRuntimeRecords(
base: string,
ctx: ExtensionContext,
): Promise<void> {
try {
const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
const records = listUnitRuntimeRecords(base);
let healed = 0;
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
const now = Date.now();
for (const record of records) {
const { unitType, unitId } = record;
// Case 0 removed — roadmap checkbox auto-fix is no longer needed.
// With DB-as-truth, stale checkboxes are fixed by repairStaleRenders().
// Clear stale dispatched records (dispatched > 1h ago, process crashed)
const age = now - (record.startedAt ?? 0);
if (record.phase === "dispatched" && age > STALE_THRESHOLD_MS) {
clearUnitRuntimeRecord(base, unitType, unitId);
healed++;
continue;
}
}
if (healed > 0) {
ctx.ui.notify(
`Self-heal: cleared ${healed} stale runtime record(s).`,
"info",
);
}
} catch (e) {
// Non-fatal — self-heal should never block auto-mode start
void e;
}
}
// ─── Loop Remediation ─────────────────────────────────────────────────────────
/**

View file

@ -52,7 +52,7 @@ import {
setActiveMilestoneId,
} from "./worktree.js";
import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js";
import { readResourceVersion } from "./auto-worktree-sync.js";
import { readResourceVersion, cleanStaleRuntimeUnits } from "./auto-worktree.js";
import { initMetrics } from "./metrics.js";
import { initRoutingHistory } from "./routing-history.js";
import { restoreHookState, resetHookState } from "./post-unit-hooks.js";
@ -258,31 +258,10 @@ export async function bootstrapAutoSession(
invalidateAllCaches();
// Clean stale runtime unit files for completed milestones (#887)
try {
const runtimeUnitsDir = join(gsdRoot(base), "runtime", "units");
if (existsSync(runtimeUnitsDir)) {
for (const file of readdirSync(runtimeUnitsDir)) {
if (!file.endsWith(".json")) continue;
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
if (!midMatch) continue;
const mid = midMatch[1];
if (resolveMilestoneFile(base, mid, "SUMMARY")) {
try {
unlinkSync(join(runtimeUnitsDir, file));
} catch (e) {
debugLog("stale-unit-cleanup-failed", {
file,
error: e instanceof Error ? e.message : String(e),
});
}
}
}
}
} catch (e) {
debugLog("stale-unit-dir-cleanup-failed", {
error: e instanceof Error ? e.message : String(e),
});
}
cleanStaleRuntimeUnits(
gsdRoot(base),
(mid) => !!resolveMilestoneFile(base, mid, "SUMMARY"),
);
let state = await deriveState(base);

View file

@ -71,8 +71,6 @@ export async function runPostUnitVerification(
}
const result = runVerificationGate({
basePath: s.basePath,
unitId: s.currentUnit.id,
cwd: s.basePath,
preferenceCommands: prefs?.verification_commands,
taskPlanVerify,

View file

@ -1,247 +0,0 @@
/**
* Worktree project root state synchronization for auto-mode.
*
* When auto-mode runs inside a worktree, dispatch-critical state files
* (.gsd/ metadata) diverge between the worktree (where work happens)
* and the project root (where startAutoMode reads initial state on restart).
* Without syncing, restarting auto-mode reads stale state from the project
* root and re-dispatches already-completed units.
*
* Also contains resource staleness detection and stale worktree escape.
*/
import {
existsSync,
mkdirSync,
readFileSync,
cpSync,
unlinkSync,
readdirSync,
} from "node:fs";
import { join, sep as pathSep } from "node:path";
import { homedir } from "node:os";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Project Root → Worktree Sync ─────────────────────────────────────────
/**
* Sync milestone artifacts from project root INTO worktree before deriveState.
* Covers the case where the LLM wrote artifacts to the main repo filesystem
* (e.g. via absolute paths) but the worktree has stale data. Also deletes
* gsd.db in the worktree so it rebuilds from fresh disk state (#853).
* Non-fatal sync failure should never block dispatch.
*/
export function syncProjectRootToWorktree(
projectRoot: string,
worktreePath: string,
milestoneId: string | null,
): void {
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
if (!milestoneId) return;
const prGsd = join(projectRoot, ".gsd");
const wtGsd = join(worktreePath, ".gsd");
// Copy milestone directory from project root to worktree — additive only.
// force:false prevents cpSync from overwriting existing worktree files.
// Without this, worktree-authoritative files (e.g. VALIDATION.md written
// by validate-milestone) get clobbered by stale project root copies,
// causing an infinite re-validation loop (#1886).
safeCopyRecursive(
join(prGsd, "milestones", milestoneId),
join(wtGsd, "milestones", milestoneId),
{ force: false },
);
// Forward-sync completed-units.json from project root to worktree.
// Project root is authoritative for completion state after crash recovery;
// without this, the worktree re-dispatches already-completed units (#1886).
safeCopy(
join(prGsd, "completed-units.json"),
join(wtGsd, "completed-units.json"),
{ force: true },
);
// Delete worktree gsd.db so it rebuilds from the freshly synced files.
// Stale DB rows are the root cause of the infinite skip loop (#853).
try {
const wtDb = join(wtGsd, "gsd.db");
if (existsSync(wtDb)) {
unlinkSync(wtDb);
}
} catch {
/* non-fatal */
}
}
// ─── Worktree → Project Root Sync ─────────────────────────────────────────
/**
* Sync dispatch-critical .gsd/ state files from worktree to project root.
* Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
* Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
* Non-fatal sync failure should never block dispatch.
*/
export function syncStateToProjectRoot(
worktreePath: string,
projectRoot: string,
milestoneId: string | null,
): void {
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
if (!milestoneId) return;
const wtGsd = join(worktreePath, ".gsd");
const prGsd = join(projectRoot, ".gsd");
// 1. STATE.md — the quick-glance status used by initial deriveState()
safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true });
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
// Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
safeCopyRecursive(
join(wtGsd, "milestones", milestoneId),
join(prGsd, "milestones", milestoneId),
{ force: true },
);
// 3. metrics.json — session cost/token tracking (#2313).
// Without this, metrics accumulated in the worktree are invisible from the
// project root and never appear in the dashboard or skill-health reports.
safeCopy(join(wtGsd, "metrics.json"), join(prGsd, "metrics.json"), { force: true });
// 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
// Without this, a crash during a unit leaves the runtime record only in the
// worktree. If the next session resolves basePath before worktree re-entry,
// selfHeal can't find or clear the stale record (#769).
safeCopyRecursive(
join(wtGsd, "runtime", "units"),
join(prGsd, "runtime", "units"),
{ force: true },
);
}
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
* Read the resource version (semver) from the managed-resources manifest.
* Uses gsdVersion instead of syncedAt so that launching a second session
* doesn't falsely trigger staleness (#804).
*/
export function readResourceVersion(): string | null {
const agentDir =
process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent");
const manifestPath = join(agentDir, "managed-resources.json");
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
return typeof manifest?.gsdVersion === "string"
? manifest.gsdVersion
: null;
} catch {
return null;
}
}
/**
* Check if managed resources have been updated since session start.
* Returns a warning message if stale, null otherwise.
*/
export function checkResourcesStale(
versionOnStart: string | null,
): string | null {
if (versionOnStart === null) return null;
const current = readResourceVersion();
if (current === null) return null;
if (current !== versionOnStart) {
return "GSD resources were updated since this session started. Restart gsd to load the new code.";
}
return null;
}
// ─── Stale Worktree Escape ────────────────────────────────────────────────
/**
* Detect and escape a stale worktree cwd (#608).
*
* After milestone completion + merge, the worktree directory is removed but
* the process cwd may still point inside `.gsd/worktrees/<MID>/`.
* When a new session starts, `process.cwd()` is passed as `base` to startAuto
* and all subsequent writes land in the wrong directory. This function detects
* that scenario and chdir back to the project root.
*
* Returns the corrected base path.
*/
export function escapeStaleWorktree(base: string): string {
// Direct layout: /.gsd/worktrees/
const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
let idx = base.indexOf(directMarker);
if (idx === -1) {
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
const symlinkRe = new RegExp(
`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`,
);
const match = base.match(symlinkRe);
if (!match || match.index === undefined) return base;
idx = match.index;
}
// base is inside .gsd/worktrees/<something> — extract the project root
const projectRoot = base.slice(0, idx);
// Guard: If the candidate project root's .gsd IS the user-level ~/.gsd,
// the string-slice heuristic matched the wrong /.gsd/ boundary. This happens
// when .gsd is a symlink into ~/.gsd/projects/<hash> and process.cwd()
// resolved through the symlink. Returning ~ would be catastrophic (#1676).
const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/");
const gsdHomePath = gsdHome.replaceAll("\\", "/");
if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) {
// Don't chdir to home — return base unchanged.
// resolveProjectRoot() in worktree.ts has the full git-file-based recovery
// and will be called by the caller (startAuto → projectRoot()).
return base;
}
try {
process.chdir(projectRoot);
} catch {
// If chdir fails, return the original — caller will handle errors downstream
return base;
}
return projectRoot;
}
/**
* Clean stale runtime unit files for completed milestones.
*
* After restart, stale runtime/units/*.json from prior milestones can
* cause deriveState to resume the wrong milestone (#887). Removes files
* for milestones that have a SUMMARY (fully complete).
*/
export function cleanStaleRuntimeUnits(
gsdRootPath: string,
hasMilestoneSummary: (mid: string) => boolean,
): number {
const runtimeUnitsDir = join(gsdRootPath, "runtime", "units");
if (!existsSync(runtimeUnitsDir)) return 0;
let cleaned = 0;
try {
for (const file of readdirSync(runtimeUnitsDir)) {
if (!file.endsWith(".json")) continue;
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
if (!midMatch) continue;
if (hasMilestoneSummary(midMatch[1])) {
try {
unlinkSync(join(runtimeUnitsDir, file));
cleaned++;
} catch {
/* non-fatal */
}
}
}
} catch {
/* non-fatal */
}
return cleaned;
}

View file

@ -17,7 +17,8 @@ import {
unlinkSync,
lstatSync as lstatSyncFn,
} from "node:fs";
import { isAbsolute, join } from "node:path";
import { isAbsolute, join, sep as pathSep } from "node:path";
import { homedir } from "node:os";
import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
import {
reconcileWorktreeDb,
@ -63,6 +64,38 @@ import {
nativeIsAncestor,
} from "./native-git-bridge.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Shared Constants & Helpers ─────────────────────────────────────────────
/**
* Root-level .gsd/ state files synced between worktree and project root.
* Single source of truth used by syncGsdStateToWorktree, syncWorktreeStateBack,
* and the dispatch-level sync functions.
*/
const ROOT_STATE_FILES = [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
"QUEUE.md",
"completed-units.json",
"metrics.json",
] as const;
/**
* Check if two filesystem paths resolve to the same real location.
* Returns false if either path cannot be resolved (e.g. doesn't exist).
*/
function isSamePath(a: string, b: string): boolean {
try {
return realpathSync(a) === realpathSync(b);
} catch {
return false;
}
}
// ─── Module State ──────────────────────────────────────────────────────────
/** Original project root before chdir into auto-worktree. */
@ -119,6 +152,227 @@ function clearProjectRootStateFiles(basePath: string, milestoneId: string): void
}
}
}
// ─── Dispatch-Level Sync (project root ↔ worktree) ──────────────────────────
/**
* Sync milestone artifacts from project root INTO worktree before deriveState.
* Covers the case where the LLM wrote artifacts to the main repo filesystem
* (e.g. via absolute paths) but the worktree has stale data. Also deletes
* gsd.db in the worktree so it rebuilds from fresh disk state (#853).
* Non-fatal sync failure should never block dispatch.
*/
export function syncProjectRootToWorktree(
projectRoot: string,
worktreePath_: string,
milestoneId: string | null,
): void {
if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return;
if (!milestoneId) return;
const prGsd = join(projectRoot, ".gsd");
const wtGsd = join(worktreePath_, ".gsd");
// Copy milestone directory from project root to worktree — additive only.
// force:false prevents cpSync from overwriting existing worktree files.
// Without this, worktree-authoritative files (e.g. VALIDATION.md written
// by validate-milestone) get clobbered by stale project root copies,
// causing an infinite re-validation loop (#1886).
safeCopyRecursive(
join(prGsd, "milestones", milestoneId),
join(wtGsd, "milestones", milestoneId),
{ force: false },
);
// Forward-sync completed-units.json from project root to worktree.
// Project root is authoritative for completion state after crash recovery;
// without this, the worktree re-dispatches already-completed units (#1886).
safeCopy(
join(prGsd, "completed-units.json"),
join(wtGsd, "completed-units.json"),
{ force: true },
);
// Delete worktree gsd.db so it rebuilds from the freshly synced files.
// Stale DB rows are the root cause of the infinite skip loop (#853).
try {
const wtDb = join(wtGsd, "gsd.db");
if (existsSync(wtDb)) {
unlinkSync(wtDb);
}
} catch {
/* non-fatal */
}
}
/**
* Sync dispatch-critical .gsd/ state files from worktree to project root.
* Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
* Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
* Non-fatal sync failure should never block dispatch.
*/
export function syncStateToProjectRoot(
worktreePath_: string,
projectRoot: string,
milestoneId: string | null,
): void {
if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return;
if (!milestoneId) return;
const wtGsd = join(worktreePath_, ".gsd");
const prGsd = join(projectRoot, ".gsd");
// 1. STATE.md — the quick-glance status used by initial deriveState()
safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true });
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
// Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
safeCopyRecursive(
join(wtGsd, "milestones", milestoneId),
join(prGsd, "milestones", milestoneId),
{ force: true },
);
// 3. metrics.json — session cost/token tracking (#2313).
// Without this, metrics accumulated in the worktree are invisible from the
// project root and never appear in the dashboard or skill-health reports.
safeCopy(join(wtGsd, "metrics.json"), join(prGsd, "metrics.json"), { force: true });
// 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
// Without this, a crash during a unit leaves the runtime record only in the
// worktree. If the next session resolves basePath before worktree re-entry,
// selfHeal can't find or clear the stale record (#769).
safeCopyRecursive(
join(wtGsd, "runtime", "units"),
join(prGsd, "runtime", "units"),
{ force: true },
);
}
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
* Read the resource version (semver) from the managed-resources manifest.
* Uses gsdVersion instead of syncedAt so that launching a second session
* doesn't falsely trigger staleness (#804).
*/
export function readResourceVersion(): string | null {
const agentDir =
process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent");
const manifestPath = join(agentDir, "managed-resources.json");
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
return typeof manifest?.gsdVersion === "string"
? manifest.gsdVersion
: null;
} catch {
return null;
}
}
/**
* Check if managed resources have been updated since session start.
* Returns a warning message if stale, null otherwise.
*/
export function checkResourcesStale(
versionOnStart: string | null,
): string | null {
if (versionOnStart === null) return null;
const current = readResourceVersion();
if (current === null) return null;
if (current !== versionOnStart) {
return "GSD resources were updated since this session started. Restart gsd to load the new code.";
}
return null;
}
// ─── Stale Worktree Escape ────────────────────────────────────────────────
/**
* Detect and escape a stale worktree cwd (#608).
*
* After milestone completion + merge, the worktree directory is removed but
* the process cwd may still point inside `.gsd/worktrees/<MID>/`.
* When a new session starts, `process.cwd()` is passed as `base` to startAuto
* and all subsequent writes land in the wrong directory. This function detects
* that scenario and chdir back to the project root.
*
* Returns the corrected base path.
*/
export function escapeStaleWorktree(base: string): string {
// Direct layout: /.gsd/worktrees/
const directMarker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
let idx = base.indexOf(directMarker);
if (idx === -1) {
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/
const symlinkRe = new RegExp(
`\\${pathSep}\\.gsd\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`,
);
const match = base.match(symlinkRe);
if (!match || match.index === undefined) return base;
idx = match.index;
}
// base is inside .gsd/worktrees/<something> — extract the project root
const projectRoot = base.slice(0, idx);
// Guard: If the candidate project root's .gsd IS the user-level ~/.gsd,
// the string-slice heuristic matched the wrong /.gsd/ boundary. This happens
// when .gsd is a symlink into ~/.gsd/projects/<hash> and process.cwd()
// resolved through the symlink. Returning ~ would be catastrophic (#1676).
const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/");
const gsdHomePath = gsdHome.replaceAll("\\", "/");
if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) {
// Don't chdir to home — return base unchanged.
// resolveProjectRoot() in worktree.ts has the full git-file-based recovery
// and will be called by the caller (startAuto → projectRoot()).
return base;
}
try {
process.chdir(projectRoot);
} catch {
// If chdir fails, return the original — caller will handle errors downstream
return base;
}
return projectRoot;
}
/**
* Clean stale runtime unit files for completed milestones.
*
* After restart, stale runtime/units/*.json from prior milestones can
* cause deriveState to resume the wrong milestone (#887). Removes files
* for milestones that have a SUMMARY (fully complete).
*/
export function cleanStaleRuntimeUnits(
gsdRootPath: string,
hasMilestoneSummary: (mid: string) => boolean,
): number {
const runtimeUnitsDir = join(gsdRootPath, "runtime", "units");
if (!existsSync(runtimeUnitsDir)) return 0;
let cleaned = 0;
try {
for (const file of readdirSync(runtimeUnitsDir)) {
if (!file.endsWith(".json")) continue;
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
if (!midMatch) continue;
if (hasMilestoneSummary(midMatch[1])) {
try {
unlinkSync(join(runtimeUnitsDir, file));
cleaned++;
} catch {
/* non-fatal */
}
}
}
} catch {
/* non-fatal */
}
return cleaned;
}
// ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
/**
@ -144,28 +398,12 @@ export function syncGsdStateToWorktree(
const synced: string[] = [];
// If both resolve to the same directory (symlink), no sync needed
try {
const mainResolved = realpathSync(mainGsd);
const wtResolved = realpathSync(wtGsd);
if (mainResolved === wtResolved) return { synced };
} catch {
// Can't resolve — proceed with sync as a safety measure
}
if (isSamePath(mainGsd, wtGsd)) return { synced };
if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
// Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE, etc.)
const rootFiles = [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
"QUEUE.md",
"completed-units.json",
"metrics.json",
];
for (const f of rootFiles) {
for (const f of ROOT_STATE_FILES) {
const src = join(mainGsd, f);
const dst = join(wtGsd, f);
if (existsSync(src) && !existsSync(dst)) {
@ -298,13 +536,7 @@ export function syncWorktreeStateBack(
const synced: string[] = [];
// If both resolve to the same directory (symlink), no sync needed
try {
const mainResolved = realpathSync(mainGsd);
const wtResolved = realpathSync(wtGsd);
if (mainResolved === wtResolved) return { synced };
} catch {
// Can't resolve — proceed with sync
}
if (isSamePath(mainGsd, wtGsd)) return { synced };
if (!existsSync(wtGsd) || !existsSync(mainGsd)) return { synced };
@ -330,17 +562,7 @@ export function syncWorktreeStateBack(
// Also includes QUEUE.md, completed-units.json, and metrics.json which are
// written during milestone closeout and lost on teardown without explicit sync
// (#1787, #2313).
const rootFiles = [
"DECISIONS.md",
"REQUIREMENTS.md",
"PROJECT.md",
"KNOWLEDGE.md",
"OVERRIDES.md",
"QUEUE.md",
"completed-units.json",
"metrics.json",
];
for (const f of rootFiles) {
for (const f of ROOT_STATE_FILES) {
const src = join(wtGsd, f);
const dst = join(mainGsd, f);
if (existsSync(src)) {

View file

@ -38,6 +38,7 @@ import { clearActivityLogState } from "./activity-log.js";
import {
synthesizeCrashRecovery,
getDeepDiagnostic,
readActiveMilestoneId,
} from "./session-forensics.js";
import {
writeLock,
@ -76,13 +77,6 @@ import {
import { closeoutUnit } from "./auto-unit-closeout.js";
import { recoverTimedOutUnit } from "./auto-timeout-recovery.js";
import { selectAndApplyModel, resolveModelId } from "./auto-model-selection.js";
import {
syncProjectRootToWorktree,
syncStateToProjectRoot,
readResourceVersion,
checkResourcesStale,
escapeStaleWorktree,
} from "./auto-worktree-sync.js";
import { resetRoutingHistory, recordOutcome } from "./routing-history.js";
import {
checkPostUnitHooks,
@ -143,6 +137,11 @@ import {
mergeMilestoneToMain,
autoWorktreeBranch,
syncWorktreeStateBack,
syncProjectRootToWorktree,
syncStateToProjectRoot,
readResourceVersion,
checkResourcesStale,
escapeStaleWorktree,
} from "./auto-worktree.js";
import { pruneQueueOrder } from "./queue-order.js";
@ -190,8 +189,6 @@ import {
} from "./worktree-resolver.js";
import { reorderForCaching } from "./prompt-ordering.js";
// Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts
// ─── Session State ─────────────────────────────────────────────────────────
import {
@ -980,7 +977,11 @@ function buildLoopDeps(): LoopDeps {
startUnitSupervision,
// Prompt helpers
getDeepDiagnostic,
getDeepDiagnostic: (basePath: string) => {
const mid = readActiveMilestoneId(basePath);
const wtPath = mid ? getAutoWorktreePath(basePath, mid) : undefined;
return getDeepDiagnostic(basePath, wtPath ?? undefined);
},
isDbAvailable,
reorderForCaching,

View file

@ -1112,4 +1112,97 @@ export function registerDbTools(pi: ExtensionAPI): void {
pi.registerTool(reassessRoadmapTool);
registerAlias(pi, reassessRoadmapTool, "gsd_roadmap_reassess", "gsd_reassess_roadmap");
// ─── gsd_save_gate_result ──────────────────────────────────────────────
const saveGateResultExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => {
const dbAvailable = await ensureDbOpen();
if (!dbAvailable) {
return {
content: [{ type: "text" as const, text: "Error: GSD database is not available." }],
details: { operation: "save_gate_result", error: "db_unavailable" } as any,
};
}
const validGates = ["Q3", "Q4", "Q5", "Q6", "Q7", "Q8"];
if (!validGates.includes(params.gateId)) {
return {
content: [{ type: "text" as const, text: `Error: Invalid gateId "${params.gateId}". Must be one of: ${validGates.join(", ")}` }],
details: { operation: "save_gate_result", error: "invalid_gate_id" } as any,
};
}
const validVerdicts = ["pass", "flag", "omitted"];
if (!validVerdicts.includes(params.verdict)) {
return {
content: [{ type: "text" as const, text: `Error: Invalid verdict "${params.verdict}". Must be one of: ${validVerdicts.join(", ")}` }],
details: { operation: "save_gate_result", error: "invalid_verdict" } as any,
};
}
try {
const { saveGateResult } = await import("../gsd-db.js");
const { invalidateStateCache } = await import("../state.js");
saveGateResult({
milestoneId: params.milestoneId,
sliceId: params.sliceId,
gateId: params.gateId,
taskId: params.taskId ?? "",
verdict: params.verdict,
rationale: params.rationale,
findings: params.findings ?? "",
});
invalidateStateCache();
return {
content: [{ type: "text" as const, text: `Gate ${params.gateId} result saved: verdict=${params.verdict}` }],
details: { operation: "save_gate_result", gateId: params.gateId, verdict: params.verdict } as any,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError("tool", `gsd_save_gate_result failed: ${msg}`, { tool: "gsd_save_gate_result", error: String(err) });
return {
content: [{ type: "text" as const, text: `Error saving gate result: ${msg}` }],
details: { operation: "save_gate_result", error: msg } as any,
};
}
};
const saveGateResultTool = {
name: "gsd_save_gate_result",
label: "Save Gate Result",
description:
"Save the result of a quality gate evaluation (Q3-Q8) to the GSD database. " +
"Called by gate evaluation sub-agents after analyzing a specific quality question.",
promptSnippet: "Save quality gate evaluation result (verdict, rationale, findings)",
promptGuidelines: [
"Use gsd_save_gate_result after evaluating a quality gate question.",
"gateId must be one of: Q3, Q4, Q5, Q6, Q7, Q8.",
"verdict must be: pass (no concerns), flag (concerns found), or omitted (not applicable).",
"rationale should be a one-sentence justification for the verdict.",
"findings should contain detailed markdown analysis (or empty string if omitted).",
],
parameters: Type.Object({
milestoneId: Type.String({ description: "Milestone ID (e.g. M001)" }),
sliceId: Type.String({ description: "Slice ID (e.g. S01)" }),
gateId: Type.String({ description: "Gate ID: Q3, Q4, Q5, Q6, Q7, or Q8" }),
taskId: Type.Optional(Type.String({ description: "Task ID for task-scoped gates (Q5/Q6/Q7)" })),
verdict: Type.String({ description: "pass, flag, or omitted" }),
rationale: Type.String({ description: "One-sentence justification" }),
findings: Type.Optional(Type.String({ description: "Detailed markdown findings" })),
}),
execute: saveGateResultExecute,
renderCall(args: any, theme: any) {
let text = theme.fg("toolTitle", theme.bold("save_gate_result "));
text += theme.fg("accent", args.gateId ?? "");
text += theme.fg("dim", `${args.verdict ?? ""}`);
return new Text(text, 0, 0);
},
renderResult(result: any, _options: any, theme: any) {
const d = result.details;
if (result.isError || d?.error) {
return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0);
}
const color = d?.verdict === "flag" ? "warning" : "success";
return new Text(theme.fg(color, `${d?.gateId}: ${d?.verdict}`), 0, 0);
},
};
pi.registerTool(saveGateResultTool);
}

View file

@ -0,0 +1,16 @@
/**
* GSD branch naming patterns single source of truth.
*
* gsd/<worktree>/<milestone>/<slice> SLICE_BRANCH_RE
* gsd/quick/<id>-<slug> QUICK_BRANCH_RE
* gsd/<workflow>/<...> WORKFLOW_BRANCH_RE (non-milestone gsd/ branches)
*/
/** Matches gsd/ slice branches: gsd/[worktree/]M001[-hash]/S01 */
export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+(?:-[a-z0-9]{6})?)\/(S\d+)$/;
/** Matches gsd/quick/ task branches */
export const QUICK_BRANCH_RE = /^gsd\/quick\//;
/** Matches gsd/ workflow branches (non-milestone, e.g. gsd/workflow-name/...) */
export const WORKFLOW_BRANCH_RE = /^gsd\/(?!M\d)[\w-]+\//;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,182 @@
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import type { DoctorIssue } from "./doctor-types.js";
import { isDbAvailable, _getAdapter } from "./gsd-db.js";
import { resolveMilestoneFile } from "./paths.js";
import { deriveState } from "./state.js";
import { readEvents } from "./workflow-events.js";
import { renderAllProjections } from "./workflow-projections.js";
export async function checkEngineHealth(
basePath: string,
issues: DoctorIssue[],
fixesApplied: string[],
): Promise<void> {
// ── DB constraint violation detection (full doctor only, not pre-dispatch per D-10) ──
try {
if (isDbAvailable()) {
const adapter = _getAdapter()!;
// a. Orphaned tasks (task.slice_id points to non-existent slice)
try {
const orphanedTasks = adapter
.prepare(
`SELECT t.id, t.slice_id, t.milestone_id
FROM tasks t
LEFT JOIN slices s ON t.milestone_id = s.milestone_id AND t.slice_id = s.id
WHERE s.id IS NULL`,
)
.all() as Array<{ id: string; slice_id: string; milestone_id: string }>;
for (const row of orphanedTasks) {
issues.push({
severity: "error",
code: "db_orphaned_task",
scope: "task",
unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`,
message: `Task ${row.id} references slice ${row.slice_id} in milestone ${row.milestone_id} but no such slice exists in the database`,
fixable: false,
});
}
} catch {
// Non-fatal — orphaned task check failed
}
// b. Orphaned slices (slice.milestone_id points to non-existent milestone)
try {
const orphanedSlices = adapter
.prepare(
`SELECT s.id, s.milestone_id
FROM slices s
LEFT JOIN milestones m ON s.milestone_id = m.id
WHERE m.id IS NULL`,
)
.all() as Array<{ id: string; milestone_id: string }>;
for (const row of orphanedSlices) {
issues.push({
severity: "error",
code: "db_orphaned_slice",
scope: "slice",
unitId: `${row.milestone_id}/${row.id}`,
message: `Slice ${row.id} references milestone ${row.milestone_id} but no such milestone exists in the database`,
fixable: false,
});
}
} catch {
// Non-fatal — orphaned slice check failed
}
// c. Tasks marked complete without summaries
try {
const doneTasks = adapter
.prepare(
`SELECT id, slice_id, milestone_id FROM tasks
WHERE status = 'done' AND (summary IS NULL OR summary = '')`,
)
.all() as Array<{ id: string; slice_id: string; milestone_id: string }>;
for (const row of doneTasks) {
issues.push({
severity: "warning",
code: "db_done_task_no_summary",
scope: "task",
unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`,
message: `Task ${row.id} is marked done but has no summary in the database`,
fixable: false,
});
}
} catch {
// Non-fatal — done-task-no-summary check failed
}
// d. Duplicate entity IDs (safety check)
try {
const dupMilestones = adapter
.prepare("SELECT id, COUNT(*) as cnt FROM milestones GROUP BY id HAVING cnt > 1")
.all() as Array<{ id: string; cnt: number }>;
for (const row of dupMilestones) {
issues.push({
severity: "error",
code: "db_duplicate_id",
scope: "milestone",
unitId: row.id,
message: `Duplicate milestone ID "${row.id}" appears ${row.cnt} times in the database`,
fixable: false,
});
}
const dupSlices = adapter
.prepare("SELECT id, milestone_id, COUNT(*) as cnt FROM slices GROUP BY id, milestone_id HAVING cnt > 1")
.all() as Array<{ id: string; milestone_id: string; cnt: number }>;
for (const row of dupSlices) {
issues.push({
severity: "error",
code: "db_duplicate_id",
scope: "slice",
unitId: `${row.milestone_id}/${row.id}`,
message: `Duplicate slice ID "${row.id}" in milestone ${row.milestone_id} appears ${row.cnt} times`,
fixable: false,
});
}
const dupTasks = adapter
.prepare("SELECT id, slice_id, milestone_id, COUNT(*) as cnt FROM tasks GROUP BY id, slice_id, milestone_id HAVING cnt > 1")
.all() as Array<{ id: string; slice_id: string; milestone_id: string; cnt: number }>;
for (const row of dupTasks) {
issues.push({
severity: "error",
code: "db_duplicate_id",
scope: "task",
unitId: `${row.milestone_id}/${row.slice_id}/${row.id}`,
message: `Duplicate task ID "${row.id}" in slice ${row.slice_id} appears ${row.cnt} times`,
fixable: false,
});
}
} catch {
// Non-fatal — duplicate ID check failed
}
}
} catch {
// Non-fatal — DB constraint checks failed entirely
}
// ── Projection drift detection ──────────────────────────────────────────
// If the DB is available, check whether markdown projections are stale
// relative to the event log and re-render them.
try {
if (isDbAvailable()) {
const eventLogPath = join(basePath, ".gsd", "event-log.jsonl");
const events = readEvents(eventLogPath);
if (events.length > 0) {
const lastEventTs = new Date(events[events.length - 1]!.ts).getTime();
const state = await deriveState(basePath);
for (const milestone of state.registry) {
if (milestone.status === "complete") continue;
const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP");
if (!roadmapPath || !existsSync(roadmapPath)) {
try {
await renderAllProjections(basePath, milestone.id);
fixesApplied.push(`re-rendered missing projections for ${milestone.id}`);
} catch {
// Non-fatal — projection re-render failed
}
continue;
}
const projectionMtime = statSync(roadmapPath).mtimeMs;
if (lastEventTs > projectionMtime) {
try {
await renderAllProjections(basePath, milestone.id);
fixesApplied.push(`re-rendered stale projections for ${milestone.id}`);
} catch {
// Non-fatal — projection re-render failed
}
}
}
}
}
} catch {
// Non-fatal — projection drift check must never block doctor
}
}

View file

@ -0,0 +1,415 @@
import { existsSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
import { join, sep } from "node:path";
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
import { loadFile } from "./files.js";
import { parseRoadmap as parseLegacyRoadmap } from "./parsers-legacy.js";
import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js";
import { resolveMilestoneFile } from "./paths.js";
import { deriveState, isMilestoneComplete } from "./state.js";
import { listWorktrees, resolveGitDir, worktreesDir } from "./worktree-manager.js";
import { abortAndReset } from "./git-self-heal.js";
import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js";
import { nativeIsRepo, nativeWorktreeList, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
import { getAllWorktreeHealth } from "./worktree-health.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
export async function checkGitHealth(
basePath: string,
issues: DoctorIssue[],
fixesApplied: string[],
shouldFix: (code: DoctorIssueCode) => boolean,
isolationMode: "none" | "worktree" | "branch" = "none",
): Promise<void> {
// Degrade gracefully if not a git repo
if (!nativeIsRepo(basePath)) {
return; // Not a git repo — skip all git health checks
}
const gitDir = resolveGitDir(basePath);
// ── Orphaned auto-worktrees & Stale milestone branches ────────────────
// These checks only apply in worktree/branch modes — skip in none mode
// where no milestone worktrees or branches are created.
if (isolationMode !== "none") {
try {
const worktrees = listWorktrees(basePath);
const milestoneWorktrees = worktrees.filter(wt => wt.branch.startsWith("milestone/"));
// Load roadmap state once for cross-referencing
const state = await deriveState(basePath);
for (const wt of milestoneWorktrees) {
// Extract milestone ID from branch name "milestone/M001" → "M001"
const milestoneId = wt.branch.replace(/^milestone\//, "");
const milestoneEntry = state.registry.find(m => m.id === milestoneId);
// Check if milestone is complete via roadmap
let isComplete = false;
if (milestoneEntry) {
if (isDbAvailable()) {
const dbSlices = getMilestoneSlices(milestoneId);
isComplete = dbSlices.length > 0 && dbSlices.every(s => s.status === "complete");
} else {
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
if (roadmapContent) {
const roadmap = parseLegacyRoadmap(roadmapContent);
isComplete = isMilestoneComplete(roadmap);
}
}
// When DB unavailable and no roadmap, isComplete stays false
}
if (isComplete) {
issues.push({
severity: "warning",
code: "orphaned_auto_worktree",
scope: "milestone",
unitId: milestoneId,
message: `Worktree for completed milestone ${milestoneId} still exists at ${wt.path}`,
fixable: true,
});
if (shouldFix("orphaned_auto_worktree")) {
// If cwd is inside the worktree, chdir out first — matching the
// pattern in removeWorktree() (#1946). Without this, git cannot
// remove the worktree and the doctor enters a deadlock where it
// detects the orphan every run but never cleans it up.
const cwd = process.cwd();
if (wt.path === cwd || cwd.startsWith(wt.path + sep)) {
try {
process.chdir(basePath);
} catch {
fixesApplied.push(`skipped removing worktree at ${wt.path} (cannot chdir to basePath)`);
continue;
}
}
try {
nativeWorktreeRemove(basePath, wt.path, true);
fixesApplied.push(`removed orphaned worktree ${wt.path}`);
} catch {
fixesApplied.push(`failed to remove worktree ${wt.path}`);
}
}
}
}
// ── Stale milestone branches ─────────────────────────────────────────
try {
const branches = nativeBranchList(basePath, "milestone/*");
if (branches.length > 0) {
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
for (const branch of branches) {
// Skip branches that have a worktree (handled above)
if (worktreeBranches.has(branch)) continue;
const milestoneId = branch.replace(/^milestone\//, "");
const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
let branchMilestoneComplete = false;
if (isDbAvailable()) {
const dbSlices = getMilestoneSlices(milestoneId);
branchMilestoneComplete = dbSlices.length > 0 && dbSlices.every(s => s.status === "complete");
} else {
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
if (!roadmapContent) continue;
const roadmap = parseLegacyRoadmap(roadmapContent);
branchMilestoneComplete = isMilestoneComplete(roadmap);
}
if (branchMilestoneComplete) {
issues.push({
severity: "info",
code: "stale_milestone_branch",
scope: "milestone",
unitId: milestoneId,
message: `Branch ${branch} exists for completed milestone ${milestoneId}`,
fixable: true,
});
if (shouldFix("stale_milestone_branch")) {
try {
nativeBranchDelete(basePath, branch, true);
fixesApplied.push(`deleted stale branch ${branch}`);
} catch {
fixesApplied.push(`failed to delete branch ${branch}`);
}
}
}
}
}
} catch {
// git branch list failed — skip stale branch check
}
} catch {
// listWorktrees or deriveState failed — skip worktree/branch checks
}
} // end isolationMode !== "none"
// ── Corrupt merge state ────────────────────────────────────────────────
try {
const mergeStateFiles = ["MERGE_HEAD", "SQUASH_MSG"];
const mergeStateDirs = ["rebase-apply", "rebase-merge"];
const found: string[] = [];
for (const f of mergeStateFiles) {
if (existsSync(join(gitDir, f))) found.push(f);
}
for (const d of mergeStateDirs) {
if (existsSync(join(gitDir, d))) found.push(d);
}
if (found.length > 0) {
issues.push({
severity: "error",
code: "corrupt_merge_state",
scope: "project",
unitId: "project",
message: `Corrupt merge/rebase state detected: ${found.join(", ")}`,
fixable: true,
});
if (shouldFix("corrupt_merge_state")) {
const result = abortAndReset(basePath);
fixesApplied.push(`cleaned merge state: ${result.cleaned.join(", ")}`);
}
}
} catch {
// Can't check .git dir — skip
}
// ── Tracked runtime files ──────────────────────────────────────────────
try {
const trackedPaths: string[] = [];
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
try {
const files = nativeLsFiles(basePath, exclusion);
if (files.length > 0) {
trackedPaths.push(...files);
}
} catch {
// Individual ls-files can fail — continue
}
}
if (trackedPaths.length > 0) {
issues.push({
severity: "warning",
code: "tracked_runtime_files",
scope: "project",
unitId: "project",
message: `${trackedPaths.length} runtime file(s) are tracked by git: ${trackedPaths.slice(0, 5).join(", ")}${trackedPaths.length > 5 ? "..." : ""}`,
fixable: true,
});
if (shouldFix("tracked_runtime_files")) {
try {
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
nativeRmCached(basePath, [exclusion]);
}
fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
} catch {
fixesApplied.push("failed to untrack runtime files");
}
}
}
} catch {
// git ls-files failed — skip
}
// ── Legacy slice branches ──────────────────────────────────────────────
try {
const branchList = nativeBranchList(basePath, "gsd/*/*")
.filter((branch) => !branch.startsWith("gsd/quick/"));
if (branchList.length > 0) {
issues.push({
severity: "info",
code: "legacy_slice_branches",
scope: "project",
unitId: "project",
message: `${branchList.length} legacy slice branch(es) found: ${branchList.slice(0, 3).join(", ")}${branchList.length > 3 ? "..." : ""}. These are no longer used (branchless architecture).`,
fixable: true,
});
if (shouldFix("legacy_slice_branches")) {
let deleted = 0;
for (const branch of branchList) {
try {
nativeBranchDelete(basePath, branch, true);
deleted++;
} catch { /* skip branches that can't be deleted */ }
}
if (deleted > 0) {
fixesApplied.push(`deleted ${deleted} legacy slice branch(es)`);
}
}
}
} catch {
// git branch list failed — skip
}
// ── Integration branch existence ──────────────────────────────────────
// For each active (non-complete) milestone, verify the stored integration
// branch still exists in git. A missing integration branch blocks merge-back
// and causes the next merge operation to fail silently.
try {
const state = await deriveState(basePath);
const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
for (const milestone of state.registry) {
if (milestone.status === "complete") continue;
const resolution = resolveMilestoneIntegrationBranch(basePath, milestone.id, gitPrefs);
if (!resolution.recordedBranch) continue; // No stored branch — skip (not yet set)
if (resolution.status === "fallback" && resolution.effectiveBranch) {
issues.push({
severity: "warning",
code: "integration_branch_missing",
scope: "milestone",
unitId: milestone.id,
message: resolution.reason,
fixable: true,
});
if (shouldFix("integration_branch_missing")) {
writeIntegrationBranch(basePath, milestone.id, resolution.effectiveBranch);
fixesApplied.push(`updated integration branch for ${milestone.id} to "${resolution.effectiveBranch}"`);
}
continue;
}
if (resolution.status === "missing") {
issues.push({
severity: "error",
code: "integration_branch_missing",
scope: "milestone",
unitId: milestone.id,
message: resolution.reason,
fixable: false,
});
}
}
} catch {
// Non-fatal — integration branch check failed
}
// ── Orphaned worktree directories ────────────────────────────────────
// Worktree removal can fail after a branch delete, leaving a directory
// that is no longer registered with git. These orphaned dirs cause
// "already exists" errors when re-creating the same worktree name.
try {
const wtDir = worktreesDir(basePath);
if (existsSync(wtDir)) {
// Resolve symlinks and normalize separators so that symlinked .gsd
// paths (e.g. ~/.gsd/projects/<hash>/worktrees/…) match the paths
// returned by `git worktree list`.
const normalizePath = (p: string): string => {
try { p = realpathSync(p); } catch { /* path may not exist */ }
return p.replaceAll("\\", "/");
};
const registeredPaths = new Set(
nativeWorktreeList(basePath).map(entry => normalizePath(entry.path)),
);
for (const entry of readdirSync(wtDir)) {
const fullPath = join(wtDir, entry);
try {
if (!statSync(fullPath).isDirectory()) continue;
} catch { continue; }
const normalizedFullPath = normalizePath(fullPath);
if (!registeredPaths.has(normalizedFullPath)) {
issues.push({
severity: "warning",
code: "worktree_directory_orphaned",
scope: "project",
unitId: entry,
message: `Worktree directory ${fullPath} exists on disk but is not registered with git. Run "git worktree prune" or doctor --fix to remove it.`,
fixable: true,
});
if (shouldFix("worktree_directory_orphaned")) {
try {
rmSync(fullPath, { recursive: true, force: true });
fixesApplied.push(`removed orphaned worktree directory ${fullPath}`);
} catch {
fixesApplied.push(`failed to remove orphaned worktree directory ${fullPath}`);
}
}
}
}
}
} catch {
// Non-fatal — orphaned worktree directory check failed
}
// ── Worktree lifecycle checks ──────────────────────────────────────────
// Check GSD-managed worktrees for: merged branches, stale work, dirty
// state, and unpushed commits. Only worktrees under .gsd/worktrees/.
try {
const healthStatuses = getAllWorktreeHealth(basePath);
const cwd = process.cwd();
for (const health of healthStatuses) {
const wt = health.worktree;
const isCwd = wt.path === cwd || cwd.startsWith(wt.path + sep);
// Branch fully merged into main — safe to remove
if (health.mergedIntoMain) {
issues.push({
severity: "info",
code: "worktree_branch_merged",
scope: "project",
unitId: wt.name,
message: `Worktree "${wt.name}" (branch ${wt.branch}) is fully merged into main${health.safeToRemove ? " — safe to remove" : ""}`,
fixable: health.safeToRemove,
});
if (health.safeToRemove && shouldFix("worktree_branch_merged") && !isCwd) {
try {
const { removeWorktree } = await import("./worktree-manager.js");
removeWorktree(basePath, wt.name, { deleteBranch: true, branch: wt.branch });
fixesApplied.push(`removed merged worktree "${wt.name}" and deleted branch ${wt.branch}`);
} catch {
fixesApplied.push(`failed to remove merged worktree "${wt.name}"`);
}
}
// If merged, skip the stale/dirty/unpushed checks — they're irrelevant
continue;
}
// Stale: no commits in N days, not merged
if (health.stale) {
const days = Math.floor(health.lastCommitAgeDays);
issues.push({
severity: "warning",
code: "worktree_stale",
scope: "project",
unitId: wt.name,
message: `Worktree "${wt.name}" has had no commits in ${days} day${days === 1 ? "" : "s"}`,
fixable: false,
});
}
// Dirty: uncommitted changes in a worktree (only flag on stale worktrees to avoid noise)
if (health.dirty && health.stale) {
issues.push({
severity: "warning",
code: "worktree_dirty",
scope: "project",
unitId: wt.name,
message: `Worktree "${wt.name}" has ${health.dirtyFileCount} uncommitted file${health.dirtyFileCount === 1 ? "" : "s"} and is stale`,
fixable: false,
});
}
// Unpushed: commits not on any remote (only flag on stale worktrees to avoid noise)
if (health.unpushedCommits > 0 && health.stale) {
issues.push({
severity: "warning",
code: "worktree_unpushed",
scope: "project",
unitId: wt.name,
message: `Worktree "${wt.name}" has ${health.unpushedCommits} unpushed commit${health.unpushedCommits === 1 ? "" : "s"}`,
fixable: false,
});
}
}
} catch {
// Non-fatal — worktree lifecycle check failed
}
}

View file

@ -0,0 +1,84 @@
import { existsSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
import { readRepoMeta, externalProjectsRoot } from "./repo-identity.js";
/**
* Check for orphaned project state directories in ~/.gsd/projects/.
*
* A project directory is orphaned when its recorded gitRoot no longer exists
* on disk the repo was deleted, moved, or the external drive was unmounted.
* These directories accumulate silently and waste disk space.
*
* Severity: info orphaned state is harmless but takes disk space.
* Fixable: yes rmSync the directory. Never auto-fixed at fixLevel="task".
*/
export async function checkGlobalHealth(
issues: DoctorIssue[],
fixesApplied: string[],
shouldFix: (code: DoctorIssueCode) => boolean,
): Promise<void> {
try {
const projectsDir = externalProjectsRoot();
if (!existsSync(projectsDir)) return;
let entries: string[];
try {
entries = readdirSync(projectsDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name);
} catch {
return; // Can't read directory — skip
}
if (entries.length === 0) return;
const orphaned: Array<{ hash: string; gitRoot: string; remoteUrl: string }> = [];
let unknownCount = 0;
for (const hash of entries) {
const dirPath = join(projectsDir, hash);
const meta = readRepoMeta(dirPath);
if (!meta) {
unknownCount++;
continue;
}
if (!existsSync(meta.gitRoot)) {
orphaned.push({ hash, gitRoot: meta.gitRoot, remoteUrl: meta.remoteUrl });
}
}
if (orphaned.length === 0) return;
const labels = orphaned.slice(0, 3).map(o => o.gitRoot).join(", ");
const overflow = orphaned.length > 3 ? ` (+${orphaned.length - 3} more)` : "";
const unknownNote = unknownCount > 0 ? `${unknownCount} additional director${unknownCount === 1 ? "y" : "ies"} have no metadata yet (open those repos once to register them)` : "";
issues.push({
severity: "info",
code: "orphaned_project_state",
scope: "project",
unitId: "global",
message: `${orphaned.length} orphaned GSD project state director${orphaned.length === 1 ? "y" : "ies"} in ${projectsDir} whose git root no longer exists: ${labels}${overflow}${unknownNote}. Run /gsd cleanup projects to audit or /gsd cleanup projects --fix to reclaim disk space.`,
file: projectsDir,
fixable: true,
});
if (shouldFix("orphaned_project_state")) {
let removed = 0;
for (const { hash } of orphaned) {
try {
rmSync(join(projectsDir, hash), { recursive: true, force: true });
removed++;
} catch {
// Individual removal failure is non-fatal — continue with remaining
}
}
fixesApplied.push(`removed ${removed} orphaned project state director${removed === 1 ? "y" : "ies"} from ${projectsDir}`);
}
} catch {
// Non-fatal — global health check must not block per-project doctor
}
}

View file

@ -0,0 +1,626 @@
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
import { cleanNumberedGsdVariants } from "./repo-identity.js";
import { milestonesDir, gsdRoot, resolveGsdRootFile } from "./paths.js";
import { deriveState } from "./state.js";
import { saveFile } from "./files.js";
import { nativeIsRepo, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js";
import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js";
import { ensureGitignore } from "./gitignore.js";
import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js";
import { recoverFailedMigration } from "./migrate-external.js";
export async function checkRuntimeHealth(
basePath: string,
issues: DoctorIssue[],
fixesApplied: string[],
shouldFix: (code: DoctorIssueCode) => boolean,
): Promise<void> {
const root = gsdRoot(basePath);
// ── Stale crash lock ──────────────────────────────────────────────────
try {
const lock = readCrashLock(basePath);
if (lock) {
const alive = isLockProcessAlive(lock);
if (!alive) {
issues.push({
severity: "error",
code: "stale_crash_lock",
scope: "project",
unitId: "project",
message: `Stale auto.lock from PID ${lock.pid} (started ${lock.startedAt}, was executing ${lock.unitType} ${lock.unitId}) — process is no longer running`,
file: ".gsd/auto.lock",
fixable: true,
});
if (shouldFix("stale_crash_lock")) {
clearLock(basePath);
fixesApplied.push("cleared stale auto.lock");
}
}
}
} catch {
// Non-fatal — crash lock check failed
}
// ── Stranded lock directory ────────────────────────────────────────────
// proper-lockfile creates a `.gsd.lock/` directory as the OS-level lock
// mechanism. If the process was SIGKILLed or crashed hard, this directory
// can remain on disk without any live process holding it. The next session
// fails to acquire the lock until the directory is removed (#1245).
try {
const lockDir = join(dirname(root), `${basename(root)}.lock`);
if (existsSync(lockDir)) {
const statRes = statSync(lockDir);
if (statRes.isDirectory()) {
// Check if any live process actually holds this lock
const lock = readCrashLock(basePath);
const lockHolderAlive = lock ? isLockProcessAlive(lock) : false;
if (!lockHolderAlive) {
issues.push({
severity: "error",
code: "stranded_lock_directory",
scope: "project",
unitId: "project",
message: `Stranded lock directory "${lockDir}" exists but no live process holds the session lock. This blocks new auto-mode sessions from starting.`,
file: lockDir,
fixable: true,
});
if (shouldFix("stranded_lock_directory")) {
try {
rmSync(lockDir, { recursive: true, force: true });
fixesApplied.push(`removed stranded lock directory ${lockDir}`);
} catch {
fixesApplied.push(`failed to remove stranded lock directory ${lockDir}`);
}
}
}
}
}
} catch {
// Non-fatal — stranded lock directory check failed
}
// ── Stale parallel sessions ────────────────────────────────────────────
try {
const parallelStatuses = readAllSessionStatuses(basePath);
for (const status of parallelStatuses) {
if (isSessionStale(status)) {
issues.push({
severity: "warning",
code: "stale_parallel_session",
scope: "project",
unitId: status.milestoneId,
message: `Stale parallel session for ${status.milestoneId} (PID ${status.pid}, started ${new Date(status.startedAt).toISOString()}, last heartbeat ${new Date(status.lastHeartbeat).toISOString()}) — process is no longer running`,
file: `.gsd/parallel/${status.milestoneId}.status.json`,
fixable: true,
});
if (shouldFix("stale_parallel_session")) {
removeSessionStatus(basePath, status.milestoneId);
fixesApplied.push(`cleaned up stale parallel session for ${status.milestoneId}`);
}
}
}
} catch {
// Non-fatal — parallel session check failed
}
// ── Orphaned completed-units keys ─────────────────────────────────────
try {
const completedKeysFile = join(root, "completed-units.json");
if (existsSync(completedKeysFile)) {
const raw = readFileSync(completedKeysFile, "utf-8");
const keys: string[] = JSON.parse(raw);
const orphaned: string[] = [];
for (const key of keys) {
// Key format: "unitType/unitId" e.g. "execute-task/M001/S01/T01"
const slashIdx = key.indexOf("/");
if (slashIdx === -1) continue;
const unitType = key.slice(0, slashIdx);
const unitId = key.slice(slashIdx + 1);
// Only validate artifact-producing unit types
const { verifyExpectedArtifact } = await import("./auto-recovery.js");
if (!verifyExpectedArtifact(unitType, unitId, basePath)) {
orphaned.push(key);
}
}
if (orphaned.length > 0) {
issues.push({
severity: "warning",
code: "orphaned_completed_units",
scope: "project",
unitId: "project",
message: `${orphaned.length} completed-unit key(s) reference missing artifacts: ${orphaned.slice(0, 3).join(", ")}${orphaned.length > 3 ? "..." : ""}`,
file: ".gsd/completed-units.json",
fixable: true,
});
if (shouldFix("orphaned_completed_units")) {
const orphanedSet = new Set(orphaned);
const remaining = keys.filter((key) => !orphanedSet.has(key));
await saveFile(completedKeysFile, JSON.stringify(remaining));
fixesApplied.push(`removed ${orphaned.length} orphaned completed-unit key(s)`);
}
}
}
} catch {
// Non-fatal — completed-units check failed
}
// ── Stale hook state ──────────────────────────────────────────────────
try {
const hookStateFile = join(root, "hook-state.json");
if (existsSync(hookStateFile)) {
const raw = readFileSync(hookStateFile, "utf-8");
const state = JSON.parse(raw);
const hasCycleCounts = state.cycleCounts && typeof state.cycleCounts === "object"
&& Object.keys(state.cycleCounts).length > 0;
// Only flag if there are actual cycle counts AND no auto-mode is running
if (hasCycleCounts) {
const lock = readCrashLock(basePath);
const autoRunning = lock ? isLockProcessAlive(lock) : false;
if (!autoRunning) {
issues.push({
severity: "info",
code: "stale_hook_state",
scope: "project",
unitId: "project",
message: `hook-state.json has ${Object.keys(state.cycleCounts).length} residual cycle count(s) from a previous session`,
file: ".gsd/hook-state.json",
fixable: true,
});
if (shouldFix("stale_hook_state")) {
const { clearPersistedHookState } = await import("./post-unit-hooks.js");
clearPersistedHookState(basePath);
fixesApplied.push("cleared stale hook-state.json");
}
}
}
}
} catch {
// Non-fatal — hook state check failed
}
// ── Activity log bloat ────────────────────────────────────────────────
try {
const activityDir = join(root, "activity");
if (existsSync(activityDir)) {
const files = readdirSync(activityDir);
let totalSize = 0;
for (const f of files) {
try {
totalSize += statSync(join(activityDir, f)).size;
} catch {
// stat failed — skip
}
}
const totalMB = totalSize / (1024 * 1024);
const BLOAT_FILE_THRESHOLD = 500;
const BLOAT_SIZE_MB = 100;
if (files.length > BLOAT_FILE_THRESHOLD || totalMB > BLOAT_SIZE_MB) {
issues.push({
severity: "warning",
code: "activity_log_bloat",
scope: "project",
unitId: "project",
message: `Activity logs: ${files.length} files, ${totalMB.toFixed(1)}MB (thresholds: ${BLOAT_FILE_THRESHOLD} files / ${BLOAT_SIZE_MB}MB)`,
file: ".gsd/activity/",
fixable: true,
});
if (shouldFix("activity_log_bloat")) {
const { pruneActivityLogs } = await import("./activity-log.js");
pruneActivityLogs(activityDir, 7); // 7-day retention
fixesApplied.push("pruned activity logs (7-day retention)");
}
}
}
} catch {
// Non-fatal — activity log check failed
}
// ── STATE.md health ───────────────────────────────────────────────────
try {
const stateFilePath = resolveGsdRootFile(basePath, "STATE");
const milestonesPath = milestonesDir(basePath);
if (existsSync(milestonesPath)) {
if (!existsSync(stateFilePath)) {
issues.push({
severity: "warning",
code: "state_file_missing",
scope: "project",
unitId: "project",
message: "STATE.md is missing — state display will not work",
file: ".gsd/STATE.md",
fixable: true,
});
if (shouldFix("state_file_missing")) {
const state = await deriveState(basePath);
await saveFile(stateFilePath, buildStateMarkdownForCheck(state));
fixesApplied.push("created STATE.md from derived state");
}
} else {
// Check if STATE.md is stale by comparing active milestone/slice/phase
const currentContent = readFileSync(stateFilePath, "utf-8");
const state = await deriveState(basePath);
const freshContent = buildStateMarkdownForCheck(state);
// Extract key fields for comparison — don't compare full content
// since timestamp/formatting differences are normal
const extractFields = (content: string) => {
const milestone = content.match(/\*\*Active Milestone:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
const slice = content.match(/\*\*Active Slice:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
const phase = content.match(/\*\*Phase:\*\*\s*(.+)/)?.[1]?.trim() ?? "";
return { milestone, slice, phase };
};
const current = extractFields(currentContent);
const fresh = extractFields(freshContent);
if (current.milestone !== fresh.milestone || current.slice !== fresh.slice || current.phase !== fresh.phase) {
issues.push({
severity: "warning",
code: "state_file_stale",
scope: "project",
unitId: "project",
message: `STATE.md is stale — shows "${current.phase}" but derived state is "${fresh.phase}"`,
file: ".gsd/STATE.md",
fixable: true,
});
if (shouldFix("state_file_stale")) {
await saveFile(stateFilePath, freshContent);
fixesApplied.push("rebuilt STATE.md from derived state");
}
}
}
}
} catch {
// Non-fatal — STATE.md check failed
}
// ── Gitignore drift ───────────────────────────────────────────────────
try {
const gitignorePath = join(basePath, ".gitignore");
if (existsSync(gitignorePath) && nativeIsRepo(basePath)) {
const content = readFileSync(gitignorePath, "utf-8");
const existingLines = new Set(
content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#")),
);
// Check for critical runtime patterns that must be present
const criticalPatterns = [
".gsd/activity/",
".gsd/runtime/",
".gsd/auto.lock",
".gsd/gsd.db",
".gsd/completed-units.json",
];
// If blanket .gsd/ or .gsd is present, all patterns are covered
const hasBlanketIgnore = existingLines.has(".gsd/") || existingLines.has(".gsd");
if (!hasBlanketIgnore) {
const missing = criticalPatterns.filter(p => !existingLines.has(p));
if (missing.length > 0) {
issues.push({
severity: "warning",
code: "gitignore_missing_patterns",
scope: "project",
unitId: "project",
message: `${missing.length} critical GSD runtime pattern(s) missing from .gitignore: ${missing.join(", ")}`,
file: ".gitignore",
fixable: true,
});
if (shouldFix("gitignore_missing_patterns")) {
ensureGitignore(basePath);
fixesApplied.push("added missing GSD runtime patterns to .gitignore");
}
}
}
}
} catch {
// Non-fatal — gitignore check failed
}
// ── External state symlink health ──────────────────────────────────────
try {
const localGsd = join(basePath, ".gsd");
if (existsSync(localGsd)) {
const stat = lstatSync(localGsd);
// Check for .gsd.migrating (failed migration)
const migratingPath = join(basePath, ".gsd.migrating");
if (existsSync(migratingPath)) {
issues.push({
severity: "error",
code: "failed_migration",
scope: "project",
unitId: "project",
message: "Found .gsd.migrating — a previous external state migration failed. State may be incomplete.",
file: ".gsd.migrating",
fixable: true,
});
if (shouldFix("failed_migration")) {
if (recoverFailedMigration(basePath)) {
fixesApplied.push("recovered failed migration (.gsd.migrating → .gsd)");
}
}
}
// Check symlink target exists
if (stat.isSymbolicLink()) {
try {
realpathSync(localGsd);
} catch {
issues.push({
severity: "error",
code: "broken_symlink",
scope: "project",
unitId: "project",
message: ".gsd symlink target does not exist. External state directory may have been deleted.",
file: ".gsd",
fixable: false,
});
}
}
}
} catch {
// Non-fatal — external state check failed
}
// ── Numbered .gsd collision variants (#2205) ───────────────────────────
// macOS APFS can create ".gsd 2", ".gsd 3" etc. when a directory blocks
// symlink creation. These must be removed so the canonical .gsd is used.
try {
const variantPattern = /^\.gsd \d+$/;
const entries = readdirSync(basePath);
const variants = entries.filter(e => variantPattern.test(e));
if (variants.length > 0) {
for (const v of variants) {
issues.push({
severity: "warning",
code: "numbered_gsd_variant",
scope: "project",
unitId: "project",
message: `Found macOS collision variant "${v}" — this can cause GSD state to appear deleted.`,
file: v,
fixable: true,
});
}
if (shouldFix("numbered_gsd_variant")) {
const removed = cleanNumberedGsdVariants(basePath);
for (const name of removed) {
fixesApplied.push(`removed numbered .gsd variant: ${name}`);
}
}
}
} catch {
// Non-fatal — variant check failed
}
// ── Metrics ledger integrity ───────────────────────────────────────────
try {
const metricsPath = join(root, "metrics.json");
if (existsSync(metricsPath)) {
try {
const raw = readFileSync(metricsPath, "utf-8");
const ledger = JSON.parse(raw);
if (ledger.version !== 1 || !Array.isArray(ledger.units)) {
issues.push({
severity: "warning",
code: "metrics_ledger_corrupt",
scope: "project",
unitId: "project",
message: "metrics.json has an unexpected structure (version !== 1 or units is not an array) — metrics data may be unreliable",
file: ".gsd/metrics.json",
fixable: false,
});
}
} catch {
issues.push({
severity: "warning",
code: "metrics_ledger_corrupt",
scope: "project",
unitId: "project",
message: "metrics.json is not valid JSON — metrics data may be corrupt",
file: ".gsd/metrics.json",
fixable: false,
});
}
}
} catch {
// Non-fatal — metrics check failed
}
// ── Metrics ledger bloat ──────────────────────────────────────────────
// The metrics ledger has no TTL and grows by one entry per completed unit.
// At 50 units/day a project can accumulate tens of thousands of entries over
// months of use. Prune to the newest 1500 when the threshold is exceeded.
try {
const metricsFilePath = join(root, "metrics.json");
if (existsSync(metricsFilePath)) {
try {
const raw = readFileSync(metricsFilePath, "utf-8");
const parsed = JSON.parse(raw);
const BLOAT_UNITS_THRESHOLD = 2000;
if (parsed.version === 1 && Array.isArray(parsed.units) && parsed.units.length > BLOAT_UNITS_THRESHOLD) {
const fileSizeMB = (statSync(metricsFilePath).size / (1024 * 1024)).toFixed(1);
issues.push({
severity: "warning",
code: "metrics_ledger_bloat",
scope: "project",
unitId: "project",
message: `metrics.json has ${parsed.units.length} unit entries (${fileSizeMB}MB) — threshold is ${BLOAT_UNITS_THRESHOLD}. Run /gsd doctor --fix to prune to the newest 1500 entries.`,
file: ".gsd/metrics.json",
fixable: true,
});
if (shouldFix("metrics_ledger_bloat")) {
const { pruneMetricsLedger } = await import("./metrics.js");
const removed = pruneMetricsLedger(basePath, 1500);
fixesApplied.push(`pruned metrics ledger: removed ${removed} oldest entries (${parsed.units.length - removed} remain)`);
}
}
} catch {
// JSON parse failed — already handled by the integrity check above
}
}
} catch {
// Non-fatal — metrics bloat check failed
}
// ── Large planning file detection ──────────────────────────────────────
// Files over 100KB can cause LLM context pressure. Report the worst offenders.
try {
const MAX_FILE_BYTES = 100 * 1024; // 100KB
const milestonesPath = milestonesDir(basePath);
if (existsSync(milestonesPath)) {
const largeFiles: Array<{ path: string; sizeKB: number }> = [];
function scanForLargeFiles(dir: string, depth = 0): void {
if (depth > 6) return;
try {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
try {
const s = statSync(full);
if (s.isDirectory()) { scanForLargeFiles(full, depth + 1); continue; }
if (entry.endsWith(".md") && s.size > MAX_FILE_BYTES) {
largeFiles.push({ path: full.replace(basePath + "/", ""), sizeKB: Math.round(s.size / 1024) });
}
} catch { /* skip entry */ }
}
} catch { /* skip dir */ }
}
scanForLargeFiles(milestonesPath);
if (largeFiles.length > 0) {
largeFiles.sort((a, b) => b.sizeKB - a.sizeKB);
const worst = largeFiles[0]!;
issues.push({
severity: "warning",
code: "large_planning_file",
scope: "project",
unitId: "project",
message: `${largeFiles.length} planning file(s) exceed 100KB — largest: ${worst.path} (${worst.sizeKB}KB). Large files cause LLM context pressure.`,
file: worst.path,
fixable: false,
});
}
}
} catch {
// Non-fatal — large file scan failed
}
// ── Snapshot ref bloat ────────────────────────────────────────────────
// refs/gsd/snapshots/ accumulate over time. Prune to newest 5 per label
// when total count exceeds threshold.
try {
if (nativeIsRepo(basePath)) {
const refs = nativeForEachRef(basePath, "refs/gsd/snapshots/");
if (refs.length > 50) {
issues.push({
severity: "warning",
code: "snapshot_ref_bloat",
scope: "project",
unitId: "project",
message: `${refs.length} snapshot refs found under refs/gsd/snapshots/ — pruning to newest 5 per label will reclaim git storage`,
fixable: true,
});
if (shouldFix("snapshot_ref_bloat")) {
const byLabel = new Map<string, string[]>();
for (const ref of refs) {
const parts = ref.split("/");
const label = parts.slice(0, -1).join("/");
if (!byLabel.has(label)) byLabel.set(label, []);
byLabel.get(label)!.push(ref);
}
let pruned = 0;
for (const [, labelRefs] of byLabel) {
const sorted = labelRefs.sort();
for (const old of sorted.slice(0, -5)) {
try {
nativeUpdateRef(basePath, old);
pruned++;
} catch { /* skip */ }
}
}
if (pruned > 0) {
fixesApplied.push(`pruned ${pruned} old snapshot ref(s)`);
}
}
}
}
} catch {
// Non-fatal — snapshot ref check failed
}
}
/**
* Build STATE.md markdown content from derived state.
* Local helper used by checkRuntimeHealth for STATE.md drift detection and repair.
*/
function buildStateMarkdownForCheck(state: Awaited<ReturnType<typeof deriveState>>): 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 · ${state.requirements.validated} validated · ${state.requirements.deferred} deferred · ${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");
}

View file

@ -18,8 +18,8 @@ import { loadEffectiveGSDPreferences } from "./preferences.js";
import {
detectWorktreeName,
SLICE_BRANCH_RE,
} from "./worktree.js";
import { SLICE_BRANCH_RE, QUICK_BRANCH_RE, WORKFLOW_BRANCH_RE } from "./branch-patterns.js";
import {
nativeGetCurrentBranch,
nativeDetectMainBranch,
@ -243,17 +243,8 @@ export function readIntegrationBranch(basePath: string, milestoneId: string): st
*
* The file is committed immediately so the metadata is persisted in git.
*/
/** Regex matching GSD quick-task branches: gsd/quick/<num>-<slug> */
export const QUICK_BRANCH_RE = /^gsd\/quick\//;
/**
* Matches all GSD workflow-template branches: gsd/<templateId>/<slug>.
*
* Template IDs are lowercase alphanumeric with hyphens (e.g. hotfix, bugfix,
* small-feature, dep-upgrade). The negative lookahead excludes milestone
* branches (gsd/M001/... or gsd/M001-abc123/...) which use SLICE_BRANCH_RE.
*/
export const WORKFLOW_BRANCH_RE = /^gsd\/(?!M\d)[\w-]+\//;
/** Re-export for backward compatibility — canonical definitions in branch-patterns.ts */
export { QUICK_BRANCH_RE, WORKFLOW_BRANCH_RE } from "./branch-patterns.js";
export function writeIntegrationBranch(
basePath: string,
@ -454,11 +445,6 @@ export class GitServiceImpl {
this._milestoneId = milestoneId;
}
/** Convenience wrapper: run git in this repo's basePath. */
private git(args: string[], options: { allowFailure?: boolean; input?: string } = {}): string {
return runGit(this.basePath, args, options);
}
/**
* Smart staging: `git add -A` excluding GSD runtime paths via pathspec.
* Falls back to plain `git add -A` if the exclusion pathspec fails.
@ -617,11 +603,6 @@ export class GitServiceImpl {
return nativeGetCurrentBranch(this.basePath);
}
/** True if currently on a GSD slice branch. */
// ─── Branch Lifecycle ──────────────────────────────────────────────────
// ─── S05 Features ─────────────────────────────────────────────────────
/**
* Create a snapshot ref for the given label (typically a slice branch name).
* Gated on prefs.snapshots === true. Ref path: refs/gsd/snapshots/<label>/<timestamp>
@ -682,8 +663,6 @@ export class GitServiceImpl {
}
}
// ─── Merge ─────────────────────────────────────────────────────────────
}
// ─── Draft PR Creation ─────────────────────────────────────────────────────

View file

@ -8,7 +8,7 @@
import { createRequire } from "node:module";
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { Decision, Requirement } from "./types.js";
import type { Decision, Requirement, GateRow, GateId, GateScope, GateStatus, GateVerdict } from "./types.js";
import { GSDError, GSD_STALE_STATE } from "./errors.js";
const _require = createRequire(import.meta.url);
@ -149,7 +149,7 @@ function openRawDb(path: string): unknown {
return new Database(path);
}
const SCHEMA_VERSION = 11;
const SCHEMA_VERSION = 12;
function initSchema(db: DbAdapter, fileBacked: boolean): void {
if (fileBacked) db.exec("PRAGMA journal_mode=WAL");
@ -355,6 +355,23 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS quality_gates (
milestone_id TEXT NOT NULL,
slice_id TEXT NOT NULL,
gate_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'slice',
task_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
verdict TEXT NOT NULL DEFAULT '',
rationale TEXT NOT NULL DEFAULT '',
findings TEXT NOT NULL DEFAULT '',
evaluated_at TEXT DEFAULT NULL,
PRIMARY KEY (milestone_id, slice_id, gate_id, task_id),
FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id)
)
`);
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)");
db.exec("CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)");
@ -637,6 +654,29 @@ function migrateSchema(db: DbAdapter): void {
});
}
if (currentVersion < 12) {
db.exec(`
CREATE TABLE IF NOT EXISTS quality_gates (
milestone_id TEXT NOT NULL,
slice_id TEXT NOT NULL,
gate_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'slice',
task_id TEXT DEFAULT NULL,
status TEXT NOT NULL DEFAULT 'pending',
verdict TEXT NOT NULL DEFAULT '',
rationale TEXT NOT NULL DEFAULT '',
findings TEXT NOT NULL DEFAULT '',
evaluated_at TEXT DEFAULT NULL,
PRIMARY KEY (milestone_id, slice_id, gate_id, COALESCE(task_id, '')),
FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id)
)
`);
db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
":version": 12,
":applied_at": new Date().toISOString(),
});
}
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
@ -1722,3 +1762,111 @@ export function getAssessment(path: string): Record<string, unknown> | null {
).get({ ":path": path });
return row ?? null;
}
// ─── Quality Gates ───────────────────────────────────────────────────────
function rowToGate(row: Record<string, unknown>): GateRow {
return {
milestone_id: row["milestone_id"] as string,
slice_id: row["slice_id"] as string,
gate_id: row["gate_id"] as GateId,
scope: row["scope"] as GateScope,
task_id: (row["task_id"] as string) ?? "",
status: row["status"] as GateStatus,
verdict: (row["verdict"] as GateVerdict) || "",
rationale: (row["rationale"] as string) || "",
findings: (row["findings"] as string) || "",
evaluated_at: (row["evaluated_at"] as string) ?? null,
};
}
export function insertGateRow(g: {
milestoneId: string;
sliceId: string;
gateId: GateId;
scope: GateScope;
taskId?: string | null;
status?: GateStatus;
}): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb.prepare(
`INSERT OR IGNORE INTO quality_gates (milestone_id, slice_id, gate_id, scope, task_id, status)
VALUES (:mid, :sid, :gid, :scope, :tid, :status)`,
).run({
":mid": g.milestoneId,
":sid": g.sliceId,
":gid": g.gateId,
":scope": g.scope,
":tid": g.taskId ?? "",
":status": g.status ?? "pending",
});
}
export function saveGateResult(g: {
milestoneId: string;
sliceId: string;
gateId: string;
taskId?: string | null;
verdict: GateVerdict;
rationale: string;
findings: string;
}): void {
if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
currentDb.prepare(
`UPDATE quality_gates
SET status = 'complete', verdict = :verdict, rationale = :rationale,
findings = :findings, evaluated_at = :evaluated_at
WHERE milestone_id = :mid AND slice_id = :sid AND gate_id = :gid
AND task_id = :tid`,
).run({
":mid": g.milestoneId,
":sid": g.sliceId,
":gid": g.gateId,
":tid": g.taskId ?? "",
":verdict": g.verdict,
":rationale": g.rationale,
":findings": g.findings,
":evaluated_at": new Date().toISOString(),
});
}
export function getPendingGates(milestoneId: string, sliceId: string, scope?: GateScope): GateRow[] {
if (!currentDb) return [];
const sql = scope
? `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND scope = :scope AND status = 'pending'`
: `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND status = 'pending'`;
const params: Record<string, unknown> = { ":mid": milestoneId, ":sid": sliceId };
if (scope) params[":scope"] = scope;
return currentDb.prepare(sql).all(params).map(rowToGate);
}
export function getGateResults(milestoneId: string, sliceId: string, scope?: GateScope): GateRow[] {
if (!currentDb) return [];
const sql = scope
? `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid AND scope = :scope`
: `SELECT * FROM quality_gates WHERE milestone_id = :mid AND slice_id = :sid`;
const params: Record<string, unknown> = { ":mid": milestoneId, ":sid": sliceId };
if (scope) params[":scope"] = scope;
return currentDb.prepare(sql).all(params).map(rowToGate);
}
export function markAllGatesOmitted(milestoneId: string, sliceId: string): void {
if (!currentDb) return;
currentDb.prepare(
`UPDATE quality_gates SET status = 'omitted', verdict = 'omitted', evaluated_at = :now
WHERE milestone_id = :mid AND slice_id = :sid AND status = 'pending'`,
).run({
":mid": milestoneId,
":sid": sliceId,
":now": new Date().toISOString(),
});
}
export function getPendingSliceGateCount(milestoneId: string, sliceId: string): number {
if (!currentDb) return 0;
const row = currentDb.prepare(
`SELECT COUNT(*) as cnt FROM quality_gates
WHERE milestone_id = :mid AND slice_id = :sid AND scope = 'slice' AND status = 'pending'`,
).get({ ":mid": milestoneId, ":sid": sliceId });
return row ? (row["cnt"] as number) : 0;
}

View file

@ -20,8 +20,10 @@ import {
getSlice,
getArtifact,
insertArtifact,
getGateResults,
} from "./gsd-db.js";
import type { MilestoneRow, SliceRow, TaskRow, ArtifactRow } from "./gsd-db.js";
import type { GateRow } from "./types.js";
import {
resolveMilestoneFile,
resolveSliceFile,
@ -188,7 +190,7 @@ function renderRoadmapMarkdown(milestone: MilestoneRow, slices: SliceRow[]): str
return `${lines.join("\n").trimEnd()}\n`;
}
function renderTaskPlanMarkdown(task: TaskRow): string {
function renderTaskPlanMarkdown(task: TaskRow, taskGates: GateRow[] = []): string {
const estimatedSteps = Math.max(1, task.description.trim().split(/\n+/).filter(Boolean).length || 1);
const estimatedFiles = task.files.length > 0
? task.files.length
@ -251,10 +253,22 @@ function renderTaskPlanMarkdown(task: TaskRow): string {
lines.push("");
}
// ── Quality Gate Sections (Q5/Q6/Q7) ──────────────────────────────────
const gateLabels: Record<string, string> = { Q5: "Failure Modes", Q6: "Load Profile", Q7: "Negative Tests" };
for (const [gid, label] of Object.entries(gateLabels)) {
const gate = taskGates.find(g => g.gate_id === gid && g.status === "complete");
if (gate && gate.verdict !== "omitted") {
lines.push(`## ${label}`);
lines.push("");
lines.push(gate.findings.trim() || `- **Verdict:** ${gate.verdict}\n- **Rationale:** ${gate.rationale}`);
lines.push("");
}
}
return `${lines.join("\n").trimEnd()}\n`;
}
function renderSlicePlanMarkdown(slice: SliceRow, tasks: TaskRow[]): string {
function renderSlicePlanMarkdown(slice: SliceRow, tasks: TaskRow[], gates: GateRow[] = []): string {
const lines: string[] = [];
lines.push(`# ${slice.id}: ${slice.title || slice.id}`);
@ -274,6 +288,23 @@ function renderSlicePlanMarkdown(slice: SliceRow, tasks: TaskRow[]): string {
}
lines.push("");
// ── Quality Gate Sections (Q3/Q4) ────────────────────────────────────
const q3 = gates.find(g => g.gate_id === "Q3" && g.status === "complete");
if (q3 && q3.verdict !== "omitted") {
lines.push("## Threat Surface");
lines.push("");
lines.push(q3.findings.trim() || `- **Verdict:** ${q3.verdict}\n- **Rationale:** ${q3.rationale}`);
lines.push("");
}
const q4 = gates.find(g => g.gate_id === "Q4" && g.status === "complete");
if (q4 && q4.verdict !== "omitted") {
lines.push("## Requirement Impact");
lines.push("");
lines.push(q4.findings.trim() || `- **Verdict:** ${q4.verdict}\n- **Rationale:** ${q4.rationale}`);
lines.push("");
}
if (slice.proof_level.trim()) {
lines.push("## Proof Level");
lines.push("");
@ -354,7 +385,8 @@ export async function renderPlanFromDb(
const absPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN")
?? join(slicePath, `${sliceId}-PLAN.md`);
const artifactPath = toArtifactPath(absPath, basePath);
const content = renderSlicePlanMarkdown(slice, tasks);
const sliceGates = getGateResults(milestoneId, sliceId, "slice");
const content = renderSlicePlanMarkdown(slice, tasks, sliceGates);
await writeAndStore(absPath, artifactPath, content, {
artifact_type: "PLAN",
@ -387,7 +419,8 @@ export async function renderTaskPlanFromDb(
mkdirSync(tasksDir, { recursive: true });
const absPath = join(tasksDir, buildTaskFileName(taskId, "PLAN"));
const artifactPath = toArtifactPath(absPath, basePath);
const content = task.full_plan_md.trim() ? task.full_plan_md : renderTaskPlanMarkdown(task);
const taskGates = getGateResults(milestoneId, sliceId, "task").filter(g => g.task_id === taskId);
const content = task.full_plan_md.trim() ? task.full_plan_md : renderTaskPlanMarkdown(task, taskGates);
await writeAndStore(absPath, artifactPath, content, {
artifact_type: "PLAN",

View file

@ -18,6 +18,7 @@ import type {
ParallelConfig,
ContextSelectionMode,
ReactiveExecutionConfig,
GateEvaluationConfig,
} from "./types.js";
import type { DynamicRoutingConfig } from "./model-router.js";
import type { GitHubSyncConfig } from "../github-sync/types.js";
@ -87,6 +88,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"context_selection",
"widget_mode",
"reactive_execution",
"gate_evaluation",
"github",
"service_tier",
"forensics_dedup",
@ -96,7 +98,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
/** Canonical list of all dispatch unit types. */
export const KNOWN_UNIT_TYPES = [
"research-milestone", "plan-milestone", "research-slice", "plan-slice",
"execute-task", "reactive-execute", "complete-slice", "replan-slice", "reassess-roadmap",
"execute-task", "reactive-execute", "gate-evaluate", "complete-slice", "replan-slice", "reassess-roadmap",
"run-uat", "complete-milestone",
] as const;
export type UnitType = (typeof KNOWN_UNIT_TYPES)[number];
@ -221,6 +223,8 @@ export interface GSDPreferences {
widget_mode?: "full" | "small" | "min" | "off";
/** Reactive (graph-derived parallel) task execution within slices. Disabled by default. */
reactive_execution?: ReactiveExecutionConfig;
/** Parallel quality gate evaluation during slice planning. Disabled by default. */
gate_evaluation?: GateEvaluationConfig;
/** GitHub sync configuration. Opt-in: syncs GSD events to GitHub Issues, Milestones, and PRs. */
github?: GitHubSyncConfig;
/** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */

View file

@ -538,6 +538,43 @@ export function validatePreferences(preferences: GSDPreferences): {
}
}
// ─── Gate Evaluation ─────────────────────────────────────────────────────
if (preferences.gate_evaluation !== undefined) {
if (typeof preferences.gate_evaluation === "object" && preferences.gate_evaluation !== null) {
const ge = preferences.gate_evaluation as unknown as Record<string, unknown>;
const validGe: Record<string, unknown> = {};
if (ge.enabled !== undefined) {
if (typeof ge.enabled === "boolean") validGe.enabled = ge.enabled;
else errors.push("gate_evaluation.enabled must be a boolean");
}
if (ge.slice_gates !== undefined) {
if (Array.isArray(ge.slice_gates) && ge.slice_gates.every((g: unknown) => typeof g === "string")) {
validGe.slice_gates = ge.slice_gates;
} else {
errors.push("gate_evaluation.slice_gates must be an array of strings");
}
}
if (ge.task_gates !== undefined) {
if (typeof ge.task_gates === "boolean") validGe.task_gates = ge.task_gates;
else errors.push("gate_evaluation.task_gates must be a boolean");
}
const knownGeKeys = new Set(["enabled", "slice_gates", "task_gates"]);
for (const key of Object.keys(ge)) {
if (!knownGeKeys.has(key)) {
warnings.push(`unknown gate_evaluation key "${key}" — ignored`);
}
}
if (Object.keys(validGe).length > 0) {
validated.gate_evaluation = validGe as unknown as import("./types.js").GateEvaluationConfig;
}
} else {
errors.push("gate_evaluation must be an object");
}
}
// ─── Verification Preferences ───────────────────────────────────────────
if (preferences.verification_commands !== undefined) {
if (Array.isArray(preferences.verification_commands)) {

View file

@ -20,11 +20,13 @@ Then:
3. **Verify code changes exist.** Run `git diff --stat HEAD $(git merge-base HEAD main) -- ':!.gsd/'` (or the equivalent for the integration branch). If no non-`.gsd/` files appear in the diff, the milestone produced only planning artifacts and no actual code. Record this as a **verification failure**.
4. Verify each **success criterion** from the milestone definition in `{{roadmapPath}}`. For each criterion, confirm it was met with specific evidence from slice summaries, test results, or observable behavior. Record any criterion that was NOT met as a **verification failure**.
5. Verify the milestone's **definition of done** — all slices are `[x]`, all slice summaries exist, and any cross-slice integration points work correctly. Record any unmet items as a **verification failure**.
6. Validate **requirement status transitions**. For each requirement that changed status during this milestone, confirm the transition is supported by evidence. Requirements can move between Active, Validated, Deferred, Blocked, or Out of Scope — but only with proof.
6. If the roadmap includes a **Horizontal Checklist**, verify each item was addressed during the milestone. Note unchecked items in the milestone summary.
7. Fill the **Decision Re-evaluation** table in the milestone summary. For each key decision from `.gsd/DECISIONS.md` made during this milestone, evaluate whether it is still valid given what was actually built. Flag decisions that should be revisited next milestone.
8. Validate **requirement status transitions**. For each requirement that changed status during this milestone, confirm the transition is supported by evidence. Requirements can move between Active, Validated, Deferred, Blocked, or Out of Scope — but only with proof.
### Verification Gate — STOP if verification failed
**If ANY verification failure was recorded in steps 3, 4, or 5, you MUST follow the failure path below. Do NOT proceed to step 7.**
**If ANY verification failure was recorded in steps 3, 4, or 5, you MUST follow the failure path below. Do NOT proceed to step 9.**
**Failure path** (verification failed):
- Do NOT call `gsd_complete_milestone` — the milestone must not be marked as complete.
@ -33,9 +35,9 @@ Then:
- Write a clear summary of what failed and why to help the next attempt.
- Say: "Milestone {{milestoneId}} verification FAILED — not complete." and stop.
**Success path** (all verifications passed — continue with steps 711):
**Success path** (all verifications passed — continue with steps 913):
7. **Persist completion through `gsd_complete_milestone`.** Call it with the parameters below. The tool updates the milestone status in the DB, renders `{{milestoneSummaryPath}}`, and validates all slices are complete before proceeding.
9. **Persist completion through `gsd_complete_milestone`.** Call it with the parameters below. The tool updates the milestone status in the DB, renders `{{milestoneSummaryPath}}`, and validates all slices are complete before proceeding.
**Required parameters:**
- `milestoneId` (string) — Milestone ID (e.g. M001)
@ -53,10 +55,10 @@ Then:
**Optional parameters:**
- `followUps` (string) — Follow-up items for future milestones
- `deviations` (string) — Deviations from the original plan
8. For each requirement whose status changed in step 6, call `gsd_requirement_update` with the requirement ID and updated `status` and `validation` fields — the tool regenerates `.gsd/REQUIREMENTS.md` automatically.
9. Update `.gsd/PROJECT.md` to reflect milestone completion and current project state.
10. Review all slice summaries for cross-cutting lessons, patterns, or gotchas that emerged during this milestone. Append any non-obvious, reusable insights to `.gsd/KNOWLEDGE.md`.
11. Do not commit manually — the system auto-commits your changes after this unit completes.
10. For each requirement whose status changed in step 8, call `gsd_requirement_update` with the requirement ID and updated `status` and `validation` fields — the tool regenerates `.gsd/REQUIREMENTS.md` automatically.
11. Update `.gsd/PROJECT.md` to reflect milestone completion and current project state.
12. Review all slice summaries for cross-cutting lessons, patterns, or gotchas that emerged during this milestone. Append any non-obvious, reusable insights to `.gsd/KNOWLEDGE.md`.
13. Do not commit manually — the system auto-commits your changes after this unit completes.
- Say: "Milestone {{milestoneId}} complete."
**Important:** Do NOT skip the code change verification, success criteria, or definition of done verification (steps 3-5). The milestone summary must reflect actual verified outcomes, not assumed success. Verification failures BLOCK completion — there is no override. The milestone stays in its current state until issues are resolved and verification is re-run.

View file

@ -23,14 +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 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.
5. If the slice involved runtime behavior, fill the **Operational Readiness** section (Q8) in the slice summary: health signal, failure signal, recovery procedure, and monitoring gaps. Omit entirely for simple slices with no runtime concerns.
6. 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.
7. Write `{{sliceSummaryPath}}` (compress all task summaries).
8. 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.
9. Review task summaries for `key_decisions`. Append any significant decisions to `.gsd/DECISIONS.md` if missing.
10. 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.
11. 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.
12. Do not run git commands — the system commits your changes and handles any merge after this unit succeeds.
13. Update `.gsd/PROJECT.md` if it exists — refresh current state if needed.
**You MUST call `gsd_complete_slice` with the slice summary and UAT content before finishing. The tool persists to both DB and disk and renders `{{sliceSummaryPath}}` and `{{sliceUatPath}}` automatically.**

View file

@ -38,18 +38,21 @@ Then:
- Correct: `command > /dev/null 2>&1 &` or `nohup command > /dev/null 2>&1 &`
- Example: `python -m http.server 8080 > /dev/null 2>&1 &` (NOT `python -m http.server 8080 &`)
- Preferred: use the `bg_shell` tool if available — it manages process lifecycle correctly without stream-inheritance issues
6. Verify must-haves are met by running concrete checks (tests, commands, observable behaviors)
7. Run the slice-level verification checks defined in the slice plan's Verification section. Track which pass. On the final task of the slice, all must pass before marking done. On intermediate tasks, partial passes are expected — note which ones pass in the summary.
8. After the verification gate runs (you'll see gate results in stderr/notify output), populate the `## Verification Evidence` table in your task summary with the check results. Use the `formatEvidenceTable` format: one row per check with command, exit code, verdict (✅ pass / ❌ fail), and duration. If no verification commands were discovered, note that in the section.
9. If the task touches UI, browser flows, DOM behavior, or user-visible web state:
6. If the task plan includes a **Failure Modes** section (Q5), implement the error/timeout/malformed handling specified. Verify each dependency's failure path is handled. Skip if the section is absent.
7. If the task plan includes a **Load Profile** section (Q6), implement protections for the identified 10x breakpoint (connection pooling, rate limiting, pagination, etc.). Skip if absent.
8. If the task plan includes a **Negative Tests** section (Q7), write the specified negative test cases alongside the happy-path tests — malformed inputs, error paths, and boundary conditions. Skip if absent.
9. Verify must-haves are met by running concrete checks (tests, commands, observable behaviors)
10. Run the slice-level verification checks defined in the slice plan's Verification section. Track which pass. On the final task of the slice, all must pass before marking done. On intermediate tasks, partial passes are expected — note which ones pass in the summary.
11. After the verification gate runs (you'll see gate results in stderr/notify output), populate the `## Verification Evidence` table in your task summary with the check results. Use the `formatEvidenceTable` format: one row per check with command, exit code, verdict (✅ pass / ❌ fail), and duration. If no verification commands were discovered, note that in the section.
12. If the task touches UI, browser flows, DOM behavior, or user-visible web state:
- exercise the real flow in the browser
- prefer `browser_batch` when the next few actions are obvious and sequential
- prefer `browser_assert` for explicit pass/fail verification of the intended outcome
- use `browser_diff` when an action's effect is ambiguous
- use console/network/dialog diagnostics when validating async, stateful, or failure-prone UI
- record verification in terms of explicit checks passed/failed, not only prose interpretation
10. If the task plan includes an Observability Impact section, verify those signals directly. Skip this step if the task plan omits the section.
11. **If execution is running long or verification fails:**
13. If the task plan includes an Observability Impact section, verify those signals directly. Skip this step if the task plan omits the section.
14. **If execution is running long or verification fails:**
**Context budget:** You have approximately **{{verificationBudget}}** reserved for verification context. If you've used most of your context and haven't finished all steps, stop implementing and prioritize writing the task summary with clear notes on what's done and what remains. A partial summary that enables clean resumption is more valuable than one more half-finished step with no documentation. Never sacrifice summary quality for one more implementation step.
@ -60,13 +63,13 @@ Then:
- Distinguish "I know" from "I assume." Observable facts (the error says X) are strong evidence. Assumptions (this library should work this way) need verification.
- Know when to stop. If you've tried 3+ fixes without progress, your mental model is probably wrong. Stop. List what you know for certain. List what you've ruled out. Form fresh hypotheses from there.
- Don't fix symptoms. Understand *why* something fails before changing code. A test that passes after a change you don't understand is luck, not a fix.
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. 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.
15. **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.
16. 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.
17. 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.
18. Read the template at `~/.gsd/agent/extensions/gsd/templates/task-summary.md`
19. Write `{{taskSummaryPath}}`
20. 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.
21. 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}}`.

View file

@ -16,7 +16,7 @@ GSD extension source code is at: `{{gsdSourceDir}}`
| Domain | Files |
|--------|-------|
| **Auto-mode engine** | `auto.ts` `auto-loop.ts` `auto-dispatch.ts` `auto-start.ts` `auto-supervisor.ts` `auto-timers.ts` `auto-timeout-recovery.ts` `auto-unit-closeout.ts` `auto-post-unit.ts` `auto-verification.ts` `auto-recovery.ts` `auto-worktree.ts` `auto-worktree-sync.ts` `auto-model-selection.ts` `auto-budget.ts` `dispatch-guard.ts` |
| **Auto-mode engine** | `auto.ts` `auto-loop.ts` `auto-dispatch.ts` `auto-start.ts` `auto-supervisor.ts` `auto-timers.ts` `auto-timeout-recovery.ts` `auto-unit-closeout.ts` `auto-post-unit.ts` `auto-verification.ts` `auto-recovery.ts` `auto-worktree.ts` `auto-model-selection.ts` `auto-budget.ts` `dispatch-guard.ts` |
| **State & persistence** | `state.ts` `types.ts` `files.ts` `paths.ts` `json-persistence.ts` `atomic-write.ts` |
| **Forensics & recovery** | `forensics.ts` `session-forensics.ts` `crash-recovery.ts` `session-lock.ts` |
| **Metrics & telemetry** | `metrics.ts` `skill-telemetry.ts` `token-counter.ts` |

View file

@ -0,0 +1,32 @@
# Quality Gate Evaluation — Parallel Dispatch
**Working directory:** `{{workingDirectory}}`
**Milestone:** {{milestoneId}} — {{milestoneTitle}}
**Slice:** {{sliceId}} — {{sliceTitle}}
## Mission
You are evaluating **quality gates in parallel** for this slice. Each gate is an independent question that must be answered before task execution begins. Use the `subagent` tool to dispatch all gate evaluations simultaneously.
## Slice Plan Context
{{slicePlanContent}}
## Gates to Evaluate
{{gateCount}} gates require evaluation:
{{gateList}}
## Execution Protocol
1. **Dispatch all gates** using `subagent` in parallel mode. Each subagent prompt is provided below.
2. **Wait for all subagents** to complete.
3. **Verify each gate wrote its result** by checking that `gsd_save_gate_result` was called for each gate ID.
4. **Report the batch outcome** — which gates passed, which flagged concerns, and which were omitted as not applicable.
Gate agents may return `verdict: "omitted"` if the gate question is not applicable to this slice (e.g., no auth surface for Q3, no existing requirements touched for Q4). This is expected for simple slices.
## Subagent Prompts
{{subagentPrompts}}

View file

@ -1,3 +1,3 @@
Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Your working directory is `{{workingDirectory}}` — all file operations must use this path. All tasks are done. Your slice summary is the primary record of what was built — downstream agents (reassess-roadmap, future slice researchers) read it to understand what this slice delivered and what to watch out for. Use the **Slice Summary** and **UAT** output templates below to understand the expected structure. {{skillActivation}} Call `gsd_slice_complete` to record completion — the tool writes `{{sliceId}}-SUMMARY.md`, `{{sliceId}}-UAT.md`, and toggles the roadmap checkbox atomically. Fill the `UAT Type` plus `Not Proven By This UAT` sections explicitly in `uatContent` so the artifact states what class of acceptance it covers and what still remains unproven. Review task summaries for `key_decisions` and ensure any significant ones are in `.gsd/DECISIONS.md`. Do not commit or merge manually — the system handles this after the unit completes.
Complete slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Your working directory is `{{workingDirectory}}` — all file operations must use this path. All tasks are done. Your slice summary is the primary record of what was built — downstream agents (reassess-roadmap, future slice researchers) read it to understand what this slice delivered and what to watch out for. Use the **Slice Summary** and **UAT** output templates below to understand the expected structure. {{skillActivation}} Call `gsd_slice_complete` to record completion — the tool writes `{{sliceId}}-SUMMARY.md`, `{{sliceId}}-UAT.md`, and toggles the roadmap checkbox atomically. Fill the `UAT Type` plus `Not Proven By This UAT` sections explicitly in `uatContent` so the artifact states what class of acceptance it covers and what still remains unproven. Review task summaries for `key_decisions` and ensure any significant ones are in `.gsd/DECISIONS.md`. If the slice involved runtime behavior, fill the Operational Readiness section (Q8) in the summary: health signal, failure signal, recovery procedure, and monitoring gaps. Omit for simple slices. Do not commit or merge manually — the system handles this after the unit completes.
{{inlinedTemplates}}

View file

@ -1,3 +1,3 @@
Execute the next task: {{taskId}} ("{{taskTitle}}") in slice {{sliceId}} of milestone {{milestoneId}}. Read the task plan (`{{taskId}}-PLAN.md`), load relevant summaries from prior tasks, and execute each step. Verify must-haves when done. If the task touches UI, browser flows, DOM behavior, or user-visible web state, exercise the real flow in the browser, prefer `browser_batch` for obvious sequences, prefer `browser_assert` for explicit pass/fail verification, use `browser_diff` when an action's effect is ambiguous, and use browser diagnostics when validating async or failure-prone UI. If you made an architectural, pattern, or library decision, append it to `.gsd/DECISIONS.md`. Use the **Task Summary** output template below. Call `gsd_task_complete` to record completion (it writes the summary, toggles the checkbox, and persists to DB atomically). {{skillActivation}} If running long and not all steps are finished, stop implementing and prioritize writing a clean partial summary over attempting one more step — a recoverable handoff is more valuable than a half-finished step with no documentation. If verification fails, debug methodically: form a hypothesis and test that specific theory before changing anything, change one variable at a time, read entire functions not just the suspect line, distinguish observable facts from assumptions, and if 3+ fixes fail without progress stop and reassess your mental model — list what you know for certain, what you've ruled out, and form fresh hypotheses. Don't fix symptoms — understand why something fails before changing code.
Execute the next task: {{taskId}} ("{{taskTitle}}") in slice {{sliceId}} of milestone {{milestoneId}}. Read the task plan (`{{taskId}}-PLAN.md`), load relevant summaries from prior tasks, and execute each step. Verify must-haves when done. If the task touches UI, browser flows, DOM behavior, or user-visible web state, exercise the real flow in the browser, prefer `browser_batch` for obvious sequences, prefer `browser_assert` for explicit pass/fail verification, use `browser_diff` when an action's effect is ambiguous, and use browser diagnostics when validating async or failure-prone UI. If you made an architectural, pattern, or library decision, append it to `.gsd/DECISIONS.md`. Use the **Task Summary** output template below. Call `gsd_task_complete` to record completion (it writes the summary, toggles the checkbox, and persists to DB atomically). {{skillActivation}} If running long and not all steps are finished, stop implementing and prioritize writing a clean partial summary over attempting one more step — a recoverable handoff is more valuable than a half-finished step with no documentation. If verification fails, debug methodically: form a hypothesis and test that specific theory before changing anything, change one variable at a time, read entire functions not just the suspect line, distinguish observable facts from assumptions, and if 3+ fixes fail without progress stop and reassess your mental model — list what you know for certain, what you've ruled out, and form fresh hypotheses. Don't fix symptoms — understand why something fails before changing code. If the task plan includes Failure Modes, Load Profile, or Negative Tests sections, implement and verify them: handle each dependency's error/timeout/malformed paths (Q5), protect against identified 10x breakpoints (Q6), and write specified negative test cases (Q7).
{{inlinedTemplates}}

View file

@ -1,4 +1,4 @@
Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists and treat Active requirements as the capability contract. If `REQUIREMENTS.md` is missing, continue in legacy compatibility mode but explicitly note missing requirement coverage. Use the **Roadmap** output template below to shape the milestone planning payload you send to `gsd_plan_milestone`. Call `gsd_plan_milestone` to persist the milestone planning fields and render `{{milestoneId}}-ROADMAP.md` from DB state. Do **not** write `{{milestoneId}}-ROADMAP.md`, `ROADMAP.md`, or other planning artifacts manually. If planning produces structural decisions, append them to `.gsd/DECISIONS.md`. {{skillActivation}}
Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists and treat Active requirements as the capability contract. If `REQUIREMENTS.md` is missing, continue in legacy compatibility mode but explicitly note missing requirement coverage. Use the **Roadmap** output template below to shape the milestone planning payload you send to `gsd_plan_milestone`. Call `gsd_plan_milestone` to persist the milestone planning fields and render `{{milestoneId}}-ROADMAP.md` from DB state. Do **not** write `{{milestoneId}}-ROADMAP.md`, `ROADMAP.md`, or other planning artifacts manually. If planning produces structural decisions, append them to `.gsd/DECISIONS.md`. {{skillActivation}} Fill the Horizontal Checklist section with cross-cutting concerns considered during planning (requirements re-read, decisions re-evaluated, graceful shutdown, revenue paths, auth boundary, shared resources, reconnection). Omit for trivial milestones.
## Requirement Rules

View file

@ -1,3 +1,3 @@
Plan slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements the roadmap says this slice owns or supports, and ensure the plan delivers them. Read the roadmap boundary map, any existing context/research files, and dependency summaries. Use the **Slice Plan** and **Task Plan** output templates below. Decompose into tasks with must-haves. Fill the `Proof Level` and `Integration Closure` sections truthfully so the plan says what class of proof this slice really delivers and what end-to-end wiring still remains. Call `gsd_plan_slice` to persist the slice plan — the tool writes `{{sliceId}}-PLAN.md` and individual `T##-PLAN.md` files to disk and persists to DB. Do **not** write plan files manually — use the DB-backed tool so state stays consistent. If planning produces structural decisions, call `gsd_decision_save` for each — the tool auto-assigns IDs and regenerates `.gsd/DECISIONS.md` automatically. {{skillActivation}} Before finishing, self-audit the plan: every must-have maps to at least one task, every task has complete sections (steps, must-haves, verification, observability impact, inputs, and expected output), task ordering is consistent with no circular references, every pair of artifacts that must connect has an explicit wiring step, task scope targets 25 steps and 38 files (68 steps or 810 files — consider splitting; 10+ steps or 12+ files — must split), the plan honors locked decisions from context/research/decisions artifacts, the proof-level wording does not overclaim live integration if only fixture/contract proof is planned, every Active requirement this slice owns has at least one task with verification that proves it is met, and every task produces real user-facing progress — if the slice has a UI surface at least one task builds the real UI, if it has an API at least one task connects it to a real data source, and showing the completed result to a non-technical stakeholder would demonstrate real product progress rather than developer artifacts.
Plan slice {{sliceId}} ("{{sliceTitle}}") of milestone {{milestoneId}}. Read `.gsd/DECISIONS.md` if it exists — respect existing decisions. Read `.gsd/REQUIREMENTS.md` if it exists — identify which Active requirements the roadmap says this slice owns or supports, and ensure the plan delivers them. Read the roadmap boundary map, any existing context/research files, and dependency summaries. Use the **Slice Plan** and **Task Plan** output templates below. Decompose into tasks with must-haves. Fill the `Proof Level` and `Integration Closure` sections truthfully so the plan says what class of proof this slice really delivers and what end-to-end wiring still remains. Call `gsd_plan_slice` to persist the slice plan — the tool writes `{{sliceId}}-PLAN.md` and individual `T##-PLAN.md` files to disk and persists to DB. Do **not** write plan files manually — use the DB-backed tool so state stays consistent. If planning produces structural decisions, call `gsd_decision_save` for each — the tool auto-assigns IDs and regenerates `.gsd/DECISIONS.md` automatically. {{skillActivation}} Before finishing, self-audit the plan: every must-have maps to at least one task, every task has complete sections (steps, must-haves, verification, observability impact, inputs, and expected output), task ordering is consistent with no circular references, every pair of artifacts that must connect has an explicit wiring step, task scope targets 25 steps and 38 files (68 steps or 810 files — consider splitting; 10+ steps or 12+ files — must split), the plan honors locked decisions from context/research/decisions artifacts, the proof-level wording does not overclaim live integration if only fixture/contract proof is planned, every Active requirement this slice owns has at least one task with verification that proves it is met, and every task produces real user-facing progress — if the slice has a UI surface at least one task builds the real UI, if it has an API at least one task connects it to a real data source, and showing the completed result to a non-technical stakeholder would demonstrate real product progress rather than developer artifacts, and quality gate coverage — for non-trivial slices, Threat Surface (Q3: abuse, data exposure, input trust) and Requirement Impact (Q4: requirements touched, re-verify, decisions revisited) sections are present. For non-trivial tasks, Failure Modes (Q5), Load Profile (Q6), and Negative Tests (Q7) are filled in task plans.
{{inlinedTemplates}}

View file

@ -47,7 +47,7 @@ Then:
2. {{skillActivation}}
3. Create the roadmap: decompose into demoable vertical slices — as many as the work genuinely needs, no more. A simple feature might be 1 slice. Don't decompose for decomposition's sake.
4. Order by risk (high-risk first)
5. Call `gsd_plan_milestone` to persist the milestone planning fields and slice rows in the DB-backed planning path. Do **not** write `{{outputPath}}`, `ROADMAP.md`, or other planning artifacts manually — the planning tool owns roadmap rendering and persistence.
5. Call `gsd_plan_milestone` to persist the milestone planning fields, slice rows, and **horizontal checklist** in the DB-backed planning path. Do **not** write `{{outputPath}}`, `ROADMAP.md`, or other planning artifacts manually — the planning tool owns roadmap rendering and persistence.
6. If planning produced structural decisions (e.g. slice ordering rationale, technology choices, scope exclusions), call `gsd_decision_save` for each decision — the tool auto-assigns IDs and regenerates `.gsd/DECISIONS.md` automatically.
## Requirement Mapping Rules

View file

@ -57,14 +57,18 @@ Then:
- Include `Observability / Diagnostics` for backend, integration, async, stateful, or UI slices where failure diagnosis matters.
- Fill `Proof Level` and `Integration Closure` when the slice crosses runtime boundaries or has meaningful integration concerns.
- **Omit these sections entirely for simple slices** where they would all be "none" or trivially obvious.
5. Decompose the slice into tasks, each fitting one context window. Each task needs:
5. **Quality gates** — for non-trivial slices, fill the Threat Surface (Q3) and Requirement Impact (Q4) sections in the slice plan:
- **Threat Surface:** Identify abuse scenarios, data exposure risks, and input trust boundaries. Required when the slice handles user input, authentication, authorization, or sensitive data. Omit entirely for internal refactoring or simple changes.
- **Requirement Impact:** List which existing requirements this slice touches, what must be re-verified after shipping, and which prior decisions should be reconsidered. Omit entirely if no existing requirements are affected.
- For each task in a non-trivial slice, fill Failure Modes (Q5), Load Profile (Q6), and Negative Tests (Q7) in the task plan when the task has external dependencies, shared resources, or non-trivial input handling. Omit for simple tasks.
6. Decompose the slice into tasks, each fitting one context window. Each task needs:
- a concrete, action-oriented title
- the inline task entry fields defined in the plan.md template (Why / Files / Do / Verify / Done when)
- a matching task plan file with description, steps, must-haves, verification, inputs, and expected output
- **Inputs and Expected Output must list concrete backtick-wrapped file paths** (e.g. `` `src/types.ts` ``). These are machine-parsed to derive task dependencies — vague prose without paths breaks parallel execution. Every task must have at least one output file path.
- Observability Impact section **only if the task touches runtime boundaries, async flows, or error paths** — omit it otherwise
6. **Persist planning state through `gsd_plan_slice`.** Call it with the full slice planning payload (goal, demo, must-haves, verification, tasks, and metadata). The tool inserts all tasks in the same transaction, writes to the DB, and renders `{{outputPath}}` and `{{slicePath}}/tasks/T##-PLAN.md` files automatically. Do **not** call `gsd_plan_task` separately — `gsd_plan_slice` handles task persistence. Do **not** rely on direct `PLAN.md` writes as the source of truth; the DB-backed tool is the canonical write path for slice and task planning state.
7. **Self-audit the plan.** Walk through each check — if any fail, fix the plan files before moving on:
7. **Persist planning state through `gsd_plan_slice`.** Call it with the full slice planning payload (goal, demo, must-haves, verification, tasks, and metadata). The tool inserts all tasks in the same transaction, writes to the DB, and renders `{{outputPath}}` and `{{slicePath}}/tasks/T##-PLAN.md` files automatically. Do **not** call `gsd_plan_task` separately — `gsd_plan_slice` handles task persistence. Do **not** rely on direct `PLAN.md` writes as the source of truth; the DB-backed tool is the canonical write path for slice and task planning state.
8. **Self-audit the plan.** Walk through each check — if any fail, fix the plan files before moving on:
- **Completion semantics:** If every task were completed exactly as written, the slice goal/demo should actually be true.
- **Requirement coverage:** Every must-have in the slice maps to at least one task. No must-have is orphaned. If `REQUIREMENTS.md` exists, every Active requirement this slice owns maps to at least one task.
- **Task completeness:** Every task has steps, must-haves, verification, inputs, and expected output — none are blank or vague. Inputs and Expected Output list backtick-wrapped file paths, not prose descriptions.
@ -72,6 +76,7 @@ Then:
- **Key links planned:** For every pair of artifacts that must connect, there is an explicit step that wires them.
- **Scope sanity:** Target 25 steps and 38 files per task. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window.
- **Feature completeness:** Every task produces real, user-facing progress — not just internal scaffolding.
- **Quality gate coverage:** For non-trivial slices, Threat Surface and Requirement Impact sections are present and specific (not placeholder text). For non-trivial tasks, Failure Modes, Load Profile, and Negative Tests are addressed in the task plan.
10. If planning produced structural decisions, append them to `.gsd/DECISIONS.md`
11. {{commitInstruction}}

View file

@ -36,6 +36,9 @@ Ask yourself:
- Did assumptions in remaining slice descriptions turn out wrong?
- If `.gsd/REQUIREMENTS.md` exists: did this slice validate, invalidate, defer, block, or newly surface requirements?
- If `.gsd/REQUIREMENTS.md` exists: does the remaining roadmap still provide credible coverage for Active requirements, including launchability, primary user loop, continuity, and failure visibility where relevant?
- Are the Threat Surface and Requirement Impact sections in completed slice plans still accurate for remaining slices?
- Did this slice's Operational Readiness reveal monitoring gaps that remaining slices should address?
- Should any Horizontal Checklist items be updated based on what was actually built?
### Success-Criterion Coverage Check

View file

@ -32,7 +32,7 @@ Consider these captures when rewriting the remaining tasks — they represent th
1. Read the blocker task summary carefully. Understand exactly what was discovered and why it blocks the current plan.
2. Analyze the remaining `[ ]` tasks in the slice plan. Determine which are still valid, which need modification, and which should be replaced.
3. **Persist replan state through `gsd_replan_slice`.** Call it with: `milestoneId`, `sliceId`, `blockerTaskId`, `blockerDescription`, `whatChanged`, `updatedTasks` (array of task objects with taskId, title, description, estimate, files, verify, inputs, expectedOutput), `removedTaskIds` (array of task ID strings). The tool structurally enforces preservation of completed tasks, writes replan history to the DB, re-renders `{{planPath}}`, and renders `{{replanPath}}`.
3. **Persist replan state through `gsd_replan_slice`.** Call it with: `milestoneId`, `sliceId`, `blockerTaskId`, `blockerDescription`, `whatChanged`, `updatedTasks` (array of task objects with taskId, title, description, estimate, files, verify, inputs, expectedOutput), `removedTaskIds` (array of task ID strings). The tool structurally enforces preservation of completed tasks, writes replan history to the DB, re-renders `{{planPath}}`, and renders `{{replanPath}}`. Preserve or update the Threat Surface and Requirement Impact sections if the replan changes the slice's security posture or requirement coverage.
4. If any incomplete task had a `T0x-PLAN.md`, remove or rewrite it to match the new task description.
5. Do not commit manually — the system auto-commits your changes after this unit completes.

View file

@ -1,101 +0,0 @@
/**
* Resource version tracking and stale worktree detection.
*
* Staleness detection for managed GSD resources and utilities
* for escaping stale worktree cwd after milestone teardown.
*/
import { existsSync, readdirSync, unlinkSync } from "node:fs";
import { loadJsonFileOrNull } from "./json-persistence.js";
import { join } from "node:path";
import { homedir } from "node:os";
import { resolveProjectRoot } from "./worktree.js";
const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd");
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
* Read the resource version (semver) from the managed-resources manifest.
* Uses gsdVersion instead of syncedAt so that launching a second session
* doesn't falsely trigger staleness (#804).
*/
function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record<string, unknown>).gsdVersion === "string";
}
export function readResourceVersion(): string | null {
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(gsdHome, "agent");
const manifestPath = join(agentDir, "managed-resources.json");
const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
return manifest?.gsdVersion ?? null;
}
/**
* Check if managed resources have been updated since session start.
* Returns a warning message if stale, null otherwise.
*/
export function checkResourcesStale(versionOnStart: string | null): string | null {
if (versionOnStart === null) return null;
const current = readResourceVersion();
if (current === null) return null;
if (current !== versionOnStart) {
return "GSD resources were updated since this session started. Restart gsd to load the new code.";
}
return null;
}
// ─── Stale Worktree Escape ────────────────────────────────────────────────
/**
* Detect and escape a stale worktree cwd (#608).
*
* After milestone completion + merge, the worktree directory is removed but
* the process cwd may still point inside `.gsd/worktrees/<MID>/`.
* When a new session starts, `process.cwd()` is passed as `base` to startAuto
* and all subsequent writes land in the wrong directory. This function detects
* that scenario and chdir back to the project root.
*
* Returns the corrected base path.
*/
export function escapeStaleWorktree(base: string): string {
const projectRoot = resolveProjectRoot(base);
if (projectRoot === base) return base;
try {
process.chdir(projectRoot);
} catch {
return base;
}
return projectRoot;
}
/**
* Clean stale runtime unit files for completed milestones.
*
* After restart, stale runtime/units/*.json from prior milestones can
* cause deriveState to resume the wrong milestone (#887). Removes files
* for milestones that have a SUMMARY (fully complete).
*/
export function cleanStaleRuntimeUnits(
gsdRootPath: string,
hasMilestoneSummary: (mid: string) => boolean,
): number {
const runtimeUnitsDir = join(gsdRootPath, "runtime", "units");
if (!existsSync(runtimeUnitsDir)) return 0;
let cleaned = 0;
try {
for (const file of readdirSync(runtimeUnitsDir)) {
if (!file.endsWith(".json")) continue;
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
if (!midMatch) continue;
if (hasMilestoneSummary(midMatch[1])) {
try {
unlinkSync(join(runtimeUnitsDir, file));
cleaned++;
} catch { /* non-fatal */ }
}
}
} catch { /* non-fatal */ }
return cleaned;
}

View file

@ -25,7 +25,6 @@ import { truncateWithEllipsis } from "../shared/format-utils.js";
import { nativeParseJsonlTail } from "./native-parser-bridge.js";
import { MAX_JSONL_BYTES, parseJSONL } from "./jsonl-utils.js";
import { nativeWorkingTreeStatus, nativeDiffStat } from "./native-git-bridge.js";
import { getAutoWorktreePath } from "./auto-worktree.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -295,17 +294,13 @@ export function synthesizeCrashRecovery(
* Deep diagnostic from any JSONL source (activity log or session file).
* Replaces the old shallow getLastActivityDiagnostic().
*/
export function getDeepDiagnostic(basePath: string): string | null {
// Try worktree activity logs first if an auto-worktree is active
export function getDeepDiagnostic(basePath: string, worktreePath?: string): string | null {
// Try worktree activity logs first if a worktree path is provided
let trace: ExecutionTrace | null = null;
try {
const mid = readActiveMilestoneId(basePath);
if (mid) {
const wtPath = getAutoWorktreePath(basePath, mid);
if (wtPath) {
const wtActivityDir = join(gsdRoot(wtPath), "activity");
trace = readLastActivityLog(wtActivityDir);
}
if (worktreePath) {
const wtActivityDir = join(gsdRoot(worktreePath), "activity");
trace = readLastActivityLog(wtActivityDir);
}
} catch { /* non-fatal — fall through to root */ }
@ -323,7 +318,7 @@ export function getDeepDiagnostic(basePath: string): string | null {
* Read the active milestone ID directly from STATE.md without async deriveState().
* Looks for `**Active Milestone:** M001` pattern.
*/
function readActiveMilestoneId(basePath: string): string | null {
export function readActiveMilestoneId(basePath: string): string | null {
try {
const statePath = join(gsdRoot(basePath), "STATE.md");
if (!existsSync(statePath)) return null;

View file

@ -167,6 +167,56 @@ function ensureExitHandler(_gsdDir: string): void {
});
}
// ─── Lock Acquisition Helpers ───────────────────────────────────────────────
/**
* Create the onCompromised callback for proper-lockfile.
*
* proper-lockfile fires onCompromised when it detects mtime drift (system sleep,
* event loop stall, etc.). The default handler throws inside setTimeout an
* uncaught exception that crashes or corrupts process state.
*
* False-positive suppression (#1362): If we're still within the stale window
* (30 min since acquisition), the mtime mismatch is from an event loop stall
* during a long LLM call not a real takeover. Log and continue.
*
* PID ownership check (#1578): Past the stale window, check if the lock file
* still contains our PID before declaring compromise. Retry reads tolerate
* transient filesystem hiccups (NFS/CIFS latency, APFS snapshots, etc.) (#2324).
*/
function createLockCompromisedHandler(lockFilePath: string): () => void {
return () => {
const elapsed = Date.now() - _lockAcquiredAt;
if (elapsed < 1_800_000) {
process.stderr.write(
`[gsd] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`,
);
return;
}
const existing = readExistingLockDataWithRetry(lockFilePath);
if (existing && existing.pid === process.pid) {
process.stderr.write(
`[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`,
);
return;
}
_lockCompromised = true;
_releaseFunction = null;
};
}
/**
* Assign module-level lock state after a successful lock acquisition.
*/
function assignLockState(basePath: string, release: () => void, lockFilePath: string): void {
_releaseFunction = release;
_lockedPath = basePath;
_lockPid = process.pid;
_lockCompromised = false;
_lockAcquiredAt = Date.now();
_snapshotLockPath = lockFilePath;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
@ -226,43 +276,10 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
realpath: false,
stale: 1_800_000, // 30 minutes — safe for laptop sleep / long event loop stalls
update: 10_000, // Update lock mtime every 10s to prove liveness
onCompromised: () => {
// proper-lockfile detected mtime drift (system sleep, event loop stall, etc.).
// Default handler throws inside setTimeout — an uncaught exception that crashes
// or corrupts process state.
//
// False-positive suppression (#1362): If we're still within the stale window
// (30 min since acquisition), the mtime mismatch is from an event loop stall
// during a long LLM call — not a real takeover. Log and continue.
const elapsed = Date.now() - _lockAcquiredAt;
if (elapsed < 1_800_000) {
process.stderr.write(
`[gsd] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`,
);
return; // Suppress false positive
}
// Past the stale window — check if the lock file still belongs to us before
// declaring compromise (#1578). Retry reads to tolerate transient filesystem
// hiccups (NFS/CIFS latency, APFS snapshots, etc.) (#2324).
const existing = readExistingLockDataWithRetry(lp);
if (existing && existing.pid === process.pid) {
process.stderr.write(
`[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`,
);
return; // Our PID still owns the lock file — no real takeover
}
// Lock file is gone or owned by another PID after retries — real compromise
_lockCompromised = true;
_releaseFunction = null;
},
onCompromised: createLockCompromisedHandler(lp),
});
_releaseFunction = release;
_lockedPath = basePath;
_lockPid = process.pid;
_lockCompromised = false;
_lockAcquiredAt = Date.now();
_snapshotLockPath = lp; // Snapshot the resolved path for consistent access (#1363)
assignLockState(basePath, release, lp);
// Safety net: clean up lock dir on process exit if _releaseFunction
// wasn't called (e.g., normal exit after clean completion) (#1245).
@ -290,36 +307,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
realpath: false,
stale: 1_800_000, // 30 minutes — match primary lock settings
update: 10_000,
onCompromised: () => {
// Same false-positive suppression as the primary lock (#1512).
// Without this, the retry path fires _lockCompromised unconditionally
// on benign mtime drift (laptop sleep, heavy LLM event loop stalls).
const elapsed = Date.now() - _lockAcquiredAt;
if (elapsed < 1_800_000) {
process.stderr.write(
`[gsd] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`,
);
return;
}
// Check PID ownership before declaring compromise (#1578).
// Retry reads to tolerate transient filesystem hiccups (#2324).
const existing = readExistingLockDataWithRetry(lp);
if (existing && existing.pid === process.pid) {
process.stderr.write(
`[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`,
);
return;
}
_lockCompromised = true;
_releaseFunction = null;
},
onCompromised: createLockCompromisedHandler(lp),
});
_releaseFunction = release;
_lockedPath = basePath;
_lockPid = process.pid;
_lockCompromised = false;
_lockAcquiredAt = Date.now();
_snapshotLockPath = lp; // Snapshot for retry path too (#1363)
assignLockState(basePath, release, lp);
// Safety net — uses centralized handler to avoid double-registration
ensureExitHandler(gsdDir);

View file

@ -51,6 +51,7 @@ import {
getSlice,
insertMilestone,
updateTaskStatus,
getPendingSliceGateCount,
type MilestoneRow,
type SliceRow,
type TaskRow,
@ -709,6 +710,22 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
}
}
// ── Quality gate evaluation check ──────────────────────────────────
// If slice-scoped gates (Q3/Q4) are still pending, pause before execution
// so the gate-evaluate dispatch rule can run parallel sub-agents.
// Slices with zero gate rows (pre-feature or simple) skip straight through.
const pendingGateCount = getPendingSliceGateCount(activeMilestone.id, activeSlice.id);
if (pendingGateCount > 0) {
return {
activeMilestone, activeSlice, activeTask: null,
phase: 'evaluating-gates',
recentDecisions: [], blockers: [],
nextAction: `Evaluate ${pendingGateCount} quality gate(s) for ${activeSlice.id} before execution.`,
registry, requirements,
progress: { milestones: milestoneProgress, slices: sliceProgress, tasks: taskProgress },
};
}
// ── Blocker detection: check completed tasks for blocker_discovered ──
const completedTasks = tasks.filter(t => isStatusDone(t.status));
let blockerTaskId: string | null = null;

View file

@ -49,6 +49,14 @@ completed_at: {{date}}
- {{requirementId}}: {{fromStatus}} → {{toStatus}} — {{evidence}}
## Decision Re-evaluation
<!-- Review decisions from this milestone. OMIT if no decisions need re-evaluation. -->
| Decision | Original Rationale | Still Valid? | Action |
|----------|-------------------|-------------|--------|
| {{decisionId}} | {{originalRationale}} | {{yes/no/partially}} | {{keep/revise/supersede}} |
## Forward Intelligence
<!-- Write what you wish you'd known at the start of this milestone.

View file

@ -8,6 +8,22 @@
- {{mustHave}}
- {{mustHave}}
## Threat Surface
<!-- Q3: How can this be exploited? OMIT ENTIRELY for simple slices with no auth, user input, or data exposure. -->
- **Abuse**: {{abuseScenarios — parameter tampering, replay, privilege escalation, or N/A}}
- **Data exposure**: {{sensitiveDataAccessible — PII, tokens, secrets, or none}}
- **Input trust**: {{untrustedInput — user input reaching DB/API/filesystem, or none}}
## Requirement Impact
<!-- Q4: What existing promises does this break? OMIT ENTIRELY if no existing requirements are affected. -->
- **Requirements touched**: {{requirementIds — e.g. R001, R003, or none}}
- **Re-verify**: {{whatMustBeRetested — e.g. login flow, API contract, or N/A}}
- **Decisions revisited**: {{decisionIds — e.g. D002, or none}}
## Proof Level
<!-- Omit this section entirely for simple slices where the answer is trivially obvious. -->

View file

@ -92,6 +92,19 @@ This milestone is complete only when all are true:
- Each "After this" line must be truthful about proof level: if only fixtures or tests prove it, say so; do not imply the user can already perform the live end-to-end behavior unless that has actually been exercised
-->
## Horizontal Checklist
<!-- Cross-cutting concerns across all slices. Check each that was considered.
OMIT ENTIRELY for trivial milestones. -->
- [ ] Every active R### re-read against new code — still fully satisfied?
- [ ] Every D### from prior milestones re-evaluated — still valid at new scope?
- [ ] Graceful shutdown / cleanup on termination verified
- [ ] Revenue / billing path impact assessed (or N/A)
- [ ] Auth boundary documented — what's protected vs public
- [ ] Shared resource budget confirmed — connection pools, caches, rate limits hold under peak
- [ ] Reconnection / retry strategy verified for every external dependency
## Boundary Map
<!-- Be specific. Name concrete outputs: API endpoints, event payloads, shared types/interfaces,

View file

@ -57,6 +57,15 @@ completed_at: {{date}}
- {{requirementIdOr_none}} — {{what changed}}
## Operational Readiness
<!-- Q8: How will ops know it's healthy/broken? OMIT ENTIRELY for simple slices with no runtime concerns. -->
- **Health signal**: {{howToConfirmHealthy — health endpoint, heartbeat log, metric, or N/A}}
- **Failure signal**: {{howToDetectBroken — error rate spike, alert, log pattern, or N/A}}
- **Recovery**: {{selfRecoverOrRestart — auto-reconnect, circuit breaker, manual restart, or N/A}}
- **Monitoring gaps**: {{silentFailureModes — background jobs, cache eviction, memory pressure, or none}}
## Deviations
<!-- Deviations are unplanned changes to the written plan, not ordinary debugging inside the plan's intended scope. -->

View file

@ -17,6 +17,30 @@ skills_used:
{{description}}
## Failure Modes
<!-- Q5: What breaks when dependencies fail? OMIT ENTIRELY for tasks with no external dependencies. -->
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| {{dependency}} | {{errorStrategy}} | {{timeoutStrategy}} | {{malformedStrategy}} |
## Load Profile
<!-- Q6: What breaks at 10x load? OMIT ENTIRELY for tasks with no shared resources or scaling concerns. -->
- **Shared resources**: {{sharedResources — DB connections, caches, rate limiters, or none}}
- **Per-operation cost**: {{perOpCost — N API calls, M DB queries, K bytes, or trivial}}
- **10x breakpoint**: {{whatBreaksFirst — pool exhaustion, rate limit, memory, or N/A}}
## Negative Tests
<!-- Q7: What negative tests prove robustness? OMIT ENTIRELY for trivial tasks. -->
- **Malformed inputs**: {{malformedInputTests — empty string, null, oversized, wrong type}}
- **Error paths**: {{errorPathTests — network timeout, auth failure, 5xx, invalid JSON}}
- **Boundary conditions**: {{boundaryTests — empty list, max length, zero, off-by-one}}
## Steps
1. {{step}}

View file

@ -10,7 +10,6 @@ import {
verifyExpectedArtifact,
diagnoseExpectedArtifact,
buildLoopRemediationSteps,
selfHealRuntimeRecords,
hasImplementationArtifacts,
} from "../auto-recovery.ts";
import { parseRoadmap, parsePlan } from "../parsers-legacy.ts";
@ -572,85 +571,6 @@ test("verifyExpectedArtifact plan-slice fails after deleting a rendered task pla
}
});
// ─── selfHealRuntimeRecords — worktree base path (#769) ──────────────────
test("selfHealRuntimeRecords clears stale dispatched records (#769)", async (t) => {
// selfHealRuntimeRecords now only clears stale dispatched records (>1h).
// No completedKeySet parameter — deriveState is sole authority.
const worktreeBase = makeTmpBase();
const mainBase = makeTmpBase();
t.after(() => {
cleanup(worktreeBase);
cleanup(mainBase);
});
const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts");
// Write a stale runtime record in the worktree .gsd/runtime/units/
writeUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01", Date.now() - 7200_000, {
phase: "dispatched",
});
// Verify the runtime record exists before heal
const before = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
assert.ok(before, "runtime record should exist before heal");
// Mock ExtensionContext with minimal notify
const notifications: string[] = [];
const mockCtx = {
ui: { notify: (msg: string) => { notifications.push(msg); } },
} as any;
// Call selfHeal with worktreeBase — should clear the stale record
await selfHealRuntimeRecords(worktreeBase, mockCtx);
// The stale record should be cleared
const after = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
assert.equal(after, null, "runtime record should be cleared after heal");
assert.ok(notifications.some(n => n.includes("Self-heal")), "should emit self-heal notification");
// Write a stale record at mainBase
writeUnitRuntimeRecord(mainBase, "run-uat", "M001/S01", Date.now() - 7200_000, {
phase: "dispatched",
});
await selfHealRuntimeRecords(mainBase, mockCtx);
// The record at mainBase should also be cleared by the stale timeout (>1h)
const afterMain = readUnitRuntimeRecord(mainBase, "run-uat", "M001/S01");
assert.equal(afterMain, null, "stale record at main base should be cleared by timeout");
});
// ─── #1625: selfHealRuntimeRecords on resume clears paused-session leftovers ──
test("selfHealRuntimeRecords clears recently-paused dispatched records on resume (#1625)", async (t) => {
// When pauseAuto closes out a unit but clearUnitRuntimeRecord silently fails
// (e.g. permission error), selfHealRuntimeRecords on resume should still
// clean up stale dispatched records that are >1h old.
const base = makeTmpBase();
t.after(() => cleanup(base));
const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts");
// Simulate a record left behind after a pause — aged >1h to be considered stale
writeUnitRuntimeRecord(base, "execute-task", "M001/S01/T01", Date.now() - 3700_000, {
phase: "dispatched",
});
const before = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01");
assert.ok(before, "dispatched record should exist before resume heal");
assert.equal(before!.phase, "dispatched");
const notifications: string[] = [];
const mockCtx = {
ui: { notify: (msg: string) => { notifications.push(msg); } },
} as any;
await selfHealRuntimeRecords(base, mockCtx);
const after = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01");
assert.equal(after, null, "stale dispatched record should be cleared on resume (#1625)");
});
// ─── #793: invalidateAllCaches unblocks skip-loop ─────────────────────────
// When the skip-loop breaker fires, it must call invalidateAllCaches() (not
// just invalidateStateCache()) to clear path/parse caches that deriveState

View file

@ -125,9 +125,9 @@ console.log('\n=== complete-slice: schema v6 migration ===');
const adapter = _getAdapter()!;
// Verify schema version is current (v10 after M001 planning migrations)
// Verify schema version is current (v12 after quality gates table)
const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
assertEq(versionRow?.['v'], 11, 'schema version should be 11');
assertEq(versionRow?.['v'], 12, 'schema version should be 12');
// Verify slices table has full_summary_md and full_uat_md columns
const cols = adapter.prepare("PRAGMA table_info(slices)").all();

View file

@ -109,9 +109,9 @@ console.log('\n=== complete-task: schema v5 migration ===');
const adapter = _getAdapter()!;
// Verify schema version is current (v11 after state machine migration)
// Verify schema version is current (v12 after quality gates table)
const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
assertEq(versionRow?.['v'], 11, 'schema version should be 11');
assertEq(versionRow?.['v'], 12, 'schema version should be 12');
// Verify all 4 new tables exist
const tables = adapter.prepare(

View file

@ -48,35 +48,32 @@ test("#2313: completed-units.json should not be blindly wiped to [] on milestone
// ─── Bug 2: metrics.json should be in the sync file lists ──────────────────
test("#2313: syncStateToProjectRoot should sync metrics.json", () => {
const syncSrcPath = join(import.meta.dirname, "..", "auto-worktree-sync.ts");
const syncSrcPath = join(import.meta.dirname, "..", "auto-worktree.ts");
const syncSrc = readFileSync(syncSrcPath, "utf-8");
// syncStateToProjectRoot should copy metrics.json from worktree to project root
assert.ok(
syncSrc.includes("metrics.json"),
"auto-worktree-sync.ts should reference metrics.json for sync",
"auto-worktree.ts should reference metrics.json for sync",
);
});
test("#2313: syncWorktreeStateBack should include metrics.json in root files list", () => {
test("#2313: syncWorktreeStateBack should include metrics.json in ROOT_STATE_FILES", () => {
const autoWorktreeSrcPath = join(import.meta.dirname, "..", "auto-worktree.ts");
const autoWorktreeSrc = readFileSync(autoWorktreeSrcPath, "utf-8");
// Find the rootFiles array in syncWorktreeStateBack
const syncBackIdx = autoWorktreeSrc.indexOf("syncWorktreeStateBack");
assert.ok(syncBackIdx !== -1, "syncWorktreeStateBack exists");
// Find the ROOT_STATE_FILES constant (single source of truth for both sync directions)
const constIdx = autoWorktreeSrc.indexOf("ROOT_STATE_FILES");
assert.ok(constIdx !== -1, "ROOT_STATE_FILES constant exists");
const rootFilesIdx = autoWorktreeSrc.indexOf("rootFiles", syncBackIdx);
assert.ok(rootFilesIdx !== -1, "rootFiles list exists in syncWorktreeStateBack");
// Get the rootFiles array content
const arrayStart = autoWorktreeSrc.indexOf("[", rootFilesIdx);
// Get the array content
const arrayStart = autoWorktreeSrc.indexOf("[", constIdx);
const arrayEnd = autoWorktreeSrc.indexOf("]", arrayStart);
const rootFilesBlock = autoWorktreeSrc.slice(arrayStart, arrayEnd);
assert.ok(
rootFilesBlock.includes("metrics.json"),
"metrics.json should be in syncWorktreeStateBack rootFiles list",
"metrics.json should be in ROOT_STATE_FILES list",
);
});

View file

@ -0,0 +1,189 @@
// Quality gate dispatch + state derivation tests
// Verifies the evaluating-gates phase and dispatch rule behavior.
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
upsertSlicePlanning,
upsertTaskPlanning,
insertGateRow,
saveGateResult,
markAllGatesOmitted,
getPendingSliceGateCount,
} from "../gsd-db.ts";
import { deriveState, invalidateStateCache } from "../state.ts";
import { renderPlanFromDb } from "../markdown-renderer.ts";
import { invalidateAllCaches } from "../cache.ts";
function setupTestProject(): { tmpDir: string; dbPath: string } {
const tmpDir = mkdtempSync(join(tmpdir(), "gate-dispatch-"));
const dbPath = join(tmpDir, ".gsd", "gsd.db");
mkdirSync(join(tmpDir, ".gsd"), { recursive: true });
openDatabase(dbPath);
// Create milestone
insertMilestone({
id: "M001",
title: "Test Milestone",
status: "active",
});
// Create slice
insertSlice({
milestoneId: "M001",
id: "S01",
title: "Test Slice",
status: "pending",
risk: "medium",
depends: [],
});
// Write roadmap file (required for deriveState)
const milestoneDir = join(tmpDir, ".gsd", "milestones", "M001");
mkdirSync(milestoneDir, { recursive: true });
writeFileSync(
join(milestoneDir, "M001-ROADMAP.md"),
[
"# M001: Test Milestone",
"",
"## Vision",
"Test milestone vision.",
"",
"## Success Criteria",
"- Test criteria",
"",
"## Delivery Sequence",
"- [ ] **S01: Test Slice** `risk:medium`",
" After this: test demo",
"",
].join("\n"),
);
return { tmpDir, dbPath };
}
function planSlice(tmpDir: string) {
upsertSlicePlanning("M001", "S01", {
goal: "Test goal",
successCriteria: "Test criteria",
proofLevel: "contract",
integrationClosure: "",
observabilityImpact: "Run tests",
});
insertTask({
id: "T01",
sliceId: "S01",
milestoneId: "M001",
title: "Test Task",
status: "pending",
});
upsertTaskPlanning("M001", "S01", "T01", {
title: "Test Task",
description: "Implement test",
estimate: "1h",
files: ["src/test.ts"],
verify: "npm test",
inputs: [],
expectedOutput: ["src/test.ts"],
observabilityImpact: "",
fullPlanMd: "",
});
}
describe("evaluating-gates phase", () => {
let tmpDir: string;
beforeEach(() => {
const setup = setupTestProject();
tmpDir = setup.tmpDir;
});
afterEach(() => {
invalidateAllCaches();
invalidateStateCache();
closeDatabase();
rmSync(tmpDir, { recursive: true, force: true });
});
test("state returns evaluating-gates when slice gates are pending", async () => {
planSlice(tmpDir);
await renderPlanFromDb(tmpDir, "M001", "S01");
// Seed gates as pending
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" });
invalidateStateCache();
const state = await deriveState(tmpDir);
assert.equal(state.phase, "evaluating-gates");
assert.ok(state.nextAction.includes("quality gate"));
});
test("state returns executing when all gates are resolved", async () => {
planSlice(tmpDir);
await renderPlanFromDb(tmpDir, "M001", "S01");
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" });
saveGateResult({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", verdict: "pass", rationale: "OK", findings: "" });
saveGateResult({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", verdict: "omitted", rationale: "N/A", findings: "" });
invalidateStateCache();
const state = await deriveState(tmpDir);
assert.equal(state.phase, "executing");
});
test("state returns executing when no gates exist (backward compat)", async () => {
planSlice(tmpDir);
await renderPlanFromDb(tmpDir, "M001", "S01");
// No gates seeded at all
invalidateStateCache();
const state = await deriveState(tmpDir);
assert.equal(state.phase, "executing");
});
test("markAllGatesOmitted clears evaluating-gates phase", async () => {
planSlice(tmpDir);
await renderPlanFromDb(tmpDir, "M001", "S01");
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" });
invalidateStateCache();
assert.equal((await deriveState(tmpDir)).phase, "evaluating-gates");
markAllGatesOmitted("M001", "S01");
invalidateStateCache();
assert.equal((await deriveState(tmpDir)).phase, "executing");
});
test("task-scoped gates do not block evaluating-gates phase", async () => {
planSlice(tmpDir);
await renderPlanFromDb(tmpDir, "M001", "S01");
// Only task-scoped gates — no slice-scoped gates
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" });
invalidateStateCache();
const state = await deriveState(tmpDir);
// Should be executing, not evaluating-gates, because Q5 is task-scoped
assert.equal(state.phase, "executing");
});
test("getPendingSliceGateCount ignores task-scoped gates", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" });
assert.equal(getPendingSliceGateCount("M001", "S01"), 1);
});
});

View file

@ -0,0 +1,156 @@
// Quality gate DB storage tests
// Verifies CRUD operations on the quality_gates table.
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
openDatabase,
closeDatabase,
insertGateRow,
saveGateResult,
getPendingGates,
getGateResults,
markAllGatesOmitted,
getPendingSliceGateCount,
insertMilestone,
insertSlice,
} from "../gsd-db.ts";
describe("quality_gates CRUD", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "gate-test-"));
dbPath = join(tmpDir, "gsd.db");
openDatabase(dbPath);
// Seed parent rows
insertMilestone({
id: "M001",
title: "Test Milestone",
status: "active",
});
insertSlice({
milestoneId: "M001",
id: "S01",
title: "Test Slice",
status: "pending",
risk: "medium",
depends: [],
});
});
afterEach(() => {
closeDatabase();
rmSync(tmpDir, { recursive: true, force: true });
});
test("insertGateRow creates a pending gate", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
const pending = getPendingGates("M001", "S01");
assert.equal(pending.length, 1);
assert.equal(pending[0].gate_id, "Q3");
assert.equal(pending[0].status, "pending");
assert.equal(pending[0].scope, "slice");
});
test("insertGateRow with INSERT OR IGNORE is idempotent", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
const all = getGateResults("M001", "S01");
assert.equal(all.length, 1);
});
test("saveGateResult updates status and verdict", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
saveGateResult({
milestoneId: "M001",
sliceId: "S01",
gateId: "Q3",
verdict: "pass",
rationale: "No auth surface",
findings: "This slice has no user-facing endpoints.",
});
const results = getGateResults("M001", "S01");
assert.equal(results.length, 1);
assert.equal(results[0].status, "complete");
assert.equal(results[0].verdict, "pass");
assert.equal(results[0].rationale, "No auth surface");
assert.ok(results[0].evaluated_at);
});
test("getPendingGates filters by scope", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" });
const sliceGates = getPendingGates("M001", "S01", "slice");
assert.equal(sliceGates.length, 1);
assert.equal(sliceGates[0].gate_id, "Q3");
const taskGates = getPendingGates("M001", "S01", "task");
assert.equal(taskGates.length, 1);
assert.equal(taskGates[0].gate_id, "Q5");
});
test("markAllGatesOmitted marks all pending gates as omitted", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" });
markAllGatesOmitted("M001", "S01");
const pending = getPendingGates("M001", "S01");
assert.equal(pending.length, 0);
const all = getGateResults("M001", "S01");
assert.equal(all.length, 3);
for (const g of all) {
assert.equal(g.status, "omitted");
assert.equal(g.verdict, "omitted");
}
});
test("getPendingSliceGateCount returns correct count", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q3", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" });
assert.equal(getPendingSliceGateCount("M001", "S01"), 2);
saveGateResult({
milestoneId: "M001", sliceId: "S01", gateId: "Q3",
verdict: "pass", rationale: "OK", findings: "",
});
assert.equal(getPendingSliceGateCount("M001", "S01"), 1);
});
test("task-scoped gates with different task_id are distinct", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T01" });
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q5", scope: "task", taskId: "T02" });
const all = getGateResults("M001", "S01", "task");
assert.equal(all.length, 2);
});
test("getGateResults returns empty for nonexistent slice", () => {
const results = getGateResults("M001", "S99");
assert.equal(results.length, 0);
});
test("saveGateResult with flag verdict preserves findings", () => {
insertGateRow({ milestoneId: "M001", sliceId: "S01", gateId: "Q4", scope: "slice" });
saveGateResult({
milestoneId: "M001", sliceId: "S01", gateId: "Q4",
verdict: "flag", rationale: "Breaks R003",
findings: "## R003 Impact\n\n- Login flow must be re-tested\n- Session token format changed",
});
const results = getGateResults("M001", "S01", "slice");
const q4 = results.find(g => g.gate_id === "Q4")!;
assert.equal(q4.verdict, "flag");
assert.ok(q4.findings.includes("R003 Impact"));
});
});

View file

@ -64,7 +64,7 @@ describe('gsd-db', () => {
// Check schema_version table
const adapter = _getAdapter()!;
const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get();
assert.deepStrictEqual(version?.['version'], 11, 'schema version should be 11');
assert.deepStrictEqual(version?.['version'], 12, 'schema version should be 12');
// Check tables exist by querying them
const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get();

View file

@ -363,7 +363,7 @@ test('md-importer: schema v1→v2 migration', () => {
openDatabase(':memory:');
const adapter = _getAdapter();
const version = adapter?.prepare('SELECT MAX(version) as v FROM schema_version').get();
assert.deepStrictEqual(version?.v, 11, 'new DB should be at schema version 11');
assert.deepStrictEqual(version?.v, 12, 'new DB should be at schema version 12');
// Artifacts table should exist
const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get();

View file

@ -323,9 +323,9 @@ test('memory-store: schema includes memories table', () => {
const viewCount = adapter.prepare('SELECT count(*) as cnt FROM active_memories').get();
assert.deepStrictEqual(viewCount?.['cnt'], 0, 'active_memories view should exist');
// Verify schema version is 11 (after state machine migration)
// Verify schema version is 12 (after quality gates table)
const version = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
assert.deepStrictEqual(version?.['v'], 11, 'schema version should be 11');
assert.deepStrictEqual(version?.['v'], 12, 'schema version should be 12');
closeDatabase();
});

View file

@ -0,0 +1,347 @@
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { extractSection } from "../files.ts";
import { createTestContext } from "./test-helpers.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const templatesDir = join(__dirname, "..", "templates");
const promptsDir = join(__dirname, "..", "prompts");
const { assertTrue, report } = createTestContext();
function loadTemplate(name: string): string {
return readFileSync(join(templatesDir, `${name}.md`), "utf-8");
}
function loadPrompt(name: string): string {
return readFileSync(join(promptsDir, `${name}.md`), "utf-8");
}
// ═══════════════════════════════════════════════════════════════════════════
// Level 1: Templates contain quality gate headings
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== Level 1: Templates contain quality gate headings ===");
{
const plan = loadTemplate("plan");
assertTrue(plan.includes("## Threat Surface"), "plan.md contains ## Threat Surface");
assertTrue(plan.includes("## Requirement Impact"), "plan.md contains ## Requirement Impact");
const taskPlan = loadTemplate("task-plan");
assertTrue(taskPlan.includes("## Failure Modes"), "task-plan.md contains ## Failure Modes");
assertTrue(taskPlan.includes("## Load Profile"), "task-plan.md contains ## Load Profile");
assertTrue(taskPlan.includes("## Negative Tests"), "task-plan.md contains ## Negative Tests");
const sliceSummary = loadTemplate("slice-summary");
assertTrue(sliceSummary.includes("## Operational Readiness"), "slice-summary.md contains ## Operational Readiness");
const roadmap = loadTemplate("roadmap");
assertTrue(roadmap.includes("## Horizontal Checklist"), "roadmap.md contains ## Horizontal Checklist");
const milestoneSummary = loadTemplate("milestone-summary");
assertTrue(milestoneSummary.includes("## Decision Re-evaluation"), "milestone-summary.md contains ## Decision Re-evaluation");
}
// ═══════════════════════════════════════════════════════════════════════════
// Level 2: Prompts reference quality gates
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== Level 2: Prompts reference quality gates ===");
{
const planSlice = loadPrompt("plan-slice");
assertTrue(planSlice.includes("Threat Surface"), "plan-slice.md mentions Threat Surface");
assertTrue(planSlice.includes("Requirement Impact"), "plan-slice.md mentions Requirement Impact");
assertTrue(planSlice.toLowerCase().includes("quality gate"), "plan-slice.md mentions quality gate");
const guidedPlanSlice = loadPrompt("guided-plan-slice");
assertTrue(
guidedPlanSlice.includes("Threat Surface") || guidedPlanSlice.includes("Q3"),
"guided-plan-slice.md mentions Threat Surface or Q3"
);
const executeTask = loadPrompt("execute-task");
assertTrue(executeTask.includes("Failure Modes"), "execute-task.md mentions Failure Modes");
assertTrue(executeTask.includes("Load Profile"), "execute-task.md mentions Load Profile");
assertTrue(executeTask.includes("Negative Tests"), "execute-task.md mentions Negative Tests");
const guidedExecuteTask = loadPrompt("guided-execute-task");
assertTrue(
guidedExecuteTask.includes("Failure Modes") || guidedExecuteTask.includes("Q5"),
"guided-execute-task.md mentions Failure Modes or Q5"
);
const completeSlice = loadPrompt("complete-slice");
assertTrue(completeSlice.includes("Operational Readiness"), "complete-slice.md mentions Operational Readiness");
const guidedCompleteSlice = loadPrompt("guided-complete-slice");
assertTrue(
guidedCompleteSlice.includes("Operational Readiness") || guidedCompleteSlice.includes("Q8"),
"guided-complete-slice.md mentions Operational Readiness or Q8"
);
const completeMilestone = loadPrompt("complete-milestone");
assertTrue(completeMilestone.includes("Horizontal Checklist"), "complete-milestone.md mentions Horizontal Checklist");
assertTrue(completeMilestone.includes("Decision Re-evaluation"), "complete-milestone.md mentions Decision Re-evaluation");
const planMilestone = loadPrompt("plan-milestone");
assertTrue(planMilestone.toLowerCase().includes("horizontal checklist"), "plan-milestone.md mentions horizontal checklist");
const guidedPlanMilestone = loadPrompt("guided-plan-milestone");
assertTrue(guidedPlanMilestone.includes("Horizontal Checklist"), "guided-plan-milestone.md mentions Horizontal Checklist");
const reassess = loadPrompt("reassess-roadmap");
assertTrue(reassess.includes("Threat Surface"), "reassess-roadmap.md mentions Threat Surface");
assertTrue(reassess.includes("Operational Readiness"), "reassess-roadmap.md mentions Operational Readiness");
assertTrue(reassess.includes("Horizontal Checklist"), "reassess-roadmap.md mentions Horizontal Checklist");
const replan = loadPrompt("replan-slice");
assertTrue(replan.includes("Threat Surface"), "replan-slice.md mentions Threat Surface");
}
// ═══════════════════════════════════════════════════════════════════════════
// Level 3: Parser backward compatibility — extractSection handles new headings
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== Level 3: extractSection backward compatibility ===");
{
// Old-style slice plan (no quality gate sections)
const oldPlan = `# S01: Auth Flow
**Goal:** Build login
**Demo:** User can log in
## Must-Haves
- Login form works
- Session persists
## Proof Level
- This slice proves: integration
## Tasks
- [ ] **T01: Build login** \`est:1h\`
`;
// New-style slice plan (with quality gate sections)
const newPlan = `# S01: Auth Flow
**Goal:** Build login
**Demo:** User can log in
## Must-Haves
- Login form works
- Session persists
## Threat Surface
- **Abuse**: Credential stuffing, brute force login attempts
- **Data exposure**: Session tokens in cookies, password in request body
- **Input trust**: Username/password from form input reaching DB query
## Requirement Impact
- **Requirements touched**: R001, R003
- **Re-verify**: Login flow, session management
- **Decisions revisited**: D002
## Proof Level
- This slice proves: integration
## Tasks
- [ ] **T01: Build login** \`est:1h\`
`;
// Old plan: quality gate sections return null (not found)
assertTrue(
extractSection(oldPlan, "Threat Surface") === null,
"extractSection returns null for Threat Surface on old plan"
);
assertTrue(
extractSection(oldPlan, "Requirement Impact") === null,
"extractSection returns null for Requirement Impact on old plan"
);
// Old plan: core sections still parse correctly
const oldMustHaves = extractSection(oldPlan, "Must-Haves");
assertTrue(
oldMustHaves !== null && oldMustHaves.includes("Login form works"),
"extractSection still parses Must-Haves on old plan"
);
// New plan: quality gate sections are extracted
const threatSurface = extractSection(newPlan, "Threat Surface");
assertTrue(
threatSurface !== null && threatSurface.includes("Credential stuffing"),
"extractSection extracts Threat Surface content from new plan"
);
const reqImpact = extractSection(newPlan, "Requirement Impact");
assertTrue(
reqImpact !== null && reqImpact.includes("R001"),
"extractSection extracts Requirement Impact content from new plan"
);
// New plan: core sections still parse correctly
const newMustHaves = extractSection(newPlan, "Must-Haves");
assertTrue(
newMustHaves !== null && newMustHaves.includes("Login form works"),
"extractSection still parses Must-Haves on new plan"
);
// Task plan: Failure Modes
const oldTaskPlan = `# T01: Build Login
## Description
Build the login endpoint.
## Steps
1. Create route
`;
const newTaskPlan = `# T01: Build Login
## Description
Build the login endpoint.
## Failure Modes
| Dependency | On error | On timeout | On malformed response |
|------------|----------|-----------|----------------------|
| Auth DB | Return 500 | 3s timeout, retry once | Reject, log warning |
## Steps
1. Create route
`;
assertTrue(
extractSection(oldTaskPlan, "Failure Modes") === null,
"extractSection returns null for Failure Modes on old task plan"
);
const failureModes = extractSection(newTaskPlan, "Failure Modes");
assertTrue(
failureModes !== null && failureModes.includes("Auth DB"),
"extractSection extracts Failure Modes content from new task plan"
);
// Slice summary: Operational Readiness
const oldSummary = `# S01: Auth Flow
**Built login with session management**
## Verification
All tests pass.
## Deviations
None.
`;
const newSummary = `# S01: Auth Flow
**Built login with session management**
## Verification
All tests pass.
## Operational Readiness
- **Health signal**: /health endpoint returns 200 with session count
- **Failure signal**: Auth error rate > 5% triggers alert
- **Recovery**: Stateless restart clears nothing
- **Monitoring gaps**: None
## Deviations
None.
`;
assertTrue(
extractSection(oldSummary, "Operational Readiness") === null,
"extractSection returns null for Operational Readiness on old summary"
);
const opReadiness = extractSection(newSummary, "Operational Readiness");
assertTrue(
opReadiness !== null && opReadiness.includes("/health endpoint"),
"extractSection extracts Operational Readiness content from new summary"
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Level 4: Template section ordering is correct
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== Level 4: Template section ordering ===");
{
const plan = loadTemplate("plan");
const mustHavesIdx = plan.indexOf("## Must-Haves");
const threatIdx = plan.indexOf("## Threat Surface");
const proofIdx = plan.indexOf("## Proof Level");
assertTrue(
mustHavesIdx < threatIdx && threatIdx < proofIdx,
"plan.md: Threat Surface is between Must-Haves and Proof Level"
);
const reqImpactIdx = plan.indexOf("## Requirement Impact");
assertTrue(
threatIdx < reqImpactIdx && reqImpactIdx < proofIdx,
"plan.md: Requirement Impact is between Threat Surface and Proof Level"
);
const taskPlan = loadTemplate("task-plan");
const descIdx = taskPlan.indexOf("## Description");
const failIdx = taskPlan.indexOf("## Failure Modes");
const stepsIdx = taskPlan.indexOf("## Steps");
assertTrue(
descIdx < failIdx && failIdx < stepsIdx,
"task-plan.md: Failure Modes is between Description and Steps"
);
const loadIdx = taskPlan.indexOf("## Load Profile");
const negIdx = taskPlan.indexOf("## Negative Tests");
assertTrue(
failIdx < loadIdx && loadIdx < negIdx && negIdx < stepsIdx,
"task-plan.md: Failure Modes < Load Profile < Negative Tests < Steps"
);
const sliceSummary = loadTemplate("slice-summary");
const reqInvalidIdx = sliceSummary.indexOf("## Requirements Invalidated");
const opIdx = sliceSummary.indexOf("## Operational Readiness");
const devIdx = sliceSummary.indexOf("## Deviations");
assertTrue(
reqInvalidIdx < opIdx && opIdx < devIdx,
"slice-summary.md: Operational Readiness is between Requirements Invalidated and Deviations"
);
const roadmap = loadTemplate("roadmap");
const horizIdx = roadmap.indexOf("## Horizontal Checklist");
const boundaryIdx = roadmap.indexOf("## Boundary Map");
assertTrue(
horizIdx > 0 && horizIdx < boundaryIdx,
"roadmap.md: Horizontal Checklist is before Boundary Map"
);
const milestoneSummary = loadTemplate("milestone-summary");
const reqChangesIdx = milestoneSummary.indexOf("## Requirement Changes");
const decRevalIdx = milestoneSummary.indexOf("## Decision Re-evaluation");
const fwdIntelIdx = milestoneSummary.indexOf("## Forward Intelligence");
assertTrue(
reqChangesIdx < decRevalIdx && decRevalIdx < fwdIntelIdx,
"milestone-summary.md: Decision Re-evaluation is between Requirement Changes and Forward Intelligence"
);
}
report();

View file

@ -503,7 +503,8 @@ console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue
// Artifact Resolution: resolveExpectedArtifactPath for replan-slice (#858)
// ═══════════════════════════════════════════════════════════════════════════
import { resolveExpectedArtifactPath, verifyExpectedArtifact } from '../auto-recovery.ts';
import { resolveExpectedArtifactPath } from '../auto-artifact-paths.ts';
import { verifyExpectedArtifact } from '../auto-recovery.ts';
describe('replan-slice', () => {

View file

@ -44,7 +44,7 @@ console.log('\n── Tool naming: registration count ──');
const pi = makeMockPi();
registerDbTools(pi);
assert.deepStrictEqual(pi.tools.length, 26, 'Should register exactly 26 tools (13 canonical + 13 aliases)');
assert.deepStrictEqual(pi.tools.length, 27, 'Should register exactly 27 tools (13 canonical + 13 aliases + 1 gate tool)');
// ─── Both names exist for each pair ──────────────────────────────────────────

View file

@ -6,7 +6,8 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { deriveState, isValidationTerminal } from "../state.ts";
import { resolveExpectedArtifactPath, verifyExpectedArtifact, diagnoseExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.ts";
import { resolveExpectedArtifactPath, diagnoseExpectedArtifact } from "../auto-artifact-paths.ts";
import { verifyExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.ts";
import { resolveDispatch, type DispatchContext } from "../auto-dispatch.ts";
import type { GSDState } from "../types.ts";
import { clearPathCache } from "../paths.ts";

View file

@ -226,8 +226,6 @@ describe("verification-gate: execution", () => {
test("all commands pass → gate passes", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["echo hello", "echo world"],
});
@ -243,8 +241,6 @@ describe("verification-gate: execution", () => {
test("one command fails → gate fails with exit code + stderr", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["echo ok", "sh -c 'echo err >&2; exit 1'"],
});
@ -257,8 +253,6 @@ describe("verification-gate: execution", () => {
test("no commands discovered → gate passes with 0 checks", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
});
assert.equal(result.passed, true);
@ -268,8 +262,6 @@ describe("verification-gate: execution", () => {
test("command not found → exit code 127", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["__nonexistent_command_xyz_42__"],
});
@ -289,8 +281,6 @@ describe("verification-gate: execution", () => {
const script = [
`import { runVerificationGate } from ${JSON.stringify(pathToFileURL(gatePath).href)};`,
`runVerificationGate({`,
` basePath: ${JSON.stringify(tmp)},`,
` unitId: "T-DEP",`,
` cwd: ${JSON.stringify(tmp)},`,
` preferenceCommands: ["echo dep0190-check"],`,
`});`,
@ -317,8 +307,6 @@ describe("verification-gate: execution", () => {
test("each check has durationMs", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
cwd: tmp,
preferenceCommands: ["echo fast"],
});
@ -330,8 +318,6 @@ describe("verification-gate: execution", () => {
test("one command fails — remaining commands still run (non-short-circuit)", () => {
// First fails, second and third should still execute
const result = runVerificationGate({
basePath: tmp,
unitId: "T02",
cwd: tmp,
preferenceCommands: [
"sh -c 'exit 1'",
@ -351,8 +337,6 @@ describe("verification-gate: execution", () => {
test("gate execution uses cwd for spawnSync", () => {
// pwd should report the temp dir
const result = runVerificationGate({
basePath: tmp,
unitId: "T02",
cwd: tmp,
preferenceCommands: ["pwd"],
});

View file

@ -27,7 +27,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { syncProjectRootToWorktree } from '../auto-worktree-sync.ts';
import { syncProjectRootToWorktree } from '../auto-worktree.ts';
import { syncGsdStateToWorktree, syncWorktreeStateBack } from '../auto-worktree.ts';
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';

View file

@ -29,7 +29,7 @@ import {
import { join } from "node:path";
import { tmpdir } from "node:os";
import { syncProjectRootToWorktree } from "../auto-worktree-sync.ts";
import { syncProjectRootToWorktree } from "../auto-worktree.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertTrue, assertEq, report } = createTestContext();

View file

@ -6,8 +6,10 @@ import {
insertTask,
upsertSlicePlanning,
upsertTaskPlanning,
insertGateRow,
_getAdapter,
} from "../gsd-db.js";
import type { GateId } from "../types.js";
import { invalidateStateCache } from "../state.js";
import { renderPlanFromDb } from "../markdown-renderer.js";
import { renderAllProjections } from "../workflow-projections.js";
@ -190,6 +192,20 @@ export async function handlePlanSlice(
fullPlanMd: task.fullPlanMd,
});
}
// Seed quality gate rows inside the transaction — all-or-nothing with
// the plan data so a crash can't leave orphaned gates without tasks.
const sliceGates: GateId[] = ["Q3", "Q4"];
for (const gid of sliceGates) {
insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: gid, scope: "slice" });
}
const taskGates: GateId[] = ["Q5", "Q6", "Q7"];
for (const task of params.tasks) {
for (const gid of taskGates) {
insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: gid, scope: "task", taskId: task.taskId });
}
}
insertGateRow({ milestoneId: params.milestoneId, sliceId: params.sliceId, gateId: "Q8", scope: "slice" });
});
} catch (err) {
return { error: `db write failed: ${(err as Error).message}` };

View file

@ -11,6 +11,7 @@ export type Phase =
| "discussing"
| "researching"
| "planning"
| "evaluating-gates"
| "executing"
| "verifying"
| "summarizing"
@ -557,3 +558,32 @@ export interface CompleteSliceParams {
/** Optional caller-provided reason this action was triggered */
triggerReason?: string;
}
// ─── Quality Gates ───────────────────────────────────────────────────────
export type GateId = "Q3" | "Q4" | "Q5" | "Q6" | "Q7" | "Q8";
export type GateScope = "slice" | "task";
export type GateStatus = "pending" | "complete" | "omitted";
export type GateVerdict = "pass" | "flag" | "omitted" | "";
export interface GateRow {
milestone_id: string;
slice_id: string;
gate_id: GateId;
scope: GateScope;
task_id: string;
status: GateStatus;
verdict: GateVerdict;
rationale: string;
findings: string;
evaluated_at: string | null;
}
/** Configuration for parallel quality gate evaluation during slice planning. */
export interface GateEvaluationConfig {
enabled: boolean;
/** Which slice-scoped gates to evaluate in parallel. Default: ['Q3', 'Q4']. */
slice_gates?: string[];
/** Whether to evaluate task-level gates (Q5/Q6/Q7) via reactive-execute. Default: true when enabled. */
task_gates?: boolean;
}

View file

@ -220,8 +220,6 @@ function sanitizeCommand(cmd: string): string | null {
}
export interface RunVerificationGateOptions {
basePath: string;
unitId: string;
cwd: string;
preferenceCommands?: string[];
taskPlanVerify?: string;

View file

@ -235,8 +235,9 @@ export function getSliceBranchName(milestoneId: string, sliceId: string, worktre
return `gsd/${milestoneId}/${sliceId}`;
}
/** Regex that matches both plain and worktree-namespaced slice branches. */
export const SLICE_BRANCH_RE = /^gsd\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+(?:-[a-z0-9]{6})?)\/(S\d+)$/;
/** Re-export for backward compatibility — canonical definition in branch-patterns.ts */
export { SLICE_BRANCH_RE } from "./branch-patterns.js";
import { SLICE_BRANCH_RE } from "./branch-patterns.js";
/**
* Parse a slice branch name into its components.

View file

@ -29,6 +29,8 @@ function phasePresentation(phase: string): { label: string; tone: PhaseTone } {
return { label: "Replanning", tone: "info" }
case "completing-milestone":
return { label: "Completing", tone: "info" }
case "evaluating-gates":
return { label: "Evaluating Gates", tone: "info" }
default:
return { label: phase, tone: "muted" }
}