gsd-pi 2.22.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +25 -1
  2. package/dist/cli.js +62 -4
  3. package/dist/headless.d.ts +21 -0
  4. package/dist/headless.js +346 -0
  5. package/dist/help-text.js +32 -0
  6. package/dist/mcp-server.d.ts +20 -3
  7. package/dist/mcp-server.js +21 -1
  8. package/dist/models-resolver.d.ts +32 -0
  9. package/dist/models-resolver.js +50 -0
  10. package/dist/resources/extensions/bg-shell/output-formatter.ts +36 -16
  11. package/dist/resources/extensions/bg-shell/process-manager.ts +6 -4
  12. package/dist/resources/extensions/bg-shell/types.ts +33 -1
  13. package/dist/resources/extensions/browser-tools/capture.ts +18 -16
  14. package/dist/resources/extensions/browser-tools/index.ts +20 -0
  15. package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  16. package/dist/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  17. package/dist/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  18. package/dist/resources/extensions/browser-tools/tools/device.ts +183 -0
  19. package/dist/resources/extensions/browser-tools/tools/extract.ts +229 -0
  20. package/dist/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  21. package/dist/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  22. package/dist/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  23. package/dist/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  24. package/dist/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  25. package/dist/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  26. package/dist/resources/extensions/gsd/auto-dashboard.ts +2 -0
  27. package/dist/resources/extensions/gsd/auto-recovery.ts +10 -0
  28. package/dist/resources/extensions/gsd/auto.ts +437 -11
  29. package/dist/resources/extensions/gsd/captures.ts +49 -0
  30. package/dist/resources/extensions/gsd/commands.ts +20 -3
  31. package/dist/resources/extensions/gsd/dashboard-overlay.ts +16 -2
  32. package/dist/resources/extensions/gsd/diff-context.ts +73 -80
  33. package/dist/resources/extensions/gsd/doctor.ts +20 -1
  34. package/dist/resources/extensions/gsd/forensics.ts +95 -52
  35. package/dist/resources/extensions/gsd/guided-flow.ts +10 -5
  36. package/dist/resources/extensions/gsd/mcp-server.ts +33 -12
  37. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  38. package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -0
  39. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  40. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  41. package/dist/resources/extensions/gsd/prompts/system.md +2 -1
  42. package/dist/resources/extensions/gsd/prompts/validate-milestone.md +91 -0
  43. package/dist/resources/extensions/gsd/roadmap-slices.ts +41 -1
  44. package/dist/resources/extensions/gsd/session-forensics.ts +36 -2
  45. package/dist/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  46. package/dist/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  47. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +64 -0
  48. package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  49. package/dist/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  50. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  51. package/dist/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  52. package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  53. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  54. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  55. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  56. package/dist/resources/extensions/gsd/triage-resolution.ts +83 -0
  57. package/dist/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  58. package/dist/resources/extensions/gsd/workspace-index.ts +34 -6
  59. package/package.json +1 -1
  60. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts +10 -0
  61. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts.map +1 -0
  62. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js +79 -0
  63. package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js.map +1 -0
  64. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +18 -0
  65. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/tools/bash.js +77 -1
  67. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -1
  69. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -1
  71. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  73. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  74. package/packages/pi-coding-agent/dist/index.js +1 -1
  75. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  76. package/packages/pi-coding-agent/src/core/tools/bash-background.test.ts +91 -0
  77. package/packages/pi-coding-agent/src/core/tools/bash.ts +83 -1
  78. package/packages/pi-coding-agent/src/core/tools/index.ts +1 -0
  79. package/packages/pi-coding-agent/src/index.ts +1 -0
  80. package/src/resources/extensions/bg-shell/output-formatter.ts +36 -16
  81. package/src/resources/extensions/bg-shell/process-manager.ts +6 -4
  82. package/src/resources/extensions/bg-shell/types.ts +33 -1
  83. package/src/resources/extensions/browser-tools/capture.ts +18 -16
  84. package/src/resources/extensions/browser-tools/index.ts +20 -0
  85. package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
  86. package/src/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
  87. package/src/resources/extensions/browser-tools/tools/codegen.ts +274 -0
  88. package/src/resources/extensions/browser-tools/tools/device.ts +183 -0
  89. package/src/resources/extensions/browser-tools/tools/extract.ts +229 -0
  90. package/src/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
  91. package/src/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
  92. package/src/resources/extensions/browser-tools/tools/pdf.ts +92 -0
  93. package/src/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
  94. package/src/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
  95. package/src/resources/extensions/browser-tools/tools/zoom.ts +104 -0
  96. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
  97. package/src/resources/extensions/gsd/auto-recovery.ts +10 -0
  98. package/src/resources/extensions/gsd/auto.ts +437 -11
  99. package/src/resources/extensions/gsd/captures.ts +49 -0
  100. package/src/resources/extensions/gsd/commands.ts +20 -3
  101. package/src/resources/extensions/gsd/dashboard-overlay.ts +16 -2
  102. package/src/resources/extensions/gsd/diff-context.ts +73 -80
  103. package/src/resources/extensions/gsd/doctor.ts +20 -1
  104. package/src/resources/extensions/gsd/forensics.ts +95 -52
  105. package/src/resources/extensions/gsd/guided-flow.ts +10 -5
  106. package/src/resources/extensions/gsd/mcp-server.ts +33 -12
  107. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  108. package/src/resources/extensions/gsd/prompts/execute-task.md +5 -0
  109. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
  110. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
  111. package/src/resources/extensions/gsd/prompts/system.md +2 -1
  112. package/src/resources/extensions/gsd/prompts/validate-milestone.md +91 -0
  113. package/src/resources/extensions/gsd/roadmap-slices.ts +41 -1
  114. package/src/resources/extensions/gsd/session-forensics.ts +36 -2
  115. package/src/resources/extensions/gsd/templates/milestone-validation.md +62 -0
  116. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
  117. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +64 -0
  118. package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
  119. package/src/resources/extensions/gsd/tests/doctor.test.ts +58 -0
  120. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
  121. package/src/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
  122. package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
  123. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
  124. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
  125. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
  126. package/src/resources/extensions/gsd/triage-resolution.ts +83 -0
  127. package/src/resources/extensions/gsd/visualizer-overlay.ts +8 -1
  128. package/src/resources/extensions/gsd/workspace-index.ts +34 -6
