feat(report): generate deterministic session task reports (#1802)

* feat(report): generate deterministic session task reports

* fix(report): address task report review findings

* fix(report): stabilize task report paths on Windows

* test(report): expect redacted git metadata cwd

* test(report): assert literal redacted git cwd

* fix(report): capture PowerShell and backgrounded validations

* fix(report): detect quoted validation commands

* fix(report): reconcile background validation notifications

* fix(report): keep foreground command statuses authoritative

* test(report): assert command status precedence
This commit is contained in:
Bogdan
2026-06-29 17:57:09 +08:00
committed by GitHub
parent 8023356841
commit a47493342f
5 changed files with 2733 additions and 0 deletions
+4
View File
@@ -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 <file>` for an explicit transcript, `--session <id>` 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
+109
View File
@@ -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<void> {
return new Promise((resolve, reject) => {
stream.write(`${message}\n`, error => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
export async function taskReportHandler(
options: TaskReportArgs,
): Promise<void> {
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<void> {
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<string> {
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 <file> or --session <id>.',
)
}
return latest.path
}
+47
View File
@@ -4215,6 +4215,53 @@ async function run(): Promise<CommanderCommand> {
});
}
program
.command('report')
.description(
'Generate a deterministic JSON task report for an OpenClaude session',
)
.option('--json', 'Print JSON output')
.option('--transcript <file>', 'Path to a session JSONL transcript')
.option(
'--session <id>',
'Session ID to report (defaults to latest session in the current project)',
)
.option('--out <file>', '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 <file> or --session <id>, 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')
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff