refactor: extract parseUnitId() to centralize unit ID parsing (#1282)
Replaces 30+ inline `unitId.split("/")` + destructuring patterns across
16 production files with a single `parseUnitId()` helper that returns
`{ milestone, slice?, task? }`. If the unit ID format ever changes,
only one function needs updating.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
afb438164e
commit
e6ab3b6722
17 changed files with 75 additions and 75 deletions
|
|
@ -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}] `) : "";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<StuckRes
|
|||
|
||||
// Final reconciliation pass for execute-task
|
||||
if (unitType === "execute-task") {
|
||||
const [mid, sid, tid] = unitId.split("/");
|
||||
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
||||
if (mid && sid && tid) {
|
||||
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
||||
if (status) {
|
||||
|
|
@ -168,7 +169,7 @@ export async function checkStuckAndRecover(sctx: StuckContext): Promise<StuckRes
|
|||
// Adaptive self-repair: each retry attempts a different remediation step.
|
||||
if (unitType === "execute-task") {
|
||||
const status = await inspectExecuteTaskDurability(basePath, unitId);
|
||||
const [mid, sid, tid] = unitId.split("/");
|
||||
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
||||
if (status && mid && sid && tid) {
|
||||
if (status.summaryExists && !status.taskChecked) {
|
||||
const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
writeBlockerPlaceholder,
|
||||
} from "./auto-recovery.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { parseUnitId } from "./unit-id.js";
|
||||
|
||||
export interface RecoveryContext {
|
||||
basePath: string;
|
||||
|
|
@ -128,7 +129,7 @@ export async function recoverTimedOutUnit(
|
|||
|
||||
// Retries exhausted — write missing durable artifacts and advance.
|
||||
const diagnostic = formatExecuteTaskRecoveryStatus(status);
|
||||
const [mid, sid, tid] = unitId.split("/");
|
||||
const { milestone: mid, slice: sid, task: tid } = parseUnitId(unitId);
|
||||
const skipped = mid && sid && tid
|
||||
? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
|
||||
: false;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { removePersistedKey } from "./auto-recovery.js";
|
|||
import type { AutoSession, PendingVerificationRetry } from "./auto/session.js";
|
||||
import { join } from "node:path";
|
||||
import { getErrorMessage } from "./error-utils.js";
|
||||
import { parseUnitId } from "./unit-id.js";
|
||||
|
||||
export interface VerificationContext {
|
||||
s: AutoSession;
|
||||
|
|
@ -58,10 +59,9 @@ export async function runPostUnitVerification(
|
|||
const prefs = effectivePrefs?.preferences;
|
||||
|
||||
// Read task plan verify field
|
||||
const parts = s.currentUnit.id.split("/");
|
||||
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
|
||||
let taskPlanVerify: string | undefined;
|
||||
if (parts.length >= 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");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, SliceAggregate>();
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
14
src/resources/extensions/gsd/unit-id.ts
Normal file
14
src/resources/extensions/gsd/unit-id.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<ExecuteTaskRecoveryStatus | null> {
|
||||
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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue