feat: enhance /gsd forensics with journal and activity log awareness

- Add journalSummary to ForensicReport: flow count, event type
  distribution, recent events timeline, date range
- Add activityLogMeta to ForensicReport: file count, total size,
  oldest/newest files
- Add journal-based anomaly detectors: stuck-detected, guard-block,
  rapid-iterations, worktree-failure events
- Update formatReportForPrompt and saveForensicReport to include
  journal timeline and activity log metadata
- Update forensics prompt template with journal format docs,
  investigation guidance for cross-referencing activity+journal
- Update web types (diagnostics-types.ts) and forensics-service.ts
  for new fields
- Add forensics-journal.test.ts with 11 contract tests

Co-authored-by: glittercowboy <186001655+glittercowboy@users.noreply.github.com>
Agent-Logs-Url: https://github.com/gsd-build/gsd-2/sessions/d648480a-42f4-4c41-81c7-85038609c717
This commit is contained in:
copilot-swe-agent[bot] 2026-03-25 18:19:08 +00:00
parent 811680f5b6
commit 1737b169b5
6 changed files with 429 additions and 7 deletions

View file

@ -28,6 +28,7 @@ import { deriveState } from "./state.js";
import { isAutoActive } from "./auto.js";
import { loadPrompt } from "./prompt-loader.js";
import { gsdRoot } from "./paths.js";
import { queryJournal } from "./journal.js";
import { formatDuration } from "../shared/format-utils.js";
import { getAutoWorktreePath } from "./auto-worktree.js";
import { loadEffectiveGSDPreferences, loadGlobalGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
@ -37,7 +38,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 +55,31 @@ 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. */
interface JournalSummary {
/** Total journal entries scanned */
totalEntries: number;
/** Distinct flow IDs (each = one auto-mode iteration) */
flowCount: number;
/** Event counts by type */
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 +94,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 +304,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 +318,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 +334,8 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
doctorIssues,
anomalies,
recentUnits,
journalSummary,
activityLogMeta,
};
}
@ -380,6 +417,89 @@ function resolveActivityDirs(basePath: string, activeMilestone?: string | null):
return dirs;
}
// ─── Journal Scanner ──────────────────────────────────────────────────────────
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;
const entries = queryJournal(basePath);
if (entries.length === 0) return null;
// Count events by type
const eventCounts: Record<string, number> = {};
const flowIds = new Set<string>();
for (const e of entries) {
eventCounts[e.eventType] = (eventCounts[e.eventType] ?? 0) + 1;
flowIds.add(e.flowId);
}
// Extract recent events (last 20) with key fields for the report
const recentEvents = entries.slice(-20).map(e => ({
ts: e.ts,
flowId: e.flowId,
eventType: e.eventType,
rule: e.rule,
unitId: (e.data as Record<string, unknown> | undefined)?.unitId as string | undefined,
}));
return {
totalEntries: entries.length,
flowCount: flowIds.size,
eventCounts,
recentEvents,
oldestEntry: entries[0]?.ts ?? null,
newestEntry: entries[entries.length - 1]?.ts ?? null,
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 +644,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 < 5000) { // Less than 5 seconds per iteration
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 +780,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 +900,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,107 @@
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("forensics.ts imports queryJournal from journal module", () => {
assert.ok(
forensicsSrc.includes('from "./journal.js"') || forensicsSrc.includes("from './journal.js'"),
"forensics.ts must import from journal.js",
);
assert.ok(
forensicsSrc.includes("queryJournal"),
"forensics.ts must reference queryJournal",
);
});
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("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 ───────────────────────────────────────────────────────────────────