gsd-pi 2.32.0 → 2.33.0-dev.bafba33

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 (91) 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-dispatch.ts +40 -12
  5. package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
  6. package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
  7. package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
  8. package/dist/resources/extensions/gsd/auto-prompts.ts +46 -44
  9. package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
  10. package/dist/resources/extensions/gsd/auto-start.ts +8 -6
  11. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  12. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  13. package/dist/resources/extensions/gsd/auto-timers.ts +3 -2
  14. package/dist/resources/extensions/gsd/auto-verification.ts +6 -6
  15. package/dist/resources/extensions/gsd/auto-worktree.ts +5 -4
  16. package/dist/resources/extensions/gsd/auto.ts +108 -182
  17. package/dist/resources/extensions/gsd/commands-inspect.ts +2 -1
  18. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  19. package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
  20. package/dist/resources/extensions/gsd/crash-recovery.ts +15 -2
  21. package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
  22. package/dist/resources/extensions/gsd/error-utils.ts +6 -0
  23. package/dist/resources/extensions/gsd/export.ts +2 -1
  24. package/dist/resources/extensions/gsd/git-service.ts +3 -2
  25. package/dist/resources/extensions/gsd/guided-flow.ts +3 -2
  26. package/dist/resources/extensions/gsd/index.ts +12 -5
  27. package/dist/resources/extensions/gsd/key-manager.ts +2 -1
  28. package/dist/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  29. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  30. package/dist/resources/extensions/gsd/migrate-external.ts +21 -4
  31. package/dist/resources/extensions/gsd/milestone-ids.ts +2 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +2 -1
  33. package/dist/resources/extensions/gsd/parallel-merge.ts +2 -1
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  35. package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  36. package/dist/resources/extensions/gsd/quick.ts +58 -3
  37. package/dist/resources/extensions/gsd/repo-identity.ts +22 -1
  38. package/dist/resources/extensions/gsd/session-lock.ts +12 -1
  39. package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  40. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  41. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +839 -0
  42. package/dist/resources/extensions/gsd/undo.ts +5 -7
  43. package/dist/resources/extensions/gsd/unit-id.ts +14 -0
  44. package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
  45. package/dist/resources/extensions/gsd/worktree-command.ts +8 -7
  46. package/package.json +3 -2
  47. package/packages/pi-coding-agent/package.json +1 -1
  48. package/pkg/package.json +1 -1
  49. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
  50. package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
  51. package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
  52. package/src/resources/extensions/gsd/auto-observability.ts +2 -4
  53. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  54. package/src/resources/extensions/gsd/auto-prompts.ts +46 -44
  55. package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
  56. package/src/resources/extensions/gsd/auto-start.ts +8 -6
  57. package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
  58. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
  59. package/src/resources/extensions/gsd/auto-timers.ts +3 -2
  60. package/src/resources/extensions/gsd/auto-verification.ts +6 -6
  61. package/src/resources/extensions/gsd/auto-worktree.ts +5 -4
  62. package/src/resources/extensions/gsd/auto.ts +108 -182
  63. package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
  64. package/src/resources/extensions/gsd/commands-workflow-templates.ts +2 -1
  65. package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
  66. package/src/resources/extensions/gsd/crash-recovery.ts +15 -2
  67. package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
  68. package/src/resources/extensions/gsd/error-utils.ts +6 -0
  69. package/src/resources/extensions/gsd/export.ts +2 -1
  70. package/src/resources/extensions/gsd/git-service.ts +3 -2
  71. package/src/resources/extensions/gsd/guided-flow.ts +3 -2
  72. package/src/resources/extensions/gsd/index.ts +12 -5
  73. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  74. package/src/resources/extensions/gsd/marketplace-discovery.ts +4 -3
  75. package/src/resources/extensions/gsd/metrics.ts +3 -3
  76. package/src/resources/extensions/gsd/migrate-external.ts +21 -4
  77. package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
  78. package/src/resources/extensions/gsd/native-git-bridge.ts +2 -1
  79. package/src/resources/extensions/gsd/parallel-merge.ts +2 -1
  80. package/src/resources/extensions/gsd/parallel-orchestrator.ts +2 -1
  81. package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
  82. package/src/resources/extensions/gsd/quick.ts +58 -3
  83. package/src/resources/extensions/gsd/repo-identity.ts +22 -1
  84. package/src/resources/extensions/gsd/session-lock.ts +12 -1
  85. package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
  86. package/src/resources/extensions/gsd/tests/context-compression.test.ts +1 -1
  87. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +839 -0
  88. package/src/resources/extensions/gsd/undo.ts +5 -7
  89. package/src/resources/extensions/gsd/unit-id.ts +14 -0
  90. package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
  91. package/src/resources/extensions/gsd/worktree-command.ts +8 -7
@@ -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",
3
+ "version": "2.33.0-dev.bafba33",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-coding-agent",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
4
  "description": "Coding agent CLI (vendored from pi-mono)",
5
5
  "type": "module",
