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:
commit
73c0fd8043
7 changed files with 1407 additions and 3 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
},
|
||||
|
|
|
|||
449
src/resources/extensions/gsd/post-unit-hooks.ts
Normal file
449
src/resources/extensions/gsd/post-unit-hooks.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
297
src/resources/extensions/gsd/tests/post-unit-hooks.test.ts
Normal file
297
src/resources/extensions/gsd/tests/post-unit-hooks.test.ts
Normal 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();
|
||||
226
src/resources/extensions/gsd/tests/preferences-hooks.test.ts
Normal file
226
src/resources/extensions/gsd/tests/preferences-hooks.test.ts
Normal 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();
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue