gsd-pi 2.32.0-dev.3d7932c → 2.32.0-dev.f3d5d53

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 (35) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +1 -3
  2. package/dist/resources/extensions/gsd/auto-idempotency.ts +2 -3
  3. package/dist/resources/extensions/gsd/auto-observability.ts +4 -2
  4. package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
  5. package/dist/resources/extensions/gsd/auto-recovery.ts +22 -8
  6. package/dist/resources/extensions/gsd/auto-start.ts +1 -2
  7. package/dist/resources/extensions/gsd/auto-stuck-detection.ts +2 -3
  8. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +1 -2
  9. package/dist/resources/extensions/gsd/auto-verification.ts +5 -4
  10. package/dist/resources/extensions/gsd/auto.ts +4 -4
  11. package/dist/resources/extensions/gsd/complexity-classifier.ts +7 -5
  12. package/dist/resources/extensions/gsd/dispatch-guard.ts +1 -2
  13. package/dist/resources/extensions/gsd/metrics.ts +3 -3
  14. package/dist/resources/extensions/gsd/post-unit-hooks.ts +9 -8
  15. package/dist/resources/extensions/gsd/undo.ts +7 -5
  16. package/dist/resources/extensions/gsd/unit-runtime.ts +1 -2
  17. package/package.json +1 -1
  18. package/src/resources/extensions/gsd/auto-dashboard.ts +1 -3
  19. package/src/resources/extensions/gsd/auto-idempotency.ts +2 -3
  20. package/src/resources/extensions/gsd/auto-observability.ts +4 -2
  21. package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
  22. package/src/resources/extensions/gsd/auto-recovery.ts +22 -8
  23. package/src/resources/extensions/gsd/auto-start.ts +1 -2
  24. package/src/resources/extensions/gsd/auto-stuck-detection.ts +2 -3
  25. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +1 -2
  26. package/src/resources/extensions/gsd/auto-verification.ts +5 -4
  27. package/src/resources/extensions/gsd/auto.ts +4 -4
  28. package/src/resources/extensions/gsd/complexity-classifier.ts +7 -5
  29. package/src/resources/extensions/gsd/dispatch-guard.ts +1 -2
  30. package/src/resources/extensions/gsd/metrics.ts +3 -3
  31. package/src/resources/extensions/gsd/post-unit-hooks.ts +9 -8
  32. package/src/resources/extensions/gsd/undo.ts +7 -5
  33. package/src/resources/extensions/gsd/unit-runtime.ts +1 -2
  34. package/dist/resources/extensions/gsd/unit-id.ts +0 -14
  35. package/src/resources/extensions/gsd/unit-id.ts +0 -14
@@ -20,7 +20,6 @@ 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";
24
23
 
25
24
  // ─── Dashboard Data ───────────────────────────────────────────────────────────
26
25
 
