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.
Files changed (88) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +54 -1
  2. package/dist/resources/extensions/gsd/auto-model-selection.js +8 -3
  3. package/dist/resources/extensions/gsd/auto-post-unit.js +40 -1
  4. package/dist/resources/extensions/gsd/auto-prompts.js +13 -0
  5. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +70 -0
  6. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +51 -5
  7. package/dist/resources/extensions/gsd/captures.js +54 -1
  8. package/dist/resources/extensions/gsd/complexity-classifier.js +1 -1
  9. package/dist/resources/extensions/gsd/context-masker.js +68 -0
  10. package/dist/resources/extensions/gsd/docs/preferences-reference.md +7 -0
  11. package/dist/resources/extensions/gsd/gsd-db.js +2 -2
  12. package/dist/resources/extensions/gsd/model-router.js +123 -4
  13. package/dist/resources/extensions/gsd/phase-anchor.js +56 -0
  14. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  15. package/dist/resources/extensions/gsd/preferences-validation.js +46 -0
  16. package/dist/resources/extensions/gsd/prompts/execute-task.md +2 -0
  17. package/dist/resources/extensions/gsd/prompts/rethink.md +7 -0
  18. package/dist/resources/extensions/gsd/prompts/triage-captures.md +6 -1
  19. package/dist/resources/extensions/gsd/rethink.js +5 -2
  20. package/dist/resources/extensions/gsd/state.js +1 -1
  21. package/dist/resources/extensions/gsd/status-guards.js +4 -3
  22. package/dist/resources/extensions/gsd/triage-resolution.js +128 -1
  23. package/dist/resources/extensions/gsd/triage-ui.js +12 -3
  24. package/dist/web/standalone/.next/BUILD_ID +1 -1
  25. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  26. package/dist/web/standalone/.next/build-manifest.json +2 -2
  27. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  28. package/dist/web/standalone/.next/required-server-files.json +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  30. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.html +1 -1
  46. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  53. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  54. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  55. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  56. package/dist/web/standalone/server.js +1 -1
  57. package/package.json +1 -1
  58. package/src/resources/extensions/gsd/auto/phases.ts +60 -1
  59. package/src/resources/extensions/gsd/auto-model-selection.ts +12 -3
  60. package/src/resources/extensions/gsd/auto-post-unit.ts +48 -1
  61. package/src/resources/extensions/gsd/auto-prompts.ts +17 -0
  62. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +78 -0
  63. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +53 -4
  64. package/src/resources/extensions/gsd/captures.ts +71 -2
  65. package/src/resources/extensions/gsd/complexity-classifier.ts +1 -1
  66. package/src/resources/extensions/gsd/context-masker.ts +74 -0
  67. package/src/resources/extensions/gsd/docs/preferences-reference.md +7 -0
  68. package/src/resources/extensions/gsd/gsd-db.ts +2 -2
  69. package/src/resources/extensions/gsd/model-router.ts +171 -8
  70. package/src/resources/extensions/gsd/phase-anchor.ts +71 -0
  71. package/src/resources/extensions/gsd/preferences-types.ts +9 -0
  72. package/src/resources/extensions/gsd/preferences-validation.ts +38 -0
  73. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -0
  74. package/src/resources/extensions/gsd/prompts/rethink.md +7 -0
  75. package/src/resources/extensions/gsd/prompts/triage-captures.md +6 -1
  76. package/src/resources/extensions/gsd/rethink.ts +5 -2
  77. package/src/resources/extensions/gsd/state.ts +1 -1
  78. package/src/resources/extensions/gsd/status-guards.ts +4 -3
  79. package/src/resources/extensions/gsd/tests/context-masker.test.ts +122 -0
  80. package/src/resources/extensions/gsd/tests/model-router.test.ts +87 -1
  81. package/src/resources/extensions/gsd/tests/phase-anchor.test.ts +83 -0
  82. package/src/resources/extensions/gsd/tests/status-guards.test.ts +4 -0
  83. package/src/resources/extensions/gsd/tests/stop-backtrack.test.ts +216 -0
  84. package/src/resources/extensions/gsd/tests/tool-naming.test.ts +1 -1
  85. package/src/resources/extensions/gsd/triage-resolution.ts +144 -1
  86. package/src/resources/extensions/gsd/triage-ui.ts +12 -3
  87. /package/dist/web/standalone/.next/static/{QlWL-8CXgQpzV3ehkNMzh → t_cBZAENjaOJIRST3dw08}/_buildManifest.js +0 -0
  88. /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
- sliceInfo = `${done}/${slices.length} complete`;
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. Two values indicate
5
- * "closed": "complete" (canonical) and "done" (legacy / alias).
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, 29, 'Should register exactly 29 tools (14 canonical + 14 aliases + 1 gate tool)');
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