gsd-pi 2.65.0-dev.6cc5110 → 2.65.0-dev.800ece0

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 (96) hide show
  1. package/dist/resources/extensions/gsd/auto/finalize-timeout.js +2 -0
  2. package/dist/resources/extensions/gsd/auto/loop.js +2 -2
  3. package/dist/resources/extensions/gsd/auto/phases.js +48 -5
  4. package/dist/resources/extensions/gsd/auto/types.js +2 -0
  5. package/dist/resources/extensions/gsd/auto-dashboard.js +2 -1
  6. package/dist/resources/extensions/gsd/auto-start.js +134 -2
  7. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -2
  8. package/dist/resources/extensions/gsd/bootstrap/system-context.js +3 -1
  9. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +31 -1
  10. package/dist/resources/extensions/gsd/commands/handlers/core.js +3 -2
  11. package/dist/resources/extensions/gsd/files.js +17 -0
  12. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  13. package/dist/resources/extensions/gsd/index.js +1 -1
  14. package/dist/resources/extensions/gsd/notification-overlay.js +1 -1
  15. package/dist/resources/extensions/gsd/notification-widget.js +2 -1
  16. package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +1 -1
  17. package/dist/resources/extensions/gsd/pre-execution-checks.js +16 -2
  18. package/dist/resources/extensions/gsd/prompts/discuss.md +2 -0
  19. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  20. package/dist/resources/extensions/gsd/prompts/queue.md +2 -0
  21. package/dist/resources/extensions/gsd/prompts/system.md +2 -2
  22. package/dist/resources/extensions/gsd/state.js +3 -6
  23. package/dist/resources/extensions/gsd/workflow-events.js +1 -0
  24. package/dist/resources/extensions/gsd/workflow-projections.js +3 -2
  25. package/dist/resources/extensions/subagent/agents.js +19 -5
  26. package/dist/web/standalone/.next/BUILD_ID +1 -1
  27. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  28. package/dist/web/standalone/.next/build-manifest.json +2 -2
  29. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  30. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  31. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.html +1 -1
  47. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  54. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  55. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  56. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  57. package/package.json +1 -1
  58. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  59. package/packages/pi-tui/dist/tui.js +3 -1
  60. package/packages/pi-tui/dist/tui.js.map +1 -1
  61. package/packages/pi-tui/src/tui.ts +3 -1
  62. package/src/resources/extensions/gsd/auto/finalize-timeout.ts +3 -0
  63. package/src/resources/extensions/gsd/auto/loop.ts +2 -2
  64. package/src/resources/extensions/gsd/auto/phases.ts +68 -3
  65. package/src/resources/extensions/gsd/auto/types.ts +5 -0
  66. package/src/resources/extensions/gsd/auto-dashboard.ts +2 -1
  67. package/src/resources/extensions/gsd/auto-start.ts +143 -0
  68. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +7 -2
  69. package/src/resources/extensions/gsd/bootstrap/system-context.ts +3 -1
  70. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +36 -1
  71. package/src/resources/extensions/gsd/commands/handlers/core.ts +3 -2
  72. package/src/resources/extensions/gsd/files.ts +19 -0
  73. package/src/resources/extensions/gsd/gsd-db.ts +33 -2
  74. package/src/resources/extensions/gsd/index.ts +1 -0
  75. package/src/resources/extensions/gsd/notification-overlay.ts +1 -1
  76. package/src/resources/extensions/gsd/notification-widget.ts +2 -1
  77. package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +1 -1
  78. package/src/resources/extensions/gsd/pre-execution-checks.ts +19 -2
  79. package/src/resources/extensions/gsd/prompts/discuss.md +2 -0
  80. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
  81. package/src/resources/extensions/gsd/prompts/queue.md +2 -0
  82. package/src/resources/extensions/gsd/prompts/system.md +2 -2
  83. package/src/resources/extensions/gsd/state.ts +3 -6
  84. package/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +125 -0
  85. package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +69 -0
  86. package/src/resources/extensions/gsd/tests/journal-integration.test.ts +11 -10
  87. package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +189 -0
  88. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +66 -0
  89. package/src/resources/extensions/gsd/tests/subagent-agent-discovery.test.ts +47 -0
  90. package/src/resources/extensions/gsd/tests/wave5-consistency-regressions.test.ts +165 -0
  91. package/src/resources/extensions/gsd/tests/write-gate.test.ts +127 -2
  92. package/src/resources/extensions/gsd/workflow-events.ts +5 -3
  93. package/src/resources/extensions/gsd/workflow-projections.ts +3 -2
  94. package/src/resources/extensions/subagent/agents.ts +30 -6
  95. /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → E0hBt4ifuG7QBbhUR5-6U}/_buildManifest.js +0 -0
  96. /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → E0hBt4ifuG7QBbhUR5-6U}/_ssgManifest.js +0 -0
