gsd-pi 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/onboarding.js +3 -0
  2. package/dist/resources/extensions/bg-shell/index.ts +51 -7
  3. package/dist/resources/extensions/gsd/auto.ts +159 -2
  4. package/dist/resources/extensions/gsd/commands.ts +9 -3
  5. package/dist/resources/extensions/gsd/doctor.ts +60 -3
  6. package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
  7. package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  8. package/dist/resources/extensions/gsd/preferences.ts +192 -0
  9. package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
  10. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  11. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  12. package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
  13. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
  14. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  15. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  16. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  17. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  18. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  19. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  20. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  21. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  22. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  23. package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
  24. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  26. package/dist/resources/extensions/gsd/templates/context.md +1 -1
  27. package/dist/resources/extensions/gsd/templates/state.md +3 -3
  28. package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  29. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  30. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  31. package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  32. package/dist/resources/extensions/gsd/types.ts +109 -0
  33. package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  34. package/dist/resources/extensions/search-the-web/provider.ts +19 -2
  35. package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  36. package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  37. package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
  38. package/dist/wizard.js +1 -0
  39. package/package.json +1 -1
  40. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  41. package/packages/pi-agent-core/dist/agent-loop.js +169 -55
  42. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  43. package/packages/pi-agent-core/dist/agent.d.ts +13 -1
  44. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  45. package/packages/pi-agent-core/dist/agent.js +16 -0
  46. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  47. package/packages/pi-agent-core/dist/types.d.ts +91 -1
  48. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  49. package/packages/pi-agent-core/dist/types.js.map +1 -1
  50. package/packages/pi-agent-core/src/agent-loop.ts +273 -63
  51. package/packages/pi-agent-core/src/agent.ts +24 -0
  52. package/packages/pi-agent-core/src/types.ts +98 -0
  53. package/packages/pi-ai/dist/env-api-keys.js +1 -0
  54. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  55. package/packages/pi-ai/dist/models.generated.d.ts +314 -0
  56. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  57. package/packages/pi-ai/dist/models.generated.js +236 -0
  58. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  59. package/packages/pi-ai/dist/types.d.ts +1 -1
  60. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  61. package/packages/pi-ai/dist/types.js.map +1 -1
  62. package/packages/pi-ai/src/env-api-keys.ts +1 -0
  63. package/packages/pi-ai/src/models.generated.ts +236 -0
  64. package/packages/pi-ai/src/types.ts +2 -1
  65. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/cli/args.js +1 -0
  67. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
  69. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
  71. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  73. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  74. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  75. package/packages/pi-coding-agent/src/cli/args.ts +1 -0
  76. package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
  77. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  78. package/src/resources/extensions/bg-shell/index.ts +51 -7
  79. package/src/resources/extensions/gsd/auto.ts +159 -2
  80. package/src/resources/extensions/gsd/commands.ts +9 -3
  81. package/src/resources/extensions/gsd/doctor.ts +60 -3
  82. package/src/resources/extensions/gsd/guided-flow.ts +81 -9
  83. package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  84. package/src/resources/extensions/gsd/preferences.ts +192 -0
  85. package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
  86. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  87. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  88. package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
  89. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
  90. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  91. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  92. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  93. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  94. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  95. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  96. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  97. package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  98. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  99. package/src/resources/extensions/gsd/prompts/queue.md +3 -1
  100. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  101. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  102. package/src/resources/extensions/gsd/templates/context.md +1 -1
  103. package/src/resources/extensions/gsd/templates/state.md +3 -3
  104. package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  105. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  106. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  107. package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  108. package/src/resources/extensions/gsd/types.ts +109 -0
  109. package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  110. package/src/resources/extensions/search-the-web/provider.ts +19 -2
  111. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  112. package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  113. package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
