diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts index df518bb13..f2156c52e 100644 --- a/src/resources/extensions/gsd/forensics.ts +++ b/src/resources/extensions/gsd/forensics.ts @@ -27,6 +27,7 @@ import { isAutoActive } from "./auto.js"; import { loadPrompt } from "./prompt-loader.js"; import { gsdRoot } from "./paths.js"; import { formatDuration } from "./history.js"; +import { getAutoWorktreePath } from "./auto-worktree.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -54,6 +55,7 @@ interface ForensicReport { basePath: string; activeMilestone: string | null; activeSlice: string | null; + activeWorktree: string | null; unitTraces: UnitTrace[]; metrics: MetricsLedger | null; completedKeys: string[]; @@ -143,8 +145,11 @@ async function buildForensicReport(basePath: string): Promise { activeSlice = state.activeSlice?.id ?? null; } catch { /* state derivation failure is non-fatal */ } - // 2. Scan activity logs (last 5) - const unitTraces = scanActivityLogs(basePath); + // 1b. Check for active auto-worktree + const activeWorktree = activeMilestone ? getAutoWorktreePath(basePath, activeMilestone) : null; + + // 2. Scan activity logs (last 5) — worktree-aware + const unitTraces = scanActivityLogs(basePath, activeMilestone); // 3. Load metrics const metrics = loadLedgerFromDisk(basePath); @@ -191,7 +196,7 @@ async function buildForensicReport(basePath: string): Promise { if (metrics?.units) detectStuckLoops(metrics.units, anomalies); if (metrics?.units) detectCostSpikes(metrics.units, anomalies); detectTimeouts(unitTraces, anomalies); - detectMissingArtifacts(completedKeys, basePath, anomalies); + detectMissingArtifacts(completedKeys, basePath, activeMilestone, anomalies); detectCrash(crashLock, anomalies); detectDoctorIssues(doctorIssues, anomalies); detectErrorTraces(unitTraces, anomalies); @@ -202,6 +207,7 @@ async function buildForensicReport(basePath: string): Promise { basePath, activeMilestone, activeSlice, + activeWorktree: activeWorktree ? relative(basePath, activeWorktree) : null, unitTraces, metrics, completedKeys, @@ -216,48 +222,78 @@ async function buildForensicReport(basePath: string): Promise { const ACTIVITY_FILENAME_RE = /^(\d+)-(.+?)-(.+)\.jsonl$/; -function scanActivityLogs(basePath: string): UnitTrace[] { - const activityDir = join(gsdRoot(basePath), "activity"); - if (!existsSync(activityDir)) return []; +function scanActivityLogs(basePath: string, activeMilestone?: string | null): UnitTrace[] { + const activityDirs = resolveActivityDirs(basePath, activeMilestone); + const allTraces: UnitTrace[] = []; - const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")).sort(); - const lastFiles = files.slice(-5); - const traces: UnitTrace[] = []; + for (const activityDir of activityDirs) { + if (!existsSync(activityDir)) continue; - for (const file of lastFiles) { - const match = ACTIVITY_FILENAME_RE.exec(file); - if (!match) continue; + const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl")).sort(); + const lastFiles = files.slice(-5); - const seq = parseInt(match[1]!, 10); - const unitType = match[2]!; - const unitId = match[3]!; - const filePath = join(activityDir, file); + for (const file of lastFiles) { + const match = ACTIVITY_FILENAME_RE.exec(file); + if (!match) continue; - 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 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 }); + + allTraces.push({ + file: activityDirs.length > 1 ? `[${relative(basePath, activityDir)}] ${file}` : file, + unitType, + unitId, + seq, + trace, + mtime: stat?.mtimeMs ?? 0, + }); } - - 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); + // Sort by mtime descending so the most recent traces (regardless of source) come first + return allTraces.sort((a, b) => b.mtime - a.mtime).slice(0, 5); +} + +/** + * Resolve activity directories to scan for forensics. + * If an active auto-worktree exists for the milestone, its activity dir + * is included first (preferred) so stale root logs don't mask worktree progress. + */ +function resolveActivityDirs(basePath: string, activeMilestone?: string | null): string[] { + const dirs: string[] = []; + + // Check for active auto-worktree activity logs + if (activeMilestone) { + const wtPath = getAutoWorktreePath(basePath, activeMilestone); + if (wtPath) { + const wtActivityDir = join(wtPath, ".gsd", "activity"); + if (existsSync(wtActivityDir)) { + dirs.push(wtActivityDir); + } + } + } + + // Always include root activity logs + const rootActivityDir = join(gsdRoot(basePath), "activity"); + dirs.push(rootActivityDir); + + return dirs; } // ─── Completed Keys Loader ──────────────────────────────────────────────────── @@ -336,21 +372,27 @@ function detectTimeouts(traces: UnitTrace[], anomalies: ForensicAnomaly[]): void } } -function detectMissingArtifacts(completedKeys: string[], basePath: string, anomalies: ForensicAnomaly[]): void { +function detectMissingArtifacts(completedKeys: string[], basePath: string, activeMilestone: string | null, anomalies: ForensicAnomaly[]): void { + // Also check the worktree path for artifacts — they may exist there but not at root + const wtBasePath = activeMilestone ? getAutoWorktreePath(basePath, activeMilestone) : null; + 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)) { + const rootHasArtifact = verifyExpectedArtifact(unitType, unitId, basePath); + const wtHasArtifact = wtBasePath ? verifyExpectedArtifact(unitType, unitId, wtBasePath) : false; + + if (!rootHasArtifact && !wtHasArtifact) { 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.`, + details: `The unit is recorded as completed but verifyExpectedArtifact() returns false at both project root and worktree. The completion state is stale.`, }); } } @@ -416,6 +458,7 @@ function saveForensicReport(basePath: string, report: ForensicReport, problemDes `**GSD Version:** ${report.gsdVersion}`, `**Active Milestone:** ${report.activeMilestone ?? "none"}`, `**Active Slice:** ${report.activeSlice ?? "none"}`, + `**Active Worktree:** ${report.activeWorktree ?? "none"}`, ``, `## Problem Description`, ``, @@ -559,6 +602,10 @@ function formatReportForPrompt(report: ForensicReport): string { sections.push(`### GSD Version: ${report.gsdVersion}`); sections.push(`### Active Milestone: ${report.activeMilestone ?? "none"}`); sections.push(`### Active Slice: ${report.activeSlice ?? "none"}`); + if (report.activeWorktree) { + sections.push(`### Active Worktree: ${report.activeWorktree}`); + sections.push(`Note: Activity logs were scanned from both the worktree and the project root. Worktree logs take priority.`); + } let result = sections.join("\n"); if (result.length > MAX_BYTES) { diff --git a/src/resources/extensions/gsd/session-forensics.ts b/src/resources/extensions/gsd/session-forensics.ts index 4abf760a2..4db0a2e12 100644 --- a/src/resources/extensions/gsd/session-forensics.ts +++ b/src/resources/extensions/gsd/session-forensics.ts @@ -22,6 +22,7 @@ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; import { basename, join } from "node:path"; import { nativeParseJsonlTail } from "./native-parser-bridge.js"; import { nativeWorkingTreeStatus, nativeDiffStat } from "./native-git-bridge.js"; +import { getAutoWorktreePath } from "./auto-worktree.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -296,12 +297,45 @@ export function synthesizeCrashRecovery( * Replaces the old shallow getLastActivityDiagnostic(). */ export function getDeepDiagnostic(basePath: string): string | null { - const activityDir = join(basePath, ".gsd", "activity"); - const trace = readLastActivityLog(activityDir); + // Try worktree activity logs first if an auto-worktree is active + let trace: ExecutionTrace | null = null; + try { + const mid = readActiveMilestoneId(basePath); + if (mid) { + const wtPath = getAutoWorktreePath(basePath, mid); + if (wtPath) { + const wtActivityDir = join(wtPath, ".gsd", "activity"); + trace = readLastActivityLog(wtActivityDir); + } + } + } catch { /* non-fatal — fall through to root */ } + + // Fall back to root activity logs + if (!trace || trace.toolCallCount === 0) { + const activityDir = join(basePath, ".gsd", "activity"); + trace = readLastActivityLog(activityDir); + } + if (!trace || trace.toolCallCount === 0) return null; return formatTraceSummary(trace); } +/** + * Read the active milestone ID directly from STATE.md without async deriveState(). + * Looks for `**Active Milestone:** M001` pattern. + */ +function readActiveMilestoneId(basePath: string): string | null { + try { + const statePath = join(basePath, ".gsd", "STATE.md"); + if (!existsSync(statePath)) return null; + const content = readFileSync(statePath, "utf-8"); + const match = /\*\*Active Milestone:\*\*\s*(\S+)/i.exec(content); + return match?.[1] ?? null; + } catch { + return null; + } +} + // ─── Formatting ─────────────────────────────────────────────────────────────── function formatRecoveryPrompt(