@@ -0,0 +1,186 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdirSync, mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ import { writeLock, readCrashLock, clearLock, isLockProcessAlive } from "../crash-recovery.ts";
8
+
9
+ // ─── writeLock creates auto.lock in .gsd/ ────────────────────────────────
10
+
11
+ test("writeLock creates auto.lock with correct structure", () => {
12
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
13
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
14
+
15
+ writeLock(dir, "starting", "M001", 0);
16
+
17
+ const lockPath = join(dir, ".gsd", "auto.lock");
18
+ assert.ok(existsSync(lockPath), "auto.lock should exist after writeLock");
19
+
20
+ const data = JSON.parse(readFileSync(lockPath, "utf-8"));
21
+ assert.equal(data.pid, process.pid, "lock should contain current PID");
22
+ assert.equal(data.unitType, "starting", "lock should contain unit type");
23
+ assert.equal(data.unitId, "M001", "lock should contain unit ID");
24
+ assert.equal(data.completedUnits, 0, "lock should show 0 completed units");
25
+ assert.ok(data.startedAt, "lock should have startedAt timestamp");
26
+
27
+ rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ test("writeLock updates existing lock with new unit info", () => {
31
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
32
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
33
+
34
+ writeLock(dir, "starting", "M001", 0);
35
+ writeLock(dir, "execute-task", "M001/S01/T01", 2, "/tmp/session.jsonl");
36
+
37
+ const data = JSON.parse(readFileSync(join(dir, ".gsd", "auto.lock"), "utf-8"));
38
+ assert.equal(data.unitType, "execute-task", "lock should be updated to new unit type");
39
+ assert.equal(data.unitId, "M001/S01/T01", "lock should be updated to new unit ID");
40
+ assert.equal(data.completedUnits, 2, "completed count should be updated");
41
+ assert.equal(data.sessionFile, "/tmp/session.jsonl", "session file should be recorded");
42
+
43
+ rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ // ─── readCrashLock reads auto.lock data ──────────────────────────────────
47
+
48
+ test("readCrashLock returns null when no lock file exists", () => {
49
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
50
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
51
+
52
+ const lock = readCrashLock(dir);
53
+ assert.equal(lock, null, "should return null when no lock file");
54
+
55
+ rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ test("readCrashLock returns lock data when file exists", () => {
59
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
60
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
61
+
62
+ writeLock(dir, "plan-milestone", "M002", 5);
63
+ const lock = readCrashLock(dir);
64
+
65
+ assert.ok(lock, "should return lock data");
66
+ assert.equal(lock!.unitType, "plan-milestone");
67
+ assert.equal(lock!.unitId, "M002");
68
+ assert.equal(lock!.completedUnits, 5);
69
+
70
+ rmSync(dir, { recursive: true, force: true });
71
+ });
72
+
73
+ // ─── clearLock removes auto.lock ─────────────────────────────────────────
74
+
75
+ test("clearLock removes the lock file", () => {
76
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
77
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
78
+
79
+ writeLock(dir, "starting", "M001", 0);
80
+ assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "lock should exist before clear");
81
+
82
+ clearLock(dir);
83
+ assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "lock should be removed after clear");
84
+
85
+ rmSync(dir, { recursive: true, force: true });
86
+ });
87
+
88
+ test("clearLock is safe when no lock file exists", () => {
89
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
90
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
91
+
92
+ // Should not throw
93
+ clearLock(dir);
94
+
95
+ rmSync(dir, { recursive: true, force: true });
96
+ });
97
+
98
+ // ─── isLockProcessAlive detects live vs dead PIDs ────────────────────────
99
+
100
+ test("isLockProcessAlive returns false for dead PID", () => {
101
+ const lock = {
102
+ pid: 9999999,
103
+ startedAt: new Date().toISOString(),
104
+ unitType: "execute-task",
105
+ unitId: "M001/S01/T01",
106
+ unitStartedAt: new Date().toISOString(),
107
+ completedUnits: 0,
108
+ };
109
+ assert.equal(isLockProcessAlive(lock), false, "dead PID should return false");
110
+ });
111
+
112
+ test("isLockProcessAlive returns false for own PID (recycled)", () => {
113
+ const lock = {
114
+ pid: process.pid,
115
+ startedAt: new Date().toISOString(),
116
+ unitType: "execute-task",
117
+ unitId: "M001/S01/T01",
118
+ unitStartedAt: new Date().toISOString(),
119
+ completedUnits: 0,
120
+ };
121
+ assert.equal(isLockProcessAlive(lock), false, "own PID should return false (recycled)");
122
+ });
123
+
124
+ test("isLockProcessAlive returns false for invalid PID", () => {
125
+ const lock = {
126
+ pid: -1,
127
+ startedAt: new Date().toISOString(),
128
+ unitType: "execute-task",
129
+ unitId: "M001/S01/T01",
130
+ unitStartedAt: new Date().toISOString(),
131
+ completedUnits: 0,
132
+ };
133
+ assert.equal(isLockProcessAlive(lock), false, "negative PID should return false");
134
+ });
135
+
136
+ // ─── Cross-process detection via lock file ───────────────────────────────
137
+
138
+ test("lock file enables cross-process auto-mode detection", () => {
139
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
140
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
141
+
142
+ // Use the parent process PID — guaranteed alive on all platforms (Unix and Windows).
143
+ // PID 1 (init) only works on Unix; on Windows it doesn't exist.
144
+ const alivePid = process.ppid;
145
+ const lockData = {
146
+ pid: alivePid,
147
+ startedAt: new Date().toISOString(),
148
+ unitType: "execute-task",
149
+ unitId: "M001/S01/T02",
150
+ unitStartedAt: new Date().toISOString(),
151
+ completedUnits: 3,
152
+ };
153
+ writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
154
+
155
+ const lock = readCrashLock(dir);
156
+ assert.ok(lock, "should read the lock");
157
+ assert.equal(lock!.pid, alivePid);
158
+
159
+ // Parent PID is always alive — isLockProcessAlive should detect it
160
+ const alive = isLockProcessAlive(lock!);
161
+ assert.equal(alive, true, "parent PID should be detected as alive");
162
+
163
+ rmSync(dir, { recursive: true, force: true });
164
+ });
165
+
166
+ test("stale lock from dead process is detected as not alive", () => {
167
+ const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
168
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
169
+
170
+ // Simulate a stale lock from a process that no longer exists
171
+ const lockData = {
172
+ pid: 9999999,
173
+ startedAt: "2026-03-01T00:00:00Z",
174
+ unitType: "plan-slice",
175
+ unitId: "M001/S02",
176
+ unitStartedAt: "2026-03-01T00:05:00Z",
177
+ completedUnits: 1,
178
+ };
179
+ writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
180
+
181
+ const lock = readCrashLock(dir);
182
+ assert.ok(lock, "should read the stale lock");
183
+ assert.equal(isLockProcessAlive(lock!), false, "dead process should not be alive");
184
+
185
+ rmSync(dir, { recursive: true, force: true });
186
+ });
@@ -320,3 +320,67 @@ test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", ()
320
320
  cleanup(base);
321
321
  }
322
322
  });
323
+
324
+ // ─── verifyExpectedArtifact: plan-slice empty scaffold regression (#699) ──
325
+
326
+ test("verifyExpectedArtifact rejects plan-slice with empty scaffold", () => {
327
+ const base = makeTmpBase();
328
+ try {
329
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
330
+ mkdirSync(sliceDir, { recursive: true });
331
+ writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n\n");
332
+ assert.strictEqual(
333
+ verifyExpectedArtifact("plan-slice", "M001/S01", base),
334
+ false,
335
+ "Empty scaffold should not be treated as completed artifact",
336
+ );
337
+ } finally {
338
+ cleanup(base);
339
+ }
340
+ });
341
+
342
+ test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => {
343
+ const base = makeTmpBase();
344
+ try {
345
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
346
+ mkdirSync(sliceDir, { recursive: true });
347
+ writeFileSync(join(sliceDir, "S01-PLAN.md"), [
348
+ "# S01: Test Slice",
349
+ "",
350
+ "## Tasks",
351
+ "",
352
+ "- [ ] **T01: Implement feature** `est:2h`",
353
+ "- [ ] **T02: Write tests** `est:1h`",
354
+ ].join("\n"));
355
+ assert.strictEqual(
356
+ verifyExpectedArtifact("plan-slice", "M001/S01", base),
357
+ true,
358
+ "Plan with task entries should be treated as completed artifact",
359
+ );
360
+ } finally {
361
+ cleanup(base);
362
+ }
363
+ });
364
+
365
+ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
366
+ const base = makeTmpBase();
367
+ try {
368
+ const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
369
+ mkdirSync(sliceDir, { recursive: true });
370
+ writeFileSync(join(sliceDir, "S01-PLAN.md"), [
371
+ "# S01: Test Slice",
372
+ "",
373
+ "## Tasks",
374
+ "",
375
+ "- [x] **T01: Implement feature** `est:2h`",
376
+ "- [ ] **T02: Write tests** `est:1h`",
377
+ ].join("\n"));
378
+ assert.strictEqual(
379
+ verifyExpectedArtifact("plan-slice", "M001/S01", base),
380
+ true,
381
+ "Plan with completed task entries should be treated as completed artifact",
382
+ );
383
+ } finally {
384
+ cleanup(base);
385
+ }
386
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * auto-skip-loop.test.ts — Tests for the consecutive-skip loop breaker.
3
+ *
4
+ * Regression for #728: auto-mode infinite skip loop on previously completed
5
+ * plan-slice units when deriveState keeps returning the same unit.
6
+ *
7
+ * The skip paths in dispatchNextUnit track consecutive skips per unit via
8
+ * unitConsecutiveSkips. When the same unit is skipped > MAX_CONSECUTIVE_SKIPS
9
+ * times without a real dispatch in between, the completion record is evicted
10
+ * so deriveState can reconcile.
11
+ */
12
+
13
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ import {
18
+ _getUnitConsecutiveSkips,
19
+ _resetUnitConsecutiveSkips,
20
+ MAX_CONSECUTIVE_SKIPS,
21
+ } from "../auto.ts";
22
+ import { persistCompletedKey, removePersistedKey, loadPersistedKeys } from "../auto-recovery.ts";
23
+ import { createTestContext } from "./test-helpers.ts";
24
+
25
+ const { assertEq, assertTrue, report } = createTestContext();
26
+
27
+ function makeTmpBase(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "gsd-skip-loop-test-"));
29
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
30
+ return dir;
31
+ }
32
+
33
+ async function main(): Promise<void> {
34
+ // ─── Counter starts at zero ────────────────────────────────────────────
35
+ console.log("\n=== skip loop counter: initial state ===");
36
+ {
37
+ _resetUnitConsecutiveSkips();
38
+ const map = _getUnitConsecutiveSkips();
39
+ assertEq(map.size, 0, "counter map starts empty after reset");
40
+ }
41
+
42
+ // ─── Counter increments correctly ────────────────────────────────────
43
+ console.log("\n=== skip loop counter: increments on repeated calls ===");
44
+ {
45
+ _resetUnitConsecutiveSkips();
46
+ const map = _getUnitConsecutiveSkips();
47
+ const key = "plan-slice/M001/S04";
48
+
49
+ for (let i = 1; i <= MAX_CONSECUTIVE_SKIPS; i++) {
50
+ const prev = map.get(key) ?? 0;
51
+ map.set(key, prev + 1);
52
+ }
53
+
54
+ assertEq(map.get(key), MAX_CONSECUTIVE_SKIPS, `counter reaches MAX_CONSECUTIVE_SKIPS (${MAX_CONSECUTIVE_SKIPS})`);
55
+ }
56
+
57
+ // ─── Threshold constant is sane ──────────────────────────────────────
58
+ console.log("\n=== skip loop counter: threshold is reasonable ===");
59
+ {
60
+ assertTrue(MAX_CONSECUTIVE_SKIPS >= 3, "threshold allows a few legitimate skips");
61
+ assertTrue(MAX_CONSECUTIVE_SKIPS <= 10, "threshold catches loops quickly");
62
+ }
63
+
64
+ // ─── Reset clears all keys ────────────────────────────────────────────
65
+ console.log("\n=== skip loop counter: reset clears all keys ===");
66
+ {
67
+ _resetUnitConsecutiveSkips();
68
+ const map = _getUnitConsecutiveSkips();
69
+ map.set("plan-slice/M001/S01", 2);
70
+ map.set("plan-slice/M001/S02", 1);
71
+ assertEq(map.size, 2, "map has 2 entries before reset");
72
+
73
+ _resetUnitConsecutiveSkips();
74
+ assertEq(_getUnitConsecutiveSkips().size, 0, "map empty after reset");
75
+ }
76
+
77
+ // ─── Eviction path: persistCompletedKey + removePersistedKey round-trip
78
+ // (simulates what the loop-breaker does) ───────────────────────────
79
+ console.log("\n=== skip loop counter: eviction removes persisted key ===");
80
+ {
81
+ _resetUnitConsecutiveSkips();
82
+ const base = makeTmpBase();
83
+ try {
84
+ const key = "plan-slice/M001/S04";
85
+ const keySet = new Set<string>();
86
+
87
+ persistCompletedKey(base, key);
88
+ loadPersistedKeys(base, keySet);
89
+ assertTrue(keySet.has(key), "key persisted before eviction");
90
+
91
+ // Simulate loop-breaker eviction
92
+ keySet.delete(key);
93
+ removePersistedKey(base, key);
94
+ const keySet2 = new Set<string>();
95
+ loadPersistedKeys(base, keySet2);
96
+ assertTrue(!keySet2.has(key), "key absent after eviction");
97
+ } finally {
98
+ rmSync(base, { recursive: true, force: true });
99
+ }
100
+ }
101
+
102
+ // ─── Counter resets per-key, not globally ─────────────────────────────
103
+ console.log("\n=== skip loop counter: per-key isolation ===");
104
+ {
105
+ _resetUnitConsecutiveSkips();
106
+ const map = _getUnitConsecutiveSkips();
107
+ map.set("plan-slice/M001/S04", MAX_CONSECUTIVE_SKIPS + 1);
108
+ map.set("plan-slice/M001/S05", 1);
109
+
110
+ // Deleting S04 (eviction) should not affect S05
111
+ map.delete("plan-slice/M001/S04");
112
+ assertTrue(!map.has("plan-slice/M001/S04"), "S04 evicted");
113
+ assertEq(map.get("plan-slice/M001/S05"), 1, "S05 counter unaffected");
114
+ }
115
+
116
+ _resetUnitConsecutiveSkips();
117
+ report();
118
+ }
119
+
120
+ main().catch((err) => {
121
+ console.error(err);
122
+ process.exit(1);
123
+ });
@@ -585,6 +585,64 @@ Discovered an issue.
585
585
  rmSync(dtBase, { recursive: true, force: true });