@@ -0,0 +1,69 @@
1
+ // GSD Extension — formatShortcut tests
2
+ // Verifies OS-specific keyboard shortcut rendering.
3
+
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { formatShortcut } from '../files.ts';
7
+
8
+ // ─── formatShortcut renders per-platform shortcuts ──────────────────────
9
+
10
+ test('formatShortcut: converts Ctrl+Alt combo on macOS', () => {
11
+ // formatShortcut uses process.platform at module load time.
12
+ // We can only test the current platform's behavior.
13
+ const result = formatShortcut('Ctrl+Alt+G');
14
+ if (process.platform === 'darwin') {
15
+ assert.strictEqual(result, '⌃⌥G', 'macOS should use ⌃⌥ symbols');
16
+ } else {
17
+ assert.strictEqual(result, 'Ctrl+Alt+G', 'non-macOS should pass through unchanged');
18
+ }
19
+ });
20
+
21
+ test('formatShortcut: converts Ctrl+Alt+N', () => {
22
+ const result = formatShortcut('Ctrl+Alt+N');
23
+ if (process.platform === 'darwin') {
24
+ assert.strictEqual(result, '⌃⌥N');
25
+ } else {
26
+ assert.strictEqual(result, 'Ctrl+Alt+N');
27
+ }
28
+ });
29
+
30
+ test('formatShortcut: converts Ctrl+Alt+B', () => {
31
+ const result = formatShortcut('Ctrl+Alt+B');
32
+ if (process.platform === 'darwin') {
33
+ assert.strictEqual(result, '⌃⌥B');
34
+ } else {
35
+ assert.strictEqual(result, 'Ctrl+Alt+B');
36
+ }
37
+ });
38
+
39
+ test('formatShortcut: converts standalone Ctrl modifier', () => {
40
+ const result = formatShortcut('Ctrl+C');
41
+ if (process.platform === 'darwin') {
42
+ assert.strictEqual(result, '⌃C');
43
+ } else {
44
+ assert.strictEqual(result, 'Ctrl+C');
45
+ }
46
+ });
47
+
48
+ test('formatShortcut: converts Shift modifier', () => {
49
+ const result = formatShortcut('Shift+Tab');
50
+ if (process.platform === 'darwin') {
51
+ assert.strictEqual(result, '⇧Tab');
52
+ } else {
53
+ assert.strictEqual(result, 'Shift+Tab');
54
+ }
55
+ });
56
+
57
+ test('formatShortcut: converts Cmd modifier', () => {
58
+ const result = formatShortcut('Cmd+S');
59
+ if (process.platform === 'darwin') {
60
+ assert.strictEqual(result, '⌘S');
61
+ } else {
62
+ assert.strictEqual(result, 'Cmd+S');
63
+ }
64
+ });
65
+
66
+ test('formatShortcut: passes through plain key names', () => {
67
+ assert.strictEqual(formatShortcut('Escape'), 'Escape');
68
+ assert.strictEqual(formatShortcut('Enter'), 'Enter');
69
+ });
@@ -216,7 +216,7 @@ test("runDispatch emits dispatch-match with correct rule and flowId", async () =
216
216
  mid: "M001",
217
217
  midTitle: "Test Milestone",
218
218
  };
219
- const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
219
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
220
220
 
221
221
  const result = await runDispatch(ic, preData, loopState);
222
222
 
@@ -248,7 +248,7 @@ test("runDispatch emits dispatch-stop when dispatch returns stop action", async
248
248
  mid: "M001",
249
249
  midTitle: "Test",
250
250
  };
251
- const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
251
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
252
252
 