@@ -0,0 +1,449 @@
1
+ // GSD Extension — Hook Engine (Post-Unit, Pre-Dispatch, State Persistence)
2
+ // Manages hook queue, cycle tracking, artifact verification, pre-dispatch
3
+ // interception, and durable hook state for user-configured extensibility.
4
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
5
+
6
+ import type {
7
+ PostUnitHookConfig,
8
+ PreDispatchHookConfig,
9
+ HookExecutionState,
10
+ HookDispatchResult,
11
+ PreDispatchResult,
12
+ PersistedHookState,
13
+ HookStatusEntry,
14
+ } from "./types.js";
15
+ import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ // ─── Hook Queue State ──────────────────────────────────────────────────────
20
+
21
+ /** Currently executing hook, or null if in normal dispatch flow. */
22
+ let activeHook: HookExecutionState | null = null;
23
+
24
+ /** Queue of hooks remaining for the current trigger unit. */
25
+ let hookQueue: Array<{
26
+ config: PostUnitHookConfig;
27
+ triggerUnitType: string;
28
+ triggerUnitId: string;
29
+ }> = [];
30
+
31
+ /** Cycle counts per hook+trigger, keyed as "hookName/triggerUnitType/triggerUnitId". */
32
+ const cycleCounts = new Map<string, number>();
33
+
34
+ /** Set when a hook completes with retry_on artifact present — signals caller to re-run trigger. */
35
+ let retryPending = false;
36
+
37
+ /** Stores the trigger unit info for pending retries so caller knows what to re-run. */
38
+ let retryTrigger: { unitType: string; unitId: string } | null = null;
39
+
40
+ // ─── Public API ────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Called after a unit completes. Returns the next hook unit to dispatch,
44
+ * or null if no hooks apply (normal dispatch should proceed).
45
+ *
46
+ * Call flow:
47
+ * 1. A core unit (e.g. execute-task) completes → handleAgentEnd calls this
48
+ * 2. If hooks match, returns first hook to dispatch. Caller sends the prompt.
49
+ * 3. Hook unit completes → handleAgentEnd calls this again (activeHook is set)
50
+ * 4. Checks retry_on / next hook / done → returns next action or null
51
+ */
52
+ export function checkPostUnitHooks(
53
+ completedUnitType: string,
54
+ completedUnitId: string,
55
+ basePath: string,
56
+ ): HookDispatchResult | null {
57
+ // If we just completed a hook unit, handle its result
58
+ if (activeHook) {
59
+ return handleHookCompletion(basePath);
60
+ }
61
+
62
+ // Don't trigger hooks for other hook units (prevent hook-on-hook chains)
63
+ if (completedUnitType.startsWith("hook/")) return null;
64
+
65
+ // Check if any hooks are configured for this unit type
66
+ const hooks = resolvePostUnitHooks().filter(h =>
67
+ h.after.includes(completedUnitType),
68
+ );
69
+ if (hooks.length === 0) return null;
70
+
71
+ // Build hook queue for this trigger
72
+ hookQueue = hooks.map(config => ({
73
+ config,
74
+ triggerUnitType: completedUnitType,
75
+ triggerUnitId: completedUnitId,
76
+ }));
77
+
78
+ return dequeueNextHook(basePath);
79
+ }
80
+
81
+ /**
82
+ * Returns whether a hook is currently active (for progress display).
83
+ */
84
+ export function getActiveHook(): HookExecutionState | null {
85
+ return activeHook;
86
+ }
87
+
88
+ /**
89
+ * Returns true if a retry of the trigger unit was requested by a hook.
90
+ * Caller should re-dispatch the original trigger unit, then hooks will
91
+ * fire again on its next completion.
92
+ */
93
+ export function isRetryPending(): boolean {
94
+ return retryPending;
95
+ }
96
+
97
+ /**
98
+ * Returns the trigger unit info for a pending retry, or null.
99
+ * Clears the retry state after reading.
100
+ */
101
+ export function consumeRetryTrigger(): { unitType: string; unitId: string } | null {
102
+ if (!retryPending || !retryTrigger) return null;
103
+ const trigger = { ...retryTrigger };
104
+ retryPending = false;
105
+ retryTrigger = null;
106
+ return trigger;
107
+ }
108
+
109
+ /**
110
+ * Reset all hook state. Called on auto-mode start/stop.
111
+ */
112
+ export function resetHookState(): void {
113
+ activeHook = null;
114
+ hookQueue = [];
115
+ cycleCounts.clear();
116
+ retryPending = false;
117
+ retryTrigger = null;
118
+ }
119
+
120
+ // ─── Internal ──────────────────────────────────────────────────────────────
121
+
122
+ function dequeueNextHook(basePath: string): HookDispatchResult | null {
123
+ while (hookQueue.length > 0) {
124
+ const entry = hookQueue.shift()!;
125
+ const { config, triggerUnitType, triggerUnitId } = entry;
126
+
127
+ // Check idempotency — if artifact already exists, skip this hook
128
+ if (config.artifact) {
129
+ const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact);
130
+ if (existsSync(artifactPath)) continue;
131
+ }
132
+
133
+ // Check cycle limit
134
+ const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`;
135
+ const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1;
136
+ const maxCycles = config.max_cycles ?? 1;
137
+ if (currentCycle > maxCycles) continue;
138
+
139
+ cycleCounts.set(cycleKey, currentCycle);
140
+
141
+ activeHook = {
142
+ hookName: config.name,
143
+ triggerUnitType,
144
+ triggerUnitId,
145
+ cycle: currentCycle,
146
+ pendingRetry: false,
147
+ };
148
+
149
+ // Build the prompt with variable substitution
150
+ const [mid, sid, tid] = triggerUnitId.split("/");
151
+ const prompt = config.prompt
152
+ .replace(/\{milestoneId\}/g, mid ?? "")
153
+ .replace(/\{sliceId\}/g, sid ?? "")
154
+ .replace(/\{taskId\}/g, tid ?? "");
155
+
156
+ return {
157
+ hookName: config.name,
158
+ prompt,
159
+ model: config.model,
160
+ unitType: `hook/${config.name}`,
161
+ unitId: triggerUnitId,
162
+ };
163
+ }
164
+
165
+ // No more hooks — clear active state and return null for normal dispatch
166
+ activeHook = null;
167
+ return null;
168
+ }
169
+
170
+ function handleHookCompletion(basePath: string): HookDispatchResult | null {
171
+ const hook = activeHook!;
172
+ const hooks = resolvePostUnitHooks();
173
+ const config = hooks.find(h => h.name === hook.hookName);
174
+
175
+ // Check if retry was requested via retry_on artifact
176
+ if (config?.retry_on) {
177
+ const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on);
178
+ if (existsSync(retryArtifactPath)) {
179
+ // Check cycle limit before allowing retry
180
+ const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`;
181
+ const currentCycle = cycleCounts.get(cycleKey) ?? 1;
182
+ const maxCycles = config.max_cycles ?? 1;
183
+
184
+ if (currentCycle < maxCycles) {
185
+ // Signal retry — caller will re-dispatch the trigger unit
186
+ activeHook = null;
187
+ hookQueue = [];
188
+ retryPending = true;
189
+ retryTrigger = { unitType: hook.triggerUnitType, unitId: hook.triggerUnitId };
190
+ return null;
191
+ }
192
+ // Max cycles reached — fall through to normal completion
193
+ }
194
+ }
195
+
196
+ // Hook completed normally — try next hook in queue
197
+ activeHook = null;
198
+ return dequeueNextHook(basePath);
199
+ }
200
+
201
+ /**
202
+ * Resolve the path where a hook artifact is expected to be written.
203
+ * Uses the trigger unit's directory context:
204
+ * - Task-level (M001/S01/T01): .gsd/M001/slices/S01/tasks/T01-{artifact}
205
+ * - Slice-level (M001/S01): .gsd/M001/slices/S01/{artifact}
206
+ * - Milestone-level (M001): .gsd/M001/{artifact}
207
+ */
208
+ export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
209
+ const parts = unitId.split("/");
210
+ if (parts.length === 3) {
211
+ const [mid, sid, tid] = parts;
212
+ return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
213
+ }
214
+ if (parts.length === 2) {
215
+ const [mid, sid] = parts;
216
+ return join(basePath, ".gsd", mid, "slices", sid, artifactName);
217
+ }
218
+ return join(basePath, ".gsd", parts[0], artifactName);
219
+ }
220
+
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+ // Phase 2: Pre-Dispatch Hooks
223
+ // ═══════════════════════════════════════════════════════════════════════════
224
+
225
+ /**
226
+ * Run pre-dispatch hooks for a unit about to be dispatched.
227
+ * Returns a result indicating whether the unit should proceed (with optional
228
+ * prompt modifications), be skipped, or be replaced entirely.
229
+ *
230
+ * Multiple hooks can fire for the same unit type. They compose:
231
+ * - "modify" hooks stack (all prepend/append applied in order)
232
+ * - "skip" short-circuits (first matching skip wins)
233
+ * - "replace" short-circuits (first matching replace wins)
234
+ * - Skip/replace hooks take precedence over modify hooks
235
+ */
236
+ export function runPreDispatchHooks(
237
+ unitType: string,
238
+ unitId: string,
239
+ prompt: string,
240
+ basePath: string,
241
+ ): PreDispatchResult {
242
+ // Don't intercept hook units
243
+ if (unitType.startsWith("hook/")) {
244
+ return { action: "proceed", prompt, firedHooks: [] };
245
+ }
246
+
247
+ const hooks = resolvePreDispatchHooks().filter(h =>
248
+ h.before.includes(unitType),
249
+ );
250
+ if (hooks.length === 0) {
251
+ return { action: "proceed", prompt, firedHooks: [] };
252
+ }
253
+
254
+ const [mid, sid, tid] = unitId.split("/");
255
+ const substitute = (text: string): string =>
256
+ text
257
+ .replace(/\{milestoneId\}/g, mid ?? "")
258
+ .replace(/\{sliceId\}/g, sid ?? "")
259
+ .replace(/\{taskId\}/g, tid ?? "");
260
+
261
+ const firedHooks: string[] = [];
262
+ let currentPrompt = prompt;
263
+
264
+ for (const hook of hooks) {
265
+ if (hook.action === "skip") {
266
+ // Check optional skip condition
267
+ if (hook.skip_if) {
268
+ const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if);
269
+ if (!existsSync(conditionPath)) continue; // Condition not met, don't skip
270
+ }
271
+ firedHooks.push(hook.name);
272
+ return { action: "skip", firedHooks };
273
+ }
274
+
275
+ if (hook.action === "replace") {
276
+ firedHooks.push(hook.name);
277
+ return {
278
+ action: "replace",
279
+ prompt: substitute(hook.prompt ?? ""),
280
+ unitType: hook.unit_type,
281
+ model: hook.model,
282
+ firedHooks,
283
+ };
284
+ }
285
+
286
+ if (hook.action === "modify") {
287
+ firedHooks.push(hook.name);
288
+ if (hook.prepend) {
289
+ currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`;
290
+ }
291
+ if (hook.append) {
292
+ currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`;
293
+ }
294
+ }
295
+ }
296
+
297
+ return {
298
+ action: "proceed",
299
+ prompt: currentPrompt,
300
+ model: hooks.find(h => h.action === "modify" && h.model)?.model,
301
+ firedHooks,
302
+ };
303
+ }
304
+
305
+ // ═══════════════════════════════════════════════════════════════════════════
306
+ // Phase 3: Hook State Persistence
307
+ // ═══════════════════════════════════════════════════════════════════════════
308
+
309
+ const HOOK_STATE_FILE = "hook-state.json";
310
+
311
+ function hookStatePath(basePath: string): string {
312
+ return join(basePath, ".gsd", HOOK_STATE_FILE);
313
+ }
314
+
315
+ /**
316
+ * Persist current hook cycle counts to disk so they survive crashes/restarts.
317
+ * Called after each hook dispatch and on auto-mode pause.
318
+ */
319
+ export function persistHookState(basePath: string): void {
320
+ const state: PersistedHookState = {
321
+ cycleCounts: Object.fromEntries(cycleCounts),
322
+ savedAt: new Date().toISOString(),
323
+ };
324
+ try {
325
+ const dir = join(basePath, ".gsd");
326
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
327
+ writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8");
328
+ } catch {
329
+ // Non-fatal — state is recreatable from artifacts
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Restore hook cycle counts from disk after a crash/restart.
335
+ * Called during auto-mode resume.
336
+ */
337
+ export function restoreHookState(basePath: string): void {
338
+ try {
339
+ const filePath = hookStatePath(basePath);
340
+ if (!existsSync(filePath)) return;
341
+ const raw = readFileSync(filePath, "utf-8");
342
+ const state: PersistedHookState = JSON.parse(raw);
343
+ if (state.cycleCounts && typeof state.cycleCounts === "object") {
344
+ cycleCounts.clear();
345
+ for (const [key, value] of Object.entries(state.cycleCounts)) {
346
+ if (typeof value === "number") {
347
+ cycleCounts.set(key, value);
348
+ }
349
+ }
350
+ }
351
+ } catch {
352
+ // Non-fatal — fresh state is fine
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Clear persisted hook state file from disk.
358
+ * Called on clean auto-mode stop.
359
+ */
360
+ export function clearPersistedHookState(basePath: string): void {
361
+ try {
362
+ const filePath = hookStatePath(basePath);
363
+ if (existsSync(filePath)) {
364
+ writeFileSync(filePath, JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), "utf-8");
365
+ }
366
+ } catch {
367
+ // Non-fatal
368
+ }
369
+ }
370
+
371
+ // ═══════════════════════════════════════════════════════════════════════════
372
+ // Phase 3: Hook Status Reporting
373
+ // ═══════════════════════════════════════════════════════════════════════════
374
+
375
+ /**
376
+ * Get status of all configured hooks for display by /gsd hooks.
377
+ */
378
+ export function getHookStatus(): HookStatusEntry[] {
379
+ const entries: HookStatusEntry[] = [];
380
+
381
+ // Post-unit hooks
382
+ const postHooks = resolvePostUnitHooks();
383
+ for (const hook of postHooks) {
384
+ const activeCycles: Record<string, number> = {};
385
+ for (const [key, count] of cycleCounts) {
386
+ if (key.startsWith(`${hook.name}/`)) {
387
+ activeCycles[key] = count;
388
+ }
389
+ }
390
+ entries.push({
391
+ name: hook.name,
392
+ type: "post",
393
+ enabled: hook.enabled !== false,
394
+ targets: hook.after,
395
+ activeCycles,
396
+ });
397
+ }
398
+
399
+ // Pre-dispatch hooks
400
+ const preHooks = resolvePreDispatchHooks();
401
+ for (const hook of preHooks) {
402
+ entries.push({
403
+ name: hook.name,
404
+ type: "pre",
405
+ enabled: hook.enabled !== false,
406
+ targets: hook.before,
407
+ activeCycles: {},
408
+ });
409
+ }
410
+
411
+ return entries;
412
+ }
413
+
414
+ /**
415
+ * Format hook status for terminal display.
416
+ */
417
+ export function formatHookStatus(): string {
418
+ const entries = getHookStatus();
419
+ if (entries.length === 0) {
420
+ return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md";
421
+ }
422
+
423
+ const lines: string[] = ["Configured Hooks:", ""];
424
+
425
+ const postHooks = entries.filter(e => e.type === "post");
426
+ const preHooks = entries.filter(e => e.type === "pre");
427
+
428
+ if (postHooks.length > 0) {
429
+ lines.push("Post-Unit Hooks (run after unit completes):");
430
+ for (const hook of postHooks) {
431
+ const status = hook.enabled ? "enabled" : "disabled";
432
+ const cycles = Object.keys(hook.activeCycles).length;
433
+ const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : "";
434
+ lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`);
435
+ }
436
+ lines.push("");
437
+ }
438
+
439
+ if (preHooks.length > 0) {
440
+ lines.push("Pre-Dispatch Hooks (run before unit dispatches):");
441
+ for (const hook of preHooks) {
442
+ const status = hook.enabled ? "enabled" : "disabled";
443
+ lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`);
444
+ }
445
+ lines.push("");
446
+ }
447
+
448
+ return lines.join("\n");
449
+ }
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
4
  import { getAgentDir } from "@gsd/pi-coding-agent";
5
5
  import type { GitPreferences } from "./git-service.js";
6
+ import type { PostUnitHookConfig, PreDispatchHookConfig } from "./types.js";
6
7
  import { VALID_BRANCH_NAME } from "./git-service.js";
7
8
 
8
9
  const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
@@ -93,6 +94,8 @@ export interface GSDPreferences {
93
94
  budget_ceiling?: number;
94
95
  remote_questions?: RemoteQuestionsConfig;
95
96
  git?: GitPreferences;
97
+ post_unit_hooks?: PostUnitHookConfig[];
98
+ pre_dispatch_hooks?: PreDispatchHookConfig[];
96
99
  }
97
100
 
98
101
  export interface LoadedGSDPreferences {
@@ -626,6 +629,8 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
626
629
  git: (base.git || override.git)
627
630
  ? { ...(base.git ?? {}), ...(override.git ?? {}) }
628
631
  : undefined,
632
+ post_unit_hooks: mergePostUnitHooks(base.post_unit_hooks, override.post_unit_hooks),
633
+ pre_dispatch_hooks: mergePreDispatchHooks(base.pre_dispatch_hooks, override.pre_dispatch_hooks),
629
634
  };
630
635
  }
631
636
 
@@ -713,6 +718,138 @@ function validatePreferences(preferences: GSDPreferences): {
713
718
  }
714
719
  }