@@ -373,9 +372,8 @@ export function updateProgressWidget(
373
372
  lines.push("");
374
373
 
375
374
  const isHook = unitType.startsWith("hook/");
376
- const hookParsed = isHook ? parseUnitId(unitId) : undefined;
377
375
  const target = isHook
378
- ? (hookParsed!.task ?? hookParsed!.slice ?? unitId)
376
+ ? (unitId.split("/").pop() ?? unitId)
379
377
  : (task ? `${task.id}: ${task.title}` : unitId);
380
378
  const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
381
379
  const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
@@ -18,7 +18,6 @@ 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";
22
21
 
23
22
  export interface IdempotencyContext {
24
23
  s: AutoSession;
@@ -55,7 +54,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
55
54
  s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
56
55
  if (skipCount > MAX_CONSECUTIVE_SKIPS) {
57
56
  // Cross-check: verify the unit's milestone is still active (#790)
58
- const skippedMid = parseUnitId(unitId).milestone;
57
+ const skippedMid = unitId.split("/")[0];
59
58
  const skippedMilestoneComplete = skippedMid
60
59
  ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
61
60
  : false;
@@ -111,7 +110,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
111
110
  const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
112
111
  s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
113
112
  if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
114
- const skippedMid2 = parseUnitId(unitId).milestone;
113
+ const skippedMid2 = unitId.split("/")[0];
115
114
  const skippedMilestoneComplete2 = skippedMid2
116
115
  ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
117
116
  : false;
@@ -12,7 +12,6 @@ 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";
16
15
 
17
16
  export async function collectObservabilityWarnings(
18
17
  ctx: ExtensionContext,
@@ -23,7 +22,10 @@ export async function collectObservabilityWarnings(
23
22
  // Hook units have custom artifacts — skip standard observability checks
24
23
  if (unitType.startsWith("hook/")) return [];
25
24
 
26
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
25
+ const parts = unitId.split("/");
26
+ const mid = parts[0];
27
+ const sid = parts[1];
28
+ const tid = parts[2];
27
29
 
28
30
  if (!mid || !sid) return [];
29
31
 
@@ -61,7 +61,6 @@ 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";
65
64
 
66
65
  /**
67
66
  * Initialize a unit dispatch: stamp the current time, set `s.currentUnit`,
@@ -135,7 +134,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
135
134
  let taskContext: TaskCommitContext | undefined;
136
135
 
137
136
  if (s.currentUnit.type === "execute-task") {
138
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
137
+ const parts = s.currentUnit.id.split("/");
138
+ const [mid, sid, tid] = parts;
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 { milestone, slice } = parseUnitId(s.currentUnit.id);
171
- const doctorScope = slice ? `${milestone}/${slice}` : milestone;
170
+ const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
171
+ const doctorScope = scopeParts.join("/");
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 { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id);
351
+ const [mid, sid] = s.currentUnit.id.split("/");
352
352
  if (mid && sid) {
353
353
  const state = await deriveState(s.basePath);
354
354
  if (state.phase === "summarizing" && state.activeSlice?.id === sid) {
@@ -42,7 +42,6 @@ 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";
46
45
 
47
46
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
48
47
 
@@ -50,7 +49,9 @@ import { parseUnitId } from "./unit-id.js";
50
49
  * Resolve the expected artifact for a unit to an absolute path.
51
50
  */
52
51
  export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
53
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
52
+ const parts = unitId.split("/");
53
+ const mid = parts[0]!;
54
+ const sid = parts[1];
54
55
  switch (unitType) {
55
56
  case "research-milestone": {
56
57
  const dir = resolveMilestonePath(base, mid);
@@ -77,6 +78,7 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
77
78
  return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
78
79
  }
79
80
  case "execute-task": {
81
+ const tid = parts[2];
80
82
  const dir = resolveSlicePath(base, mid, sid!);
81
83
  return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
82
84
  }
@@ -165,7 +167,10 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
165
167
 
166
168
  // execute-task must also have its checkbox marked [x] in the slice plan
167
169
  if (unitType === "execute-task") {
168
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
170
+ const parts = unitId.split("/");
171
+ const mid = parts[0];
172
+ const sid = parts[1];
173
+ const tid = parts[2];
169
174
  if (mid && sid && tid) {
170
175
  const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
171
176
  if (planAbs && existsSync(planAbs)) {
@@ -182,7 +187,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
182
187
  // but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task
183
188
  // to dispatch with a missing task plan (see issue #739).
184
189
  if (unitType === "plan-slice") {
185
- const { milestone: mid, slice: sid } = parseUnitId(unitId);
190
+ const parts = unitId.split("/");
191
+ const mid = parts[0];
192
+ const sid = parts[1];
186
193
  if (mid && sid) {
187
194
  try {
188
195
  const planContent = readFileSync(absPath, "utf-8");
@@ -206,8 +213,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
206
213
  // state machine keeps returning the same complete-slice unit (roadmap still shows
207
214
  // the slice incomplete), so dispatchNextUnit recurses forever.
208
215
  if (unitType === "complete-slice") {
209
- const { milestone: mid, slice: sid } = parseUnitId(unitId);
210
-
216
+ const parts = unitId.split("/");
217
+ const mid = parts[0];
218
+ const sid = parts[1];
211
219
  if (mid && sid) {
212
220
  const dir = resolveSlicePath(base, mid, sid);
213
221
  if (dir) {
@@ -260,7 +268,9 @@ export function writeBlockerPlaceholder(unitType: string, unitId: string, base:
260
268
  }
261
269
 
262
270
  export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
263
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
271
+ const parts = unitId.split("/");
272
+ const mid = parts[0];
273
+ const sid = parts[1];
264
274
  switch (unitType) {
265
275
  case "research-milestone":
266
276
  return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
@@ -271,6 +281,7 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base:
271
281
  case "plan-slice":
272
282
  return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
273
283
  case "execute-task": {
284
+ const tid = parts[2];
274
285
  return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
275
286
  }
276
287
  case "complete-slice":
@@ -528,7 +539,10 @@ export async function selfHealRuntimeRecords(
528
539
  * These are shown when automatic reconciliation is not possible.
529
540
  */
530
541
  export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null {
531
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
542
+ const parts = unitId.split("/");
543
+ const mid = parts[0];
544
+ const sid = parts[1];
545
+ const tid = parts[2];
532
546
  switch (unitType) {
533
547
  case "execute-task": {
534
548
  if (!mid || !sid || !tid) break;
@@ -64,7 +64,6 @@ import type { AutoSession } from "./auto/session.js";
64
64
  import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
65
65
  import { join } from "node:path";
66
66
  import { getErrorMessage } from "./error-utils.js";
67
- import { parseUnitId } from "./unit-id.js";
68
67
 
69
68
  export interface BootstrapDeps {
70
69
  shouldUseWorktreeIsolation: () => boolean;
@@ -140,7 +139,7 @@ export async function bootstrapAutoSession(
140
139
  if (crashLock && crashLock.pid !== process.pid) {
141
140
  // We already hold the session lock, so no concurrent session is running.
142
141
  // The crash lock is from a dead process — recover context from it.
143
- const recoveredMid = parseUnitId(crashLock.unitId).milestone;
142
+ const recoveredMid = crashLock.unitId.split("/")[0];
144
143
  const milestoneAlreadyComplete = recoveredMid
145
144
  ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
146
145
  : false;
@@ -39,7 +39,6 @@ import {
39
39
  import type { AutoSession } from "./auto/session.js";
40
40
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
41
41
  import { join } from "node:path";
42
- import { parseUnitId } from "./unit-id.js";
43
42
 
44
43
  export interface StuckContext {
45
44
  s: AutoSession;
@@ -100,7 +99,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckRes
100
99
 
101
100
  // Final reconciliation pass for execute-task
102
101
  if (unitType === "execute-task") {
103
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
102
+ const [mid, sid, tid] = unitId.split("/");
104
103
  if (mid && sid && tid) {
105
104
  const status = await inspectExecuteTaskDurability(basePath, unitId);
106
105
  if (status) {
@@ -169,7 +168,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckRes
169
168
  // Adaptive self-repair: each retry attempts a different remediation step.
170
169
  if (unitType === "execute-task") {
171
170
  const status = await inspectExecuteTaskDurability(basePath, unitId);
172
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
171
+ const [mid, sid, tid] = unitId.split("/");
173
172
  if (status && mid && sid && tid) {
174
173
  if (status.summaryExists && !status.taskChecked) {
175
174
  const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
@@ -18,7 +18,6 @@ import {
18
18
  writeBlockerPlaceholder,
19
19
  } from "./auto-recovery.js";
20
20
  import { existsSync } from "node:fs";
21
- import { parseUnitId } from "./unit-id.js";
22
21
 
23
22
  export interface RecoveryContext {
24
23
  basePath: string;
@@ -129,7 +128,7 @@ export async function recoverTimedOutUnit(
129
128
 
130
129
  // Retries exhausted — write missing durable artifacts and advance.
131
130
  const diagnostic = formatExecuteTaskRecoveryStatus(status);
132
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
131
+ const [mid, sid, tid] = unitId.split("/");
133
132
  const skipped = mid && sid && tid
134
133
  ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
135
134
  : false;
@@ -25,7 +25,6 @@ import { removePersistedKey } from "./auto-recovery.js";
25
25
  import type { AutoSession, PendingVerificationRetry } from "./auto/session.js";
26
26
  import { join } from "node:path";
27
27
  import { getErrorMessage } from "./error-utils.js";
28
- import { parseUnitId } from "./unit-id.js";
29
28
 
30
29
  export interface VerificationContext {
31
30
  s: AutoSession;
@@ -59,9 +58,10 @@ export async function runPostUnitVerification(
59
58
  const prefs = effectivePrefs?.preferences;
60
59
 
61
60
  // Read task plan verify field
62
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
61
+ const parts = s.currentUnit.id.split("/");
63
62
  let taskPlanVerify: string | undefined;
64
- if (mid && sid && tid) {
63
+ if (parts.length >= 3) {
64
+ const [mid, sid, tid] = parts;
65
65
  const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
66
66
  if (planFile) {
67
67
  const planContent = await loadFile(planFile);
@@ -153,8 +153,9 @@ export async function runPostUnitVerification(
153
153
 
154
154
  // Write verification evidence JSON
155
155
  const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0;
156
- if (mid && sid && tid) {
156
+ if (parts.length >= 3) {
157
157
  try {
158
+ const [mid, sid, tid] = parts;
158
159
  const sDir = resolveSlicePath(s.basePath, mid, sid);
159
160
  if (sDir) {
160
161
  const tasksDir = join(sDir, "tasks");
@@ -105,7 +105,6 @@ 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";
109
108
  import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
110
109
  import { atomicWriteSync } from "./atomic-write.js";
111
110
  import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js";
@@ -1749,7 +1748,8 @@ async function dispatchNextUnit(
1749
1748
  function ensurePreconditions(
1750
1749
  unitType: string, unitId: string, base: string, state: GSDState,
1751
1750
  ): void {
1752
- const { milestone: mid } = parseUnitId(unitId);
1751
+ const parts = unitId.split("/");
1752
+ const mid = parts[0]!;
1753
1753
 
1754
1754
  const mDir = resolveMilestonePath(base, mid);
1755
1755
  if (!mDir) {
@@ -1757,8 +1757,8 @@ function ensurePreconditions(
1757
1757
  mkdirSync(join(newDir, "slices"), { recursive: true });
1758
1758
  }
1759
1759
 
1760
- const sid = parseUnitId(unitId).slice;
1761
- if (sid) {
1760
+ if (parts.length >= 2) {
1761
+ const sid = parts[1]!;
1762
1762
 
1763
1763
  const mDirResolved = resolveMilestonePath(base, mid);
1764
1764
  if (mDirResolved) {
@@ -6,7 +6,6 @@ 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";
10
9
 
11
10
  // ─── Types ───────────────────────────────────────────────────────────────────
12
11
 
@@ -181,14 +180,15 @@ function analyzePlanComplexity(
181
180
  basePath: string,
182
181
  ): TaskAnalysis | null {
183
182
  // Check if this is a milestone-level plan (more complex) vs single slice
184
- const { milestone: mid, slice: sid } = parseUnitId(unitId);
185
- if (!sid) {
183
+ const parts = unitId.split("/");
184
+ if (parts.length === 1) {
186
185
  // Milestone-level planning is always at least standard
187
186
  return { tier: "standard", reason: "milestone-level planning" };
188
187
  }
189
188
 
190
189
  // For slice planning, try to read the context/research to gauge complexity
191
190
  // 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,8 +210,10 @@ function analyzePlanComplexity(
210
210
  */
211
211
  function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata {
212
212
  const meta: TaskMetadata = {};
213
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
214
- if (!mid || !sid || !tid) return meta;
213
+ const parts = unitId.split("/");
214
+ if (parts.length !== 3) return meta;
215
+
216
+ const [mid, sid, tid] = parts;
215
217
  const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
216
218
 
217
219
  try {
@@ -5,7 +5,6 @@ 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";
9
8
 
10
9
  const SLICE_DISPATCH_TYPES = new Set([
11
10
  "research-slice",
@@ -40,7 +39,7 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null {
40
39
  export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null {
41
40
  if (!SLICE_DISPATCH_TYPES.has(unitType)) return null;
42
41
 
43
- const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
42
+ const [targetMid, targetSid] = unitId.split("/");
44
43
  if (!targetMid || !targetSid) return null;
45
44
 
46
45
  // Use findMilestoneIds to respect custom queue order.
@@ -18,7 +18,6 @@ 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";
22
21
 
23
22
  // Re-export from shared — canonical implementation lives in format-utils.
24
23
  export { formatTokenCount } from "../shared/mod.js";
@@ -291,8 +290,9 @@ export function aggregateByPhase(units: UnitMetrics[]): PhaseAggregate[] {
291
290
  export function aggregateBySlice(units: UnitMetrics[]): SliceAggregate[] {
292
291
  const map = new Map<string, SliceAggregate>();
293
292
  for (const u of units) {
294
- const { milestone, slice } = parseUnitId(u.id);
295
- const sliceId = slice ? `${milestone}/${slice}` : milestone;
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];
296
296
  let agg = map.get(sliceId);
297
297
  if (!agg) {
298
298
  agg = { sliceId, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 };
@@ -15,7 +15,6 @@ 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";
19
18
 
20
19
  // ─── Hook Queue State ──────────────────────────────────────────────────────
21
20
 
@@ -150,7 +149,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
150
149
  };
151
150
 
152
151
  // Build the prompt with variable substitution
153
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId);
152
+ const [mid, sid, tid] = triggerUnitId.split("/");
154
153
  const prompt = config.prompt
155
154
  .replace(/\{milestoneId\}/g, mid ?? "")
156
155
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -209,14 +208,16 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null {
209
208
  * - Milestone-level (M001): .gsd/M001/{artifact}
210
209
  */
211
210
  export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
212
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
213
- if (mid && sid && tid) {
211
+ const parts = unitId.split("/");
212
+ if (parts.length === 3) {
213
+ const [mid, sid, tid] = parts;
214
214
  return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
215
215
  }
216
- if (mid && sid) {
216
+ if (parts.length === 2) {
217
+ const [mid, sid] = parts;
217
218
  return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
218
219
  }
219
- return join(gsdRoot(basePath), mid, artifactName);
220
+ return join(gsdRoot(basePath), parts[0], artifactName);
220
221
  }
221
222
 
222
223
  // ═══════════════════════════════════════════════════════════════════════════
@@ -252,7 +253,7 @@ export function runPreDispatchHooks(
252
253
  return { action: "proceed", prompt, firedHooks: [] };
253
254
  }
254
255
 
255
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
256
+ const [mid, sid, tid] = unitId.split("/");
256
257
  const substitute = (text: string): string =>
257
258
  text
258
259
  .replace(/\{milestoneId\}/g, mid ?? "")
@@ -465,7 +466,7 @@ export function triggerHookManually(
465
466
  activeHook.cycle = currentCycle;
466
467
 
467
468
  // Build the prompt with variable substitution
468
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
469
+ const [mid, sid, tid] = unitId.split("/");
469
470
  const prompt = hook.prompt
470
471
  .replace(/\{milestoneId\}/g, mid ?? "")
471
472
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -9,7 +9,6 @@ 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";
13
12
 
14
13
  /**
15
14
  * Undo the last completed unit: revert git commits, remove from completed-units,
@@ -63,10 +62,11 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
63
62
  writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8");
64
63
 
65
64
  // 3. Delete summary artifact
66
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
65
+ const parts = unitId.split("/");
67
66
  let summaryRemoved = false;
68
- if (mid && sid && tid) {
67
+ if (parts.length === 3) {
69
68
  // 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,8 +75,9 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
75
75
  summaryRemoved = true;
76
76
  }
77
77
  }
78
- } else if (mid && sid) {
78
+ } else if (parts.length === 2) {
79
79
  // Slice-level: M001/S01
80
+ const [mid, sid] = parts;
80
81
  const slicePath = resolveSlicePath(basePath, mid, sid);
81
82
  if (slicePath) {
82
83
  // Try common summary filenames
@@ -92,7 +93,8 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
92
93
 
93
94
  // 4. Uncheck task in PLAN if execute-task
94
95
  let planUpdated = false;
95
- if (unitType === "execute-task" && mid && sid && tid) {
96
+ if (unitType === "execute-task" && parts.length === 3) {
97
+ const [mid, sid, tid] = parts;
96
98
  planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid);
97
99
  }
98
100
 
@@ -9,7 +9,6 @@ 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";
13
12
 
14
13
  export type UnitRuntimePhase =
15
14
  | "dispatched"
@@ -132,7 +131,7 @@ export async function inspectExecuteTaskDurability(
132
131
  basePath: string,
133
132
  unitId: string,
134
133
  ): Promise<ExecuteTaskRecoveryStatus | null> {
135
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
134
+ const [mid, sid, tid] = unitId.split("/");
136
135
  if (!mid || !sid || !tid) return null;
137
136
 
138
137
  const planAbs = resolveSliceFile(basePath, mid, sid, "PLAN");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.32.0-dev.3d7932c",
3
+ "version": "2.32.0-dev.f3d5d53",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,7 +20,6 @@ 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";
24
23
 
25
24
  // ─── Dashboard Data ───────────────────────────────────────────────────────────
26
25
 
@@ -373,9 +372,8 @@ export function updateProgressWidget(
373
372
  lines.push("");
374
373
 
375
374
  const isHook = unitType.startsWith("hook/");
376
- const hookParsed = isHook ? parseUnitId(unitId) : undefined;
377
375
  const target = isHook
378
- ? (hookParsed!.task ?? hookParsed!.slice ?? unitId)
376
+ ? (unitId.split("/").pop() ?? unitId)
379
377
  : (task ? `${task.id}: ${task.title}` : unitId);
380
378
  const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
381
379
  const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
@@ -18,7 +18,6 @@ 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";
22
21
 
23
22
  export interface IdempotencyContext {
24
23
  s: AutoSession;
@@ -55,7 +54,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
55
54
  s.unitConsecutiveSkips.set(idempotencyKey, skipCount);
56
55
  if (skipCount > MAX_CONSECUTIVE_SKIPS) {
57
56
  // Cross-check: verify the unit's milestone is still active (#790)
58
- const skippedMid = parseUnitId(unitId).milestone;
57
+ const skippedMid = unitId.split("/")[0];
59
58
  const skippedMilestoneComplete = skippedMid
60
59
  ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY")
61
60
  : false;
@@ -111,7 +110,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult {
111
110
  const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1;
112
111
  s.unitConsecutiveSkips.set(idempotencyKey, skipCount2);
113
112
  if (skipCount2 > MAX_CONSECUTIVE_SKIPS) {
114
- const skippedMid2 = parseUnitId(unitId).milestone;
113
+ const skippedMid2 = unitId.split("/")[0];
115
114
  const skippedMilestoneComplete2 = skippedMid2
116
115
  ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY")
117
116
  : false;
@@ -12,7 +12,6 @@ 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";
16
15
 
17
16
  export async function collectObservabilityWarnings(
18
17
  ctx: ExtensionContext,
@@ -23,7 +22,10 @@ export async function collectObservabilityWarnings(
23
22
  // Hook units have custom artifacts — skip standard observability checks
24
23
  if (unitType.startsWith("hook/")) return [];
25
24
 
26
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
25
+ const parts = unitId.split("/");
26
+ const mid = parts[0];
27
+ const sid = parts[1];
28
+ const tid = parts[2];
27
29
 
28
30
  if (!mid || !sid) return [];
29
31
 
@@ -61,7 +61,6 @@ 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";
65
64
 
66
65
  /**
67
66
  * Initialize a unit dispatch: stamp the current time, set `s.currentUnit`,
@@ -135,7 +134,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
135
134
  let taskContext: TaskCommitContext | undefined;
136
135
 
137
136
  if (s.currentUnit.type === "execute-task") {
138
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
137
+ const parts = s.currentUnit.id.split("/");
138
+ const [mid, sid, tid] = parts;
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 { milestone, slice } = parseUnitId(s.currentUnit.id);
171
- const doctorScope = slice ? `${milestone}/${slice}` : milestone;
170
+ const scopeParts = s.currentUnit.id.split("/").slice(0, 2);
171
+ const doctorScope = scopeParts.join("/");
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 { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id);
351
+ const [mid, sid] = s.currentUnit.id.split("/");
352
352
  if (mid && sid) {
353
353
  const state = await deriveState(s.basePath);
354
354
  if (state.phase === "summarizing" && state.activeSlice?.id === sid) {
@@ -42,7 +42,6 @@ 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";
46
45
 
47
46
  // ─── Artifact Resolution & Verification ───────────────────────────────────────
48
47
 
@@ -50,7 +49,9 @@ import { parseUnitId } from "./unit-id.js";
50
49
  * Resolve the expected artifact for a unit to an absolute path.
51
50
  */
52
51
  export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
53
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
52
+ const parts = unitId.split("/");
53
+ const mid = parts[0]!;
54
+ const sid = parts[1];
54
55
  switch (unitType) {
55
56
  case "research-milestone": {
56
57
  const dir = resolveMilestonePath(base, mid);
@@ -77,6 +78,7 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
77
78
  return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
78
79
  }
79
80
  case "execute-task": {
81
+ const tid = parts[2];
80
82
  const dir = resolveSlicePath(base, mid, sid!);
81
83
  return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
82
84
  }
@@ -165,7 +167,10 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
165
167
 
166
168
  // execute-task must also have its checkbox marked [x] in the slice plan
167
169
  if (unitType === "execute-task") {
168
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
170
+ const parts = unitId.split("/");
171
+ const mid = parts[0];
172
+ const sid = parts[1];
173
+ const tid = parts[2];
169
174
  if (mid && sid && tid) {
170
175
  const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
171
176
  if (planAbs && existsSync(planAbs)) {
@@ -182,7 +187,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
182
187
  // but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task
183
188
  // to dispatch with a missing task plan (see issue #739).
184
189
  if (unitType === "plan-slice") {
185
- const { milestone: mid, slice: sid } = parseUnitId(unitId);
190
+ const parts = unitId.split("/");
191
+ const mid = parts[0];
192
+ const sid = parts[1];
186
193
  if (mid && sid) {
187
194
  try {
188
195
  const planContent = readFileSync(absPath, "utf-8");
@@ -206,8 +213,9 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
206
213
  // state machine keeps returning the same complete-slice unit (roadmap still shows
207
214
  // the slice incomplete), so dispatchNextUnit recurses forever.
208
215
  if (unitType === "complete-slice") {
209
- const { milestone: mid, slice: sid } = parseUnitId(unitId);
210
-
216
+ const parts = unitId.split("/");
217
+ const mid = parts[0];
218
+ const sid = parts[1];
211
219
  if (mid && sid) {
212
220
  const dir = resolveSlicePath(base, mid, sid);
213
221
  if (dir) {
@@ -260,7 +268,9 @@ export function writeBlockerPlaceholder(unitType: string, unitId: string, base:
260
268
  }
261
269
 
262
270
  export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
263
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
271
+ const parts = unitId.split("/");
272
+ const mid = parts[0];
273
+ const sid = parts[1];
264
274
  switch (unitType) {
265
275
  case "research-milestone":
266
276
  return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
@@ -271,6 +281,7 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base:
271
281
  case "plan-slice":
272
282
  return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
273
283
  case "execute-task": {
284
+ const tid = parts[2];
274
285
  return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
275
286
  }
276
287
  case "complete-slice":
@@ -528,7 +539,10 @@ export async function selfHealRuntimeRecords(
528
539
  * These are shown when automatic reconciliation is not possible.
529
540
  */
530
541
  export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null {
531
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
542
+ const parts = unitId.split("/");
543
+ const mid = parts[0];
544
+ const sid = parts[1];
545
+ const tid = parts[2];
532
546
  switch (unitType) {
533
547
  case "execute-task": {
534
548
  if (!mid || !sid || !tid) break;
@@ -64,7 +64,6 @@ import type { AutoSession } from "./auto/session.js";
64
64
  import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
65
65
  import { join } from "node:path";
66
66
  import { getErrorMessage } from "./error-utils.js";
67
- import { parseUnitId } from "./unit-id.js";
68
67
 
69
68
  export interface BootstrapDeps {
70
69
  shouldUseWorktreeIsolation: () => boolean;
@@ -140,7 +139,7 @@ export async function bootstrapAutoSession(
140
139
  if (crashLock && crashLock.pid !== process.pid) {
141
140
  // We already hold the session lock, so no concurrent session is running.
142
141
  // The crash lock is from a dead process — recover context from it.
143
- const recoveredMid = parseUnitId(crashLock.unitId).milestone;
142
+ const recoveredMid = crashLock.unitId.split("/")[0];
144
143
  const milestoneAlreadyComplete = recoveredMid
145
144
  ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY")
146
145
  : false;
@@ -39,7 +39,6 @@ import {
39
39
  import type { AutoSession } from "./auto/session.js";
40
40
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
41
41
  import { join } from "node:path";
42
- import { parseUnitId } from "./unit-id.js";
43
42
 
44
43
  export interface StuckContext {
45
44
  s: AutoSession;
@@ -100,7 +99,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckRes
100
99
 
101
100
  // Final reconciliation pass for execute-task
102
101
  if (unitType === "execute-task") {
103
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
102
+ const [mid, sid, tid] = unitId.split("/");
104
103
  if (mid && sid && tid) {
105
104
  const status = await inspectExecuteTaskDurability(basePath, unitId);
106
105
  if (status) {
@@ -169,7 +168,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckRes
169
168
  // Adaptive self-repair: each retry attempts a different remediation step.
170
169
  if (unitType === "execute-task") {
171
170
  const status = await inspectExecuteTaskDurability(basePath, unitId);
172
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
171
+ const [mid, sid, tid] = unitId.split("/");
173
172
  if (status && mid && sid && tid) {
174
173
  if (status.summaryExists && !status.taskChecked) {
175
174
  const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
@@ -18,7 +18,6 @@ import {
18
18
  writeBlockerPlaceholder,
19
19
  } from "./auto-recovery.js";
20
20
  import { existsSync } from "node:fs";
21
- import { parseUnitId } from "./unit-id.js";
22
21
 
23
22
  export interface RecoveryContext {
24
23
  basePath: string;
@@ -129,7 +128,7 @@ export async function recoverTimedOutUnit(
129
128
 
130
129
  // Retries exhausted — write missing durable artifacts and advance.
131
130
  const diagnostic = formatExecuteTaskRecoveryStatus(status);
132
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
131
+ const [mid, sid, tid] = unitId.split("/");
133
132
  const skipped = mid && sid && tid
134
133
  ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
135
134
  : false;
@@ -25,7 +25,6 @@ import { removePersistedKey } from "./auto-recovery.js";
25
25
  import type { AutoSession, PendingVerificationRetry } from "./auto/session.js";
26
26
  import { join } from "node:path";
27
27
  import { getErrorMessage } from "./error-utils.js";
28
- import { parseUnitId } from "./unit-id.js";
29
28
 
30
29
  export interface VerificationContext {
31
30
  s: AutoSession;
@@ -59,9 +58,10 @@ export async function runPostUnitVerification(
59
58
  const prefs = effectivePrefs?.preferences;
60
59
 
61
60
  // Read task plan verify field
62
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
61
+ const parts = s.currentUnit.id.split("/");
63
62
  let taskPlanVerify: string | undefined;
64
- if (mid && sid && tid) {
63
+ if (parts.length >= 3) {
64
+ const [mid, sid, tid] = parts;
65
65
  const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN");
66
66
  if (planFile) {
67
67
  const planContent = await loadFile(planFile);
@@ -153,8 +153,9 @@ export async function runPostUnitVerification(
153
153
 
154
154
  // Write verification evidence JSON
155
155
  const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0;
156
- if (mid && sid && tid) {
156
+ if (parts.length >= 3) {
157
157
  try {
158
+ const [mid, sid, tid] = parts;
158
159
  const sDir = resolveSlicePath(s.basePath, mid, sid);
159
160
  if (sDir) {
160
161
  const tasksDir = join(sDir, "tasks");
@@ -105,7 +105,6 @@ 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";
109
108
  import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
110
109
  import { atomicWriteSync } from "./atomic-write.js";
111
110
  import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js";
@@ -1749,7 +1748,8 @@ async function dispatchNextUnit(
1749
1748
  function ensurePreconditions(
1750
1749
  unitType: string, unitId: string, base: string, state: GSDState,
1751
1750
  ): void {
1752
- const { milestone: mid } = parseUnitId(unitId);
1751
+ const parts = unitId.split("/");
1752
+ const mid = parts[0]!;
1753
1753
 
1754
1754
  const mDir = resolveMilestonePath(base, mid);
1755
1755
  if (!mDir) {
@@ -1757,8 +1757,8 @@ function ensurePreconditions(
1757
1757
  mkdirSync(join(newDir, "slices"), { recursive: true });
1758
1758
  }
1759
1759
 
1760
- const sid = parseUnitId(unitId).slice;
1761
- if (sid) {
1760
+ if (parts.length >= 2) {
1761
+ const sid = parts[1]!;
1762
1762
 
1763
1763
  const mDirResolved = resolveMilestonePath(base, mid);
1764
1764
  if (mDirResolved) {
@@ -6,7 +6,6 @@ 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";
10
9
 
11
10
  // ─── Types ───────────────────────────────────────────────────────────────────
12
11
 
@@ -181,14 +180,15 @@ function analyzePlanComplexity(
181
180
  basePath: string,
182
181
  ): TaskAnalysis | null {
183
182
  // Check if this is a milestone-level plan (more complex) vs single slice
184
- const { milestone: mid, slice: sid } = parseUnitId(unitId);
185
- if (!sid) {
183
+ const parts = unitId.split("/");
184
+ if (parts.length === 1) {
186
185
  // Milestone-level planning is always at least standard
187
186
  return { tier: "standard", reason: "milestone-level planning" };
188
187
  }
189
188
 
190
189
  // For slice planning, try to read the context/research to gauge complexity
191
190
  // 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,8 +210,10 @@ function analyzePlanComplexity(
210
210
  */
211
211
  function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata {
212
212
  const meta: TaskMetadata = {};
213
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
214
- if (!mid || !sid || !tid) return meta;
213
+ const parts = unitId.split("/");
214
+ if (parts.length !== 3) return meta;
215
+
216
+ const [mid, sid, tid] = parts;
215
217
  const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`);
216
218
 
217
219
  try {
@@ -5,7 +5,6 @@ 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";
9
8
 
10
9
  const SLICE_DISPATCH_TYPES = new Set([
11
10
  "research-slice",
@@ -40,7 +39,7 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null {
40
39
  export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null {
41
40
  if (!SLICE_DISPATCH_TYPES.has(unitType)) return null;
42
41
 
43
- const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId);
42
+ const [targetMid, targetSid] = unitId.split("/");
44
43
  if (!targetMid || !targetSid) return null;
45
44
 
46
45
  // Use findMilestoneIds to respect custom queue order.
@@ -18,7 +18,6 @@ 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";
22
21
 
23
22
  // Re-export from shared — canonical implementation lives in format-utils.
24
23
  export { formatTokenCount } from "../shared/mod.js";
@@ -291,8 +290,9 @@ export function aggregateByPhase(units: UnitMetrics[]): PhaseAggregate[] {
291
290
  export function aggregateBySlice(units: UnitMetrics[]): SliceAggregate[] {
292
291
  const map = new Map<string, SliceAggregate>();
293
292
  for (const u of units) {
294
- const { milestone, slice } = parseUnitId(u.id);
295
- const sliceId = slice ? `${milestone}/${slice}` : milestone;
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];
296
296
  let agg = map.get(sliceId);
297
297
  if (!agg) {
298
298
  agg = { sliceId, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 };
@@ -15,7 +15,6 @@ 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";
19
18
 
20
19
  // ─── Hook Queue State ──────────────────────────────────────────────────────
21
20
 
@@ -150,7 +149,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null {
150
149
  };
151
150
 
152
151
  // Build the prompt with variable substitution
153
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId);
152
+ const [mid, sid, tid] = triggerUnitId.split("/");
154
153
  const prompt = config.prompt
155
154
  .replace(/\{milestoneId\}/g, mid ?? "")
156
155
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -209,14 +208,16 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null {
209
208
  * - Milestone-level (M001): .gsd/M001/{artifact}
210
209
  */
211
210
  export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
212
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
213
- if (mid && sid && tid) {
211
+ const parts = unitId.split("/");
212
+ if (parts.length === 3) {
213
+ const [mid, sid, tid] = parts;
214
214
  return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
215
215
  }
216
- if (mid && sid) {
216
+ if (parts.length === 2) {
217
+ const [mid, sid] = parts;
217
218
  return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
218
219
  }
219
- return join(gsdRoot(basePath), mid, artifactName);
220
+ return join(gsdRoot(basePath), parts[0], artifactName);
220
221
  }
221
222
 
222
223
  // ═══════════════════════════════════════════════════════════════════════════
@@ -252,7 +253,7 @@ export function runPreDispatchHooks(
252
253
  return { action: "proceed", prompt, firedHooks: [] };
253
254
  }
254
255
 
255
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
256
+ const [mid, sid, tid] = unitId.split("/");
256
257
  const substitute = (text: string): string =>
257
258
  text
258
259
  .replace(/\{milestoneId\}/g, mid ?? "")
@@ -465,7 +466,7 @@ export function triggerHookManually(
465
466
  activeHook.cycle = currentCycle;
466
467
 
467
468
  // Build the prompt with variable substitution
468
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
469
+ const [mid, sid, tid] = unitId.split("/");
469
470
  const prompt = hook.prompt
470
471
  .replace(/\{milestoneId\}/g, mid ?? "")
471
472
  .replace(/\{sliceId\}/g, sid ?? "")
@@ -9,7 +9,6 @@ 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";
13
12
 
14
13
  /**
15
14
  * Undo the last completed unit: revert git commits, remove from completed-units,
@@ -63,10 +62,11 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
63
62
  writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8");
64
63
 
65
64
  // 3. Delete summary artifact
66
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
65
+ const parts = unitId.split("/");
67
66
  let summaryRemoved = false;
68
- if (mid && sid && tid) {
67
+ if (parts.length === 3) {
69
68
  // 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,8 +75,9 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
75
75
  summaryRemoved = true;
76
76
  }
77
77
  }
78
- } else if (mid && sid) {
78
+ } else if (parts.length === 2) {
79
79
  // Slice-level: M001/S01
80
+ const [mid, sid] = parts;
80
81
  const slicePath = resolveSlicePath(basePath, mid, sid);
81
82
  if (slicePath) {
82
83
  // Try common summary filenames
@@ -92,7 +93,8 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
92
93
 
93
94
  // 4. Uncheck task in PLAN if execute-task
94
95
  let planUpdated = false;
95
- if (unitType === "execute-task" && mid && sid && tid) {
96
+ if (unitType === "execute-task" && parts.length === 3) {
97
+ const [mid, sid, tid] = parts;
96
98
  planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid);
97
99
  }
98
100
 
@@ -9,7 +9,6 @@ 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";
13
12
 
14
13
  export type UnitRuntimePhase =
15
14
  | "dispatched"
@@ -132,7 +131,7 @@ export async function inspectExecuteTaskDurability(
132
131
  basePath: string,
133
132
  unitId: string,
134
133
  ): Promise<ExecuteTaskRecoveryStatus | null> {
135
- const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
134
+ const [mid, sid, tid] = unitId.split("/");
136
135
  if (!mid || !sid || !tid) return null;
137
136
 
138
137
  const planAbs = resolveSliceFile(basePath, mid, sid, "PLAN");
@@ -1,14 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
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
- }