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.
- package/dist/resources/extensions/gsd/auto/finalize-timeout.js +2 -0
- package/dist/resources/extensions/gsd/auto/loop.js +2 -2
- package/dist/resources/extensions/gsd/auto/phases.js +48 -5
- package/dist/resources/extensions/gsd/auto/types.js +2 -0
- package/dist/resources/extensions/gsd/auto-dashboard.js +2 -1
- package/dist/resources/extensions/gsd/auto-start.js +134 -2
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -2
- package/dist/resources/extensions/gsd/bootstrap/system-context.js +3 -1
- package/dist/resources/extensions/gsd/bootstrap/write-gate.js +31 -1
- package/dist/resources/extensions/gsd/commands/handlers/core.js +3 -2
- package/dist/resources/extensions/gsd/files.js +17 -0
- package/dist/resources/extensions/gsd/gsd-db.js +36 -2
- package/dist/resources/extensions/gsd/index.js +1 -1
- package/dist/resources/extensions/gsd/notification-overlay.js +1 -1
- package/dist/resources/extensions/gsd/notification-widget.js +2 -1
- package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +1 -1
- package/dist/resources/extensions/gsd/pre-execution-checks.js +16 -2
- package/dist/resources/extensions/gsd/prompts/discuss.md +2 -0
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/dist/resources/extensions/gsd/prompts/queue.md +2 -0
- package/dist/resources/extensions/gsd/prompts/system.md +2 -2
- package/dist/resources/extensions/gsd/state.js +3 -6
- package/dist/resources/extensions/gsd/workflow-events.js +1 -0
- package/dist/resources/extensions/gsd/workflow-projections.js +3 -2
- package/dist/resources/extensions/subagent/agents.js +19 -5
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-tui/dist/tui.d.ts.map +1 -1
- package/packages/pi-tui/dist/tui.js +3 -1
- package/packages/pi-tui/dist/tui.js.map +1 -1
- package/packages/pi-tui/src/tui.ts +3 -1
- package/src/resources/extensions/gsd/auto/finalize-timeout.ts +3 -0
- package/src/resources/extensions/gsd/auto/loop.ts +2 -2
- package/src/resources/extensions/gsd/auto/phases.ts +68 -3
- package/src/resources/extensions/gsd/auto/types.ts +5 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +2 -1
- package/src/resources/extensions/gsd/auto-start.ts +143 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +7 -2
- package/src/resources/extensions/gsd/bootstrap/system-context.ts +3 -1
- package/src/resources/extensions/gsd/bootstrap/write-gate.ts +36 -1
- package/src/resources/extensions/gsd/commands/handlers/core.ts +3 -2
- package/src/resources/extensions/gsd/files.ts +19 -0
- package/src/resources/extensions/gsd/gsd-db.ts +33 -2
- package/src/resources/extensions/gsd/index.ts +1 -0
- package/src/resources/extensions/gsd/notification-overlay.ts +1 -1
- package/src/resources/extensions/gsd/notification-widget.ts +2 -1
- package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +1 -1
- package/src/resources/extensions/gsd/pre-execution-checks.ts +19 -2
- package/src/resources/extensions/gsd/prompts/discuss.md +2 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +2 -0
- package/src/resources/extensions/gsd/prompts/queue.md +2 -0
- package/src/resources/extensions/gsd/prompts/system.md +2 -2
- package/src/resources/extensions/gsd/state.ts +3 -6
- package/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +125 -0
- package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +69 -0
- package/src/resources/extensions/gsd/tests/journal-integration.test.ts +11 -10
- package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +189 -0
- package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +66 -0
- package/src/resources/extensions/gsd/tests/subagent-agent-discovery.test.ts +47 -0
- package/src/resources/extensions/gsd/tests/wave5-consistency-regressions.test.ts +165 -0
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +127 -2
- package/src/resources/extensions/gsd/workflow-events.ts +5 -3
- package/src/resources/extensions/gsd/workflow-projections.ts +3 -2
- package/src/resources/extensions/subagent/agents.ts +30 -6
- /package/dist/web/standalone/.next/static/{iueakR5x5bQbax2sGz8Yr → E0hBt4ifuG7QBbhUR5-6U}/_buildManifest.js +0 -0
- /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
|
+
});
|