Unify dispatch rules and hooks into a flat rule registry, add structured event journal with causal tracing, expose journal query as an LLM tool, and adopt gsd_concept_action tool naming. - RuleRegistry class absorbs dispatch rules + hooks into UnifiedRule objects with common when/where/then shape - post-unit-hooks.ts refactored from 524 lines → 90-line thin facade delegating to the registry - Event journal emits structured JSONL events with per-iteration flowId grouping and causedBy chains - gsd_journal_query LLM-callable tool for AI self-debugging of autonomous runs - 4 DB tools renamed to gsd_concept_action pattern with backward-compatible aliases - 164 new tests, zero regressions Closes #1763, closes #1764, closes #1766 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1474 lines
60 KiB
TypeScript
1474 lines
60 KiB
TypeScript
/**
|
|
* GSD Guided Flow — Smart Entry Wizard
|
|
*
|
|
* One function: showSmartEntry(). Reads state from disk, shows a contextual
|
|
* wizard via showNextAction(), and dispatches through GSD-WORKFLOW.md.
|
|
* No execution state, no hooks, no tools — the LLM does the rest.
|
|
*/
|
|
|
|
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
|
|
import { showNextAction } from "../shared/tui.js";
|
|
import { loadFile, parseRoadmap } from "./files.js";
|
|
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
|
|
import { buildSkillActivationBlock } from "./auto-prompts.js";
|
|
import { deriveState } from "./state.js";
|
|
import { invalidateAllCaches } from "./cache.js";
|
|
import { startAuto } from "./auto.js";
|
|
import { readCrashLock, clearLock, formatCrashInfo } from "./crash-recovery.js";
|
|
import { listUnitRuntimeRecords, clearUnitRuntimeRecord } from "./unit-runtime.js";
|
|
import { resolveExpectedArtifactPath } from "./auto.js";
|
|
import {
|
|
gsdRoot, milestonesDir, resolveMilestoneFile, resolveMilestonePath,
|
|
resolveSliceFile, resolveSlicePath, resolveGsdRootFile, relGsdRootFile,
|
|
relMilestoneFile, relSliceFile,
|
|
} from "./paths.js";
|
|
import { join } from "node:path";
|
|
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
|
import { readSessionLockData, isSessionLockProcessAlive } from "./session-lock.js";
|
|
import { nativeIsRepo, nativeInit } from "./native-git-bridge.js";
|
|
import { isInheritedRepo } from "./repo-identity.js";
|
|
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
|
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
import { detectProjectState } from "./detection.js";
|
|
import { showProjectInit, offerMigration } from "./init-wizard.js";
|
|
import { validateDirectory } from "./validate-directory.js";
|
|
import { showConfirm } from "../shared/tui.js";
|
|
import { debugLog } from "./debug-logger.js";
|
|
import { findMilestoneIds, nextMilestoneId, reserveMilestoneId, getReservedMilestoneIds } from "./milestone-ids.js";
|
|
import { parkMilestone, discardMilestone } from "./milestone-actions.js";
|
|
import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
|
|
|
|
// ─── Re-exports (preserve public API for existing importers) ────────────────
|
|
export {
|
|
MILESTONE_ID_RE, generateMilestoneSuffix, nextMilestoneId,
|
|
extractMilestoneSeq, parseMilestoneId, milestoneIdSort,
|
|
maxMilestoneNum, findMilestoneIds,
|
|
reserveMilestoneId, claimReservedId, getReservedMilestoneIds, clearReservedMilestoneIds,
|
|
} from "./milestone-ids.js";
|
|
export {
|
|
showQueue, handleQueueReorder, showQueueAdd,
|
|
buildExistingMilestonesContext,
|
|
} from "./guided-flow-queue.js";
|
|
import { getErrorMessage } from "./error-utils.js";
|
|
|
|
// ─── ID Generation with Reservation ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate the next milestone ID, accounting for reserved IDs, and reserve it.
|
|
* Ensures any preview ID shown in the UI matches what `gsd_milestone_generate_id`
|
|
* will later return.
|
|
*/
|
|
function nextMilestoneIdReserved(existingIds: string[], uniqueEnabled: boolean): string {
|
|
const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])];
|
|
const id = nextMilestoneId(allIds, uniqueEnabled);
|
|
reserveMilestoneId(id);
|
|
return id;
|
|
}
|
|
|
|
// ─── Commit Instruction Helpers ──────────────────────────────────────────────
|
|
|
|
/** Build commit instruction for planning prompts. .gsd/ is managed externally and always gitignored. */
|
|
function buildDocsCommitInstruction(_message: string): string {
|
|
return "Do not commit planning artifacts — .gsd/ is managed externally.";
|
|
}
|
|
|
|
// ─── Auto-start after discuss ─────────────────────────────────────────────────
|
|
|
|
/** Stashed context + flag for auto-starting after discuss phase completes */
|
|
let pendingAutoStart: {
|
|
ctx: ExtensionCommandContext;
|
|
pi: ExtensionAPI;
|
|
basePath: string;
|
|
milestoneId: string; // the milestone being discussed
|
|
step?: boolean; // preserve step mode through discuss → auto transition
|
|
} | null = null;
|
|
|
|
/** Returns the milestoneId being discussed, or null if no discussion is active */
|
|
export function getDiscussionMilestoneId(): string | null {
|
|
return pendingAutoStart?.milestoneId ?? null;
|
|
}
|
|
|
|
/** Called from agent_end to check if auto-mode should start after discuss */
|
|
export function checkAutoStartAfterDiscuss(): boolean {
|
|
if (!pendingAutoStart) return false;
|
|
|
|
const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart;
|
|
|
|
// Gate 1: Primary milestone must have CONTEXT.md or ROADMAP.md
|
|
// The "discuss" path creates CONTEXT.md; the "plan" path creates ROADMAP.md.
|
|
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
|
|
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
if (!contextFile && !roadmapFile) return false; // neither artifact yet — keep waiting
|
|
|
|
// Gate 2: STATE.md must exist — written as the last step in the discuss
|
|
// output phase. This prevents auto-start from firing during Phase 3
|
|
// (sequential readiness gates for remaining milestones) in multi-milestone
|
|
// discussions, where M001-CONTEXT.md exists but M002/M003 haven't been
|
|
// processed yet.
|
|
const stateFile = resolveGsdRootFile(basePath, "STATE");
|
|
if (!stateFile) return false; // discussion not finalized yet
|
|
|
|
// Gate 3: Multi-milestone completeness warning
|
|
// Parse PROJECT.md for milestone sequence, warn if any are missing context.
|
|
// Don't block — milestones can be intentionally queued without context.
|
|
const projectFile = resolveGsdRootFile(basePath, "PROJECT");
|
|
if (projectFile) {
|
|
try {
|
|
const projectContent = readFileSync(projectFile, "utf-8");
|
|
const milestoneIds = parseMilestoneSequenceFromProject(projectContent);
|
|
if (milestoneIds.length > 1) {
|
|
const missing = milestoneIds.filter(id => {
|
|
const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT");
|
|
const hasDraft = !!resolveMilestoneFile(basePath, id, "CONTEXT-DRAFT");
|
|
const hasDir = existsSync(join(gsdRoot(basePath), "milestones", id));
|
|
return !hasContext && !hasDraft && !hasDir;
|
|
});
|
|
if (missing.length > 0) {
|
|
ctx.ui.notify(
|
|
`Multi-milestone validation: ${missing.join(", ")} not found in filesystem. ` +
|
|
`Discussion may not have completed all readiness gates.`,
|
|
"warning",
|
|
);
|
|
}
|
|
}
|
|
} catch { /* non-fatal — PROJECT.md parsing failure shouldn't block auto-start */ }
|
|
}
|
|
|
|
// Gate 4: Discussion manifest process verification (multi-milestone only)
|
|
// The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
|
|
// If the manifest exists but gates_completed < total, the LLM hasn't finished
|
|
// presenting all readiness gates to the user — block auto-start.
|
|
const manifestPath = join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json");
|
|
if (existsSync(manifestPath)) {
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
const total = typeof manifest.total === "number" ? manifest.total : 0;
|
|
const completed = typeof manifest.gates_completed === "number" ? manifest.gates_completed : 0;
|
|
|
|
if (total > 1 && completed < total) {
|
|
// Discussion not complete — block auto-start until all gates are done
|
|
return false;
|
|
}
|
|
|
|
// Cross-check manifest milestones against PROJECT.md if available
|
|
if (projectFile) {
|
|
const projectContent = readFileSync(projectFile, "utf-8");
|
|
const projectIds = parseMilestoneSequenceFromProject(projectContent);
|
|
const manifestIds = Object.keys(manifest.milestones ?? {});
|
|
const untracked = projectIds.filter(id => !manifestIds.includes(id));
|
|
if (untracked.length > 0) {
|
|
ctx.ui.notify(
|
|
`Discussion manifest missing gates for: ${untracked.join(", ")}`,
|
|
"warning",
|
|
);
|
|
}
|
|
}
|
|
} catch { /* malformed manifest — warn but don't block */ }
|
|
}
|
|
|
|
// Draft promotion cleanup: if a CONTEXT-DRAFT.md exists alongside the new
|
|
// CONTEXT.md, delete the draft — it's been consumed by the discussion.
|
|
try {
|
|
const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
|
|
if (draftFile) unlinkSync(draftFile);
|
|
} catch { /* non-fatal — stale draft doesn't break anything, CONTEXT.md wins */ }
|
|
|
|
// Cleanup: remove discussion manifest after auto-start (only needed during discussion)
|
|
try { unlinkSync(manifestPath); } catch { /* may not exist for single-milestone */ }
|
|
|
|
pendingAutoStart = null;
|
|
startAuto(ctx, pi, basePath, false, { step }).catch((err) => {
|
|
ctx.ui.notify(`Auto-start failed: ${getErrorMessage(err)}`, "error");
|
|
if (process.env.GSD_DEBUG) console.error('[gsd] auto start error:', err);
|
|
debugLog("auto-start-failed", { error: getErrorMessage(err) });
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Extract milestone IDs from PROJECT.md milestone sequence table.
|
|
* Looks for rows like "| M001 | Name | Status |" and extracts the ID column.
|
|
*/
|
|
function parseMilestoneSequenceFromProject(content: string): string[] {
|
|
const ids: string[] = [];
|
|
const lines = content.split(/\r?\n/);
|
|
for (const line of lines) {
|
|
const match = line.match(/^\|\s*(M\d{3}[A-Z0-9-]*)\s*\|/);
|
|
if (match) ids.push(match[1]);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type UIContext = ExtensionContext;
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Read GSD-WORKFLOW.md and dispatch it to the LLM with a contextual note.
|
|
* This is the only way the wizard triggers work — everything else is the LLM's job.
|
|
*
|
|
* When a unitType is provided, resolves the user's model preference for that
|
|
* phase (e.g., models.planning → "plan-milestone") and applies it before
|
|
* dispatching. This ensures guided-flow dispatches respect the same
|
|
* per-phase model preferences that auto-mode uses.
|
|
*/
|
|
async function dispatchWorkflow(
|
|
pi: ExtensionAPI,
|
|
note: string,
|
|
customType = "gsd-run",
|
|
ctx?: ExtensionContext,
|
|
unitType?: string,
|
|
): Promise<void> {
|
|
// Apply model preference for this unit type (if configured)
|
|
if (ctx && unitType) {
|
|
const modelConfig = resolveModelWithFallbacksForUnit(unitType);
|
|
if (modelConfig) {
|
|
const availableModels = ctx.modelRegistry.getAvailable();
|
|
const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
|
|
|
|
for (const modelId of modelsToTry) {
|
|
// Resolve model from available models (same logic as auto-model-selection)
|
|
const model = resolveAvailableModel(modelId, availableModels, ctx.model?.provider);
|
|
if (!model) continue;
|
|
|
|
const ok = await pi.setModel(model, { persist: false });
|
|
if (ok) {
|
|
debugLog("guided-flow-model-applied", { unitType, model: `${model.provider}/${model.id}` });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
|
|
const workflow = readFileSync(workflowPath, "utf-8");
|
|
|
|
pi.sendMessage(
|
|
{
|
|
customType,
|
|
content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}\n\n## Your Task\n\n${note}`,
|
|
display: false,
|
|
},
|
|
{ triggerTurn: true },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve a model ID string to a model object from available models.
|
|
* Handles "provider/model" and bare ID formats.
|
|
*/
|
|
function resolveAvailableModel<T extends { id: string; provider: string }>(
|
|
modelId: string,
|
|
availableModels: T[],
|
|
currentProvider: string | undefined,
|
|
): T | undefined {
|
|
const slashIdx = modelId.indexOf("/");
|
|
|
|
if (slashIdx !== -1) {
|
|
const maybeProvider = modelId.substring(0, slashIdx);
|
|
const id = modelId.substring(slashIdx + 1);
|
|
|
|
const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase()));
|
|
if (knownProviders.has(maybeProvider.toLowerCase())) {
|
|
const match = availableModels.find(
|
|
m => m.provider.toLowerCase() === maybeProvider.toLowerCase()
|
|
&& m.id.toLowerCase() === id.toLowerCase(),
|
|
);
|
|
if (match) return match;
|
|
}
|
|
|
|
// Try matching the full string as a model ID (OpenRouter-style)
|
|
const lower = modelId.toLowerCase();
|
|
return availableModels.find(
|
|
m => m.id.toLowerCase() === lower
|
|
|| `${m.provider}/${m.id}`.toLowerCase() === lower,
|
|
);
|
|
}
|
|
|
|
// Bare ID — prefer current provider, then first available
|
|
const exactProviderMatch = availableModels.find(
|
|
m => m.id === modelId && m.provider === currentProvider,
|
|
);
|
|
return exactProviderMatch ?? availableModels.find(m => m.id === modelId);
|
|
}
|
|
|
|
/**
|
|
* Build the discuss-and-plan prompt for a new milestone.
|
|
* Used by all three "new milestone" paths (first ever, no active, all complete).
|
|
*/
|
|
function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string): string {
|
|
const milestoneRel = `.gsd/milestones/${nextId}`;
|
|
const inlinedTemplates = [
|
|
inlineTemplate("project", "Project"),
|
|
inlineTemplate("requirements", "Requirements"),
|
|
inlineTemplate("context", "Context"),
|
|
inlineTemplate("roadmap", "Roadmap"),
|
|
inlineTemplate("decisions", "Decisions"),
|
|
].join("\n\n---\n\n");
|
|
return loadPrompt("discuss", {
|
|
milestoneId: nextId,
|
|
preamble,
|
|
contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`,
|
|
roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`,
|
|
inlinedTemplates,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${nextId}): context, requirements, and roadmap`),
|
|
multiMilestoneCommitInstruction: buildDocsCommitInstruction("docs: project plan — N milestones"),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build the discuss prompt for headless milestone creation.
|
|
* Uses the discuss-headless prompt template with seed context injected.
|
|
*/
|
|
function buildHeadlessDiscussPrompt(nextId: string, seedContext: string, _basePath: string): string {
|
|
const milestoneRel = `.gsd/milestones/${nextId}`;
|
|
const inlinedTemplates = [
|
|
inlineTemplate("project", "Project"),
|
|
inlineTemplate("requirements", "Requirements"),
|
|
inlineTemplate("context", "Context"),
|
|
inlineTemplate("roadmap", "Roadmap"),
|
|
inlineTemplate("decisions", "Decisions"),
|
|
].join("\n\n---\n\n");
|
|
return loadPrompt("discuss-headless", {
|
|
milestoneId: nextId,
|
|
seedContext,
|
|
contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`,
|
|
roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`,
|
|
inlinedTemplates,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${nextId}): context, requirements, and roadmap`),
|
|
multiMilestoneCommitInstruction: buildDocsCommitInstruction("docs: project plan — N milestones"),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Bootstrap a .gsd/ project from scratch for headless use.
|
|
* Ensures git repo, .gsd/ structure, gitignore, and preferences all exist.
|
|
*/
|
|
function bootstrapGsdProject(basePath: string): void {
|
|
if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) {
|
|
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
|
|
nativeInit(basePath, mainBranch);
|
|
}
|
|
|
|
const root = gsdRoot(basePath);
|
|
mkdirSync(join(root, "milestones"), { recursive: true });
|
|
mkdirSync(join(root, "runtime"), { recursive: true });
|
|
|
|
ensureGitignore(basePath);
|
|
ensurePreferences(basePath);
|
|
untrackRuntimeFiles(basePath);
|
|
}
|
|
|
|
/**
|
|
* Headless milestone creation from a seed specification document.
|
|
* Bootstraps the project if needed, generates the next milestone ID,
|
|
* and dispatches the headless discuss prompt (no Q&A rounds).
|
|
*/
|
|
export async function showHeadlessMilestoneCreation(
|
|
ctx: ExtensionCommandContext,
|
|
pi: ExtensionAPI,
|
|
basePath: string,
|
|
seedContext: string,
|
|
): Promise<void> {
|
|
// Ensure .gsd/ is bootstrapped
|
|
bootstrapGsdProject(basePath);
|
|
|
|
// Generate next milestone ID
|
|
const existingIds = findMilestoneIds(basePath);
|
|
const prefs = loadEffectiveGSDPreferences();
|
|
const nextId = nextMilestoneIdReserved(existingIds, prefs?.preferences?.unique_milestone_ids ?? false);
|
|
|
|
// Create milestone directory
|
|
const milestoneDir = join(gsdRoot(basePath), "milestones", nextId, "slices");
|
|
mkdirSync(milestoneDir, { recursive: true });
|
|
|
|
// Build and dispatch the headless discuss prompt
|
|
const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath);
|
|
|
|
// Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss)
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
|
|
|
|
// Dispatch — headless milestone creation is a planning activity
|
|
await dispatchWorkflow(pi, prompt, "gsd-run", ctx, "plan-milestone");
|
|
}
|
|
|
|
|
|
// ─── Discuss Flow ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build a rich inlined-context prompt for discussing a specific slice.
|
|
* Preloads roadmap, milestone context, research, decisions, and completed
|
|
* slice summaries so the agent can ask grounded UX/behaviour questions
|
|
* without wasting a turn reading files.
|
|
*/
|
|
async function buildDiscussSlicePrompt(
|
|
mid: string,
|
|
sid: string,
|
|
sTitle: string,
|
|
base: string,
|
|
options?: { rediscuss?: boolean },
|
|
): Promise<string> {
|
|
const inlined: string[] = [];
|
|
|
|
// Roadmap — always included so the agent sees surrounding slices
|
|
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
|
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
|
const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
|
|
if (roadmapContent) {
|
|
inlined.push(`### Milestone Roadmap\nSource: \`${roadmapRel}\`\n\n${roadmapContent.trim()}`);
|
|
}
|
|
|
|
// Milestone context — understanding the full milestone intent
|
|
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
|
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
|
const contextContent = contextPath ? await loadFile(contextPath) : null;
|
|
if (contextContent) {
|
|
inlined.push(`### Milestone Context\nSource: \`${contextRel}\`\n\n${contextContent.trim()}`);
|
|
}
|
|
|
|
// Milestone research — technical grounding
|
|
const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
|
|
const researchRel = relMilestoneFile(base, mid, "RESEARCH");
|
|
const researchContent = researchPath ? await loadFile(researchPath) : null;
|
|
if (researchContent) {
|
|
inlined.push(`### Milestone Research\nSource: \`${researchRel}\`\n\n${researchContent.trim()}`);
|
|
}
|
|
|
|
// Decisions — architectural context that constrains this slice
|
|
const decisionsPath = resolveGsdRootFile(base, "DECISIONS");
|
|
if (existsSync(decisionsPath)) {
|
|
const decisionsContent = await loadFile(decisionsPath);
|
|
if (decisionsContent) {
|
|
inlined.push(`### Decisions Register\nSource: \`${relGsdRootFile("DECISIONS")}\`\n\n${decisionsContent.trim()}`);
|
|
}
|
|
}
|
|
|
|
// Completed slice summaries — what was already built that this slice builds on
|
|
if (roadmapContent) {
|
|
const roadmap = parseRoadmap(roadmapContent);
|
|
for (const s of roadmap.slices) {
|
|
if (!s.done || s.id === sid) continue;
|
|
const summaryPath = resolveSliceFile(base, mid, s.id, "SUMMARY");
|
|
const summaryRel = relSliceFile(base, mid, s.id, "SUMMARY");
|
|
const summaryContent = summaryPath ? await loadFile(summaryPath) : null;
|
|
if (summaryContent) {
|
|
inlined.push(`### ${s.id} Summary (completed)\nSource: \`${summaryRel}\`\n\n${summaryContent.trim()}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const inlinedContext = inlined.length > 0
|
|
? `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`
|
|
: `## Inlined Context\n\n_(no context files found yet — go in blind and ask broad questions)_`;
|
|
|
|
const sliceDirPath = `.gsd/milestones/${mid}/slices/${sid}`;
|
|
const sliceContextPath = `${sliceDirPath}/${sid}-CONTEXT.md`;
|
|
|
|
// When re-discussing, inject a preamble so the agent treats this as an update interview
|
|
const rediscussPreamble = options?.rediscuss
|
|
? `\n\n## Re-discuss Mode\n\nThis slice already has an existing context file (\`${sliceContextPath}\`) from a prior discussion. The user has chosen to re-discuss it. Read the existing context file, interview for any updates, changes, or new decisions, and rewrite the file with merged findings. Do NOT skip the interview — the user explicitly asked to revisit this slice.\n`
|
|
: "";
|
|
|
|
const inlinedTemplates = inlineTemplate("slice-context", "Slice Context");
|
|
return loadPrompt("guided-discuss-slice", {
|
|
milestoneId: mid,
|
|
sliceId: sid,
|
|
sliceTitle: sTitle,
|
|
inlinedContext: inlinedContext + rediscussPreamble,
|
|
sliceDirPath,
|
|
contextPath: sliceContextPath,
|
|
projectRoot: base,
|
|
inlinedTemplates,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${mid}/${sid}): slice context from discuss`),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* /gsd discuss — show a picker of non-done slices and run a slice interview.
|
|
* Loops back to the picker after each discussion so the user can chain
|
|
* multiple slice interviews in one session.
|
|
*/
|
|
export async function showDiscuss(
|
|
ctx: ExtensionCommandContext,
|
|
pi: ExtensionAPI,
|
|
basePath: string,
|
|
): Promise<void> {
|
|
// Guard: no .gsd/ project
|
|
if (!existsSync(gsdRoot(basePath))) {
|
|
ctx.ui.notify("No GSD project found. Run /gsd to start one first.", "warning");
|
|
return;
|
|
}
|
|
|
|
// Invalidate caches to pick up artifacts written by a just-completed discuss/plan
|
|
invalidateAllCaches();
|
|
|
|
const state = await deriveState(basePath);
|
|
|
|
// Guard: no active milestone
|
|
if (!state.activeMilestone) {
|
|
ctx.ui.notify("No active milestone. Run /gsd to create one first.", "warning");
|
|
return;
|
|
}
|
|
|
|
const mid = state.activeMilestone.id;
|
|
const milestoneTitle = state.activeMilestone.title;
|
|
|
|
// Special case: milestone is in needs-discussion phase (has CONTEXT-DRAFT.md but no roadmap yet).
|
|
// Route to the draft discussion flow instead of erroring — the discussion IS how the roadmap gets created.
|
|
if (state.phase === "needs-discussion") {
|
|
const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT");
|
|
const draftContent = draftFile ? await loadFile(draftFile) : null;
|
|
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${mid}: ${milestoneTitle}`,
|
|
summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."],
|
|
actions: [
|
|
{
|
|
id: "discuss_draft",
|
|
label: "Discuss from draft",
|
|
description: "Continue where the prior discussion left off — seed material is loaded automatically.",
|
|
recommended: true,
|
|
},
|
|
{
|
|
id: "discuss_fresh",
|
|
label: "Start fresh discussion",
|
|
description: "Discard the draft and start a new discussion from scratch.",
|
|
},
|
|
{
|
|
id: "skip_milestone",
|
|
label: "Skip — create new milestone",
|
|
description: "Leave this milestone as-is and start something new.",
|
|
},
|
|
],
|
|
notYetMessage: "Run /gsd discuss when ready to discuss this milestone.",
|
|
});
|
|
|
|
if (choice === "discuss_draft") {
|
|
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
|
|
const basePrompt = loadPrompt("guided-discuss-milestone", {
|
|
milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`),
|
|
});
|
|
const seed = draftContent
|
|
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
|
|
: basePrompt;
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
|
|
await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone");
|
|
} else if (choice === "discuss_fresh") {
|
|
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
|
|
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
|
|
milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`),
|
|
}), "gsd-discuss", ctx, "plan-milestone");
|
|
} else if (choice === "skip_milestone") {
|
|
const milestoneIds = findMilestoneIds(basePath);
|
|
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
|
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false };
|
|
await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Guard: no roadmap yet
|
|
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
|
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
|
|
if (!roadmapContent) {
|
|
ctx.ui.notify("No roadmap yet for this milestone. Run /gsd to plan first.", "warning");
|
|
return;
|
|
}
|
|
|
|
const roadmap = parseRoadmap(roadmapContent);
|
|
const pendingSlices = roadmap.slices.filter(s => !s.done);
|
|
|
|
if (pendingSlices.length === 0) {
|
|
ctx.ui.notify("All slices are complete — nothing to discuss.", "info");
|
|
return;
|
|
}
|
|
|
|
// Loop: show picker, dispatch discuss, repeat until "not_yet"
|
|
while (true) {
|
|
// Invalidate caches so we pick up CONTEXT files written by the just-completed discussion
|
|
invalidateAllCaches();
|
|
|
|
// Build discussion-state map: which slices have CONTEXT files already?
|
|
const discussedMap = new Map<string, boolean>();
|
|
for (const s of pendingSlices) {
|
|
const contextFile = resolveSliceFile(basePath, mid, s.id, "CONTEXT");
|
|
discussedMap.set(s.id, !!contextFile);
|
|
}
|
|
|
|
// If all pending slices are discussed, notify and exit instead of looping
|
|
const allDiscussed = pendingSlices.every(s => discussedMap.get(s.id));
|
|
if (allDiscussed) {
|
|
const lockData = readSessionLockData(basePath);
|
|
const remoteAutoRunning = lockData && lockData.pid !== process.pid && isSessionLockProcessAlive(lockData);
|
|
const nextStep = remoteAutoRunning
|
|
? "Auto-mode is already running — use /gsd status to check progress."
|
|
: "Run /gsd to start planning.";
|
|
ctx.ui.notify(
|
|
`All ${pendingSlices.length} slices discussed. ${nextStep}`,
|
|
"info",
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Find the first undiscussed slice to recommend
|
|
const firstUndiscussedId = pendingSlices.find(s => !discussedMap.get(s.id))?.id;
|
|
|
|
const actions = pendingSlices.map((s) => {
|
|
const discussed = discussedMap.get(s.id) ?? false;
|
|
const statusParts: string[] = [];
|
|
if (state.activeSlice?.id === s.id) statusParts.push("active");
|
|
else statusParts.push("upcoming");
|
|
statusParts.push(discussed ? "discussed ✓" : "not discussed");
|
|
|
|
return {
|
|
id: s.id,
|
|
label: `${s.id}: ${s.title}`,
|
|
description: statusParts.join(" · "),
|
|
recommended: s.id === firstUndiscussedId,
|
|
};
|
|
});
|
|
|
|
const choice = await showNextAction(ctx, {
|
|
title: "GSD — Discuss a slice",
|
|
summary: [
|
|
`${mid}: ${milestoneTitle}`,
|
|
"Pick a slice to interview. Context file will be written when done.",
|
|
],
|
|
actions,
|
|
notYetMessage: "Run /gsd discuss when ready.",
|
|
});
|
|
|
|
if (choice === "not_yet") return;
|
|
|
|
const chosen = pendingSlices.find(s => s.id === choice);
|
|
if (!chosen) return;
|
|
|
|
// If the slice already has a CONTEXT file, confirm re-discuss intent
|
|
const isRediscuss = discussedMap.get(chosen.id) ?? false;
|
|
if (isRediscuss) {
|
|
const confirm = await showNextAction(ctx, {
|
|
title: `Re-discuss ${chosen.id}?`,
|
|
summary: [
|
|
`${chosen.id} already has a context file from a prior discussion.`,
|
|
"Re-discussing will interview for updates and rewrite the context file.",
|
|
],
|
|
actions: [
|
|
{ id: "rediscuss", label: "Re-discuss to update context", description: "Interview for changes and rewrite", recommended: true },
|
|
{ id: "cancel", label: "Cancel", description: "Go back to slice picker" },
|
|
],
|
|
});
|
|
if (confirm !== "rediscuss") continue;
|
|
}
|
|
|
|
const prompt = await buildDiscussSlicePrompt(mid, chosen.id, chosen.title, basePath, { rediscuss: isRediscuss });
|
|
await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "plan-slice");
|
|
|
|
// Wait for the discuss session to finish, then loop back to the picker
|
|
await ctx.waitForIdle();
|
|
invalidateAllCaches();
|
|
}
|
|
}
|
|
|
|
// ─── Smart Entry Point ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The one wizard. Reads state, shows contextual options, dispatches into the workflow doc.
|
|
*/
|
|
/**
|
|
* Self-heal: scan runtime records and clear stale ones left behind when
|
|
* auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords()
|
|
* but guided-flow (manual /gsd mode) never called it — meaning stale records
|
|
* persisted until the next /gsd auto run. This ensures the wizard always
|
|
* starts from a clean state regardless of how the previous session ended.
|
|
*/
|
|
function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } {
|
|
try {
|
|
const records = listUnitRuntimeRecords(basePath);
|
|
let cleared = 0;
|
|
for (const record of records) {
|
|
const { unitType, unitId, phase } = record;
|
|
// Clear records whose expected artifact already exists (completed but not cleaned up)
|
|
const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
|
|
if (artifactPath && existsSync(artifactPath)) {
|
|
clearUnitRuntimeRecord(basePath, unitType, unitId);
|
|
cleared++;
|
|
continue;
|
|
}
|
|
// Clear records stuck in dispatched or timeout phase (process died mid-unit)
|
|
if (phase === "dispatched" || phase === "timeout") {
|
|
clearUnitRuntimeRecord(basePath, unitType, unitId);
|
|
cleared++;
|
|
}
|
|
}
|
|
if (cleared > 0) {
|
|
ctx.ui.notify(`Self-heal: cleared ${cleared} stale runtime record(s) from a previous session.`, "info");
|
|
}
|
|
return { cleared };
|
|
} catch {
|
|
// Non-fatal — self-heal should never block the wizard
|
|
return { cleared: 0 };
|
|
}
|
|
}
|
|
|
|
// ─── Milestone Actions Submenu ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Shows a submenu with Park / Discard / Skip / Back options for the active milestone.
|
|
* Returns true if an action was taken (caller should re-enter showSmartEntry or
|
|
* dispatch a new workflow). Returns false if the user chose "Back".
|
|
*/
|
|
async function handleMilestoneActions(
|
|
ctx: ExtensionCommandContext,
|
|
pi: ExtensionAPI,
|
|
basePath: string,
|
|
milestoneId: string,
|
|
milestoneTitle: string,
|
|
options?: { step?: boolean },
|
|
): Promise<boolean> {
|
|
const stepMode = options?.step;
|
|
const choice = await showNextAction(ctx, {
|
|
title: `Milestone Actions — ${milestoneId}`,
|
|
summary: [`${milestoneId}: ${milestoneTitle}`],
|
|
actions: [
|
|
{
|
|
id: "park",
|
|
label: "Park milestone",
|
|
description: "Pause this milestone — it stays on disk but is skipped.",
|
|
},
|
|
{
|
|
id: "discard",
|
|
label: "Discard milestone",
|
|
description: "Permanently delete this milestone and all its contents.",
|
|
},
|
|
{
|
|
id: "skip",
|
|
label: "Skip — create new milestone",
|
|
description: "Leave this milestone and start a fresh one.",
|
|
},
|
|
{
|
|
id: "back",
|
|
label: "Back",
|
|
description: "Return to the previous menu.",
|
|
},
|
|
],
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
if (choice === "park") {
|
|
const reason = await showNextAction(ctx, {
|
|
title: `Park ${milestoneId}`,
|
|
summary: ["Why is this milestone being parked?"],
|
|
actions: [
|
|
{ id: "priority_shift", label: "Priority shift", description: "Other work is more important right now." },
|
|
{ id: "blocked_external", label: "Blocked externally", description: "Waiting on an external dependency or decision." },
|
|
{ id: "needs_rethink", label: "Needs rethinking", description: "The approach needs to be reconsidered." },
|
|
],
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
// User pressed "Not yet" / Escape — cancel the park operation
|
|
if (!reason || reason === "not_yet") return false;
|
|
|
|
const reasonText = reason === "priority_shift" ? "Priority shift — other work is more important"
|
|
: reason === "blocked_external" ? "Blocked externally — waiting on external dependency"
|
|
: reason === "needs_rethink" ? "Needs rethinking — approach needs reconsideration"
|
|
: "Parked by user";
|
|
|
|
const success = parkMilestone(basePath, milestoneId, reasonText);
|
|
if (success) {
|
|
ctx.ui.notify(`Parked ${milestoneId}. Run /gsd unpark ${milestoneId} to reactivate.`, "info");
|
|
} else {
|
|
ctx.ui.notify(`Could not park ${milestoneId} — milestone not found or already parked.`, "warning");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (choice === "discard") {
|
|
const confirmed = await showConfirm(ctx, {
|
|
title: "Discard milestone?",
|
|
message: `This will permanently delete ${milestoneId} and all its contents (roadmap, plans, task summaries).`,
|
|
confirmLabel: "Discard",
|
|
declineLabel: "Cancel",
|
|
});
|
|
if (confirmed) {
|
|
discardMilestone(basePath, milestoneId);
|
|
ctx.ui.notify(`Discarded ${milestoneId}.`, "info");
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (choice === "skip") {
|
|
const milestoneIds = findMilestoneIds(basePath);
|
|
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
|
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
|
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
|
`New milestone ${nextId}.`,
|
|
basePath
|
|
), "gsd-run", ctx, "plan-milestone");
|
|
return true;
|
|
}
|
|
|
|
// "back" or null
|
|
return false;
|
|
}
|
|
|
|
export async function showSmartEntry(
|
|
ctx: ExtensionCommandContext,
|
|
pi: ExtensionAPI,
|
|
basePath: string,
|
|
options?: { step?: boolean },
|
|
): Promise<void> {
|
|
const stepMode = options?.step;
|
|
|
|
// ── Directory safety check — refuse to operate in system/home dirs ───
|
|
const dirCheck = validateDirectory(basePath);
|
|
if (dirCheck.severity === "blocked") {
|
|
ctx.ui.notify(dirCheck.reason!, "error");
|
|
return;
|
|
}
|
|
if (dirCheck.severity === "warning") {
|
|
const proceed = await showConfirm(ctx, {
|
|
title: "GSD — Unusual Directory",
|
|
message: dirCheck.reason!,
|
|
confirmLabel: "Continue anyway",
|
|
declineLabel: "Cancel",
|
|
});
|
|
if (!proceed) return;
|
|
}
|
|
|
|
// ── Detection preamble — run before any bootstrap ────────────────────
|
|
if (!existsSync(gsdRoot(basePath))) {
|
|
const detection = detectProjectState(basePath);
|
|
|
|
// v1 .planning/ detected — offer migration before anything else
|
|
if (detection.state === "v1-planning" && detection.v1) {
|
|
const migrationChoice = await offerMigration(ctx, detection.v1);
|
|
if (migrationChoice === "cancel") return;
|
|
if (migrationChoice === "migrate") {
|
|
const { handleMigrate } = await import("./migrate/command.js");
|
|
await handleMigrate("", ctx, pi);
|
|
return;
|
|
}
|
|
// "fresh" — fall through to init wizard
|
|
}
|
|
|
|
// No .gsd/ — run the project init wizard
|
|
const result = await showProjectInit(ctx, pi, basePath, detection);
|
|
if (!result.completed) return; // User cancelled
|
|
|
|
// Init wizard bootstrapped .gsd/ — fall through to the normal flow below
|
|
// which will detect "no milestones" and start the discuss prompt
|
|
}
|
|
|
|
// ── Ensure git repo exists — GSD needs it for worktree isolation ──────
|
|
// Also handle inherited repos: if basePath is a subdirectory of another
|
|
// git repo that has no .gsd, create a fresh repo to prevent cross-project
|
|
// state leaks (#1639).
|
|
if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) {
|
|
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
|
|
nativeInit(basePath, mainBranch);
|
|
}
|
|
|
|
// ── Ensure .gitignore has baseline patterns ──────────────────────────
|
|
ensureGitignore(basePath);
|
|
untrackRuntimeFiles(basePath);
|
|
|
|
// ── Self-heal stale runtime records from crashed auto-mode sessions ──
|
|
selfHealRuntimeRecords(basePath, ctx);
|
|
|
|
// Check for crash from previous auto-mode session.
|
|
// Skip if the lock was written by the current process — acquireSessionLock()
|
|
// writes to the same file, so we'd always false-positive (#1398).
|
|
const crashLock = readCrashLock(basePath);
|
|
if (crashLock && crashLock.pid !== process.pid) {
|
|
clearLock(basePath);
|
|
|
|
// Bootstrap crash with zero completed units = no work was lost.
|
|
// Auto-discard instead of prompting the user — this commonly happens
|
|
// when the user exits during init wizard or discuss phase before any
|
|
// real auto-mode work begins.
|
|
const isBootstrapCrash = crashLock.unitType === "starting"
|
|
&& crashLock.unitId === "bootstrap"
|
|
&& crashLock.completedUnits === 0;
|
|
|
|
if (!isBootstrapCrash) {
|
|
const resume = await showNextAction(ctx, {
|
|
title: "GSD — Interrupted Session Detected",
|
|
summary: [formatCrashInfo(crashLock)],
|
|
actions: [
|
|
{ id: "resume", label: "Resume with /gsd auto", description: "Pick up where it left off", recommended: true },
|
|
{ id: "continue", label: "Continue manually", description: "Open the wizard as normal" },
|
|
],
|
|
});
|
|
if (resume === "resume") {
|
|
await startAuto(ctx, pi, basePath, false);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const state = await deriveState(basePath);
|
|
|
|
if (!state.activeMilestone) {
|
|
// Guard: if a discuss session is already in flight, don't re-inject the prompt.
|
|
// Both /gsd and /gsd auto reach this branch when no milestone exists yet.
|
|
// Without this guard, every subsequent /gsd call overwrites pendingAutoStart
|
|
// and fires another dispatchWorkflow, resetting the conversation mid-interview.
|
|
if (pendingAutoStart) {
|
|
ctx.ui.notify("Discussion already in progress — answer the question above to continue.", "info");
|
|
return;
|
|
}
|
|
|
|
const milestoneIds = findMilestoneIds(basePath);
|
|
|
|
// Sanity check (#456): if findMilestoneIds returns [] but the milestones
|
|
// directory has contents, something went wrong (permissions, stale worktree
|
|
// cwd, etc). Warn instead of silently starting a new-project flow.
|
|
if (milestoneIds.length === 0) {
|
|
const mDir = milestonesDir(basePath);
|
|
if (existsSync(mDir)) {
|
|
try {
|
|
const entries = readdirSync(mDir);
|
|
if (entries.length > 0) {
|
|
ctx.ui.notify(
|
|
`Milestone directory has ${entries.length} entries but none were recognized as milestones. ` +
|
|
`This may indicate a corrupted state or wrong working directory. Run \`/gsd doctor\` to diagnose.`,
|
|
"warning",
|
|
);
|
|
return;
|
|
}
|
|
} catch { /* directory exists but unreadable — fall through to normal flow */ }
|
|
}
|
|
}
|
|
|
|
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
|
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
|
|
const isFirst = milestoneIds.length === 0;
|
|
|
|
if (isFirst) {
|
|
// First ever — skip wizard, just ask directly
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
|
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
|
`New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`,
|
|
basePath
|
|
), "gsd-run", ctx, "plan-milestone");
|
|
} else {
|
|
const choice = await showNextAction(ctx, {
|
|
title: "GSD — Get Shit Done",
|
|
summary: ["No active milestone."],
|
|
actions: [
|
|
{
|
|
id: "new_milestone",
|
|
label: "Create next milestone",
|
|
description: "Define what to build next.",
|
|
recommended: true,
|
|
},
|
|
],
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
if (choice === "new_milestone") {
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
|
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
|
`New milestone ${nextId}.`,
|
|
basePath
|
|
), "gsd-run", ctx, "plan-milestone");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const milestoneId = state.activeMilestone.id;
|
|
const milestoneTitle = state.activeMilestone.title;
|
|
|
|
// ── All milestones complete → New milestone ──────────────────────────
|
|
if (state.phase === "complete") {
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${milestoneId}: ${milestoneTitle}`,
|
|
summary: ["All milestones complete."],
|
|
actions: [
|
|
{
|
|
id: "new_milestone",
|
|
label: "Start new milestone",
|
|
description: "Define and plan the next milestone.",
|
|
recommended: true,
|
|
},
|
|
{
|
|
id: "status",
|
|
label: "View status",
|
|
description: "Review what was built.",
|
|
},
|
|
],
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
if (choice === "new_milestone") {
|
|
const milestoneIds = findMilestoneIds(basePath);
|
|
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
|
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
|
|
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
|
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
|
`New milestone ${nextId}.`,
|
|
basePath
|
|
), "gsd-run", ctx, "plan-milestone");
|
|
} else if (choice === "status") {
|
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
await fireStatusViaCommand(ctx);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Draft milestone — needs discussion before planning ────────────────
|
|
if (state.phase === "needs-discussion") {
|
|
const draftFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT-DRAFT");
|
|
const draftContent = draftFile ? await loadFile(draftFile) : null;
|
|
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${milestoneId}: ${milestoneTitle}`,
|
|
summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."],
|
|
actions: [
|
|
{
|
|
id: "discuss_draft",
|
|
label: "Discuss from draft",
|
|
description: "Continue where the prior discussion left off — seed material is loaded automatically.",
|
|
recommended: true,
|
|
},
|
|
{
|
|
id: "discuss_fresh",
|
|
label: "Start fresh discussion",
|
|
description: "Discard the draft and start a new discussion from scratch.",
|
|
},
|
|
{
|
|
id: "skip_milestone",
|
|
label: "Skip — create new milestone",
|
|
description: "Leave this milestone as-is and start something new.",
|
|
},
|
|
],
|
|
notYetMessage: "Run /gsd when ready to discuss this milestone.",
|
|
});
|
|
|
|
if (choice === "discuss_draft") {
|
|
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
|
|
const basePrompt = loadPrompt("guided-discuss-milestone", {
|
|
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
|
|
});
|
|
const seed = draftContent
|
|
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
|
|
: basePrompt;
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
|
await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone");
|
|
} else if (choice === "discuss_fresh") {
|
|
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
|
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
|
|
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
|
|
}), "gsd-discuss", ctx, "plan-milestone");
|
|
} else if (choice === "skip_milestone") {
|
|
const milestoneIds = findMilestoneIds(basePath);
|
|
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
|
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
|
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
|
`New milestone ${nextId}.`,
|
|
basePath
|
|
), "gsd-run", ctx, "plan-milestone");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── No active slice ──────────────────────────────────────────────────
|
|
if (!state.activeSlice) {
|
|
const roadmapFile = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
|
|
const hasRoadmap = !!(roadmapFile && await loadFile(roadmapFile));
|
|
|
|
if (!hasRoadmap) {
|
|
// No roadmap → discuss or plan
|
|
const contextFile = resolveMilestoneFile(basePath, milestoneId, "CONTEXT");
|
|
const hasContext = !!(contextFile && await loadFile(contextFile));
|
|
|
|
const actions = [
|
|
{
|
|
id: "plan",
|
|
label: "Create roadmap",
|
|
description: hasContext
|
|
? "Context captured. Decompose into slices with a boundary map."
|
|
: "Decompose the milestone into slices with a boundary map.",
|
|
recommended: true,
|
|
},
|
|
...(!hasContext ? [{
|
|
id: "discuss",
|
|
label: "Discuss first",
|
|
description: "Capture decisions on gray areas before planning.",
|
|
}] : []),
|
|
{
|
|
id: "skip_milestone",
|
|
label: "Skip — create new milestone",
|
|
description: "Leave this milestone on disk and start a fresh one.",
|
|
},
|
|
{
|
|
id: "discard_milestone",
|
|
label: "Discard this milestone",
|
|
description: "Delete the milestone directory and start over.",
|
|
},
|
|
];
|
|
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${milestoneId}: ${milestoneTitle}`,
|
|
summary: [hasContext ? "Context captured. Ready to create roadmap." : "New milestone — no roadmap yet."],
|
|
actions,
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
if (choice === "plan") {
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
|
|
const planMilestoneTemplates = [
|
|
inlineTemplate("roadmap", "Roadmap"),
|
|
inlineTemplate("plan", "Slice Plan"),
|
|
inlineTemplate("task-plan", "Task Plan"),
|
|
inlineTemplate("secrets-manifest", "Secrets Manifest"),
|
|
].join("\n\n---\n\n");
|
|
const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
|
|
await dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
|
|
milestoneId,
|
|
milestoneTitle,
|
|
secretsOutputPath,
|
|
inlinedTemplates: planMilestoneTemplates,
|
|
skillActivation: buildSkillActivationBlock({
|
|
base: basePath,
|
|
milestoneId,
|
|
milestoneTitle,
|
|
extraContext: [planMilestoneTemplates],
|
|
}),
|
|
}), "gsd-run", ctx, "plan-milestone");
|
|
} else if (choice === "discuss") {
|
|
const discussMilestoneTemplates = inlineTemplate("context", "Context");
|
|
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
|
|
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
|
|
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
|
|
commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
|
|
}), "gsd-run", ctx, "plan-milestone");
|
|
} else if (choice === "skip_milestone") {
|
|
const milestoneIds = findMilestoneIds(basePath);
|
|
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
|
|
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
|
|
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
|
|
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
|
|
`New milestone ${nextId}.`,
|
|
basePath
|
|
), "gsd-run", ctx, "plan-milestone");
|
|
} else if (choice === "discard_milestone") {
|
|
const confirmed = await showConfirm(ctx, {
|
|
title: "Discard milestone?",
|
|
message: `This will permanently delete ${milestoneId} and all its contents.`,
|
|
confirmLabel: "Discard",
|
|
declineLabel: "Cancel",
|
|
});
|
|
if (confirmed) {
|
|
discardMilestone(basePath, milestoneId);
|
|
return showSmartEntry(ctx, pi, basePath, options);
|
|
}
|
|
}
|
|
} else {
|
|
// Roadmap exists — either blocked or ready for auto
|
|
const actions = [
|
|
{
|
|
id: "auto",
|
|
label: "Go auto",
|
|
description: "Execute everything automatically until milestone complete.",
|
|
recommended: true,
|
|
},
|
|
{
|
|
id: "status",
|
|
label: "View status",
|
|
description: "See milestone progress and blockers.",
|
|
},
|
|
{
|
|
id: "milestone_actions",
|
|
label: "Milestone actions",
|
|
description: "Park, discard, or skip this milestone.",
|
|
},
|
|
];
|
|
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${milestoneId}: ${milestoneTitle}`,
|
|
summary: ["Roadmap exists. Ready to execute."],
|
|
actions,
|
|
notYetMessage: "Run /gsd status for details.",
|
|
});
|
|
|
|
if (choice === "auto") {
|
|
await startAuto(ctx, pi, basePath, false);
|
|
} else if (choice === "status") {
|
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
await fireStatusViaCommand(ctx);
|
|
} else if (choice === "milestone_actions") {
|
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
|
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const sliceId = state.activeSlice.id;
|
|
const sliceTitle = state.activeSlice.title;
|
|
|
|
// ── Slice needs planning ─────────────────────────────────────────────
|
|
if (state.phase === "planning") {
|
|
const contextFile = resolveSliceFile(basePath, milestoneId, sliceId, "CONTEXT");
|
|
const researchFile = resolveSliceFile(basePath, milestoneId, sliceId, "RESEARCH");
|
|
const hasContext = !!(contextFile && await loadFile(contextFile));
|
|
const hasResearch = !!(researchFile && await loadFile(researchFile));
|
|
|
|
const actions = [
|
|
{
|
|
id: "plan",
|
|
label: `Plan ${sliceId}`,
|
|
description: `Decompose "${sliceTitle}" into tasks with must-haves.`,
|
|
recommended: true,
|
|
},
|
|
...(!hasContext ? [{
|
|
id: "discuss",
|
|
label: `Discuss ${sliceId} first`,
|
|
description: "Capture context and decisions for this slice.",
|
|
}] : []),
|
|
...(!hasResearch ? [{
|
|
id: "research",
|
|
label: `Research ${sliceId} first`,
|
|
description: "Scout codebase and relevant docs.",
|
|
}] : []),
|
|
{
|
|
id: "status",
|
|
label: "View status",
|
|
description: "See milestone progress.",
|
|
},
|
|
{
|
|
id: "milestone_actions",
|
|
label: "Milestone actions",
|
|
description: "Park, discard, or skip this milestone.",
|
|
},
|
|
];
|
|
|
|
const summaryParts = [];
|
|
if (hasContext) summaryParts.push("context ✓");
|
|
if (hasResearch) summaryParts.push("research ✓");
|
|
const summaryLine = summaryParts.length > 0
|
|
? `${sliceId}: ${sliceTitle} (${summaryParts.join(", ")})`
|
|
: `${sliceId}: ${sliceTitle} — ready for planning.`;
|
|
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${milestoneId} / ${sliceId}: ${sliceTitle}`,
|
|
summary: [summaryLine],
|
|
actions,
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
if (choice === "plan") {
|
|
const planSliceTemplates = [
|
|
inlineTemplate("plan", "Slice Plan"),
|
|
inlineTemplate("task-plan", "Task Plan"),
|
|
].join("\n\n---\n\n");
|
|
await dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
|
|
milestoneId,
|
|
sliceId,
|
|
sliceTitle,
|
|
inlinedTemplates: planSliceTemplates,
|
|
skillActivation: buildSkillActivationBlock({
|
|
base: basePath,
|
|
milestoneId,
|
|
sliceId,
|
|
sliceTitle,
|
|
extraContext: [planSliceTemplates],
|
|
}),
|
|
}), "gsd-run", ctx, "plan-slice");
|
|
} else if (choice === "discuss") {
|
|
await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }), "gsd-run", ctx, "plan-slice");
|
|
} else if (choice === "research") {
|
|
const researchTemplates = inlineTemplate("research", "Research");
|
|
await dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
|
|
milestoneId,
|
|
sliceId,
|
|
sliceTitle,
|
|
inlinedTemplates: researchTemplates,
|
|
skillActivation: buildSkillActivationBlock({
|
|
base: basePath,
|
|
milestoneId,
|
|
sliceId,
|
|
sliceTitle,
|
|
extraContext: [researchTemplates],
|
|
}),
|
|
}), "gsd-run", ctx, "research-slice");
|
|
} else if (choice === "status") {
|
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
await fireStatusViaCommand(ctx);
|
|
} else if (choice === "milestone_actions") {
|
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
|
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── All tasks done → Complete slice ──────────────────────────────────
|
|
if (state.phase === "summarizing") {
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${milestoneId} / ${sliceId}: ${sliceTitle}`,
|
|
summary: ["All tasks complete. Ready for slice summary."],
|
|
actions: [
|
|
{
|
|
id: "complete",
|
|
label: `Complete ${sliceId}`,
|
|
description: "Write slice summary, UAT, mark done, and squash-merge to main.",
|
|
recommended: true,
|
|
},
|
|
{
|
|
id: "status",
|
|
label: "View status",
|
|
description: "Review tasks before completing.",
|
|
},
|
|
{
|
|
id: "milestone_actions",
|
|
label: "Milestone actions",
|
|
description: "Park, discard, or skip this milestone.",
|
|
},
|
|
],
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
if (choice === "complete") {
|
|
const completeSliceTemplates = [
|
|
inlineTemplate("slice-summary", "Slice Summary"),
|
|
inlineTemplate("uat", "UAT"),
|
|
].join("\n\n---\n\n");
|
|
await dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
|
|
workingDirectory: basePath,
|
|
milestoneId,
|
|
sliceId,
|
|
sliceTitle,
|
|
inlinedTemplates: completeSliceTemplates,
|
|
skillActivation: buildSkillActivationBlock({
|
|
base: basePath,
|
|
milestoneId,
|
|
sliceId,
|
|
sliceTitle,
|
|
extraContext: [completeSliceTemplates],
|
|
}),
|
|
}), "gsd-run", ctx, "complete-slice");
|
|
} else if (choice === "status") {
|
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
await fireStatusViaCommand(ctx);
|
|
} else if (choice === "milestone_actions") {
|
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
|
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Active task → Execute ────────────────────────────────────────────
|
|
if (state.activeTask) {
|
|
const taskId = state.activeTask.id;
|
|
const taskTitle = state.activeTask.title;
|
|
|
|
const continueFile = resolveSliceFile(basePath, milestoneId, sliceId, "CONTINUE");
|
|
const sDir = resolveSlicePath(basePath, milestoneId, sliceId);
|
|
const hasInterrupted = !!(continueFile && await loadFile(continueFile)) ||
|
|
!!(sDir && await loadFile(join(sDir, "continue.md")));
|
|
|
|
const choice = await showNextAction(ctx, {
|
|
title: `GSD — ${milestoneId} / ${sliceId}: ${sliceTitle}`,
|
|
summary: [
|
|
hasInterrupted
|
|
? `Resuming: ${taskId} — ${taskTitle}`
|
|
: `Next: ${taskId} — ${taskTitle}`,
|
|
],
|
|
actions: [
|
|
{
|
|
id: "execute",
|
|
label: hasInterrupted ? `Resume ${taskId}` : `Execute ${taskId}`,
|
|
description: hasInterrupted
|
|
? "Continue from where you left off."
|
|
: `Start working on "${taskTitle}".`,
|
|
recommended: true,
|
|
},
|
|
{
|
|
id: "auto",
|
|
label: "Go auto",
|
|
description: "Execute this and all remaining tasks automatically.",
|
|
},
|
|
{
|
|
id: "status",
|
|
label: "View status",
|
|
description: "See slice progress before starting.",
|
|
},
|
|
{
|
|
id: "milestone_actions",
|
|
label: "Milestone actions",
|
|
description: "Park, discard, or skip this milestone.",
|
|
},
|
|
],
|
|
notYetMessage: "Run /gsd when ready.",
|
|
});
|
|
|
|
if (choice === "auto") {
|
|
await startAuto(ctx, pi, basePath, false);
|
|
return;
|
|
}
|
|
|
|
if (choice === "execute") {
|
|
if (hasInterrupted) {
|
|
await dispatchWorkflow(pi, loadPrompt("guided-resume-task", {
|
|
milestoneId,
|
|
sliceId,
|
|
skillActivation: buildSkillActivationBlock({
|
|
base: basePath,
|
|
milestoneId,
|
|
sliceId,
|
|
taskId,
|
|
taskTitle,
|
|
}),
|
|
}), "gsd-run", ctx, "execute-task");
|
|
} else {
|
|
const executeTaskTemplates = inlineTemplate("task-summary", "Task Summary");
|
|
await dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
|
|
milestoneId,
|
|
sliceId,
|
|
taskId,
|
|
taskTitle,
|
|
inlinedTemplates: executeTaskTemplates,
|
|
skillActivation: buildSkillActivationBlock({
|
|
base: basePath,
|
|
milestoneId,
|
|
sliceId,
|
|
taskId,
|
|
taskTitle,
|
|
extraContext: [executeTaskTemplates],
|
|
}),
|
|
}), "gsd-run", ctx, "execute-task");
|
|
}
|
|
} else if (choice === "status") {
|
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
await fireStatusViaCommand(ctx);
|
|
} else if (choice === "milestone_actions") {
|
|
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options);
|
|
if (acted) return showSmartEntry(ctx, pi, basePath, options);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── Fallback: show status ────────────────────────────────────────────
|
|
const { fireStatusViaCommand } = await import("./commands.js");
|
|
await fireStatusViaCommand(ctx);
|
|
}
|