gsd-pi 2.32.0-dev.1e39869 → 2.32.0-dev.3d7932c

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 (83) hide show
  1. package/README.md +27 -20
  2. package/dist/resource-loader.js +13 -3
  3. package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
  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-prompts.ts +46 -44
  8. package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
  9. package/dist/resources/extensions/gsd/auto-start.ts +8 -6
  10. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  11. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  12. package/dist/resources/extensions/gsd/auto-timers.ts +3 -2
  13. package/dist/resources/extensions/gsd/auto-verification.ts +6 -6
  14. package/dist/resources/extensions/gsd/auto-worktree.ts +5 -4
  15. package/dist/resources/extensions/gsd/auto.ts +28 -27
  16. package/dist/resources/extensions/gsd/commands-inspect.ts +2 -1
  17. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  18. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  19. package/dist/resources/extensions/gsd/crash-recovery.ts +15 -2
  20. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  21. package/dist/resources/extensions/gsd/error-utils.ts +6 -0
  22. package/dist/resources/extensions/gsd/export.ts +2 -1
  23. package/dist/resources/extensions/gsd/git-service.ts +3 -2
  24. package/dist/resources/extensions/gsd/guided-flow.ts +3 -2
  25. package/dist/resources/extensions/gsd/index.ts +12 -5
  26. package/dist/resources/extensions/gsd/key-manager.ts +2 -1
  27. package/dist/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  28. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  29. package/dist/resources/extensions/gsd/migrate-external.ts +21 -4
  30. package/dist/resources/extensions/gsd/milestone-ids.ts +2 -1
  31. package/dist/resources/extensions/gsd/native-git-bridge.ts +2 -1
  32. package/dist/resources/extensions/gsd/parallel-merge.ts +2 -1
  33. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  34. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  35. package/dist/resources/extensions/gsd/quick.ts +58 -3
  36. package/dist/resources/extensions/gsd/repo-identity.ts +22 -1
  37. package/dist/resources/extensions/gsd/session-lock.ts +12 -1
  38. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  39. package/dist/resources/extensions/gsd/undo.ts +5 -7
  40. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  41. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  42. package/dist/resources/extensions/gsd/worktree-command.ts +8 -7
  43. package/package.json +1 -1
  44. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
  45. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  46. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  47. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  48. package/src/resources/extensions/gsd/auto-prompts.ts +46 -44
  49. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  50. package/src/resources/extensions/gsd/auto-start.ts +8 -6
  51. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  52. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  53. package/src/resources/extensions/gsd/auto-timers.ts +3 -2
  54. package/src/resources/extensions/gsd/auto-verification.ts +6 -6
  55. package/src/resources/extensions/gsd/auto-worktree.ts +5 -4
  56. package/src/resources/extensions/gsd/auto.ts +28 -27
  57. package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
  58. package/src/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  59. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  60. package/src/resources/extensions/gsd/crash-recovery.ts +15 -2
  61. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  62. package/src/resources/extensions/gsd/error-utils.ts +6 -0
  63. package/src/resources/extensions/gsd/export.ts +2 -1
  64. package/src/resources/extensions/gsd/git-service.ts +3 -2
  65. package/src/resources/extensions/gsd/guided-flow.ts +3 -2
  66. package/src/resources/extensions/gsd/index.ts +12 -5
  67. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  68. package/src/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  69. package/src/resources/extensions/gsd/metrics.ts +3 -3
  70. package/src/resources/extensions/gsd/migrate-external.ts +21 -4
  71. package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
  72. package/src/resources/extensions/gsd/native-git-bridge.ts +2 -1
  73. package/src/resources/extensions/gsd/parallel-merge.ts +2 -1
  74. package/src/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  75. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  76. package/src/resources/extensions/gsd/quick.ts +58 -3
  77. package/src/resources/extensions/gsd/repo-identity.ts +22 -1
  78. package/src/resources/extensions/gsd/session-lock.ts +12 -1
  79. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  80. package/src/resources/extensions/gsd/undo.ts +5 -7
  81. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  82. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  83. package/src/resources/extensions/gsd/worktree-command.ts +8 -7
@@ -9,6 +9,7 @@ import { randomInt } from "node:crypto";
9
9
  import { readdirSync, existsSync } from "node:fs";
10
10
  import { milestonesDir } from "./paths.js";
11
11
  import { loadQueueOrder, sortByQueueOrder } from "./queue-order.js";
12
+ import { getErrorMessage } from "./error-utils.js";
12
13
 
