gsd-pi 2.18.0 → 2.19.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/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/dist/resources/extensions/gsd/auto.ts +276 -19
- package/dist/resources/extensions/gsd/captures.ts +384 -0
- package/dist/resources/extensions/gsd/commands.ts +139 -3
- package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/dist/resources/extensions/gsd/metrics.ts +48 -0
- package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/dist/resources/extensions/gsd/model-router.ts +256 -0
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/dist/resources/extensions/gsd/preferences.ts +73 -0
- package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/dist/resources/extensions/remote-questions/format.ts +12 -6
- package/dist/resources/extensions/remote-questions/manager.ts +8 -0
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
- package/src/resources/extensions/gsd/auto.ts +276 -19
- package/src/resources/extensions/gsd/captures.ts +384 -0
- package/src/resources/extensions/gsd/commands.ts +139 -3
- package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
- package/src/resources/extensions/gsd/metrics.ts +48 -0
- package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
- package/src/resources/extensions/gsd/model-router.ts +256 -0
- package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/src/resources/extensions/gsd/preferences.ts +73 -0
- package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
- package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
- package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
- package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
- package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
- package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
- package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
- package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
- package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
- package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
- package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
- package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
- package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
- package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
- package/src/resources/extensions/gsd/triage-ui.ts +175 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
- package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
- package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
- package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
- package/src/resources/extensions/remote-questions/format.ts +12 -6
- package/src/resources/extensions/remote-questions/manager.ts +8 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for GSD Captures — file I/O, parsing, and worktree path resolution.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the boundary contract that S02 (auto-mode dispatch) depends on:
|
|
5
|
+
* - appendCapture creates/appends entries to CAPTURES.md
|
|
6
|
+
* - loadAllCaptures / loadPendingCaptures parse and filter correctly
|
|
7
|
+
* - hasPendingCaptures does fast regex check without full parse
|
|
8
|
+
* - markCaptureResolved updates entry in place
|
|
9
|
+
* - resolveCapturesPath handles worktree paths
|
|
10
|
+
* - parseTriageOutput handles valid, malformed, and partial JSON
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import {
|
|
19
|
+
appendCapture,
|
|
20
|
+
loadAllCaptures,
|
|
21
|
+
loadPendingCaptures,
|
|
22
|
+
hasPendingCaptures,
|
|
23
|
+
markCaptureResolved,
|
|
24
|
+
resolveCapturesPath,
|
|
25
|
+
parseTriageOutput,
|
|
26
|
+
} from "../captures.ts";
|
|
27
|
+
|
|
28
|
+
function makeTempDir(prefix: string): string {
|
|
29
|
+
const dir = join(
|
|
30
|
+
tmpdir(),
|
|
31
|
+
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
32
|
+
);
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── appendCapture ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
test("captures: appendCapture creates CAPTURES.md on first call", () => {
|
|
40
|
+
const tmp = makeTempDir("cap-create");
|
|
41
|
+
try {
|
|
42
|
+
const id = appendCapture(tmp, "first thought");
|
|
43
|
+
assert.ok(id.startsWith("CAP-"), "ID should start with CAP-");
|
|
44
|
+
assert.ok(
|
|
45
|
+
existsSync(join(tmp, ".gsd", "CAPTURES.md")),
|
|
46
|
+
"CAPTURES.md should exist",
|
|
47
|
+
);
|
|
48
|
+
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
|
49
|
+
assert.ok(content.includes("# Captures"), "should have header");
|
|
50
|
+
assert.ok(content.includes(`### ${id}`), "should have entry heading");
|
|
51
|
+
assert.ok(
|
|
52
|
+
content.includes("**Text:** first thought"),
|
|
53
|
+
"should have text field",
|
|
54
|
+
);
|
|
55
|
+
assert.ok(
|
|
56
|
+
content.includes("**Status:** pending"),
|
|
57
|
+
"should have pending status",
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("captures: appendCapture appends to existing file", () => {
|
|
65
|
+
const tmp = makeTempDir("cap-append");
|
|
66
|
+
try {
|
|
67
|
+
const id1 = appendCapture(tmp, "thought one");
|
|
68
|
+
const id2 = appendCapture(tmp, "thought two");
|
|
69
|
+
assert.notStrictEqual(id1, id2, "IDs should be unique");
|
|
70
|
+
|
|
71
|
+
const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8");
|
|
72
|
+
assert.ok(content.includes(`### ${id1}`), "should have first entry");
|
|
73
|
+
assert.ok(content.includes(`### ${id2}`), "should have second entry");
|
|
74
|
+
assert.ok(
|
|
75
|
+
content.includes("**Text:** thought one"),
|
|
76
|
+
"should have first text",
|
|
77
|
+
);
|
|
78
|
+
assert.ok(
|
|
79
|
+
content.includes("**Text:** thought two"),
|
|
80
|
+
"should have second text",
|
|
81
|
+
);
|
|
82
|
+
} finally {
|
|
83
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ─── loadAllCaptures / loadPendingCaptures ────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
test("captures: loadAllCaptures parses entries correctly", () => {
|
|
90
|
+
const tmp = makeTempDir("cap-load");
|
|
91
|
+
try {
|
|
92
|
+
appendCapture(tmp, "alpha");
|
|
93
|
+
appendCapture(tmp, "beta");
|
|
94
|
+
|
|
95
|
+
const all = loadAllCaptures(tmp);
|
|
96
|
+
assert.strictEqual(all.length, 2, "should have 2 entries");
|
|
97
|
+
assert.strictEqual(all[0].text, "alpha");
|
|
98
|
+
assert.strictEqual(all[1].text, "beta");
|
|
99
|
+
assert.strictEqual(all[0].status, "pending");
|
|
100
|
+
assert.strictEqual(all[1].status, "pending");
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("captures: loadAllCaptures returns empty array when no file", () => {
|
|
107
|
+
const tmp = makeTempDir("cap-nofile");
|
|
108
|
+
try {
|
|
109
|
+
const all = loadAllCaptures(tmp);
|
|
110
|
+
assert.strictEqual(all.length, 0);
|
|
111
|
+
} finally {
|
|
112
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("captures: loadPendingCaptures filters resolved entries", () => {
|
|
117
|
+
const tmp = makeTempDir("cap-pending");
|
|
118
|
+
try {
|
|
119
|
+
const id1 = appendCapture(tmp, "pending one");
|
|
120
|
+
appendCapture(tmp, "pending two");
|
|
121
|
+
|
|
122
|
+
// Resolve the first one
|
|
123
|
+
markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note");
|
|
124
|
+
|
|
125
|
+
const pending = loadPendingCaptures(tmp);
|
|
126
|
+
assert.strictEqual(pending.length, 1, "should have 1 pending");
|
|
127
|
+
assert.strictEqual(pending[0].text, "pending two");
|
|
128
|
+
|
|
129
|
+
const all = loadAllCaptures(tmp);
|
|
130
|
+
assert.strictEqual(all.length, 2, "all should still have 2");
|
|
131
|
+
assert.strictEqual(all[0].status, "resolved");
|
|
132
|
+
assert.strictEqual(all[1].status, "pending");
|
|
133
|
+
} finally {
|
|
134
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ─── hasPendingCaptures ───────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
test("captures: hasPendingCaptures returns false when no file", () => {
|
|
141
|
+
const tmp = makeTempDir("cap-has-nofile");
|
|
142
|
+
try {
|
|
143
|
+
assert.strictEqual(hasPendingCaptures(tmp), false);
|
|
144
|
+
} finally {
|
|
145
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("captures: hasPendingCaptures returns true with pending entries", () => {
|
|
150
|
+
const tmp = makeTempDir("cap-has-true");
|
|
151
|
+
try {
|
|
152
|
+
appendCapture(tmp, "something");
|
|
153
|
+
assert.strictEqual(hasPendingCaptures(tmp), true);
|
|
154
|
+
} finally {
|
|
155
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("captures: hasPendingCaptures returns false when all resolved", () => {
|
|
160
|
+
const tmp = makeTempDir("cap-has-false");
|
|
161
|
+
try {
|
|
162
|
+
const id = appendCapture(tmp, "will resolve");
|
|
163
|
+
markCaptureResolved(tmp, id, "note", "done", "resolved it");
|
|
164
|
+
assert.strictEqual(hasPendingCaptures(tmp), false);
|
|
165
|
+
} finally {
|
|
166
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ─── markCaptureResolved ──────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
test("captures: markCaptureResolved updates entry in place", () => {
|
|
173
|
+
const tmp = makeTempDir("cap-resolve");
|
|
174
|
+
try {
|
|
175
|
+
const id1 = appendCapture(tmp, "keep pending");
|
|
176
|
+
const id2 = appendCapture(tmp, "will resolve");
|
|
177
|
+
appendCapture(tmp, "also pending");
|
|
178
|
+
|
|
179
|
+
markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix");
|
|
180
|
+
|
|
181
|
+
const all = loadAllCaptures(tmp);
|
|
182
|
+
assert.strictEqual(all.length, 3, "should still have 3 entries");
|
|
183
|
+
|
|
184
|
+
const resolved = all.find((c) => c.id === id2)!;
|
|
185
|
+
assert.strictEqual(resolved.status, "resolved");
|
|
186
|
+
assert.strictEqual(resolved.classification, "quick-task");
|
|
187
|
+
assert.strictEqual(resolved.resolution, "executed inline");
|
|
188
|
+
assert.strictEqual(resolved.rationale, "small fix");
|
|
189
|
+
assert.ok(resolved.resolvedAt, "should have resolved timestamp");
|
|
190
|
+
|
|
191
|
+
// Others should be unaffected
|
|
192
|
+
const kept = all.find((c) => c.id === id1)!;
|
|
193
|
+
assert.strictEqual(kept.status, "pending");
|
|
194
|
+
assert.strictEqual(kept.classification, undefined);
|
|
195
|
+
} finally {
|
|
196
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── resolveCapturesPath ──────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
test("captures: resolveCapturesPath returns .gsd/CAPTURES.md for normal path", () => {
|
|
203
|
+
const base = join(tmpdir(), "cap-test-project");
|
|
204
|
+
const result = resolveCapturesPath(base);
|
|
205
|
+
assert.ok(result.endsWith(join(".gsd", "CAPTURES.md")));
|
|
206
|
+
assert.ok(result.startsWith(base));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("captures: resolveCapturesPath resolves worktree path to project root", () => {
|
|
210
|
+
const base = join(tmpdir(), "cap-test-project");
|
|
211
|
+
const worktreePath = join(base, ".gsd", "worktrees", "M004");
|
|
212
|
+
const result = resolveCapturesPath(worktreePath);
|
|
213
|
+
assert.ok(
|
|
214
|
+
result.endsWith(join(".gsd", "CAPTURES.md")),
|
|
215
|
+
`should end with .gsd/CAPTURES.md, got: ${result}`,
|
|
216
|
+
);
|
|
217
|
+
// Should resolve to project root, not worktree root
|
|
218
|
+
assert.ok(
|
|
219
|
+
!result.includes("worktrees"),
|
|
220
|
+
`should not contain worktrees, got: ${result}`,
|
|
221
|
+
);
|
|
222
|
+
assert.ok(
|
|
223
|
+
result.startsWith(base),
|
|
224
|
+
`should start with ${base}, got: ${result}`,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ─── parseTriageOutput ────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
test("triage: parseTriageOutput handles valid JSON array", () => {
|
|
231
|
+
const input = JSON.stringify([
|
|
232
|
+
{
|
|
233
|
+
captureId: "CAP-abc123",
|
|
234
|
+
classification: "quick-task",
|
|
235
|
+
rationale: "Small fix",
|
|
236
|
+
affectedFiles: ["src/foo.ts"],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
captureId: "CAP-def456",
|
|
240
|
+
classification: "defer",
|
|
241
|
+
rationale: "Future work",
|
|
242
|
+
targetSlice: "S03",
|
|
243
|
+
},
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
const results = parseTriageOutput(input);
|
|
247
|
+
assert.strictEqual(results.length, 2);
|
|
248
|
+
assert.strictEqual(results[0].captureId, "CAP-abc123");
|
|
249
|
+
assert.strictEqual(results[0].classification, "quick-task");
|
|
250
|
+
assert.deepStrictEqual(results[0].affectedFiles, ["src/foo.ts"]);
|
|
251
|
+
assert.strictEqual(results[1].classification, "defer");
|
|
252
|
+
assert.strictEqual(results[1].targetSlice, "S03");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("triage: parseTriageOutput handles fenced code block", () => {
|
|
256
|
+
const input = `Here are my classifications:
|
|
257
|
+
|
|
258
|
+
\`\`\`json
|
|
259
|
+
[
|
|
260
|
+
{
|
|
261
|
+
"captureId": "CAP-aaa",
|
|
262
|
+
"classification": "note",
|
|
263
|
+
"rationale": "Just informational"
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
\`\`\`
|
|
267
|
+
|
|
268
|
+
That's my analysis.`;
|
|
269
|
+
|
|
270
|
+
const results = parseTriageOutput(input);
|
|
271
|
+
assert.strictEqual(results.length, 1);
|
|
272
|
+
assert.strictEqual(results[0].captureId, "CAP-aaa");
|
|
273
|
+
assert.strictEqual(results[0].classification, "note");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("triage: parseTriageOutput handles JSON with leading/trailing prose", () => {
|
|
277
|
+
const input = `I've analyzed the captures. Here are my results:
|
|
278
|
+
[{"captureId": "CAP-bbb", "classification": "inject", "rationale": "Needs a new task"}]
|
|
279
|
+
Let me know if you need changes.`;
|
|
280
|
+
|
|
281
|
+
const results = parseTriageOutput(input);
|
|
282
|
+
assert.strictEqual(results.length, 1);
|
|
283
|
+
assert.strictEqual(results[0].classification, "inject");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("triage: parseTriageOutput returns empty array on malformed JSON", () => {
|
|
287
|
+
const results = parseTriageOutput("this is not json at all");
|
|
288
|
+
assert.strictEqual(results.length, 0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("triage: parseTriageOutput returns empty array on empty input", () => {
|
|
292
|
+
assert.strictEqual(parseTriageOutput("").length, 0);
|
|
293
|
+
assert.strictEqual(parseTriageOutput(" ").length, 0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("triage: parseTriageOutput filters invalid entries from partial results", () => {
|
|
297
|
+
const input = JSON.stringify([
|
|
298
|
+
{
|
|
299
|
+
captureId: "CAP-good",
|
|
300
|
+
classification: "note",
|
|
301
|
+
rationale: "Valid entry",
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
captureId: "CAP-bad",
|
|
305
|
+
classification: "invalid-type",
|
|
306
|
+
rationale: "Bad classification",
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
// Missing required fields
|
|
310
|
+
captureId: "CAP-incomplete",
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
captureId: "CAP-also-good",
|
|
314
|
+
classification: "replan",
|
|
315
|
+
rationale: "Needs restructuring",
|
|
316
|
+
},
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const results = parseTriageOutput(input);
|
|
320
|
+
assert.strictEqual(results.length, 2, "should keep only valid entries");
|
|
321
|
+
assert.strictEqual(results[0].captureId, "CAP-good");
|
|
322
|
+
assert.strictEqual(results[1].captureId, "CAP-also-good");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("triage: parseTriageOutput wraps single object in array", () => {
|
|
326
|
+
const input = JSON.stringify({
|
|
327
|
+
captureId: "CAP-single",
|
|
328
|
+
classification: "quick-task",
|
|
329
|
+
rationale: "Just one",
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const results = parseTriageOutput(input);
|
|
333
|
+
assert.strictEqual(results.length, 1);
|
|
334
|
+
assert.strictEqual(results[0].captureId, "CAP-single");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("triage: parseTriageOutput handles all five classification types", () => {
|
|
338
|
+
const types = [
|
|
339
|
+
"quick-task",
|
|
340
|
+
"inject",
|
|
341
|
+
"defer",
|
|
342
|
+
"replan",
|
|
343
|
+
"note",
|
|
344
|
+
] as const;
|
|
345
|
+
|
|
346
|
+
const input = JSON.stringify(
|
|
347
|
+
types.map((t, i) => ({
|
|
348
|
+
captureId: `CAP-${i}`,
|
|
349
|
+
classification: t,
|
|
350
|
+
rationale: `Type: ${t}`,
|
|
351
|
+
})),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const results = parseTriageOutput(input);
|
|
355
|
+
assert.strictEqual(results.length, 5);
|
|
356
|
+
for (let i = 0; i < types.length; i++) {
|
|
357
|
+
assert.strictEqual(results[i].classification, types[i]);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ─── Edge Cases ───────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
test("captures: appendCapture handles special characters in text", () => {
|
|
364
|
+
const tmp = makeTempDir("cap-special");
|
|
365
|
+
try {
|
|
366
|
+
const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`');
|
|
367
|
+
const all = loadAllCaptures(tmp);
|
|
368
|
+
assert.strictEqual(all.length, 1);
|
|
369
|
+
assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes");
|
|
370
|
+
assert.ok(all[0].text.includes("**bold**"), "should preserve bold");
|
|
371
|
+
} finally {
|
|
372
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("captures: markCaptureResolved is no-op for non-existent ID", () => {
|
|
377
|
+
const tmp = makeTempDir("cap-noop");
|
|
378
|
+
try {
|
|
379
|
+
appendCapture(tmp, "real capture");
|
|
380
|
+
// Should not throw
|
|
381
|
+
markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test");
|
|
382
|
+
const all = loadAllCaptures(tmp);
|
|
383
|
+
assert.strictEqual(all.length, 1);
|
|
384
|
+
assert.strictEqual(all[0].status, "pending", "original should be unchanged");
|
|
385
|
+
} finally {
|
|
386
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("captures: markCaptureResolved is no-op when no file exists", () => {
|
|
391
|
+
const tmp = makeTempDir("cap-nofile-resolve");
|
|
392
|
+
try {
|
|
393
|
+
// Should not throw
|
|
394
|
+
markCaptureResolved(tmp, "CAP-abc", "note", "test", "test");
|
|
395
|
+
} finally {
|
|
396
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("captures: re-resolving a capture overwrites previous resolution", () => {
|
|
401
|
+
const tmp = makeTempDir("cap-reresolve");
|
|
402
|
+
try {
|
|
403
|
+
const id = appendCapture(tmp, "will re-resolve");
|
|
404
|
+
markCaptureResolved(tmp, id, "note", "first resolution", "first rationale");
|
|
405
|
+
markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale");
|
|
406
|
+
|
|
407
|
+
const all = loadAllCaptures(tmp);
|
|
408
|
+
assert.strictEqual(all.length, 1);
|
|
409
|
+
assert.strictEqual(all[0].classification, "inject", "should have updated classification");
|
|
410
|
+
assert.strictEqual(all[0].resolution, "second resolution");
|
|
411
|
+
assert.strictEqual(all[0].rationale, "second rationale");
|
|
412
|
+
} finally {
|
|
413
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("triage: parseTriageOutput preserves affectedFiles and targetSlice", () => {
|
|
418
|
+
const input = JSON.stringify([
|
|
419
|
+
{
|
|
420
|
+
captureId: "CAP-files",
|
|
421
|
+
classification: "quick-task",
|
|
422
|
+
rationale: "Has files",
|
|
423
|
+
affectedFiles: ["src/a.ts", "src/b.ts"],
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
captureId: "CAP-target",
|
|
427
|
+
classification: "defer",
|
|
428
|
+
rationale: "Has target",
|
|
429
|
+
targetSlice: "S04",
|
|
430
|
+
},
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
const results = parseTriageOutput(input);
|
|
434
|
+
assert.deepStrictEqual(results[0].affectedFiles, ["src/a.ts", "src/b.ts"]);
|
|
435
|
+
assert.strictEqual(results[0].targetSlice, undefined);
|
|
436
|
+
assert.strictEqual(results[1].targetSlice, "S04");
|
|
437
|
+
assert.strictEqual(results[1].affectedFiles, undefined);
|
|
438
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { classifyUnitComplexity, tierLabel, tierOrdinal } from "../complexity-classifier.js";
|
|
5
|
+
import type { ComplexityTier, TaskMetadata } from "../complexity-classifier.js";
|
|
6
|
+
|
|
7
|
+
// ─── tierLabel ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
test("tierLabel returns correct short labels", () => {
|
|
10
|
+
assert.equal(tierLabel("light"), "L");
|
|
11
|
+
assert.equal(tierLabel("standard"), "S");
|
|
12
|
+
assert.equal(tierLabel("heavy"), "H");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ─── tierOrdinal ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
test("tierOrdinal returns correct ordering", () => {
|
|
18
|
+
assert.ok(tierOrdinal("light") < tierOrdinal("standard"));
|
|
19
|
+
assert.ok(tierOrdinal("standard") < tierOrdinal("heavy"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ─── Unit Type Classification ────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
test("complete-slice classifies as light", () => {
|
|
25
|
+
const result = classifyUnitComplexity("complete-slice", "M001/S01", "/tmp/fake");
|
|
26
|
+
assert.equal(result.tier, "light");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("run-uat classifies as light", () => {
|
|
30
|
+
const result = classifyUnitComplexity("run-uat", "M001/S01", "/tmp/fake");
|
|
31
|
+
assert.equal(result.tier, "light");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("research-milestone classifies as standard", () => {
|
|
35
|
+
const result = classifyUnitComplexity("research-milestone", "M001", "/tmp/fake");
|
|
36
|
+
assert.equal(result.tier, "standard");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("research-slice classifies as standard", () => {
|
|
40
|
+
const result = classifyUnitComplexity("research-slice", "M001/S01", "/tmp/fake");
|
|
41
|
+
assert.equal(result.tier, "standard");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("plan-milestone classifies as standard", () => {
|
|
45
|
+
const result = classifyUnitComplexity("plan-milestone", "M001", "/tmp/fake");
|
|
46
|
+
assert.equal(result.tier, "standard");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("plan-slice classifies as standard", () => {
|
|
50
|
+
const result = classifyUnitComplexity("plan-slice", "M001/S01", "/tmp/fake");
|
|
51
|
+
assert.equal(result.tier, "standard");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("replan-slice classifies as heavy", () => {
|
|
55
|
+
const result = classifyUnitComplexity("replan-slice", "M001/S01", "/tmp/fake");
|
|
56
|
+
assert.equal(result.tier, "heavy");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reassess-roadmap classifies as heavy", () => {
|
|
60
|
+
const result = classifyUnitComplexity("reassess-roadmap", "M001", "/tmp/fake");
|
|
61
|
+
assert.equal(result.tier, "heavy");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("hook units classify as light", () => {
|
|
65
|
+
const result = classifyUnitComplexity("hook/verify", "M001/S01/T01", "/tmp/fake");
|
|
66
|
+
assert.equal(result.tier, "light");
|
|
67
|
+
assert.match(result.reason, /hook/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("unknown unit types default to standard", () => {
|
|
71
|
+
const result = classifyUnitComplexity("custom-thing", "M001", "/tmp/fake");
|
|
72
|
+
assert.equal(result.tier, "standard");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ─── Task Metadata Classification ────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
test("execute-task with many dependencies classifies as heavy", () => {
|
|
78
|
+
const metadata: TaskMetadata = { dependencyCount: 4 };
|
|
79
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
80
|
+
assert.equal(result.tier, "heavy");
|
|
81
|
+
assert.match(result.reason, /dependencies/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("execute-task with many files classifies as heavy", () => {
|
|
85
|
+
const metadata: TaskMetadata = { fileCount: 8 };
|
|
86
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
87
|
+
assert.equal(result.tier, "heavy");
|
|
88
|
+
assert.match(result.reason, /files/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("execute-task with large estimated lines classifies as heavy", () => {
|
|
92
|
+
const metadata: TaskMetadata = { estimatedLines: 600 };
|
|
93
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
94
|
+
assert.equal(result.tier, "heavy");
|
|
95
|
+
assert.match(result.reason, /lines/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("execute-task with docs tags classifies as light", () => {
|
|
99
|
+
const metadata: TaskMetadata = { tags: ["docs"] };
|
|
100
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
101
|
+
assert.equal(result.tier, "light");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("execute-task with single file modification classifies as light", () => {
|
|
105
|
+
const metadata: TaskMetadata = { fileCount: 1, isNewFile: false };
|
|
106
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
107
|
+
assert.equal(result.tier, "light");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("execute-task with no metadata classifies as standard", () => {
|
|
111
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake");
|
|
112
|
+
assert.equal(result.tier, "standard");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ─── Budget Pressure ─────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
test("no budget pressure below 50%", () => {
|
|
118
|
+
const result = classifyUnitComplexity("research-slice", "M001/S01", "/tmp/fake", 0.3);
|
|
119
|
+
assert.equal(result.tier, "standard");
|
|
120
|
+
assert.equal(result.downgraded, false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("budget pressure at 50% downgrades standard to light", () => {
|
|
124
|
+
const result = classifyUnitComplexity("research-slice", "M001/S01", "/tmp/fake", 0.55);
|
|
125
|
+
assert.equal(result.tier, "light");
|
|
126
|
+
assert.equal(result.downgraded, true);
|
|
127
|
+
assert.match(result.reason, /budget pressure/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("budget pressure at 75% keeps heavy as heavy", () => {
|
|
131
|
+
const result = classifyUnitComplexity("replan-slice", "M001/S01", "/tmp/fake", 0.80);
|
|
132
|
+
assert.equal(result.tier, "heavy");
|
|
133
|
+
assert.equal(result.downgraded, false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("budget pressure at 90% downgrades heavy to standard", () => {
|
|
137
|
+
const result = classifyUnitComplexity("replan-slice", "M001/S01", "/tmp/fake", 0.95);
|
|
138
|
+
assert.equal(result.tier, "standard");
|
|
139
|
+
assert.equal(result.downgraded, true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("budget pressure at 90% downgrades standard to light", () => {
|
|
143
|
+
const result = classifyUnitComplexity("research-slice", "M001/S01", "/tmp/fake", 0.95);
|
|
144
|
+
assert.equal(result.tier, "light");
|
|
145
|
+
assert.equal(result.downgraded, true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("budget pressure at 90% downgrades light stays light", () => {
|
|
149
|
+
const result = classifyUnitComplexity("complete-slice", "M001/S01", "/tmp/fake", 0.95);
|
|
150
|
+
assert.equal(result.tier, "light");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Phase 4: Task Plan Introspection ────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test("execute-task with multiple complexity keywords classifies as heavy", () => {
|
|
156
|
+
const metadata: TaskMetadata = { complexityKeywords: ["migration", "security"] };
|
|
157
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
158
|
+
assert.equal(result.tier, "heavy");
|
|
159
|
+
assert.match(result.reason, /migration/);
|
|
160
|
+
assert.match(result.reason, /security/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("execute-task with single complexity keyword classifies as standard", () => {
|
|
164
|
+
const metadata: TaskMetadata = { complexityKeywords: ["performance"] };
|
|
165
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
166
|
+
assert.equal(result.tier, "standard");
|
|
167
|
+
assert.match(result.reason, /performance/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("execute-task with many code blocks classifies as heavy", () => {
|
|
171
|
+
const metadata: TaskMetadata = { codeBlockCount: 6 };
|
|
172
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
173
|
+
assert.equal(result.tier, "heavy");
|
|
174
|
+
assert.match(result.reason, /code blocks/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("execute-task with few code blocks stays standard", () => {
|
|
178
|
+
const metadata: TaskMetadata = { codeBlockCount: 2 };
|
|
179
|
+
const result = classifyUnitComplexity("execute-task", "M001/S01/T01", "/tmp/fake", undefined, metadata);
|
|
180
|
+
assert.equal(result.tier, "standard");
|
|
181
|
+
});
|