diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index ca88ede51..60b4acfa0 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -377,6 +377,9 @@ openclaude doctor report --markdown # write a redacted JSON issue report for attachment openclaude doctor report --json --out openclaude-report.json +# write a deterministic JSON task report from a session transcript +openclaude report --json --transcript ~/.openclaude/projects/-path-to-project/session-id.jsonl --out task-report.json + # full local hardening check (smoke + runtime doctor) bun run hardening:check @@ -391,6 +394,7 @@ Notes: - Local providers such as `http://localhost:11434/v1`, `http://10.0.0.1:11434/v1`, and `http://127.0.0.1:1337/v1` can run without `OPENAI_API_KEY`. - Codex profiles validate `CODEX_API_KEY` or the Codex CLI auth file and probe `POST /responses` instead of `GET /models`. - `openclaude doctor report` is redacted by default and is intended for GitHub issues. It summarizes provider/runtime/build/settings state without prompts, transcripts, raw settings files, API keys, MCP command details, or full home-directory paths. +- `openclaude report --json` summarizes observed session facts such as tool uses, Bash commands, validation commands, changed files, branch metadata, warnings, and linked issue/PR references. Use `--transcript ` for an explicit transcript, `--session ` for a stored session, or omit both to report the latest session for the current project. Large previews are truncated and credential-shaped strings are redacted. When no validation command is observed, the report keeps `validations` empty and includes a warning instead of claiming checks passed. ## Provider Launch Profiles diff --git a/src/cli/handlers/taskReport.ts b/src/cli/handlers/taskReport.ts new file mode 100644 index 000000000..776b173dc --- /dev/null +++ b/src/cli/handlers/taskReport.ts @@ -0,0 +1,109 @@ +import { resolve } from 'node:path' + +import { + buildTaskReport, + formatTaskReportAsJson, + writeTaskReport, + type TaskReportArgs, +} from '../../utils/taskReport.js' +import { + getProjectDir, + getSessionFilesWithMtime, +} from '../../utils/sessionStorage.js' + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function writeLine( + stream: NodeJS.WritableStream, + message: string, +): Promise { + return new Promise((resolve, reject) => { + stream.write(`${message}\n`, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +export async function taskReportHandler( + options: TaskReportArgs, +): Promise { + if (options.format !== 'json') { + throw new Error('Task reports currently support JSON output only') + } + + const cwd = resolve(options.cwd ?? process.cwd()) + const transcriptPath = await resolveTranscriptPath({ + cwd, + sessionId: options.sessionId ?? null, + transcriptPath: options.transcriptPath ?? null, + }) + const report = await buildTaskReport({ + transcriptPath, + cwd, + }) + const content = formatTaskReportAsJson(report) + + if (options.outFile) { + const outputPath = await writeTaskReport(options.outFile, content) + await writeLine(process.stderr, `Task report written to ${outputPath}`) + return + } + + await writeLine(process.stdout, content) +} + +export async function printTaskReportError(error: unknown): Promise { + await writeLine( + process.stderr, + `Failed to generate task report: ${formatError(error)}`, + ) +} + +async function resolveTranscriptPath({ + cwd, + sessionId, + transcriptPath, +}: { + cwd: string + sessionId: string | null + transcriptPath: string | null +}): Promise { + if (transcriptPath) { + return resolve(cwd, transcriptPath) + } + + const sessionFiles = await getSessionFilesWithMtime(getProjectDir(cwd)) + if (sessionId) { + const sessionFile = sessionFiles.get(sessionId) + if (!sessionFile) { + throw new Error(`Session transcript not found: ${sessionId}`) + } + return sessionFile.path + } + + let latest: { path: string; mtime: number; ctime: number } | null = null + for (const sessionFile of sessionFiles.values()) { + if ( + !latest || + sessionFile.mtime > latest.mtime || + (sessionFile.mtime === latest.mtime && + (sessionFile.ctime > latest.ctime || + (sessionFile.ctime === latest.ctime && + sessionFile.path.localeCompare(latest.path) < 0))) + ) { + latest = sessionFile + } + } + if (!latest) { + throw new Error( + 'No session transcripts found for the current project. Pass --transcript or --session .', + ) + } + return latest.path +} diff --git a/src/main.tsx b/src/main.tsx index 0f355eed8..973b12e30 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4215,6 +4215,53 @@ async function run(): Promise { }); } + program + .command('report') + .description( + 'Generate a deterministic JSON task report for an OpenClaude session', + ) + .option('--json', 'Print JSON output') + .option('--transcript ', 'Path to a session JSONL transcript') + .option( + '--session ', + 'Session ID to report (defaults to latest session in the current project)', + ) + .option('--out ', 'Write the report to a file') + .action(async (options: { + json?: boolean; + transcript?: string; + session?: string; + out?: string; + }) => { + const { + taskReportHandler, + printTaskReportError, + } = await import('./cli/handlers/taskReport.js'); + try { + if (options.json !== true) { + throw new Error( + 'Task reports currently support JSON output only. Pass --json.', + ); + } + if (options.transcript && options.session) { + throw new Error( + 'Pass either --transcript or --session , not both.', + ); + } + await taskReportHandler({ + format: 'json', + transcriptPath: options.transcript ?? null, + sessionId: options.session ?? null, + outFile: options.out ?? null, + cwd: process.cwd(), + }); + process.exit(0); + } catch (error) { + await printTaskReportError(error); + process.exit(1); + } + }); + // Doctor command - check installation health const doctorCommand = program .command('doctor') diff --git a/src/utils/reportTask.test.ts b/src/utils/reportTask.test.ts new file mode 100644 index 000000000..2df266da3 --- /dev/null +++ b/src/utils/reportTask.test.ts @@ -0,0 +1,1358 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + buildTaskReport, + collectTaskReportGitMetadata, + formatTaskReportAsJson, + type TaskReportGitMetadata, +} from './taskReport.js' + +const sessionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const cwd = '/workspace/openclaude' + +function withTempTranscript( + entries: Array | string>, + fn: (path: string) => Promise, +) { + const dir = mkdtempSync(join(tmpdir(), 'openclaude-task-report-')) + const file = join(dir, `${sessionId}.jsonl`) + writeFileSync( + file, + entries + .map(entry => (typeof entry === 'string' ? entry : JSON.stringify(entry))) + .join('\n'), + ) + + return fn(file).finally(() => { + rmSync(dir, { recursive: true, force: true }) + }) +} + +function userMessage(uuid: string, content: unknown, timestamp: string) { + return { + type: 'user', + uuid, + parentUuid: null, + isSidechain: false, + cwd, + sessionId, + timestamp, + version: 'test', + gitBranch: 'feat/source-branch', + userType: 'external', + message: { + role: 'user', + content, + }, + } +} + +function assistantToolMessage( + uuid: string, + toolUse: Record, + timestamp: string, +) { + return { + type: 'assistant', + uuid, + parentUuid: null, + isSidechain: false, + cwd, + sessionId, + timestamp, + version: 'test', + gitBranch: 'feat/source-branch', + message: { + role: 'assistant', + id: `msg-${uuid}`, + model: 'gpt-5-test', + content: [ + { + type: 'tool_use', + ...toolUse, + }, + ], + }, + } +} + +function toolResultMessage( + uuid: string, + toolUseId: string, + content: unknown, + timestamp: string, + toolUseResult?: unknown, + isError = false, +) { + return { + type: 'user', + uuid, + parentUuid: null, + isSidechain: false, + cwd, + sessionId, + timestamp, + version: 'test', + userType: 'external', + sourceToolAssistantUUID: 'assistant-source', + toolUseResult, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content, + is_error: isError, + }, + ], + }, + } +} + +function gitMetadata( + overrides: Partial = {}, +): TaskReportGitMetadata { + return { + status: 'available', + cwd, + branch: 'feat/session-task-report-json', + head: '13cf30af', + dirty: true, + changedFiles: ['src/report.ts'], + ...overrides, + } +} + +describe('task report generation', () => { + test('uses an empty validation list and explicit warning when no validation was observed', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000001', + 'Generate a task report for issue #123.', + '2026-06-27T08:00:00.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.schemaVersion).toBe(1) + expect(report.session.id).toBe(sessionId) + expect(report.session.cwd).toBe(cwd) + expect(report.session.initialRequest).toBe( + 'Generate a task report for issue #123.', + ) + expect(report.validations).toEqual([]) + expect(report.warnings).toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + }) + + test('captures passing validation commands from observed Bash results', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000002', + 'Run the checks.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000003', + { + id: 'tool-validation-pass', + name: 'Bash', + input: { + command: 'bun run typecheck', + description: 'Run TypeScript checks', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000004', + 'tool-validation-pass', + 'Typecheck passed', + '2026-06-27T08:01:03.000Z', + { stdout: 'Typecheck passed\n', stderr: '', interrupted: false }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.commands).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-validation-pass', + command: 'bun run typecheck', + description: 'Run TypeScript checks', + status: 'success', + }), + ]) + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-validation-pass', + command: 'bun run typecheck', + status: 'success', + }), + ]) + expect(report.warnings).not.toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + }) + + test('captures passing validation commands from observed PowerShell results', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000045', + 'Run the Windows checks.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000046', + { + id: 'tool-powershell-validation-pass', + name: 'PowerShell', + input: { + command: 'bun run typecheck', + description: 'Run TypeScript checks', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000047', + 'tool-powershell-validation-pass', + 'Typecheck passed', + '2026-06-27T08:01:03.000Z', + { stdout: 'Typecheck passed\n', stderr: '', interrupted: false }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.commands).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-powershell-validation-pass', + command: 'bun run typecheck', + description: 'Run TypeScript checks', + status: 'success', + }), + ]) + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-powershell-validation-pass', + command: 'bun run typecheck', + status: 'success', + }), + ]) + expect(report.warnings).not.toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + }) + + test('captures failing validation commands with exit code when it is persisted', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000005', + 'Run the failing test.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000006', + { + id: 'tool-validation-fail', + name: 'Bash', + input: { + command: 'bun test src/utils/reportTask.test.ts', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000007', + 'tool-validation-fail', + 'Error calling tool (Bash): tests failed\nExit code 1', + '2026-06-27T08:01:03.000Z', + 'Error calling tool (Bash): tests failed\nExit code 1', + true, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.commands).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-validation-fail', + command: 'bun test src/utils/reportTask.test.ts', + status: 'error', + exitCode: 1, + }), + ]) + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-validation-fail', + command: 'bun test src/utils/reportTask.test.ts', + status: 'error', + exitCode: 1, + }), + ]) + expect(report.errors).toEqual([ + expect.objectContaining({ + source: 'tool', + toolUseId: 'tool-validation-fail', + toolName: 'Bash', + }), + ]) + }, + ) + }) + + test('treats nonzero observed exit code as an error status', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000025', + 'Run a command.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000026', + { + id: 'tool-command-nonzero', + name: 'Bash', + input: { + command: 'node missing.js', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000027', + 'tool-command-nonzero', + 'Exit code 1', + '2026-06-27T08:01:03.000Z', + 'Exit code 1', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.toolUses).toEqual([ + expect.objectContaining({ + id: 'tool-command-nonzero', + status: 'error', + }), + ]) + expect(report.commands).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-command-nonzero', + status: 'error', + exitCode: 1, + }), + ]) + }, + ) + }) + + test('captures numeric structured exit codes from observed Bash results', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000028', + 'Run a structured command.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000029', + { + id: 'tool-command-structured-exit', + name: 'Bash', + input: { + command: 'node missing.js', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000030', + 'tool-command-structured-exit', + 'No textual exit code here', + '2026-06-27T08:01:03.000Z', + { stdout: '', stderr: 'missing\n', exitCode: 2 }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.commands).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-command-structured-exit', + status: 'error', + exitCode: 2, + }), + ]) + }, + ) + }) + + test('reports backgrounded validation commands with unknown status', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000048', + 'Run tests in the background.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000049', + { + id: 'tool-background-validation', + name: 'Bash', + input: { + command: 'bun test src/utils/reportTask.test.ts', + run_in_background: true, + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000050', + 'tool-background-validation', + 'Command running in background with ID: bg-report-tests.', + '2026-06-27T08:01:03.000Z', + { + stdout: '', + stderr: '', + interrupted: false, + backgroundTaskId: 'bg-report-tests', + }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.commands).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-background-validation', + command: 'bun test src/utils/reportTask.test.ts', + status: 'unknown', + }), + ]) + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-background-validation', + command: 'bun test src/utils/reportTask.test.ts', + status: 'unknown', + }), + ]) + expect(report.warnings).not.toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + }) + + test('reconciles completed backgrounded validation notifications', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000084', + 'Run tests in the background.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000085', + { + id: 'tool-background-validation-complete', + name: 'Bash', + input: { + command: 'bun test src/utils/reportTask.test.ts', + run_in_background: true, + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000086', + 'tool-background-validation-complete', + 'Command running in background with ID: bg-report-tests.', + '2026-06-27T08:01:03.000Z', + { + stdout: '', + stderr: '', + interrupted: false, + backgroundTaskId: 'bg-report-tests', + }, + ), + userMessage( + '00000000-0000-4000-8000-000000000087', + [ + { + type: 'text', + text: ` +bg-report-tests +tool-background-validation-complete +/tmp/bg-report-tests.txt +completed +Background command "bun test src/utils/reportTask.test.ts" completed (exit code 0) +`, + }, + ], + '2026-06-27T08:02:03.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.toolUses).toEqual([ + expect.objectContaining({ + id: 'tool-background-validation-complete', + status: 'success', + }), + ]) + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-background-validation-complete', + command: 'bun test src/utils/reportTask.test.ts', + status: 'success', + }), + ]) + }, + ) + }) + + test('reconciles failed backgrounded validation notifications', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000088', + 'Run checks in the background.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000089', + { + id: 'tool-background-validation-fail', + name: 'PowerShell', + input: { + command: 'bun run typecheck', + run_in_background: true, + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000090', + 'tool-background-validation-fail', + 'Command running in background with ID: bg-typecheck.', + '2026-06-27T08:01:03.000Z', + { + stdout: '', + stderr: '', + interrupted: false, + backgroundTaskId: 'bg-typecheck', + }, + ), + userMessage( + '00000000-0000-4000-8000-000000000091', + ` +bg-typecheck +tool-background-validation-fail +/tmp/bg-typecheck.txt +failed +Background command "bun run typecheck" failed with exit code 2 +`, + '2026-06-27T08:02:03.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.toolUses).toEqual([ + expect.objectContaining({ + id: 'tool-background-validation-fail', + status: 'error', + }), + ]) + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-background-validation-fail', + command: 'bun run typecheck', + status: 'error', + }), + ]) + }, + ) + }) + + test('does not let task notifications override resolved foreground shell results', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000092', + 'Run a foreground validation command.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000093', + { + id: 'tool-foreground-validation-conflict', + name: 'Bash', + input: { + command: 'bun test src/utils/reportTask.test.ts', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000094', + 'tool-foreground-validation-conflict', + 'Command failed with exit code 2.', + '2026-06-27T08:01:03.000Z', + { + stdout: '', + stderr: 'failure', + interrupted: false, + exitCode: 2, + }, + ), + userMessage( + '00000000-0000-4000-8000-000000000095', + ` +unrelated-stale-task +tool-foreground-validation-conflict +/tmp/unrelated-stale-task.txt +completed +Background command "bun test src/utils/reportTask.test.ts" completed (exit code 0) +`, + '2026-06-27T08:02:03.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.toolUses).toEqual([ + expect.objectContaining({ + id: 'tool-foreground-validation-conflict', + status: 'error', + }), + ]) + expect(report.commands).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-foreground-validation-conflict', + command: 'bun test src/utils/reportTask.test.ts', + status: 'error', + exitCode: 2, + }), + ]) + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-foreground-validation-conflict', + command: 'bun test src/utils/reportTask.test.ts', + status: 'error', + exitCode: 2, + }), + ]) + }, + ) + }) + + test('classifies validation commands from the raw Bash command before truncation', async () => { + const longPrefix = 'echo setup && '.repeat(20) + const rawCommand = `${longPrefix}bun test src/utils/reportTask.test.ts` + + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000019', + 'Run the long validation command.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000020', + { + id: 'tool-validation-long', + name: 'Bash', + input: { + command: rawCommand, + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000021', + 'tool-validation-long', + 'tests passed', + '2026-06-27T08:01:03.000Z', + { stdout: 'tests passed\n', stderr: '', interrupted: false }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + maxPreviewChars: 32, + }) + + expect(report.commands[0]?.command).not.toContain('bun test') + expect(report.validations).toEqual([ + expect.objectContaining({ + toolUseId: 'tool-validation-long', + status: 'success', + }), + ]) + expect(report.warnings).not.toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + }) + + test('classifies validation commands inside quoted shell wrappers', async () => { + const commands = [ + "bash -lc 'bun run check'", + 'powershell -NoProfile -Command "bun run typecheck"', + ] + + for (const [index, command] of commands.entries()) { + await withTempTranscript( + [ + userMessage( + `00000000-0000-4000-8000-${String(78 + index * 3).padStart(12, '0')}`, + `Run ${command}.`, + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + `00000000-0000-4000-8000-${String(79 + index * 3).padStart(12, '0')}`, + { + id: `tool-wrapper-validation-${index}`, + name: 'Bash', + input: { + command, + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + `00000000-0000-4000-8000-${String(80 + index * 3).padStart(12, '0')}`, + `tool-wrapper-validation-${index}`, + 'passed', + '2026-06-27T08:01:03.000Z', + { stdout: 'passed\n', stderr: '', exitCode: 0 }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.validations).toEqual([ + expect.objectContaining({ + command, + status: 'success', + }), + ]) + expect(report.warnings).not.toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + } + }) + + test('classifies documented package checks as validations', async () => { + const commands = [ + 'bun run web:typecheck', + 'bun run web:build', + 'bun run integrations:check', + 'bun run verify:privacy', + 'bun run doctor:runtime', + 'bun run doctor:runtime:json', + 'bun run build:verified', + 'bun run hardening:check', + 'bun run hardening:strict', + ] + + for (const [index, command] of commands.entries()) { + await withTempTranscript( + [ + userMessage( + `00000000-0000-4000-8000-${String(51 + index * 3).padStart(12, '0')}`, + `Run ${command}.`, + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + `00000000-0000-4000-8000-${String(52 + index * 3).padStart(12, '0')}`, + { + id: `tool-${command.replaceAll(/[^A-Za-z0-9]/g, '-')}`, + name: 'Bash', + input: { + command, + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + `00000000-0000-4000-8000-${String(53 + index * 3).padStart(12, '0')}`, + `tool-${command.replaceAll(/[^A-Za-z0-9]/g, '-')}`, + 'passed', + '2026-06-27T08:01:03.000Z', + { stdout: 'passed\n', stderr: '', exitCode: 0 }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.validations).toEqual([ + expect.objectContaining({ + command, + status: 'success', + }), + ]) + expect(report.warnings).not.toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + } + }) + + test('captures file changes and branch metadata when available', async () => { + await withTempTranscript( + [ + { + type: 'custom-title', + sessionId, + customTitle: 'Generate deterministic task reports', + }, + { + type: 'worktree-state', + sessionId, + worktreeSession: { + originalCwd: cwd, + worktreePath: '/workspace/openclaude-report', + worktreeName: 'openclaude-report', + worktreeBranch: 'feat/session-task-report-json', + originalBranch: 'main', + originalHeadCommit: '13cf30af', + sessionId, + }, + }, + { + type: 'pr-link', + sessionId, + prNumber: 456, + prUrl: 'https://github.com/Gitlawb/openclaude/pull/456', + prRepository: 'Gitlawb/openclaude', + timestamp: '2026-06-27T08:01:00.000Z', + }, + userMessage( + '00000000-0000-4000-8000-000000000008', + 'Update src/report.ts for https://github.com/Gitlawb/openclaude/issues/123.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000009', + { + id: 'tool-edit', + name: 'Edit', + input: { + file_path: `${cwd}/src/report.ts`, + old_string: 'old', + new_string: 'new', + }, + }, + '2026-06-27T08:02:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000010', + 'tool-edit', + 'Updated src/report.ts', + '2026-06-27T08:02:02.000Z', + { filePath: `${cwd}/src/report.ts` }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => + gitMetadata({ + changedFiles: ['src/report.ts', 'src/report.test.ts'], + }), + }) + + expect(report.session.name).toBe('Generate deterministic task reports') + expect(report.branch.transcriptBranch).toBe('feat/source-branch') + expect(report.branch.worktree).toEqual( + expect.objectContaining({ + branch: 'feat/session-task-report-json', + originalBranch: 'main', + originalHead: '13cf30af', + }), + ) + expect(report.branch.pullRequest).toEqual({ + number: 456, + repository: 'Gitlawb/openclaude', + url: 'https://github.com/Gitlawb/openclaude/pull/456', + }) + expect(report.git).toEqual( + expect.objectContaining({ + status: 'available', + branch: 'feat/session-task-report-json', + head: '13cf30af', + dirty: true, + changedFiles: ['src/report.test.ts', 'src/report.ts'], + }), + ) + expect(report.changedFiles).toEqual([ + { path: 'src/report.test.ts', sources: ['git'] }, + { path: 'src/report.ts', sources: ['git', 'tool'] }, + ]) + expect(report.linkedReferences).toEqual([ + { + kind: 'issue', + number: 123, + repository: 'Gitlawb/openclaude', + url: 'https://github.com/Gitlawb/openclaude/issues/123', + }, + { + kind: 'pull_request', + number: 456, + repository: 'Gitlawb/openclaude', + url: 'https://github.com/Gitlawb/openclaude/pull/456', + }, + ]) + }, + ) + }) + + test('normalizes in-repo paths whose relative path starts with dots', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000037', + 'Update a dot-prefixed fixture path.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000038', + { + id: 'tool-dot-fixture', + name: 'Edit', + input: { + file_path: `${cwd}/..fixtures/report.ts`, + old_string: 'old', + new_string: 'new', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000039', + 'tool-dot-fixture', + 'Updated fixture', + '2026-06-27T08:01:02.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.changedFiles).toEqual([ + { path: '..fixtures/report.ts', sources: ['tool'] }, + ]) + }, + ) + }) + + test('normalizes Windows-style tool paths before merging with git paths', async () => { + const windowsCwd = 'C:\\workspace\\openclaude' + + await withTempTranscript( + [ + { + ...userMessage( + '00000000-0000-4000-8000-000000000040', + 'Update Windows paths.', + '2026-06-27T08:00:00.000Z', + ), + cwd: windowsCwd, + }, + assistantToolMessage( + '00000000-0000-4000-8000-000000000041', + { + id: 'tool-windows-path', + name: 'Edit', + input: { + file_path: 'C:\\workspace\\openclaude\\src\\report.ts', + old_string: 'old', + new_string: 'new', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000042', + 'tool-windows-path', + 'Updated report', + '2026-06-27T08:01:02.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000043', + { + id: 'tool-windows-dot-path', + name: 'Edit', + input: { + file_path: 'C:\\workspace\\openclaude\\..fixtures\\report.ts', + old_string: 'old', + new_string: 'new', + }, + }, + '2026-06-27T08:02:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000044', + 'tool-windows-dot-path', + 'Updated fixture', + '2026-06-27T08:02:02.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => + gitMetadata({ + cwd: windowsCwd, + changedFiles: ['src/report.ts'], + }), + }) + + expect(report.changedFiles).toEqual([ + { path: '..fixtures/report.ts', sources: ['tool'] }, + { path: 'src/report.ts', sources: ['git', 'tool'] }, + ]) + }, + ) + }) + + test('prefers transcript cwd over caller cwd for git metadata', async () => { + const callerCwd = '/workspace/different-project' + const observedGitCwds: string[] = [] + + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000015', + 'Report the session.', + '2026-06-27T08:00:00.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + cwd: callerCwd, + git: async gitCwd => { + observedGitCwds.push(gitCwd) + return gitMetadata({ + cwd: gitCwd, + branch: 'feat/session-cwd', + dirty: false, + changedFiles: [], + }) + }, + }) + + expect(observedGitCwds).toEqual([cwd]) + expect(report.git).toEqual( + expect.objectContaining({ + cwd, + branch: 'feat/session-cwd', + dirty: false, + }), + ) + }, + ) + }) + + test('does not serialize file read result content in tool summaries', async () => { + const fileBody = 'PRIVATE_FILE_BODY_SHOULD_NOT_APPEAR' + + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000016', + 'Inspect a file.', + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000017', + { + id: 'tool-read', + name: 'Read', + input: { + file_path: 'src/secret.ts', + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000018', + 'tool-read', + fileBody, + '2026-06-27T08:01:01.000Z', + { filePath: 'src/secret.ts', content: fileBody }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + const serialized = formatTaskReportAsJson(report) + + expect(serialized).not.toContain(fileBody) + expect(report.toolUses).toEqual([ + expect.objectContaining({ + id: 'tool-read', + name: 'Read', + files: ['src/secret.ts'], + }), + ]) + expect(report.toolUses[0]).not.toHaveProperty('resultSummary') + }, + ) + }) + + test('does not collect linked references from tool result content', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000022', + 'Summarize the session.', + '2026-06-27T08:00:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000023', + 'tool-read', + 'File body mentions https://github.com/Gitlawb/openclaude/issues/999.', + '2026-06-27T08:01:01.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + }) + + expect(report.linkedReferences).toEqual([]) + }, + ) + }) + + test('redacts credential-shaped strings and truncates large outputs deterministically', async () => { + const secret = 'sk-ant-secret-token' + const longOutput = `${'x'.repeat(200)} ${secret}` + + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000011', + `Please use token ghp_1234567890abcdef to test redaction.`, + '2026-06-27T08:00:00.000Z', + ), + assistantToolMessage( + '00000000-0000-4000-8000-000000000012', + { + id: 'tool-secret', + name: 'Bash', + input: { + command: `curl -H "Authorization: Bearer ${secret}" https://example.test`, + }, + }, + '2026-06-27T08:01:00.000Z', + ), + toolResultMessage( + '00000000-0000-4000-8000-000000000013', + 'tool-secret', + longOutput, + '2026-06-27T08:01:01.000Z', + { stdout: longOutput, stderr: '', interrupted: false }, + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => gitMetadata({ dirty: false, changedFiles: [] }), + maxPreviewChars: 64, + }) + const serialized = formatTaskReportAsJson(report) + + expect(report.redaction).toEqual({ + mode: 'best_effort', + maxPreviewChars: 64, + }) + expect(serialized).toBe(formatTaskReportAsJson(report)) + expect(serialized.endsWith('\n')).toBe(false) + expect(serialized).not.toContain(secret) + expect(serialized).not.toContain('ghp_1234567890abcdef') + expect(serialized).toContain('[redacted]') + expect(report.commands[0]?.stdout?.preview.length).toBeLessThanOrEqual( + 64, + ) + expect(report.commands[0]?.stdout?.truncated).toBe(true) + }, + ) + }) + + test('normalizes max preview chars in report metadata', async () => { + await withTempTranscript( + [ + userMessage( + '00000000-0000-4000-8000-000000000024', + 'abcdef', + '2026-06-27T08:00:00.000Z', + ), + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: false, + maxPreviewChars: 0, + }) + + expect(report.redaction.maxPreviewChars).toBe(1) + expect(report.session.initialRequest).toBe('a') + }, + ) + }) + + test('omits dirty status when git status cannot be collected', async () => { + const repoDir = join(homedir(), 'openclaude-task-report-git-repo') + const calls: string[] = [] + + const metadata = await collectTaskReportGitMetadata( + repoDir, + async (gitCwd, args) => { + expect(gitCwd).toBe(repoDir) + const command = args.join(' ') + calls.push(command) + + switch (command) { + case '--no-optional-locks rev-parse --is-inside-work-tree': + return { stdout: 'true', stderr: '', code: 0 } + case '--no-optional-locks branch --show-current': + return { stdout: 'feat/report', stderr: '', code: 0 } + case '--no-optional-locks rev-parse --short=12 HEAD': + return { stdout: '13cf30afa469', stderr: '', code: 0 } + case '--no-optional-locks status --porcelain=v1': + return { stdout: '', stderr: 'status failed', code: 1 } + default: + return { + stdout: '', + stderr: `unexpected command: ${command}`, + code: 2, + } + } + } + ) + + expect(calls).toEqual( + expect.arrayContaining([ + '--no-optional-locks rev-parse --is-inside-work-tree', + '--no-optional-locks branch --show-current', + '--no-optional-locks rev-parse --short=12 HEAD', + '--no-optional-locks status --porcelain=v1', + ]), + ) + expect(metadata).toEqual({ + status: 'available', + cwd: join('~', 'openclaude-task-report-git-repo'), + branch: 'feat/report', + head: '13cf30afa469', + changedFiles: [], + error: 'status failed', + }) + expect(metadata).not.toHaveProperty('dirty') + }) + + test('degrades gracefully for malformed and old transcripts', async () => { + await withTempTranscript( + [ + '{not valid json', + { + type: 'summary', + leafUuid: '00000000-0000-4000-8000-000000000014', + summary: 'old transcript metadata', + }, + ], + async transcriptPath => { + const report = await buildTaskReport({ + transcriptPath, + git: async () => ({ + status: 'unavailable', + cwd, + changedFiles: [], + error: 'not a git repository', + }), + }) + + expect(report.session.id).toBe(sessionId) + expect(report.toolUses).toEqual([]) + expect(report.commands).toEqual([]) + expect(report.warnings).toContain( + 'Skipped 1 malformed transcript line.', + ) + expect(report.warnings).toContain( + 'No validation commands were observed in this transcript.', + ) + }, + ) + }) +}) diff --git a/src/utils/taskReport.ts b/src/utils/taskReport.ts new file mode 100644 index 000000000..97d7da472 --- /dev/null +++ b/src/utils/taskReport.ts @@ -0,0 +1,1215 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { + basename, + dirname, + extname, + isAbsolute, + relative, + resolve, + win32, +} from 'node:path' + +import { execa } from 'execa' + +import { + STATUS_TAG, + TASK_NOTIFICATION_TAG, + TOOL_USE_ID_TAG, +} from '../constants/xml.js' +import { + redactDiagnosticObject, + redactHomePath, + redactLikelySecrets, +} from './diagnostics/redaction.js' +import { stableStringifyJson } from './stableStringify.js' + +export const TASK_REPORT_SCHEMA_VERSION = 1 +export const DEFAULT_TASK_REPORT_PREVIEW_CHARS = 1_000 + +type JsonRecord = Record + +export type TaskReportStatus = 'success' | 'error' | 'cancelled' | 'unknown' + +export type TaskReportSource = { + transcriptPath: string + malformedLineCount: number +} + +export type TaskReportSession = { + id: string | null + name?: string + tag?: string + cwd?: string + startedAt?: string + endedAt?: string + initialRequest?: string + models: string[] +} + +export type TaskReportBranch = { + transcriptBranch?: string + worktree?: { + name?: string + path?: string + branch?: string + originalBranch?: string + originalHead?: string + originalCwd?: string + } + pullRequest?: { + number: number + url?: string + repository?: string + } +} + +export type TaskReportGitMetadata = { + status: 'available' | 'unavailable' + cwd: string + branch?: string + head?: string + dirty?: boolean + changedFiles: string[] + error?: string +} + +type TaskReportGitCommandResult = { + stdout: string + stderr: string + code: number + error?: string +} + +export type TaskReportGitRunner = ( + cwd: string, + args: string[], +) => Promise + +export type TaskReportChangedFile = { + path: string + sources: Array<'tool' | 'git'> +} + +export type TaskReportPreview = { + preview: string + truncated: boolean + chars: number +} + +export type TaskReportToolUse = { + id: string + name: string + timestamp?: string + status: TaskReportStatus + inputSummary?: string + resultSummary?: TaskReportPreview + files: string[] +} + +export type TaskReportCommand = { + toolUseId: string + timestamp?: string + command: string + description?: string + status: TaskReportStatus + exitCode?: number + stdout?: TaskReportPreview + stderr?: TaskReportPreview +} + +export type TaskReportValidation = TaskReportCommand + +export type TaskReportError = { + source: 'tool' | 'transcript' + message: string + timestamp?: string + toolUseId?: string + toolName?: string +} + +export type TaskReportReference = { + kind: 'issue' | 'pull_request' | 'unknown' + number: number + url?: string + repository?: string +} + +export type TaskReport = { + schemaVersion: typeof TASK_REPORT_SCHEMA_VERSION + source: TaskReportSource + session: TaskReportSession + branch: TaskReportBranch + git?: TaskReportGitMetadata + changedFiles: TaskReportChangedFile[] + toolUses: TaskReportToolUse[] + commands: TaskReportCommand[] + validations: TaskReportValidation[] + errors: TaskReportError[] + warnings: string[] + linkedReferences: TaskReportReference[] + redaction: { + mode: 'best_effort' + maxPreviewChars: number + } +} + +export type BuildTaskReportOptions = { + transcriptPath: string + cwd?: string + git?: false | ((cwd: string) => Promise) + maxPreviewChars?: number +} + +export type TaskReportArgs = { + format: 'json' + transcriptPath?: string | null + sessionId?: string | null + outFile?: string | null + cwd?: string +} + +type ParsedTranscript = { + entries: JsonRecord[] + malformedLineCount: number +} + +type ObservedToolUse = { + id: string + name: string + timestamp?: string + input: unknown +} + +type ObservedToolResult = { + toolUseId: string + timestamp?: string + content: unknown + toolUseResult: unknown + isError: boolean +} + +const MUTATING_FILE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']) +const FILE_CONTENT_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit']) +const SHELL_COMMAND_TOOLS = new Set(['Bash', 'PowerShell']) + +const VALIDATION_COMMAND_PATTERNS = [ + /\b(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:build:verified|doctor:runtime(?::json)?|hardening:(?:check|strict)|integrations:check|security:pr-scan|verify:privacy|web:(?:build|typecheck)|test(?::[A-Za-z0-9_-]+)?|typecheck(?::[A-Za-z0-9_-]+)?|build|check|lint|smoke)(?=$|[\s;&|)"'])/, + /\bbun\s+test\b/, + /\bgit\s+diff\s+--check\b/, + /\bpython\s+-m\s+pytest\b/, + /\bpytest\b/, + /\btsc\b/, +] + +export async function buildTaskReport( + options: BuildTaskReportOptions, +): Promise { + const maxPreviewChars = normalizeMaxPreviewChars(options.maxPreviewChars) + const transcriptPath = resolve(options.transcriptPath) + const { entries, malformedLineCount } = + await readTranscriptEntries(transcriptPath) + + const sessionId = + findSessionId(entries) ?? basenameWithoutExtension(transcriptPath) + const metadata = collectSessionMetadata(entries, maxPreviewChars) + const toolResults = collectToolResults(entries) + const taskNotificationStatuses = collectTaskNotificationStatuses(entries) + const observedToolUses = collectToolUses(entries) + const changedFileSources = new Map>() + const errors: TaskReportError[] = [] + const commands: TaskReportCommand[] = [] + const validations: TaskReportValidation[] = [] + const toolUses: TaskReportToolUse[] = [] + const referenceTextParts: string[] = [] + const cwdForGit = metadata.cwd ?? options.cwd ?? process.cwd() + + if (metadata.initialRequest) { + referenceTextParts.push(metadata.initialRequest) + } + + for (const observed of observedToolUses) { + const result = toolResults.get(observed.id) + const observedStatus = getObservedStatus(result) + const status = isShellCommandTool(observed.name) + ? reconcileShellStatus( + observedStatus, + taskNotificationStatuses.get(observed.id), + ) + : observedStatus + const files = extractToolFiles(observed.name, observed.input, result) + const changedFiles = extractChangedFiles(observed.name, observed.input, result) + for (const file of changedFiles) { + addChangedFileSource(changedFileSources, file, 'tool', cwdForGit) + } + + const resultSummary = shouldIncludeToolResultSummary(observed.name) + ? previewUnknown(result?.content ?? result?.toolUseResult, maxPreviewChars) + : undefined + const toolUse: TaskReportToolUse = { + id: observed.id, + name: observed.name, + status, + files: sortUnique(files.map(path => redact(path))), + } + if (observed.timestamp) toolUse.timestamp = observed.timestamp + const summary = summarizeToolInput( + observed.name, + observed.input, + maxPreviewChars, + ) + if (summary) toolUse.inputSummary = summary + if (resultSummary) toolUse.resultSummary = resultSummary + toolUses.push(toolUse) + + if (isShellCommandTool(observed.name)) { + const rawCommand = extractShellCommand(observed.input) + const command = buildCommandReport( + observed, + result, + status, + maxPreviewChars, + ) + if (command) { + commands.push(command) + if (rawCommand) referenceTextParts.push(redact(rawCommand)) + if (rawCommand && isValidationCommand(rawCommand)) { + validations.push(command) + } + } + } + + if (result?.isError === true) { + const message = previewUnknown( + result.content ?? result.toolUseResult, + maxPreviewChars, + ) + errors.push({ + source: 'tool', + toolUseId: observed.id, + toolName: observed.name, + message: message?.preview ?? 'Tool result was marked as an error.', + ...(result.timestamp ? { timestamp: result.timestamp } : {}), + }) + } + } + + const rawGit = + options.git === false + ? undefined + : await (options.git ?? collectTaskReportGitMetadata)(cwdForGit) + const git = rawGit ? normalizeGitMetadata(rawGit) : undefined + if (git) { + for (const file of git.changedFiles) { + addChangedFileSource(changedFileSources, file, 'git', cwdForGit) + } + } + + const branch = collectBranchMetadata(entries) + if (branch.pullRequest?.url) { + referenceTextParts.push(branch.pullRequest.url) + } + for (const text of metadata.referenceTextParts) { + referenceTextParts.push(text) + } + + const warnings: string[] = [] + if (malformedLineCount > 0) { + warnings.push( + `Skipped ${malformedLineCount} malformed transcript line${ + malformedLineCount === 1 ? '' : 's' + }.`, + ) + } + if (entries.length === 0) { + warnings.push('No transcript entries were available for this report.') + } + if (validations.length === 0) { + warnings.push('No validation commands were observed in this transcript.') + } + + return { + schemaVersion: TASK_REPORT_SCHEMA_VERSION, + source: { + transcriptPath: redact(transcriptPath), + malformedLineCount, + }, + session: { + id: sessionId || null, + ...(metadata.name ? { name: metadata.name } : {}), + ...(metadata.tag ? { tag: metadata.tag } : {}), + ...(metadata.cwd ? { cwd: redact(metadata.cwd) } : {}), + ...(metadata.startedAt ? { startedAt: metadata.startedAt } : {}), + ...(metadata.endedAt ? { endedAt: metadata.endedAt } : {}), + ...(metadata.initialRequest + ? { initialRequest: metadata.initialRequest } + : {}), + models: sortUnique(metadata.models), + }, + branch, + ...(git ? { git } : {}), + changedFiles: formatChangedFiles(changedFileSources), + toolUses, + commands, + validations, + errors, + warnings, + linkedReferences: collectLinkedReferences(referenceTextParts, branch), + redaction: { + mode: 'best_effort', + maxPreviewChars, + }, + } +} + +export async function collectTaskReportGitMetadata( + cwd: string, + gitRunner: TaskReportGitRunner = runGit, +): Promise { + const base = ['--no-optional-locks'] + const inside = await gitRunner(cwd, [ + ...base, + 'rev-parse', + '--is-inside-work-tree', + ]) + if (inside.code !== 0 || inside.stdout.trim() !== 'true') { + return { + status: 'unavailable', + cwd: redact(cwd), + changedFiles: [], + error: redact( + inside.error ?? inside.stderr.trim() ?? 'not a git repository', + ), + } + } + + const [branch, head, status] = await Promise.all([ + gitRunner(cwd, [...base, 'branch', '--show-current']), + gitRunner(cwd, [...base, 'rev-parse', '--short=12', 'HEAD']), + gitRunner(cwd, [...base, 'status', '--porcelain=v1']), + ]) + const gitStatusAvailable = status.code === 0 + const changedFiles = gitStatusAvailable + ? parseGitStatusChangedFiles(status.stdout) + : [] + + return { + status: 'available', + cwd: redact(cwd), + ...(branch.code === 0 && branch.stdout.trim() + ? { branch: redact(branch.stdout.trim()) } + : {}), + ...(head.code === 0 && head.stdout.trim() + ? { head: redact(head.stdout.trim()) } + : {}), + ...(gitStatusAvailable ? { dirty: changedFiles.length > 0 } : {}), + changedFiles: sortUnique(changedFiles.map(path => redact(path))), + ...(status.code !== 0 + ? { error: redact(status.error ?? status.stderr.trim()) } + : {}), + } +} + +export function formatTaskReportAsJson(report: TaskReport): string { + return stableStringifyJson(report, 2) +} + +export async function writeTaskReport( + outFile: string, + content: string, +): Promise { + const outputPath = resolve(process.cwd(), outFile) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, content, 'utf8') + return outputPath +} + +async function readTranscriptEntries( + transcriptPath: string, +): Promise { + const raw = await readFile(transcriptPath, 'utf8') + if (transcriptPath.endsWith('.json')) { + try { + const parsed = JSON.parse(raw) as unknown + return { + entries: coerceJsonTranscriptEntries(parsed), + malformedLineCount: 0, + } + } catch { + return { entries: [], malformedLineCount: 1 } + } + } + + const entries: JsonRecord[] = [] + let malformedLineCount = 0 + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const parsed = JSON.parse(trimmed) as unknown + if (isRecord(parsed)) { + entries.push(parsed) + } + } catch { + malformedLineCount++ + } + } + return { entries, malformedLineCount } +} + +function coerceJsonTranscriptEntries(value: unknown): JsonRecord[] { + if (Array.isArray(value)) { + return value.filter(isRecord) + } + if (!isRecord(value)) { + return [] + } + const messages = value.messages + if (Array.isArray(messages)) { + return messages.filter(isRecord) + } + return [value] +} + +function collectSessionMetadata( + entries: JsonRecord[], + maxPreviewChars: number, +): { + name?: string + tag?: string + cwd?: string + startedAt?: string + endedAt?: string + initialRequest?: string + models: string[] + referenceTextParts: string[] +} { + const timestamps = entries + .map(entry => stringValue(entry.timestamp)) + .filter((value): value is string => Boolean(value)) + const models: string[] = [] + const referenceTextParts: string[] = [] + let name: string | undefined + let tag: string | undefined + let cwd: string | undefined + let initialRequest: string | undefined + + for (const entry of entries) { + if (entry.type === 'custom-title') { + const title = stringValue(entry.customTitle) + if (title) name = truncateText(redact(title), maxPreviewChars).preview + } else if (entry.type === 'tag') { + const observedTag = stringValue(entry.tag) + if (observedTag) tag = truncateText(redact(observedTag), maxPreviewChars).preview + } + + const observedCwd = stringValue(entry.cwd) + if (!cwd && observedCwd) cwd = observedCwd + + const message = recordValue(entry.message) + const model = stringValue(message?.model) + if (model) models.push(redact(model)) + + const isToolResult = isToolResultEntry(entry) + const text = isToolResult ? undefined : extractMessageText(message) + if (text) referenceTextParts.push(text) + if (!initialRequest && entry.type === 'user' && !isToolResult) { + const request = text?.trim() + if (request) { + initialRequest = truncateText(redact(request), maxPreviewChars).preview + } + } + } + + return { + ...(name ? { name } : {}), + ...(tag ? { tag } : {}), + ...(cwd ? { cwd } : {}), + ...(timestamps[0] ? { startedAt: timestamps[0] } : {}), + ...(timestamps.at(-1) ? { endedAt: timestamps.at(-1) } : {}), + ...(initialRequest ? { initialRequest } : {}), + models, + referenceTextParts, + } +} + +function collectBranchMetadata(entries: JsonRecord[]): TaskReportBranch { + const branch: TaskReportBranch = {} + for (const entry of entries) { + const transcriptBranch = stringValue(entry.gitBranch) + if (transcriptBranch) { + branch.transcriptBranch = redact(transcriptBranch) + } + + if (entry.type === 'worktree-state') { + const worktree = recordValue(entry.worktreeSession) + if (worktree) { + branch.worktree = { + ...(stringValue(worktree.worktreeName) + ? { name: redact(stringValue(worktree.worktreeName) as string) } + : {}), + ...(stringValue(worktree.worktreePath) + ? { path: redact(stringValue(worktree.worktreePath) as string) } + : {}), + ...(stringValue(worktree.worktreeBranch) + ? { branch: redact(stringValue(worktree.worktreeBranch) as string) } + : {}), + ...(stringValue(worktree.originalBranch) + ? { + originalBranch: redact( + stringValue(worktree.originalBranch) as string, + ), + } + : {}), + ...(stringValue(worktree.originalHeadCommit) + ? { + originalHead: redact( + stringValue(worktree.originalHeadCommit) as string, + ), + } + : {}), + ...(stringValue(worktree.originalCwd) + ? { originalCwd: redact(stringValue(worktree.originalCwd) as string) } + : {}), + } + } + } + + if (entry.type === 'pr-link') { + const number = numberValue(entry.prNumber) + if (number !== undefined) { + branch.pullRequest = { + number, + ...(stringValue(entry.prUrl) ? { url: redact(stringValue(entry.prUrl) as string) } : {}), + ...(stringValue(entry.prRepository) + ? { repository: redact(stringValue(entry.prRepository) as string) } + : {}), + } + } + } + } + return branch +} + +function collectToolUses(entries: JsonRecord[]): ObservedToolUse[] { + const toolUses: ObservedToolUse[] = [] + for (const entry of entries) { + if (entry.type !== 'assistant') continue + const message = recordValue(entry.message) + const content = message?.content + if (!Array.isArray(content)) continue + for (const block of content) { + if (!isRecord(block) || block.type !== 'tool_use') continue + const id = stringValue(block.id) + const name = stringValue(block.name) + if (!id || !name) continue + toolUses.push({ + id, + name, + input: block.input, + ...(stringValue(entry.timestamp) + ? { timestamp: stringValue(entry.timestamp) as string } + : {}), + }) + } + } + return toolUses +} + +function collectToolResults(entries: JsonRecord[]): Map { + const results = new Map() + for (const entry of entries) { + if (entry.type !== 'user') continue + const message = recordValue(entry.message) + const content = message?.content + if (!Array.isArray(content)) continue + for (const block of content) { + if (!isRecord(block) || block.type !== 'tool_result') continue + const toolUseId = stringValue(block.tool_use_id) + if (!toolUseId) continue + results.set(toolUseId, { + toolUseId, + content: block.content, + toolUseResult: entry.toolUseResult, + isError: block.is_error === true, + ...(stringValue(entry.timestamp) + ? { timestamp: stringValue(entry.timestamp) as string } + : {}), + }) + } + } + return results +} + +function collectTaskNotificationStatuses( + entries: JsonRecord[], +): Map { + const statuses = new Map() + for (const entry of entries) { + if (isToolResultEntry(entry)) continue + + const text = extractRawMessageText(recordValue(entry.message)) + if (!text?.includes(`<${TASK_NOTIFICATION_TAG}`)) continue + + const toolUseId = extractXmlTag(text, TOOL_USE_ID_TAG) + if (!toolUseId) continue + + const status = taskNotificationStatusToReportStatus( + extractXmlTag(text, STATUS_TAG), + ) + if (status) { + statuses.set(toolUseId, status) + } + } + return statuses +} + +function taskNotificationStatusToReportStatus( + status: string | undefined, +): TaskReportStatus | undefined { + switch (status) { + case 'completed': + return 'success' + case 'failed': + return 'error' + case 'killed': + case 'stopped': + return 'cancelled' + default: + return undefined + } +} + +function reconcileShellStatus( + observedStatus: TaskReportStatus, + notificationStatus: TaskReportStatus | undefined, +): TaskReportStatus { + return observedStatus === 'unknown' + ? (notificationStatus ?? observedStatus) + : observedStatus +} + +function buildCommandReport( + observed: ObservedToolUse, + result: ObservedToolResult | undefined, + status: TaskReportStatus, + maxPreviewChars: number, +): TaskReportCommand | null { + const input = recordValue(observed.input) + const rawCommand = extractShellCommand(observed.input) + if (!rawCommand) return null + + const structuredResult = recordValue(result?.toolUseResult) + const stdout = stringValue(structuredResult?.stdout) + const stderr = stringValue(structuredResult?.stderr) + const exitCode = extractExitCode(result) + const command: TaskReportCommand = { + toolUseId: observed.id, + command: truncateText(redact(rawCommand), maxPreviewChars).preview, + status, + } + const description = stringValue(input?.description) + if (description) { + command.description = truncateText(redact(description), maxPreviewChars).preview + } + if (observed.timestamp) { + command.timestamp = observed.timestamp + } + if (exitCode !== undefined) { + command.exitCode = exitCode + } + if (stdout !== undefined) { + command.stdout = truncateText(redact(stdout), maxPreviewChars) + } + if (stderr !== undefined) { + command.stderr = truncateText(redact(stderr), maxPreviewChars) + } + return command +} + +function extractShellCommand(input: unknown): string | undefined { + const inputRecord = recordValue(input) + return stringValue(inputRecord?.command) +} + +function getObservedStatus( + result: ObservedToolResult | undefined, +): TaskReportStatus { + if (!result) return 'unknown' + const structuredResult = recordValue(result.toolUseResult) + if (structuredResult?.interrupted === true) return 'cancelled' + if (stringValue(structuredResult?.backgroundTaskId)) return 'unknown' + const exitCode = extractExitCode(result) + if (exitCode !== undefined && exitCode !== 0) return 'error' + if (result.isError) return 'error' + return 'success' +} + +function shouldIncludeToolResultSummary(toolName: string): boolean { + return !FILE_CONTENT_TOOLS.has(toolName) +} + +function extractToolFiles( + toolName: string, + input: unknown, + result: ObservedToolResult | undefined, +): string[] { + const files = new Set() + const inputRecord = recordValue(input) + for (const file of extractFilePathsFromRecord(inputRecord)) { + files.add(file) + } + if (toolName === 'Bash') { + const simulatedSedEdit = recordValue(inputRecord?._simulatedSedEdit) + const sedFile = stringValue(simulatedSedEdit?.filePath) + if (sedFile) files.add(sedFile) + } + const resultRecord = recordValue(result?.toolUseResult) + for (const file of extractFilePathsFromRecord(resultRecord)) { + files.add(file) + } + return [...files] +} + +function extractChangedFiles( + toolName: string, + input: unknown, + result: ObservedToolResult | undefined, +): string[] { + if (!MUTATING_FILE_TOOLS.has(toolName) && toolName !== 'Bash') { + return [] + } + if (toolName === 'Bash') { + const inputRecord = recordValue(input) + const simulatedSedEdit = recordValue(inputRecord?._simulatedSedEdit) + const sedFile = stringValue(simulatedSedEdit?.filePath) + return sedFile ? [sedFile] : [] + } + return extractToolFiles(toolName, input, result) +} + +function extractFilePathsFromRecord(record: JsonRecord | null | undefined): string[] { + if (!record) return [] + const candidates = [ + stringValue(record.file_path), + stringValue(record.filePath), + stringValue(record.notebook_path), + stringValue(record.path), + ].filter((value): value is string => Boolean(value)) + const gitDiff = recordValue(record.gitDiff) + const diffFile = stringValue(gitDiff?.filename) + if (diffFile) candidates.push(diffFile) + return candidates +} + +function summarizeToolInput( + toolName: string, + input: unknown, + maxPreviewChars: number, +): string | undefined { + const record = recordValue(input) + if (!record) return undefined + if (isShellCommandTool(toolName)) { + const command = stringValue(record.command) + return command ? truncateText(redact(command), maxPreviewChars).preview : undefined + } + const filePath = + stringValue(record.file_path) ?? + stringValue(record.filePath) ?? + stringValue(record.notebook_path) + if (filePath) { + return truncateText(redact(`${toolName} ${filePath}`), maxPreviewChars).preview + } + const keys = Object.keys(record).sort() + if (keys.length === 0) return undefined + return truncateText(redact(`${toolName} input keys: ${keys.join(', ')}`), maxPreviewChars).preview +} + +function extractExitCode(result: ObservedToolResult | undefined): number | undefined { + if (!result) return undefined + const structuredResult = recordValue(result.toolUseResult) + const structuredExitCode = numberValue(structuredResult?.exitCode) + if (structuredExitCode !== undefined) { + return structuredExitCode + } + + const parts = [ + unknownToString(result.content), + unknownToString(result.toolUseResult), + ].filter((value): value is string => Boolean(value)) + for (const part of parts) { + const match = /\bexit code[:\s]+(\d+)\b/i.exec(part) + if (match?.[1]) { + return Number(match[1]) + } + } + return undefined +} + +function previewUnknown( + value: unknown, + maxPreviewChars: number, +): TaskReportPreview | undefined { + const text = unknownToString(value) + if (!text) return undefined + return truncateText(redact(text), maxPreviewChars) +} + +function unknownToString(value: unknown): string | undefined { + if (typeof value === 'string') return value + if (value === null || value === undefined) return undefined + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (Array.isArray(value)) { + return value + .map(item => unknownToString(item)) + .filter((item): item is string => Boolean(item)) + .join('\n') + } + if (isRecord(value)) { + const content = value.content + if (typeof content === 'string') return content + try { + return stableStringifyJson(redactObject(value)) + } catch { + return undefined + } + } + return undefined +} + +function extractRawMessageText( + message: JsonRecord | null | undefined, +): string | undefined { + if (!message) return undefined + const content = message.content + if (typeof content === 'string') return content + if (!Array.isArray(content)) return undefined + const parts = content + .map(block => { + if (typeof block === 'string') return block + if (!isRecord(block)) return undefined + if (block.type === 'text' && typeof block.text === 'string') { + return block.text + } + return undefined + }) + .filter((part): part is string => Boolean(part?.trim())) + return parts.length > 0 ? parts.join('\n') : undefined +} + +function extractMessageText(message: JsonRecord | null | undefined): string | undefined { + if (!message) return undefined + const content = message.content + if (typeof content === 'string') return redact(content) + if (!Array.isArray(content)) return undefined + const parts = content + .map(block => { + if (typeof block === 'string') return block + if (!isRecord(block)) return undefined + if (block.type === 'text' && typeof block.text === 'string') { + return block.text + } + if (block.type === 'tool_result') { + return unknownToString(block.content) + } + return undefined + }) + .filter((part): part is string => Boolean(part?.trim())) + return parts.length > 0 ? redact(parts.join('\n')) : undefined +} + +function extractXmlTag(text: string, tagName: string): string | undefined { + const openingTag = `<${tagName}>` + const closingTag = `` + const valueStart = text.indexOf(openingTag) + if (valueStart === -1) return undefined + + const contentStart = valueStart + openingTag.length + const valueEnd = text.indexOf(closingTag, contentStart) + if (valueEnd === -1) return undefined + + const value = text.slice(contentStart, valueEnd).trim() + return value || undefined +} + +function collectLinkedReferences( + textParts: string[], + branch: TaskReportBranch, +): TaskReportReference[] { + const references = new Map() + + if (branch.pullRequest) { + const pr = branch.pullRequest + const key = `pull_request:${pr.repository ?? ''}:${pr.number}:${pr.url ?? ''}` + references.set(key, { + kind: 'pull_request', + number: pr.number, + ...(pr.url ? { url: pr.url } : {}), + ...(pr.repository ? { repository: pr.repository } : {}), + }) + } + + for (const text of textParts) { + for (const reference of extractGithubUrlReferences(text)) { + const key = `${reference.kind}:${reference.repository ?? ''}:${ + reference.number + }:${reference.url ?? ''}` + references.set(key, reference) + } + for (const reference of extractShorthandReferences(text)) { + const key = `${reference.kind}:${reference.number}` + if (!references.has(key)) { + references.set(key, reference) + } + } + } + + return [...references.values()].sort((a, b) => { + const kindCompare = a.kind.localeCompare(b.kind) + if (kindCompare !== 0) return kindCompare + const repoCompare = (a.repository ?? '').localeCompare(b.repository ?? '') + if (repoCompare !== 0) return repoCompare + return a.number - b.number + }) +} + +function extractGithubUrlReferences(text: string): TaskReportReference[] { + const references: TaskReportReference[] = [] + const urlPattern = + /https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/(pull|issues)\/(\d+)/g + for (const match of text.matchAll(urlPattern)) { + const repository = match[1] + const kind = match[2] === 'pull' ? 'pull_request' : 'issue' + const number = Number(match[3]) + const url = match[0] + if (repository && Number.isSafeInteger(number)) { + references.push({ + kind, + number, + repository, + url, + }) + } + } + return references +} + +function extractShorthandReferences(text: string): TaskReportReference[] { + const references: TaskReportReference[] = [] + for (const match of text.matchAll(/(?>, +): TaskReportChangedFile[] { + return [...changedFileSources.entries()] + .map(([path, sources]) => ({ + path, + sources: [...sources].sort() as Array<'tool' | 'git'>, + })) + .sort((a, b) => a.path.localeCompare(b.path)) +} + +function addChangedFileSource( + changedFileSources: Map>, + path: string, + source: 'tool' | 'git', + cwd: string, +) { + const redactedPath = redact(normalizeChangedFilePath(path, cwd)) + const sources = changedFileSources.get(redactedPath) ?? new Set<'tool' | 'git'>() + sources.add(source) + changedFileSources.set(redactedPath, sources) +} + +function normalizeChangedFilePath(path: string, cwd: string): string { + const value = path.trim() + const posixRelative = relativeWithinCwd(value, cwd, isAbsolute, relative) + if (posixRelative) return posixRelative.replaceAll('\\', '/') + + const windowsRelative = relativeWithinCwd( + value, + cwd, + win32.isAbsolute, + win32.relative, + ) + if (windowsRelative) return windowsRelative.replaceAll('\\', '/') + + return value +} + +function relativeWithinCwd( + path: string, + cwd: string, + isAbsolutePath: (value: string) => boolean, + relativePath: (from: string, to: string) => string, +): string | undefined { + if (!isAbsolutePath(path) || !isAbsolutePath(cwd)) return undefined + const relativePathValue = relativePath(cwd, path) + const isOutsideCwd = + relativePathValue === '..' || + relativePathValue.startsWith('../') || + relativePathValue.startsWith('..\\') + if ( + !relativePathValue || + isOutsideCwd || + isAbsolutePath(relativePathValue) + ) { + return undefined + } + return relativePathValue +} + +function isValidationCommand(command: string): boolean { + return VALIDATION_COMMAND_PATTERNS.some(pattern => pattern.test(command)) +} + +function isShellCommandTool(toolName: string): boolean { + return SHELL_COMMAND_TOOLS.has(toolName) +} + +function parseGitStatusChangedFiles(stdout: string): string[] { + const changedFiles: string[] = [] + for (const rawLine of stdout.split(/\r?\n/)) { + if (!rawLine.trim()) continue + const pathPart = rawLine.length > 3 ? rawLine.slice(3).trim() : rawLine.trim() + const renamedPath = pathPart.includes(' -> ') + ? pathPart.slice(pathPart.lastIndexOf(' -> ') + ' -> '.length) + : pathPart + const unquoted = renamedPath.replace(/^"|"$/g, '') + if (unquoted) changedFiles.push(unquoted) + } + return changedFiles +} + +async function runGit( + cwd: string, + args: string[], +): Promise { + try { + const result = await execa('git', args, { + cwd, + reject: false, + timeout: 3_000, + maxBuffer: 1_000_000, + }) + return { + stdout: result.stdout, + stderr: result.stderr, + code: result.exitCode ?? 0, + } + } catch (error) { + const execaError = error as { + stdout?: unknown + stderr?: unknown + exitCode?: unknown + timedOut?: unknown + } + const message = error instanceof Error ? error.message : String(error) + return { + stdout: typeof execaError.stdout === 'string' ? execaError.stdout : '', + stderr: typeof execaError.stderr === 'string' ? execaError.stderr : '', + code: + typeof execaError.exitCode === 'number' + ? execaError.exitCode + : execaError.timedOut === true + ? 124 + : 1, + error: execaError.timedOut === true ? 'git command timed out' : message, + } + } +} + +function findSessionId(entries: JsonRecord[]): string | undefined { + for (const entry of entries) { + const sessionId = stringValue(entry.sessionId) + if (sessionId) return sessionId + } + return undefined +} + +function isToolResultEntry(entry: JsonRecord): boolean { + if (entry.sourceToolAssistantUUID) return true + const message = recordValue(entry.message) + const content = message?.content + return ( + Array.isArray(content) && + content.some(block => isRecord(block) && block.type === 'tool_result') + ) +} + +function basenameWithoutExtension(path: string): string { + const extension = extname(path) + return extension ? basename(path, extension) : basename(path) +} + +function truncateText(value: string, maxChars: number): TaskReportPreview { + const safeMax = Math.max(1, maxChars) + if (value.length <= safeMax) { + return { + preview: value, + truncated: false, + chars: value.length, + } + } + return { + preview: value.slice(0, safeMax), + truncated: true, + chars: value.length, + } +} + +function redact(value: string): string { + return redactLikelySecrets(redactHomePath(value)) +} + +function redactObject(value: unknown): unknown { + return redactDiagnosticObject(value) +} + +function normalizeGitMetadata( + metadata: TaskReportGitMetadata, +): TaskReportGitMetadata { + return { + status: metadata.status, + cwd: redact(metadata.cwd), + ...(metadata.branch ? { branch: redact(metadata.branch) } : {}), + ...(metadata.head ? { head: redact(metadata.head) } : {}), + ...(metadata.dirty !== undefined ? { dirty: metadata.dirty } : {}), + changedFiles: sortUnique(metadata.changedFiles.map(path => redact(path))), + ...(metadata.error ? { error: redact(metadata.error) } : {}), + } +} + +function sortUnique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b)) +} + +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function recordValue(value: unknown): JsonRecord | null { + return isRecord(value) ? value : null +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) + ? value + : undefined +} + +function normalizeMaxPreviewChars(value: number | undefined): number { + const candidate = value ?? DEFAULT_TASK_REPORT_PREVIEW_CHARS + if (!Number.isFinite(candidate)) return DEFAULT_TASK_REPORT_PREVIEW_CHARS + return Math.max(1, Math.floor(candidate)) +}