Merge pull request #2547 from gsd-build/copilot/update-gsd-forensics-logs-and-journal

Enhance /gsd forensics with journal and activity log awareness
This commit is contained in:
TÂCHES 2026-03-25 16:05:01 -06:00 committed by GitHub
commit 9cc993f21c
6 changed files with 557 additions and 7 deletions

View file

@ -37,7 +37,7 @@ import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./comm
// ─── Types ────────────────────────────────────────────────────────────────────
interface ForensicAnomaly {
type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace";
type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace" | "journal-stuck" | "journal-guard-block" | "journal-rapid-iterations" | "journal-worktree-failure";
severity: "info" | "warning" | "error";
unitType?: string;
unitId?: string;
@ -54,6 +54,37 @@ interface UnitTrace {
mtime: number;
}
/** Summary of .gsd/activity/ directory metadata. */
interface ActivityLogMeta {
fileCount: number;
totalSizeBytes: number;
oldestFile: string | null;
newestFile: string | null;
}
/**
* Summary of .gsd/journal/ data for forensic investigation.
*
* To avoid loading huge journal histories into memory, only the most recent
* daily files are fully parsed. Older files are line-counted for totals.
* Event counts and flow IDs reflect only recent files.
*/
interface JournalSummary {
/** Total journal entries across all files (recent parsed + older line-counted) */
totalEntries: number;
/** Distinct flow IDs from recent files (each = one auto-mode iteration) */
flowCount: number;
/** Event counts by type (from recent files only) */
eventCounts: Record<string, number>;
/** Most recent journal entries (last 20) for context */
recentEvents: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[];
/** Date range of journal data */
oldestEntry: string | null;
newestEntry: string | null;
/** Daily file count */
fileCount: number;
}
interface ForensicReport {
gsdVersion: string;
timestamp: string;
@ -68,6 +99,8 @@ interface ForensicReport {
doctorIssues: DoctorIssue[];
anomalies: ForensicAnomaly[];
recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[];
journalSummary: JournalSummary | null;
activityLogMeta: ActivityLogMeta | null;
}
// ─── Duplicate Detection ──────────────────────────────────────────────────────
@ -276,7 +309,13 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
// from import.meta.url would resolve to ~/package.json (wrong on every system).
const gsdVersion = process.env.GSD_VERSION || "unknown";
// 9. Run anomaly detectors
// 9. Scan journal for flow timeline and structured events
const journalSummary = scanJournalForForensics(basePath);
// 10. Gather activity log directory metadata
const activityLogMeta = gatherActivityLogMeta(basePath, activeMilestone);
// 11. Run anomaly detectors
if (metrics?.units) detectStuckLoops(metrics.units, anomalies);
if (metrics?.units) detectCostSpikes(metrics.units, anomalies);
detectTimeouts(unitTraces, anomalies);
@ -284,6 +323,7 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
detectCrash(crashLock, anomalies);
detectDoctorIssues(doctorIssues, anomalies);
detectErrorTraces(unitTraces, anomalies);
detectJournalAnomalies(journalSummary, anomalies);
return {
gsdVersion,
@ -299,6 +339,8 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
doctorIssues,
anomalies,
recentUnits,
journalSummary,
activityLogMeta,
};
}
@ -306,6 +348,9 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
const ACTIVITY_FILENAME_RE = /^(\d+)-(.+?)-(.+)\.jsonl$/;
/** Threshold below which iteration cadence is considered rapid (thrashing). */
const RAPID_ITERATION_THRESHOLD_MS = 5000;
function scanActivityLogs(basePath: string, activeMilestone?: string | null): UnitTrace[] {
const activityDirs = resolveActivityDirs(basePath, activeMilestone);
const allTraces: UnitTrace[] = [];
@ -380,6 +425,154 @@ function resolveActivityDirs(basePath: string, activeMilestone?: string | null):
return dirs;
}
// ─── Journal Scanner ──────────────────────────────────────────────────────────
/**
* Max recent journal files to fully parse for event counts and recent events.
* Older files are line-counted only to avoid loading huge amounts of data.
*/
const MAX_JOURNAL_RECENT_FILES = 3;
/** Max recent events to extract for the forensic report timeline. */
const MAX_JOURNAL_RECENT_EVENTS = 20;
/**
* Intelligently scan journal files for forensic summary.
*
* Journal files can be huge (thousands of JSONL entries over weeks of auto-mode).
* Instead of loading all entries into memory:
* - Only fully parse the most recent N daily files (event counts, flow tracking)
* - Line-count older files for approximate totals (no JSON parsing)
* - Extract only the last 20 events for the timeline
*/
function scanJournalForForensics(basePath: string): JournalSummary | null {
try {
const journalDir = join(gsdRoot(basePath), "journal");
if (!existsSync(journalDir)) return null;
const files = readdirSync(journalDir).filter(f => f.endsWith(".jsonl")).sort();
if (files.length === 0) return null;
// Split into recent (fully parsed) and older (line-counted only)
const recentFiles = files.slice(-MAX_JOURNAL_RECENT_FILES);
const olderFiles = files.slice(0, -MAX_JOURNAL_RECENT_FILES);
// Line-count older files without parsing — avoids loading megabytes of JSON
let olderEntryCount = 0;
let oldestEntry: string | null = null;
for (const file of olderFiles) {
try {
const raw = readFileSync(join(journalDir, file), "utf-8");
const lines = raw.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
olderEntryCount++;
// Extract only the timestamp from the first non-empty line of the oldest file
if (!oldestEntry) {
try {
const parsed = JSON.parse(line) as { ts?: string };
if (parsed.ts) oldestEntry = parsed.ts;
} catch { /* skip malformed */ }
}
}
} catch { /* skip unreadable files */ }
}
// Fully parse recent files for event counts and timeline
const eventCounts: Record<string, number> = {};
const flowIds = new Set<string>();
const recentParsedEntries: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[] = [];
let recentEntryCount = 0;
for (const file of recentFiles) {
try {
const raw = readFileSync(join(journalDir, file), "utf-8");
for (const line of raw.split("\n")) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line) as { ts: string; flowId: string; eventType: string; rule?: string; data?: Record<string, unknown> };
recentEntryCount++;
eventCounts[entry.eventType] = (eventCounts[entry.eventType] ?? 0) + 1;
flowIds.add(entry.flowId);
if (!oldestEntry) oldestEntry = entry.ts;
// Keep a rolling window of last N events — avoids accumulating unbounded arrays
recentParsedEntries.push({
ts: entry.ts,
flowId: entry.flowId,
eventType: entry.eventType,
rule: entry.rule,
unitId: entry.data?.unitId as string | undefined,
});
if (recentParsedEntries.length > MAX_JOURNAL_RECENT_EVENTS) {
recentParsedEntries.shift();
}
} catch { /* skip malformed lines */ }
}
} catch { /* skip unreadable files */ }
}
const totalEntries = olderEntryCount + recentEntryCount;
if (totalEntries === 0) return null;
const newestEntry = recentParsedEntries.length > 0
? recentParsedEntries[recentParsedEntries.length - 1]!.ts
: null;
return {
totalEntries,
flowCount: flowIds.size,
eventCounts,
recentEvents: recentParsedEntries,
oldestEntry,
newestEntry,
fileCount: files.length,
};
} catch {
return null;
}
}
// ─── Activity Log Metadata ────────────────────────────────────────────────────
function gatherActivityLogMeta(basePath: string, activeMilestone?: string | null): ActivityLogMeta | null {
try {
const activityDirs = resolveActivityDirs(basePath, activeMilestone);
let fileCount = 0;
let totalSizeBytes = 0;
let oldestFile: string | null = null;
let newestFile: string | null = null;
let oldestMtime = Infinity;
let newestMtime = 0;
for (const activityDir of activityDirs) {
if (!existsSync(activityDir)) continue;
const files = readdirSync(activityDir).filter(f => f.endsWith(".jsonl"));
for (const file of files) {
const filePath = join(activityDir, file);
const stat = statSync(filePath, { throwIfNoEntry: false });
if (!stat) continue;
fileCount++;
totalSizeBytes += stat.size;
if (stat.mtimeMs < oldestMtime) {
oldestMtime = stat.mtimeMs;
oldestFile = file;
}
if (stat.mtimeMs > newestMtime) {
newestMtime = stat.mtimeMs;
newestFile = file;
}
}
}
if (fileCount === 0) return null;
return { fileCount, totalSizeBytes, oldestFile, newestFile };
} catch {
return null;
}
}
// ─── Completed Keys Loader ────────────────────────────────────────────────────
function loadCompletedKeys(basePath: string): string[] {
@ -524,6 +717,66 @@ function detectErrorTraces(traces: UnitTrace[], anomalies: ForensicAnomaly[]): v
}
}
function detectJournalAnomalies(journal: JournalSummary | null, anomalies: ForensicAnomaly[]): void {
if (!journal) return;
// Detect stuck-detected events from the journal
const stuckCount = journal.eventCounts["stuck-detected"] ?? 0;
if (stuckCount > 0) {
anomalies.push({
type: "journal-stuck",
severity: stuckCount >= 3 ? "error" : "warning",
summary: `Journal recorded ${stuckCount} stuck-detected event(s)`,
details: `The auto-mode loop detected it was stuck ${stuckCount} time(s). Check journal events for flow IDs and causal chains to trace the root cause.`,
});
}
// Detect guard-block events (dispatch was blocked by a guard)
const guardCount = journal.eventCounts["guard-block"] ?? 0;
if (guardCount > 0) {
anomalies.push({
type: "journal-guard-block",
severity: guardCount >= 5 ? "warning" : "info",
summary: `Journal recorded ${guardCount} guard-block event(s)`,
details: `Dispatch was blocked by a guard condition ${guardCount} time(s). This may indicate a persistent blocking condition preventing progress.`,
});
}
// Detect rapid iterations (many flows in short time = likely thrashing)
if (journal.flowCount > 0 && journal.oldestEntry && journal.newestEntry) {
const oldest = new Date(journal.oldestEntry).getTime();
const newest = new Date(journal.newestEntry).getTime();
const spanMs = newest - oldest;
if (spanMs > 0 && journal.flowCount > 10) {
const avgMs = spanMs / journal.flowCount;
if (avgMs < RAPID_ITERATION_THRESHOLD_MS) {
anomalies.push({
type: "journal-rapid-iterations",
severity: "warning",
summary: `${journal.flowCount} iterations in ${formatDuration(spanMs)} (avg ${formatDuration(avgMs)}/iteration)`,
details: `Unusually rapid iteration cadence suggests the loop may be thrashing without making progress. Review recent journal events for dispatch-stop or terminal events.`,
});
}
}
}
// Detect worktree failures from journal events
const wtCreateFailed = journal.eventCounts["worktree-create-failed"] ?? 0;
const wtMergeFailed = journal.eventCounts["worktree-merge-failed"] ?? 0;
const wtFailures = wtCreateFailed + wtMergeFailed;
if (wtFailures > 0) {
const parts: string[] = [];
if (wtCreateFailed > 0) parts.push(`${wtCreateFailed} create failure(s)`);
if (wtMergeFailed > 0) parts.push(`${wtMergeFailed} merge failure(s)`);
anomalies.push({
type: "journal-worktree-failure",
severity: "warning",
summary: `Worktree failures: ${parts.join(", ")}`,
details: `Journal recorded worktree operation failures. These may indicate git state corruption or conflicting branches.`,
});
}
}
// ─── Report Persistence ───────────────────────────────────────────────────────
function saveForensicReport(basePath: string, report: ForensicReport, problemDescription: string): string {
@ -600,6 +853,45 @@ function saveForensicReport(basePath: string, report: ForensicReport, problemDes
sections.push(redact(formatCrashInfo(report.crashLock)), ``);
}
// Activity log metadata
if (report.activityLogMeta) {
const meta = report.activityLogMeta;
sections.push(`## Activity Log Metadata`, ``);
sections.push(`- Files: ${meta.fileCount}`);
sections.push(`- Total size: ${(meta.totalSizeBytes / 1024).toFixed(1)} KB`);
if (meta.oldestFile) sections.push(`- Oldest: ${meta.oldestFile}`);
if (meta.newestFile) sections.push(`- Newest: ${meta.newestFile}`);
sections.push(``);
}
// Journal summary
if (report.journalSummary) {
const js = report.journalSummary;
sections.push(`## Journal Summary`, ``);
sections.push(`- Total entries: ${js.totalEntries}`);
sections.push(`- Distinct flows (iterations): ${js.flowCount}`);
sections.push(`- Daily files: ${js.fileCount}`);
if (js.oldestEntry) sections.push(`- Date range: ${js.oldestEntry}${js.newestEntry}`);
sections.push(``);
sections.push(`### Event Type Distribution`, ``);
sections.push(`| Event Type | Count |`);
sections.push(`|------------|-------|`);
for (const [evType, count] of Object.entries(js.eventCounts).sort((a, b) => b[1] - a[1])) {
sections.push(`| ${evType} | ${count} |`);
}
sections.push(``);
if (js.recentEvents.length > 0) {
sections.push(`### Recent Journal Events (last ${js.recentEvents.length})`, ``);
for (const ev of js.recentEvents) {
const parts = [`${ev.ts} [${ev.eventType}] flow=${ev.flowId.slice(0, 8)}`];
if (ev.rule) parts.push(`rule=${ev.rule}`);
if (ev.unitId) parts.push(`unit=${ev.unitId}`);
sections.push(`- ${parts.join(" ")}`);
}
sections.push(``);
}
}
writeFileSync(filePath, sections.join("\n"), "utf-8");
return filePath;
}
@ -681,6 +973,41 @@ function formatReportForPrompt(report: ForensicReport): string {
sections.push("");
}
// Activity log metadata
if (report.activityLogMeta) {
const meta = report.activityLogMeta;
sections.push("### Activity Log Overview");
sections.push(`- Files: ${meta.fileCount}, Total size: ${(meta.totalSizeBytes / 1024).toFixed(1)} KB`);
if (meta.oldestFile) sections.push(`- Oldest: ${meta.oldestFile}`);
if (meta.newestFile) sections.push(`- Newest: ${meta.newestFile}`);
sections.push("");
}
// Journal summary — structured event timeline
if (report.journalSummary) {
const js = report.journalSummary;
sections.push("### Journal Summary (Iteration Event Log)");
sections.push(`- Total entries: ${js.totalEntries}, Distinct flows: ${js.flowCount}, Daily files: ${js.fileCount}`);
if (js.oldestEntry) sections.push(`- Date range: ${js.oldestEntry}${js.newestEntry}`);
// Event type distribution (compact)
const eventPairs = Object.entries(js.eventCounts).sort((a, b) => b[1] - a[1]);
sections.push(`- Events: ${eventPairs.map(([t, c]) => `${t}(${c})`).join(", ")}`);
// Recent events timeline (for tracing what just happened)
if (js.recentEvents.length > 0) {
sections.push("");
sections.push(`**Recent Journal Events (last ${js.recentEvents.length}):**`);
for (const ev of js.recentEvents) {
const parts = [`${ev.ts} [${ev.eventType}] flow=${ev.flowId.slice(0, 8)}`];
if (ev.rule) parts.push(`rule=${ev.rule}`);
if (ev.unitId) parts.push(`unit=${ev.unitId}`);
sections.push(`- ${parts.join(" ")}`);
}
}
sections.push("");
}
// Completed keys count
sections.push(`### Completed Keys: ${report.completedKeys.length}`);
sections.push(`### GSD Version: ${report.gsdVersion}`);

View file

@ -36,6 +36,8 @@ GSD extension source code is at: `{{gsdSourceDir}}`
├── doctor-history.jsonl — doctor check history
├── activity/ — session activity logs (JSONL per unit)
│ └── {seq}-{unitType}-{unitId}.jsonl
├── journal/ — structured event journal (JSONL per day)
│ └── YYYY-MM-DD.jsonl
├── runtime/
│ ├── paused-session.json — serialized session when auto pauses
│ └── headless-context.md — headless resume context
@ -60,6 +62,32 @@ GSD extension source code is at: `{{gsdSourceDir}}`
- `usage` field on assistant messages: `input`, `output`, `cacheRead`, `cacheWrite`, `totalTokens`, `cost`
- **To trace a failure**: find the last activity log, search for `isError: true` tool results, then read the agent's reasoning text preceding that error
### Journal Format (`.gsd/journal/`)
The journal is a structured event log for auto-mode iterations. Each daily file contains JSONL entries:
```
{ ts: "ISO-8601", flowId: "UUID", seq: 0, eventType: "iteration-start", rule?: "rule-name", causedBy?: { flowId, seq }, data?: { unitId, status, ... } }
```
**Key event types:**
- `iteration-start` / `iteration-end` — marks loop iteration boundaries
- `dispatch-match` / `dispatch-stop` — what the auto-mode decided to do (or not do)
- `unit-start` / `unit-end` — lifecycle of individual work units
- `terminal` — auto-mode reached a terminal state (all done, budget exceeded, etc.)
- `guard-block` — dispatch was blocked by a guard condition (e.g. needs user input)
- `stuck-detected` — the loop detected it was stuck (same unit repeatedly dispatched)
- `milestone-transition` — a milestone was promoted or completed
- `worktree-enter` / `worktree-create-failed` / `worktree-merge-start` / `worktree-merge-failed` — worktree operations
**Key concepts:**
- **flowId**: UUID grouping all events in one iteration. Use to reconstruct what happened in a single loop pass.
- **causedBy**: Cross-reference to a prior event (same or different flow). Enables causal chain tracing.
- **seq**: Monotonically increasing within a flow. Reconstruct event order within an iteration.
**To trace a stuck loop**: filter for `stuck-detected` events, then follow `flowId` to see the surrounding dispatch and unit events.
**To trace a guard block**: filter for `guard-block` events, check `data.reason` for why dispatch was blocked.
### Crash Lock Format (`auto.lock`)
JSON with fields: `pid`, `startedAt`, `unitType`, `unitId`, `unitStartedAt`, `completedUnits`, `sessionFile`
@ -78,20 +106,24 @@ A unit dispatched more than once (`type/id` appears multiple times) indicates a
1. **Start with the pre-parsed forensic report** above. The anomaly section contains automated findings — treat these as leads, not conclusions.
2. **Form hypotheses** about which module and code path is responsible. Use the source map to identify candidate files.
2. **Check the journal timeline** if present. The journal events show the auto-mode's decision sequence (dispatches, guards, stuck detection, worktree operations). Use flow IDs to group related events and trace causal chains.
3. **Read the actual GSD source code** at `{{gsdSourceDir}}` to confirm or deny each hypothesis. Do not guess what code does — read it.
3. **Cross-reference activity logs and journal**. Activity logs show *what the LLM did* (tool calls, reasoning, errors). Journal events show *what auto-mode decided* (dispatch rules, iteration boundaries, state transitions). Together they reveal the full picture.
4. **Trace the code path** from the entry point (usually `auto-loop.ts` dispatch or `auto-dispatch.ts`) through to the failure point. Follow function calls across files.
4. **Form hypotheses** about which module and code path is responsible. Use the source map to identify candidate files.
5. **Identify the specific file and line** where the bug lives. Determine what kind of defect it is:
5. **Read the actual GSD source code** at `{{gsdSourceDir}}` to confirm or deny each hypothesis. Do not guess what code does — read it.
6. **Trace the code path** from the entry point (usually `auto-loop.ts` dispatch or `auto-dispatch.ts`) through to the failure point. Follow function calls across files.
7. **Identify the specific file and line** where the bug lives. Determine what kind of defect it is:
- Missing edge case / unhandled condition
- Wrong boolean logic or comparison
- Race condition or ordering issue
- State corruption (e.g. completed-units.json out of sync with artifacts)
- Timeout / recovery logic not triggering correctly
6. **Clarify if needed.** Use ask_user_questions (max 2 questions) only if the report is genuinely insufficient. Do not ask questions you can answer from the data or source code.
8. **Clarify if needed.** Use ask_user_questions (max 2 questions) only if the report is genuinely insufficient. Do not ask questions you can answer from the data or source code.
## Output

