diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index afaca8d4b..14d6849a5 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -69,13 +69,13 @@ function projectRoot(): string { export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|migrate|remote|steer|knowledge", + description: "GSD — Get Shit Done: /gsd help|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|history|undo|skip|export|cleanup|mode|prefs|config|hooks|run-hook|skill-health|doctor|forensics|migrate|remote|steer|knowledge", getArgumentCompletions: (prefix: string) => { const subcommands = [ "help", "next", "auto", "stop", "pause", "status", "visualize", "queue", "quick", "discuss", "capture", "triage", "history", "undo", "skip", "export", "cleanup", "mode", "prefs", - "config", "hooks", "run-hook", "skill-health", "doctor", "migrate", "remote", "steer", "inspect", "knowledge", + "config", "hooks", "run-hook", "skill-health", "doctor", "forensics", "migrate", "remote", "steer", "inspect", "knowledge", ]; const parts = prefix.trim().split(/\s+/); @@ -205,6 +205,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "forensics" || trimmed.startsWith("forensics ")) { + const { handleForensics } = await import("./forensics.js"); + await handleForensics(trimmed.replace(/^forensics\s*/, "").trim(), ctx, pi); + return; + } + if (trimmed === "next" || trimmed.startsWith("next ")) { if (trimmed.includes("--dry-run")) { await handleDryRun(ctx, projectRoot()); diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts new file mode 100644 index 000000000..df518bb13 --- /dev/null +++ b/src/resources/extensions/gsd/forensics.ts @@ -0,0 +1,596 @@ +/** + * GSD Forensics — Post-mortem investigation of auto-mode failures + * + * Programmatically scans activity logs, metrics, crash locks, and doctor + * diagnostics for anomalies, then hands a structured report to the LLM + * for interactive investigation. + * + * Entry point: handleForensics() called from commands.ts + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { join, dirname, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { extractTrace, type ExecutionTrace } from "./session-forensics.js"; +import { nativeParseJsonlTail } from "./native-parser-bridge.js"; +import { + loadLedgerFromDisk, getAverageCostPerUnitType, getProjectTotals, + formatCost, formatTokenCount, type UnitMetrics, type MetricsLedger, +} from "./metrics.js"; +import { readCrashLock, isLockProcessAlive, formatCrashInfo, type LockData } from "./crash-recovery.js"; +import { runGSDDoctor, formatDoctorIssuesForPrompt, type DoctorIssue } from "./doctor.js"; +import { verifyExpectedArtifact } from "./auto-recovery.js"; +import { deriveState } from "./state.js"; +import { isAutoActive } from "./auto.js"; +import { loadPrompt } from "./prompt-loader.js"; +import { gsdRoot } from "./paths.js"; +import { formatDuration } from "./history.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ForensicAnomaly { + type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace"; + severity: "info" | "warning" | "error"; + unitType?: string; + unitId?: string; + summary: string; + details: string; +} + +interface UnitTrace { + file: string; + unitType: string; + unitId: string; + seq: number; + trace: ExecutionTrace; + mtime: number; +} + +interface ForensicReport { + gsdVersion: string; + timestamp: string; + basePath: string; + activeMilestone: string | null; + activeSlice: string | null; + unitTraces: UnitTrace[]; + metrics: MetricsLedger | null; + completedKeys: string[]; + crashLock: LockData | null; + doctorIssues: DoctorIssue[]; + anomalies: ForensicAnomaly[]; + recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[]; +} + +// ─── JSONL Parser (inline — session-forensics.ts version is module-private) ── + +const MAX_JSONL_BYTES = 5 * 1024 * 1024; + +function parseJSONL(raw: string): unknown[] { + const source = raw.length > MAX_JSONL_BYTES ? raw.slice(-MAX_JSONL_BYTES) : raw; + return source.trim().split("\n").map(line => { + try { return JSON.parse(line); } catch { return null; } + }).filter(Boolean) as unknown[]; +} + +// ─── Entry Point ────────────────────────────────────────────────────────────── + +export async function handleForensics( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + if (isAutoActive()) { + ctx.ui.notify("Cannot run forensics while auto-mode is active. Stop auto-mode first.", "error"); + return; + } + + const basePath = process.cwd(); + const root = gsdRoot(basePath); + if (!existsSync(root)) { + ctx.ui.notify("No GSD state found. Run /gsd auto first.", "warning"); + return; + } + + let problemDescription = args.trim(); + if (!problemDescription) { + problemDescription = await ctx.ui.input( + "Describe what went wrong:", + "e.g. auto-mode got stuck on task T03", + ) ?? ""; + } + if (!problemDescription?.trim()) { + ctx.ui.notify("Problem description required for forensic analysis.", "warning"); + return; + } + + ctx.ui.notify("Building forensic report...", "info"); + + const report = await buildForensicReport(basePath); + const savedPath = saveForensicReport(basePath, report, problemDescription); + + // Derive GSD source dir for prompt + const __extensionDir = dirname(fileURLToPath(import.meta.url)); + const gsdSourceDir = __extensionDir; + + const forensicData = formatReportForPrompt(report); + const content = loadPrompt("forensics", { + problemDescription, + forensicData, + gsdSourceDir, + }); + + ctx.ui.notify(`Forensic report saved: ${relative(basePath, savedPath)}`, "info"); + + pi.sendMessage( + { customType: "gsd-forensics", content, display: false }, + { triggerTurn: true }, + ); +} + +// ─── Report Builder ─────────────────────────────────────────────────────────── + +async function buildForensicReport(basePath: string): Promise { + const anomalies: ForensicAnomaly[] = []; + + // 1. Derive current state + let activeMilestone: string | null = null; + let activeSlice: string | null = null; + try { + const state = await deriveState(basePath); + activeMilestone = state.activeMilestone?.id ?? null; + activeSlice = state.activeSlice?.id ?? null; + } catch { /* state derivation failure is non-fatal */ } + + // 2. Scan activity logs (last 5) + const unitTraces = scanActivityLogs(basePath); + + // 3. Load metrics + const metrics = loadLedgerFromDisk(basePath); + + // 4. Load completed keys + const completedKeys = loadCompletedKeys(basePath); + + // 5. Check crash lock + const crashLock = readCrashLock(basePath); + + // 6. Run doctor + let doctorIssues: DoctorIssue[] = []; + try { + const report = await runGSDDoctor(basePath, { scope: undefined }); + doctorIssues = report.issues; + } catch { /* doctor failure is non-fatal */ } + + // 7. Build recent units from metrics + const recentUnits: ForensicReport["recentUnits"] = []; + if (metrics?.units) { + const sorted = [...metrics.units].sort((a, b) => b.finishedAt - a.finishedAt).slice(0, 10); + for (const u of sorted) { + recentUnits.push({ + type: u.type, + id: u.id, + cost: u.cost, + duration: u.finishedAt - u.startedAt, + model: u.model, + finishedAt: u.finishedAt, + }); + } + } + + // 8. GSD version + let gsdVersion = "unknown"; + try { + const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "../../../../package.json"); + if (existsSync(pkgPath)) { + gsdVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "unknown"; + } + } catch { /* non-fatal */ } + + // 9. Run anomaly detectors + if (metrics?.units) detectStuckLoops(metrics.units, anomalies); + if (metrics?.units) detectCostSpikes(metrics.units, anomalies); + detectTimeouts(unitTraces, anomalies); + detectMissingArtifacts(completedKeys, basePath, anomalies); + detectCrash(crashLock, anomalies); + detectDoctorIssues(doctorIssues, anomalies); + detectErrorTraces(unitTraces, anomalies); + + return { + gsdVersion, + timestamp: new Date().toISOString(), + basePath, + activeMilestone, + activeSlice, + unitTraces, + metrics, + completedKeys, + crashLock, + doctorIssues, + anomalies, + recentUnits, + }; +} + +// ─── Activity Log Scanner ───────────────────────────────────────────────────── + +const ACTIVITY_FILENAME_RE = /^(\d+)-(.+?)-(.+)\.jsonl$/; + +function scanActivityLogs(basePath: string): UnitTrace[] { + const activityDir = join(gsdRoot(basePath), "activity"); + if (!existsSync(activityDir)) return []; + + const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")).sort(); + const lastFiles = files.slice(-5); + const traces: UnitTrace[] = []; + + for (const file of lastFiles) { + const match = ACTIVITY_FILENAME_RE.exec(file); + if (!match) continue; + + const seq = parseInt(match[1]!, 10); + const unitType = match[2]!; + const unitId = match[3]!; + const filePath = join(activityDir, file); + + let entries: unknown[] = []; + const nativeResult = nativeParseJsonlTail(filePath, MAX_JSONL_BYTES); + if (nativeResult) { + entries = nativeResult.entries; + } else { + try { + const raw = readFileSync(filePath, "utf-8"); + entries = parseJSONL(raw); + } catch { continue; } + } + + const trace = extractTrace(entries); + const stat = statSync(filePath, { throwIfNoEntry: false }); + + traces.push({ + file, + unitType, + unitId, + seq, + trace, + mtime: stat?.mtimeMs ?? 0, + }); + } + + return traces.sort((a, b) => b.seq - a.seq); +} + +// ─── Completed Keys Loader ──────────────────────────────────────────────────── + +function loadCompletedKeys(basePath: string): string[] { + const file = join(basePath, ".gsd", "completed-units.json"); + try { + if (existsSync(file)) { + return JSON.parse(readFileSync(file, "utf-8")); + } + } catch { /* non-fatal */ } + return []; +} + +// ─── Anomaly Detectors ─────────────────────────────────────────────────────── + +function detectStuckLoops(units: UnitMetrics[], anomalies: ForensicAnomaly[]): void { + const counts = new Map(); + for (const u of units) { + const key = `${u.type}/${u.id}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + for (const [key, count] of counts) { + if (count > 1) { + const [unitType, ...idParts] = key.split("/"); + anomalies.push({ + type: "stuck-loop", + severity: count >= 3 ? "error" : "warning", + unitType, + unitId: idParts.join("/"), + summary: `Unit ${key} was dispatched ${count} times`, + details: `Repeated dispatch suggests the unit completed but its artifacts weren't verified, or the state machine kept returning it.`, + }); + } + } +} + +function detectCostSpikes(units: UnitMetrics[], anomalies: ForensicAnomaly[]): void { + const avgMap = getAverageCostPerUnitType(units); + for (const u of units) { + const avg = avgMap.get(u.type); + if (avg && avg > 0 && u.cost > avg * 3) { + anomalies.push({ + type: "cost-spike", + severity: "warning", + unitType: u.type, + unitId: u.id, + summary: `${formatCost(u.cost)} vs ${formatCost(avg)} average for ${u.type}`, + details: `Unit ${u.type}/${u.id} cost ${(u.cost / avg).toFixed(1)}x the average. May indicate excessive retries or large context.`, + }); + } + } +} + +function detectTimeouts(traces: UnitTrace[], anomalies: ForensicAnomaly[]): void { + for (const ut of traces) { + // Check for timeout-recovery custom messages in tool calls + const hasTimeout = ut.trace.toolCalls.some(tc => + tc.name === "sendmessage" && + JSON.stringify(tc.input).includes("gsd-auto-timeout-recovery"), + ); + // Check for timeout keywords in last reasoning + const reasoningTimeout = ut.trace.lastReasoning && + /(?:idle.?timeout|hard.?timeout|timeout.?recovery)/i.test(ut.trace.lastReasoning); + + if (hasTimeout || reasoningTimeout) { + anomalies.push({ + type: "timeout", + severity: "warning", + unitType: ut.unitType, + unitId: ut.unitId, + summary: `Timeout detected in ${ut.unitType}/${ut.unitId}`, + details: `Activity log ${ut.file} contains timeout recovery patterns. The unit may have stalled.`, + }); + } + } +} + +function detectMissingArtifacts(completedKeys: string[], basePath: string, anomalies: ForensicAnomaly[]): void { + for (const key of completedKeys) { + const slashIdx = key.indexOf("/"); + if (slashIdx === -1) continue; + const unitType = key.slice(0, slashIdx); + const unitId = key.slice(slashIdx + 1); + + if (!verifyExpectedArtifact(unitType, unitId, basePath)) { + anomalies.push({ + type: "missing-artifact", + severity: "error", + unitType, + unitId, + summary: `Completed key ${key} but artifact missing or invalid`, + details: `The unit is recorded as completed but verifyExpectedArtifact() returns false. The completion state is stale.`, + }); + } + } +} + +function detectCrash(crashLock: LockData | null, anomalies: ForensicAnomaly[]): void { + if (!crashLock) return; + if (isLockProcessAlive(crashLock)) return; // Process still running, not a crash + + anomalies.push({ + type: "crash", + severity: "error", + unitType: crashLock.unitType, + unitId: crashLock.unitId, + summary: `Stale crash lock: PID ${crashLock.pid} is dead`, + details: formatCrashInfo(crashLock), + }); +} + +function detectDoctorIssues(issues: DoctorIssue[], anomalies: ForensicAnomaly[]): void { + for (const issue of issues) { + if (issue.severity === "error") { + anomalies.push({ + type: "doctor-issue", + severity: "error", + summary: `Doctor: ${issue.message}`, + details: `Code: ${issue.code}, Scope: ${issue.scope}, Unit: ${issue.unitId}${issue.file ? `, File: ${issue.file}` : ""}`, + }); + } + } +} + +function detectErrorTraces(traces: UnitTrace[], anomalies: ForensicAnomaly[]): void { + for (const ut of traces) { + if (ut.trace.errors.length > 0) { + anomalies.push({ + type: "error-trace", + severity: "warning", + unitType: ut.unitType, + unitId: ut.unitId, + summary: `${ut.trace.errors.length} error(s) in ${ut.unitType}/${ut.unitId}`, + details: ut.trace.errors.slice(0, 3).join("\n"), + }); + } + } +} + +// ─── Report Persistence ─────────────────────────────────────────────────────── + +function saveForensicReport(basePath: string, report: ForensicReport, problemDescription: string): string { + const dir = join(gsdRoot(basePath), "forensics"); + mkdirSync(dir, { recursive: true }); + + const ts = new Date().toISOString().replace(/[:.]/g, "-").replace("T", "-").slice(0, 19); + const filePath = join(dir, `report-${ts}.md`); + + const redact = (s: string) => redactForGitHub(s, basePath); + + const sections: string[] = [ + `# GSD Forensic Report`, + ``, + `**Generated:** ${report.timestamp}`, + `**GSD Version:** ${report.gsdVersion}`, + `**Active Milestone:** ${report.activeMilestone ?? "none"}`, + `**Active Slice:** ${report.activeSlice ?? "none"}`, + ``, + `## Problem Description`, + ``, + problemDescription, + ``, + ]; + + // Anomalies + if (report.anomalies.length > 0) { + sections.push(`## Anomalies Detected (${report.anomalies.length})`, ``); + for (const a of report.anomalies) { + sections.push(`### [${a.severity.toUpperCase()}] ${a.type}: ${a.summary}`); + if (a.unitType) sections.push(`- Unit: ${a.unitType}/${a.unitId ?? ""}`); + sections.push(`- ${redact(a.details)}`, ``); + } + } else { + sections.push(`## Anomalies`, ``, `No anomalies detected.`, ``); + } + + // Recent units + if (report.recentUnits.length > 0) { + sections.push(`## Recent Units`, ``); + sections.push(`| Type | ID | Cost | Duration | Model |`); + sections.push(`|------|-----|------|----------|-------|`); + for (const u of report.recentUnits) { + sections.push(`| ${u.type} | ${u.id} | ${formatCost(u.cost)} | ${formatDuration(u.duration)} | ${u.model} |`); + } + sections.push(``); + } + + // Unit traces + if (report.unitTraces.length > 0) { + sections.push(`## Activity Log Traces (last ${report.unitTraces.length})`, ``); + for (const ut of report.unitTraces) { + sections.push(`### ${ut.unitType}/${ut.unitId} (seq ${ut.seq})`); + sections.push(`- Tool calls: ${ut.trace.toolCallCount}`); + sections.push(`- Files written: ${ut.trace.filesWritten.length}`); + sections.push(`- Errors: ${ut.trace.errors.length}`); + if (ut.trace.lastReasoning) { + sections.push(`- Last reasoning: ${redact(ut.trace.lastReasoning.slice(0, 200))}`); + } + sections.push(``); + } + } + + // Doctor issues + if (report.doctorIssues.length > 0) { + sections.push(`## Doctor Issues`, ``); + sections.push(formatDoctorIssuesForPrompt(report.doctorIssues), ``); + } + + // Crash lock + if (report.crashLock) { + sections.push(`## Crash Lock`, ``); + sections.push(redact(formatCrashInfo(report.crashLock)), ``); + } + + writeFileSync(filePath, sections.join("\n"), "utf-8"); + return filePath; +} + +// ─── Prompt Formatter ───────────────────────────────────────────────────────── + +function formatReportForPrompt(report: ForensicReport): string { + const MAX_BYTES = 30 * 1024; + const sections: string[] = []; + + // Anomalies (most important, first) + sections.push(`### Anomalies (${report.anomalies.length})`); + if (report.anomalies.length === 0) { + sections.push("No anomalies detected."); + } else { + for (const a of report.anomalies) { + sections.push(`- **[${a.severity.toUpperCase()}] ${a.type}**: ${a.summary}`); + if (a.details) sections.push(` ${a.details.slice(0, 300)}`); + } + } + sections.push(""); + + // Recent unit history + if (report.recentUnits.length > 0) { + sections.push(`### Recent Units (last ${report.recentUnits.length})`); + sections.push("| Type | ID | Cost | Duration | Model |"); + sections.push("|------|-----|------|----------|-------|"); + for (const u of report.recentUnits) { + sections.push(`| ${u.type} | ${u.id} | ${formatCost(u.cost)} | ${formatDuration(u.duration)} | ${u.model} |`); + } + sections.push(""); + } + + // Trace summaries (last 3) + const recentTraces = report.unitTraces.slice(0, 3); + if (recentTraces.length > 0) { + sections.push(`### Activity Log Traces (last ${recentTraces.length})`); + for (const ut of recentTraces) { + sections.push(`**${ut.unitType}/${ut.unitId}** (seq ${ut.seq})`); + sections.push(`- Tool calls: ${ut.trace.toolCallCount}, Errors: ${ut.trace.errors.length}`); + if (ut.trace.filesWritten.length > 0) { + sections.push(`- Files written: ${ut.trace.filesWritten.slice(0, 5).join(", ")}`); + } + if (ut.trace.errors.length > 0) { + sections.push(`- Errors: ${ut.trace.errors.slice(0, 2).map(e => e.slice(0, 200)).join("; ")}`); + } + if (ut.trace.lastReasoning) { + sections.push(`- Last reasoning: "${ut.trace.lastReasoning.slice(0, 300)}"`); + } + sections.push(""); + } + } + + // Doctor issues (error severity only) + const errorIssues = report.doctorIssues.filter(i => i.severity === "error"); + if (errorIssues.length > 0) { + sections.push(`### Doctor Issues (${errorIssues.length} errors)`); + sections.push(formatDoctorIssuesForPrompt(errorIssues)); + sections.push(""); + } + + // Crash lock + if (report.crashLock) { + sections.push("### Crash Lock"); + sections.push(formatCrashInfo(report.crashLock)); + const alive = isLockProcessAlive(report.crashLock); + sections.push(`Process alive: ${alive}`); + sections.push(""); + } + + // Metrics summary + if (report.metrics?.units) { + const totals = getProjectTotals(report.metrics.units); + sections.push("### Metrics Summary"); + sections.push(`- Total units: ${totals.units}`); + sections.push(`- Total cost: ${formatCost(totals.cost)}`); + sections.push(`- Total tokens: ${formatTokenCount(totals.tokens.total)}`); + sections.push(`- Total duration: ${formatDuration(totals.duration)}`); + sections.push(""); + } + + // Completed keys count + sections.push(`### Completed Keys: ${report.completedKeys.length}`); + sections.push(`### GSD Version: ${report.gsdVersion}`); + sections.push(`### Active Milestone: ${report.activeMilestone ?? "none"}`); + sections.push(`### Active Slice: ${report.activeSlice ?? "none"}`); + + let result = sections.join("\n"); + if (result.length > MAX_BYTES) { + result = result.slice(0, MAX_BYTES) + "\n\n[... truncated at 30KB ...]"; + } + return result; +} + +// ─── Redaction ──────────────────────────────────────────────────────────────── + +function redactForGitHub(text: string, basePath: string): string { + let result = text; + + // Replace absolute paths + result = result.replaceAll(basePath, "."); + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + if (home) result = result.replaceAll(home, "~"); + + // Strip API key patterns + result = result.replace(/sk-[a-zA-Z0-9]{20,}/g, "sk-***"); + result = result.replace(/Bearer\s+\S+/g, "Bearer ***"); + + // Strip env var assignments + result = result.replace(/[A-Z_]{2,}=\S+/g, (match) => { + const eq = match.indexOf("="); + return match.slice(0, eq + 1) + "***"; + }); + + // Truncate long lines + result = result.split("\n").map(line => + line.length > 500 ? line.slice(0, 497) + "..." : line, + ).join("\n"); + + return result; +} diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 4b16b44e6..937dd9bc4 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -16,6 +16,7 @@ import { nativeRmCached } from "./native-git-bridge.js"; */ const GSD_RUNTIME_PATTERNS = [ ".gsd/activity/", + ".gsd/forensics/", ".gsd/runtime/", ".gsd/worktrees/", ".gsd/auto.lock", diff --git a/src/resources/extensions/gsd/prompts/forensics.md b/src/resources/extensions/gsd/prompts/forensics.md new file mode 100644 index 000000000..a3922e8e8 --- /dev/null +++ b/src/resources/extensions/gsd/prompts/forensics.md @@ -0,0 +1,71 @@ +You are investigating a GSD auto-mode failure. The user has described their problem and a structured forensic report has been gathered automatically. + +## User's Problem + +{{problemDescription}} + +## Forensic Report + +{{forensicData}} + +## GSD Source Location + +GSD extension source code is at: {{gsdSourceDir}} +Key files for understanding failures: +- auto.ts — unit dispatch loop, stuck detection, timeout recovery +- session-forensics.ts — trace extraction from activity logs +- auto-recovery.ts — artifact verification, skip logic +- crash-recovery.ts — crash lock lifecycle +- doctor.ts — state integrity checks + +You may read these files to identify the specific code path that caused the failure. + +## Your Task + +1. **Analyze** the forensic report. Identify the root cause of the user's problem. + +2. **Clarify** if needed. Use ask_user_questions (max 2 questions) to narrow down ambiguity. Only ask if the report is genuinely insufficient — do not ask questions you can answer from the data. + +3. **Explain** your findings clearly: + - What happened (the failure sequence) + - Why it happened (root cause in GSD's logic) + - What the user can do to recover (immediate fix) + +4. **Offer GitHub issue creation.** Ask the user: + "Would you like me to create a GitHub issue for this on gsd-build/gsd-2?" + + If yes, create the issue using bash with `gh issue create`: + - Repository: gsd-build/gsd-2 + - Labels: bug, auto-generated + - Title: concise description of the failure + - Body format: + ``` + ## Problem + [1-2 sentence summary] + + ## Environment + - GSD version: [from report] + - Model: [from report] + - Unit: [type/id that failed] + + ## Reproduction Context + [What was happening when it failed — phase, milestone, slice] + + ## Forensic Findings + [Key anomalies detected, error traces, relevant tool call sequences] + + ## Suggested Fix Area + [File:line references in GSD source if identified] + + --- + *Auto-generated by `/gsd forensics`* + ``` + + **CRITICAL REDACTION RULES** before creating the issue: + - Replace all absolute paths with relative paths + - Remove any API keys, tokens, or credentials + - Remove any environment variable values + - Do not include file content (code written by the user) + - Only include GSD structural information (tool names, file names, error messages) + +5. **Report saved.** Remind the user that the full forensic report was saved locally (the path will be in the notification).