Merge pull request #416 from fluxlabs/feat/post-unit-hooks-140

feat: extensible hook system for auto-mode state machine
This commit is contained in:
TÂCHES 2026-03-14 17:14:31 -06:00 committed by GitHub
commit 73c0fd8043
7 changed files with 1407 additions and 3 deletions

View file

@ -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<import("./observability-validator.ts").ValidationIssue[]> {
// 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];

View file

@ -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 <path>, 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 <path>, or /gsd remote [slack|discord|status|disconnect].`,
"warning",
);
},

View file

@ -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 <jeremy@fluxlabs.net>
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<string, number>();
/** 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<string, number> = {};
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");
}

View file

@ -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<string>();
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<string>();
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<string, unknown> = {};
@ -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);
}

View file

@ -0,0 +1,297 @@
// GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
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();

View file

@ -0,0 +1,226 @@
// GSD Extension — Hook Preferences Parsing Tests (Post-Unit + Pre-Dispatch)
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
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();

View file

@ -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<string, number>;
/** 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<string, number>;
}