diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index d2aeb5193..1c65b3cbd 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -20,6 +20,7 @@ import { parseRoadmap, parsePlan } from "./files.js"; import { readFileSync, existsSync } from "node:fs"; import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; import { makeUI, GLYPH, INDENT } from "../shared/mod.js"; +import { parseUnitId } from "./unit-id.js"; // ─── Dashboard Data ─────────────────────────────────────────────────────────── @@ -372,8 +373,9 @@ export function updateProgressWidget( lines.push(""); const isHook = unitType.startsWith("hook/"); + const hookParsed = isHook ? parseUnitId(unitId) : undefined; const target = isHook - ? (unitId.split("/").pop() ?? unitId) + ? (hookParsed!.task ?? hookParsed!.slice ?? unitId) : (task ? `${task.id}: ${task.title}` : unitId); const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : ""; diff --git a/src/resources/extensions/gsd/auto-idempotency.ts b/src/resources/extensions/gsd/auto-idempotency.ts index 2923ed7ff..8edc001b9 100644 --- a/src/resources/extensions/gsd/auto-idempotency.ts +++ b/src/resources/extensions/gsd/auto-idempotency.ts @@ -18,6 +18,7 @@ import { import { resolveMilestoneFile } from "./paths.js"; import { MAX_CONSECUTIVE_SKIPS, MAX_LIFETIME_DISPATCHES } from "./auto/session.js"; import type { AutoSession } from "./auto/session.js"; +import { parseUnitId } from "./unit-id.js"; export interface IdempotencyContext { s: AutoSession; @@ -54,7 +55,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult { s.unitConsecutiveSkips.set(idempotencyKey, skipCount); if (skipCount > MAX_CONSECUTIVE_SKIPS) { // Cross-check: verify the unit's milestone is still active (#790) - const skippedMid = unitId.split("/")[0]; + const skippedMid = parseUnitId(unitId).milestone; const skippedMilestoneComplete = skippedMid ? !!resolveMilestoneFile(basePath, skippedMid, "SUMMARY") : false; @@ -110,7 +111,7 @@ export function checkIdempotency(ictx: IdempotencyContext): IdempotencyResult { const skipCount2 = (s.unitConsecutiveSkips.get(idempotencyKey) ?? 0) + 1; s.unitConsecutiveSkips.set(idempotencyKey, skipCount2); if (skipCount2 > MAX_CONSECUTIVE_SKIPS) { - const skippedMid2 = unitId.split("/")[0]; + const skippedMid2 = parseUnitId(unitId).milestone; const skippedMilestoneComplete2 = skippedMid2 ? !!resolveMilestoneFile(basePath, skippedMid2, "SUMMARY") : false; diff --git a/src/resources/extensions/gsd/auto-observability.ts b/src/resources/extensions/gsd/auto-observability.ts index ddcc0bf3d..0715a9ac4 100644 --- a/src/resources/extensions/gsd/auto-observability.ts +++ b/src/resources/extensions/gsd/auto-observability.ts @@ -12,6 +12,7 @@ import { formatValidationIssues, } from "./observability-validator.js"; import type { ValidationIssue } from "./observability-validator.js"; +import { parseUnitId } from "./unit-id.js"; export async function collectObservabilityWarnings( ctx: ExtensionContext, @@ -22,10 +23,7 @@ export async function collectObservabilityWarnings( // Hook units have custom artifacts — skip standard observability checks if (unitType.startsWith("hook/")) return []; - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; - const tid = parts[2]; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); if (!mid || !sid) return []; diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 4a225cb62..e4a2f4820 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -61,6 +61,7 @@ import { } from "./auto-dashboard.js"; import { join } from "node:path"; import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js"; +import { parseUnitId } from "./unit-id.js"; /** * Initialize a unit dispatch: stamp the current time, set `s.currentUnit`, @@ -134,8 +135,7 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d let taskContext: TaskCommitContext | undefined; if (s.currentUnit.type === "execute-task") { - const parts = s.currentUnit.id.split("/"); - const [mid, sid, tid] = parts; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id); if (mid && sid && tid) { const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY"); if (summaryPath) { @@ -167,8 +167,8 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d // Doctor: fix mechanical bookkeeping try { - const scopeParts = s.currentUnit.id.split("/").slice(0, 2); - const doctorScope = scopeParts.join("/"); + const { milestone, slice } = parseUnitId(s.currentUnit.id); + const doctorScope = slice ? `${milestone}/${slice}` : milestone; const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]); const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const; const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); @@ -348,7 +348,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<" // instead of dispatching LLM sessions for complete-slice / validate-milestone. if (s.currentUnit?.type === "execute-task" && !s.stepMode) { try { - const [mid, sid] = s.currentUnit.id.split("/"); + const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit.id); if (mid && sid) { const state = await deriveState(s.basePath); if (state.phase === "summarizing" && state.activeSlice?.id === sid) { diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 119589c31..82fc1067a 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -42,6 +42,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from " import { atomicWriteSync } from "./atomic-write.js"; import { loadJsonFileOrNull } from "./json-persistence.js"; import { dirname, join } from "node:path"; +import { parseUnitId } from "./unit-id.js"; // ─── Artifact Resolution & Verification ─────────────────────────────────────── @@ -49,9 +50,7 @@ import { dirname, join } from "node:path"; * Resolve the expected artifact for a unit to an absolute path. */ export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null { - const parts = unitId.split("/"); - const mid = parts[0]!; - const sid = parts[1]; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); switch (unitType) { case "research-milestone": { const dir = resolveMilestonePath(base, mid); @@ -78,7 +77,6 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null; } case "execute-task": { - const tid = parts[2]; const dir = resolveSlicePath(base, mid, sid!); return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null; } @@ -167,10 +165,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s // execute-task must also have its checkbox marked [x] in the slice plan if (unitType === "execute-task") { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; - const tid = parts[2]; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); if (mid && sid && tid) { const planAbs = resolveSliceFile(base, mid, sid, "PLAN"); if (planAbs && existsSync(planAbs)) { @@ -187,9 +182,7 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s // but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task // to dispatch with a missing task plan (see issue #739). if (unitType === "plan-slice") { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; + const { milestone: mid, slice: sid } = parseUnitId(unitId); if (mid && sid) { try { const planContent = readFileSync(absPath, "utf-8"); @@ -213,9 +206,8 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s // state machine keeps returning the same complete-slice unit (roadmap still shows // the slice incomplete), so dispatchNextUnit recurses forever. if (unitType === "complete-slice") { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; + const { milestone: mid, slice: sid } = parseUnitId(unitId); + if (mid && sid) { const dir = resolveSlicePath(base, mid, sid); if (dir) { @@ -268,9 +260,7 @@ export function writeBlockerPlaceholder(unitType: string, unitId: string, base: } export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); switch (unitType) { case "research-milestone": return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`; @@ -281,7 +271,6 @@ export function diagnoseExpectedArtifact(unitType: string, unitId: string, base: case "plan-slice": return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`; case "execute-task": { - const tid = parts[2]; return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`; } case "complete-slice": @@ -539,10 +528,7 @@ export async function selfHealRuntimeRecords( * These are shown when automatic reconciliation is not possible. */ export function buildLoopRemediationSteps(unitType: string, unitId: string, base: string): string | null { - const parts = unitId.split("/"); - const mid = parts[0]; - const sid = parts[1]; - const tid = parts[2]; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); switch (unitType) { case "execute-task": { if (!mid || !sid || !tid) break; diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 97395d768..067300534 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -64,6 +64,7 @@ import type { AutoSession } from "./auto/session.js"; import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { getErrorMessage } from "./error-utils.js"; +import { parseUnitId } from "./unit-id.js"; export interface BootstrapDeps { shouldUseWorktreeIsolation: () => boolean; @@ -139,7 +140,7 @@ export async function bootstrapAutoSession( if (crashLock && crashLock.pid !== process.pid) { // We already hold the session lock, so no concurrent session is running. // The crash lock is from a dead process — recover context from it. - const recoveredMid = crashLock.unitId.split("/")[0]; + const recoveredMid = parseUnitId(crashLock.unitId).milestone; const milestoneAlreadyComplete = recoveredMid ? !!resolveMilestoneFile(base, recoveredMid, "SUMMARY") : false; diff --git a/src/resources/extensions/gsd/auto-stuck-detection.ts b/src/resources/extensions/gsd/auto-stuck-detection.ts index 183436d81..b8ec5d954 100644 --- a/src/resources/extensions/gsd/auto-stuck-detection.ts +++ b/src/resources/extensions/gsd/auto-stuck-detection.ts @@ -39,6 +39,7 @@ import { import type { AutoSession } from "./auto/session.js"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { parseUnitId } from "./unit-id.js"; export interface StuckContext { s: AutoSession; @@ -99,7 +100,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise= 3) { - const [mid, sid, tid] = parts; + if (mid && sid && tid) { const planFile = resolveSliceFile(s.basePath, mid, sid, "PLAN"); if (planFile) { const planContent = await loadFile(planFile); @@ -153,9 +153,8 @@ export async function runPostUnitVerification( // Write verification evidence JSON const attempt = s.verificationRetryCount.get(s.currentUnit.id) ?? 0; - if (parts.length >= 3) { + if (mid && sid && tid) { try { - const [mid, sid, tid] = parts; const sDir = resolveSlicePath(s.basePath, mid, sid); if (sDir) { const tasksDir = join(sDir, "tasks"); diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 1ac433bb9..1d90893ad 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -105,6 +105,7 @@ import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.j import { GSDError, GSD_ARTIFACT_MISSING } from "./errors.js"; import { join } from "node:path"; import { sep as pathSep } from "node:path"; +import { parseUnitId } from "./unit-id.js"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs"; import { atomicWriteSync } from "./atomic-write.js"; import { nativeIsRepo, nativeInit, nativeAddAll, nativeCommit } from "./native-git-bridge.js"; @@ -1748,8 +1749,7 @@ async function dispatchNextUnit( function ensurePreconditions( unitType: string, unitId: string, base: string, state: GSDState, ): void { - const parts = unitId.split("/"); - const mid = parts[0]!; + const { milestone: mid } = parseUnitId(unitId); const mDir = resolveMilestonePath(base, mid); if (!mDir) { @@ -1757,8 +1757,8 @@ function ensurePreconditions( mkdirSync(join(newDir, "slices"), { recursive: true }); } - if (parts.length >= 2) { - const sid = parts[1]!; + const sid = parseUnitId(unitId).slice; + if (sid) { const mDirResolved = resolveMilestonePath(base, mid); if (mDirResolved) { diff --git a/src/resources/extensions/gsd/complexity-classifier.ts b/src/resources/extensions/gsd/complexity-classifier.ts index 03ca0049e..17f2bc190 100644 --- a/src/resources/extensions/gsd/complexity-classifier.ts +++ b/src/resources/extensions/gsd/complexity-classifier.ts @@ -6,6 +6,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; import { getAdaptiveTierAdjustment } from "./routing-history.js"; +import { parseUnitId } from "./unit-id.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -180,15 +181,14 @@ function analyzePlanComplexity( basePath: string, ): TaskAnalysis | null { // Check if this is a milestone-level plan (more complex) vs single slice - const parts = unitId.split("/"); - if (parts.length === 1) { + const { milestone: mid, slice: sid } = parseUnitId(unitId); + if (!sid) { // Milestone-level planning is always at least standard return { tier: "standard", reason: "milestone-level planning" }; } // For slice planning, try to read the context/research to gauge complexity // If research exists and is large, bump to heavy - const [mid, sid] = parts; const researchPath = join(gsdRoot(basePath), mid, "slices", sid, "RESEARCH.md"); try { if (existsSync(researchPath)) { @@ -210,10 +210,8 @@ function analyzePlanComplexity( */ function extractTaskMetadata(unitId: string, basePath: string): TaskMetadata { const meta: TaskMetadata = {}; - const parts = unitId.split("/"); - if (parts.length !== 3) return meta; - - const [mid, sid, tid] = parts; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + if (!mid || !sid || !tid) return meta; const taskPlanPath = join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-PLAN.md`); try { diff --git a/src/resources/extensions/gsd/dispatch-guard.ts b/src/resources/extensions/gsd/dispatch-guard.ts index 94e9a550b..d12f12a37 100644 --- a/src/resources/extensions/gsd/dispatch-guard.ts +++ b/src/resources/extensions/gsd/dispatch-guard.ts @@ -5,6 +5,7 @@ import { readdirSync } from "node:fs"; import { resolveMilestoneFile, milestonesDir } from "./paths.js"; import { parseRoadmapSlices } from "./roadmap-slices.js"; import { findMilestoneIds } from "./guided-flow.js"; +import { parseUnitId } from "./unit-id.js"; const SLICE_DISPATCH_TYPES = new Set([ "research-slice", @@ -39,7 +40,7 @@ function readRoadmapFromDisk(base: string, milestoneId: string): string | null { export function getPriorSliceCompletionBlocker(base: string, _mainBranch: string, unitType: string, unitId: string): string | null { if (!SLICE_DISPATCH_TYPES.has(unitType)) return null; - const [targetMid, targetSid] = unitId.split("/"); + const { milestone: targetMid, slice: targetSid } = parseUnitId(unitId); if (!targetMid || !targetSid) return null; // Use findMilestoneIds to respect custom queue order. diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 197763e0b..f17195a2b 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -18,6 +18,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { gsdRoot } from "./paths.js"; import { getAndClearSkills } from "./skill-telemetry.js"; import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; +import { parseUnitId } from "./unit-id.js"; // Re-export from shared — canonical implementation lives in format-utils. export { formatTokenCount } from "../shared/mod.js"; @@ -290,9 +291,8 @@ export function aggregateByPhase(units: UnitMetrics[]): PhaseAggregate[] { export function aggregateBySlice(units: UnitMetrics[]): SliceAggregate[] { const map = new Map(); for (const u of units) { - const parts = u.id.split("/"); - // Slice ID is parts[0]/parts[1] if it exists, else parts[0] - const sliceId = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0]; + const { milestone, slice } = parseUnitId(u.id); + const sliceId = slice ? `${milestone}/${slice}` : milestone; let agg = map.get(sliceId); if (!agg) { agg = { sliceId, units: 0, tokens: emptyTokens(), cost: 0, duration: 0 }; diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts index 0dd2a4d92..05dcb5286 100644 --- a/src/resources/extensions/gsd/post-unit-hooks.ts +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -15,6 +15,7 @@ import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js" import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; +import { parseUnitId } from "./unit-id.js"; // ─── Hook Queue State ────────────────────────────────────────────────────── @@ -149,7 +150,7 @@ function dequeueNextHook(basePath: string): HookDispatchResult | null { }; // Build the prompt with variable substitution - const [mid, sid, tid] = triggerUnitId.split("/"); + const { milestone: mid, slice: sid, task: tid } = parseUnitId(triggerUnitId); const prompt = config.prompt .replace(/\{milestoneId\}/g, mid ?? "") .replace(/\{sliceId\}/g, sid ?? "") @@ -208,16 +209,14 @@ function handleHookCompletion(basePath: string): HookDispatchResult | null { * - Milestone-level (M001): .gsd/M001/{artifact} */ export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string { - const parts = unitId.split("/"); - if (parts.length === 3) { - const [mid, sid, tid] = parts; + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); + if (mid && sid && tid) { return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`); } - if (parts.length === 2) { - const [mid, sid] = parts; + if (mid && sid) { return join(gsdRoot(basePath), mid, "slices", sid, artifactName); } - return join(gsdRoot(basePath), parts[0], artifactName); + return join(gsdRoot(basePath), mid, artifactName); } // ═══════════════════════════════════════════════════════════════════════════ @@ -253,7 +252,7 @@ export function runPreDispatchHooks( return { action: "proceed", prompt, firedHooks: [] }; } - const [mid, sid, tid] = unitId.split("/"); + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); const substitute = (text: string): string => text .replace(/\{milestoneId\}/g, mid ?? "") @@ -466,7 +465,7 @@ export function triggerHookManually( activeHook.cycle = currentCycle; // Build the prompt with variable substitution - const [mid, sid, tid] = unitId.split("/"); + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); const prompt = hook.prompt .replace(/\{milestoneId\}/g, mid ?? "") .replace(/\{sliceId\}/g, sid ?? "") diff --git a/src/resources/extensions/gsd/undo.ts b/src/resources/extensions/gsd/undo.ts index bf6f5f39a..fa3815d57 100644 --- a/src/resources/extensions/gsd/undo.ts +++ b/src/resources/extensions/gsd/undo.ts @@ -9,6 +9,7 @@ import { deriveState } from "./state.js"; import { invalidateAllCaches } from "./cache.js"; import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js"; import { sendDesktopNotification } from "./notifications.js"; +import { parseUnitId } from "./unit-id.js"; /** * 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 writeFileSync(completedKeysFile, JSON.stringify(keys), "utf-8"); // 3. Delete summary artifact - const parts = unitId.split("/"); + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); let summaryRemoved = false; - if (parts.length === 3) { + if (mid && sid && tid) { // Task-level: M001/S01/T01 - const [mid, sid, tid] = parts; const tasksDir = resolveTasksDir(basePath, mid, sid); if (tasksDir) { const summaryFile = join(tasksDir, buildTaskFileName(tid, "SUMMARY")); @@ -75,9 +75,8 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi summaryRemoved = true; } } - } else if (parts.length === 2) { + } else if (mid && sid) { // Slice-level: M001/S01 - const [mid, sid] = parts; const slicePath = resolveSlicePath(basePath, mid, sid); if (slicePath) { // Try common summary filenames @@ -93,8 +92,7 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi // 4. Uncheck task in PLAN if execute-task let planUpdated = false; - if (unitType === "execute-task" && parts.length === 3) { - const [mid, sid, tid] = parts; + if (unitType === "execute-task" && mid && sid && tid) { planUpdated = uncheckTaskInPlan(basePath, mid, sid, tid); } diff --git a/src/resources/extensions/gsd/unit-id.ts b/src/resources/extensions/gsd/unit-id.ts new file mode 100644 index 000000000..8ade330b0 --- /dev/null +++ b/src/resources/extensions/gsd/unit-id.ts @@ -0,0 +1,14 @@ +// GSD Extension — Unit ID Parsing +// Centralizes the milestone/slice/task decomposition of unit ID strings. + +export interface ParsedUnitId { + milestone: string; + slice?: string; + task?: string; +} + +/** Parse a unit ID string (e.g. "M1/S1/T1") into its milestone, slice, and task components. */ +export function parseUnitId(unitId: string): ParsedUnitId { + const [milestone, slice, task] = unitId.split("/"); + return { milestone: milestone!, slice, task }; +} diff --git a/src/resources/extensions/gsd/unit-runtime.ts b/src/resources/extensions/gsd/unit-runtime.ts index 04c027d5e..ba1dbef55 100644 --- a/src/resources/extensions/gsd/unit-runtime.ts +++ b/src/resources/extensions/gsd/unit-runtime.ts @@ -9,6 +9,7 @@ import { } from "./paths.js"; import { loadFile, parseTaskPlanMustHaves, countMustHavesMentionedInSummary } from "./files.js"; import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; +import { parseUnitId } from "./unit-id.js"; export type UnitRuntimePhase = | "dispatched" @@ -131,7 +132,7 @@ export async function inspectExecuteTaskDurability( basePath: string, unitId: string, ): Promise { - const [mid, sid, tid] = unitId.split("/"); + const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId); if (!mid || !sid || !tid) return null; const planAbs = resolveSliceFile(basePath, mid, sid, "PLAN");