253
253
  const result = await runDispatch(ic, preData, loopState);
254
254
  assert.equal(result.action, "break");
@@ -303,6 +303,7 @@ test("runDispatch checks prior-slice completion against the project root in work
303
303
  const result = await runDispatch(ic, preData, {
304
304
  recentUnits: [],
305
305
  stuckRecoveryAttempts: 0,
306
+ consecutiveFinalizeTimeouts: 0,
306
307
  });
307
308
 
308
309
  assert.equal(result.action, "next");
@@ -343,7 +344,7 @@ test("runUnitPhase emits unit-start and unit-end with causedBy reference", async
343
344
  isRetry: false,
344
345
  previousTier: undefined,
345
346
  };
346
- const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0 };
347
+ const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
347
348
 
348
349
  // Start runUnitPhase (it will block on runUnit internally)
349
350
  const unitPromise = runUnitPhase(ic, iterData, loopState);
@@ -400,7 +401,7 @@ test("all events from a mock iteration have monotonically increasing seq and sam
400
401
  mid: "M001",
401
402
  midTitle: "Test",
402
403
  };
403
- const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
404
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
404
405
  const dispatchResult = await runDispatch(ic, preData, loopState);
405
406
  assert.equal(dispatchResult.action, "next");
406
407
 
@@ -446,7 +447,7 @@ test("dispatch-match events include matchedRule field matching the rule name", a
446
447
  midTitle: "Test",
447
448
  };
448
449
 
449
- await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0 });
450
+ await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 });
450
451
 
451
452
  const matchEvents = capture.events.filter(e => e.eventType === "dispatch-match");
452
453
  assert.equal(matchEvents.length, 1);
@@ -475,7 +476,7 @@ test("pre-dispatch-hook event is emitted when hooks fire", async () => {
475
476
  midTitle: "Test",
476
477
  };
477
478
 
478
- await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0 });
479
+ await runDispatch(ic, preData, { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 });
479
480
 
480
481
  const hookEvents = capture.events.filter(e => e.eventType === "pre-dispatch-hook");
481
482
  assert.equal(hookEvents.length, 1, "should emit one pre-dispatch-hook event");
@@ -497,7 +498,7 @@ test("terminal event is emitted on milestone-complete", async () => {
497
498
  }) as any,
498
499
  });
499
500
  const ic = makeIC(deps);
500
- const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
501
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
501
502
 
502
503
  const result = await runPreDispatch(ic, loopState);
503
504
  assert.equal(result.action, "break");
@@ -521,7 +522,7 @@ test("terminal event is emitted on blocked state", async () => {
521
522
  }) as any,
522
523
  });
523
524
  const ic = makeIC(deps);
524
- const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
525
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
525
526
 
526
527
  const result = await runPreDispatch(ic, loopState);
527
528
  assert.equal(result.action, "break");
@@ -550,7 +551,7 @@ test("milestone-transition event is emitted when milestone changes", async () =>
550
551
  const ic = makeIC(deps);
551
552
  // Session says current milestone is M001, but state will return M002
552
553
  ic.s.currentMilestoneId = "M001";
553
- const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0 };
554
+ const loopState: LoopState = { recentUnits: [], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
554
555
 
555
556
  await runPreDispatch(ic, loopState);
556
557
 
@@ -580,7 +581,7 @@ test("unit-end event contains errorContext when unit is cancelled with structure
580
581
  isRetry: false,
581
582
  previousTier: undefined,
582
583
  };
583
- const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0 };
584
+ const loopState: LoopState = { recentUnits: [{ key: "execute-task/M001/S01/T01" }], stuckRecoveryAttempts: 0, consecutiveFinalizeTimeouts: 0 };
584
585
 
585
586
  const unitPromise = runUnitPhase(ic, iterData, loopState);
586
587
  await new Promise(r => setTimeout(r, 50));