View file

@ -0,0 +1,162 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const gsdDir = join(__dirname, "..");
describe("forensics journal & activity log awareness", () => {
const forensicsSrc = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
const promptSrc = readFileSync(join(gsdDir, "prompts", "forensics.md"), "utf-8");
it("scanJournalForForensics reads journal files directly (no full queryJournal load)", () => {
// Must NOT use queryJournal which loads ALL entries into memory
assert.ok(
!forensicsSrc.includes('queryJournal('),
"forensics.ts must NOT call queryJournal() which loads all entries at once",
);
// Must have its own journal scanning with file-level limits
assert.ok(
forensicsSrc.includes("scanJournalForForensics"),
"forensics.ts must have scanJournalForForensics function",
);
});
it("journal scanning limits files parsed to avoid memory bloat", () => {
assert.ok(
forensicsSrc.includes("MAX_JOURNAL_RECENT_FILES"),
"must have MAX_JOURNAL_RECENT_FILES constant to limit parsed files",
);
assert.ok(
forensicsSrc.includes("MAX_JOURNAL_RECENT_EVENTS"),
"must have MAX_JOURNAL_RECENT_EVENTS constant to limit events extracted",
);
});
it("older journal files are line-counted without full JSON parse", () => {
assert.ok(
forensicsSrc.includes("olderEntryCount") || forensicsSrc.includes("olderFiles"),
"must handle older files separately from recent files",
);
});
it("ForensicReport includes journalSummary field", () => {
assert.ok(
forensicsSrc.includes("journalSummary"),
"ForensicReport must include journalSummary field",
);
});
it("ForensicReport includes activityLogMeta field", () => {
assert.ok(
forensicsSrc.includes("activityLogMeta"),
"ForensicReport must include activityLogMeta field",
);
});
it("buildForensicReport calls scanJournalForForensics", () => {
assert.ok(
forensicsSrc.includes("scanJournalForForensics"),
"buildForensicReport must call scanJournalForForensics",
);
});
it("buildForensicReport calls gatherActivityLogMeta", () => {
assert.ok(
forensicsSrc.includes("gatherActivityLogMeta"),
"buildForensicReport must call gatherActivityLogMeta",
);
});
it("forensics detects journal-based anomalies", () => {
assert.ok(
forensicsSrc.includes("detectJournalAnomalies"),
"forensics.ts must have detectJournalAnomalies function",
);
// Check for specific journal anomaly types
assert.ok(forensicsSrc.includes('"journal-stuck"'), "must detect journal-stuck anomalies");
assert.ok(forensicsSrc.includes('"journal-guard-block"'), "must detect journal-guard-block anomalies");
assert.ok(forensicsSrc.includes('"journal-rapid-iterations"'), "must detect journal-rapid-iterations anomalies");
assert.ok(forensicsSrc.includes('"journal-worktree-failure"'), "must detect journal-worktree-failure anomalies");
});
it("formatReportForPrompt includes journal summary section", () => {
assert.ok(
forensicsSrc.includes("Journal Summary"),
"prompt formatter must include a Journal Summary section",
);
});
it("formatReportForPrompt includes activity log overview section", () => {
assert.ok(
forensicsSrc.includes("Activity Log Overview"),
"prompt formatter must include an Activity Log Overview section",
);
});
it("activity log scanning uses tail-read with byte cap (not full file load)", () => {
// scanActivityLogs uses nativeParseJsonlTail + MAX_JSONL_BYTES for efficient reading
assert.ok(
forensicsSrc.includes("nativeParseJsonlTail"),
"activity log scanning must use nativeParseJsonlTail for tail-reading",
);
assert.ok(
forensicsSrc.includes("MAX_JSONL_BYTES"),
"activity log scanning must respect MAX_JSONL_BYTES cap",
);
// Only reads last 5 files
assert.ok(
forensicsSrc.includes("slice(-5)"),
"activity log scanning must limit to last 5 files",
);
});
it("activity log entries are distilled through extractTrace, not sent raw", () => {
assert.ok(
forensicsSrc.includes("extractTrace("),
"activity log entries must be distilled through extractTrace before reporting",
);
});
it("prompt output is hard-capped at 30KB", () => {
assert.ok(
forensicsSrc.includes("MAX_BYTES") && forensicsSrc.includes("30 * 1024"),
"formatReportForPrompt must have a 30KB hard cap",
);
assert.ok(
forensicsSrc.includes("truncated at 30KB"),
"prompt must show truncation message when capped",
);
});
it("forensics prompt documents journal format", () => {
assert.ok(
promptSrc.includes("### Journal Format"),
"forensics.md must document the journal format",
);
assert.ok(
promptSrc.includes("flowId"),
"forensics.md must reference flowId concept",
);
assert.ok(
promptSrc.includes("causedBy"),
"forensics.md must reference causedBy for causal chains",
);
});
it("forensics prompt includes journal directory in runtime path reference", () => {
assert.ok(
promptSrc.includes("journal/"),
"forensics.md runtime path reference must include journal/",
);
});
it("investigation protocol references journal data", () => {
assert.ok(
promptSrc.includes("journal timeline") || promptSrc.includes("journal events"),
"investigation protocol must reference journal data for tracing",
);
});
});