13
14
  // ─── Regex ──────────────────────────────────────────────────────────────────
14
15
 
@@ -88,7 +89,7 @@ export function findMilestoneIds(basePath: string): string[] {
88
89
  } catch (err) {
89
90
  // Log why milestone scanning failed — silent [] here causes infinite loops (#456)
90
91
  if (existsSync(dir)) {
91
- console.error(`[gsd] findMilestoneIds: .gsd/milestones/ exists but readdirSync failed — ${err instanceof Error ? err.message : String(err)}`);
92
+ console.error(`[gsd] findMilestoneIds: .gsd/milestones/ exists but readdirSync failed — ${getErrorMessage(err)}`);
92
93
  }
93
94
  return [];
94
95
  }
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, unlinkSync, rmSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { GSDError, GSD_GIT_ERROR } from "./errors.js";
12
12
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
13
+ import { getErrorMessage } from "./error-utils.js";
13
14
 
14
15
  // Issue #453: keep auto-mode bookkeeping on the stable git CLI path unless a
15
16
  // caller explicitly opts into the native helper.
@@ -716,7 +717,7 @@ export function nativeCommit(
716
717
  try {
717
718
  return native.gitCommit(basePath, message, options?.allowEmpty);
718
719
  } catch (e) {
719
- const msg = e instanceof Error ? e.message : String(e);
720
+ const msg = getErrorMessage(e);
720
721
  if (msg.includes("nothing to commit")) return null;
721
722
  throw e;
722
723
  }
@@ -11,6 +11,7 @@ import { mergeMilestoneToMain } from "./auto-worktree.js";
11
11
  import { MergeConflictError } from "./git-service.js";
12
12
  import { removeSessionStatus } from "./session-status-io.js";
13
13
  import type { WorkerInfo } from "./parallel-orchestrator.js";
14
+ import { getErrorMessage } from "./error-utils.js";
14
15
 
15
16
  // ─── Types ─────────────────────────────────────────────────────────────────
16
17
 
@@ -99,7 +100,7 @@ export async function mergeCompletedMilestone(
99
100
  return {
100
101
  milestoneId,
101
102
  success: false,
102
- error: err instanceof Error ? err.message : String(err),
103
+ error: getErrorMessage(err),
103
104
  };
104
105
  }
105
106
  }
@@ -38,6 +38,7 @@ import {
38
38
  analyzeParallelEligibility,
39
39
  type ParallelCandidates,
40
40
  } from "./parallel-eligibility.js";
41
+ import { getErrorMessage } from "./error-utils.js";
41
42
 
42
43
  // ─── Types ─────────────────────────────────────────────────────────────────
43
44
 
@@ -363,7 +364,7 @@ export async function startParallel(
363
364
 
364
365
  started.push(mid);
365
366
  } catch (err) {
366
- const message = err instanceof Error ? err.message : String(err);
367
+ const message = getErrorMessage(err);
367
368
  errors.push({ mid, error: message });
368
369
  }
369
370
  }
@@ -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 ?? "")
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { loadPrompt } from "./prompt-loader.js";
16
16
  import { gsdRoot } from "./paths.js";
17
17
  import { createGitService, runGit } from "./git-service.js";
18
+ import { getErrorMessage } from "./error-utils.js";
18
19
 
19
20
  // ─── Quick Task Helpers ───────────────────────────────────────────────────────
20
21
 
