gsd-pi 2.59.0-dev.023bd39 → 2.59.0-dev.d77b3dd
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/phases.js +54 -1
- package/dist/resources/extensions/gsd/auto-model-selection.js +8 -3
- package/dist/resources/extensions/gsd/auto-post-unit.js +40 -1
- package/dist/resources/extensions/gsd/auto-prompts.js +13 -0
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +70 -0
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +51 -5
- package/dist/resources/extensions/gsd/captures.js +54 -1
- package/dist/resources/extensions/gsd/complexity-classifier.js +1 -1
- package/dist/resources/extensions/gsd/context-masker.js +68 -0
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +7 -0
- package/dist/resources/extensions/gsd/gsd-db.js +2 -2
- package/dist/resources/extensions/gsd/model-router.js +123 -4
- package/dist/resources/extensions/gsd/phase-anchor.js +56 -0
- package/dist/resources/extensions/gsd/preferences-types.js +1 -0
- package/dist/resources/extensions/gsd/preferences-validation.js +46 -0
- package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -0
- package/dist/resources/extensions/gsd/prompts/rethink.md +7 -0
- package/dist/resources/extensions/gsd/prompts/triage-captures.md +6 -1
- package/dist/resources/extensions/gsd/rethink.js +5 -2
- package/dist/resources/extensions/gsd/state.js +1 -1
- package/dist/resources/extensions/gsd/status-guards.js +4 -3
- package/dist/resources/extensions/gsd/triage-resolution.js +128 -1
- package/dist/resources/extensions/gsd/triage-ui.js +12 -3
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/required-server-files.json +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/phases.ts +60 -1
- package/src/resources/extensions/gsd/auto-model-selection.ts +12 -3
- package/src/resources/extensions/gsd/auto-post-unit.ts +48 -1
- package/src/resources/extensions/gsd/auto-prompts.ts +17 -0
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +78 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +53 -4
- package/src/resources/extensions/gsd/captures.ts +71 -2
- package/src/resources/extensions/gsd/complexity-classifier.ts +1 -1
- package/src/resources/extensions/gsd/context-masker.ts +74 -0
- package/src/resources/extensions/gsd/docs/preferences-reference.md +7 -0
- package/src/resources/extensions/gsd/gsd-db.ts +2 -2
- package/src/resources/extensions/gsd/model-router.ts +171 -8
- package/src/resources/extensions/gsd/phase-anchor.ts +71 -0
- package/src/resources/extensions/gsd/preferences-types.ts +9 -0
- package/src/resources/extensions/gsd/preferences-validation.ts +38 -0
- package/src/resources/extensions/gsd/prompts/execute-task.md +2 -0
- package/src/resources/extensions/gsd/prompts/rethink.md +7 -0
- package/src/resources/extensions/gsd/prompts/triage-captures.md +6 -1
- package/src/resources/extensions/gsd/rethink.ts +5 -2
- package/src/resources/extensions/gsd/state.ts +1 -1
- package/src/resources/extensions/gsd/status-guards.ts +4 -3
- package/src/resources/extensions/gsd/tests/context-masker.test.ts +122 -0
- package/src/resources/extensions/gsd/tests/model-router.test.ts +87 -1
- package/src/resources/extensions/gsd/tests/phase-anchor.test.ts +83 -0
- package/src/resources/extensions/gsd/tests/status-guards.test.ts +4 -0
- package/src/resources/extensions/gsd/tests/stop-backtrack.test.ts +216 -0
- package/src/resources/extensions/gsd/tests/tool-naming.test.ts +1 -1
- package/src/resources/extensions/gsd/triage-resolution.ts +144 -1
- package/src/resources/extensions/gsd/triage-ui.ts +12 -3
- /package/dist/web/standalone/.next/static/{QlWL-8CXgQpzV3ehkNMzh → t_cBZAENjaOJIRST3dw08}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{QlWL-8CXgQpzV3ehkNMzh → t_cBZAENjaOJIRST3dw08}/_ssgManifest.js +0 -0
|
@@ -112,8 +112,11 @@ function buildRethinkData(
|
|
|
112
112
|
if (dbAvailable && status !== "complete") {
|
|
113
113
|
const slices = getMilestoneSlices(mid);
|
|
114
114
|
if (slices.length > 0) {
|
|
115
|
-
const done = slices.filter(s => s.status === "complete").length;
|
|
116
|
-
|
|
115
|
+
const done = slices.filter(s => s.status === "complete" || s.status === "done").length;
|
|
116
|
+
const skipped = slices.filter(s => s.status === "skipped").length;
|
|
117
|
+
sliceInfo = skipped > 0
|
|
118
|
+
? `${done}/${slices.length} complete, ${skipped} skipped`
|
|
119
|
+
: `${done}/${slices.length} complete`;
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
|
|
@@ -295,7 +295,7 @@ function extractContextTitle(content: string | null, fallback: string): string {
|
|
|
295
295
|
* Helper: check if a DB status counts as "done" (handles K002 ambiguity).
|
|
296
296
|
*/
|
|
297
297
|
function isStatusDone(status: string): boolean {
|
|
298
|
-
return status === 'complete' || status === 'done';
|
|
298
|
+
return status === 'complete' || status === 'done' || status === 'skipped';
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
/**
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Status predicates for GSD state-machine guards.
|
|
3
3
|
*
|
|
4
|
-
* The DB stores status as free-form strings.
|
|
5
|
-
* "closed": "complete" (canonical)
|
|
4
|
+
* The DB stores status as free-form strings. Three values indicate
|
|
5
|
+
* "closed": "complete" (canonical), "done" (legacy / alias), and
|
|
6
|
+
* "skipped" (user-directed skip via rethink or backtrack).
|
|
6
7
|
* Every inline `status === "complete" || status === "done"` should
|
|
7
8
|
* use isClosedStatus() instead.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
/** Returns true when a milestone, slice, or task status indicates closure. */
|
|
11
12
|
export function isClosedStatus(status: string): boolean {
|
|
12
|
-
return status === "complete" || status === "done";
|
|
13
|
+
return status === "complete" || status === "done" || status === "skipped";
|
|
13
14
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { createObservationMask } from "../context-masker.js";
|
|
5
|
+
|
|
6
|
+
// These helpers produce messages in the pi-ai LLM payload format
|
|
7
|
+
// (post-convertToLlm, pre-provider), which is what before_provider_request sees.
|
|
8
|
+
|
|
9
|
+
function userMsg(content: string) {
|
|
10
|
+
return { role: "user", content: [{ type: "text", text: content }] };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assistantMsg(content: string) {
|
|
14
|
+
return { role: "assistant", content: [{ type: "text", text: content }] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** toolResult in pi-ai format: role "toolResult", content as TextContent[] */
|
|
18
|
+
function toolResult(text: string) {
|
|
19
|
+
return { role: "toolResult", content: [{ type: "text", text }], toolCallId: "toolu_test", toolName: "Read", isError: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** bashExecution after convertToLlm: becomes a user message with "Ran `cmd`" prefix */
|
|
23
|
+
function bashResult(text: string) {
|
|
24
|
+
return { role: "user", content: [{ type: "text", text: `Ran \`echo test\`\n\`\`\`\n${text}\n\`\`\`` }] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MASK_TEXT = "[result masked — within summarized history]";
|
|
28
|
+
|
|
29
|
+
test("masks nothing when message count is within keepRecentTurns", () => {
|
|
30
|
+
const mask = createObservationMask(8);
|
|
31
|
+
const messages = [
|
|
32
|
+
userMsg("hello"),
|
|
33
|
+
assistantMsg("hi"),
|
|
34
|
+
toolResult("file contents"),
|
|
35
|
+
];
|
|
36
|
+
const result = mask(messages as any);
|
|
37
|
+
assert.equal(result.length, 3);
|
|
38
|
+
assert.deepEqual((result[2].content as any)[0].text, "file contents");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("masks tool results older than keepRecentTurns", () => {
|
|
42
|
+
const mask = createObservationMask(2);
|
|
43
|
+
const messages = [
|
|
44
|
+
userMsg("turn 1"),
|
|
45
|
+
toolResult("old tool output"),
|
|
46
|
+
assistantMsg("response 1"),
|
|
47
|
+
userMsg("turn 2"),
|
|
48
|
+
toolResult("newer tool output"),
|
|
49
|
+
assistantMsg("response 2"),
|
|
50
|
+
userMsg("turn 3"),
|
|
51
|
+
toolResult("newest tool output"),
|
|
52
|
+
assistantMsg("response 3"),
|
|
53
|
+
];
|
|
54
|
+
const result = mask(messages as any);
|
|
55
|
+
// Old tool result (before boundary) should be masked
|
|
56
|
+
assert.equal((result[1].content as any)[0].text, MASK_TEXT);
|
|
57
|
+
// Recent tool results (within keep window) should be preserved
|
|
58
|
+
assert.equal((result[4].content as any)[0].text, "newer tool output");
|
|
59
|
+
assert.equal((result[7].content as any)[0].text, "newest tool output");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("never masks assistant messages", () => {
|
|
63
|
+
const mask = createObservationMask(1);
|
|
64
|
+
const messages = [
|
|
65
|
+
userMsg("turn 1"),
|
|
66
|
+
assistantMsg("old reasoning"),
|
|
67
|
+
userMsg("turn 2"),
|
|
68
|
+
assistantMsg("new reasoning"),
|
|
69
|
+
];
|
|
70
|
+
const result = mask(messages as any);
|
|
71
|
+
assert.equal((result[1].content as any)[0].text, "old reasoning");
|
|
72
|
+
assert.equal((result[3].content as any)[0].text, "new reasoning");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("never masks user messages", () => {
|
|
76
|
+
const mask = createObservationMask(1);
|
|
77
|
+
const messages = [
|
|
78
|
+
userMsg("old user message"),
|
|
79
|
+
assistantMsg("response"),
|
|
80
|
+
userMsg("new user message"),
|
|
81
|
+
assistantMsg("response"),
|
|
82
|
+
];
|
|
83
|
+
const result = mask(messages as any);
|
|
84
|
+
assert.equal((result[0].content as any)[0].text, "old user message");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("masks bash result user messages", () => {
|
|
88
|
+
const mask = createObservationMask(1);
|
|
89
|
+
const messages = [
|
|
90
|
+
userMsg("turn 1"),
|
|
91
|
+
bashResult("huge log output"),
|
|
92
|
+
assistantMsg("response 1"),
|
|
93
|
+
userMsg("turn 2"),
|
|
94
|
+
assistantMsg("response 2"),
|
|
95
|
+
];
|
|
96
|
+
const result = mask(messages as any);
|
|
97
|
+
assert.equal((result[1].content as any)[0].text, MASK_TEXT);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("returns same array length", () => {
|
|
101
|
+
const mask = createObservationMask(1);
|
|
102
|
+
const messages = [
|
|
103
|
+
userMsg("a"), toolResult("b"), assistantMsg("c"),
|
|
104
|
+
userMsg("d"), toolResult("e"), assistantMsg("f"),
|
|
105
|
+
];
|
|
106
|
+
const result = mask(messages as any);
|
|
107
|
+
assert.equal(result.length, messages.length);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("masks toolResult by role, not by type field", () => {
|
|
111
|
+
const mask = createObservationMask(1);
|
|
112
|
+
const messages = [
|
|
113
|
+
userMsg("turn 1"),
|
|
114
|
+
// This is the actual pi-ai format: role "toolResult", no type field
|
|
115
|
+
{ role: "toolResult", content: [{ type: "text", text: "old result" }], toolCallId: "t1", toolName: "Read", isError: false },
|
|
116
|
+
assistantMsg("response 1"),
|
|
117
|
+
userMsg("turn 2"),
|
|
118
|
+
assistantMsg("response 2"),
|
|
119
|
+
];
|
|
120
|
+
const result = mask(messages as any);
|
|
121
|
+
assert.equal((result[1].content as any)[0].text, MASK_TEXT);
|
|
122
|
+
});
|
|
@@ -5,8 +5,11 @@ import {
|
|
|
5
5
|
resolveModelForComplexity,
|
|
6
6
|
escalateTier,
|
|
7
7
|
defaultRoutingConfig,
|
|
8
|
+
scoreModel,
|
|
9
|
+
computeTaskRequirements,
|
|
10
|
+
MODEL_CAPABILITY_PROFILES,
|
|
8
11
|
} from "../model-router.js";
|
|
9
|
-
import type { DynamicRoutingConfig, RoutingDecision } from "../model-router.js";
|
|
12
|
+
import type { DynamicRoutingConfig, RoutingDecision, ModelCapabilities } from "../model-router.js";
|
|
10
13
|
import type { ClassificationResult } from "../complexity-classifier.js";
|
|
11
14
|
|
|
12
15
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -206,6 +209,89 @@ test("#2192: known model is still downgraded normally", () => {
|
|
|
206
209
|
assert.notEqual(result.modelId, "claude-opus-4-6");
|
|
207
210
|
});
|
|
208
211
|
|
|
212
|
+
// ─── Capability Scoring (ADR-004 Phase 2) ───────────────────────────────────
|
|
213
|
+
|
|
214
|
+
test("defaultRoutingConfig includes capability_routing: false", () => {
|
|
215
|
+
const config = defaultRoutingConfig();
|
|
216
|
+
assert.equal(config.capability_routing, false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("scoreModel computes weighted average of capability × requirement", () => {
|
|
220
|
+
const caps: ModelCapabilities = {
|
|
221
|
+
coding: 90, debugging: 80, research: 70,
|
|
222
|
+
reasoning: 85, speed: 50, longContext: 60, instruction: 75,
|
|
223
|
+
};
|
|
224
|
+
const reqs = { coding: 0.9, reasoning: 0.5 };
|
|
225
|
+
const score = scoreModel(caps, reqs);
|
|
226
|
+
// Expected: (0.9*90 + 0.5*85) / (0.9 + 0.5) = (81 + 42.5) / 1.4 = 88.21...
|
|
227
|
+
assert.ok(Math.abs(score - 88.21) < 0.1, `score ${score} should be ~88.21`);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("scoreModel returns 50 for empty requirements", () => {
|
|
231
|
+
const caps: ModelCapabilities = {
|
|
232
|
+
coding: 90, debugging: 80, research: 70,
|
|
233
|
+
reasoning: 85, speed: 50, longContext: 60, instruction: 75,
|
|
234
|
+
};
|
|
235
|
+
const score = scoreModel(caps, {});
|
|
236
|
+
assert.equal(score, 50);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("computeTaskRequirements returns base vector for known unit type", () => {
|
|
240
|
+
const reqs = computeTaskRequirements("execute-task");
|
|
241
|
+
assert.ok(reqs.coding !== undefined && reqs.coding > 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("computeTaskRequirements boosts instruction for docs-tagged tasks", () => {
|
|
245
|
+
const reqs = computeTaskRequirements("execute-task", { tags: ["docs"] });
|
|
246
|
+
assert.ok((reqs.instruction ?? 0) >= 0.8);
|
|
247
|
+
assert.ok((reqs.coding ?? 1) <= 0.4);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("computeTaskRequirements returns generic vector for unknown unit type", () => {
|
|
251
|
+
const reqs = computeTaskRequirements("unknown-unit");
|
|
252
|
+
assert.ok(reqs.reasoning !== undefined);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("resolveModelForComplexity uses capability scoring when enabled", () => {
|
|
256
|
+
const config: DynamicRoutingConfig = {
|
|
257
|
+
...defaultRoutingConfig(),
|
|
258
|
+
enabled: true,
|
|
259
|
+
capability_routing: true,
|
|
260
|
+
};
|
|
261
|
+
const result = resolveModelForComplexity(
|
|
262
|
+
makeClassification("light"),
|
|
263
|
+
{ primary: "claude-opus-4-6", fallbacks: [] },
|
|
264
|
+
config,
|
|
265
|
+
["claude-opus-4-6", "claude-haiku-4-5", "gpt-4o-mini"],
|
|
266
|
+
"execute-task",
|
|
267
|
+
);
|
|
268
|
+
assert.equal(result.wasDowngraded, true);
|
|
269
|
+
assert.equal(result.selectionMethod, "capability-scored");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("resolveModelForComplexity falls back to tier-only when capability_routing is false", () => {
|
|
273
|
+
const config: DynamicRoutingConfig = {
|
|
274
|
+
...defaultRoutingConfig(),
|
|
275
|
+
enabled: true,
|
|
276
|
+
capability_routing: false,
|
|
277
|
+
};
|
|
278
|
+
const result = resolveModelForComplexity(
|
|
279
|
+
makeClassification("light"),
|
|
280
|
+
{ primary: "claude-opus-4-6", fallbacks: [] },
|
|
281
|
+
config,
|
|
282
|
+
["claude-opus-4-6", "claude-haiku-4-5", "gpt-4o-mini"],
|
|
283
|
+
);
|
|
284
|
+
assert.equal(result.wasDowngraded, true);
|
|
285
|
+
assert.ok(!result.selectionMethod || result.selectionMethod === "tier-only");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("MODEL_CAPABILITY_PROFILES has entries for core models", () => {
|
|
289
|
+
const profiledModels = Object.keys(MODEL_CAPABILITY_PROFILES);
|
|
290
|
+
assert.ok(profiledModels.length >= 9, `Expected ≥9 profiles, got ${profiledModels.length}`);
|
|
291
|
+
assert.ok(MODEL_CAPABILITY_PROFILES["claude-opus-4-6"]);
|
|
292
|
+
assert.ok(MODEL_CAPABILITY_PROFILES["claude-haiku-4-5"]);
|
|
293
|
+
});
|
|
294
|
+
|
|
209
295
|
// ─── #2885: openai-codex and modern OpenAI models in tier map ────────────────
|
|
210
296
|
|
|
211
297
|
test("#2885: openai-codex light-tier models are recognized", () => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
import { writePhaseAnchor, readPhaseAnchor, formatAnchorForPrompt } from "../phase-anchor.js";
|
|
8
|
+
import type { PhaseAnchor } from "../phase-anchor.js";
|
|
9
|
+
|
|
10
|
+
function makeTempBase(): string {
|
|
11
|
+
const tmp = mkdtempSync(join(tmpdir(), "gsd-anchor-test-"));
|
|
12
|
+
mkdirSync(join(tmp, ".gsd", "milestones", "M001", "anchors"), { recursive: true });
|
|
13
|
+
return tmp;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("writePhaseAnchor creates anchor file in correct location", () => {
|
|
17
|
+
const base = makeTempBase();
|
|
18
|
+
try {
|
|
19
|
+
const anchor: PhaseAnchor = {
|
|
20
|
+
phase: "discuss",
|
|
21
|
+
milestoneId: "M001",
|
|
22
|
+
generatedAt: new Date().toISOString(),
|
|
23
|
+
intent: "Define authentication requirements",
|
|
24
|
+
decisions: ["Use JWT tokens", "Session expiry 24h"],
|
|
25
|
+
blockers: [],
|
|
26
|
+
nextSteps: ["Plan the implementation slices"],
|
|
27
|
+
};
|
|
28
|
+
writePhaseAnchor(base, "M001", anchor);
|
|
29
|
+
assert.ok(existsSync(join(base, ".gsd", "milestones", "M001", "anchors", "discuss.json")));
|
|
30
|
+
} finally {
|
|
31
|
+
rmSync(base, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("readPhaseAnchor returns written anchor", () => {
|
|
36
|
+
const base = makeTempBase();
|
|
37
|
+
try {
|
|
38
|
+
const anchor: PhaseAnchor = {
|
|
39
|
+
phase: "plan",
|
|
40
|
+
milestoneId: "M001",
|
|
41
|
+
generatedAt: new Date().toISOString(),
|
|
42
|
+
intent: "Break work into slices",
|
|
43
|
+
decisions: ["3 slices: auth, UI, tests"],
|
|
44
|
+
blockers: ["Need DB schema first"],
|
|
45
|
+
nextSteps: ["Execute S01"],
|
|
46
|
+
};
|
|
47
|
+
writePhaseAnchor(base, "M001", anchor);
|
|
48
|
+
const read = readPhaseAnchor(base, "M001", "plan");
|
|
49
|
+
assert.ok(read);
|
|
50
|
+
assert.equal(read!.intent, "Break work into slices");
|
|
51
|
+
assert.deepEqual(read!.decisions, ["3 slices: auth, UI, tests"]);
|
|
52
|
+
assert.deepEqual(read!.blockers, ["Need DB schema first"]);
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(base, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("readPhaseAnchor returns null when no anchor exists", () => {
|
|
59
|
+
const base = makeTempBase();
|
|
60
|
+
try {
|
|
61
|
+
const read = readPhaseAnchor(base, "M001", "discuss");
|
|
62
|
+
assert.equal(read, null);
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(base, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("formatAnchorForPrompt produces markdown block", () => {
|
|
69
|
+
const anchor: PhaseAnchor = {
|
|
70
|
+
phase: "discuss",
|
|
71
|
+
milestoneId: "M001",
|
|
72
|
+
generatedAt: "2026-04-03T00:00:00.000Z",
|
|
73
|
+
intent: "Define requirements",
|
|
74
|
+
decisions: ["Use JWT"],
|
|
75
|
+
blockers: [],
|
|
76
|
+
nextSteps: ["Plan slices"],
|
|
77
|
+
};
|
|
78
|
+
const md = formatAnchorForPrompt(anchor);
|
|
79
|
+
assert.ok(md.includes("## Handoff from discuss"));
|
|
80
|
+
assert.ok(md.includes("Define requirements"));
|
|
81
|
+
assert.ok(md.includes("Use JWT"));
|
|
82
|
+
assert.ok(md.includes("Plan slices"));
|
|
83
|
+
});
|
|
@@ -13,6 +13,10 @@ test('isClosedStatus: "done" returns true', () => {
|
|
|
13
13
|
assert.equal(isClosedStatus('done'), true);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
test('isClosedStatus: "skipped" returns true', () => {
|
|
17
|
+
assert.equal(isClosedStatus('skipped'), true);
|
|
18
|
+
});
|
|
19
|
+
|
|
16
20
|
test('isClosedStatus: "pending" returns false', () => {
|
|
17
21
|
assert.equal(isClosedStatus('pending'), false);
|
|
18
22
|
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for stop/backtrack capture classifications and milestone regression (#3487).
|
|
3
|
+
*
|
|
4
|
+
* Tests:
|
|
5
|
+
* - "stop" and "backtrack" are valid classification types
|
|
6
|
+
* - loadStopCaptures returns unexecuted stop+backtrack captures
|
|
7
|
+
* - loadBacktrackCaptures returns only backtrack captures
|
|
8
|
+
* - revertExecutorResolvedCaptures reverts silenced captures
|
|
9
|
+
* - executeBacktrack writes trigger and regression markers
|
|
10
|
+
* - readBacktrackTrigger parses trigger file
|
|
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 { isClosedStatus } from "../status-guards.ts";
|
|
19
|
+
import {
|
|
20
|
+
appendCapture,
|
|
21
|
+
loadAllCaptures,
|
|
22
|
+
loadStopCaptures,
|
|
23
|
+
loadBacktrackCaptures,
|
|
24
|
+
markCaptureResolved,
|
|
25
|
+
revertExecutorResolvedCaptures,
|
|
26
|
+
hasPendingCaptures,
|
|
27
|
+
} from "../captures.ts";
|
|
28
|
+
import {
|
|
29
|
+
executeBacktrack,
|
|
30
|
+
readBacktrackTrigger,
|
|
31
|
+
} from "../triage-resolution.ts";
|
|
32
|
+
|
|
33
|
+
function makeTempDir(prefix: string): string {
|
|
34
|
+
const dir = join(
|
|
35
|
+
tmpdir(),
|
|
36
|
+
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
37
|
+
);
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setupGsdDir(tmp: string): void {
|
|
43
|
+
mkdirSync(join(tmp, ".gsd"), { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Classification Types ─────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
test("stop is a valid classification", () => {
|
|
49
|
+
const tmp = makeTempDir("stop-class");
|
|
50
|
+
setupGsdDir(tmp);
|
|
51
|
+
const id = appendCapture(tmp, "stop running immediately");
|
|
52
|
+
markCaptureResolved(tmp, id, "stop", "Halt auto-mode", "User said stop", "M005");
|
|
53
|
+
const all = loadAllCaptures(tmp);
|
|
54
|
+
const cap = all.find(c => c.id === id);
|
|
55
|
+
assert.equal(cap?.classification, "stop");
|
|
56
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("backtrack is a valid classification", () => {
|
|
60
|
+
const tmp = makeTempDir("bt-class");
|
|
61
|
+
setupGsdDir(tmp);
|
|
62
|
+
const id = appendCapture(tmp, "restart from M003");
|
|
63
|
+
markCaptureResolved(tmp, id, "backtrack", "Backtrack to M003", "User wants to restart", "M005");
|
|
64
|
+
const all = loadAllCaptures(tmp);
|
|
65
|
+
const cap = all.find(c => c.id === id);
|
|
66
|
+
assert.equal(cap?.classification, "backtrack");
|
|
67
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ─── loadStopCaptures ─────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
test("loadStopCaptures returns unexecuted stop and backtrack captures", () => {
|
|
73
|
+
const tmp = makeTempDir("load-stop");
|
|
74
|
+
setupGsdDir(tmp);
|
|
75
|
+
const stopId = appendCapture(tmp, "halt execution");
|
|
76
|
+
const btId = appendCapture(tmp, "go back to M003");
|
|
77
|
+
const noteId = appendCapture(tmp, "just a note");
|
|
78
|
+
markCaptureResolved(tmp, stopId, "stop", "Halt", "User stop", "M005");
|
|
79
|
+
markCaptureResolved(tmp, btId, "backtrack", "Backtrack to M003", "User backtrack", "M005");
|
|
80
|
+
markCaptureResolved(tmp, noteId, "note", "Info only", "Not actionable", "M005");
|
|
81
|
+
|
|
82
|
+
const stops = loadStopCaptures(tmp);
|
|
83
|
+
assert.equal(stops.length, 2);
|
|
84
|
+
assert.ok(stops.some(c => c.classification === "stop"));
|
|
85
|
+
assert.ok(stops.some(c => c.classification === "backtrack"));
|
|
86
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("loadBacktrackCaptures returns only backtrack captures", () => {
|
|
90
|
+
const tmp = makeTempDir("load-bt");
|
|
91
|
+
setupGsdDir(tmp);
|
|
92
|
+
const stopId = appendCapture(tmp, "halt execution");
|
|
93
|
+
const btId = appendCapture(tmp, "go back to M003");
|
|
94
|
+
markCaptureResolved(tmp, stopId, "stop", "Halt", "User stop", "M005");
|
|
95
|
+
markCaptureResolved(tmp, btId, "backtrack", "Backtrack to M003", "User backtrack", "M005");
|
|
96
|
+
|
|
97
|
+
const bts = loadBacktrackCaptures(tmp);
|
|
98
|
+
assert.equal(bts.length, 1);
|
|
99
|
+
assert.equal(bts[0].classification, "backtrack");
|
|
100
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── revertExecutorResolvedCaptures ───────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
test("revertExecutorResolvedCaptures reverts captures resolved without classification", () => {
|
|
106
|
+
const tmp = makeTempDir("revert-exec");
|
|
107
|
+
setupGsdDir(tmp);
|
|
108
|
+
const id = appendCapture(tmp, "stop everything");
|
|
109
|
+
|
|
110
|
+
// Simulate an executor writing Status: resolved directly (no classification)
|
|
111
|
+
const capPath = join(tmp, ".gsd", "CAPTURES.md");
|
|
112
|
+
let content = readFileSync(capPath, "utf-8");
|
|
113
|
+
content = content.replace("**Status:** pending", "**Status:** resolved");
|
|
114
|
+
writeFileSync(capPath, content, "utf-8");
|
|
115
|
+
|
|
116
|
+
// Verify it's now "resolved" without classification
|
|
117
|
+
assert.equal(hasPendingCaptures(tmp), false);
|
|
118
|
+
|
|
119
|
+
// Revert should detect and fix it
|
|
120
|
+
const reverted = revertExecutorResolvedCaptures(tmp);
|
|
121
|
+
assert.equal(reverted, 1);
|
|
122
|
+
|
|
123
|
+
// Should be pending again
|
|
124
|
+
assert.equal(hasPendingCaptures(tmp), true);
|
|
125
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("revertExecutorResolvedCaptures does NOT revert properly triaged captures", () => {
|
|
129
|
+
const tmp = makeTempDir("revert-skip");
|
|
130
|
+
setupGsdDir(tmp);
|
|
131
|
+
const id = appendCapture(tmp, "restart from M003");
|
|
132
|
+
markCaptureResolved(tmp, id, "backtrack", "Backtrack to M003", "User wants restart", "M005");
|
|
133
|
+
|
|
134
|
+
// This capture was properly triaged — should NOT be reverted
|
|
135
|
+
const reverted = revertExecutorResolvedCaptures(tmp);
|
|
136
|
+
assert.equal(reverted, 0);
|
|
137
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─── executeBacktrack ─────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
test("executeBacktrack writes trigger and regression markers", () => {
|
|
143
|
+
const tmp = makeTempDir("exec-bt");
|
|
144
|
+
setupGsdDir(tmp);
|
|
145
|
+
|
|
146
|
+
// Create target milestone directory
|
|
147
|
+
mkdirSync(join(tmp, ".gsd", "milestones", "M003"), { recursive: true });
|
|
148
|
+
|
|
149
|
+
const targetMid = executeBacktrack(tmp, "M005", {
|
|
150
|
+
id: "CAP-test123",
|
|
151
|
+
text: "restart from M003 — milestones after 2 failed",
|
|
152
|
+
timestamp: new Date().toISOString(),
|
|
153
|
+
status: "resolved",
|
|
154
|
+
classification: "backtrack",
|
|
155
|
+
resolution: "Backtrack to M003",
|
|
156
|
+
rationale: "User directive",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
assert.equal(targetMid, "M003");
|
|
160
|
+
|
|
161
|
+
// Check trigger file exists
|
|
162
|
+
const triggerPath = join(tmp, ".gsd", "BACKTRACK-TRIGGER.md");
|
|
163
|
+
assert.ok(existsSync(triggerPath));
|
|
164
|
+
const triggerContent = readFileSync(triggerPath, "utf-8");
|
|
165
|
+
assert.ok(triggerContent.includes("M005"));
|
|
166
|
+
assert.ok(triggerContent.includes("M003"));
|
|
167
|
+
|
|
168
|
+
// Check regression marker exists on target milestone
|
|
169
|
+
const regressionPath = join(tmp, ".gsd", "milestones", "M003", "M003-REGRESSION.md");
|
|
170
|
+
assert.ok(existsSync(regressionPath));
|
|
171
|
+
const regressionContent = readFileSync(regressionPath, "utf-8");
|
|
172
|
+
assert.ok(regressionContent.includes("M005"));
|
|
173
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ─── readBacktrackTrigger ─────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
test("readBacktrackTrigger parses trigger file", () => {
|
|
179
|
+
const tmp = makeTempDir("read-bt");
|
|
180
|
+
setupGsdDir(tmp);
|
|
181
|
+
mkdirSync(join(tmp, ".gsd", "milestones", "M003"), { recursive: true });
|
|
182
|
+
|
|
183
|
+
executeBacktrack(tmp, "M005", {
|
|
184
|
+
id: "CAP-abc",
|
|
185
|
+
text: "go back to M003",
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
status: "resolved",
|
|
188
|
+
classification: "backtrack",
|
|
189
|
+
resolution: "Backtrack to M003",
|
|
190
|
+
rationale: "Regression",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const trigger = readBacktrackTrigger(tmp);
|
|
194
|
+
assert.ok(trigger);
|
|
195
|
+
assert.equal(trigger.target, "M003");
|
|
196
|
+
assert.equal(trigger.from, "M005");
|
|
197
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("readBacktrackTrigger returns null when no trigger exists", () => {
|
|
201
|
+
const tmp = makeTempDir("no-bt");
|
|
202
|
+
setupGsdDir(tmp);
|
|
203
|
+
const trigger = readBacktrackTrigger(tmp);
|
|
204
|
+
assert.equal(trigger, null);
|
|
205
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ─── Slice Skip Status (#3477) ──────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
test("isClosedStatus treats 'skipped' as closed", () => {
|
|
211
|
+
assert.equal(isClosedStatus("skipped"), true);
|
|
212
|
+
assert.equal(isClosedStatus("complete"), true);
|
|
213
|
+
assert.equal(isClosedStatus("done"), true);
|
|
214
|
+
assert.equal(isClosedStatus("pending"), false);
|
|
215
|
+
assert.equal(isClosedStatus("active"), false);
|
|
216
|
+
});
|
|
@@ -45,7 +45,7 @@ console.log('\n── Tool naming: registration count ──');
|
|
|
45
45
|
const pi = makeMockPi();
|
|
46
46
|
registerDbTools(pi);
|
|
47
47
|
|
|
48
|
-
assert.deepStrictEqual(pi.tools.length,
|
|
48
|
+
assert.deepStrictEqual(pi.tools.length, 30, 'Should register exactly 30 tools (14 canonical + 14 aliases + 1 gate tool + 1 gsd_skip_slice)');
|
|
49
49
|
|
|
50
50
|
// ─── Both names exist for each pair ──────────────────────────────────────────
|
|
51
51
|
|