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:
parent
e07f2bc225
commit
1891ccbdcd
2 changed files with 0 additions and 887 deletions
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
Loading…
Add table
Reference in a new issue