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.
- package/dist/onboarding.js +3 -0
- package/dist/resources/extensions/bg-shell/index.ts +51 -7
- package/dist/resources/extensions/gsd/auto.ts +159 -2
- package/dist/resources/extensions/gsd/commands.ts +9 -3
- package/dist/resources/extensions/gsd/doctor.ts +60 -3
- package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/dist/resources/extensions/gsd/preferences.ts +192 -0
- package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
- package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/templates/context.md +1 -1
- package/dist/resources/extensions/gsd/templates/state.md +3 -3
- package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/dist/resources/extensions/gsd/types.ts +109 -0
- package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/dist/resources/extensions/search-the-web/provider.ts +19 -2
- package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
- package/dist/wizard.js +1 -0
- package/package.json +1 -1
- package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent-loop.js +169 -55
- package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
- package/packages/pi-agent-core/dist/agent.d.ts +13 -1
- package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/agent.js +16 -0
- package/packages/pi-agent-core/dist/agent.js.map +1 -1
- package/packages/pi-agent-core/dist/types.d.ts +91 -1
- package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
- package/packages/pi-agent-core/dist/types.js.map +1 -1
- package/packages/pi-agent-core/src/agent-loop.ts +273 -63
- package/packages/pi-agent-core/src/agent.ts +24 -0
- package/packages/pi-agent-core/src/types.ts +98 -0
- package/packages/pi-ai/dist/env-api-keys.js +1 -0
- package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
- package/packages/pi-ai/dist/models.generated.d.ts +314 -0
- package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
- package/packages/pi-ai/dist/models.generated.js +236 -0
- package/packages/pi-ai/dist/models.generated.js.map +1 -1
- package/packages/pi-ai/dist/types.d.ts +1 -1
- package/packages/pi-ai/dist/types.d.ts.map +1 -1
- package/packages/pi-ai/dist/types.js.map +1 -1
- package/packages/pi-ai/src/env-api-keys.ts +1 -0
- package/packages/pi-ai/src/models.generated.ts +236 -0
- package/packages/pi-ai/src/types.ts +2 -1
- package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js +1 -0
- package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/src/cli/args.ts +1 -0
- package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
- package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
- package/src/resources/extensions/bg-shell/index.ts +51 -7
- package/src/resources/extensions/gsd/auto.ts +159 -2
- package/src/resources/extensions/gsd/commands.ts +9 -3
- package/src/resources/extensions/gsd/doctor.ts +60 -3
- package/src/resources/extensions/gsd/guided-flow.ts +81 -9
- package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
- package/src/resources/extensions/gsd/preferences.ts +192 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
- package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
- package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
- package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
- package/src/resources/extensions/gsd/prompts/queue.md +3 -1
- package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/templates/context.md +1 -1
- package/src/resources/extensions/gsd/templates/state.md +3 -3
- package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
- package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
- package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
- package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
- package/src/resources/extensions/gsd/types.ts +109 -0
- package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
- package/src/resources/extensions/search-the-web/provider.ts +19 -2
- package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
- package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
- 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
|
+
}
|