View file

@ -69,6 +69,8 @@ describe("diagnostics type exports", () => {
unitTraces: [],
completedKeyCount: 0,
metrics: null,
journalSummary: null,
activityLogMeta: null,
}
assert.equal(typeof report.gsdVersion, "string")
assert.equal(typeof report.timestamp, "string")
@ -79,6 +81,8 @@ describe("diagnostics type exports", () => {
assert.equal(typeof report.doctorIssueCount, "number")
assert.equal(typeof report.unitTraceCount, "number")
assert.equal(typeof report.completedKeyCount, "number")
assert.equal(report.journalSummary, null)
assert.equal(report.activityLogMeta, null)
})
it("ForensicMetricsSummary has required fields", () => {

View file

@ -70,6 +70,8 @@ export async function collectForensicsData(projectCwdOverride?: string): Promise
' unitTraces,',
' completedKeyCount: (report.completedKeys || []).length,',
' metrics,',
' journalSummary: report.journalSummary || null,',
' activityLogMeta: report.activityLogMeta || null,',
'};',
'process.stdout.write(JSON.stringify(result));',
].join(" ")

View file

@ -13,6 +13,10 @@ export type ForensicAnomalyType =
| "crash"
| "doctor-issue"
| "error-trace"
| "journal-stuck"
| "journal-guard-block"
| "journal-rapid-iterations"
| "journal-worktree-failure"
export interface ForensicAnomaly {
type: ForensicAnomalyType
@ -56,6 +60,23 @@ export interface ForensicRecentUnit {
finishedAt: number
}
export interface ForensicActivityLogMeta {
fileCount: number
totalSizeBytes: number
oldestFile: string | null
newestFile: string | null
}
export interface ForensicJournalSummary {
totalEntries: number
flowCount: number
eventCounts: Record<string, number>
recentEvents: { ts: string; flowId: string; eventType: string; rule?: string; unitId?: string }[]
oldestEntry: string | null
newestEntry: string | null
fileCount: number
}
export interface ForensicReport {
gsdVersion: string
timestamp: string
@ -70,6 +91,8 @@ export interface ForensicReport {
unitTraces: ForensicUnitTrace[]
completedKeyCount: number
metrics: ForensicMetricsSummary | null
journalSummary: ForensicJournalSummary | null
activityLogMeta: ForensicActivityLogMeta | null
}
// ─── Doctor ───────────────────────────────────────────────────────────────────