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
@@ -105,6 +105,7 @@ import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.j
105
105
  import { GSDError, GSD_ARTIFACT_MISSING } from "./errors.js";
106
106
  import { join } from "node:path";
107
107
  import { sep as pathSep } from "node:path";
108
+ import { parseUnitId } from "./unit-id.js";
108
109
  import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
109
110
  import { atomicWriteSync } from "./atomic-write.js";
110
111
  import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js";
@@ -214,52 +215,7 @@ export function shouldUseWorktreeIsolation(): boolean {
214
215
  return true; // default: worktree
215
216
  }
216
217
 
217
- /** Crash recovery prompt set by startAuto, consumed by first dispatchNextUnit */
218
-
219
- /** Pending verification retry — set when gate fails with retries remaining, consumed by dispatchNextUnit */
220
-
221
- /** Verification retry count per unitId — separate from s.unitDispatchCount which tracks artifact-missing retries */
222
-
223
- /** Session file path captured at pause — used to synthesize recovery briefing on resume */
224
-
225
- /** Dashboard tracking */
226
-
227
- /** Track dynamic routing decision for the current unit (for metrics) */
228
-
229
- /** Queue of quick-task captures awaiting dispatch after triage resolution */
230
-
231
- /**
232
- * Model captured at auto-mode start. Used to prevent model bleed between
233
- * concurrent GSD instances sharing the same global settings.json (#650).
234
- * When preferences don't specify a model for a unit type, this ensures
235
- * the session's original model is re-applied instead of reading from
236
- * the shared global settings (which another instance may have overwritten).
237
- */
238
-
239
- /** Track current milestone to detect transitions */
240
-
241
- /** Model the user had selected before auto-mode started */
242
-
243
- /** Progress-aware timeout supervision */
244
-
245
- /** Context-pressure continue-here monitor — fires once when context usage >= 70% */
246
-
247
- /** Dispatch gap watchdog — detects when the state machine stalls between units.
248
- * After handleAgentEnd completes, if auto-mode is still active but no new unit
249
- * has been dispatched (sendMessage not called), this timer fires to force a
250
- * re-evaluation. Covers the case where dispatchNextUnit silently fails or
251
- * an unhandled error kills the dispatch chain. */
252
-
253
- /** Prompt character measurement for token savings analysis (R051). */
254
-
255
- /** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
256
-
257
- /**
258
- * Tool calls currently being executed — prevents false idle detection during long-running tools.
259
- * Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been
260
- * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open).
261
- */
262
-
218
+ // All mutable state lives in AutoSession (auto/session.ts) see encapsulation invariant above.
263
219
  /** Wrapper: register SIGTERM handler and store reference. */
264
220
  function registerSigtermHandler(currentBasePath: string): void {
265
221
  s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
@@ -405,6 +361,79 @@ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFire
405
361
  };
406
362
  }
407
363
 
