From d63e8d4b4fe3fe83a68987ca872353cec7889548 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 25 May 2026 14:50:04 +0100 Subject: [PATCH] chore: add agent transcript skill --- .agents/skills/agent-transcript/SKILL.md | 83 +++ .../agent-transcript/scripts/agent-transcript | 636 ++++++++++++++++++ 2 files changed, 719 insertions(+) create mode 100644 .agents/skills/agent-transcript/SKILL.md create mode 100755 .agents/skills/agent-transcript/scripts/agent-transcript diff --git a/.agents/skills/agent-transcript/SKILL.md b/.agents/skills/agent-transcript/SKILL.md new file mode 100644 index 000000000000..f7429a2d0d85 --- /dev/null +++ b/.agents/skills/agent-transcript/SKILL.md @@ -0,0 +1,83 @@ +--- +name: agent-transcript +description: "Add a redacted agent transcript section to GitHub PR or issue bodies during OpenClaw agent-created PR/issue workflows." +--- + +# Agent Transcript + +Best-effort local-only provenance for OpenClaw PR/issue bodies. Use during agent-created GitHub PR or issue workflows before creating/updating the body. + +## Contract + +- Never use network. Session discovery reads local agent logs only. +- Never upload raw logs. Render sanitized Markdown first. +- Always ask the user before adding transcript logs to a GitHub PR/issue body. +- Tell the user sanitized session logs help reviewers and can make PRs easier to prioritize. +- Offer a local HTML preview before insertion. If the user wants preview, open it and wait for confirmation before adding the section. +- Fail closed on unresolved secrets, private keys, browser/session/cookie details, or auth URLs. +- Drop system/developer prompts, raw tool outputs, reasoning, env, cookies, tokens, and broad local paths. +- Keep user prompts, assistant visible decisions, terse tool summaries, and test/proof outcomes. +- Best effort only: PR/issue creation must continue if no safe transcript is found. +- Use a collapsed `
` section and update existing markers instead of duplicating sections. + +## Helper + +```bash +.agents/skills/agent-transcript/scripts/agent-transcript --help +``` + +Find a likely local session: + +```bash +.agents/skills/agent-transcript/scripts/agent-transcript find \ + --query "$PR_TITLE $BRANCH_OR_PR_URL" \ + --cwd "$PWD" \ + --since-days 14 +``` + +Render a PR/issue body section: + +```bash +.agents/skills/agent-transcript/scripts/agent-transcript render \ + --session "$SESSION_JSONL" \ + --out /tmp/agent-transcript.md +``` + +Preview one candidate session locally: + +```bash +.agents/skills/agent-transcript/scripts/agent-transcript preview \ + --session "$SESSION_JSONL" \ + --out /tmp/agent-transcript-preview.html +open /tmp/agent-transcript-preview.html +``` + +Append/update a body file before `gh pr create --body-file` or connector PR creation: + +```bash +.agents/skills/agent-transcript/scripts/agent-transcript append-body \ + --body /tmp/pr-body.md \ + --session "$SESSION_JSONL" \ + --out /tmp/pr-body.with-transcript.md +``` + +## PR/Issue Workflow + +1. Draft the normal PR/issue body first. +2. Run `find` with title, branch, PR URL/number if known, and cwd. +3. If a high-confidence session is found, ask: + `Include a redacted agent transcript? It helps reviewers and can make the PR easier to prioritize. I can open a local preview first.` +4. If the user wants preview, run `preview`, open the HTML with `open`, and wait for confirmation. +5. If the user approves, run `append-body`. +6. Use the enriched body file for creation/update. +7. If no safe session is found, say nothing and continue without transcript. If the user declines, continue without transcript. + +## Review Artifacts + +For manual audits across many PR/session candidates, create a local HTML preview from a local JSON file. This is for maintainers only and is not part of the PR/issue workflow: + +```bash +.agents/skills/agent-transcript/scripts/agent-transcript html \ + --prs /tmp/recent-prs.json \ + --out /tmp/agent-transcript-preview.html +``` diff --git a/.agents/skills/agent-transcript/scripts/agent-transcript b/.agents/skills/agent-transcript/scripts/agent-transcript new file mode 100755 index 000000000000..1099ecea39bb --- /dev/null +++ b/.agents/skills/agent-transcript/scripts/agent-transcript @@ -0,0 +1,636 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const MARKER_START = ""; +const MARKER_END = ""; +const DEFAULT_MAX_CHARS = 50000; +const DEFAULT_ENTRY_MAX_CHARS = 6000; + +function usage() { + console.log(`Usage: + agent-transcript find --query TEXT [--cwd PATH] [--since-days N] [--root PATH...] + agent-transcript render --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N] [--title TEXT] [--url URL] + agent-transcript preview --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N] [--title TEXT] [--url URL] + agent-transcript append-body --body FILE --session FILE [--out FILE] [--max-chars N] [--entry-max-chars N] + agent-transcript html --prs FILE [--out FILE] [--since-days N] [--min-score N] [--root PATH...] [--exclude-session FILE...] + +Local-only. No network calls.`); +} + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + args._.push(arg); + continue; + } + const key = arg.slice(2); + const next = argv[i + 1]; + if (next == null || next.startsWith("--")) { + args[key] = true; + continue; + } + i++; + if (args[key] == null) args[key] = next; + else if (Array.isArray(args[key])) args[key].push(next); + else args[key] = [args[key], next]; + } + return args; +} + +function asArray(value) { + if (value == null) return []; + return Array.isArray(value) ? value : [value]; +} + +function homePath(...parts) { + return path.join(os.homedir(), ...parts); +} + +function defaultRoots() { + return [ + homePath(".codex", "sessions"), + homePath(".claude", "projects"), + homePath(".pi", "agent", "sessions"), + ]; +} + +function walkJsonl(root, sinceMs, out = []) { + if (!root || !fs.existsSync(root)) return out; + const stat = fs.statSync(root); + if (stat.isFile()) { + if (root.endsWith(".jsonl") && stat.mtimeMs >= sinceMs) out.push(root); + return out; + } + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const file = path.join(root, entry.name); + if (entry.isDirectory()) walkJsonl(file, sinceMs, out); + else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + const entryStat = fs.statSync(file); + if (entryStat.mtimeMs >= sinceMs) out.push(file); + } + } + return out; +} + +function readJsonl(file, maxLines = 12000) { + const text = fs.readFileSync(file, "utf8"); + const lines = text.split(/\n+/).filter(Boolean).slice(0, maxLines); + const rows = []; + for (const line of lines) { + try { + rows.push(JSON.parse(line)); + } catch { + rows.push({ type: "unparsed", text: line }); + } + } + return rows; +} + +function stringContent(value) { + if (value == null) return ""; + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(stringContent).filter(Boolean).join("\n"); + if (typeof value === "object") { + if (typeof value.text === "string") return value.text; + if (typeof value.content === "string") return value.content; + if (typeof value.message === "string") return value.message; + if (Array.isArray(value.content)) return stringContent(value.content); + if (value.type === "text" && value.text) return String(value.text); + } + return ""; +} + +function detectAgent(file, rows) { + if (file.includes(`${path.sep}.codex${path.sep}`)) return "codex"; + if (file.includes(`${path.sep}.claude${path.sep}`)) return "claude"; + if (file.includes(`${path.sep}.pi${path.sep}`)) return "pi"; + if (rows.some((row) => row?.type === "session_meta" || row?.type === "response_item")) return "codex"; + if (rows.some((row) => row?.sessionId && row?.userType)) return "claude"; + return "agent"; +} + +function eventText(row) { + if (row?.type === "event_msg") { + const payload = row.payload || {}; + return stringContent(payload.message || payload.text_elements || payload.content); + } + if (row?.type === "response_item") { + const payload = row.payload || {}; + return stringContent(payload.content || payload.summary || payload.arguments || payload.output); + } + if (row?.message) return stringContent(row.message); + if (row?.content) return stringContent(row.content); + if (row?.text) return stringContent(row.text); + return ""; +} + +function eventRole(row) { + if (row?.type === "event_msg") { + const type = row.payload?.type; + if (type === "user_message") return "user"; + if (type === "agent_message") return "assistant"; + if (type === "token_count" || type === "task_started" || type === "task_complete") return null; + if (type === "web_search_end") return "web"; + } + if (row?.type === "response_item") { + const payload = row.payload || {}; + if (payload.type === "function_call") return "tool"; + if (payload.type === "function_call_output") return "tool_output"; + if (payload.type === "reasoning") return null; + if (payload.type === "web_search_call") return "web"; + if (payload.role === "user") return "user"; + if (payload.role === "assistant") return "assistant"; + } + if (row?.type === "user") return "user"; + if (row?.type === "assistant") return "assistant"; + if (row?.message?.role === "user") return "user"; + if (row?.message?.role === "assistant") return "assistant"; + if (row?.type === "tool_result" || row?.type === "tool_use") return "tool"; + return null; +} + +function hasSetupBlob(text) { + return ( + text.includes("") || + text.includes("# AGENTS.MD") || + text.includes("Knowledge cutoff:") || + text.includes("You are Codex") || + /\byour instructions\b/i.test(text) || + /\binstructions absorbed\b/i.test(text) || + /\bAGENTS\.md\b/i.test(text) + ); +} + +function redact(input, stats) { + let s = String(input ?? ""); + const rules = [ + [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]"], + [/sk-[A-Za-z0-9_-]{20,}/g, "[REDACTED_OPENAI_KEY]"], + [/(gh[pousr]_[A-Za-z0-9_]{20,})/g, "[REDACTED_GITHUB_TOKEN]"], + [/(AKIA[0-9A-Z]{16})/g, "[REDACTED_AWS_KEY]"], + [/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g, "[REDACTED_JWT]"], + [/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi, "[REDACTED_AUTH_HEADER]"], + [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[REDACTED_EMAIL]"], + [/\b(?:\+?\d[\d .()-]{7,}\d)\b/g, "[REDACTED_PHONE]"], + [/\/Users\/[^\s`"'>)]+/g, "[LOCAL_PATH]"], + [/~\/[^\s`"'>)]+/g, "[HOME_PATH]"], + [/([?&](?:token|key|secret|signature|sig|access_token|auth)=)[^\s`"'>&]+/gi, "$1[REDACTED]"], + ]; + for (const [re, repl] of rules) { + const before = s; + s = s.replace(re, repl); + if (s !== before) stats.redactions++; + } + return s; +} + +function unsafe(text) { + const patterns = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/i, + /\b(?:user_session|_gh_sess|__Host-user_session_same_site|GH_SESSION_TOKEN)\b/i, + /\b(?:GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY)\b/, + /\/upload\/policies\/assets|uploadToken|authenticity_token/i, + ]; + return patterns.filter((pattern) => pattern.test(text)).map((pattern) => String(pattern)); +} + +function normalizeEntry(role, text, stats, options = {}) { + let t = redact(text, stats).replace(/\n{3,}/g, "\n\n").trim(); + if (!t) return null; + if (hasSetupBlob(t)) t = "[instructions recap omitted; policy/config text, not task dialogue]"; + if (unsafe(t).length) t = "[omitted: browser/session/auth internals; not useful for public PR transcript]"; + const entryMaxChars = Number(options.entryMaxChars || options["entry-max-chars"] || DEFAULT_ENTRY_MAX_CHARS); + if (t.length > entryMaxChars) { + t = `${t.slice(0, entryMaxChars).trimEnd()}\n...[truncated ${t.length - entryMaxChars} chars]`; + } + return `[${role}]\n${t}`; +} + +function entryRole(entry) { + const match = entry.match(/^\[([^\]]+)\]\n/); + return match ? match[1] : null; +} + +function entryBody(entry) { + return entry.replace(/^\[[^\]]+\]\n/, ""); +} + +function coalesceEntries(entries) { + const coalesced = []; + for (const entry of entries) { + const role = entryRole(entry); + const body = entryBody(entry); + const last = coalesced[coalesced.length - 1]; + if (!last || !role || entryRole(last) !== role || role === "tool summary") { + coalesced.push(entry); + continue; + } + const lastBody = entryBody(last); + if (lastBody === body || lastBody.includes(body)) continue; + if (body.includes(lastBody)) { + coalesced[coalesced.length - 1] = `[${role}]\n${body}`; + continue; + } + coalesced[coalesced.length - 1] = `[${role}]\n${lastBody}\n\n${body}`; + } + return coalesced; +} + +function toolFamily(name) { + const normalized = String(name).toLowerCase(); + if ( + /(read|fetch|open|list|find|search|grep|rg|sed|cat|head|tail|jq|wc|status|diff|show|view|snapshot|screenshot)/.test( + normalized, + ) + ) { + return "read"; + } + if (/(write|edit|patch|apply|create|update|append|save|comment|fill|click|type|navigate|upload)/.test(normalized)) { + return "write"; + } + if (/(exec|command|shell|run|test|build|lint|format|install|pnpm|npm|node|git|gh|ssh)/.test(normalized)) { + return "execute"; + } + if (/(web|http|fetch|browser|chrome|github|dropbox|notion|gmail|calendar)/.test(normalized)) { + return "network"; + } + return "other"; +} + +function shellFamily(command) { + const cmd = String(command || "").trim(); + if (!cmd) return "execute"; + if ( + /^(rg|grep|sed|cat|head|tail|jq|wc|ls|find|pwd|git (status|diff|show|log|blame)|gh (pr|issue|api|run|repo|auth) (view|list|status)|test |stat |ps |which |command -v )\b/.test( + cmd, + ) + ) { + return "read"; + } + if (/^(open |chmod |mkdir |touch |cp |mv |kill |git add|git commit|git push|gh pr create|gh issue create)\b/.test(cmd)) { + return "write"; + } + if (/^(node|npm|pnpm|bun|python|python3|ruby|tsx|tsgo|make|cargo|go test|swift|xcodebuild)\b/.test(cmd)) { + return "execute"; + } + if (/^(ssh|curl|wget|tailscale|nc )\b/.test(cmd)) return "network"; + return "execute"; +} + +function toolCallFamily(row) { + const name = row.payload?.name || row.name || row.message?.name || row.type || "tool"; + if (name === "exec_command") { + try { + const args = JSON.parse(row.payload?.arguments || "{}"); + return shellFamily(args.cmd); + } catch { + return "execute"; + } + } + if (name === "apply_patch") return "write"; + if (name === "write_stdin") return "execute"; + return toolFamily(name); +} + +function compactToolSummary(familyCounts, dropped) { + const families = new Map(); + for (const [family, count] of familyCounts.entries()) { + families.set(family, (families.get(family) || 0) + count); + } + const ordered = ["read", "write", "execute", "network", "other"] + .map((family) => [family, families.get(family) || 0]) + .filter(([, count]) => count > 0) + .map(([family, count]) => `${count} ${family}`); + const calls = ordered.length ? ordered.join(", ") : "0 tool"; + return `${calls}; raw tool outputs dropped: ${dropped}`; +} + +function recountEntries(stats, entries) { + stats.rawEntries = stats.entries; + stats.entries = entries.length; + stats.user = entries.filter((entry) => entry.startsWith("[user]\n")).length; + stats.assistant = entries.filter((entry) => entry.startsWith("[assistant]\n")).length; +} + +function renderSession(file, options = {}) { + const rows = readJsonl(file); + const agent = detectAgent(file, rows); + const stats = { + agent, + entries: 0, + user: 0, + assistant: 0, + toolCalls: 0, + toolOutputsDropped: 0, + web: 0, + redactions: 0, + omittedUnsafe: 0, + }; + const toolCounts = new Map(); + const items = []; + const seenEntries = new Set(); + const hasEventDialogue = rows.some((row) => { + const type = row?.type === "event_msg" ? row.payload?.type : null; + return type === "user_message" || type === "agent_message"; + }); + for (const row of rows) { + const role = eventRole(row); + if (!role) continue; + if (hasEventDialogue && row.type === "response_item" && (role === "user" || role === "assistant")) { + continue; + } + if (role === "tool_output") { + stats.toolOutputsDropped++; + continue; + } + if (role === "tool") { + const family = toolCallFamily(row); + toolCounts.set(family, (toolCounts.get(family) || 0) + 1); + stats.toolCalls++; + continue; + } + if (role === "web") { + stats.web++; + continue; + } + const before = eventText(row); + const entry = normalizeEntry(role, before, stats, options); + if (!entry) continue; + const dedupeKey = entry.replace(/\s+/g, " ").trim(); + if (seenEntries.has(dedupeKey)) continue; + seenEntries.add(dedupeKey); + if (entry.includes("[omitted: browser/session/auth internals")) stats.omittedUnsafe++; + items.push(entry); + stats.entries++; + if (role === "user") stats.user++; + if (role === "assistant") stats.assistant++; + } + if (toolCounts.size) { + items.push(`[tool summary]\n${compactToolSummary(toolCounts, stats.toolOutputsDropped)}`); + stats.entries++; + } + const renderedItems = coalesceEntries(items); + recountEntries(stats, renderedItems); + const maxChars = Number(options.maxChars || DEFAULT_MAX_CHARS); + let joined = renderedItems.join("\n\n"); + if (joined.length > maxChars) joined = `${joined.slice(0, maxChars).trimEnd()}\n\n...[transcript truncated to ${maxChars} chars]`; + const headerBits = [options.title, options.url].filter(Boolean).join(" | "); + const unsafeAfter = unsafe(joined); + const safe = unsafeAfter.length === 0; + const markdown = `${MARKER_START} +## Agent Transcript + +
+Redacted ${agent} session transcript${headerBits ? `: ${redact(headerBits, stats)}` : ""} + +\`\`\`\`text +source: [LOCAL_SESSION] +redaction: local paths, emails, phone-shaped strings, token-shaped strings, auth headers, auth query params +omitted: raw tool outputs, system/developer prompts, local paths, secrets, browser/session/auth details +stats: ${JSON.stringify(stats)} + +${joined} +\`\`\`\` + +
+${MARKER_END} +`; + return { file, agent, safe, unsafeAfter, stats, markdown }; +} + +function readBoundedText(file, maxBytes = 220000) { + const fd = fs.openSync(file, "r"); + try { + const stat = fs.fstatSync(fd); + if (stat.size <= maxBytes) { + const buffer = Buffer.alloc(stat.size); + fs.readSync(fd, buffer, 0, stat.size, 0); + return buffer.toString("utf8"); + } + const half = Math.floor(maxBytes / 2); + const head = Buffer.alloc(half); + const tail = Buffer.alloc(half); + fs.readSync(fd, head, 0, half, 0); + fs.readSync(fd, tail, 0, half, Math.max(0, stat.size - half)); + return `${head.toString("utf8")}\n[...middle omitted for scan...]\n${tail.toString("utf8")}`; + } finally { + fs.closeSync(fd); + } +} + +function sessionScanRecord(file) { + const stat = fs.statSync(file); + let agent = "agent"; + try { + agent = detectAgent(file, readJsonl(file, 50)); + } catch {} + return { + file, + agent, + mtime: new Date(stat.mtimeMs).toISOString(), + haystack: `${file}\n${readBoundedText(file)}`.toLowerCase(), + }; +} + +function scoreScanRecord(record, terms, cwd) { + const haystack = record.haystack; + let score = 0; + const reasons = []; + for (const term of terms) { + const normalized = term.toLowerCase().trim(); + if (normalized.length < 3) continue; + if (haystack.includes(normalized)) { + score += Math.min(20, Math.max(3, Math.floor(normalized.length / 3))); + reasons.push(normalized.slice(0, 80)); + } + } + if (cwd) { + const cwdLower = cwd.toLowerCase(); + if (haystack.includes(cwdLower) || record.file.toLowerCase().includes(cwdLower.replaceAll("/", "-"))) { + score += 8; + reasons.push("cwd"); + } + } + return { file: record.file, score, reasons, mtime: record.mtime, agent: record.agent }; +} + +function findSessions(options) { + const sinceDays = Number(options["since-days"] || 14); + const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000; + const roots = asArray(options.root).length ? asArray(options.root) : defaultRoots(); + const query = String(options.query || ""); + const terms = query + .split(/\s+/) + .concat(query.match(/https?:\/\/\S+/g) || []) + .filter(Boolean); + const files = roots.flatMap((root) => walkJsonl(root, sinceMs)); + const results = files + .map((file) => scoreScanRecord(sessionScanRecord(file), terms, options.cwd)) + .filter((result) => result.score > 0) + .sort((a, b) => b.score - a.score || b.mtime.localeCompare(a.mtime)) + .slice(0, Number(options.limit || 10)); + return results; +} + +function sessionScanRecords(options) { + const sinceDays = Number(options["since-days"] || 14); + const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000; + const roots = asArray(options.root).length ? asArray(options.root) : defaultRoots(); + const excluded = new Set(asArray(options["exclude-session"]).map((file) => path.resolve(file))); + return roots + .flatMap((root) => walkJsonl(root, sinceMs)) + .filter((file) => !excluded.has(path.resolve(file))) + .map(sessionScanRecord); +} + +function replaceSection(body, section) { + const start = body.indexOf(MARKER_START); + const end = body.indexOf(MARKER_END); + if (start !== -1 && end !== -1 && end > start) { + return `${body.slice(0, start).trimEnd()}\n\n${section.trim()}\n\n${body.slice(end + MARKER_END.length).trimStart()}`; + } + return `${body.trimEnd()}\n\n${section.trim()}\n`; +} + +function escapeHtml(text) { + return String(text) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function htmlDocument(records) { + const rows = records + .map((record) => `
+

${escapeHtml(record.title || record.url || "PR")}

+

${escapeHtml(record.session ? "[LOCAL_SESSION]" : "no session")} score: ${escapeHtml(record.score ?? "")} safe: ${escapeHtml(record.safe ?? "")}

+
${escapeHtml(record.markdown || record.error || "")}
+
`) + .join("\n"); + return ` + +Agent Transcript Preview + +

Agent Transcript Preview

+${rows} +`; +} + +function singlePreviewDocument(record) { + return htmlDocument([record]); +} + +function readPrs(file) { + const raw = fs.readFileSync(file, "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : parsed.items || parsed.prs || []; +} + +function main() { + const [command, ...rest] = process.argv.slice(2); + const args = parseArgs(rest); + if (!command || command === "--help" || command === "-h" || args.help) { + usage(); + return; + } + if (command === "find") { + console.log(JSON.stringify(findSessions(args), null, 2)); + return; + } + if (command === "render") { + if (!args.session) throw new Error("--session is required"); + const rendered = renderSession(args.session, args); + if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`); + if (args.out) fs.writeFileSync(args.out, rendered.markdown); + else process.stdout.write(rendered.markdown); + return; + } + if (command === "preview") { + if (!args.session) throw new Error("--session is required"); + const rendered = renderSession(args.session, args); + if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`); + const output = singlePreviewDocument({ + title: args.title || "Agent Transcript Preview", + url: args.url || "", + session: args.session, + safe: rendered.safe, + markdown: rendered.markdown, + }); + if (args.out) fs.writeFileSync(args.out, output); + else process.stdout.write(output); + return; + } + if (command === "append-body") { + if (!args.body || !args.session) throw new Error("--body and --session are required"); + const rendered = renderSession(args.session, args); + if (!rendered.safe) throw new Error(`unsafe transcript after redaction: ${rendered.unsafeAfter.join(", ")}`); + const body = fs.readFileSync(args.body, "utf8"); + const next = replaceSection(body, rendered.markdown); + if (args.out) fs.writeFileSync(args.out, next); + else process.stdout.write(next); + return; + } + if (command === "html") { + if (!args.prs) throw new Error("--prs is required"); + const records = []; + const scanRecords = sessionScanRecords(args); + const minScore = Number(args["min-score"] || 50); + for (const pr of readPrs(args.prs)) { + const query = [pr.url, pr.number ? `#${pr.number}` : "", pr.number, pr.title, pr.headRefName, pr.headRefName || pr.branch] + .filter(Boolean) + .join(" "); + const terms = query + .split(/\s+/) + .concat(query.match(/https?:\/\/\S+/g) || []) + .filter(Boolean); + const [candidate] = scanRecords + .map((record) => scoreScanRecord(record, terms, args.cwd)) + .filter((result) => result.score >= minScore) + .sort((a, b) => b.score - a.score || b.mtime.localeCompare(a.mtime)); + if (!candidate) { + records.push({ ...pr, error: "No local session match found." }); + continue; + } + try { + const rendered = renderSession(candidate.file, { ...args, title: pr.title, url: pr.url }); + records.push({ + ...pr, + session: candidate.file, + score: candidate.score, + safe: rendered.safe, + markdown: rendered.markdown, + }); + } catch (error) { + records.push({ ...pr, session: candidate.file, score: candidate.score, error: String(error) }); + } + } + const output = htmlDocument(records); + if (args.out) fs.writeFileSync(args.out, output); + else process.stdout.write(output); + return; + } + usage(); + process.exitCode = 2; +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}