@@ -0,0 +1,189 @@
1
+ // GSD2 — Tests for auditOrphanedMilestoneBranches bootstrap audit
2
+ import { describe, test, beforeEach, afterEach } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { execSync } from "node:child_process";
8
+
9
+ import { auditOrphanedMilestoneBranches } from "../auto-start.ts";
10
+ import { openDatabase, closeDatabase, insertMilestone, updateMilestoneStatus } from "../gsd-db.ts";
11
+
12
+ function run(cmd: string, cwd: string): string {
13
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
14
+ }
15
+
16
+ /** Create a temp git repo with .gsd structure and DB. */
17
+ function createRepo(): string {
18
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "orphan-audit-test-")));
19
+ run("git init", dir);
20
+ run("git config user.email test@test.com", dir);
21
+ run("git config user.name Test", dir);
22
+
23
+ writeFileSync(join(dir, "README.md"), "# test\n");
24
+ run("git add .", dir);
25
+ run("git commit -m init", dir);
26
+ run("git branch -M main", dir);
27
+
28
+ // Create .gsd structure on disk (not tracked in git)
29
+ mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true });
30
+
31
+ return dir;
32
+ }
33
+
34
+ describe("auditOrphanedMilestoneBranches", () => {
35
+ let dir: string;
36
+
37
+ beforeEach(() => {
38
+ dir = createRepo();
39
+ openDatabase(join(dir, ".gsd", "gsd.db"));
40
+ });
41
+
42
+ afterEach(() => {
43
+ closeDatabase();
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ test("no milestone branches → no-op", () => {
48
+ const result = auditOrphanedMilestoneBranches(dir, "worktree");
49
+ assert.deepStrictEqual(result.recovered, []);
50
+ assert.deepStrictEqual(result.warnings, []);
51
+ });
52
+
53
+ test("skips in none isolation mode", () => {
54
+ // Create a milestone branch that would otherwise be detected
55
+ run("git branch milestone/M001", dir);
56
+ insertMilestone({ id: "M001", title: "Test", status: "complete" });
57
+
58
+ const result = auditOrphanedMilestoneBranches(dir, "none");
59
+ assert.deepStrictEqual(result.recovered, []);
60
+ assert.deepStrictEqual(result.warnings, []);
61
+
62
+ // Branch should still exist
63
+ const branches = run("git branch --list milestone/M001", dir);
64
+ assert.ok(branches.includes("milestone/M001"), "branch should be preserved in none mode");
65
+ });
66
+
67
+ test("deletes merged branch for completed milestone", () => {
68
+ // Create milestone branch from main (so it's already merged)
69
+ run("git branch milestone/M001", dir);
70
+ insertMilestone({ id: "M001", title: "Test", status: "complete" });
71
+
72
+ const result = auditOrphanedMilestoneBranches(dir, "worktree");
73
+
74
+ assert.ok(result.recovered.length > 0, "should have recovered actions");
75
+ assert.ok(
76
+ result.recovered.some(r => r.includes("Deleted merged branch milestone/M001")),
77
+ "should report branch deletion",
78
+ );
79
+ assert.deepStrictEqual(result.warnings, []);
80
+
81
+ // Branch should be gone
82
+ const branches = run("git branch --list milestone/M001", dir);
83
+ assert.deepStrictEqual(branches, "", "branch should be deleted");
84
+ });
85
+
86
+ test("warns about unmerged branch for completed milestone", () => {
87
+ // Create milestone branch with divergent commits (not merged into main)
88
+ run("git checkout -b milestone/M001", dir);
89
+ writeFileSync(join(dir, "feature.txt"), "new feature\n");
90
+ run("git add feature.txt", dir);
91
+ run("git commit -m \"add feature on milestone branch\"", dir);
92
+ run("git checkout main", dir);
93
+
94
+ insertMilestone({ id: "M001", title: "Test", status: "complete" });
95
+
96
+ const result = auditOrphanedMilestoneBranches(dir, "worktree");
97
+
98
+ assert.deepStrictEqual(result.recovered, [], "should not delete unmerged branch");
99
+ assert.ok(result.warnings.length > 0, "should have warnings");
100
+ assert.ok(
101
+ result.warnings.some(w => w.includes("NOT merged")),
102
+ "should warn about unmerged branch",
103
+ );
104
+
105
+ // Branch should still exist (data safety)
106
+ const branches = run("git branch --list milestone/M001", dir);
107
+ assert.ok(branches.includes("milestone/M001"), "unmerged branch must be preserved");
108
+ });
109
+
110
+ test("skips active (non-complete) milestone branches", () => {
111
+ run("git branch milestone/M001", dir);
112
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
113
+
114
+ const result = auditOrphanedMilestoneBranches(dir, "worktree");
115
+
116
+ assert.deepStrictEqual(result.recovered, []);
117
+ assert.deepStrictEqual(result.warnings, []);
118
+
119
+ // Branch should still exist
120
+ const branches = run("git branch --list milestone/M001", dir);
121
+ assert.ok(branches.includes("milestone/M001"), "active milestone branch should be preserved");
122
+ });
123
+
124
+ test("cleans up orphaned worktree directory for merged milestone", () => {
125
+ // Create milestone branch (merged — same as main)
126
+ run("git branch milestone/M001", dir);
127
+ insertMilestone({ id: "M001", title: "Test", status: "complete" });
128
+
129
+ // Create orphaned worktree directory
130
+ const wtDir = join(dir, ".gsd", "worktrees", "M001");
131
+ mkdirSync(wtDir, { recursive: true });
132
+ writeFileSync(join(wtDir, "leftover.txt"), "orphaned file\n");
133
+
134
+ const result = auditOrphanedMilestoneBranches(dir, "worktree");
135
+
136
+ assert.ok(result.recovered.length > 0, "should have recovered actions");
137
+ assert.ok(
138
+ result.recovered.some(r => r.includes("worktree directory")),
139
+ "should report worktree cleanup",
140
+ );
141
+
142
+ // Worktree directory should be cleaned up
143
+ assert.ok(!existsSync(wtDir), "orphaned worktree directory should be removed");
144
+ });
145
+
146
+ test("handles multiple milestones with mixed states", () => {
147
+ // M001: complete, branch merged → should clean up
148
+ run("git branch milestone/M001", dir);
149
+ insertMilestone({ id: "M001", title: "First", status: "complete" });
150
+
151
+ // M002: active, branch exists → should skip
152
+ run("git branch milestone/M002", dir);
153
+ insertMilestone({ id: "M002", title: "Second", status: "active" });
154
+
155
+ const result = auditOrphanedMilestoneBranches(dir, "worktree");
156
+
157
+ // M001 should be cleaned up
158
+ assert.ok(
159
+ result.recovered.some(r => r.includes("M001")),
160
+ "should clean up completed M001",
161
+ );
162
+
163
+ // M002 should not be touched
164
+ const branches = run("git branch --list milestone/M002", dir);
165
+ assert.ok(branches.includes("milestone/M002"), "active M002 branch should be preserved");
166
+ });
167
+
168
+ test("works in branch isolation mode", () => {
169
+ run("git branch milestone/M001", dir);
170
+ insertMilestone({ id: "M001", title: "Test", status: "complete" });
171
+
172
+ const result = auditOrphanedMilestoneBranches(dir, "branch");
173
+
174
+ assert.ok(result.recovered.length > 0, "should work in branch mode too");
175
+ assert.ok(
176
+ result.recovered.some(r => r.includes("Deleted merged branch")),
177
+ "should delete branch in branch mode",
178
+ );
179
+ });
180
+
181
+ test("handles milestone in DB but no branch (no-op)", () => {
182
+ insertMilestone({ id: "M001", title: "Test", status: "complete" });
183
+
184
+ const result = auditOrphanedMilestoneBranches(dir, "worktree");
185
+
186
+ assert.deepStrictEqual(result.recovered, []);
187
+ assert.deepStrictEqual(result.warnings, []);
188
+ });
189
+ });
@@ -1083,11 +1083,77 @@ describe("checkTaskOrdering false positive regression (#3677)", () => {
1083
1083
  const results = checkTaskOrdering(tasks, "/tmp");
1084
1084
  assert.equal(results.length, 0, "Normalized task.files path should not trigger a false positive");
1085
1085
  });
1086
+
1087
+ test("annotated inputs still trigger ordering violations against later plain outputs", () => {
1088
+ const tasks = [
1089
+ createTask({
1090
+ id: "T01",
1091
+ sequence: 0,
1092
+ files: [],
1093
+ inputs: ["`later.ts` — needed first"],
1094
+ expected_output: [],
1095
+ }),
1096
+ createTask({
1097
+ id: "T02",
1098
+ sequence: 1,
1099
+ files: [],
1100
+ inputs: [],
1101
+ expected_output: ["later.ts"],
1102
+ }),
1103
+ ];
1104
+
1105
+ const results = checkTaskOrdering(tasks, "/tmp");
1106
+ assert.equal(results.length, 1, "Annotated inputs should still match later plain expected_output entries");
1107
+ assert.equal(results[0].target, "`later.ts` — needed first");
1108
+ assert.ok(results[0].message.includes("sequence violation"));
1109
+ });
1086
1110
  });