364
+ // ─── Extracted Merge Helper ───────────────────────────────────────────────
365
+
366
+ /**
367
+ * Attempt to merge the current milestone branch to main.
368
+ * Handles both worktree and branch isolation modes with a single code path.
369
+ * Returns true if merge succeeded, false on error (non-fatal, logged).
370
+ *
371
+ * Extracted from 4 duplicate merge blocks in dispatchNextUnit to eliminate
372
+ * the bug factory where fixing one copy didn't fix the others (#1308).
373
+ */
374
+ function tryMergeMilestone(ctx: ExtensionContext, milestoneId: string, mode: "transition" | "complete"): boolean {
375
+ const isolationMode = getIsolationMode();
376
+
377
+ // Worktree merge path
378
+ if (isInAutoWorktree(s.basePath) && s.originalBasePath) {
379
+ try {
380
+ const roadmapPath = resolveMilestoneFile(s.originalBasePath, milestoneId, "ROADMAP");
381
+ if (!roadmapPath) {
382
+ teardownAutoWorktree(s.originalBasePath, milestoneId);
383
+ ctx.ui.notify(`Exited worktree for ${milestoneId} (no roadmap for merge).`, "info");
384
+ return false;
385
+ }
386
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
387
+ const mergeResult = mergeMilestoneToMain(s.originalBasePath, milestoneId, roadmapContent);
388
+ s.basePath = s.originalBasePath;
389
+ s.gitService = createGitService(s.basePath);
390
+ ctx.ui.notify(
391
+ `Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
392
+ "info",
393
+ );
394
+ return true;
395
+ } catch (err) {
396
+ ctx.ui.notify(
397
+ `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
398
+ "warning",
399
+ );
400
+ if (s.originalBasePath) {
401
+ s.basePath = s.originalBasePath;
402
+ try { process.chdir(s.basePath); } catch { /* best-effort */ }
403
+ }
404
+ return false;
405
+ }
406
+ }
407
+
408
+ // Branch-mode merge path
409
+ if (isolationMode === "branch") {
410
+ try {
411
+ const currentBranch = getCurrentBranch(s.basePath);
412
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
413
+ if (currentBranch === milestoneBranch) {
414
+ const roadmapPath = resolveMilestoneFile(s.basePath, milestoneId, "ROADMAP");
415
+ if (roadmapPath) {
416
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
417
+ const mergeResult = mergeMilestoneToMain(s.basePath, milestoneId, roadmapContent);
418
+ s.gitService = createGitService(s.basePath);
419
+ ctx.ui.notify(
420
+ `Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
421
+ "info",
422
+ );
423
+ return true;
424
+ }
425
+ }
426
+ } catch (err) {
427
+ ctx.ui.notify(
428
+ `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
429
+ "warning",
430
+ );
431
+ }
432
+ }
433
+
434
+ return false;
435
+ }
436
+
408
437
  /**
409
438
  * Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS
410
439
  * after handleAgentEnd completes. This catches the case where the dispatch chain silently
@@ -1106,32 +1135,14 @@ async function dispatchNextUnit(
1106
1135
  } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
1107
1136
 
1108
1137
  // ── Worktree lifecycle on milestone transition (#616) ──
1109
- if (isInAutoWorktree(s.basePath) && s.originalBasePath && shouldUseWorktreeIsolation()) {
1110
- try {
1111
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1112
- if (roadmapPath) {
1113
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1114
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1115
- ctx.ui.notify(
1116
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1117
- "info",
1118
- );
1119
- } else {
1120
- teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId);
1121
- ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info");
1122
- }
1123
- } catch (err) {
1124
- ctx.ui.notify(
1125
- `Milestone merge failed during transition: ${getErrorMessage(err)}`,
1126
- "warning",
1127
- );
1128
- if (s.originalBasePath) {
1129
- try { process.chdir(s.originalBasePath); } catch { /* best-effort */ }
1130
- }
1131
- }
1138
+ if ((isInAutoWorktree(s.basePath) || getIsolationMode() === "branch") && shouldUseWorktreeIsolation()) {
1139
+ tryMergeMilestone(ctx, s.currentMilestoneId, "transition");
1132
1140
 
1133
- s.basePath = s.originalBasePath;
1134
- s.gitService = createGitService(s.basePath);
1141
+ // Reset to project root and re-derive state for the new milestone
1142
+ if (s.originalBasePath) {
1143
+ s.basePath = s.originalBasePath;
1144
+ s.gitService = createGitService(s.basePath);
1145
+ }
1135
1146
  invalidateAllCaches();
1136
1147
 
1137
1148
  state = await deriveState(s.basePath);
@@ -1176,51 +1187,8 @@ async function dispatchNextUnit(
1176
1187
  const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
1177
1188
  if (incomplete.length === 0) {
1178
1189
  // Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962)
1179
- if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
1180
- try {
1181
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1182
- if (roadmapPath) {
1183
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1184
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1185
- s.basePath = s.originalBasePath;
1186
- s.gitService = createGitService(s.basePath);
1187
- ctx.ui.notify(
1188
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1189
- "info",
1190
- );
1191
- }
1192
- } catch (err) {
1193
- ctx.ui.notify(
1194
- `Milestone merge failed: ${getErrorMessage(err)}`,
1195
- "warning",
1196
- );
1197
- if (s.originalBasePath) {
1198
- s.basePath = s.originalBasePath;
1199
- try { process.chdir(s.basePath); } catch { /* best-effort */ }
1200
- }
1201
- }
1202
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1203
- try {
1204
- const currentBranch = getCurrentBranch(s.basePath);
1205
- const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
1206
- if (currentBranch === milestoneBranch) {
1207
- const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
1208
- if (roadmapPath) {
1209
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1210
- const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1211
- s.gitService = createGitService(s.basePath);
1212
- ctx.ui.notify(
1213
- `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1214
- "info",
1215
- );
1216
- }
1217
- }
1218
- } catch (err) {
1219
- ctx.ui.notify(
1220
- `Milestone merge failed (branch mode): ${getErrorMessage(err)}`,
1221
- "warning",
1222
- );
1223
- }
1190
+ if (s.currentMilestoneId) {
1191
+ tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
1224
1192
  }
1225
1193
  sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
1226
1194
  await stopAuto(ctx, pi, "All milestones complete");
@@ -1279,50 +1247,8 @@ async function dispatchNextUnit(
1279
1247
  s.completedKeySet.clear();
1280
1248
  } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
1281
1249
  // ── Milestone merge ──
1282
- if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
1283
- try {
1284
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1285
- if (!roadmapPath) throw new GSDError(GSD_ARTIFACT_MISSING, `Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
1286
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1287
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1288
- s.basePath = s.originalBasePath;
1289
- s.gitService = createGitService(s.basePath);
1290
- ctx.ui.notify(
1291
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1292
- "info",
1293
- );
1294
- } catch (err) {
1295
- ctx.ui.notify(
1296
- `Milestone merge failed: ${getErrorMessage(err)}`,
1297
- "warning",
1298
- );
1299
- if (s.originalBasePath) {
1300
- s.basePath = s.originalBasePath;
1301
- try { process.chdir(s.basePath); } catch { /* best-effort */ }
1302
- }
1303
- }
1304
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1305
- try {
1306
- const currentBranch = getCurrentBranch(s.basePath);
1307
- const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
1308
- if (currentBranch === milestoneBranch) {
1309
- const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
1310
- if (roadmapPath) {
1311
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1312
- const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1313
- s.gitService = createGitService(s.basePath);
1314
- ctx.ui.notify(
1315
- `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1316
- "info",
1317
- );
1318
- }
1319
- }
1320
- } catch (err) {
1321
- ctx.ui.notify(
1322
- `Milestone merge failed (branch mode): ${getErrorMessage(err)}`,
1323
- "warning",
1324
- );
1325
- }
1250
+ if (s.currentMilestoneId) {
1251
+ tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
1326
1252
  }
1327
1253
  sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
1328
1254
  await stopAuto(ctx, pi, `Milestone ${mid} complete`);
@@ -1748,8 +1674,7 @@ async function dispatchNextUnit(
1748
1674
  function ensurePreconditions(
1749
1675
  unitType: string, unitId: string, base: string, state: GSDState,
1750
1676
  ): void {
1751
- const parts = unitId.split("/");
1752
- const mid = parts[0]!;
1677
+ const { milestone: mid } = parseUnitId(unitId);
1753
1678
 
1754
1679
  const mDir = resolveMilestonePath(base, mid);
1755
1680
  if (!mDir) {
@@ -1757,8 +1682,8 @@ function ensurePreconditions(
1757
1682
  mkdirSync(join(newDir, "slices"), { recursive: true });
1758
1683
  }
1759
1684
 
1760
- if (parts.length >= 2) {
1761
- const sid = parts[1]!;
1685
+ const sid = parseUnitId(unitId).slice;
1686
+ if (sid) {
1762
1687
 
1763
1688
  const mDirResolved = resolveMilestonePath(base, mid);
1764
1689
  if (mDirResolved) {
@@ -52,8 +52,20 @@ import { handleStart, handleTemplates, getTemplateCompletions } from "./commands
52
52
 
53
53
  /** Resolve the effective project root, accounting for worktree paths. */
54
54
  export function projectRoot(): string {
55
- const root = resolveProjectRoot(process.cwd());
56
- assertSafeDirectory(root);
55
+ const cwd = process.cwd();
56
+ const root = resolveProjectRoot(cwd);
57
+
58
+ // When running inside a GSD worktree, the resolved root may be a "dangerous"
59
+ // directory (e.g., $HOME used as a git repo root — #1317). The safety check
60
+ // should validate the actual working directory, not the upstream root,
61
+ // because the worktree itself is a safe project subdirectory.
62
+ // Only skip the root check when we can confirm we're in a valid worktree.
63
+ if (root !== cwd) {
64
+ // We're in a worktree — validate the worktree path instead of the root
65
+ assertSafeDirectory(cwd);
66
+ } else {
67
+ assertSafeDirectory(root);
68
+ }
57
69
  return root;
58
70
  }
59
71
 
@@ -6,6 +6,7 @@ import { existsSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { gsdRoot } from "./paths.js";
8
8
  import { getAdaptiveTierAdjustment } from "./routing-history.js";
9
+ import { parseUnitId } from "./unit-id.js";
9
10
 
10
11
  // ─── Types ───────────────────────────────────────────────────────────────────
11
12
 
@@ -180,15 +181,14 @@ function analyzePlanComplexity(
180
181
  basePath: string,
181
182
  ): TaskAnalysis | null {
182
183
  // Check if this is a milestone-level plan (more complex) vs single slice
183
- const parts = unitId.split("/");
184
- if (parts.length === 1) {
184
+ const { milestone: mid, slice: sid } = parseUnitId(unitId);
185
+ if (!sid) {
185
186
  // Milestone-level planning is always at least standard
186
187
  return { tier: "standard", reason: "milestone-level planning" };
187
188
  }
188
189
 
189
190
  // For slice planning, try to read the context/research to gauge complexity
190
191
  // If research exists and is large, bump to heavy
191
- const [mid, sid] = parts;
192
192
  const researchPath = join(gsdRoot(basePath), mid, "slices", sid, "RESEARCH.md");
193
193
  try {
194
194
  if (existsSync(researchPath)) {
@@ -210,10 +210,8 @@ function analyzePlanComplexity(
210
210
  */
211
211
  function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata {
212
212
  const meta: TaskMetadata = {};
213
- const parts = unitId.split("/");
214
- if (parts.length !== 3) return meta;
215
-
216
- const [mid, sid, tid] = parts;
213
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
214
+ if (!mid || !sid || !tid) return meta;
217
215
  const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
218
216
 
219
217
  try {
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
5
5
  import { resolveMilestoneFile, milestonesDir } from "./paths.js";
6
6
  import { parseRoadmapSlices } from "./roadmap-slices.js";
7
7
  import { findMilestoneIds } from "./guided-flow.js";
8
+ import { parseUnitId } from "./unit-id.js";
8
9
 
9
10
  const SLICE_DISPATCH_TYPES = new Set([
10
11
  "research-slice",
@@ -39,7 +40,7 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null {
39
40
  export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null {
40
41
  if (!SLICE_DISPATCH_TYPES.has(unitType)) return null;
41
42
 
42
- const [targetMid, targetSid] = unitId.split("/");
43
+ const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
43
44
  if (!targetMid || !targetSid) return null;
44
45
 
45
46
  // Use findMilestoneIds to respect custom queue order.
@@ -18,6 +18,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent";
18
18
  import { gsdRoot } from "./paths.js";
19
19
  import { getAndClearSkills } from "./skill-telemetry.js";
20
20
  import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
21
+ import { parseUnitId } from "./unit-id.js";
21
22
 
22
23
  // Re-export from shared — canonical implementation lives in format-utils.
23
24
  export { formatTokenCount } from "../shared/mod.js";
@@ -290,9 +291,8 @@ export function aggregateByPhase(units: UnitMetrics[]): PhaseAggregate[] {
290
291
  export function aggregateBySlice(units: UnitMetrics[]): SliceAggregate[] {
291
292
  const map = new Map<string, SliceAggregate>();
292
293
  for (const u of units) {
293
- const parts = u.id.split("/");
294
- // Slice ID is parts[0]/parts[1] if it exists, else parts[0]
295
- const sliceId = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
294
+ const { milestone, slice } = parseUnitId(u.id);
295
+ const sliceId = slice ? `${milestone}/${slice}` : milestone;
296
296
  let agg = map.get(sliceId);
297
297
  if (!agg) {
298
298
  agg = { sliceId, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 };
@@ -15,6 +15,7 @@ import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js"
15
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { gsdRoot } from "./paths.js";
18
+ import { parseUnitId } from "./unit-id.js";
18
19
 
19
20
  // ─── Hook Queue State ──────────────────────────────────────────────────────
20
21
 
@@ -149,7 +150,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
149
150
  };
150
151
 
151
152
  // Build the prompt with variable substitution
152
- const [mid, sid, tid] = triggerUnitId.split("/");
153
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId);
153
154
  const prompt = config.prompt
154
155
  .replace(/\{milestoneId\}/g, mid ?? "")
155
156
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -208,16 +209,14 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null {
208
209
  * - Milestone-level (M001): .gsd/M001/{artifact}
209
210
  */
210
211
  export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
211
- const parts = unitId.split("/");
212
- if (parts.length === 3) {
213
- const [mid, sid, tid] = parts;
212
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
213
+ if (mid && sid && tid) {
214
214
  return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
215
215
  }
216
- if (parts.length === 2) {
217
- const [mid, sid] = parts;
216
+ if (mid && sid) {
218
217
  return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
219
218
  }
220
- return join(gsdRoot(basePath), parts[0], artifactName);
219
+ return join(gsdRoot(basePath), mid, artifactName);
221
220
  }
222
221
 
223
222
  // ═══════════════════════════════════════════════════════════════════════════
@@ -253,7 +252,7 @@ export function runPreDispatchHooks(
253
252
  return { action: "proceed", prompt, firedHooks: [] };
254
253
  }
255
254
 
256
- const [mid, sid, tid] = unitId.split("/");
255
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
257
256
  const substitute = (text: string): string =>
258
257
  text
259
258
  .replace(/\{milestoneId\}/g, mid ?? "")
@@ -466,7 +465,7 @@ export function triggerHookManually(
466
465
  activeHook.cycle = currentCycle;
467
466
 
468
467
  // Build the prompt with variable substitution
469
- const [mid, sid, tid] = unitId.split("/");
468
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
470
469
  const prompt = hook.prompt
471
470
  .replace(/\{milestoneId\}/g, mid ?? "")
472
471
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { createRequire } from "node:module";
20
- import { existsSync, readFileSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
20
+ import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
21
21
  import { join, dirname } from "node:path";
22
22
  import { gsdRoot } from "./paths.js";
23
23
  import { atomicWriteSync } from "./atomic-write.js";
@@ -54,12 +54,81 @@ let _lockPid: number = 0;
54
54
  /** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
55
55
  let _lockCompromised: boolean = false;
56
56
 
57
+ /** Whether we've already registered a process.on('exit') handler. */
58
+ let _exitHandlerRegistered: boolean = false;
59
+
57
60
  const LOCK_FILE = "auto.lock";
58
61
 
59
62
  function lockPath(basePath: string): string {
60
63
  return join(gsdRoot(basePath), LOCK_FILE);
61
64
  }
62
65
 
66
+ // ─── Stray Lock Cleanup ─────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Remove numbered lock file variants (e.g. "auto 2.lock", "auto 3.lock")
70
+ * that accumulate from macOS file conflict resolution (iCloud/Dropbox/OneDrive)
71
+ * or other filesystem-level copy-on-conflict behavior (#1315).
72
+ *
73
+ * Also removes stray proper-lockfile directories beyond the canonical `.gsd.lock/`.
74
+ */
75
+ export function cleanupStrayLockFiles(basePath: string): void {
76
+ const gsdDir = gsdRoot(basePath);
77
+
78
+ // Clean numbered auto lock files inside .gsd/
79
+ try {
80
+ if (existsSync(gsdDir)) {
81
+ for (const entry of readdirSync(gsdDir)) {
82
+ // Match "auto <N>.lock" or "auto (<N>).lock" variants but NOT the canonical "auto.lock"
83
+ if (entry !== LOCK_FILE && /^auto\s.+\.lock$/i.test(entry)) {
84
+ try { unlinkSync(join(gsdDir, entry)); } catch { /* best-effort */ }
85
+ }
86
+ }
87
+ }
88
+ } catch { /* non-fatal: directory read failure */ }
89
+
90
+ // Clean stray proper-lockfile directories (e.g. ".gsd 2.lock/")
91
+ // The canonical one is ".gsd.lock/" — anything else is stray.
92
+ try {
93
+ const parentDir = dirname(gsdDir);
94
+ const gsdDirName = gsdDir.split("/").pop() || ".gsd";
95
+ if (existsSync(parentDir)) {
96
+ for (const entry of readdirSync(parentDir)) {
97
+ // Match ".gsd <N>.lock" or ".gsd (<N>).lock" directories but NOT ".gsd.lock"
98
+ if (entry !== `${gsdDirName}.lock` && entry.startsWith(gsdDirName) && entry.endsWith(".lock")) {
99
+ const fullPath = join(parentDir, entry);
100
+ try {
101
+ const stat = statSync(fullPath);
102
+ if (stat.isDirectory()) {
103
+ rmSync(fullPath, { recursive: true, force: true });
104
+ }
105
+ } catch { /* best-effort */ }
106
+ }
107
+ }
108
+ }
109
+ } catch { /* non-fatal */ }
110
+ }
111
+
112
+ /**
113
+ * Register a single process exit handler that cleans up lock state.
114
+ * Uses module-level references so it always operates on current state.
115
+ * Only registers once — subsequent calls are no-ops.
116
+ */
117
+ function ensureExitHandler(gsdDir: string): void {
118
+ if (_exitHandlerRegistered) return;
119
+ _exitHandlerRegistered = true;
120
+
121
+ process.once("exit", () => {
122
+ try {
123
+ if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
124
+ } catch { /* best-effort */ }
125
+ try {
126
+ const lockDir = join(gsdDir + ".lock");
127
+ if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true });
128
+ } catch { /* best-effort */ }
129
+ });
130
+ }
131
+
63
132
  // ─── Public API ─────────────────────────────────────────────────────────────
64
133
 
65
134
  /**
@@ -77,6 +146,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
77
146
  // Ensure the directory exists
78
147
  mkdirSync(dirname(lp), { recursive: true });
79
148
 
149
+ // Clean up numbered lock file variants from cloud sync conflicts (#1315)
150
+ cleanupStrayLockFiles(basePath);
151
+
80
152
  // Write our lock data first (the content is informational; the OS lock is the real guard)
81
153
  const lockData: SessionLockData = {
82
154
  pid: process.pid,
@@ -124,15 +196,7 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
124
196
 
125
197
  // Safety net: clean up lock dir on process exit if _releaseFunction
126
198
  // wasn't called (e.g., normal exit after clean completion) (#1245).
127
- const lockDirForCleanup = join(gsdDir + ".lock");
128
- process.once("exit", () => {
129
- try {
130
- if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
131
- } catch { /* best-effort */ }
132
- try {
133
- if (existsSync(lockDirForCleanup)) rmSync(lockDirForCleanup, { recursive: true, force: true });
134
- } catch { /* best-effort */ }
135
- });
199
+ ensureExitHandler(gsdDir);
136
200
 
137
201
  // Write the informational lock data
138
202
  atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
@@ -158,18 +222,15 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
158
222
  update: 10_000,
159
223
  onCompromised: () => {
160
224
  _lockCompromised = true;
225
+ _releaseFunction = null;
161
226
  },
162
227
  });
163
228
  _releaseFunction = release;
164
229
  _lockedPath = basePath;
165
230
  _lockPid = process.pid;
166
231
 
167
- // Safety net for retry path too
168
- const retryLockDir = join(gsdDir + ".lock");
169
- process.once("exit", () => {
170
- try { if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; } } catch {}
171
- try { if (existsSync(retryLockDir)) rmSync(retryLockDir, { recursive: true, force: true }); } catch {}
172
- });
232
+ // Safety net uses centralized handler to avoid double-registration
233
+ ensureExitHandler(gsdDir);
173
234
 
174
235
  atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
175
236
  return { acquired: true };
@@ -310,6 +371,9 @@ export function releaseSessionLock(basePath: string): void {
310
371
  // Non-fatal
311
372
  }
312
373
 
374
+ // Clean up numbered lock file variants from cloud sync conflicts (#1315)
375
+ cleanupStrayLockFiles(basePath);
376
+
313
377
  _lockedPath = null;
314
378
  _lockPid = 0;
315
379
  _lockCompromised = false;
@@ -70,31 +70,34 @@ test("auto.ts 'all milestones complete' path merges before stopping (#962)", ()
70
70
  const incompleteIdx = autoSrc.indexOf("incomplete.length === 0");
71
71
  assert.ok(incompleteIdx > -1, "auto.ts should have 'incomplete.length === 0' check");
72
72
 
73
- // The merge call must appear BETWEEN the incomplete check and the stopAuto call
74
- // in that same block
73
+ // The merge call must appear BETWEEN the incomplete check and the stopAuto call.
74
+ // After the #1308 refactor, the merge is delegated to tryMergeMilestone.
75
75
  const blockAfterIncomplete = autoSrc.slice(incompleteIdx, incompleteIdx + 3000);
76
76
 
77
77
  assert.ok(
78
- blockAfterIncomplete.includes("mergeMilestoneToMain"),
79
- "auto.ts should call mergeMilestoneToMain in the 'all milestones complete' path",
78
+ blockAfterIncomplete.includes("tryMergeMilestone"),
79
+ "auto.ts should call tryMergeMilestone in the 'all milestones complete' path",
80
80
  );
81
81
 
82
82
  // The merge should come before stopAuto in this block
83
- const mergePos = blockAfterIncomplete.indexOf("mergeMilestoneToMain");
83
+ const mergePos = blockAfterIncomplete.indexOf("tryMergeMilestone");
84
84
  const stopPos = blockAfterIncomplete.indexOf("stopAuto");
85
85
  assert.ok(
86
86
  mergePos < stopPos,
87
- "mergeMilestoneToMain should be called before stopAuto in the 'all complete' path",
87
+ "tryMergeMilestone should be called before stopAuto in the 'all complete' path",
88
88
  );
89
89
 
90
- // Should handle both worktree and branch isolation modes
90
+ // Verify tryMergeMilestone handles both worktree and branch isolation
91
+ const helperIdx = autoSrc.indexOf("function tryMergeMilestone");
92
+ assert.ok(helperIdx > -1, "tryMergeMilestone helper should exist");
93
+ const helperBlock = autoSrc.slice(helperIdx, helperIdx + 2000);
91
94
  assert.ok(
92
- blockAfterIncomplete.includes("isInAutoWorktree"),
93
- "should check isInAutoWorktree for worktree mode",
95
+ helperBlock.includes("isInAutoWorktree"),
96
+ "tryMergeMilestone should check isInAutoWorktree for worktree mode",
94
97
  );
95
98
  assert.ok(
96
- blockAfterIncomplete.includes("getIsolationMode"),
97
- "should check getIsolationMode for branch isolation mode",
99
+ helperBlock.includes("getIsolationMode") || helperBlock.includes("isolationMode"),
100
+ "tryMergeMilestone should check isolation mode for branch mode",
98
101
  );
99
102
  });
100
103