chore(sf): delete orphaned commands-debug + debug-session-store

`/sf debug` was ported in 360208cba but never wired up:

- handleDebug exported but no caller anywhere in the tree
- not in commands/catalog.ts
- loadPrompt("debug-session-manager") and loadPrompt("debug-diagnose")
  referenced prompts that never existed in prompts/ — guaranteed
  runtime crash if the dispatch path were ever hit
- debug-session-store.ts only consumed by commands-debug.ts
- no tests reference any of it

887 LOC of dead code with a latent crash. Removing both files
eliminates the orphan-prompt callsite that gap-audit kept flagging
and the broken dispatch path. Resolves sf-moohvyzc-ll5bd0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 17:42:28 +02:00
parent e07f2bc225
commit 1891ccbdcd
2 changed files with 0 additions and 887 deletions

View file

@ -1,510 +0,0 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@singularity-forge/pi-coding-agent";
import {
assertValidDebugSessionSlug,
createDebugSession,
listDebugSessions,
loadDebugSession,
updateDebugSession,
type DebugTddGate,
type DebugSpecialistReview,
} from "./debug-session-store.js";
import { loadPrompt } from "./prompt-loader.js";
export type DebugCommandIntent
= { type: "usage" }
| { type: "issue-start"; issue: string }
| { type: "list" }
| { type: "status"; slug: string }
| { type: "continue"; slug: string }
| { type: "diagnose"; slug?: string }
| { type: "diagnose-issue"; issue: string }
| { type: "error"; message: string };
const SUBCOMMANDS = new Set(["list", "status", "continue", "--diagnose"]);
function isValidSlugCandidate(input: string): boolean {
try {
assertValidDebugSessionSlug(input);
return true;
} catch {
return false;
}
}
function formatSessionLine(prefix: string, session: {
slug: string;
mode: string;
status: string;
phase: string;
issue: string;
updatedAt: number;
}): string {
return `${prefix} ${session.slug} [mode=${session.mode} status=${session.status} phase=${session.phase}] — ${session.issue} (updated ${new Date(session.updatedAt).toISOString()})`;
}
function usageText(): string {
return [
"Usage: /sf debug <issue-text>",
" /sf debug list",
" /sf debug status <slug>",
" /sf debug continue <slug>",
" /sf debug --diagnose [<slug> | <issue text>]",
].join("\n");
}
export function parseDebugCommand(args: string): DebugCommandIntent {
const raw = args.trim();
if (!raw) return { type: "usage" };
const parts = raw.split(/\s+/).filter(Boolean);
const head = parts[0] ?? "";
if (head === "list") {
// Strict match only; otherwise treat as issue text for deterministic fallback behavior.
if (parts.length === 1) return { type: "list" };
return { type: "issue-start", issue: raw };
}
if (head === "status") {
if (parts.length === 1) return { type: "error", message: "Missing slug. Usage: /sf debug status <slug>" };
if (parts.length === 2 && isValidSlugCandidate(parts[1])) return { type: "status", slug: parts[1] };
return { type: "issue-start", issue: raw };
}
if (head === "continue") {
if (parts.length === 1) return { type: "error", message: "Missing slug. Usage: /sf debug continue <slug>" };
if (parts.length === 2 && isValidSlugCandidate(parts[1])) return { type: "continue", slug: parts[1] };
return { type: "issue-start", issue: raw };
}
if (head === "--diagnose") {
if (parts.length === 1) return { type: "diagnose" };
if (parts.length === 2 && isValidSlugCandidate(parts[1])) return { type: "diagnose", slug: parts[1] };
if (parts.length >= 3) return { type: "diagnose-issue", issue: parts.slice(1).join(" ") };
return { type: "error", message: "Invalid diagnose target. Usage: /sf debug --diagnose [<slug> | <issue text>]" };
}
if (head.startsWith("-") && !SUBCOMMANDS.has(head)) {
return { type: "error", message: `Unknown debug flag: ${head}.\n${usageText()}` };
}
return { type: "issue-start", issue: raw };
}
export async function handleDebug(args: string, ctx: ExtensionCommandContext, pi?: ExtensionAPI): Promise<void> {
const parsed = parseDebugCommand(args);
const basePath = process.cwd();
if (parsed.type === "usage") {
ctx.ui.notify(usageText(), "info");
return;
}
if (parsed.type === "error") {
ctx.ui.notify(parsed.message, "warning");
return;
}
if (parsed.type === "issue-start") {
const issue = parsed.issue.trim();
if (!issue) {
ctx.ui.notify(`Issue text is required.\n${usageText()}`, "warning");
return;
}
try {
const created = createDebugSession(basePath, { issue });
const s = created.session;
const canDispatch = pi != null && typeof (pi as ExtensionAPI).sendMessage === "function";
const dispatchNote = canDispatch ? `\ndispatchMode=find_and_fix` : "";
ctx.ui.notify(
[
`Debug session started: ${s.slug}`,
formatSessionLine("Session:", s),
`Artifact: ${created.artifactPath}`,
`Log: ${s.logPath}`,
`Next: /sf debug status ${s.slug} or /sf debug continue ${s.slug}`,
].join("\n") + dispatchNote,
"info",
);
if (canDispatch) {
try {
const prompt = loadPrompt("debug-session-manager", {
goal: "find_and_fix",
issue: s.issue,
slug: s.slug,
mode: s.mode,
workingDirectory: basePath,
checkpointContext: "",
tddContext: "",
specialistContext: "",
});
pi.sendMessage(
{ customType: "sf-debug-start", content: prompt, display: false },
{ triggerTurn: true },
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(
`Debug dispatch failed: ${msg}\nSession '${s.slug}' is persisted; retry with /sf debug continue ${s.slug}`,
"warning",
);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(
`Unable to create debug session: ${message}\nTry /sf debug --diagnose for artifact health details.`,
"error",
);
}
return;
}
if (parsed.type === "list") {
try {
const listed = listDebugSessions(basePath);
if (listed.sessions.length === 0 && listed.malformed.length === 0) {
ctx.ui.notify("No debug sessions found. Start one with: /sf debug <issue-text>", "info");
return;
}
const lines: string[] = [];
if (listed.sessions.length > 0) {
lines.push("Debug sessions:");
for (const record of listed.sessions) {
lines.push(formatSessionLine(" -", record.session));
}
}
if (listed.malformed.length > 0) {
lines.push("");
lines.push(`Malformed artifacts: ${listed.malformed.length}`);
for (const bad of listed.malformed.slice(0, 5)) {
lines.push(` - ${bad.artifactPath} :: ${bad.message}`);
}
if (listed.malformed.length > 5) {
lines.push(` ... and ${listed.malformed.length - 5} more`);
}
lines.push("Run /sf debug --diagnose for remediation guidance.");
}
ctx.ui.notify(lines.join("\n"), "info");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(
`Unable to list debug sessions: ${message}\nRun /sf debug --diagnose for details.`,
"warning",
);
}
return;
}
if (parsed.type === "status") {
try {
const loaded = loadDebugSession(basePath, parsed.slug);
if (!loaded) {
ctx.ui.notify(
`Unknown debug session slug '${parsed.slug}'. Run /sf debug list to see available sessions.`,
"warning",
);
return;
}
const s = loaded.session;
ctx.ui.notify(
[
`Debug session status: ${s.slug}`,
`mode=${s.mode}`,
`status=${s.status}`,
`phase=${s.phase}`,
`issue=${s.issue}`,
`artifact=${loaded.artifactPath}`,
`log=${s.logPath}`,
`updated=${new Date(s.updatedAt).toISOString()}`,
`lastError=${s.lastError ?? "none"}`,
].join("\n"),
"info",
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(
`Unable to load debug session '${parsed.slug}': ${message}\nTry /sf debug --diagnose ${parsed.slug}`,
"warning",
);
}
return;
}
if (parsed.type === "continue") {
try {
const loaded = loadDebugSession(basePath, parsed.slug);
if (!loaded) {
ctx.ui.notify(
`Unknown debug session slug '${parsed.slug}'. Run /sf debug list to see available sessions.`,
"warning",
);
return;
}
if (loaded.session.status === "resolved") {
ctx.ui.notify(
`Session '${parsed.slug}' is resolved. Open a new session with /sf debug <issue-text> for follow-up work.`,
"warning",
);
return;
}
// Determine checkpoint/TDD/specialist dispatch context before updating session state.
const checkpoint = loaded.session.checkpoint;
const tddGate = loaded.session.tddGate;
const specialistReview: DebugSpecialistReview | null | undefined = loaded.session.specialistReview;
const hasCheckpoint = checkpoint != null && checkpoint.awaitingResponse;
const hasTddGate = tddGate != null && tddGate.enabled;
let dispatchTemplate = "debug-diagnose";
let goal = "find_and_fix";
let dispatchModeLabel = "find_and_fix";
let checkpointContext = "";
let tddContext = "";
let specialistContext = "";
let tddGateUpdate: DebugTddGate | undefined;
if (hasCheckpoint || hasTddGate) {
dispatchTemplate = "debug-session-manager";
if (hasCheckpoint) {
const cpLines = [
`## Active Checkpoint`,
`- type: ${checkpoint.type}`,
`- summary: ${checkpoint.summary}`,
];
if (checkpoint.userResponse) {
cpLines.push(`- userResponse:\n\nDATA_START\n${checkpoint.userResponse}\nDATA_END`);
} else {
cpLines.push(`- awaitingResponse: true`);
}
checkpointContext = cpLines.join("\n");
dispatchModeLabel = `checkpointType=${checkpoint.type}`;
}
if (hasTddGate) {
if (tddGate.phase === "red") {
goal = "find_and_fix";
const tddLines = [
`## TDD Gate`,
`- phase: red → green`,
];
if (tddGate.testFile) tddLines.push(`- testFile: ${tddGate.testFile}`);
if (tddGate.testName) tddLines.push(`- testName: ${tddGate.testName}`);
if (tddGate.failureOutput) tddLines.push(`- failureOutput:\n${tddGate.failureOutput}`);
tddLines.push(`The failing test has been confirmed. Proceed to implement the fix that makes this test pass.`);
tddContext = tddLines.join("\n");
tddGateUpdate = { ...tddGate, phase: "green" };
dispatchModeLabel = "tddPhase=red→green";
} else if (tddGate.phase === "green") {
goal = "find_and_fix";
const tddLines = [
`## TDD Gate`,
`- phase: green`,
];
if (tddGate.testFile) tddLines.push(`- testFile: ${tddGate.testFile}`);
if (tddGate.testName) tddLines.push(`- testName: ${tddGate.testName}`);
tddLines.push(`The test is now passing. Continue verifying the fix.`);
tddContext = tddLines.join("\n");
dispatchModeLabel = "tddPhase=green";
} else {
// phase === "pending": investigate only, do not fix yet
goal = "find_root_cause_only";
const tddLines = [
`## TDD Gate`,
`- phase: pending`,
`TDD mode is active. Write a failing test that captures this bug first. Do NOT fix the issue yet.`,
];
if (tddGate.testFile) tddLines.push(`- testFile: ${tddGate.testFile}`);
tddContext = tddLines.join("\n");
dispatchModeLabel = "tddPhase=pending";
}
} else {
// Checkpoint only, no TDD gate — apply fix after human response
goal = "find_and_fix";
}
}
// Build specialistContext from session's specialistReview field (null/undefined → empty string).
if (specialistReview != null) {
specialistContext = [
`## Prior Specialist Review`,
`- hint: ${specialistReview.hint}`,
`- skill: ${specialistReview.skill ?? ""}`,
`- verdict: ${specialistReview.verdict}`,
`- detail: ${specialistReview.detail}`,
].join("\n");
dispatchModeLabel += ` specialistHint=${specialistReview.hint}`;
}
// Update session state BEFORE dispatch — handler returns after sendMessage.
const resumed = updateDebugSession(basePath, parsed.slug, {
status: "active",
phase: "continued",
lastError: null,
...(tddGateUpdate !== undefined ? { tddGate: tddGateUpdate } : {}),
});
const canDispatch = pi != null && typeof (pi as ExtensionAPI).sendMessage === "function";
const dispatchNote = canDispatch ? `\ndispatchMode=${dispatchModeLabel}` : "";
ctx.ui.notify(
[
`Resumed debug session: ${resumed.session.slug}`,
formatSessionLine("Session:", resumed.session),
`Log: ${resumed.session.logPath}`,
`Next: /sf debug status ${resumed.session.slug}`,
].join("\n") + dispatchNote,
"info",
);
if (canDispatch) {
try {
const promptVars: Record<string, string> = {
goal,
issue: resumed.session.issue,
slug: resumed.session.slug,
mode: resumed.session.mode,
workingDirectory: basePath,
};
if (dispatchTemplate === "debug-session-manager") {
promptVars.checkpointContext = checkpointContext;
promptVars.tddContext = tddContext;
promptVars.specialistContext = specialistContext;
}
const prompt = loadPrompt(dispatchTemplate, promptVars);
pi.sendMessage(
{ customType: "sf-debug-continue", content: prompt, display: false },
{ triggerTurn: true },
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(
`Continue dispatch failed: ${msg}\nSession '${resumed.session.slug}' is persisted; retry with /sf debug continue ${resumed.session.slug}`,
"warning",
);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(
`Unable to continue debug session '${parsed.slug}': ${message}\nTry /sf debug --diagnose ${parsed.slug}`,
"warning",
);
}
return;
}
if (parsed.type === "diagnose-issue") {
const issue = parsed.issue.trim();
if (!issue) {
ctx.ui.notify(`Issue text is required.\n${usageText()}`, "warning");
return;
}
try {
const created = createDebugSession(basePath, { issue, mode: "diagnose" });
const s = created.session;
ctx.ui.notify(
[
`Diagnose session started: ${s.slug}`,
formatSessionLine("Session:", s),
`Artifact: ${created.artifactPath}`,
`Log: ${s.logPath}`,
`dispatchMode=find_root_cause_only`,
`Next: /sf debug status ${s.slug} or /sf debug --diagnose ${s.slug}`,
].join("\n"),
"info",
);
if (pi && typeof pi.sendMessage === "function") {
try {
const prompt = loadPrompt("debug-diagnose", {
goal: "find_root_cause_only",
issue: s.issue,
slug: s.slug,
mode: s.mode,
workingDirectory: basePath,
});
pi.sendMessage(
{ customType: "sf-debug-diagnose", content: prompt, display: false },
{ triggerTurn: true },
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.ui.notify(
`Diagnose dispatch failed: ${msg}\nSession '${s.slug}' is persisted; continue manually with /sf debug continue ${s.slug}`,
"warning",
);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(
`Unable to create diagnose session: ${message}\nTry /sf debug --diagnose for artifact health details.`,
"error",
);
}
return;
}
if (parsed.type === "diagnose") {
try {
const listed = listDebugSessions(basePath);
if (parsed.slug) {
const loaded = loadDebugSession(basePath, parsed.slug);
if (!loaded) {
ctx.ui.notify(
`Diagnose: session '${parsed.slug}' not found.\nRun /sf debug list to discover valid slugs.`,
"warning",
);
return;
}
const s = loaded.session;
ctx.ui.notify(
[
`Diagnose session: ${s.slug}`,
`mode=${s.mode}`,
`status=${s.status}`,
`phase=${s.phase}`,
`artifact=${loaded.artifactPath}`,
`log=${s.logPath}`,
`lastError=${s.lastError ?? "none"}`,
`malformedArtifactsInStore=${listed.malformed.length}`,
].join("\n"),
"info",
);
return;
}
const lines = [
"Debug session diagnostics:",
`healthySessions=${listed.sessions.length}`,
`malformedArtifacts=${listed.malformed.length}`,
];
if (listed.malformed.length > 0) {
lines.push("");
lines.push("Malformed artifacts (first 10):");
for (const malformed of listed.malformed.slice(0, 10)) {
lines.push(` - ${malformed.artifactPath}`);
lines.push(` ${malformed.message}`);
}
lines.push("Remediation: repair/remove malformed JSON artifacts under .sf/debug/sessions/.");
}
ctx.ui.notify(lines.join("\n"), listed.malformed.length > 0 ? "warning" : "info");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Diagnose failed: ${message}`, "error");
}
}
}

View file

@ -1,377 +0,0 @@
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteSync, type AtomicWriteSyncOps } from "./atomic-write.js";
import { sfRoot } from "./paths.js";
export type DebugSessionStatus = "active" | "paused" | "resolved" | "failed";
export interface DebugCheckpoint {
type: "human-verify" | "human-action" | "decision" | "root-cause-found" | "inconclusive";
summary: string;
awaitingResponse: boolean;
userResponse?: string;
}
export interface DebugTddGate {
enabled: boolean;
phase: "pending" | "red" | "green";
testFile?: string;
testName?: string;
failureOutput?: string;
}
export interface DebugSpecialistReview {
hint: string;
skill: string | null;
verdict: string;
detail: string;
reviewedAt: number;
}
export interface DebugSessionArtifact {
version: 1;
mode: "debug" | "diagnose";
slug: string;
issue: string;
status: DebugSessionStatus;
phase: string;
createdAt: number;
updatedAt: number;
logPath: string;
lastError: string | null;
checkpoint?: DebugCheckpoint | null;
tddGate?: DebugTddGate | null;
specialistReview?: DebugSpecialistReview | null;
}
export interface DebugSessionRecord {
artifactPath: string;
session: DebugSessionArtifact;
}
export interface DebugMalformedSessionArtifact {
artifactPath: string;
message: string;
}
export interface DebugSessionListResult {
sessions: DebugSessionRecord[];
malformed: DebugMalformedSessionArtifact[];
}
export interface CreateDebugSessionInput {
issue: string;
mode?: "debug" | "diagnose";
status?: DebugSessionStatus;
phase?: string;
createdAt?: number;
}
export interface UpdateDebugSessionInput {
status?: DebugSessionStatus;
phase?: string;
issue?: string;
lastError?: string | null;
updatedAt?: number;
checkpoint?: DebugCheckpoint | null;
tddGate?: DebugTddGate | null;
specialistReview?: DebugSpecialistReview | null;
}
export interface DebugSessionStoreDeps {
atomicWrite?: (filePath: string, content: string, encoding?: BufferEncoding) => void;
readFile?: (filePath: string, encoding: BufferEncoding) => string;
listDir?: (dirPath: string) => string[];
exists?: (filePath: string) => boolean;
now?: () => number;
}
const DEFAULT_PHASE = "queued";
const DEFAULT_STATUS: DebugSessionStatus = "active";
const SESSION_FILE_SUFFIX = ".json";
const MAX_SLUG_LENGTH = 64;
const MAX_COLLISION_ATTEMPTS = 10_000;
function debugRoot(basePath: string): string {
return join(sfRoot(basePath), "debug");
}
export function debugSessionsDir(basePath: string): string {
return join(debugRoot(basePath), "sessions");
}
export function debugSessionArtifactPath(basePath: string, slug: string): string {
assertValidDebugSessionSlug(slug);
return join(debugSessionsDir(basePath), `${slug}${SESSION_FILE_SUFFIX}`);
}
export function debugSessionLogPath(basePath: string, slug: string): string {
assertValidDebugSessionSlug(slug);
return join(debugRoot(basePath), `${slug}.log`);
}
function ensureSessionsDir(basePath: string): string {
const dir = debugSessionsDir(basePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return dir;
}
export function slugifyDebugSessionIssue(issue: string): string {
const normalized = issue
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-")
.slice(0, MAX_SLUG_LENGTH)
.replace(/-+$/g, "");
if (!normalized) {
throw new Error("Issue text must contain at least one alphanumeric character.");
}
return normalized;
}
export function assertValidDebugSessionSlug(slug: string): void {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
throw new Error(`Invalid debug session slug: ${slug}`);
}
}
function isDebugSessionStatus(value: unknown): value is DebugSessionStatus {
return value === "active" || value === "paused" || value === "resolved" || value === "failed";
}
function isDebugCheckpointShape(value: unknown): value is DebugCheckpoint {
if (!value || typeof value !== "object") return false;
const o = value as Record<string, unknown>;
const validTypes = ["human-verify", "human-action", "decision", "root-cause-found", "inconclusive"];
return (
validTypes.includes(o.type as string)
&& typeof o.summary === "string"
&& typeof o.awaitingResponse === "boolean"
&& (o.userResponse === undefined || typeof o.userResponse === "string")
);
}
function isDebugTddGateShape(value: unknown): value is DebugTddGate {
if (!value || typeof value !== "object") return false;
const o = value as Record<string, unknown>;
const validPhases = ["pending", "red", "green"];
return (
typeof o.enabled === "boolean"
&& validPhases.includes(o.phase as string)
&& (o.testFile === undefined || typeof o.testFile === "string")
&& (o.testName === undefined || typeof o.testName === "string")
&& (o.failureOutput === undefined || typeof o.failureOutput === "string")
);
}
function isDebugSpecialistReviewShape(value: unknown): value is DebugSpecialistReview {
if (!value || typeof value !== "object") return false;
const o = value as Record<string, unknown>;
return (
typeof o.hint === "string"
&& (typeof o.skill === "string" || o.skill === null)
&& typeof o.verdict === "string"
&& typeof o.detail === "string"
&& typeof o.reviewedAt === "number"
);
}
function isDebugSessionArtifact(value: unknown): value is DebugSessionArtifact {
if (!value || typeof value !== "object") return false;
const o = value as Record<string, unknown>;
return (
o.version === 1
&& (o.mode === "debug" || o.mode === "diagnose")
&& typeof o.slug === "string"
&& typeof o.issue === "string"
&& isDebugSessionStatus(o.status)
&& typeof o.phase === "string"
&& typeof o.createdAt === "number"
&& typeof o.updatedAt === "number"
&& typeof o.logPath === "string"
&& (typeof o.lastError === "string" || o.lastError === null)
&& (o.checkpoint === undefined || o.checkpoint === null || isDebugCheckpointShape(o.checkpoint))
&& (o.tddGate === undefined || o.tddGate === null || isDebugTddGateShape(o.tddGate))
&& (o.specialistReview === undefined || o.specialistReview === null || isDebugSpecialistReviewShape(o.specialistReview))
);
}
function parseDebugSessionArtifact(filePath: string, raw: string): DebugSessionArtifact {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse debug session artifact ${filePath}: ${message}`);
}
if (!isDebugSessionArtifact(parsed)) {
throw new Error(`Malformed debug session artifact ${filePath}: schema validation failed`);
}
return parsed;
}
function defaultDeps(deps: DebugSessionStoreDeps) {
return {
atomicWrite: deps.atomicWrite ?? atomicWriteSync,
readFile: deps.readFile ?? ((filePath: string, encoding: BufferEncoding) => readFileSync(filePath, encoding)),
listDir: deps.listDir ?? ((dirPath: string) => readdirSync(dirPath)),
exists: deps.exists ?? ((filePath: string) => existsSync(filePath)),
now: deps.now ?? (() => Date.now()),
};
}
function nextSlug(basePath: string, baseSlug: string, deps: ReturnType<typeof defaultDeps>): string {
const baseArtifactPath = debugSessionArtifactPath(basePath, baseSlug);
if (!deps.exists(baseArtifactPath)) return baseSlug;
for (let n = 2; n < MAX_COLLISION_ATTEMPTS; n++) {
const candidate = `${baseSlug}-${n}`;
const candidatePath = debugSessionArtifactPath(basePath, candidate);
if (!deps.exists(candidatePath)) return candidate;
}
throw new Error(`Unable to allocate unique debug session slug for '${baseSlug}'`);
}
function serializeArtifact(session: DebugSessionArtifact): string {
return JSON.stringify(session, null, 2) + "\n";
}
export function createDebugSession(
basePath: string,
input: CreateDebugSessionInput,
deps: DebugSessionStoreDeps = {},
): DebugSessionRecord {
const d = defaultDeps(deps);
const issue = input.issue?.trim() ?? "";
if (!issue) {
throw new Error("Issue text is required to create a debug session.");
}
ensureSessionsDir(basePath);
const baseSlug = slugifyDebugSessionIssue(issue);
const slug = nextSlug(basePath, baseSlug, d);
const now = input.createdAt ?? d.now();
const session: DebugSessionArtifact = {
version: 1,
mode: input.mode ?? "debug",
slug,
issue,
status: input.status ?? DEFAULT_STATUS,
phase: input.phase ?? DEFAULT_PHASE,
createdAt: now,
updatedAt: now,
logPath: debugSessionLogPath(basePath, slug),
lastError: null,
};
const artifactPath = debugSessionArtifactPath(basePath, slug);
d.atomicWrite(artifactPath, serializeArtifact(session), "utf-8");
return { artifactPath, session };
}
export function loadDebugSession(
basePath: string,
slug: string,
deps: DebugSessionStoreDeps = {},
): DebugSessionRecord | null {
assertValidDebugSessionSlug(slug);
const d = defaultDeps(deps);
const artifactPath = debugSessionArtifactPath(basePath, slug);
if (!d.exists(artifactPath)) return null;
const raw = d.readFile(artifactPath, "utf-8");
const session = parseDebugSessionArtifact(artifactPath, raw);
return { artifactPath, session };
}
export function listDebugSessions(
basePath: string,
deps: DebugSessionStoreDeps = {},
): DebugSessionListResult {
const d = defaultDeps(deps);
const dir = debugSessionsDir(basePath);
if (!d.exists(dir)) return { sessions: [], malformed: [] };
const entries = d.listDir(dir)
.filter(entry => entry.endsWith(SESSION_FILE_SUFFIX))
.sort((a, b) => a.localeCompare(b));
const sessions: DebugSessionRecord[] = [];
const malformed: DebugMalformedSessionArtifact[] = [];
for (const entry of entries) {
const artifactPath = join(dir, entry);
try {
const raw = d.readFile(artifactPath, "utf-8");
const session = parseDebugSessionArtifact(artifactPath, raw);
sessions.push({ artifactPath, session });
} catch (error) {
malformed.push({
artifactPath,
message: error instanceof Error ? error.message : String(error),
});
}
}
sessions.sort((a, b) => {
if (a.session.updatedAt !== b.session.updatedAt) {
return b.session.updatedAt - a.session.updatedAt;
}
if (a.session.createdAt !== b.session.createdAt) {
return b.session.createdAt - a.session.createdAt;
}
return a.session.slug.localeCompare(b.session.slug);
});
return { sessions, malformed };
}
export function updateDebugSession(
basePath: string,
slug: string,
update: UpdateDebugSessionInput,
deps: DebugSessionStoreDeps = {},
): DebugSessionRecord {
const d = defaultDeps(deps);
const loaded = loadDebugSession(basePath, slug, d);
if (!loaded) {
throw new Error(`Debug session not found for slug: ${slug}`);
}
const nextIssue = update.issue?.trim() ?? loaded.session.issue;
if (!nextIssue) {
throw new Error("Issue text cannot be empty.");
}
const nextStatus = update.status ?? loaded.session.status;
if (!isDebugSessionStatus(nextStatus)) {
throw new Error(`Invalid debug session status: ${String(update.status)}`);
}
const nextUpdatedAt = update.updatedAt ?? d.now();
const session: DebugSessionArtifact = {
...loaded.session,
issue: nextIssue,
status: nextStatus,
phase: update.phase ?? loaded.session.phase,
lastError: update.lastError === undefined ? loaded.session.lastError : update.lastError,
checkpoint: update.checkpoint === undefined ? loaded.session.checkpoint : update.checkpoint,
tddGate: update.tddGate === undefined ? loaded.session.tddGate : update.tddGate,
specialistReview: update.specialistReview === undefined ? loaded.session.specialistReview : update.specialistReview,
updatedAt: nextUpdatedAt,
};
d.atomicWrite(loaded.artifactPath, serializeArtifact(session), "utf-8");
return { artifactPath: loaded.artifactPath, session };
}
// Keep this exported for focused fault-injection tests around rename retry behavior.
export type { AtomicWriteSyncOps };