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,297 @@
1
+ // GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence)
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { createTestContext } from "./test-helpers.ts";
8
+ import {
9
+ checkPostUnitHooks,
10
+ getActiveHook,
11
+ resetHookState,
12
+ isRetryPending,
13
+ consumeRetryTrigger,
14
+ resolveHookArtifactPath,
15
+ runPreDispatchHooks,
16
+ persistHookState,
17
+ restoreHookState,
18
+ clearPersistedHookState,
19
+ getHookStatus,
20
+ formatHookStatus,
21
+ } from "../post-unit-hooks.ts";
22
+
23
+ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
24
+
25
+ // ─── Fixture Helpers ───────────────────────────────────────────────────────
26
+
27
+ function createFixtureBase(): string {
28
+ const base = mkdtempSync(join(tmpdir(), "gsd-hook-test-"));
29
+ mkdirSync(join(base, ".gsd", "M001", "slices", "S01", "tasks"), { recursive: true });
30
+ return base;
31
+ }
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // Phase 1: Post-Unit Hook Tests
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ // ─── resolveHookArtifactPath ───────────────────────────────────────────────
38
+
39
+ console.log("\n=== resolveHookArtifactPath ===");
40
+
41
+ {
42
+ const base = "/project";
43
+
44
+ // Task-level
45
+ const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md");
46
+ assertEq(
47
+ taskPath,
48
+ join(base, ".gsd", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"),
49
+ "task-level artifact path",
50
+ );
51
+
52
+ // Slice-level
53
+ const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md");
54
+ assertEq(
55
+ slicePath,
56
+ join(base, ".gsd", "M001", "slices", "S01", "REVIEW-PASS.md"),
57
+ "slice-level artifact path",
58
+ );
59
+
60
+ // Milestone-level
61
+ const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md");
62
+ assertEq(
63
+ milestonePath,
64
+ join(base, ".gsd", "M001", "REVIEW-PASS.md"),
65
+ "milestone-level artifact path",
66
+ );
67
+ }
68
+
69
+ // ─── resetHookState ────────────────────────────────────────────────────────
70
+
71
+ console.log("\n=== resetHookState ===");
72
+
73
+ {
74
+ resetHookState();
75
+ assertEq(getActiveHook(), null, "no active hook after reset");
76
+ assertTrue(!isRetryPending(), "no retry pending after reset");
77
+ assertEq(consumeRetryTrigger(), null, "no retry trigger after reset");
78
+ }
79
+
80
+ // ─── checkPostUnitHooks with no hooks configured ───────────────────────────
81
+
82
+ console.log("\n=== No hooks configured ===");
83
+
84
+ {
85
+ resetHookState();
86
+ const base = createFixtureBase();
87
+ try {
88
+ const result = checkPostUnitHooks("execute-task", "M001/S01/T01", base);
89
+ assertEq(result, null, "returns null when no hooks configured");
90
+ } finally {
91
+ rmSync(base, { recursive: true, force: true });
92
+ }
93
+ }
94
+
95
+ // ─── Hook units don't trigger hooks (no hook-on-hook) ──────────────────────
96
+
97
+ console.log("\n=== Hook-on-hook prevention ===");
98
+
99
+ {
100
+ resetHookState();
101
+ const base = createFixtureBase();
102
+ try {
103
+ const result = checkPostUnitHooks("hook/code-review", "M001/S01/T01", base);
104
+ assertEq(result, null, "hook units don't trigger other hooks");
105
+ } finally {
106
+ rmSync(base, { recursive: true, force: true });
107
+ }
108
+ }
109
+
110
+ // ─── consumeRetryTrigger clears state ──────────────────────────────────────
111
+
112
+ console.log("\n=== consumeRetryTrigger clears state ===");
113
+
114
+ {
115
+ resetHookState();
116
+ assertEq(consumeRetryTrigger(), null, "no trigger initially");
117
+ assertTrue(!isRetryPending(), "no retry initially");
118
+ }
119
+
120
+ // ─── Variable substitution in prompts ──────────────────────────────────────
121
+
122
+ console.log("\n=== Variable substitution ===");
123
+
124
+ {
125
+ const base = "/project";
126
+
127
+ // 3-part ID
128
+ const path3 = resolveHookArtifactPath(base, "M002/S03/T05", "result.md");
129
+ assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId");
130
+ assertTrue(path3.includes("S03"), "3-part ID extracts sliceId");
131
+ assertTrue(path3.includes("T05"), "3-part ID extracts taskId");
132
+
133
+ // 2-part ID
134
+ const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md");
135
+ assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId");
136
+ assertTrue(path2.includes("S03"), "2-part ID extracts sliceId");
137
+
138
+ // 1-part ID
139
+ const path1 = resolveHookArtifactPath(base, "M002", "result.md");
140
+ assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId");
141
+ }
142
+
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+ // Phase 2: Pre-Dispatch Hook Tests
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+
147
+ console.log("\n=== Pre-dispatch: no hooks configured ===");
148
+
149
+ {
150
+ const base = createFixtureBase();
151
+ try {
152
+ const result = runPreDispatchHooks("execute-task", "M001/S01/T01", "original prompt", base);
153
+ assertEq(result.action, "proceed", "proceeds when no hooks");
154
+ assertEq(result.prompt, "original prompt", "prompt unchanged");
155
+ assertEq(result.firedHooks.length, 0, "no hooks fired");
156
+ } finally {
157
+ rmSync(base, { recursive: true, force: true });
158
+ }
159
+ }
160
+
161
+ console.log("\n=== Pre-dispatch: hook units bypass ===");
162
+
163
+ {
164
+ const base = createFixtureBase();
165
+ try {
166
+ const result = runPreDispatchHooks("hook/review", "M001/S01/T01", "hook prompt", base);
167
+ assertEq(result.action, "proceed", "hook units always proceed");
168
+ assertEq(result.prompt, "hook prompt", "hook prompt unchanged");
169
+ assertEq(result.firedHooks.length, 0, "no hooks fired for hook units");
170
+ } finally {
171
+ rmSync(base, { recursive: true, force: true });
172
+ }
173
+ }
174
+
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+ // Phase 3: State Persistence Tests
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+
179
+ console.log("\n=== State persistence: persist and restore ===");
180
+
181
+ {
182
+ const base = createFixtureBase();
183
+ try {
184
+ resetHookState();
185
+
186
+ // Persist empty state
187
+ persistHookState(base);
188
+ const filePath = join(base, ".gsd", "hook-state.json");
189
+ assertTrue(existsSync(filePath), "hook-state.json created");
190
+
191
+ const content = JSON.parse(readFileSync(filePath, "utf-8"));
192
+ assertEq(typeof content.savedAt, "string", "savedAt is a string");
193
+ assertEq(Object.keys(content.cycleCounts).length, 0, "empty cycle counts");
194
+ } finally {
195
+ rmSync(base, { recursive: true, force: true });
196
+ }
197
+ }
198
+
199
+ console.log("\n=== State persistence: restore from disk ===");
200
+
201
+ {
202
+ const base = createFixtureBase();
203
+ try {
204
+ resetHookState();
205
+
206
+ // Write a state file with some cycle counts
207
+ const stateFile = join(base, ".gsd", "hook-state.json");
208
+ writeFileSync(stateFile, JSON.stringify({
209
+ cycleCounts: {
210
+ "review/execute-task/M001/S01/T01": 2,
211
+ "simplify/execute-task/M001/S01/T02": 1,
212
+ },
213
+ savedAt: new Date().toISOString(),
214
+ }), "utf-8");
215
+
216
+ // Restore
217
+ restoreHookState(base);
218
+
219
+ // Verify by persisting and reading back
220
+ persistHookState(base);
221
+ const restored = JSON.parse(readFileSync(stateFile, "utf-8"));
222
+ assertEq(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review");
223
+ assertEq(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify");
224
+ } finally {
225
+ rmSync(base, { recursive: true, force: true });
226
+ }
227
+ }
228
+
229
+ console.log("\n=== State persistence: clear ===");
230
+
231
+ {
232
+ const base = createFixtureBase();
233
+ try {
234
+ resetHookState();
235
+
236
+ // Write then clear
237
+ const stateFile = join(base, ".gsd", "hook-state.json");
238
+ writeFileSync(stateFile, JSON.stringify({
239
+ cycleCounts: { "review/execute-task/M001/S01/T01": 3 },
240
+ savedAt: new Date().toISOString(),
241
+ }), "utf-8");
242
+
243
+ clearPersistedHookState(base);
244
+
245
+ const cleared = JSON.parse(readFileSync(stateFile, "utf-8"));
246
+ assertEq(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared");
247
+ } finally {
248
+ rmSync(base, { recursive: true, force: true });
249
+ }
250
+ }
251
+
252
+ console.log("\n=== State persistence: restore handles missing file ===");
253
+
254
+ {
255
+ const base = createFixtureBase();
256
+ try {
257
+ resetHookState();
258
+ // Should not throw
259
+ restoreHookState(base);
260
+ assertEq(getActiveHook(), null, "no active hook after restore from missing file");
261
+ } finally {
262
+ rmSync(base, { recursive: true, force: true });
263
+ }
264
+ }
265
+
266
+ console.log("\n=== State persistence: restore handles corrupt file ===");
267
+
268
+ {
269
+ const base = createFixtureBase();
270
+ try {
271
+ resetHookState();
272
+ writeFileSync(join(base, ".gsd", "hook-state.json"), "not json", "utf-8");
273
+ // Should not throw
274
+ restoreHookState(base);
275
+ assertEq(getActiveHook(), null, "no active hook after corrupt restore");
276
+ } finally {
277
+ rmSync(base, { recursive: true, force: true });
278
+ }
279
+ }
280
+
281
+ // ═══════════════════════════════════════════════════════════════════════════
282
+ // Phase 3: Hook Status Reporting Tests
283
+ // ═══════════════════════════════════════════════════════════════════════════
284
+
285
+ console.log("\n=== Hook status: no hooks ===");
286
+
287
+ {
288
+ resetHookState();
289
+ const entries = getHookStatus();
290
+ // No preferences file = no hooks
291
+ assertEq(entries.length, 0, "no entries when no hooks configured");
292
+
293
+ const formatted = formatHookStatus();
294
+ assertMatch(formatted, /No hooks configured/, "status message says no hooks");
295
+ }
296
+
297
+ report();
@@ -0,0 +1,226 @@
1
+ // GSD Extension — Hook Preferences Parsing Tests (Post-Unit + Pre-Dispatch)
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { createTestContext } from "./test-helpers.ts";
5
+
6
+ const { assertEq, assertTrue, report } = createTestContext();
7
+
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+ // Phase 1: Post-Unit Hook Config Tests
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+
12
+ console.log("\n=== Post-unit hook config validation ===");
13
+
14
+ {
15
+ const validHook = {
16
+ name: "test-hook",
17
+ after: ["execute-task"],
18
+ prompt: "Test prompt",
19
+ max_cycles: 2,
20
+ model: "claude-sonnet-4-6",
21
+ artifact: "TEST-RESULT.md",
22
+ retry_on: "TEST-ISSUES.md",
23
+ enabled: true,
24
+ };
25
+
26
+ assertEq(validHook.name, "test-hook", "valid hook has name");
27
+ assertEq(validHook.after.length, 1, "valid hook has one after entry");
28
+ assertEq(validHook.after[0], "execute-task", "valid hook triggers after execute-task");
29
+ assertTrue(validHook.max_cycles! <= 10, "max_cycles within limit");
30
+ assertTrue(validHook.max_cycles! >= 1, "max_cycles above minimum");
31
+ }
32
+
33
+ console.log("\n=== max_cycles clamping ===");
34
+
35
+ {
36
+ const clampedHigh = Math.max(1, Math.min(10, Math.round(15)));
37
+ assertEq(clampedHigh, 10, "max_cycles above 10 clamped to 10");
38
+
39
+ const clampedLow = Math.max(1, Math.min(10, Math.round(0)));
40
+ assertEq(clampedLow, 1, "max_cycles below 1 clamped to 1");
41
+
42
+ const clampedNeg = Math.max(1, Math.min(10, Math.round(-5)));
43
+ assertEq(clampedNeg, 1, "negative max_cycles clamped to 1");
44
+
45
+ const normal = Math.max(1, Math.min(10, Math.round(3)));
46
+ assertEq(normal, 3, "normal max_cycles passes through");
47
+ }
48
+
49
+ console.log("\n=== Post-unit hook merging ===");
50
+
51
+ {
52
+ const baseHooks = [
53
+ { name: "review", after: ["execute-task"], prompt: "base prompt" },
54
+ { name: "lint", after: ["plan-slice"], prompt: "lint code" },
55
+ ];
56
+
57
+ const overrideHooks = [
58
+ { name: "review", after: ["execute-task", "complete-slice"], prompt: "override prompt" },
59
+ { name: "security", after: ["execute-task"], prompt: "security check" },
60
+ ];
61
+
62
+ const merged = [...baseHooks];
63
+ for (const hook of overrideHooks) {
64
+ const idx = merged.findIndex(h => h.name === hook.name);
65
+ if (idx >= 0) {
66
+ merged[idx] = hook;
67
+ } else {
68
+ merged.push(hook);
69
+ }
70
+ }
71
+
72
+ assertEq(merged.length, 3, "merged has 3 hooks");
73
+ assertEq(merged[0].prompt, "override prompt", "review hook was overridden");
74
+ assertEq(merged[0].after.length, 2, "overridden review has 2 after entries");
75
+ assertEq(merged[1].name, "lint", "lint kept from base");
76
+ assertEq(merged[2].name, "security", "security added from override");
77
+ }
78
+
79
+ // ═══════════════════════════════════════════════════════════════════════════
80
+ // Phase 2: Pre-Dispatch Hook Config Tests
81
+ // ═══════════════════════════════════════════════════════════════════════════
82
+
83
+ console.log("\n=== Pre-dispatch hook config shape ===");
84
+
85
+ {
86
+ const modifyHook = {
87
+ name: "inject-context",
88
+ before: ["execute-task"],
89
+ action: "modify" as const,
90
+ prepend: "Remember to follow coding conventions.",
91
+ append: "Run tests after making changes.",
92
+ enabled: true,
93
+ };
94
+
95
+ assertEq(modifyHook.name, "inject-context", "modify hook has name");
96
+ assertEq(modifyHook.action, "modify", "action is modify");
97
+ assertTrue(!!modifyHook.prepend, "has prepend text");
98
+ assertTrue(!!modifyHook.append, "has append text");
99
+ }
100
+
101
+ {
102
+ const skipHook = {
103
+ name: "skip-research",
104
+ before: ["research-slice"],
105
+ action: "skip" as const,
106
+ skip_if: "RESEARCH-DONE.md",
107
+ enabled: true,
108
+ };
109
+
110
+ assertEq(skipHook.action, "skip", "action is skip");
111
+ assertEq(skipHook.skip_if, "RESEARCH-DONE.md", "has skip condition");
112
+ }
113
+
114
+ {
115
+ const replaceHook = {
116
+ name: "custom-planning",
117
+ before: ["plan-slice"],
118
+ action: "replace" as const,
119
+ prompt: "Use custom planning approach for {sliceId}",
120
+ unit_type: "custom-plan",
121
+ model: "claude-opus-4-6",
122
+ enabled: true,
123
+ };
124
+
125
+ assertEq(replaceHook.action, "replace", "action is replace");
126
+ assertTrue(!!replaceHook.prompt, "replace hook has prompt");
127
+ assertEq(replaceHook.unit_type, "custom-plan", "has unit_type override");
128
+ }
129
+
130
+ console.log("\n=== Pre-dispatch action validation ===");
131
+
132
+ {
133
+ const validActions = new Set(["modify", "skip", "replace"]);
134
+ assertTrue(validActions.has("modify"), "modify is valid");
135
+ assertTrue(validActions.has("skip"), "skip is valid");
136
+ assertTrue(validActions.has("replace"), "replace is valid");
137
+ assertTrue(!validActions.has("delete"), "delete is not valid");
138
+ assertTrue(!validActions.has(""), "empty string is not valid");
139
+ }
140
+
141
+ console.log("\n=== Pre-dispatch hook merging ===");
142
+
143
+ {
144
+ const baseHooks = [
145
+ { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "base" },
146
+ ];
147
+
148
+ const overrideHooks = [
149
+ { name: "inject", before: ["execute-task"], action: "modify" as const, prepend: "override" },
150
+ { name: "gate", before: ["plan-slice"], action: "skip" as const },
151
+ ];
152
+
153
+ const merged = [...baseHooks];
154
+ for (const hook of overrideHooks) {
155
+ const idx = merged.findIndex(h => h.name === hook.name);
156
+ if (idx >= 0) {
157
+ merged[idx] = hook;
158
+ } else {
159
+ merged.push(hook);
160
+ }
161
+ }
162
+
163
+ assertEq(merged.length, 2, "merged has 2 pre-dispatch hooks");
164
+ assertEq(merged[0].prepend, "override", "inject hook overridden");
165
+ assertEq(merged[1].name, "gate", "gate hook added");
166
+ }
167
+
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+ // Known unit types validation
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ console.log("\n=== Known unit types ===");
173
+
174
+ {
175
+ const knownUnitTypes = new Set([
176
+ "research-milestone", "plan-milestone", "research-slice", "plan-slice",
177
+ "execute-task", "complete-slice", "replan-slice", "reassess-roadmap",
178
+ "run-uat", "fix-merge", "complete-milestone",
179
+ ]);
180
+
181
+ assertTrue(knownUnitTypes.has("execute-task"), "execute-task is known");
182
+ assertTrue(knownUnitTypes.has("complete-slice"), "complete-slice is known");
183
+ assertTrue(knownUnitTypes.has("plan-slice"), "plan-slice is known");
184
+ assertTrue(!knownUnitTypes.has("hook/review"), "hook types are not in known set");
185
+ assertTrue(!knownUnitTypes.has("invalid-type"), "invalid types are not in known set");
186
+ }
187
+
188
+ // ═══════════════════════════════════════════════════════════════════════════
189
+ // Preferences YAML format verification
190
+ // ═══════════════════════════════════════════════════════════════════════════
191
+
192
+ console.log("\n=== Preferences YAML format ===");
193
+
194
+ {
195
+ const prefsContent = [
196
+ "---",
197
+ "version: 1",
198
+ "post_unit_hooks:",
199
+ " - name: code-review",
200
+ " after:",
201
+ " - execute-task",
202
+ " prompt: Review the changes",
203
+ " max_cycles: 3",
204
+ " artifact: REVIEW-PASS.md",
205
+ " retry_on: REVIEW-ISSUES.md",
206
+ "pre_dispatch_hooks:",
207
+ " - name: inject-conventions",
208
+ " before:",
209
+ " - execute-task",
210
+ " action: modify",
211
+ " append: Follow project coding conventions",
212
+ " - name: custom-research",
213
+ " before:",
214
+ " - research-slice",
215
+ " action: replace",
216
+ " prompt: Custom research prompt",
217
+ "---",
218
+ ].join("\n");
219
+
220
+ assertTrue(prefsContent.includes("post_unit_hooks:"), "has post_unit_hooks key");
221
+ assertTrue(prefsContent.includes("pre_dispatch_hooks:"), "has pre_dispatch_hooks key");
222
+ assertTrue(prefsContent.includes("action: modify"), "has modify action");
223
+ assertTrue(prefsContent.includes("action: replace"), "has replace action");
224
+ }
225
+
226
+ report();
@@ -67,6 +67,18 @@ async function main(): Promise<void> {
67
67
  assertEq('M001-abc123: Title'.replace(TITLE_STRIP_RE, ''), 'Title', 'strips M001-abc123: Title → Title');
68
68
  assertEq('M042-z9a8b7: Dashboard'.replace(TITLE_STRIP_RE, ''), 'Dashboard', 'strips M042-z9a8b7: Dashboard');
69
69
 
70
+ // Em dash in title — current format (M001: Title) correctly preserves em dash in title body
71
+ assertEq(
72
+ 'M001: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''),
73
+ 'Foundation — Build Core',
74
+ 'strips M001: prefix and preserves em dash in title body',
75
+ );
76
+ assertEq(
77
+ 'M001-abc123: Foundation — Build Core'.replace(TITLE_STRIP_RE, ''),
78
+ 'Foundation — Build Core',
79
+ 'strips M001-abc123: prefix and preserves em dash in title body (unique format)',
80
+ );
81
+
70
82
  // Edge case: dash-style separator (M001 — Title: Subtitle preserves colon in body)
71
83
  assertEq(
72
84
  'M001 — Unique Milestone IDs: Foo'.replace(TITLE_STRIP_RE, ''),
@@ -185,3 +185,112 @@ export interface GSDState {
185
185
  tasks?: { done: number; total: number };
186
186
  };
187
187
  }
188
+
189
+ // ─── Post-Unit Hook Types ─────────────────────────────────────────────────
190
+
191
+ export interface PostUnitHookConfig {
192
+ /** Unique hook identifier — used in idempotency keys and logging. */
193
+ name: string;
194
+ /** Unit types that trigger this hook (e.g., ["execute-task"]). */
195
+ after: string[];
196
+ /** Prompt sent to the LLM session. Supports {milestoneId}, {sliceId}, {taskId} substitutions. */
197
+ prompt: string;
198
+ /** Max times this hook can fire for the same trigger unit. Default 1, max 10. */
199
+ max_cycles?: number;
200
+ /** Model override for hook sessions. */
201
+ model?: string;
202
+ /** Expected output file name (relative to task/slice dir). Used for idempotency — skip if exists. */
203
+ artifact?: string;
204
+ /** If this file is produced instead of artifact, re-run the trigger unit then re-run hooks. */
205
+ retry_on?: string;
206
+ /** Agent definition file to use. */
207
+ agent?: string;
208
+ /** Set false to disable without removing config. Default true. */
209
+ enabled?: boolean;
210
+ }
211
+
212
+ export interface HookExecutionState {
213
+ /** Hook name. */
214
+ hookName: string;
215
+ /** The unit type that triggered this hook. */
216
+ triggerUnitType: string;
217
+ /** The unit ID that triggered this hook. */
218
+ triggerUnitId: string;
219
+ /** Current cycle (1-based). */
220
+ cycle: number;
221
+ /** Whether the hook completed with a retry signal (retry_on artifact found). */
222
+ pendingRetry: boolean;
223
+ }
224
+
225
+ export interface HookDispatchResult {
226
+ /** Hook name for display. */
227
+ hookName: string;
228
+ /** The prompt to send. */
229
+ prompt: string;
230
+ /** Model override, if configured. */
231
+ model?: string;
232
+ /** Synthetic unit type, e.g. "hook/code-review". */
233
+ unitType: string;
234
+ /** The trigger unit's ID, reused for the hook. */
235
+ unitId: string;
236
+ }
237
+
238
+ // ─── Pre-Dispatch Hook Types ──────────────────────────────────────────────
239
+
240
+ export interface PreDispatchHookConfig {
241
+ /** Unique hook identifier. */
242
+ name: string;
243
+ /** Unit types this hook intercepts before dispatch (e.g., ["execute-task"]). */
244
+ before: string[];
245
+ /** Action to take: "modify" mutates the prompt, "skip" skips the unit, "replace" swaps it. */
246
+ action: 'modify' | 'skip' | 'replace';
247
+ /** For "modify": text prepended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
248
+ prepend?: string;
249
+ /** For "modify": text appended to the unit prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
250
+ append?: string;
251
+ /** For "replace": the replacement prompt. Supports {milestoneId}, {sliceId}, {taskId}. */
252
+ prompt?: string;
253
+ /** For "replace": override the unit type label. */
254
+ unit_type?: string;
255
+ /** For "skip": optional condition file — only skip if this file exists (relative to unit dir). */
256
+ skip_if?: string;
257
+ /** Model override when this hook fires. */
258
+ model?: string;
259
+ /** Set false to disable without removing config. Default true. */
260
+ enabled?: boolean;
261
+ }
262
+
263
+ export interface PreDispatchResult {
264
+ /** What happened: the unit proceeds with modifications, was skipped, or was replaced. */
265
+ action: 'proceed' | 'skip' | 'replace';
266
+ /** Modified/replacement prompt (for "proceed" and "replace"). */
267
+ prompt?: string;
268
+ /** Override unit type (for "replace"). */
269
+ unitType?: string;
270
+ /** Model override. */
271
+ model?: string;
272
+ /** Names of hooks that fired, for logging. */
273
+ firedHooks: string[];
274
+ }
275
+
276
+ // ─── Hook State Persistence Types ─────────────────────────────────────────
277
+
278
+ export interface PersistedHookState {
279
+ /** Cycle counts keyed as "hookName/triggerUnitType/triggerUnitId". */
280
+ cycleCounts: Record<string, number>;
281
+ /** Timestamp of last state save. */
282
+ savedAt: string;
283
+ }
284
+
285
+ export interface HookStatusEntry {
286
+ /** Hook name. */
287
+ name: string;
288
+ /** Hook type: "post" or "pre". */
289
+ type: 'post' | 'pre';
290
+ /** Whether hook is enabled. */
291
+ enabled: boolean;
292
+ /** What unit types it targets. */
293
+ targets: string[];
294
+ /** Current cycle counts for active triggers. */
295
+ activeCycles: Record<string, number>;
296
+ }