6
6
  "piConfig": {
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -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}] `) : "";
@@ -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 sid = state.activeSlice!.id;
97
- const sTitle = state.activeSlice!.title;
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 sid = state.activeSlice!.id;
226
- const sTitle = state.activeSlice!.title;
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 sid = state.activeSlice!.id;
246
- const sTitle = state.activeSlice!.title;
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 sid = state.activeSlice!.id;
260
- const sTitle = state.activeSlice!.title;
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 sid = state.activeSlice!.id;
274
- const sTitle = state.activeSlice!.title;
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 sid = state.activeSlice!.id;
300
- const sTitle = state.activeSlice!.title;
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.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) {
@@ -189,30 +189,52 @@ export async function inlineGsdRootFile(
189
189
  // ─── DB-Aware Inline Helpers ──────────────────────────────────────────────
190
190
 
191
191
  /**
192
- * Inline decisions with optional milestone scoping from the DB.
193
- * Falls back to filesystem via inlineGsdRootFile when DB unavailable or empty.
192
+ * Shared DB-fallback pattern: attempt a DB query via the context-store, format
193
+ * the result, and fall back to the filesystem file when the DB is unavailable
194
+ * or the query yields no results.
195
+ *
196
+ * @param base Project root for filesystem fallback
197
+ * @param label Section heading (e.g. "Decisions")
198
+ * @param filename Filesystem fallback file (e.g. "decisions.md")
199
+ * @param queryDb Async callback receiving the dynamically-imported
200
+ * context-store module. Returns formatted markdown or null.
194
201
  */
195
- export async function inlineDecisionsFromDb(
196
- base: string, milestoneId?: string, scope?: string, level?: InlineLevel,
202
+ async function inlineFromDbOrFile(
203
+ base: string,
204
+ label: string,
205
+ filename: string,
206
+ queryDb: (cs: typeof import("./context-store.js")) => string | null,
197
207
  ): Promise<string | null> {
198
- const inlineLevel = level ?? resolveInlineLevel();
199
208
  try {
200
209
  const { isDbAvailable } = await import("./gsd-db.js");
201
210
  if (isDbAvailable()) {
202
- const { queryDecisions, formatDecisionsForPrompt } = await import("./context-store.js");
203
- const decisions = queryDecisions({ milestoneId, scope });
204
- if (decisions.length > 0) {
205
- // Use compact format for non-full levels to save ~35% tokens
206
- const formatted = inlineLevel !== "full"
207
- ? formatDecisionsCompact(decisions)
208
- : formatDecisionsForPrompt(decisions);
209
- return `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${formatted}`;
211
+ const contextStore = await import("./context-store.js");
212
+ const content = queryDb(contextStore);
213
+ if (content) {
214
+ return `### ${label}\nSource: \`.gsd/${filename.toUpperCase().replace(/\.MD$/i, "")}.md\`\n\n${content}`;
210
215
  }
211
216
  }
212
217
  } catch {
213
218
  // DB not available — fall through to filesystem
214
219
  }
215
- return inlineGsdRootFile(base, "decisions.md", "Decisions");
220
+ return inlineGsdRootFile(base, filename, label);
221
+ }
222
+
223
+ /**
224
+ * Inline decisions with optional milestone scoping from the DB.
225
+ * Falls back to filesystem via inlineGsdRootFile when DB unavailable or empty.
226
+ */
227
+ export async function inlineDecisionsFromDb(
228
+ base: string, milestoneId?: string, scope?: string, level?: InlineLevel,
229
+ ): Promise<string | null> {
230
+ const inlineLevel = level ?? resolveInlineLevel();
231
+ return inlineFromDbOrFile(base, "Decisions", "decisions.md", (cs) => {
232
+ const decisions = cs.queryDecisions({ milestoneId, scope });
233
+ if (decisions.length === 0) return null;
234
+ return inlineLevel !== "full"
235
+ ? formatDecisionsCompact(decisions)
236
+ : cs.formatDecisionsForPrompt(decisions);
237
+ });
216
238
  }
217
239
 