586
586
  }
587
587
 
588
+ // ─── unresolvable_dependency: range syntax dep warns ─────────────────
589
+ console.log("\n=== doctor: unresolvable_dependency warns for leftover range ID ===");
590
+ {
591
+ // Simulate a roadmap where expandDependencies did NOT expand (pre-fix stored artifact)
592
+ // by writing a dep that looks like a range but doesn't match any real slice.
593
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-udep-"));
594
+ const mDir2 = join(base, ".gsd", "milestones", "M001");
595
+ const sDir2 = join(mDir2, "slices", "S01");
596
+ const tDir2 = join(sDir2, "tasks");
597
+ mkdirSync(tDir2, { recursive: true });
598
+ writeFileSync(join(mDir2, "M001-ROADMAP.md"), [
599
+ "# M001: Test",
600
+ "",
601
+ "## Slices",
602
+ "- [x] **S01: Done** `risk:low` `depends:[]`",
603
+ " > After this: done",
604
+ "- [ ] **S02: Blocked** `risk:low` `depends:[S99]`",
605
+ " > After this: also done",
606
+ ].join("\n") + "\n");
607
+ writeFileSync(join(sDir2, "S01-PLAN.md"), "# S01\n\n**Goal:** g\n**Demo:** d\n\n## Tasks\n- [x] **T01: t** `est:5m`\n");
608
+ writeFileSync(join(tDir2, "T01-SUMMARY.md"), "---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01\n## What Happened\nDone.\n");
609
+
610
+ const r = await runGSDDoctor(base, { fix: false });
611
+ const udepIssues = r.issues.filter(i => i.code === "unresolvable_dependency");
612
+ assertTrue(udepIssues.length > 0, "unresolvable_dependency fires for unknown dep S99");
613
+ assertEq(udepIssues[0]?.severity, "warning", "severity is warning");
614
+ assertTrue(udepIssues[0]?.message.includes("S99"), "message names the bad dep");
615
+
616
+ rmSync(base, { recursive: true, force: true });
617
+ }
618
+
619
+ // ─── unresolvable_dependency: valid deps do not warn ─────────────────
620
+ console.log("\n=== doctor: no unresolvable_dependency for valid deps ===");
621
+ {
622
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-udep-ok-"));
623
+ const mDir2 = join(base, ".gsd", "milestones", "M001");
624
+ const sDir2 = join(mDir2, "slices", "S01");
625
+ const tDir2 = join(sDir2, "tasks");
626
+ mkdirSync(tDir2, { recursive: true });
627
+ writeFileSync(join(mDir2, "M001-ROADMAP.md"), [
628
+ "# M001: Test",
629
+ "",
630
+ "## Slices",
631
+ "- [x] **S01: Done** `risk:low` `depends:[]`",
632
+ " > After this: done",
633
+ "- [ ] **S02: Next** `risk:low` `depends:[S01]`",
634
+ " > After this: next done",
635
+ ].join("\n") + "\n");
636
+ writeFileSync(join(sDir2, "S01-PLAN.md"), "# S01\n\n**Goal:** g\n**Demo:** d\n\n## Tasks\n- [x] **T01: t** `est:5m`\n");
637
+ writeFileSync(join(tDir2, "T01-SUMMARY.md"), "---\nid: T01\nparent: S01\nmilestone: M001\n---\n# T01\n## What Happened\nDone.\n");
638
+
639
+ const r = await runGSDDoctor(base, { fix: false });
640
+ const udepIssues = r.issues.filter(i => i.code === "unresolvable_dependency");
641
+ assertEq(udepIssues.length, 0, "no unresolvable_dependency for valid S01 dep");
642
+
643
+ rmSync(base, { recursive: true, force: true });
644
+ }
645
+
588
646
  report();
