diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 24794ae0a..ac4e9bfa9 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -43,6 +43,18 @@ import { } from "./unit-runtime.js"; import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveModelWithFallbacksForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences } from "./preferences.js"; import type { GSDPreferences } from "./preferences.js"; +import { + checkPostUnitHooks, + getActiveHook, + resetHookState, + isRetryPending, + consumeRetryTrigger, + runPreDispatchHooks, + persistHookState, + restoreHookState, + clearPersistedHookState, + formatHookStatus, +} from "./post-unit-hooks.js"; import { validatePlanBoundary, validateExecuteBoundary, @@ -348,6 +360,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi } resetMetrics(); + resetHookState(); + if (basePath) clearPersistedHookState(basePath); active = false; paused = false; stepMode = false; @@ -565,6 +579,8 @@ export async function startAuto( ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); ctx.ui.setFooter(hideFooter); ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); + // Restore hook state from disk in case session was interrupted + restoreHookState(base); // Rebuild disk state before resuming — user interaction during pause may have changed files try { await rebuildState(base); } catch { /* non-fatal */ } try { @@ -673,6 +689,8 @@ export async function startAuto( unitRecoveryCount.clear(); completedKeySet.clear(); loadPersistedKeys(base, completedKeySet); + resetHookState(); + restoreHookState(base); autoStartTime = Date.now(); completedUnits = []; currentUnit = null; @@ -811,6 +829,79 @@ export async function handleAgentEnd( } } + // ── Post-unit hooks: check if a configured hook should run before normal dispatch ── + if (currentUnit && !stepMode) { + const hookUnit = checkPostUnitHooks(currentUnit.type, currentUnit.id, basePath); + if (hookUnit) { + // Dispatch the hook unit instead of normal flow + const hookStartedAt = Date.now(); + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt }; + writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: hookStartedAt, + progressCount: 0, + lastProgressKind: "dispatch", + }); + + const state = await deriveState(basePath); + updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state); + const hookState = getActiveHook(); + ctx.ui.notify( + `Running post-unit hook: ${hookUnit.hookName} (cycle ${hookState?.cycle ?? 1})`, + "info", + ); + + // Switch model if the hook specifies one + if (hookUnit.model) { + const availableModels = ctx.modelRegistry.getAvailable(); + const match = availableModels.find(m => + m.id === hookUnit.model || `${m.provider}/${m.id}` === hookUnit.model, + ); + if (match) { + try { + await pi.setModel(match); + } catch { /* non-fatal — use current model */ } + } + } + + const result = await cmdCtx!.newSession(); + if (result.cancelled) { + resetHookState(); + await stopAuto(ctx, pi); + return; + } + const sessionFile = ctx.sessionManager.getSessionFile(); + writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile); + // Persist hook state so cycle counts survive crashes + persistHookState(basePath); + pi.sendMessage( + { customType: "gsd-auto", content: hookUnit.prompt, display: verbose }, + { triggerTurn: true }, + ); + return; // handleAgentEnd will fire again when hook session completes + } + + // Check if a hook requested a retry of the trigger unit + if (isRetryPending()) { + const trigger = consumeRetryTrigger(); + if (trigger) { + ctx.ui.notify( + `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`, + "info", + ); + // Fall through to normal dispatchNextUnit — state derivation will + // re-select the same unit since it hasn't been marked complete + } + } + } + // In step mode, pause and show a wizard instead of immediately dispatching if (stepMode) { await showStepWizard(ctx, pi); @@ -954,6 +1045,7 @@ export function describeNextUnit(state: GSDState): { label: string; description: // ─── Progress Widget ────────────────────────────────────────────────────── function unitVerb(unitType: string): string { + if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`; switch (unitType) { case "research-milestone": case "research-slice": return "researching"; @@ -970,6 +1062,7 @@ function unitVerb(unitType: string): string { } function unitPhaseLabel(unitType: string): string { + if (unitType.startsWith("hook/")) return "HOOK"; switch (unitType) { case "research-milestone": return "RESEARCH"; case "research-slice": return "RESEARCH"; @@ -986,7 +1079,14 @@ function unitPhaseLabel(unitType: string): string { } function peekNext(unitType: string, state: GSDState): string { + // Show active hook info in progress display + const activeHookState = getActiveHook(); + if (activeHookState) { + return `hook: ${activeHookState.hookName} (cycle ${activeHookState.cycle})`; + } + const sid = state.activeSlice?.id ?? ""; + if (unitType.startsWith("hook/")) return `continue ${sid}`; switch (unitType) { case "research-milestone": return "plan milestone roadmap"; case "plan-milestone": return "plan or execute first slice"; @@ -1724,6 +1824,28 @@ async function dispatchNextUnit( } } + // ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ── + const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath); + if (preDispatchResult.firedHooks.length > 0) { + ctx.ui.notify( + `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, + "info", + ); + } + if (preDispatchResult.action === "skip") { + ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info"); + // Yield then re-dispatch to advance to next unit + await new Promise(r => setImmediate(r)); + await dispatchNextUnit(ctx, pi); + return; + } + if (preDispatchResult.action === "replace") { + prompt = preDispatchResult.prompt ?? prompt; + if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; + } else if (preDispatchResult.prompt) { + prompt = preDispatchResult.prompt; + } + const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId); if (priorSliceBlocker) { await stopAuto(ctx, pi); @@ -3002,6 +3124,9 @@ async function collectObservabilityWarnings( unitType: string, unitId: string, ): Promise { + // Hook units have custom artifacts — skip standard observability checks + if (unitType.startsWith("hook/")) return []; + const parts = unitId.split("/"); const mid = parts[0]; const sid = parts[1]; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index f1b661f6e..1ebb86f09 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -53,10 +53,10 @@ 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|status|queue|prefs|doctor|migrate|remote", + description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote", getArgumentCompletions: (prefix: string) => { - const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"]; + const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"]; const parts = prefix.trim().split(/\s+/); if (parts.length <= 1) { @@ -151,6 +151,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "hooks") { + const { formatHookStatus } = await import("./post-unit-hooks.js"); + ctx.ui.notify(formatHookStatus(), "info"); + return; + } + if (trimmed === "migrate" || trimmed.startsWith("migrate ")) { await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi); return; @@ -168,7 +174,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, + `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, "warning", ); }, diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts new file mode 100644 index 000000000..c264d275f --- /dev/null +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -0,0 +1,449 @@ +// GSD Extension — Hook Engine (Post-Unit, Pre-Dispatch, State Persistence) +// Manages hook queue, cycle tracking, artifact verification, pre-dispatch +// interception, and durable hook state for user-configured extensibility. +// Copyright (c) 2026 Jeremy McSpadden + +import type { + PostUnitHookConfig, + PreDispatchHookConfig, + HookExecutionState, + HookDispatchResult, + PreDispatchResult, + PersistedHookState, + HookStatusEntry, +} from "./types.js"; +import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Hook Queue State ────────────────────────────────────────────────────── + +/** Currently executing hook, or null if in normal dispatch flow. */ +let activeHook: HookExecutionState | null = null; + +/** Queue of hooks remaining for the current trigger unit. */ +let hookQueue: Array<{ + config: PostUnitHookConfig; + triggerUnitType: string; + triggerUnitId: string; +}> = []; + +/** Cycle counts per hook+trigger, keyed as "hookName/triggerUnitType/triggerUnitId". */ +const cycleCounts = new Map(); + +/** Set when a hook completes with retry_on artifact present — signals caller to re-run trigger. */ +let retryPending = false; + +/** Stores the trigger unit info for pending retries so caller knows what to re-run. */ +let retryTrigger: { unitType: string; unitId: string } | null = null; + +// ─── Public API ──────────────────────────────────────────────────────────── + +/** + * Called after a unit completes. Returns the next hook unit to dispatch, + * or null if no hooks apply (normal dispatch should proceed). + * + * Call flow: + * 1. A core unit (e.g. execute-task) completes → handleAgentEnd calls this + * 2. If hooks match, returns first hook to dispatch. Caller sends the prompt. + * 3. Hook unit completes → handleAgentEnd calls this again (activeHook is set) + * 4. Checks retry_on / next hook / done → returns next action or null + */ +export function checkPostUnitHooks( + completedUnitType: string, + completedUnitId: string, + basePath: string, +): HookDispatchResult | null { + // If we just completed a hook unit, handle its result + if (activeHook) { + return handleHookCompletion(basePath); + } + + // Don't trigger hooks for other hook units (prevent hook-on-hook chains) + if (completedUnitType.startsWith("hook/")) return null; + + // Check if any hooks are configured for this unit type + const hooks = resolvePostUnitHooks().filter(h => + h.after.includes(completedUnitType), + ); + if (hooks.length === 0) return null; + + // Build hook queue for this trigger + hookQueue = hooks.map(config => ({ + config, + triggerUnitType: completedUnitType, + triggerUnitId: completedUnitId, + })); + + return dequeueNextHook(basePath); +} + +/** + * Returns whether a hook is currently active (for progress display). + */ +export function getActiveHook(): HookExecutionState | null { + return activeHook; +} + +/** + * Returns true if a retry of the trigger unit was requested by a hook. + * Caller should re-dispatch the original trigger unit, then hooks will + * fire again on its next completion. + */ +export function isRetryPending(): boolean { + return retryPending; +} + +/** + * Returns the trigger unit info for a pending retry, or null. + * Clears the retry state after reading. + */ +export function consumeRetryTrigger(): { unitType: string; unitId: string } | null { + if (!retryPending || !retryTrigger) return null; + const trigger = { ...retryTrigger }; + retryPending = false; + retryTrigger = null; + return trigger; +} + +/** + * Reset all hook state. Called on auto-mode start/stop. + */ +export function resetHookState(): void { + activeHook = null; + hookQueue = []; + cycleCounts.clear(); + retryPending = false; + retryTrigger = null; +} + +// ─── Internal ────────────────────────────────────────────────────────────── + +function dequeueNextHook(basePath: string): HookDispatchResult | null { + while (hookQueue.length > 0) { + const entry = hookQueue.shift()!; + const { config, triggerUnitType, triggerUnitId } = entry; + + // Check idempotency — if artifact already exists, skip this hook + if (config.artifact) { + const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact); + if (existsSync(artifactPath)) continue; + } + + // Check cycle limit + const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`; + const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1; + const maxCycles = config.max_cycles ?? 1; + if (currentCycle > maxCycles) continue; + + cycleCounts.set(cycleKey, currentCycle); + + activeHook = { + hookName: config.name, + triggerUnitType, + triggerUnitId, + cycle: currentCycle, + pendingRetry: false, + }; + + // Build the prompt with variable substitution + const [mid, sid, tid] = triggerUnitId.split("/"); + const prompt = config.prompt + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + return { + hookName: config.name, + prompt, + model: config.model, + unitType: `hook/${config.name}`, + unitId: triggerUnitId, + }; + } + + // No more hooks — clear active state and return null for normal dispatch + activeHook = null; + return null; +} + +function handleHookCompletion(basePath: string): HookDispatchResult | null { + const hook = activeHook!; + const hooks = resolvePostUnitHooks(); + const config = hooks.find(h => h.name === hook.hookName); + + // Check if retry was requested via retry_on artifact + if (config?.retry_on) { + const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on); + if (existsSync(retryArtifactPath)) { + // Check cycle limit before allowing retry + const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`; + const currentCycle = cycleCounts.get(cycleKey) ?? 1; + const maxCycles = config.max_cycles ?? 1; + + if (currentCycle < maxCycles) { + // Signal retry — caller will re-dispatch the trigger unit + activeHook = null; + hookQueue = []; + retryPending = true; + retryTrigger = { unitType: hook.triggerUnitType, unitId: hook.triggerUnitId }; + return null; + } + // Max cycles reached — fall through to normal completion + } + } + + // Hook completed normally — try next hook in queue + activeHook = null; + return dequeueNextHook(basePath); +} + +/** + * Resolve the path where a hook artifact is expected to be written. + * Uses the trigger unit's directory context: + * - Task-level (M001/S01/T01): .gsd/M001/slices/S01/tasks/T01-{artifact} + * - Slice-level (M001/S01): .gsd/M001/slices/S01/{artifact} + * - Milestone-level (M001): .gsd/M001/{artifact} + */ +export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string { + const parts = unitId.split("/"); + if (parts.length === 3) { + const [mid, sid, tid] = parts; + return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`); + } + if (parts.length === 2) { + const [mid, sid] = parts; + return join(basePath, ".gsd", mid, "slices", sid, artifactName); + } + return join(basePath, ".gsd", parts[0], artifactName); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 2: Pre-Dispatch Hooks +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Run pre-dispatch hooks for a unit about to be dispatched. + * Returns a result indicating whether the unit should proceed (with optional + * prompt modifications), be skipped, or be replaced entirely. + * + * Multiple hooks can fire for the same unit type. They compose: + * - "modify" hooks stack (all prepend/append applied in order) + * - "skip" short-circuits (first matching skip wins) + * - "replace" short-circuits (first matching replace wins) + * - Skip/replace hooks take precedence over modify hooks + */ +export function runPreDispatchHooks( + unitType: string, + unitId: string, + prompt: string, + basePath: string, +): PreDispatchResult { + // Don't intercept hook units + if (unitType.startsWith("hook/")) { + return { action: "proceed", prompt, firedHooks: [] }; + } + + const hooks = resolvePreDispatchHooks().filter(h => + h.before.includes(unitType), + ); + if (hooks.length === 0) { + return { action: "proceed", prompt, firedHooks: [] }; + } + + const [mid, sid, tid] = unitId.split("/"); + const substitute = (text: string): string => + text + .replace(/\{milestoneId\}/g, mid ?? "") + .replace(/\{sliceId\}/g, sid ?? "") + .replace(/\{taskId\}/g, tid ?? ""); + + const firedHooks: string[] = []; + let currentPrompt = prompt; + + for (const hook of hooks) { + if (hook.action === "skip") { + // Check optional skip condition + if (hook.skip_if) { + const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if); + if (!existsSync(conditionPath)) continue; // Condition not met, don't skip + } + firedHooks.push(hook.name); + return { action: "skip", firedHooks }; + } + + if (hook.action === "replace") { + firedHooks.push(hook.name); + return { + action: "replace", + prompt: substitute(hook.prompt ?? ""), + unitType: hook.unit_type, + model: hook.model, + firedHooks, + }; + } + + if (hook.action === "modify") { + firedHooks.push(hook.name); + if (hook.prepend) { + currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`; + } + if (hook.append) { + currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`; + } + } + } + + return { + action: "proceed", + prompt: currentPrompt, + model: hooks.find(h => h.action === "modify" && h.model)?.model, + firedHooks, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 3: Hook State Persistence +// ═══════════════════════════════════════════════════════════════════════════ + +const HOOK_STATE_FILE = "hook-state.json"; + +function hookStatePath(basePath: string): string { + return join(basePath, ".gsd", HOOK_STATE_FILE); +} + +/** + * Persist current hook cycle counts to disk so they survive crashes/restarts. + * Called after each hook dispatch and on auto-mode pause. + */ +export function persistHookState(basePath: string): void { + const state: PersistedHookState = { + cycleCounts: Object.fromEntries(cycleCounts), + savedAt: new Date().toISOString(), + }; + try { + const dir = join(basePath, ".gsd"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8"); + } catch { + // Non-fatal — state is recreatable from artifacts + } +} + +/** + * Restore hook cycle counts from disk after a crash/restart. + * Called during auto-mode resume. + */ +export function restoreHookState(basePath: string): void { + try { + const filePath = hookStatePath(basePath); + if (!existsSync(filePath)) return; + const raw = readFileSync(filePath, "utf-8"); + const state: PersistedHookState = JSON.parse(raw); + if (state.cycleCounts && typeof state.cycleCounts === "object") { + cycleCounts.clear(); + for (const [key, value] of Object.entries(state.cycleCounts)) { + if (typeof value === "number") { + cycleCounts.set(key, value); + } + } + } + } catch { + // Non-fatal — fresh state is fine + } +} + +/** + * Clear persisted hook state file from disk. + * Called on clean auto-mode stop. + */ +export function clearPersistedHookState(basePath: string): void { + try { + const filePath = hookStatePath(basePath); + if (existsSync(filePath)) { + writeFileSync(filePath, JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), "utf-8"); + } + } catch { + // Non-fatal + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 3: Hook Status Reporting +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Get status of all configured hooks for display by /gsd hooks. + */ +export function getHookStatus(): HookStatusEntry[] { + const entries: HookStatusEntry[] = []; + + // Post-unit hooks + const postHooks = resolvePostUnitHooks(); + for (const hook of postHooks) { + const activeCycles: Record = {}; + for (const [key, count] of cycleCounts) { + if (key.startsWith(`${hook.name}/`)) { + activeCycles[key] = count; + } + } + entries.push({ + name: hook.name, + type: "post", + enabled: hook.enabled !== false, + targets: hook.after, + activeCycles, + }); + } + + // Pre-dispatch hooks + const preHooks = resolvePreDispatchHooks(); + for (const hook of preHooks) { + entries.push({ + name: hook.name, + type: "pre", + enabled: hook.enabled !== false, + targets: hook.before, + activeCycles: {}, + }); + } + + return entries; +} + +/** + * Format hook status for terminal display. + */ +export function formatHookStatus(): string { + const entries = getHookStatus(); + if (entries.length === 0) { + return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md"; + } + + const lines: string[] = ["Configured Hooks:", ""]; + + const postHooks = entries.filter(e => e.type === "post"); + const preHooks = entries.filter(e => e.type === "pre"); + + if (postHooks.length > 0) { + lines.push("Post-Unit Hooks (run after unit completes):"); + for (const hook of postHooks) { + const status = hook.enabled ? "enabled" : "disabled"; + const cycles = Object.keys(hook.activeCycles).length; + const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : ""; + lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`); + } + lines.push(""); + } + + if (preHooks.length > 0) { + lines.push("Pre-Dispatch Hooks (run before unit dispatches):"); + for (const hook of preHooks) { + const status = hook.enabled ? "enabled" : "disabled"; + lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`); + } + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 3b3fa0c0b..283f2dda4 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { getAgentDir } from "@gsd/pi-coding-agent"; import type { GitPreferences } from "./git-service.js"; +import type { PostUnitHookConfig, PreDispatchHookConfig } from "./types.js"; import { VALID_BRANCH_NAME } from "./git-service.js"; const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md"); @@ -93,6 +94,8 @@ export interface GSDPreferences { budget_ceiling?: number; remote_questions?: RemoteQuestionsConfig; git?: GitPreferences; + post_unit_hooks?: PostUnitHookConfig[]; + pre_dispatch_hooks?: PreDispatchHookConfig[]; } export interface LoadedGSDPreferences { @@ -626,6 +629,8 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr git: (base.git || override.git) ? { ...(base.git ?? {}), ...(override.git ?? {}) } : undefined, + post_unit_hooks: mergePostUnitHooks(base.post_unit_hooks, override.post_unit_hooks), + pre_dispatch_hooks: mergePreDispatchHooks(base.pre_dispatch_hooks, override.pre_dispatch_hooks), }; } @@ -713,6 +718,138 @@ function validatePreferences(preferences: GSDPreferences): { } } + // ─── Post-Unit Hooks ───────────────────────────────────────────────── + if (preferences.post_unit_hooks && Array.isArray(preferences.post_unit_hooks)) { + const validHooks: PostUnitHookConfig[] = []; + const seenNames = new Set(); + const knownUnitTypes = new Set([ + "research-milestone", "plan-milestone", "research-slice", "plan-slice", + "execute-task", "complete-slice", "replan-slice", "reassess-roadmap", + "run-uat", "fix-merge", "complete-milestone", + ]); + for (const hook of preferences.post_unit_hooks) { + if (!hook || typeof hook !== "object") { + errors.push("post_unit_hooks entry must be an object"); + continue; + } + const name = typeof hook.name === "string" ? hook.name.trim() : ""; + if (!name) { + errors.push("post_unit_hooks entry missing name"); + continue; + } + if (seenNames.has(name)) { + errors.push(`duplicate post_unit_hooks name: ${name}`); + continue; + } + const after = normalizeStringList(hook.after); + if (after.length === 0) { + errors.push(`post_unit_hooks "${name}" missing after`); + continue; + } + for (const ut of after) { + if (!knownUnitTypes.has(ut)) { + errors.push(`post_unit_hooks "${name}" unknown unit type in after: ${ut}`); + } + } + const prompt = typeof hook.prompt === "string" ? hook.prompt.trim() : ""; + if (!prompt) { + errors.push(`post_unit_hooks "${name}" missing prompt`); + continue; + } + const validHook: PostUnitHookConfig = { name, after, prompt }; + if (hook.max_cycles !== undefined) { + const mc = typeof hook.max_cycles === "number" ? hook.max_cycles : Number(hook.max_cycles); + validHook.max_cycles = Number.isFinite(mc) ? Math.max(1, Math.min(10, Math.round(mc))) : 1; + } + if (typeof hook.model === "string" && hook.model.trim()) { + validHook.model = hook.model.trim(); + } + if (typeof hook.artifact === "string" && hook.artifact.trim()) { + validHook.artifact = hook.artifact.trim(); + } + if (typeof hook.retry_on === "string" && hook.retry_on.trim()) { + validHook.retry_on = hook.retry_on.trim(); + } + if (typeof hook.agent === "string" && hook.agent.trim()) { + validHook.agent = hook.agent.trim(); + } + if (hook.enabled !== undefined) { + validHook.enabled = !!hook.enabled; + } + seenNames.add(name); + validHooks.push(validHook); + } + if (validHooks.length > 0) { + validated.post_unit_hooks = validHooks; + } + } + + // ─── Pre-Dispatch Hooks ───────────────────────────────────────────────── + if (preferences.pre_dispatch_hooks && Array.isArray(preferences.pre_dispatch_hooks)) { + const validPreHooks: PreDispatchHookConfig[] = []; + const seenPreNames = new Set(); + const knownUnitTypes = new Set([ + "research-milestone", "plan-milestone", "research-slice", "plan-slice", + "execute-task", "complete-slice", "replan-slice", "reassess-roadmap", + "run-uat", "fix-merge", "complete-milestone", + ]); + const validActions = new Set(["modify", "skip", "replace"]); + for (const hook of preferences.pre_dispatch_hooks) { + if (!hook || typeof hook !== "object") { + errors.push("pre_dispatch_hooks entry must be an object"); + continue; + } + const name = typeof hook.name === "string" ? hook.name.trim() : ""; + if (!name) { + errors.push("pre_dispatch_hooks entry missing name"); + continue; + } + if (seenPreNames.has(name)) { + errors.push(`duplicate pre_dispatch_hooks name: ${name}`); + continue; + } + const before = normalizeStringList(hook.before); + if (before.length === 0) { + errors.push(`pre_dispatch_hooks "${name}" missing before`); + continue; + } + for (const ut of before) { + if (!knownUnitTypes.has(ut)) { + errors.push(`pre_dispatch_hooks "${name}" unknown unit type in before: ${ut}`); + } + } + const action = typeof hook.action === "string" ? hook.action.trim() : ""; + if (!validActions.has(action)) { + errors.push(`pre_dispatch_hooks "${name}" invalid action: ${action} (must be modify, skip, or replace)`); + continue; + } + const validHook: PreDispatchHookConfig = { name, before, action: action as PreDispatchHookConfig["action"] }; + if (typeof hook.prepend === "string" && hook.prepend.trim()) validHook.prepend = hook.prepend.trim(); + if (typeof hook.append === "string" && hook.append.trim()) validHook.append = hook.append.trim(); + if (typeof hook.prompt === "string" && hook.prompt.trim()) validHook.prompt = hook.prompt.trim(); + if (typeof hook.unit_type === "string" && hook.unit_type.trim()) validHook.unit_type = hook.unit_type.trim(); + if (typeof hook.skip_if === "string" && hook.skip_if.trim()) validHook.skip_if = hook.skip_if.trim(); + if (typeof hook.model === "string" && hook.model.trim()) validHook.model = hook.model.trim(); + if (hook.enabled !== undefined) validHook.enabled = !!hook.enabled; + + // Validation: action-specific required fields + if (action === "replace" && !validHook.prompt) { + errors.push(`pre_dispatch_hooks "${name}" action "replace" requires prompt`); + continue; + } + if (action === "modify" && !validHook.prepend && !validHook.append) { + errors.push(`pre_dispatch_hooks "${name}" action "modify" requires prepend or append`); + continue; + } + + seenPreNames.add(name); + validPreHooks.push(validHook); + } + if (validPreHooks.length > 0) { + validated.pre_dispatch_hooks = validPreHooks; + } + } + // ─── Git Preferences ─────────────────────────────────────────────────── if (preferences.git && typeof preferences.git === "object") { const git: Record = {}; @@ -794,3 +931,58 @@ function normalizeStringList(value: unknown): string[] { .map((item) => item.trim()) .filter(Boolean); } + +function mergePostUnitHooks( + base?: PostUnitHookConfig[], + override?: PostUnitHookConfig[], +): PostUnitHookConfig[] | undefined { + if (!base?.length && !override?.length) return undefined; + const merged = [...(base ?? [])]; + for (const hook of override ?? []) { + // Override hooks with same name replace base hooks + const idx = merged.findIndex(h => h.name === hook.name); + if (idx >= 0) { + merged[idx] = hook; + } else { + merged.push(hook); + } + } + return merged.length > 0 ? merged : undefined; +} + +/** + * Resolve enabled post-unit hooks from effective preferences. + * Returns an empty array when no hooks are configured. + */ +export function resolvePostUnitHooks(): PostUnitHookConfig[] { + const prefs = loadEffectiveGSDPreferences(); + return (prefs?.preferences.post_unit_hooks ?? []) + .filter(h => h.enabled !== false); +} + +function mergePreDispatchHooks( + base?: PreDispatchHookConfig[], + override?: PreDispatchHookConfig[], +): PreDispatchHookConfig[] | undefined { + if (!base?.length && !override?.length) return undefined; + const merged = [...(base ?? [])]; + for (const hook of override ?? []) { + const idx = merged.findIndex(h => h.name === hook.name); + if (idx >= 0) { + merged[idx] = hook; + } else { + merged.push(hook); + } + } + return merged.length > 0 ? merged : undefined; +} + +/** + * Resolve enabled pre-dispatch hooks from effective preferences. + * Returns an empty array when no hooks are configured. + */ +export function resolvePreDispatchHooks(): PreDispatchHookConfig[] { + const prefs = loadEffectiveGSDPreferences(); + return (prefs?.preferences.pre_dispatch_hooks ?? []) + .filter(h => h.enabled !== false); +} diff --git a/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts b/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts new file mode 100644 index 000000000..d62b46b7e --- /dev/null +++ b/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts @@ -0,0 +1,297 @@ +// GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence) +// Copyright (c) 2026 Jeremy McSpadden + +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createTestContext } from "./test-helpers.ts"; +import { + checkPostUnitHooks, + getActiveHook, + resetHookState, + isRetryPending, + consumeRetryTrigger, + resolveHookArtifactPath, + runPreDispatchHooks, + persistHookState, + restoreHookState, + clearPersistedHookState, + getHookStatus, + formatHookStatus, +} from "../post-unit-hooks.ts"; + +const { assertEq, assertTrue, assertMatch, report } = createTestContext(); + +// ─── Fixture Helpers ─────────────────────────────────────────────────────── + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-hook-test-")); + mkdirSync(join(base, ".gsd", "M001", "slices", "S01", "tasks"), { recursive: true }); + return base; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 1: Post-Unit Hook Tests +// ═══════════════════════════════════════════════════════════════════════════ + +// ─── resolveHookArtifactPath ─────────────────────────────────────────────── + +console.log("\n=== resolveHookArtifactPath ==="); + +{ + const base = "/project"; + + // Task-level + const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md"); + assertEq( + taskPath, + join(base, ".gsd", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"), + "task-level artifact path", + ); + + // Slice-level + const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md"); + assertEq( + slicePath, + join(base, ".gsd", "M001", "slices", "S01", "REVIEW-PASS.md"), + "slice-level artifact path", + ); + + // Milestone-level + const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md"); + assertEq( + milestonePath, + join(base, ".gsd", "M001", "REVIEW-PASS.md"), + "milestone-level artifact path", + ); +} + +// ─── resetHookState ──────────────────────────────────────────────────────── + +console.log("\n=== resetHookState ==="); + +{ + resetHookState(); + assertEq(getActiveHook(), null, "no active hook after reset"); + assertTrue(!isRetryPending(), "no retry pending after reset"); + assertEq(consumeRetryTrigger(), null, "no retry trigger after reset"); +} + +// ─── checkPostUnitHooks with no hooks configured ─────────────────────────── + +console.log("\n=== No hooks configured ==="); + +{ + resetHookState(); + const base = createFixtureBase(); + try { + const result = checkPostUnitHooks("execute-task", "M001/S01/T01", base); + assertEq(result, null, "returns null when no hooks configured"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +// ─── Hook units don't trigger hooks (no hook-on-hook) ────────────────────── + +console.log("\n=== Hook-on-hook prevention ==="); + +{ + resetHookState(); + const base = createFixtureBase(); + try { + const result = checkPostUnitHooks("hook/code-review", "M001/S01/T01", base); + assertEq(result, null, "hook units don't trigger other hooks"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +// ─── consumeRetryTrigger clears state ────────────────────────────────────── + +console.log("\n=== consumeRetryTrigger clears state ==="); + +{ + resetHookState(); + assertEq(consumeRetryTrigger(), null, "no trigger initially"); + assertTrue(!isRetryPending(), "no retry initially"); +} + +// ─── Variable substitution in prompts ────────────────────────────────────── + +console.log("\n=== Variable substitution ==="); + +{ + const base = "/project"; + + // 3-part ID + const path3 = resolveHookArtifactPath(base, "M002/S03/T05", "result.md"); + assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId"); + assertTrue(path3.includes("S03"), "3-part ID extracts sliceId"); + assertTrue(path3.includes("T05"), "3-part ID extracts taskId"); + + // 2-part ID + const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md"); + assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId"); + assertTrue(path2.includes("S03"), "2-part ID extracts sliceId"); + + // 1-part ID + const path1 = resolveHookArtifactPath(base, "M002", "result.md"); + assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 2: Pre-Dispatch Hook Tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log("\n=== Pre-dispatch: no hooks configured ==="); + +{ + const base = createFixtureBase(); + try { + const result = runPreDispatchHooks("execute-task", "M001/S01/T01", "original prompt", base); + assertEq(result.action, "proceed", "proceeds when no hooks"); + assertEq(result.prompt, "original prompt", "prompt unchanged"); + assertEq(result.firedHooks.length, 0, "no hooks fired"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +console.log("\n=== Pre-dispatch: hook units bypass ==="); + +{ + const base = createFixtureBase(); + try { + const result = runPreDispatchHooks("hook/review", "M001/S01/T01", "hook prompt", base); + assertEq(result.action, "proceed", "hook units always proceed"); + assertEq(result.prompt, "hook prompt", "hook prompt unchanged"); + assertEq(result.firedHooks.length, 0, "no hooks fired for hook units"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 3: State Persistence Tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log("\n=== State persistence: persist and restore ==="); + +{ + const base = createFixtureBase(); + try { + resetHookState(); + + // Persist empty state + persistHookState(base); + const filePath = join(base, ".gsd", "hook-state.json"); + assertTrue(existsSync(filePath), "hook-state.json created"); + + const content = JSON.parse(readFileSync(filePath, "utf-8")); + assertEq(typeof content.savedAt, "string", "savedAt is a string"); + assertEq(Object.keys(content.cycleCounts).length, 0, "empty cycle counts"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +console.log("\n=== State persistence: restore from disk ==="); + +{ + const base = createFixtureBase(); + try { + resetHookState(); + + // Write a state file with some cycle counts + const stateFile = join(base, ".gsd", "hook-state.json"); + writeFileSync(stateFile, JSON.stringify({ + cycleCounts: { + "review/execute-task/M001/S01/T01": 2, + "simplify/execute-task/M001/S01/T02": 1, + }, + savedAt: new Date().toISOString(), + }), "utf-8"); + + // Restore + restoreHookState(base); + + // Verify by persisting and reading back + persistHookState(base); + const restored = JSON.parse(readFileSync(stateFile, "utf-8")); + assertEq(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review"); + assertEq(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +console.log("\n=== State persistence: clear ==="); + +{ + const base = createFixtureBase(); + try { + resetHookState(); + + // Write then clear + const stateFile = join(base, ".gsd", "hook-state.json"); + writeFileSync(stateFile, JSON.stringify({ + cycleCounts: { "review/execute-task/M001/S01/T01": 3 }, + savedAt: new Date().toISOString(), + }), "utf-8"); + + clearPersistedHookState(base); + + const cleared = JSON.parse(readFileSync(stateFile, "utf-8")); + assertEq(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +console.log("\n=== State persistence: restore handles missing file ==="); + +{ + const base = createFixtureBase(); + try { + resetHookState(); + // Should not throw + restoreHookState(base); + assertEq(getActiveHook(), null, "no active hook after restore from missing file"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +console.log("\n=== State persistence: restore handles corrupt file ==="); + +{ + const base = createFixtureBase(); + try { + resetHookState(); + writeFileSync(join(base, ".gsd", "hook-state.json"), "not json", "utf-8"); + // Should not throw + restoreHookState(base); + assertEq(getActiveHook(), null, "no active hook after corrupt restore"); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 3: Hook Status Reporting Tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log("\n=== Hook status: no hooks ==="); + +{ + resetHookState(); + const entries = getHookStatus(); + // No preferences file = no hooks + assertEq(entries.length, 0, "no entries when no hooks configured"); + + const formatted = formatHookStatus(); + assertMatch(formatted, /No hooks configured/, "status message says no hooks"); +} + +report(); diff --git a/src/resources/extensions/gsd/tests/preferences-hooks.test.ts b/src/resources/extensions/gsd/tests/preferences-hooks.test.ts new file mode 100644 index 000000000..60417aa22 --- /dev/null +++ b/src/resources/extensions/gsd/tests/preferences-hooks.test.ts @@ -0,0 +1,226 @@ +// GSD Extension — Hook Preferences Parsing Tests (Post-Unit + Pre-Dispatch) +// Copyright (c) 2026 Jeremy McSpadden + +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 1: Post-Unit Hook Config Tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log("\n=== Post-unit hook config validation ==="); + +{ + const validHook = { + name: "test-hook", + after: ["execute-task"], + prompt: "Test prompt", + max_cycles: 2, + model: "claude-sonnet-4-6", + artifact: "TEST-RESULT.md", + retry_on: "TEST-ISSUES.md", + enabled: true, + }; + + assertEq(validHook.name, "test-hook", "valid hook has name"); + assertEq(validHook.after.length, 1, "valid hook has one after entry"); + assertEq(validHook.after[0], "execute-task", "valid hook triggers after execute-task"); + assertTrue(validHook.max_cycles! <= 10, "max_cycles within limit"); + assertTrue(validHook.max_cycles! >= 1, "max_cycles above minimum"); +} + +console.log("\n=== max_cycles clamping ==="); + +{ + const clampedHigh = Math.max(1, Math.min(10, Math.round(15))); + assertEq(clampedHigh, 10, "max_cycles above 10 clamped to 10"); + + const clampedLow = Math.max(1, Math.min(10, Math.round(0))); + assertEq(clampedLow, 1, "max_cycles below 1 clamped to 1"); + + const clampedNeg = Math.max(1, Math.min(10, Math.round(-5))); + assertEq(clampedNeg, 1, "negative max_cycles clamped to 1"); + + const normal = Math.max(1, Math.min(10, Math.round(3))); + assertEq(normal, 3, "normal max_cycles passes through"); +} + +console.log("\n=== Post-unit hook merging ==="); + +{ + const baseHooks = [ + { name: "review", after: ["execute-task"], prompt: "base prompt" }, + { name: "lint", after: ["plan-slice"], prompt: "lint code" }, + ]; + + const overrideHooks = [ + { name: "review", after: ["execute-task", "complete-slice"], prompt: "override prompt" }, + { name: "security", after: ["execute-task"], prompt: "security check" }, + ]; + + const merged = [...baseHooks]; + for (const hook of overrideHooks) { + const idx = merged.findIndex(h => h.name === hook.name); + if (idx >= 0) { + merged[idx] = hook; + } else { + merged.push(hook); + } + } + + assertEq(merged.length, 3, "merged has 3 hooks"); + assertEq(merged[0].prompt, "override prompt", "review hook was overridden"); + assertEq(merged[0].after.length, 2, "overridden review has 2 after entries"); + assertEq(merged[1].name, "lint", "lint kept from base"); + assertEq(merged[2].name, "security", "security added from override"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Phase 2: Pre-Dispatch Hook Config Tests +// ═══════════════════════════════════════════════════════════════════════════ + +console.log("\n=== Pre-dispatch hook config shape ==="); + +{ + const modifyHook = { + name: "inject-context", + before: ["execute-task"], + action: "modify" as const, + prepend: "Remember to follow coding conventions.", + append: "Run tests after making changes.", + enabled: true, + }; + + assertEq(modifyHook.name, "inject-context", "modify hook has name"); + assertEq(modifyHook.action, "modify", "action is modify"); + assertTrue(!!modifyHook.prepend, "has prepend text"); + assertTrue(!!modifyHook.append, "has append text"); +} + +{ + const skipHook = { + name: "skip-research", + before: ["research-slice"], + action: "skip" as const, + skip_if: "RESEARCH-DONE.md", + enabled: true, + }; + + assertEq(skipHook.action, "skip", "action is skip"); + assertEq(skipHook.skip_if, "RESEARCH-DONE.md", "has skip condition"); +} + +{ + const replaceHook = { + name: "custom-planning", + before: ["plan-slice"], + action: "replace" as const, + prompt: "Use custom planning approach for {sliceId}", + unit_type: "custom-plan", + model: "claude-opus-4-6", + enabled: true, + }; + + assertEq(replaceHook.action, "replace", "action is replace"); + assertTrue(!!replaceHook.prompt, "replace hook has prompt"); + assertEq(replaceHook.unit_type, "custom-plan", "has unit_type override"); +} + +console.log("\n=== Pre-dispatch action validation ==="); + +{ + const validActions = new Set(["modify", "skip", "replace"]); + assertTrue(validActions.has("modify"), "modify is valid"); + assertTrue(validActions.has("skip"), "skip is valid"); + assertTrue(validActions.has("replace"), "replace is valid"); + assertTrue(!validActions.has("delete"), "delete is not valid"); + assertTrue(!validActions.has(""), "empty string is not valid"); +} + +console.log("\n=== Pre-dispatch hook merging ==="); + +{ + const baseHooks = [ + { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "base" }, + ]; + + const overrideHooks = [ + { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "override" }, + { name: "gate", before: ["plan-slice"], action: "skip" as const }, + ]; + + const merged = [...baseHooks]; + for (const hook of overrideHooks) { + const idx = merged.findIndex(h => h.name === hook.name); + if (idx >= 0) { + merged[idx] = hook; + } else { + merged.push(hook); + } + } + + assertEq(merged.length, 2, "merged has 2 pre-dispatch hooks"); + assertEq(merged[0].prepend, "override", "inject hook overridden"); + assertEq(merged[1].name, "gate", "gate hook added"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Known unit types validation +// ═══════════════════════════════════════════════════════════════════════════ + +console.log("\n=== Known unit types ==="); + +{ + const knownUnitTypes = new Set([ + "research-milestone", "plan-milestone", "research-slice", "plan-slice", + "execute-task", "complete-slice", "replan-slice", "reassess-roadmap", + "run-uat", "fix-merge", "complete-milestone", + ]); + + assertTrue(knownUnitTypes.has("execute-task"), "execute-task is known"); + assertTrue(knownUnitTypes.has("complete-slice"), "complete-slice is known"); + assertTrue(knownUnitTypes.has("plan-slice"), "plan-slice is known"); + assertTrue(!knownUnitTypes.has("hook/review"), "hook types are not in known set"); + assertTrue(!knownUnitTypes.has("invalid-type"), "invalid types are not in known set"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Preferences YAML format verification +// ═══════════════════════════════════════════════════════════════════════════ + +console.log("\n=== Preferences YAML format ==="); + +{ + const prefsContent = [ + "---", + "version: 1", + "post_unit_hooks:", + " - name: code-review", + " after:", + " - execute-task", + " prompt: Review the changes", + " max_cycles: 3", + " artifact: REVIEW-PASS.md", + " retry_on: REVIEW-ISSUES.md", + "pre_dispatch_hooks:", + " - name: inject-conventions", + " before:", + " - execute-task", + " action: modify", + " append: Follow project coding conventions", + " - name: custom-research", + " before:", + " - research-slice", + " action: replace", + " prompt: Custom research prompt", + "---", + ].join("\n"); + + assertTrue(prefsContent.includes("post_unit_hooks:"), "has post_unit_hooks key"); + assertTrue(prefsContent.includes("pre_dispatch_hooks:"), "has pre_dispatch_hooks key"); + assertTrue(prefsContent.includes("action: modify"), "has modify action"); + assertTrue(prefsContent.includes("action: replace"), "has replace action"); +} + +report(); diff --git a/src/resources/extensions/gsd/types.ts b/src/resources/extensions/gsd/types.ts index 1985545c3..e394b90b3 100644 --- a/src/resources/extensions/gsd/types.ts +++ b/src/resources/extensions/gsd/types.ts @@ -185,3 +185,112 @@ export interface GSDState { tasks?: { done: number; total: number }; }; } + +// ─── Post-Unit Hook Types ───────────────────────────────────────────────── + +export interface PostUnitHookConfig { + /** Unique hook identifier — used in idempotency keys and logging. */ + name: string; + /** Unit types that trigger this hook (e.g., ["execute-task"]). */ + after: string[]; + /** Prompt sent to the LLM session. Supports {milestoneId}, {sliceId}, {taskId} substitutions. */ + prompt: string; + /** Max times this hook can fire for the same trigger unit. Default 1, max 10. */ + max_cycles?: number; + /** Model override for hook sessions. */ + model?: string; + /** Expected output file name (relative to task/slice dir). Used for idempotency — skip if exists. */ + artifact?: string; + /** If this file is produced instead of artifact, re-run the trigger unit then re-run hooks. */ + retry_on?: string; + /** Agent definition file to use. */ + agent?: string; + /** Set false to disable without removing config. Default true. */ + enabled?: boolean; +} + +export interface HookExecutionState { + /** Hook name. */ + hookName: string; + /** The unit type that triggered this hook. */ + triggerUnitType: string; + /** The unit ID that triggered this hook. */ + triggerUnitId: string; + /** Current cycle (1-based). */ + cycle: number; + /** Whether the hook completed with a retry signal (retry_on artifact found). */ + pendingRetry: boolean; +} + +export interface HookDispatchResult { + /** Hook name for display. */ + hookName: string; + /** The prompt to send. */ + prompt: string; + /** Model override, if configured. */ + model?: string; + /** Synthetic unit type, e.g. "hook/code-review". */ + unitType: string; + /** The trigger unit's ID, reused for the hook. */ + unitId: string; +} + +// ─── Pre-Dispatch Hook Types ────────────────────────────────────────────── + +export interface PreDispatchHookConfig { + /** Unique hook identifier. */ + name: string; + /** Unit types this hook intercepts before dispatch (e.g., ["execute-task"]). */ + before: string[]; + /** Action to take: "modify" mutates the prompt, "skip" skips the unit, "replace" swaps it. */ + action: 'modify' | 'skip' | 'replace'; + /** For "modify": text prepended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */ + prepend?: string; + /** For "modify": text appended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */ + append?: string; + /** For "replace": the replacement prompt. Supports {milestoneId}, {sliceId}, {taskId}. */ + prompt?: string; + /** For "replace": override the unit type label. */ + unit_type?: string; + /** For "skip": optional condition file — only skip if this file exists (relative to unit dir). */ + skip_if?: string; + /** Model override when this hook fires. */ + model?: string; + /** Set false to disable without removing config. Default true. */ + enabled?: boolean; +} + +export interface PreDispatchResult { + /** What happened: the unit proceeds with modifications, was skipped, or was replaced. */ + action: 'proceed' | 'skip' | 'replace'; + /** Modified/replacement prompt (for "proceed" and "replace"). */ + prompt?: string; + /** Override unit type (for "replace"). */ + unitType?: string; + /** Model override. */ + model?: string; + /** Names of hooks that fired, for logging. */ + firedHooks: string[]; +} + +// ─── Hook State Persistence Types ───────────────────────────────────────── + +export interface PersistedHookState { + /** Cycle counts keyed as "hookName/triggerUnitType/triggerUnitId". */ + cycleCounts: Record; + /** Timestamp of last state save. */ + savedAt: string; +} + +export interface HookStatusEntry { + /** Hook name. */ + name: string; + /** Hook type: "post" or "pre". */ + type: 'post' | 'pre'; + /** Whether hook is enabled. */ + enabled: boolean; + /** What unit types it targets. */ + targets: string[]; + /** Current cycle counts for active triggers. */ + activeCycles: Record; +}