218
240
  /**
@@ -223,23 +245,13 @@ export async function inlineRequirementsFromDb(
223
245
  base: string, sliceId?: string, level?: InlineLevel,
224
246
  ): Promise<string | null> {
225
247
  const inlineLevel = level ?? resolveInlineLevel();
226
- try {
227
- const { isDbAvailable } = await import("./gsd-db.js");
228
- if (isDbAvailable()) {
229
- const { queryRequirements, formatRequirementsForPrompt } = await import("./context-store.js");
230
- const requirements = queryRequirements({ sliceId });
231
- if (requirements.length > 0) {
232
- // Use compact format for non-full levels to save ~40% tokens
233
- const formatted = inlineLevel !== "full"
234
- ? formatRequirementsCompact(requirements)
235
- : formatRequirementsForPrompt(requirements);
236
- return `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${formatted}`;
237
- }
238
- }
239
- } catch {
240
- // DB not available — fall through to filesystem
241
- }
242
- return inlineGsdRootFile(base, "requirements.md", "Requirements");
248
+ return inlineFromDbOrFile(base, "Requirements", "requirements.md", (cs) => {
249
+ const requirements = cs.queryRequirements({ sliceId });
250
+ if (requirements.length === 0) return null;
251
+ return inlineLevel !== "full"
252
+ ? formatRequirementsCompact(requirements)
253
+ : cs.formatRequirementsForPrompt(requirements);
254
+ });
243
255
  }
244
256
 
245
257
  /**
@@ -249,19 +261,9 @@ export async function inlineRequirementsFromDb(
249
261
  export async function inlineProjectFromDb(
250
262
  base: string,
251
263
  ): Promise<string | null> {
252
- try {
253
- const { isDbAvailable } = await import("./gsd-db.js");
254
- if (isDbAvailable()) {
255
- const { queryProject } = await import("./context-store.js");
256
- const content = queryProject();
257
- if (content) {
258
- return `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${content}`;
259
- }
260
- }
261
- } catch {
262
- // DB not available — fall through to filesystem
263
- }
264
- return inlineGsdRootFile(base, "project.md", "Project");
264
+ return inlineFromDbOrFile(base, "Project", "project.md", (cs) => {
265
+ return cs.queryProject();
266
+ });
265
267
  }
266
268
 
267
269
  // ─── Skill Discovery ──────────────────────────────────────────────────────
@@ -42,6 +42,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "
42
42
  import { atomicWriteSync } from "./atomic-write.js";
43
43
  import { loadJsonFileOrNull } from "./json-persistence.js";
44
44
  import { dirname, join } from "node:path";
45
+ import { parseUnitId } from "./unit-id.js";
45
46
 
46
47
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
47
48
 
@@ -49,9 +50,7 @@ import { dirname, join } from "node:path";
49
50
  * Resolve the expected artifact for a unit to an absolute path.
50
51
  */
51
52
  export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
52
- const parts = unitId.split("/");
53
- const mid = parts[0]!;
54
- const sid = parts[1];
53
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
55
54
  switch (unitType) {
56
55
  case "research-milestone": {
57
56
  const dir = resolveMilestonePath(base, mid);
@@ -78,7 +77,6 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
78
77
  return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
79
78
  }
80
79
  case "execute-task": {
81
- const tid = parts[2];
82
80
  const dir = resolveSlicePath(base, mid, sid!);
83
81
  return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
84
82
  }
@@ -167,10 +165,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
167
165
 
168
166
  // execute-task must also have its checkbox marked [x] in the slice plan
169
167
  if (unitType === "execute-task") {
170
- const parts = unitId.split("/");
171
- const mid = parts[0];
172
- const sid = parts[1];
173
- const tid = parts[2];
168
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
174
169
  if (mid && sid && tid) {
175
170
  const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
176
171
  if (planAbs && existsSync(planAbs)) {
@@ -187,9 +182,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
187
182
  // but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task
188
183
  // to dispatch with a missing task plan (see issue #739).
189
184
  if (unitType === "plan-slice") {
190
- const parts = unitId.split("/");
191
- const mid = parts[0];
192
- const sid = parts[1];
185
+ const { milestone: mid, slice: sid } = parseUnitId(unitId);
193
186
  if (mid && sid) {
194
187
  try {
195
188
  const planContent = readFileSync(absPath, "utf-8");
@@ -213,9 +206,8 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
213
206
  // state machine keeps returning the same complete-slice unit (roadmap still shows
214
207
  // the slice incomplete), so dispatchNextUnit recurses forever.
215
208
  if (unitType === "complete-slice") {
216
- const parts = unitId.split("/");
217
- const mid = parts[0];
218
- const sid = parts[1];
209
+ const { milestone: mid, slice: sid } = parseUnitId(unitId);
210
+
219
211
  if (mid && sid) {
220
212
  const dir = resolveSlicePath(base, mid, sid);
221
213
  if (dir) {
@@ -268,9 +260,7 @@ export function writeBlockerPlaceholder(unitType: string, unitId: string, base:
268
260
  }
269
261
 
270
262
  export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
271
- const parts = unitId.split("/");
272
- const mid = parts[0];
273
- const sid = parts[1];
263
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
274
264
  switch (unitType) {
275
265
  case "research-milestone":
276
266
  return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
@@ -281,7 +271,6 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base:
281
271
  case "plan-slice":
282
272
  return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
283
273
  case "execute-task": {
284
- const tid = parts[2];
285
274
  return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
286
275
  }
287
276
  case "complete-slice":
@@ -539,10 +528,7 @@ export async function selfHealRuntimeRecords(
539
528
  * These are shown when automatic reconciliation is not possible.
540
529
  */
541
530
  export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null {
542
- const parts = unitId.split("/");
543
- const mid = parts[0];
544
- const sid = parts[1];
545
- const tid = parts[2];
531
+ const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
546
532
  switch (unitType) {
547
533
  case "execute-task": {
548
534
  if (!mid || !sid || !tid) break;