@@ -107,10 +108,11 @@ export async function handleQuick(
107
108
  const skipBranch = git.prefs.isolation === "none";
108
109
 
109
110
  let branchCreated = false;
111
+ let originalBranch: string | undefined;
110
112
  if (!skipBranch) {
111
113
  try {
112
- const current = git.getCurrentBranch();
113
- if (current !== branchName) {
114
+ originalBranch = git.getCurrentBranch();
115
+ if (originalBranch !== branchName) {
114
116
  // Auto-commit any dirty state before switching
115
117
  try {
116
118
  git.autoCommit("quick-task", `Q${taskNum}`, []);
@@ -121,7 +123,7 @@ export async function handleQuick(
121
123
  }
122
124
  } catch (err) {
123
125
  // Branch creation failed — continue on current branch
124
- const message = err instanceof Error ? err.message : String(err);
126
+ const message = getErrorMessage(err);
125
127
  ctx.ui.notify(`Could not create branch ${branchName}: ${message}. Working on current branch.`, "warning");
126
128
  }
127
129
  }
@@ -154,4 +156,57 @@ export async function handleQuick(
154
156
  },
155
157
  { triggerTurn: true },
156
158
  );
159
+
160
+ // Schedule branch merge-back after the quick task agent session ends.
161
+ // Without this, auto-mode resumes on the quick-task branch (#1269).
162
+ if (branchCreated && originalBranch) {
163
+ _pendingQuickBranchReturn = {
164
+ basePath,
165
+ originalBranch,
166
+ quickBranch: branchName,
167
+ taskNum,
168
+ slug,
169
+ description,
170
+ };
171
+ }
172
+ }
173
+
174
+ /** Pending quick-task branch return — consumed by cleanupQuickBranch(). */
175
+ let _pendingQuickBranchReturn: {
176
+ basePath: string;
177
+ originalBranch: string;
178
+ quickBranch: string;
179
+ taskNum: number;
180
+ slug: string;
181
+ description: string;
182
+ } | null = null;
183
+
184
+ /**
185
+ * Merge the quick-task branch back to the original branch and switch.
186
+ * Called from the agent_end handler after a quick task completes.
187
+ * Returns true if a branch return was performed.
188
+ */
189
+ export function cleanupQuickBranch(): boolean {
190
+ if (!_pendingQuickBranchReturn) return false;
191
+ const { basePath, originalBranch, quickBranch, taskNum, slug, description } = _pendingQuickBranchReturn;
192
+ _pendingQuickBranchReturn = null;
193
+
194
+ try {
195
+ // Auto-commit any remaining work
196
+ try { runGit(basePath, ["add", "-A"]); } catch {}
197
+ try { runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${slug}`]); } catch {}
198
+
199
+ // Switch back and merge
200
+ runGit(basePath, ["checkout", originalBranch]);
201
+ try {
202
+ runGit(basePath, ["merge", "--squash", quickBranch]);
203
+ runGit(basePath, ["commit", "-m", `quick(Q${taskNum}): ${description.slice(0, 72)}`]);
204
+ } catch { /* merge conflict or nothing — non-fatal */ }
205
+
206
+ // Clean up quick branch
207
+ try { runGit(basePath, ["branch", "-D", quickBranch]); } catch {}
208
+ return true;
209
+ } catch {
210
+ return false;
211
+ }
157
212
  }
@@ -10,7 +10,7 @@ import { createHash } from "node:crypto";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { join, resolve } from "node:path";
13
+ import { join, resolve, sep } from "node:path";
14
14
 
15
15
  // ─── Repo Identity ──────────────────────────────────────────────────────────
16
16
 
@@ -37,6 +37,27 @@ function getRemoteUrl(basePath: string): string {
37
37
  */
38
38
  function resolveGitRoot(basePath: string): string {
39
39
  try {
40
+ // In a worktree, --show-toplevel returns the worktree path, not the main
41
+ // repo root. Use --git-common-dir to find the shared .git directory,
42
+ // then derive the main repo root from it (#1288).
43
+ const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
44
+ cwd: basePath,
45
+ encoding: "utf-8",
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ timeout: 5_000,
48
+ }).trim();
49
+
50
+ // If commonDir ends with .git/worktrees/<name>, the main repo is two
51
+ // levels up from the worktrees dir. If it's just .git, resolve normally.
52
+ if (commonDir.includes(`${sep}worktrees${sep}`) || commonDir.includes("/worktrees/")) {
53
+ // e.g., /path/to/project/.gsd/worktrees/M001/.git → /path/to/project
54
+ // or /path/to/project/.git/worktrees/M001 → /path/to/project
55
+ const gitDir = commonDir.replace(/[/\\]worktrees[/\\][^/\\]+$/, "");
56
+ const mainRoot = resolve(gitDir, "..");
57
+ return mainRoot;
58
+ }
59
+
60
+ // Not in a worktree — use --show-toplevel as usual
40
61
  return execFileSync("git", ["rev-parse", "--show-toplevel"], {
41
62
  cwd: basePath,
42
63
  encoding: "utf-8",
@@ -154,12 +154,23 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
154
154
  // Retry acquisition after cleanup
155
155
  const release = lockfile.lockSync(gsdDir, {
156
156
  realpath: false,
157
- stale: 300_000,
157
+ stale: 1_800_000, // 30 minutes — match primary lock settings
158
158
  update: 10_000,
159
+ onCompromised: () => {
160
+ _lockCompromised = true;
161
+ },
159
162
  });
160
163
  _releaseFunction = release;
161
164
  _lockedPath = basePath;
162
165
  _lockPid = process.pid;
166
+
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
+ });
173
+
163
174
  atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
164
175
  return { acquired: true };
165
176
  } catch {
@@ -91,7 +91,7 @@ test("compression: buildPlanMilestonePrompt minimal drops project/requirements/d
91
91
  // The plan-milestone builder should gate root file inlining on inlineLevel
92
92
  assert.ok(
93
93
  promptsSrc.includes('inlineLevel !== "minimal"') &&
94
- promptsSrc.includes('inlineGsdRootFile(base, "project.md"'),
94
+ promptsSrc.includes("inlineProjectFromDb(base)"),
95
95
  "plan-milestone should conditionally include project.md based on level",
96
96
  );
97
97
  });
@@ -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");
@@ -34,6 +34,7 @@ import type { FileLineStat } from "./worktree-manager.js";
34
34
  import { existsSync, realpathSync, readdirSync, rmSync, unlinkSync } from "node:fs";
35
35
  import { nativeMergeAbort } from "./native-git-bridge.js";
36
36
  import { join, sep } from "node:path";
37
+ import { getErrorMessage } from "./error-utils.js";
37
38
 
38
39
  /**
39
40
  * Tracks the original project root so we can switch back.
@@ -370,7 +371,7 @@ async function handleCreate(
370
371
  "info",
371
372
  );
372
373
  } catch (error) {
373
- const msg = error instanceof Error ? error.message : String(error);
374
+ const msg = getErrorMessage(error);
374
375
  ctx.ui.notify(`Failed to create worktree: ${msg}`, "error");
375
376
  }
376
377
  }
@@ -418,7 +419,7 @@ async function handleSwitch(
418
419
  "info",
419
420
  );
420
421
  } catch (error) {
421
- const msg = error instanceof Error ? error.message : String(error);
422
+ const msg = getErrorMessage(error);
422
423
  ctx.ui.notify(`Failed to switch to worktree: ${msg}`, "error");
423
424
  }
424
425
  }
@@ -528,7 +529,7 @@ async function handleList(
528
529
 
529
530
  ctx.ui.notify(lines.join("\n"), "info");
530
531
  } catch (error) {
531
- const msg = error instanceof Error ? error.message : String(error);
532
+ const msg = getErrorMessage(error);
532
533
  ctx.ui.notify(`Failed to list worktrees: ${msg}`, "error");
533
534
  }
534
535
  }
@@ -646,7 +647,7 @@ async function handleMerge(
646
647
  );
647
648
  return;
648
649
  } catch (mergeErr) {
649
- const mergeMsg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
650
+ const mergeMsg = getErrorMessage(mergeErr);
650
651
  const isConflict = /conflict/i.test(mergeMsg);
651
652
 
652
653
  if (isConflict) {
@@ -703,7 +704,7 @@ async function handleMerge(
703
704
  "info",
704
705
  );
705
706
  } catch (error) {
706
- const msg = error instanceof Error ? error.message : String(error);
707
+ const msg = getErrorMessage(error);
707
708
  ctx.ui.notify(`Failed to start merge: ${msg}`, "error");
708
709
  }
709
710
  }
@@ -746,7 +747,7 @@ async function handleRemove(
746
747
 
747
748
  ctx.ui.notify(`${CLR.ok("✓")} Worktree ${CLR.name(name)} removed ${CLR.muted("(branch deleted)")}.`, "info");
748
749
  } catch (error) {
749
- const msg = error instanceof Error ? error.message : String(error);
750
+ const msg = getErrorMessage(error);
750
751
  ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error");
751
752
  }
752
753
  }
@@ -800,7 +801,7 @@ async function handleRemoveAll(
800
801
  if (failed.length > 0) lines.push(`${CLR.warn("✗")} Failed: ${failed.map(n => CLR.name(n)).join(", ")}`);
801
802
  ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info");
802
803
  } catch (error) {
803
- const msg = error instanceof Error ? error.message : String(error);
804
+ const msg = getErrorMessage(error);
804
805
  ctx.ui.notify(`Failed to remove worktrees: ${msg}`, "error");
805
806
  }
806
807
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.32.0-dev.1e39869",
3
+ "version": "2.32.0-dev.3d7932c",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- ? (unitId.split("/").pop() ?? unitId)
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}] `) : "";
@@ -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.split("/")[0];
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.split("/")[0];
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 parts = unitId.split("/");
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 parts = s.currentUnit.id.split("/");
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 scopeParts = s.currentUnit.id.split("/").slice(0, 2);
171
- const doctorScope = scopeParts.join("/");
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 [mid, sid] = s.currentUnit.id.split("/");
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) {