715
720
 
721
+ // ─── Post-Unit Hooks ─────────────────────────────────────────────────
722
+ if (preferences.post_unit_hooks && Array.isArray(preferences.post_unit_hooks)) {
723
+ const validHooks: PostUnitHookConfig[] = [];
724
+ const seenNames = new Set<string>();
725
+ const knownUnitTypes = new Set([
726
+ "research-milestone", "plan-milestone", "research-slice", "plan-slice",
727
+ "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
728
+ "run-uat", "fix-merge", "complete-milestone",
729
+ ]);
730
+ for (const hook of preferences.post_unit_hooks) {
731
+ if (!hook || typeof hook !== "object") {
732
+ errors.push("post_unit_hooks entry must be an object");
733
+ continue;
734
+ }
735
+ const name = typeof hook.name === "string" ? hook.name.trim() : "";
736
+ if (!name) {
737
+ errors.push("post_unit_hooks entry missing name");
738
+ continue;
739
+ }
740
+ if (seenNames.has(name)) {
741
+ errors.push(`duplicate post_unit_hooks name: ${name}`);
742
+ continue;
743
+ }
744
+ const after = normalizeStringList(hook.after);
745
+ if (after.length === 0) {
746
+ errors.push(`post_unit_hooks "${name}" missing after`);
747
+ continue;
748
+ }
749
+ for (const ut of after) {
750
+ if (!knownUnitTypes.has(ut)) {
751
+ errors.push(`post_unit_hooks "${name}" unknown unit type in after: ${ut}`);
752
+ }
753
+ }
754
+ const prompt = typeof hook.prompt === "string" ? hook.prompt.trim() : "";
755
+ if (!prompt) {
756
+ errors.push(`post_unit_hooks "${name}" missing prompt`);
757
+ continue;
758
+ }
759
+ const validHook: PostUnitHookConfig = { name, after, prompt };
760
+ if (hook.max_cycles !== undefined) {
761
+ const mc = typeof hook.max_cycles === "number" ? hook.max_cycles : Number(hook.max_cycles);
762
+ validHook.max_cycles = Number.isFinite(mc) ? Math.max(1, Math.min(10, Math.round(mc))) : 1;
763
+ }
764
+ if (typeof hook.model === "string" && hook.model.trim()) {
765
+ validHook.model = hook.model.trim();
766
+ }
767
+ if (typeof hook.artifact === "string" && hook.artifact.trim()) {
768
+ validHook.artifact = hook.artifact.trim();
769
+ }
770
+ if (typeof hook.retry_on === "string" && hook.retry_on.trim()) {
771
+ validHook.retry_on = hook.retry_on.trim();
772
+ }
773
+ if (typeof hook.agent === "string" && hook.agent.trim()) {
774
+ validHook.agent = hook.agent.trim();
775
+ }
776
+ if (hook.enabled !== undefined) {
777
+ validHook.enabled = !!hook.enabled;
778
+ }
779
+ seenNames.add(name);
780
+ validHooks.push(validHook);
781
+ }
782
+ if (validHooks.length > 0) {
783
+ validated.post_unit_hooks = validHooks;
784
+ }
785
+ }
786
+
787
+ // ─── Pre-Dispatch Hooks ─────────────────────────────────────────────────
788
+ if (preferences.pre_dispatch_hooks && Array.isArray(preferences.pre_dispatch_hooks)) {
789
+ const validPreHooks: PreDispatchHookConfig[] = [];
790
+ const seenPreNames = new Set<string>();
791
+ const knownUnitTypes = new Set([
792
+ "research-milestone", "plan-milestone", "research-slice", "plan-slice",
793
+ "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
794
+ "run-uat", "fix-merge", "complete-milestone",
795
+ ]);
796
+ const validActions = new Set(["modify", "skip", "replace"]);
797
+ for (const hook of preferences.pre_dispatch_hooks) {
798
+ if (!hook || typeof hook !== "object") {
799
+ errors.push("pre_dispatch_hooks entry must be an object");
800
+ continue;
801
+ }
802
+ const name = typeof hook.name === "string" ? hook.name.trim() : "";
803
+ if (!name) {
804
+ errors.push("pre_dispatch_hooks entry missing name");
805
+ continue;
806
+ }
807
+ if (seenPreNames.has(name)) {
808
+ errors.push(`duplicate pre_dispatch_hooks name: ${name}`);
809
+ continue;
810
+ }
811
+ const before = normalizeStringList(hook.before);
812
+ if (before.length === 0) {
813
+ errors.push(`pre_dispatch_hooks "${name}" missing before`);
814
+ continue;
815
+ }
816
+ for (const ut of before) {
817
+ if (!knownUnitTypes.has(ut)) {
818
+ errors.push(`pre_dispatch_hooks "${name}" unknown unit type in before: ${ut}`);
819
+ }
820
+ }
821
+ const action = typeof hook.action === "string" ? hook.action.trim() : "";
822
+ if (!validActions.has(action)) {
823
+ errors.push(`pre_dispatch_hooks "${name}" invalid action: ${action} (must be modify, skip, or replace)`);
824
+ continue;
825
+ }
826
+ const validHook: PreDispatchHookConfig = { name, before, action: action as PreDispatchHookConfig["action"] };
827
+ if (typeof hook.prepend === "string" && hook.prepend.trim()) validHook.prepend = hook.prepend.trim();
828
+ if (typeof hook.append === "string" && hook.append.trim()) validHook.append = hook.append.trim();
829
+ if (typeof hook.prompt === "string" && hook.prompt.trim()) validHook.prompt = hook.prompt.trim();
830
+ if (typeof hook.unit_type === "string" && hook.unit_type.trim()) validHook.unit_type = hook.unit_type.trim();
831
+ if (typeof hook.skip_if === "string" && hook.skip_if.trim()) validHook.skip_if = hook.skip_if.trim();
832
+ if (typeof hook.model === "string" && hook.model.trim()) validHook.model = hook.model.trim();
833
+ if (hook.enabled !== undefined) validHook.enabled = !!hook.enabled;
834
+
835
+ // Validation: action-specific required fields
836
+ if (action === "replace" && !validHook.prompt) {
837
+ errors.push(`pre_dispatch_hooks "${name}" action "replace" requires prompt`);
838
+ continue;
839
+ }
840
+ if (action === "modify" && !validHook.prepend && !validHook.append) {
841
+ errors.push(`pre_dispatch_hooks "${name}" action "modify" requires prepend or append`);
842
+ continue;
843
+ }
844
+
845
+ seenPreNames.add(name);
846
+ validPreHooks.push(validHook);
847
+ }
848
+ if (validPreHooks.length > 0) {
849
+ validated.pre_dispatch_hooks = validPreHooks;
850
+ }
851
+ }
852
+
716
853
  // ─── Git Preferences ───────────────────────────────────────────────────
