Merge pull request #697 from gsd-build/feat/forensics

This commit is contained in:
TÂCHES 2026-03-16 14:41:03 -06:00 committed by GitHub
commit 2b0c0064cd
4 changed files with 676 additions and 2 deletions

View file

@ -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());

View file

@ -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<void> {
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<ForensicReport> {
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<string, number>();
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;
}

View file

@ -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",

View file

@ -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).