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.
Files changed (66) hide show
  1. package/README.md +13 -18
  2. package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
  3. package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
  4. package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
  5. package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
  6. package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
  7. package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
  8. package/dist/resources/extensions/gsd/auto-start.ts +2 -1
  9. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  10. package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
  11. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  12. package/dist/resources/extensions/gsd/auto-verification.ts +4 -5
  13. package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
  14. package/dist/resources/extensions/gsd/auto.ts +89 -164
  15. package/dist/resources/extensions/gsd/commands.ts +14 -2
  16. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  17. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  18. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  19. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  20. package/dist/resources/extensions/gsd/session-lock.ts +80 -16
  21. package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  22. package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  23. package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  24. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
  25. package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  26. package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  27. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  28. package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  29. package/dist/resources/extensions/gsd/undo.ts +5 -7
  30. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  31. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  32. package/package.json +3 -2
  33. package/packages/pi-coding-agent/package.json +1 -1
  34. package/pkg/package.json +1 -1
  35. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
  36. package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
  37. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  38. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  39. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  40. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  41. package/src/resources/extensions/gsd/auto-start.ts +2 -1
  42. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  43. package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
  44. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  45. package/src/resources/extensions/gsd/auto-verification.ts +4 -5
  46. package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
  47. package/src/resources/extensions/gsd/auto.ts +89 -164
  48. package/src/resources/extensions/gsd/commands.ts +14 -2
  49. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  50. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  51. package/src/resources/extensions/gsd/metrics.ts +3 -3
  52. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  53. package/src/resources/extensions/gsd/session-lock.ts +80 -16
  54. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  55. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  56. package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  57. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
  58. package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  59. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  60. package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  61. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  62. package/src/resources/extensions/gsd/undo.ts +5 -7
  63. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  64. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  65. package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
  66. 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 parts = unitId.split("/");
66
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
66
67
  let summaryRemoved = false;
67
- if (parts.length === 3) {
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 (parts.length === 2) {
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" && parts.length === 3) {
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 [mid, sid, tid] = unitId.split("/");
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");
@@ -1,12 +0,0 @@
1
- {
2
- "id": "mcporter",
3
- "name": "MCPorter",
4
- "version": "1.0.0",
5
- "description": "Discover and call tools from MCP servers configured in Claude Desktop, Cursor, and VS Code",
6
- "tier": "bundled",
7
- "requires": { "platform": ">=2.29.0" },
8
- "provides": {
9
- "tools": ["mcp_servers", "mcp_discover", "mcp_call"],
10
- "hooks": ["session_start"]
11
- }
12
- }
@@ -1,12 +0,0 @@
1
- {
2
- "id": "mcporter",
3
- "name": "MCPorter",
4
- "version": "1.0.0",
5
- "description": "Discover and call tools from MCP servers configured in Claude Desktop, Cursor, and VS Code",
6
- "tier": "bundled",
7
- "requires": { "platform": ">=2.29.0" },
8
- "provides": {
9
- "tools": ["mcp_servers", "mcp_discover", "mcp_call"],
10
- "hooks": ["session_start"]
11
- }
12
- }