589
647
  }
590
648
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * In-flight tool tracking tests — verifies that markToolStart/markToolEnd
3
- * correctly manage the in-flight tools set used by the idle watchdog to
3
+ * correctly manage the in-flight tools map used by the idle watchdog to
4
4
  * distinguish "agent waiting on long-running tool" from "agent is idle".
5
5
  *
6
6
  * Background: The idle watchdog checks every 15s for agent progress. Without
@@ -8,12 +8,15 @@
8
8
  * can run 20+ minutes for evaluations, deployments, test suites) are falsely
9
9
  * declared idle and interrupted by recovery steering messages.
10
10
  *
11
- * The fix hooks tool_execution_start/end events to track active tool calls.
12
- * When tools are in-flight, the watchdog resets lastProgressAt instead of
13
- * triggering idle recovery.
11
+ * The fix hooks tool_execution_start/end events to track active tool calls
12
+ * with start timestamps. When tools are in-flight and started recently
13
+ * (< idleTimeoutMs), the watchdog resets lastProgressAt instead of triggering
14
+ * idle recovery. When a tool has been in-flight for longer than idleTimeoutMs,
15
+ * it is treated as stuck (e.g., `command &` keeping stdout open) and recovery
16
+ * proceeds anyway.
14
17
  */
15
18
 