1087
1111
 
1088
1112
  // ─── checkFilePathConsistency additional edge cases ──────────────────────────
1089
1113
 
1090
1114
  describe("checkFilePathConsistency additional edge cases", () => {
1115
+ test("annotated inputs match files that already exist on disk", () => {
1116
+ const tempDir = join(tmpdir(), `pre-exec-test-annotated-input-${Date.now()}`);
1117
+ mkdirSync(tempDir, { recursive: true });
1118
+ writeFileSync(join(tempDir, "existing.ts"), "// content");
1119
+
1120
+ try {
1121
+ const tasks = [
1122
+ createTask({
1123
+ id: "T01",
1124
+ files: [],
1125
+ inputs: ["`existing.ts` — file already on disk"],
1126
+ expected_output: [],
1127
+ }),
1128
+ ];
1129
+
1130
+ const results = checkFilePathConsistency(tasks, tempDir);
1131
+ assert.equal(results.length, 0, "Annotated inputs should resolve to the on-disk file path");
1132
+ } finally {
1133
+ rmSync(tempDir, { recursive: true, force: true });
1134
+ }
1135
+ });
1136
+
1137
+ test("plain inputs match prior annotated expected outputs", () => {
1138
+ const tasks = [
1139
+ createTask({
1140
+ id: "T01",
1141
+ files: [],
1142
+ inputs: [],
1143
+ expected_output: ["`generated.ts` — created earlier"],
1144
+ }),
1145
+ createTask({
1146
+ id: "T02",
1147
+ files: [],
1148
+ inputs: ["generated.ts"],
1149
+ expected_output: [],
1150
+ }),
1151
+ ];
1152
+
1153
+ const results = checkFilePathConsistency(tasks, "/tmp");
1154
+ assert.equal(results.length, 0, "Prior annotated expected_output entries should satisfy later plain inputs");
1155
+ });
1156
+
1091
1157
  test("inputs referencing glob-like patterns should not crash", () => {
1092
1158
  // A glob pattern in inputs is unusual but should be handled gracefully.
1093
1159
  // The file won't exist on disk, so it should produce a blocking result.
@@ -42,3 +42,50 @@ test("discoverAgents falls back to legacy .pi/agents when needed", (t) => {
42
42
  assert.equal(discovery.projectAgentsDir, agentsDir);
43
43
  assert.deepEqual(discovery.agents.map((agent) => agent.name), ["ping"]);
44
44
  });
45
+
46
+ test("discoverAgents accepts tools frontmatter as a YAML list", (t) => {
47
+ const root = makeProjectRoot(t);
48
+ const agentsDir = join(root, ".gsd", "agents");
49
+ mkdirSync(agentsDir, { recursive: true });
50
+ writeFileSync(
51
+ join(agentsDir, "reviewer.md"),
52
+ [
53
+ "---",
54
+ "name: reviewer",
55
+ "description: review agent",
56
+ "tools:",
57
+ " - bash",
58
+ " - read",
59
+ "---",
60
+ "Review code",
61
+ "",
62
+ ].join("\n"),
63
+ );
64
+
65
+ const discovery = discoverAgents(root, "project");
66
+
67
+ assert.deepEqual(discovery.agents.map((agent) => agent.name), ["reviewer"]);
68
+ assert.deepEqual(discovery.agents[0]?.tools, ["bash", "read"]);
69
+ });
70
+
71
+ test("discoverAgents still accepts comma-separated tools frontmatter", (t) => {
72
+ const root = makeProjectRoot(t);
73
+ const agentsDir = join(root, ".gsd", "agents");
74
+ mkdirSync(agentsDir, { recursive: true });
75
+ writeFileSync(
76
+ join(agentsDir, "reviewer.md"),
77
+ [
78
+ "---",
79
+ "name: reviewer",
80
+ "description: review agent",
81
+ "tools: bash, read",
82
+ "---",
83
+ "Review code",
84
+ "",
85
+ ].join("\n"),
86
+ );
87
+
88
+ const discovery = discoverAgents(root, "project");
89
+
90
+ assert.deepEqual(discovery.agents[0]?.tools, ["bash", "read"]);
91
+ });
@@ -0,0 +1,165 @@
1
+ // GSD State Machine — Wave 5 Consistency Regression Tests
2
+ // Validates isClosedStatus usage in projections, upsertDecision seq preservation,
3
+ // event schema versioning, and replay round-trip with mixed cmd formats.
4
+
5
+ import { describe, test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
10
+ import { isClosedStatus } from "../status-guards.js";
11
+ import { openDatabase, closeDatabase, upsertDecision, _getAdapter, insertMilestone, insertSlice, insertTask, getTask } from "../gsd-db.js";
12
+ import { extractEntityKey } from "../workflow-reconcile.js";
13
+ import type { WorkflowEvent } from "../workflow-events.js";
14
+
15
+ // ── Fix 19: isClosedStatus covers all closed statuses ──
16
+
17
+ describe("isClosedStatus used by projections", () => {
18
+ test("skipped is closed (projections now show checked)", () => {
19
+ assert.ok(isClosedStatus("skipped"));
20
+ });
21
+ test("complete is closed", () => {
22
+ assert.ok(isClosedStatus("complete"));
23
+ });
24
+ test("done is closed", () => {
25
+ assert.ok(isClosedStatus("done"));
26
+ });
27
+ test("in-progress is not closed", () => {
28
+ assert.ok(!isClosedStatus("in-progress"));
29
+ });
30
+ });
31
+
32
+ // ── Fix 20: upsertDecision preserves seq on update ──
33
+
34
+ describe("upsertDecision preserves seq column", () => {
35
+ test("seq is preserved when decision is re-upserted", () => {
36
+ const tmp = mkdtempSync(join(tmpdir(), "gsd-upsert-test-"));
37
+ const dbPath = join(tmp, "gsd.db");
38
+ try {
39
+ openDatabase(dbPath);
40
+ const adapter = _getAdapter();
41
+ assert.ok(adapter, "adapter must be available");
42
+
43
+ // Insert two decisions
44
+ upsertDecision({
45
+ id: "D001", when_context: "ctx1", scope: "s1",
46
+ decision: "d1", choice: "c1", rationale: "r1",
47
+ revisable: "yes", made_by: "agent", superseded_by: null,
48
+ });
49
+ upsertDecision({
50
+ id: "D002", when_context: "ctx2", scope: "s2",
51
+ decision: "d2", choice: "c2", rationale: "r2",
52
+ revisable: "yes", made_by: "agent", superseded_by: null,
53
+ });
54
+
55
+ // Get original seq values
56
+ const rows1 = adapter.prepare("SELECT id, seq FROM decisions ORDER BY seq").all() as Array<{ id: string; seq: number }>;
57
+ assert.strictEqual(rows1[0].id, "D001");
58
+ assert.strictEqual(rows1[1].id, "D002");
59
+ const d001OriginalSeq = rows1[0].seq;
60
+
61
+ // Re-upsert D001 with updated content
62
+ upsertDecision({
63
+ id: "D001", when_context: "updated", scope: "s1",
64
+ decision: "d1-updated", choice: "c1", rationale: "r1",
65
+ revisable: "yes", made_by: "agent", superseded_by: null,
66
+ });
67
+
68
+ // Verify seq is preserved (not moved to end)
69
+ const rows2 = adapter.prepare("SELECT id, seq FROM decisions ORDER BY seq").all() as Array<{ id: string; seq: number }>;
70
+ assert.strictEqual(rows2[0].id, "D001", "D001 should still be first by seq");
71
+ assert.strictEqual(rows2[0].seq, d001OriginalSeq, "D001 seq should be preserved");
72
+ assert.strictEqual(rows2[1].id, "D002", "D002 should still be second");
73
+
74
+ // Verify content was updated
75
+ const updated = adapter.prepare("SELECT decision FROM decisions WHERE id = 'D001'").get() as { decision: string };
76
+ assert.strictEqual(updated.decision, "d1-updated");
77
+
78
+ closeDatabase();
79
+ } finally {
80
+ rmSync(tmp, { recursive: true, force: true });
81
+ }
82
+ });
83
+ });
84
+
85
+ // ── Fix 23: Event schema versioning ──
86
+
87
+ describe("WorkflowEvent v field", () => {
88
+ test("appendEvent includes v:2 in output", async () => {
89
+ const tmp = mkdtempSync(join(tmpdir(), "gsd-event-v-test-"));
90
+ try {
91
+ const { appendEvent } = await import("../workflow-events.js");
92
+ appendEvent(tmp, {
93
+ cmd: "test-event",
94
+ params: { foo: "bar" },
95
+ ts: new Date().toISOString(),
96
+ actor: "system",
97
+ });
98
+
99
+ const logPath = join(tmp, ".gsd", "event-log.jsonl");
100
+ const line = readFileSync(logPath, "utf-8").trim();
101
+ const event = JSON.parse(line);
102
+ assert.strictEqual(event.v, 2, "New events should have v:2");
103
+ assert.strictEqual(event.cmd, "test-event");
104
+ } finally {
105
+ rmSync(tmp, { recursive: true, force: true });
106
+ }
107
+ });
108
+ });
109
+
110
+ // ── Fix 19 (behavior-level): Projection rendering with skipped tasks ──
111
+
112
+ describe("isClosedStatus drives projection checkbox logic", () => {
113
+ test("skipped task produces checked checkbox via isClosedStatus", () => {
114
+ // This tests the behavior contract that projections rely on:
115
+ // workflow-projections.ts uses isClosedStatus() to determine checkbox state.
116
+ // "skipped" tasks must render as [x], not [ ].
117
+ const statuses = ["complete", "done", "skipped"];
118
+ for (const status of statuses) {
119
+ assert.ok(
120
+ isClosedStatus(status),
121
+ `status "${status}" must be closed so projections render [x]`,
122
+ );
123
+ }
124
+ // Non-closed statuses must render as [ ]
125
+ for (const status of ["pending", "in-progress", "blocked", "active"]) {
126
+ assert.ok(
127
+ !isClosedStatus(status),
128
+ `status "${status}" must NOT be closed so projections render [ ]`,
129
+ );
130
+ }
131
+ });
132
+ });
133
+
134
+ // ── extractEntityKey: underscored cmds are recognized (Wave 5 scope) ──
135
+ // Note: hyphenated cmd normalization is in Wave 1. These tests validate
136
+ // the underscored format that Wave 5's extractEntityKey handles directly.
137
+
138
+ describe("extractEntityKey recognizes underscored cmds", () => {
139
+ const base: WorkflowEvent = { cmd: "", params: {}, ts: "", hash: "", actor: "agent", session_id: "" };
140
+
141
+ test("complete_task → task entity", () => {
142
+ const key = extractEntityKey({ ...base, cmd: "complete_task", params: { taskId: "T01" } });
143
+ assert.deepStrictEqual(key, { type: "task", id: "T01" });
144
+ });
145
+
146
+ test("complete_slice → slice entity", () => {
147
+ const key = extractEntityKey({ ...base, cmd: "complete_slice", params: { sliceId: "S01" } });
148
+ assert.deepStrictEqual(key, { type: "slice", id: "S01" });
149
+ });
150
+
151
+ test("plan_slice → slice_plan entity (distinct from complete)", () => {
152
+ const key = extractEntityKey({ ...base, cmd: "plan_slice", params: { sliceId: "S01" } });
153
+ assert.deepStrictEqual(key, { type: "slice_plan", id: "S01" });
154
+ });
155
+
156
+ test("save_decision → decision entity", () => {
157
+ const key = extractEntityKey({ ...base, cmd: "save_decision", params: { scope: "s", decision: "d" } });
158
+ assert.deepStrictEqual(key, { type: "decision", id: "s:d" });
159
+ });
160
+
161
+ test("unknown cmd returns null (not crash)", () => {
162
+ const key = extractEntityKey({ ...base, cmd: "future_unknown_cmd", params: {} });
163
+ assert.strictEqual(key, null);
164
+ });
165
+ });