From 5eb4d94e100671d0630f26bb9c8818090a2791d9 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sun, 15 Mar 2026 20:10:59 -0500 Subject: [PATCH] feat: add /gsd steer command for hard-steering plan documents (#82) (#566) Adds `/gsd steer ` command that registers user overrides in `.gsd/OVERRIDES.md`. Active overrides are injected into all prompts. A `rewrite-docs` dispatch unit propagates overrides across plan docs. Addresses all review concerns from PR #409: - resolveAllOverrides wired into handleAgentEnd - Circuit breaker (max 3 attempts, then force-resolve) - verifyExpectedArtifact validates OVERRIDES.md state - Milestone-only unitId when no active slice - Test temp dirs cleaned up --- .../extensions/gsd/auto-dashboard.ts | 3 + src/resources/extensions/gsd/auto-dispatch.ts | 32 ++++- src/resources/extensions/gsd/auto-prompts.ts | 87 +++++++++++- src/resources/extensions/gsd/auto-recovery.ts | 12 ++ src/resources/extensions/gsd/auto.ts | 15 +- src/resources/extensions/gsd/commands.ts | 60 +++++++- src/resources/extensions/gsd/files.ts | 102 +++++++++++++- src/resources/extensions/gsd/index.ts | 6 +- src/resources/extensions/gsd/paths.ts | 2 + .../extensions/gsd/prompts/execute-task.md | 2 + .../extensions/gsd/prompts/rewrite-docs.md | 32 +++++ .../extensions/gsd/prompts/system.md | 1 + .../extensions/gsd/tests/overrides.test.ts | 131 ++++++++++++++++++ 13 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 src/resources/extensions/gsd/prompts/rewrite-docs.md create mode 100644 src/resources/extensions/gsd/tests/overrides.test.ts diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index a31843876..cdcf1b1f1 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -49,6 +49,7 @@ export function unitVerb(unitType: string): string { case "execute-task": return "executing"; case "complete-slice": return "completing"; case "replan-slice": return "replanning"; + case "rewrite-docs": return "rewriting"; case "reassess-roadmap": return "reassessing"; case "run-uat": return "running UAT"; default: return unitType; @@ -65,6 +66,7 @@ export function unitPhaseLabel(unitType: string): string { case "execute-task": return "EXECUTE"; case "complete-slice": return "COMPLETE"; case "replan-slice": return "REPLAN"; + case "rewrite-docs": return "REWRITE"; case "reassess-roadmap": return "REASSESS"; case "run-uat": return "UAT"; default: return unitType.toUpperCase(); @@ -88,6 +90,7 @@ function peekNext(unitType: string, state: GSDState): string { case "execute-task": return `continue ${sid}`; case "complete-slice": return "reassess roadmap"; case "replan-slice": return `re-execute ${sid}`; + case "rewrite-docs": return "continue execution"; case "reassess-roadmap": return "advance to next slice"; case "run-uat": return "reassess roadmap"; default: return ""; diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 8d6b9341e..6ba742818 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -12,7 +12,7 @@ import type { GSDState } from "./types.js"; import type { GSDPreferences } from "./preferences.js"; import type { UatType } from "./files.js"; -import { loadFile, extractUatType } from "./files.js"; +import { loadFile, extractUatType, loadActiveOverrides } from "./files.js"; import { resolveMilestoneFile, resolveSliceFile, relSliceFile, @@ -28,6 +28,7 @@ import { buildReplanSlicePrompt, buildRunUatPrompt, buildReassessRoadmapPrompt, + buildRewriteDocsPrompt, checkNeedsReassessment, checkNeedsRunUat, } from "./auto-prompts.js"; @@ -54,9 +55,38 @@ interface DispatchRule { match: (ctx: DispatchContext) => Promise; } +// ─── Rewrite Circuit Breaker ────────────────────────────────────────────── + +const MAX_REWRITE_ATTEMPTS = 3; +let rewriteAttemptCount = 0; +export function resetRewriteCircuitBreaker(): void { + rewriteAttemptCount = 0; +} + // ─── Rules ──────────────────────────────────────────────────────────────── const DISPATCH_RULES: DispatchRule[] = [ + { + name: "rewrite-docs (override gate)", + match: async ({ mid, midTitle, state, basePath }) => { + const pendingOverrides = await loadActiveOverrides(basePath); + if (pendingOverrides.length === 0) return null; + if (rewriteAttemptCount >= MAX_REWRITE_ATTEMPTS) { + const { resolveAllOverrides } = await import("./files.js"); + await resolveAllOverrides(basePath); + rewriteAttemptCount = 0; + return null; + } + rewriteAttemptCount++; + const unitId = state.activeSlice ? `${mid}/${state.activeSlice.id}` : mid; + return { + action: "dispatch", + unitType: "rewrite-docs", + unitId, + prompt: await buildRewriteDocsPrompt(mid, midTitle, state.activeSlice, basePath, pendingOverrides), + }; + }, + }, { name: "summarizing → complete-slice", match: async ({ state, mid, midTitle, basePath }) => { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 8e4aa8d1d..e1c6f0e82 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -6,8 +6,8 @@ * utility. */ -import { loadFile, parseContinue, parseRoadmap, parseSummary, extractUatType } from "./files.js"; -import type { UatType } from "./files.js"; +import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, loadActiveOverrides, formatOverridesSection } from "./files.js"; +import type { Override, UatType } from "./files.js"; import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; import { resolveMilestoneFile, resolveSliceFile, resolveSlicePath, @@ -457,6 +457,9 @@ export async function buildResearchSlicePrompt( inlined.push(inlineTemplate("research", "Research")); const depContent = await inlineDependencySummaries(mid, sid, base); + const activeOverrides = await loadActiveOverrides(base); + const overridesInline = formatOverridesSection(activeOverrides); + if (overridesInline) inlined.unshift(overridesInline); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; @@ -495,6 +498,9 @@ export async function buildPlanSlicePrompt( inlined.push(inlineTemplate("task-plan", "Task Plan")); const depContent = await inlineDependencySummaries(mid, sid, base); + const planActiveOverrides = await loadActiveOverrides(base); + const planOverridesInline = formatOverridesSection(planActiveOverrides); + if (planOverridesInline) inlined.unshift(planOverridesInline); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; @@ -562,7 +568,11 @@ export async function buildExecuteTaskPrompt( const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`; + const activeOverrides = await loadActiveOverrides(base); + const overridesSection = formatOverridesSection(activeOverrides); + return loadPrompt("execute-task", { + overridesSection, workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle, planPath: relSliceFile(base, mid, sid, "PLAN"), @@ -609,6 +619,9 @@ export async function buildCompleteSlicePrompt( } inlined.push(inlineTemplate("slice-summary", "Slice Summary")); inlined.push(inlineTemplate("uat", "UAT")); + const completeActiveOverrides = await loadActiveOverrides(base); + const completeOverridesInline = formatOverridesSection(completeActiveOverrides); + if (completeOverridesInline) inlined.unshift(completeOverridesInline); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; @@ -712,6 +725,9 @@ export async function buildReplanSlicePrompt( // Inline decisions const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions"); if (decisionsInline) inlined.push(decisionsInline); + const replanActiveOverrides = await loadActiveOverrides(base); + const replanOverridesInline = formatOverridesSection(replanActiveOverrides); + if (replanOverridesInline) inlined.unshift(replanOverridesInline); const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`; @@ -795,3 +811,70 @@ export async function buildReassessRoadmapPrompt( inlinedContext, }); } + +export async function buildRewriteDocsPrompt( + mid: string, midTitle: string, + activeSlice: { id: string; title: string } | null, + base: string, + overrides: Override[], +): Promise { + const sid = activeSlice?.id; + const sTitle = activeSlice?.title ?? ""; + const docList: string[] = []; + + if (sid) { + const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); + const slicePlanRel = relSliceFile(base, mid, sid, "PLAN"); + if (slicePlanPath) { + docList.push(`- Slice plan: \`${slicePlanRel}\``); + const tDir = resolveTasksDir(base, mid, sid); + if (tDir) { + const planContent = await loadFile(slicePlanPath); + if (planContent) { + const plan = parsePlan(planContent); + for (const task of plan.tasks) { + if (!task.done) { + const taskPlanPath = resolveTaskFile(base, mid, sid, task.id, "PLAN"); + if (taskPlanPath) { + const taskRelPath = `${relSlicePath(base, mid, sid)}/tasks/${task.id}-PLAN.md`; + docList.push(`- Task plan: \`${taskRelPath}\``); + } + } + } + } + } + } + } + + const decisionsPath = resolveGsdRootFile(base, "DECISIONS"); + if (existsSync(decisionsPath)) docList.push(`- Decisions: \`${relGsdRootFile("DECISIONS")}\``); + const requirementsPath = resolveGsdRootFile(base, "REQUIREMENTS"); + if (existsSync(requirementsPath)) docList.push(`- Requirements: \`${relGsdRootFile("REQUIREMENTS")}\``); + const projectPath = resolveGsdRootFile(base, "PROJECT"); + if (existsSync(projectPath)) docList.push(`- Project: \`${relGsdRootFile("PROJECT")}\``); + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + if (contextPath) docList.push(`- Milestone context (reference only): \`${contextRel}\``); + const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); + const roadmapRel = relMilestoneFile(base, mid, "ROADMAP"); + if (roadmapPath) docList.push(`- Roadmap: \`${roadmapRel}\``); + + const overrideContent = overrides.map((o, i) => [ + `### Override ${i + 1}`, + `**Change:** ${o.change}`, + `**Issued:** ${o.timestamp}`, + `**During:** ${o.appliedAt}`, + ].join("\n")).join("\n\n"); + + const documentList = docList.length > 0 ? docList.join("\n") : "- No active plan documents found."; + + return loadPrompt("rewrite-docs", { + milestoneId: mid, + milestoneTitle: midTitle, + sliceId: sid ?? "none", + sliceTitle: sTitle, + overrideContent, + documentList, + overridesPath: relGsdRootFile("OVERRIDES"), + }); +} diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 462589d3e..40a6df7e7 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -26,6 +26,7 @@ import { buildTaskFileName, resolveMilestoneFile, clearPathCache, + resolveGsdRootFile, } from "./paths.js"; import { parseRoadmap } from "./files.js"; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs"; @@ -78,6 +79,8 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba const dir = resolveMilestonePath(base, mid); return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null; } + case "rewrite-docs": + return null; default: return null; } @@ -101,6 +104,13 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s if (unitType.startsWith("hook/")) return true; + if (unitType === "rewrite-docs") { + const overridesPath = resolveGsdRootFile(base, "OVERRIDES"); + if (!existsSync(overridesPath)) return true; + const content = readFileSync(overridesPath, "utf-8"); + return !content.includes("**Scope:** active"); + } + const absPath = resolveExpectedArtifactPath(unitType, unitId, base); // Unit types with no verifiable artifact always pass (e.g. replan-slice). // For all other types, null means the parent directory is missing on disk @@ -206,6 +216,8 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: 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": diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 1b86b797a..ab4c5f5da 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,7 +18,7 @@ import type { import { deriveState, invalidateStateCache } from "./state.js"; import type { BudgetEnforcementMode, GSDState } from "./types.js"; -import { loadFile, parseRoadmap, getManifestStatus } from "./files.js"; +import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js"; export { inlinePriorMilestoneSummary } from "./files.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { @@ -108,7 +108,7 @@ import { buildLoopRemediationSteps, reconcileMergeState, } from "./auto-recovery.js"; -import { resolveDispatch } from "./auto-dispatch.js"; +import { resolveDispatch, resetRewriteCircuitBreaker } from "./auto-dispatch.js"; import { type AutoDashboardData, updateProgressWidget as _updateProgressWidget, @@ -856,6 +856,17 @@ export async function handleAgentEnd( // Non-fatal } + // ── Rewrite-docs completion: resolve overrides and reset circuit breaker ── + if (currentUnit.type === "rewrite-docs") { + try { + await resolveAllOverrides(basePath); + resetRewriteCircuitBreaker(); + ctx.ui.notify("Override(s) resolved — rewrite-docs completed.", "info"); + } catch { + // Non-fatal — verifyExpectedArtifact will catch unresolved overrides + } + } + // ── Path A fix: verify artifact and persist completion before re-entering dispatch ── // After doctor + rebuildState, check whether the just-completed unit actually // produced its expected artifact. If so, persist the completion key now so the diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index e8894e212..4678fc3bf 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -22,7 +22,7 @@ import { loadEffectiveGSDPreferences, resolveAllSkillReferences, } from "./preferences.js"; -import { loadFile, saveFile } from "./files.js"; +import { loadFile, saveFile, appendOverride } from "./files.js"; import { formatDoctorIssuesForPrompt, formatDoctorReport, @@ -57,12 +57,12 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote", + description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer", getArgumentCompletions: (prefix: string) => { const subcommands = [ "next", "auto", "stop", "pause", "status", "queue", "discuss", "history", "undo", "skip", "export", "cleanup", "prefs", - "config", "hooks", "doctor", "migrate", "remote", + "config", "hooks", "doctor", "migrate", "remote", "steer", ]; const parts = prefix.trim().split(/\s+/); @@ -248,6 +248,15 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed.startsWith("steer ")) { + await handleSteer(trimmed.replace(/^steer\s+/, "").trim(), ctx, pi); + return; + } + if (trimmed === "steer") { + ctx.ui.notify("Usage: /gsd steer . Example: /gsd steer Use Postgres instead of SQLite", "warning"); + return; + } + if (trimmed === "migrate" || trimmed.startsWith("migrate ")) { const { handleMigrate } = await import("./migrate/command.js"); await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi); @@ -266,7 +275,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote.`, + `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer .`, "warning", ); }, @@ -956,3 +965,46 @@ async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: st ctx.ui.notify(`Pruned ${pruned} old snapshot refs. ${refs.length - pruned} remain.`, "success"); } + +async function handleSteer(change: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const basePath = process.cwd(); + const state = await deriveState(basePath); + const mid = state.activeMilestone?.id ?? "none"; + const sid = state.activeSlice?.id ?? "none"; + const tid = state.activeTask?.id ?? "none"; + const appliedAt = `${mid}/${sid}/${tid}`; + await appendOverride(basePath, change, appliedAt); + + if (isAutoActive()) { + pi.sendMessage({ + customType: "gsd-hard-steer", + content: [ + "HARD STEER — User override registered.", + "", + `**Override:** ${change}`, + "", + "This override has been saved to `.gsd/OVERRIDES.md` and will be injected into all future task prompts.", + "A document rewrite unit will run before the next task to propagate this change across all active plan documents.", + "", + "If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.", + ].join("\n"), + display: false, + }, { triggerTurn: true }); + ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info"); + } else { + pi.sendMessage({ + customType: "gsd-hard-steer", + content: [ + "HARD STEER — User override registered.", + "", + `**Override:** ${change}`, + "", + "This override has been saved to `.gsd/OVERRIDES.md`.", + "Before continuing, read `.gsd/OVERRIDES.md` and update the current plan documents to reflect this change.", + "Focus on: active slice plan, incomplete task plans, and DECISIONS.md.", + ].join("\n"), + display: false, + }, { triggerTurn: true }); + ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info"); + } +} diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 7e4c135e1..f36aa525d 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -5,7 +5,7 @@ import { promises as fs } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { resolveMilestoneFile, relMilestoneFile } from './paths.js'; +import { resolveMilestoneFile, relMilestoneFile, resolveGsdRootFile } from './paths.js'; import { milestoneIdSort, findMilestoneIds } from './guided-flow.js'; import type { @@ -855,3 +855,103 @@ export async function getManifestStatus( return result; } + +// ─── Overrides ────────────────────────────────────────────────────────────── + +export interface Override { + timestamp: string; + change: string; + scope: "active" | "resolved"; + appliedAt: string; +} + +export async function appendOverride(basePath: string, change: string, appliedAt: string): Promise { + const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); + const timestamp = new Date().toISOString(); + const entry = [ + `## Override: ${timestamp}`, + "", + `**Change:** ${change}`, + `**Scope:** active`, + `**Applied-at:** ${appliedAt}`, + "", + "---", + "", + ].join("\n"); + + const existing = await loadFile(overridesPath); + if (existing) { + await saveFile(overridesPath, existing.trimEnd() + "\n\n" + entry); + } else { + const header = [ + "# GSD Overrides", + "", + "User-issued overrides that supersede plan document content.", + "", + "---", + "", + ].join("\n"); + await saveFile(overridesPath, header + entry); + } +} + +export async function loadActiveOverrides(basePath: string): Promise { + const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); + const content = await loadFile(overridesPath); + if (!content) return []; + return parseOverrides(content).filter(o => o.scope === "active"); +} + +export function parseOverrides(content: string): Override[] { + const overrides: Override[] = []; + const blocks = content.split(/^## Override: /m).slice(1); + + for (const block of blocks) { + const lines = block.split("\n"); + const timestamp = lines[0]?.trim() ?? ""; + let change = ""; + let scope: "active" | "resolved" = "active"; + let appliedAt = ""; + + for (const line of lines) { + const changeMatch = line.match(/^\*\*Change:\*\*\s*(.+)$/); + if (changeMatch) change = changeMatch[1].trim(); + const scopeMatch = line.match(/^\*\*Scope:\*\*\s*(.+)$/); + if (scopeMatch) scope = scopeMatch[1].trim() as "active" | "resolved"; + const appliedMatch = line.match(/^\*\*Applied-at:\*\*\s*(.+)$/); + if (appliedMatch) appliedAt = appliedMatch[1].trim(); + } + + if (change) { + overrides.push({ timestamp, change, scope, appliedAt }); + } + } + + return overrides; +} + +export function formatOverridesSection(overrides: Override[]): string { + if (overrides.length === 0) return ""; + + const entries = overrides.map((o, i) => [ + `${i + 1}. **${o.change}**`, + ` _Issued: ${o.timestamp} during ${o.appliedAt}_`, + ].join("\n")).join("\n"); + + return [ + "## Active Overrides (supersede plan content)", + "", + "The following overrides were issued by the user and supersede any conflicting content in plan documents below. Follow these overrides even if they contradict the inlined task plan.", + "", + entries, + "", + ].join("\n"); +} + +export async function resolveAllOverrides(basePath: string): Promise { + const overridesPath = resolveGsdRootFile(basePath, "OVERRIDES"); + const content = await loadFile(overridesPath); + if (!content) return; + const updated = content.replace(/\*\*Scope:\*\* active/g, "**Scope:** resolved"); + await saveFile(overridesPath, updated); +} diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 23f6b42b6..a97e83a8a 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -28,7 +28,7 @@ import { createBashTool, createWriteTool, createReadTool, createEditTool, isTool import { registerGSDCommand, loadToolApiKeys } from "./commands.js"; import { registerExitCommand } from "./exit-command.js"; import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js"; -import { saveFile, formatContinue, loadFile, parseContinue, parseSummary } from "./files.js"; +import { saveFile, formatContinue, loadFile, parseContinue, parseSummary, loadActiveOverrides, formatOverridesSection } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; import { deriveState } from "./state.js"; import { isAutoActive, isAutoPaused, handleAgentEnd, pauseAuto, getAutoDashboardData } from "./auto.js"; @@ -603,9 +603,13 @@ async function buildTaskExecutionContextInjection( const priorTaskLines = await buildCarryForwardLines(basePath, milestoneId, sliceId, taskId); const resumeSection = await buildResumeSection(basePath, milestoneId, sliceId); + const activeOverrides = await loadActiveOverrides(basePath); + const overridesSection = formatOverridesSection(activeOverrides); + return [ "[GSD Guided Execute Context]", "Use this injected context as startup context for guided task execution. Treat the inlined task plan as the authoritative local execution contract. Use source artifacts to verify details and run checks.", + overridesSection, "", "", resumeSection, "", diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index 601e7e1d9..35cc6441f 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -160,6 +160,7 @@ export const GSD_ROOT_FILES = { QUEUE: "QUEUE.md", STATE: "STATE.md", REQUIREMENTS: "REQUIREMENTS.md", + OVERRIDES: "OVERRIDES.md", } as const; export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES; @@ -170,6 +171,7 @@ const LEGACY_GSD_ROOT_FILES: Record = { QUEUE: "queue.md", STATE: "state.md", REQUIREMENTS: "requirements.md", + OVERRIDES: "overrides.md", }; export function gsdRoot(basePath: string): string { diff --git a/src/resources/extensions/gsd/prompts/execute-task.md b/src/resources/extensions/gsd/prompts/execute-task.md index 34c41b785..4ae7255cd 100644 --- a/src/resources/extensions/gsd/prompts/execute-task.md +++ b/src/resources/extensions/gsd/prompts/execute-task.md @@ -8,6 +8,8 @@ Your working directory is `{{workingDirectory}}`. All file reads, writes, and sh A researcher explored the codebase and a planner decomposed the work — you are the executor. The task plan below is your authoritative contract. It contains the specific files, steps, and verification you need. Don't re-research or re-plan — build what the plan says, verify it works, and document what happened. +{{overridesSection}} + {{resumeSection}} {{carryForwardSection}} diff --git a/src/resources/extensions/gsd/prompts/rewrite-docs.md b/src/resources/extensions/gsd/prompts/rewrite-docs.md new file mode 100644 index 000000000..d81632456 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/rewrite-docs.md @@ -0,0 +1,32 @@ +You are executing GSD auto-mode. + +## UNIT: Rewrite Documents — Apply Override(s) for Milestone {{milestoneId}} ("{{milestoneTitle}}") + +An override was issued by the user that changes a fundamental decision or approach. Your job is to propagate this change across all active planning documents so they are internally consistent and future tasks execute correctly. + +## Active Override(s) + +{{overrideContent}} + +## Documents to Review and Update + +{{documentList}} + +## Instructions + +1. Read each document listed above +2. Identify all references to the overridden decision/approach +3. Rewrite each document to reflect the new direction: + - For task plans (T##-PLAN.md): do NOT modify completed tasks (`[x]`) — they are historical. Rewrite incomplete tasks (`[ ]`) to align with the override. If a task is no longer needed, remove it. If new tasks are needed, add them following the ID sequence. + - For DECISIONS.md: append a new decision entry documenting the override and why. Do NOT delete prior decisions — mark them as superseded with a note. + - For slice plans (S##-PLAN.md): update Goal, Demo, and Verification sections if affected. Update Files Likely Touched if the override changes scope. Do NOT modify completed task entries. + - For REQUIREMENTS.md: update requirement descriptions if the override changes what "done" means, but do not remove requirements. + - For PROJECT.md: update if the override changes project-level facts. + - Milestone context files are reference only — do not modify them. +4. Mark all active overrides as resolved: change `**Scope:** active` to `**Scope:** resolved` in `{{overridesPath}}` +5. Do not commit manually — the system auto-commits your changes after this unit completes. +6. Update `.gsd/STATE.md` + +**You MUST update the relevant documents AND mark overrides as resolved in `{{overridesPath}}` before finishing.** + +When done, say: "Override applied across all documents." diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 58bd81ea5..ed19ce52f 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -65,6 +65,7 @@ Titles live inside file content (headings, frontmatter), not in file or director PROJECT.md (living doc - what the project is right now) REQUIREMENTS.md (requirement contract - tracks active/validated/deferred/out-of-scope) DECISIONS.md (append-only register of architectural and pattern decisions) + OVERRIDES.md (user-issued overrides that supersede plan content via /gsd steer) QUEUE.md (append-only log of queued milestones via /gsd queue) STATE.md runtime/ (system-managed — dispatch state, do not edit) diff --git a/src/resources/extensions/gsd/tests/overrides.test.ts b/src/resources/extensions/gsd/tests/overrides.test.ts new file mode 100644 index 000000000..f8302d03c --- /dev/null +++ b/src/resources/extensions/gsd/tests/overrides.test.ts @@ -0,0 +1,131 @@ +// GSD Extension - Override Tests +// Tests for parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides + +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createTestContext } from './test-helpers.ts'; +import { parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides } from '../files.ts'; +import type { Override } from '../files.ts'; + +const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext(); + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), `gsd-overrides-test-${prefix}-`)); + mkdirSync(join(dir, ".gsd"), { recursive: true }); + tempDirs.push(dir); + return dir; +} + +function cleanup(): void { + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; +} + +console.log('\n=== parseOverrides: empty content ==='); +{ const result = parseOverrides(""); assertEq(result.length, 0, "empty content returns no overrides"); } + +console.log('\n=== parseOverrides: single active override ==='); +{ + const content = `# GSD Overrides\n\nUser-issued overrides that supersede plan document content.\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** active\n**Applied-at:** M001/S02/T03\n\n---\n`; + const result = parseOverrides(content); + assertEq(result.length, 1, "parses one override"); + assertEq(result[0].timestamp, "2026-03-14T10:00:00.000Z", "correct timestamp"); + assertEq(result[0].change, "Use Postgres instead of SQLite", "correct change"); + assertEq(result[0].scope, "active", "correct scope"); + assertEq(result[0].appliedAt, "M001/S02/T03", "correct appliedAt"); +} + +console.log('\n=== parseOverrides: multiple overrides, mixed scopes ==='); +{ + const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** resolved\n**Applied-at:** M001/S02/T03\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Use JWT instead of session cookies\n**Scope:** active\n**Applied-at:** M001/S03/T01\n\n---\n`; + const result = parseOverrides(content); + assertEq(result.length, 2, "parses two overrides"); + assertEq(result[0].scope, "resolved", "first is resolved"); + assertEq(result[1].scope, "active", "second is active"); + assertEq(result[1].change, "Use JWT instead of session cookies", "second change text"); +} + +console.log('\n=== appendOverride: creates new file ==='); +{ + const tmp = makeTempDir("append-new"); + await appendOverride(tmp, "Use Postgres", "M001/S01/T01"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + assertTrue(content.includes("# GSD Overrides"), "has header"); + assertTrue(content.includes("**Change:** Use Postgres"), "has change"); + assertTrue(content.includes("**Scope:** active"), "has active scope"); + assertTrue(content.includes("**Applied-at:** M001/S01/T01"), "has appliedAt"); +} + +console.log('\n=== appendOverride: appends to existing file ==='); +{ + const tmp = makeTempDir("append-existing"); + await appendOverride(tmp, "First override", "M001/S01/T01"); + await appendOverride(tmp, "Second override", "M001/S02/T02"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + assertTrue(content.includes("**Change:** First override"), "has first override"); + assertTrue(content.includes("**Change:** Second override"), "has second override"); + const parsed = parseOverrides(content); + assertEq(parsed.length, 2, "two overrides in file"); +} + +console.log('\n=== loadActiveOverrides: no file ==='); +{ + const tmp = makeTempDir("load-no-file"); + const result = await loadActiveOverrides(tmp); + assertEq(result.length, 0, "returns empty when no file"); +} + +console.log('\n=== loadActiveOverrides: filters to active only ==='); +{ + const tmp = makeTempDir("load-filter"); + const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Resolved change\n**Scope:** resolved\n**Applied-at:** M001/S01/T01\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Active change\n**Scope:** active\n**Applied-at:** M001/S02/T01\n\n---\n`; + writeFileSync(join(tmp, ".gsd", "OVERRIDES.md"), content, "utf-8"); + const result = await loadActiveOverrides(tmp); + assertEq(result.length, 1, "only one active override"); + assertEq(result[0].change, "Active change", "correct active change"); +} + +console.log('\n=== formatOverridesSection: empty array ==='); +{ const result = formatOverridesSection([]); assertEq(result, "", "empty overrides returns empty string"); } + +console.log('\n=== formatOverridesSection: formats section ==='); +{ + const overrides: Override[] = [ + { timestamp: "2026-03-14T10:00:00.000Z", change: "Use Postgres", scope: "active", appliedAt: "M001/S01/T01" }, + ]; + const result = formatOverridesSection(overrides); + assertTrue(result.includes("## Active Overrides (supersede plan content)"), "has header"); + assertTrue(result.includes("**Use Postgres**"), "has change text"); + assertTrue(result.includes("supersede any conflicting content"), "has instruction"); +} + +console.log('\n=== resolveAllOverrides: marks all as resolved ==='); +{ + const tmp = makeTempDir("resolve-all"); + await appendOverride(tmp, "First", "M001/S01/T01"); + await appendOverride(tmp, "Second", "M001/S02/T01"); + let active = await loadActiveOverrides(tmp); + assertEq(active.length, 2, "two active before resolve"); + await resolveAllOverrides(tmp); + active = await loadActiveOverrides(tmp); + assertEq(active.length, 0, "no active after resolve"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + const allOverrides = parseOverrides(content); + assertEq(allOverrides.length, 2, "still two overrides total"); + assertTrue(allOverrides.every(o => o.scope === "resolved"), "all resolved"); +} + +console.log('\n=== resolveAllOverrides: no file — no error ==='); +{ + const tmp = makeTempDir("resolve-no-file"); + await resolveAllOverrides(tmp); + assertTrue(true, "resolveAllOverrides with no file does not throw"); +} + +cleanup(); +report();