717
854
  if (preferences.git && typeof preferences.git === "object") {
718
855
  const git: Record<string, unknown> = {};
@@ -794,3 +931,58 @@ function normalizeStringList(value: unknown): string[] {
794
931
  .map((item) => item.trim())
795
932
  .filter(Boolean);
796
933
  }
934
+
935
+ function mergePostUnitHooks(
936
+ base?: PostUnitHookConfig[],
937
+ override?: PostUnitHookConfig[],
938
+ ): PostUnitHookConfig[] | undefined {
939
+ if (!base?.length && !override?.length) return undefined;
940
+ const merged = [...(base ?? [])];
941
+ for (const hook of override ?? []) {
942
+ // Override hooks with same name replace base hooks
943
+ const idx = merged.findIndex(h => h.name === hook.name);
944
+ if (idx >= 0) {
945
+ merged[idx] = hook;
946
+ } else {
947
+ merged.push(hook);
948
+ }
949
+ }
950
+ return merged.length > 0 ? merged : undefined;
951
+ }
952
+
953
+ /**
954
+ * Resolve enabled post-unit hooks from effective preferences.
955
+ * Returns an empty array when no hooks are configured.
956
+ */
957
+ export function resolvePostUnitHooks(): PostUnitHookConfig[] {
958
+ const prefs = loadEffectiveGSDPreferences();
959
+ return (prefs?.preferences.post_unit_hooks ?? [])
960
+ .filter(h => h.enabled !== false);
961
+ }
962
+
963
+ function mergePreDispatchHooks(
964
+ base?: PreDispatchHookConfig[],
965
+ override?: PreDispatchHookConfig[],
966
+ ): PreDispatchHookConfig[] | undefined {
967
+ if (!base?.length && !override?.length) return undefined;
968
+ const merged = [...(base ?? [])];
969
+ for (const hook of override ?? []) {
970
+ const idx = merged.findIndex(h => h.name === hook.name);
971
+ if (idx >= 0) {
972
+ merged[idx] = hook;
973
+ } else {
974
+ merged.push(hook);
975
+ }
976
+ }
977
+ return merged.length > 0 ? merged : undefined;
978
+ }
979
+
980
+ /**
981
+ * Resolve enabled pre-dispatch hooks from effective preferences.
982
+ * Returns an empty array when no hooks are configured.
983
+ */
984
+ export function resolvePreDispatchHooks(): PreDispatchHookConfig[] {
985
+ const prefs = loadEffectiveGSDPreferences();
986
+ return (prefs?.preferences.pre_dispatch_hooks ?? [])
987
+ .filter(h => h.enabled !== false);
988
+ }