gsd-pi 2.32.0-dev.f3d5d53 → 2.33.0-dev.69bff0f
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/README.md +13 -18
- package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
- package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/dist/resources/extensions/gsd/auto-start.ts +2 -1
- package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/dist/resources/extensions/gsd/auto-verification.ts +4 -5
- package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/dist/resources/extensions/gsd/auto.ts +89 -164
- package/dist/resources/extensions/gsd/commands.ts +14 -2
- package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/dist/resources/extensions/gsd/metrics.ts +3 -3
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/dist/resources/extensions/gsd/session-lock.ts +80 -16
- package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/dist/resources/extensions/gsd/undo.ts +5 -7
- package/dist/resources/extensions/gsd/unit-id.ts +14 -0
- package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/package.json +3 -2
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/src/resources/extensions/gsd/auto-observability.ts +2 -4
- package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/src/resources/extensions/gsd/auto-start.ts +2 -1
- package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/src/resources/extensions/gsd/auto-verification.ts +4 -5
- package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/src/resources/extensions/gsd/auto.ts +89 -164
- package/src/resources/extensions/gsd/commands.ts +14 -2
- package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/src/resources/extensions/gsd/metrics.ts +3 -3
- package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/src/resources/extensions/gsd/session-lock.ts +80 -16
- package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/src/resources/extensions/gsd/undo.ts +5 -7
- package/src/resources/extensions/gsd/unit-id.ts +14 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
- package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readSessionLockData,
|
|
13
13
|
isSessionLockHeld,
|
|
14
14
|
isSessionLockProcessAlive,
|
|
15
|
+
cleanupStrayLockFiles,
|
|
15
16
|
} from "../session-lock.ts";
|
|
16
17
|
|
|
17
18
|
// ─── acquireSessionLock ──────────────────────────────────────────────────
|
|
@@ -313,3 +314,121 @@ test("acquireSessionLock creates .gsd/ if it does not exist", () => {
|
|
|
313
314
|
releaseSessionLock(dir);
|
|
314
315
|
rmSync(dir, { recursive: true, force: true });
|
|
315
316
|
});
|
|
317
|
+
|
|
318
|
+
// ─── cleanupStrayLockFiles (#1315) ──────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
test("cleanupStrayLockFiles removes numbered lock variants but preserves auto.lock", () => {
|
|
321
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
322
|
+
const gsdDir = join(dir, ".gsd");
|
|
323
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
324
|
+
|
|
325
|
+
// Create canonical lock file + numbered variants
|
|
326
|
+
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
327
|
+
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":2}');
|
|
328
|
+
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":3}');
|
|
329
|
+
writeFileSync(join(gsdDir, "auto 4.lock"), '{"pid":4}');
|
|
330
|
+
|
|
331
|
+
cleanupStrayLockFiles(dir);
|
|
332
|
+
|
|
333
|
+
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
|
|
334
|
+
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed");
|
|
335
|
+
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed");
|
|
336
|
+
assert.ok(!existsSync(join(gsdDir, "auto 4.lock")), "auto 4.lock should be removed");
|
|
337
|
+
|
|
338
|
+
rmSync(dir, { recursive: true, force: true });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("cleanupStrayLockFiles handles parenthesized variants", () => {
|
|
342
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
343
|
+
const gsdDir = join(dir, ".gsd");
|
|
344
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
345
|
+
|
|
346
|
+
// macOS sometimes uses parenthesized format: "auto (2).lock"
|
|
347
|
+
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
348
|
+
writeFileSync(join(gsdDir, "auto (2).lock"), '{"pid":2}');
|
|
349
|
+
|
|
350
|
+
cleanupStrayLockFiles(dir);
|
|
351
|
+
|
|
352
|
+
assert.ok(existsSync(join(gsdDir, "auto.lock")), "canonical auto.lock should be preserved");
|
|
353
|
+
assert.ok(!existsSync(join(gsdDir, "auto (2).lock")), "auto (2).lock should be removed");
|
|
354
|
+
|
|
355
|
+
rmSync(dir, { recursive: true, force: true });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("cleanupStrayLockFiles does not remove unrelated files", () => {
|
|
359
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
360
|
+
const gsdDir = join(dir, ".gsd");
|
|
361
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
362
|
+
|
|
363
|
+
// Create unrelated files that should NOT be removed
|
|
364
|
+
writeFileSync(join(gsdDir, "auto.lock"), '{"pid":1}');
|
|
365
|
+
writeFileSync(join(gsdDir, "config.json"), '{}');
|
|
366
|
+
writeFileSync(join(gsdDir, "other.lock"), '{}');
|
|
367
|
+
|
|
368
|
+
cleanupStrayLockFiles(dir);
|
|
369
|
+
|
|
370
|
+
assert.ok(existsSync(join(gsdDir, "auto.lock")), "auto.lock should be preserved");
|
|
371
|
+
assert.ok(existsSync(join(gsdDir, "config.json")), "config.json should be preserved");
|
|
372
|
+
assert.ok(existsSync(join(gsdDir, "other.lock")), "other.lock should be preserved");
|
|
373
|
+
|
|
374
|
+
rmSync(dir, { recursive: true, force: true });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("cleanupStrayLockFiles is safe on empty directory", () => {
|
|
378
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
379
|
+
const gsdDir = join(dir, ".gsd");
|
|
380
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
381
|
+
|
|
382
|
+
// Should not throw
|
|
383
|
+
cleanupStrayLockFiles(dir);
|
|
384
|
+
|
|
385
|
+
rmSync(dir, { recursive: true, force: true });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("cleanupStrayLockFiles is safe when .gsd/ does not exist", () => {
|
|
389
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
390
|
+
|
|
391
|
+
// Should not throw even without .gsd/
|
|
392
|
+
cleanupStrayLockFiles(dir);
|
|
393
|
+
|
|
394
|
+
rmSync(dir, { recursive: true, force: true });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("acquireSessionLock cleans stray lock files before acquiring", () => {
|
|
398
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
399
|
+
const gsdDir = join(dir, ".gsd");
|
|
400
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
401
|
+
|
|
402
|
+
// Plant stray lock files before acquire
|
|
403
|
+
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
|
|
404
|
+
writeFileSync(join(gsdDir, "auto 3.lock"), '{"pid":9999998}');
|
|
405
|
+
|
|
406
|
+
const result = acquireSessionLock(dir);
|
|
407
|
+
assert.equal(result.acquired, true, "should acquire lock");
|
|
408
|
+
|
|
409
|
+
// Stray files should be cleaned up
|
|
410
|
+
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during acquire");
|
|
411
|
+
assert.ok(!existsSync(join(gsdDir, "auto 3.lock")), "auto 3.lock should be removed during acquire");
|
|
412
|
+
|
|
413
|
+
releaseSessionLock(dir);
|
|
414
|
+
rmSync(dir, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("releaseSessionLock cleans stray lock files after releasing", () => {
|
|
418
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-session-lock-"));
|
|
419
|
+
const gsdDir = join(dir, ".gsd");
|
|
420
|
+
mkdirSync(gsdDir, { recursive: true });
|
|
421
|
+
|
|
422
|
+
const result = acquireSessionLock(dir);
|
|
423
|
+
assert.equal(result.acquired, true);
|
|
424
|
+
|
|
425
|
+
// Plant stray lock files (simulating cloud sync creating them during session)
|
|
426
|
+
writeFileSync(join(gsdDir, "auto 2.lock"), '{"pid":9999999}');
|
|
427
|
+
|
|
428
|
+
releaseSessionLock(dir);
|
|
429
|
+
|
|
430
|
+
assert.ok(!existsSync(join(gsdDir, "auto 2.lock")), "auto 2.lock should be removed during release");
|
|
431
|
+
assert.ok(!existsSync(join(gsdDir, "auto.lock")), "auto.lock should also be removed");
|
|
432
|
+
|
|
433
|
+
rmSync(dir, { recursive: true, force: true });
|
|
434
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-sync-milestones.test.ts — Regression test for #1311.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that syncGsdStateToWorktree copies missing milestones,
|
|
5
|
+
* milestone files, and slice directories from the main repo's .gsd/
|
|
6
|
+
* into the worktree's .gsd/.
|
|
7
|
+
*
|
|
8
|
+
* Covers:
|
|
9
|
+
* - Entirely missing milestone directory
|
|
10
|
+
* - Milestone exists but missing CONTEXT/ROADMAP files
|
|
11
|
+
* - Missing slices within an existing milestone
|
|
12
|
+
* - No-op when directories are identical (symlinked)
|
|
13
|
+
* - Root-level files (DECISIONS, REQUIREMENTS, etc.)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, symlinkSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
import { syncGsdStateToWorktree } from '../auto-worktree.ts';
|
|
21
|
+
import { createTestContext } from './test-helpers.ts';
|
|
22
|
+
|
|
23
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
24
|
+
|
|
25
|
+
function createBase(name: string): string {
|
|
26
|
+
const base = mkdtempSync(join(tmpdir(), `gsd-wt-sync-${name}-`));
|
|
27
|
+
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanup(base: string): void {
|
|
32
|
+
rmSync(base, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main(): Promise<void> {
|
|
36
|
+
|
|
37
|
+
// ─── 1. Missing milestone directory is synced ─────────────────────────
|
|
38
|
+
console.log('\n=== 1. missing milestone directory is copied from main ===');
|
|
39
|
+
{
|
|
40
|
+
const mainBase = createBase('main');
|
|
41
|
+
const wtBase = createBase('wt');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// Main repo has M001 and M002
|
|
45
|
+
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
|
|
46
|
+
mkdirSync(m001Dir, { recursive: true });
|
|
47
|
+
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001\nDone.');
|
|
48
|
+
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
|
|
49
|
+
|
|
50
|
+
const m002Dir = join(mainBase, '.gsd', 'milestones', 'M002');
|
|
51
|
+
mkdirSync(m002Dir, { recursive: true });
|
|
52
|
+
writeFileSync(join(m002Dir, 'M002-CONTEXT.md'), '# M002\nNew milestone.');
|
|
53
|
+
writeFileSync(join(m002Dir, 'M002-ROADMAP.md'), '# Roadmap');
|
|
54
|
+
|
|
55
|
+
// Worktree only has M001
|
|
56
|
+
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
|
|
57
|
+
mkdirSync(wtM001Dir, { recursive: true });
|
|
58
|
+
writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001\nDone.');
|
|
59
|
+
|
|
60
|
+
// M002 is missing from worktree
|
|
61
|
+
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), 'M002 missing before sync');
|
|
62
|
+
|
|
63
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
64
|
+
|
|
65
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002')), '#1311: M002 synced to worktree');
|
|
66
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-CONTEXT.md')), 'M002 CONTEXT synced');
|
|
67
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M002', 'M002-ROADMAP.md')), 'M002 ROADMAP synced');
|
|
68
|
+
assertTrue(result.synced.length > 0, 'sync reported files');
|
|
69
|
+
} finally {
|
|
70
|
+
cleanup(mainBase);
|
|
71
|
+
cleanup(wtBase);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── 2. Missing files within existing milestone ───────────────────────
|
|
76
|
+
console.log('\n=== 2. missing files within existing milestone are synced ===');
|
|
77
|
+
{
|
|
78
|
+
const mainBase = createBase('main');
|
|
79
|
+
const wtBase = createBase('wt');
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Main repo M001 has CONTEXT, ROADMAP, RESEARCH
|
|
83
|
+
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
|
|
84
|
+
mkdirSync(m001Dir, { recursive: true });
|
|
85
|
+
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001 Context');
|
|
86
|
+
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# M001 Roadmap');
|
|
87
|
+
writeFileSync(join(m001Dir, 'M001-RESEARCH.md'), '# M001 Research');
|
|
88
|
+
|
|
89
|
+
// Worktree M001 only has CONTEXT (stale snapshot)
|
|
90
|
+
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
|
|
91
|
+
mkdirSync(wtM001Dir, { recursive: true });
|
|
92
|
+
writeFileSync(join(wtM001Dir, 'M001-CONTEXT.md'), '# M001 Context');
|
|
93
|
+
|
|
94
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
95
|
+
|
|
96
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'ROADMAP synced');
|
|
97
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-RESEARCH.md')), 'RESEARCH synced');
|
|
98
|
+
// Existing file should NOT be overwritten
|
|
99
|
+
assertEq(
|
|
100
|
+
readFileSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md'), 'utf-8'),
|
|
101
|
+
'# M001 Context',
|
|
102
|
+
'existing CONTEXT not overwritten',
|
|
103
|
+
);
|
|
104
|
+
} finally {
|
|
105
|
+
cleanup(mainBase);
|
|
106
|
+
cleanup(wtBase);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── 3. Missing slices directory synced ───────────────────────────────
|
|
111
|
+
console.log('\n=== 3. missing slices directory synced ===');
|
|
112
|
+
{
|
|
113
|
+
const mainBase = createBase('main');
|
|
114
|
+
const wtBase = createBase('wt');
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Main repo has M001 with slices S01–S03
|
|
118
|
+
const m001Dir = join(mainBase, '.gsd', 'milestones', 'M001');
|
|
119
|
+
mkdirSync(join(m001Dir, 'slices', 'S01'), { recursive: true });
|
|
120
|
+
mkdirSync(join(m001Dir, 'slices', 'S02'), { recursive: true });
|
|
121
|
+
mkdirSync(join(m001Dir, 'slices', 'S03'), { recursive: true });
|
|
122
|
+
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
|
|
123
|
+
writeFileSync(join(m001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan');
|
|
124
|
+
writeFileSync(join(m001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan');
|
|
125
|
+
writeFileSync(join(m001Dir, 'slices', 'S03', 'S03-PLAN.md'), '# S03 Plan');
|
|
126
|
+
|
|
127
|
+
// Worktree M001 has slices S01–S02 only (S03 missing)
|
|
128
|
+
const wtM001Dir = join(wtBase, '.gsd', 'milestones', 'M001');
|
|
129
|
+
mkdirSync(join(wtM001Dir, 'slices', 'S01'), { recursive: true });
|
|
130
|
+
mkdirSync(join(wtM001Dir, 'slices', 'S02'), { recursive: true });
|
|
131
|
+
writeFileSync(join(wtM001Dir, 'M001-ROADMAP.md'), '# Roadmap');
|
|
132
|
+
writeFileSync(join(wtM001Dir, 'slices', 'S01', 'S01-PLAN.md'), '# S01 Plan');
|
|
133
|
+
writeFileSync(join(wtM001Dir, 'slices', 'S02', 'S02-PLAN.md'), '# S02 Plan');
|
|
134
|
+
|
|
135
|
+
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), 'S03 missing before sync');
|
|
136
|
+
|
|
137
|
+
syncGsdStateToWorktree(mainBase, wtBase);
|
|
138
|
+
|
|
139
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03')), '#1311: S03 synced');
|
|
140
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S03', 'S03-PLAN.md')), 'S03 PLAN synced');
|
|
141
|
+
} finally {
|
|
142
|
+
cleanup(mainBase);
|
|
143
|
+
cleanup(wtBase);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── 4. No-op when both resolve to same directory (symlink) ───────────
|
|
148
|
+
console.log('\n=== 4. no-op when .gsd/ resolves to same path (symlinked) ===');
|
|
149
|
+
{
|
|
150
|
+
const sharedDir = createBase('shared');
|
|
151
|
+
const mainBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-main-'));
|
|
152
|
+
const wtBase = mkdtempSync(join(tmpdir(), 'gsd-wt-sync-wt-'));
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Both main and worktree symlink to the same shared directory
|
|
156
|
+
writeFileSync(join(sharedDir, '.gsd', 'milestones', 'keep'), '');
|
|
157
|
+
symlinkSync(join(sharedDir, '.gsd'), join(mainBase, '.gsd'));
|
|
158
|
+
symlinkSync(join(sharedDir, '.gsd'), join(wtBase, '.gsd'));
|
|
159
|
+
|
|
160
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
161
|
+
assertEq(result.synced.length, 0, 'no files synced when both point to same dir');
|
|
162
|
+
} finally {
|
|
163
|
+
cleanup(sharedDir);
|
|
164
|
+
rmSync(mainBase, { recursive: true, force: true });
|
|
165
|
+
rmSync(wtBase, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── 5. Root-level .gsd/ files synced ─────────────────────────────────
|
|
170
|
+
console.log('\n=== 5. root-level .gsd/ files synced ===');
|
|
171
|
+
{
|
|
172
|
+
const mainBase = createBase('main');
|
|
173
|
+
const wtBase = createBase('wt');
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
writeFileSync(join(mainBase, '.gsd', 'DECISIONS.md'), '# Decisions');
|
|
177
|
+
writeFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), '# Requirements');
|
|
178
|
+
writeFileSync(join(mainBase, '.gsd', 'PROJECT.md'), '# Project');
|
|
179
|
+
|
|
180
|
+
// Worktree has none of these
|
|
181
|
+
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
|
182
|
+
|
|
183
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'DECISIONS.md')), 'DECISIONS.md synced');
|
|
184
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'REQUIREMENTS.md')), 'REQUIREMENTS.md synced');
|
|
185
|
+
assertTrue(existsSync(join(wtBase, '.gsd', 'PROJECT.md')), 'PROJECT.md synced');
|
|
186
|
+
assertTrue(result.synced.length >= 3, 'at least 3 files synced');
|
|
187
|
+
} finally {
|
|
188
|
+
cleanup(mainBase);
|
|
189
|
+
cleanup(wtBase);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── 6. Non-existent directories handled gracefully ───────────────────
|
|
194
|
+
console.log('\n=== 6. non-existent directories → no-op ===');
|
|
195
|
+
{
|
|
196
|
+
const result = syncGsdStateToWorktree('/tmp/does-not-exist-main', '/tmp/does-not-exist-wt');
|
|
197
|
+
assertEq(result.synced.length, 0, 'no crash on missing directories');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
report();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch((error) => {
|
|
204
|
+
console.error(error);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
@@ -9,6 +9,7 @@ import { deriveState } from "./state.js";
|
|
|
9
9
|
import { invalidateAllCaches } from "./cache.js";
|
|
10
10
|
import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js";
|
|
11
11
|
import { sendDesktopNotification } from "./notifications.js";
|
|
12
|
+
import { parseUnitId } from "./unit-id.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Undo the last completed unit: revert git commits, remove from completed-units,
|
|
@@ -62,11 +63,10 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
|
|
|
62
63
|
writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8");
|
|
63
64
|
|
|
64
65
|
// 3. Delete summary artifact
|
|
65
|
-
const
|
|
66
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
66
67
|
let summaryRemoved = false;
|
|
67
|
-
if (
|
|
68
|
+
if (mid && sid && tid) {
|
|
68
69
|
// Task-level: M001/S01/T01
|
|
69
|
-
const [mid, sid, tid] = parts;
|
|
70
70
|
const tasksDir = resolveTasksDir(basePath, mid, sid);
|
|
71
71
|
if (tasksDir) {
|
|
72
72
|
const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY"));
|
|
@@ -75,9 +75,8 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
|
|
|
75
75
|
summaryRemoved = true;
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
-
} else if (
|
|
78
|
+
} else if (mid && sid) {
|
|
79
79
|
// Slice-level: M001/S01
|
|
80
|
-
const [mid, sid] = parts;
|
|
81
80
|
const slicePath = resolveSlicePath(basePath, mid, sid);
|
|
82
81
|
if (slicePath) {
|
|
83
82
|
// Try common summary filenames
|
|
@@ -93,8 +92,7 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
|
|
|
93
92
|
|
|
94
93
|
// 4. Uncheck task in PLAN if execute-task
|
|
95
94
|
let planUpdated = false;
|
|
96
|
-
if (unitType === "execute-task" &&
|
|
97
|
-
const [mid, sid, tid] = parts;
|
|
95
|
+
if (unitType === "execute-task" && mid && sid && tid) {
|
|
98
96
|
planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid);
|
|
99
97
|
}
|
|
100
98
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// GSD Extension — Unit ID Parsing
|
|
2
|
+
// Centralizes the milestone/slice/task decomposition of unit ID strings.
|
|
3
|
+
|
|
4
|
+
export interface ParsedUnitId {
|
|
5
|
+
milestone: string;
|
|
6
|
+
slice?: string;
|
|
7
|
+
task?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Parse a unit ID string (e.g. "M1/S1/T1") into its milestone, slice, and task components. */
|
|
11
|
+
export function parseUnitId(unitId: string): ParsedUnitId {
|
|
12
|
+
const [milestone, slice, task] = unitId.split("/");
|
|
13
|
+
return { milestone: milestone!, slice, task };
|
|
14
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "./paths.js";
|
|
10
10
|
import { loadFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js";
|
|
11
11
|
import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
|
|
12
|
+
import { parseUnitId } from "./unit-id.js";
|
|
12
13
|
|
|
13
14
|
export type UnitRuntimePhase =
|
|
14
15
|
| "dispatched"
|
|
@@ -131,7 +132,7 @@ export async function inspectExecuteTaskDurability(
|
|
|
131
132
|
basePath: string,
|
|
132
133
|
unitId: string,
|
|
133
134
|
): Promise<ExecuteTaskRecoveryStatus | null> {
|
|
134
|
-
const
|
|
135
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
135
136
|
if (!mid || !sid || !tid) return null;
|
|
136
137
|
|
|
137
138
|
const planAbs = resolveSliceFile(basePath, mid, sid, "PLAN");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gsd-pi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.33.0-dev.69bff0f",
|
|
4
4
|
"description": "GSD — Get Shit Done coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -78,7 +78,8 @@
|
|
|
78
78
|
"release:update-changelog": "node scripts/update-changelog.mjs",
|
|
79
79
|
"docker:build-runtime": "docker build --target runtime -t ghcr.io/gsd-build/gsd-pi .",
|
|
80
80
|
"docker:build-builder": "docker build --target builder -t ghcr.io/gsd-build/gsd-ci-builder .",
|
|
81
|
-
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && ([ \"$CI\" = 'true' ] || git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1)) && npm run build && npm run typecheck:extensions && npm run validate-pack"
|
|
81
|
+
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && ([ \"$CI\" = 'true' ] || git diff --exit-code || (echo 'ERROR: version sync changed files — commit them before publishing' && exit 1)) && npm run build && npm run typecheck:extensions && npm run validate-pack",
|
|
82
|
+
"test:live-regression": "node --experimental-strip-types tests/live-regression/run.ts"
|
|
82
83
|
},
|
|
83
84
|
"dependencies": {
|
|
84
85
|
"@anthropic-ai/sdk": "^0.73.0",
|
package/pkg/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import { parseRoadmap, parsePlan } from "./files.js";
|
|
|
20
20
|
import { readFileSync, existsSync } from "node:fs";
|
|
21
21
|
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
|
|
22
22
|
import { makeUI, GLYPH, INDENT } from "../shared/mod.js";
|
|
23
|
+
import { parseUnitId } from "./unit-id.js";
|
|
23
24
|
|
|
24
25
|
// ─── Dashboard Data ───────────────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -372,8 +373,9 @@ export function updateProgressWidget(
|
|
|
372
373
|
lines.push("");
|
|
373
374
|
|
|
374
375
|
const isHook = unitType.startsWith("hook/");
|
|
376
|
+
const hookParsed = isHook ? parseUnitId(unitId) : undefined;
|
|
375
377
|
const target = isHook
|
|
376
|
-
? (
|
|
378
|
+
? (hookParsed!.task ?? hookParsed!.slice ?? unitId)
|
|
377
379
|
: (task ? `${task.id}: ${task.title}` : unitId);
|
|
378
380
|
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
|
|
379
381
|
const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
|
|
@@ -65,6 +65,28 @@ export function resetRewriteCircuitBreaker(): void {
|
|
|
65
65
|
rewriteAttemptCount = 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Guard for accessing activeSlice/activeTask in dispatch rules.
|
|
70
|
+
* Returns a stop action if the expected ref is null (corrupt state).
|
|
71
|
+
*/
|
|
72
|
+
function requireSlice(state: GSDState): { sid: string; sTitle: string } | DispatchAction {
|
|
73
|
+
if (!state.activeSlice) {
|
|
74
|
+
return { action: "stop", reason: `Phase "${state.phase}" but no active slice — run /gsd doctor.`, level: "error" };
|
|
75
|
+
}
|
|
76
|
+
return { sid: state.activeSlice.id, sTitle: state.activeSlice.title };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function requireTask(state: GSDState): { sid: string; sTitle: string; tid: string; tTitle: string } | DispatchAction {
|
|
80
|
+
if (!state.activeSlice || !state.activeTask) {
|
|
81
|
+
return { action: "stop", reason: `Phase "${state.phase}" but no active slice/task — run /gsd doctor.`, level: "error" };
|
|
82
|
+
}
|
|
83
|
+
return { sid: state.activeSlice.id, sTitle: state.activeSlice.title, tid: state.activeTask.id, tTitle: state.activeTask.title };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isStopAction(v: unknown): v is DispatchAction {
|
|
87
|
+
return typeof v === "object" && v !== null && "action" in v;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
// ─── Rules ────────────────────────────────────────────────────────────────
|
|
69
91
|
|
|
70
92
|
const DISPATCH_RULES: DispatchRule[] = [
|
|
@@ -93,8 +115,9 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
93
115
|
name: "summarizing → complete-slice",
|
|
94
116
|
match: async ({ state, mid, midTitle, basePath }) => {
|
|
95
117
|
if (state.phase !== "summarizing") return null;
|
|
96
|
-
const
|
|
97
|
-
|
|
118
|
+
const sliceRef = requireSlice(state);
|
|
119
|
+
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
|
|
120
|
+
const { sid, sTitle } = sliceRef;
|
|
98
121
|
return {
|
|
99
122
|
action: "dispatch",
|
|
100
123
|
unitType: "complete-slice",
|
|
@@ -222,8 +245,9 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
222
245
|
if (state.phase !== "planning") return null;
|
|
223
246
|
// Phase skip: skip research when preference or profile says so
|
|
224
247
|
if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null;
|
|
225
|
-
const
|
|
226
|
-
|
|
248
|
+
const sliceRef = requireSlice(state);
|
|
249
|
+
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
|
|
250
|
+
const { sid, sTitle } = sliceRef;
|
|
227
251
|
const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
|
|
228
252
|
if (researchFile) return null; // has research, fall through
|
|
229
253
|
// Skip slice research for S01 when milestone research already exists —
|
|
@@ -242,8 +266,9 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
242
266
|
name: "planning → plan-slice",
|
|
243
267
|
match: async ({ state, mid, midTitle, basePath }) => {
|
|
244
268
|
if (state.phase !== "planning") return null;
|
|
245
|
-
const
|
|
246
|
-
|
|
269
|
+
const sliceRef = requireSlice(state);
|
|
270
|
+
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
|
|
271
|
+
const { sid, sTitle } = sliceRef;
|
|
247
272
|
return {
|
|
248
273
|
action: "dispatch",
|
|
249
274
|
unitType: "plan-slice",
|
|
@@ -256,8 +281,9 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
256
281
|
name: "replanning-slice → replan-slice",
|
|
257
282
|
match: async ({ state, mid, midTitle, basePath }) => {
|
|
258
283
|
if (state.phase !== "replanning-slice") return null;
|
|
259
|
-
const
|
|
260
|
-
|
|
284
|
+
const sliceRef = requireSlice(state);
|
|
285
|
+
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
|
|
286
|
+
const { sid, sTitle } = sliceRef;
|
|
261
287
|
return {
|
|
262
288
|
action: "dispatch",
|
|
263
289
|
unitType: "replan-slice",
|
|
@@ -270,8 +296,9 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
270
296
|
name: "executing → execute-task (recover missing task plan → plan-slice)",
|
|
271
297
|
match: async ({ state, mid, midTitle, basePath }) => {
|
|
272
298
|
if (state.phase !== "executing" || !state.activeTask) return null;
|
|
273
|
-
const
|
|
274
|
-
|
|
299
|
+
const sliceRef = requireSlice(state);
|
|
300
|
+
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
|
|
301
|
+
const { sid, sTitle } = sliceRef;
|
|
275
302
|
const tid = state.activeTask.id;
|
|
276
303
|
|
|
277
304
|
// Guard: if the slice plan exists but the individual task plan files are
|
|
@@ -296,8 +323,9 @@ const DISPATCH_RULES: DispatchRule[] = [
|
|
|
296
323
|
name: "executing → execute-task",
|
|
297
324
|
match: async ({ state, mid, basePath }) => {
|
|
298
325
|
if (state.phase !== "executing" || !state.activeTask) return null;
|
|
299
|
-
const
|
|
300
|
-
|
|
326
|
+
const sliceRef = requireSlice(state);
|
|
327
|
+
if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
|
|
328
|
+
const { sid, sTitle } = sliceRef;
|
|
301
329
|
const tid = state.activeTask.id;
|
|
302
330
|
const tTitle = state.activeTask.title;
|
|
303
331
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { resolveMilestoneFile } from "./paths.js";
|
|
19
19
|
import { MAX_CONSECUTIVE_SKIPS, MAX_LIFETIME_DISPATCHES } from "./auto/session.js";
|
|
20
20
|
import type { AutoSession } from "./auto/session.js";
|
|
21
|
+
import { parseUnitId } from "./unit-id.js";
|
|
21
22
|
|
|
22
23
|
export interface IdempotencyContext {
|
|
23
24
|
s: AutoSession;
|
|
@@ -54,7 +55,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
|
|
|
54
55
|
s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
|
|
55
56
|
if (skipCount > MAX_CONSECUTIVE_SKIPS) {
|
|
56
57
|
// Cross-check: verify the unit's milestone is still active (#790)
|
|
57
|
-
const skippedMid = unitId.
|
|
58
|
+
const skippedMid = parseUnitId(unitId).milestone;
|
|
58
59
|
const skippedMilestoneComplete = skippedMid
|
|
59
60
|
? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
|
|
60
61
|
: false;
|
|
@@ -110,7 +111,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
|
|
|
110
111
|
const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
|
|
111
112
|
s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
|
|
112
113
|
if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
|
|
113
|
-
const skippedMid2 = unitId.
|
|
114
|
+
const skippedMid2 = parseUnitId(unitId).milestone;
|
|
114
115
|
const skippedMilestoneComplete2 = skippedMid2
|
|
115
116
|
? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
|
|
116
117
|
: false;
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
formatValidationIssues,
|
|
13
13
|
} from "./observability-validator.js";
|
|
14
14
|
import type { ValidationIssue } from "./observability-validator.js";
|
|
15
|
+
import { parseUnitId } from "./unit-id.js";
|
|
15
16
|
|
|
16
17
|
export async function collectObservabilityWarnings(
|
|
17
18
|
ctx: ExtensionContext,
|
|
@@ -22,10 +23,7 @@ export async function collectObservabilityWarnings(
|
|
|
22
23
|
// Hook units have custom artifacts — skip standard observability checks
|
|
23
24
|
if (unitType.startsWith("hook/")) return [];
|
|
24
25
|
|
|
25
|
-
const
|
|
26
|
-
const mid = parts[0];
|
|
27
|
-
const sid = parts[1];
|
|
28
|
-
const tid = parts[2];
|
|
26
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
|
29
27
|
|
|
30
28
|
if (!mid || !sid) return [];
|
|
31
29
|
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
} from "./auto-dashboard.js";
|
|
62
62
|
import { join } from "node:path";
|
|
63
63
|
import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js";
|
|
64
|
+
import { parseUnitId } from "./unit-id.js";
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
67
|
* Initialize a unit dispatch: stamp the current time, set `s.currentUnit`,
|
|
@@ -134,8 +135,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
|
|
|
134
135
|
let taskContext: TaskCommitContext | undefined;
|
|
135
136
|
|
|
136
137
|
if (s.currentUnit.type === "execute-task") {
|
|
137
|
-
const
|
|
138
|
-
const [mid, sid, tid] = parts;
|
|
138
|
+
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
|
|
139
139
|
if (mid && sid && tid) {
|
|
140
140
|
const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
|
|
141
141
|
if (summaryPath) {
|
|
@@ -167,8 +167,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
|
|
|
167
167
|
|
|
168
168
|
// Doctor: fix mechanical bookkeeping
|
|
169
169
|
try {
|
|
170
|
-
const
|
|
171
|
-
const doctorScope =
|
|
170
|
+
const { milestone, slice } = parseUnitId(s.currentUnit.id);
|
|
171
|
+
const doctorScope = slice ? `${milestone}/${slice}` : milestone;
|
|
172
172
|
const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]);
|
|
173
173
|
const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const;
|
|
174
174
|
const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel });
|
|
@@ -348,7 +348,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
|
|
|
348
348
|
// instead of dispatching LLM sessions for complete-slice / validate-milestone.
|
|
349
349
|
if (s.currentUnit?.type === "execute-task" && !s.stepMode) {
|
|
350
350
|
try {
|
|
351
|
-
const
|
|
351
|
+
const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id);
|
|
352
352
|
if (mid && sid) {
|
|
353
353
|
const state = await deriveState(s.basePath);
|
|
354
354
|
if (state.phase === "summarizing" && state.activeSlice?.id === sid) {
|