16
- import { markToolStart, markToolEnd, isAutoActive } from "../auto.ts";
19
+ import { markToolStart, markToolEnd, isAutoActive, getOldestInFlightToolAgeMs } from "../auto.ts";
17
20
  import { createTestContext } from './test-helpers.ts';
18
21
 
19
22
  const { assertEq, assertTrue, report } = createTestContext();
@@ -49,9 +52,17 @@ const { assertEq, assertTrue, report } = createTestContext();
49
52
  // ═══ Integration contract: expected exports from auto.ts ═════════════════════
50
53
 
51
54
  {
52
- console.log("\n=== auto.ts exports markToolStart and markToolEnd ===");
55
+ console.log("\n=== auto.ts exports markToolStart, markToolEnd, and getOldestInFlightToolAgeMs ===");
53
56
  assertEq(typeof markToolStart, "function", "markToolStart should be a function");
54
57
  assertEq(typeof markToolEnd, "function", "markToolEnd should be a function");
58
+ assertEq(typeof getOldestInFlightToolAgeMs, "function", "getOldestInFlightToolAgeMs should be a function");
59
+ }
60
+
61
+ {
62
+ console.log("\n=== getOldestInFlightToolAgeMs: returns 0 when no tools in-flight ===");
63
+ // When auto-mode is inactive, inFlightTools map is empty → age is 0
64
+ const age = getOldestInFlightToolAgeMs();
65
+ assertEq(age, 0, "should return 0 when no tools are in-flight");
55
66
  }
56
67
 
57
68
  {