|
| 1 | +/** |
| 2 | + * JevLoop · 把判定记录渲染成 `decisions/2026-09-29-001.md` |
| 3 | + * |
| 4 | + * ══════════════════════════════════════════════════════════════ |
| 5 | + * 一个决策一份文件 —— 而且**那份文件本身可以重算指纹** |
| 6 | + * ══════════════════════════════════════════════════════════════ |
| 7 | + * |
| 8 | + * ── 用法 ────────────────────────────────────────────────────── |
| 9 | + * |
| 10 | + * npm run trace -- <会话日志.jsonl> [--out <目录>] |
| 11 | + * 渲染。默认写到 `agent/decisions/` |
| 12 | + * |
| 13 | + * npm run trace -- --verify <目录> |
| 14 | + * 只看**那些 .md 文件本身**(不碰原始日志),逐份重算指纹 |
| 15 | + * |
| 16 | + * ── 为什么要有 `--verify` ─────────────────────────────────────── |
| 17 | + * |
| 18 | + * 指纹**印在文件里**和指纹**能被重算**是两件事。没有前者只是不好看,没有后者 |
| 19 | + * 就只是装饰 —— 而定位文档的结论是,契约真正多出来的只有「跨宿主一致性」与 |
| 20 | + * 「第三方可审计性」两样。人最终拿到的是 `.md`,所以**可审计的那一份必须就是它**。 |
| 21 | + * |
| 22 | + * 因此 `--verify` 只读 `.md`:它从每份文件里的机器可读块取回记录、重算指纹, |
| 23 | + * 顺带核对**文件名的日期**与记录的时间对不对得上(名字因此不再是装饰)。 |
| 24 | + * |
| 25 | + * ★ 它验证不了什么,与记录层**完全一致**:不验证帧是不是从原始 ctx 正确编出来的, |
| 26 | + * 也不验证判定对不对。那两句照抄自 `REPLAY_NOTES`,不另写一套说法 —— |
| 27 | + * 两处措辞一旦分叉,读的人就会以为口径也不同。 |
| 28 | + * |
| 29 | + * @module JevLoop/trace |
| 30 | + */ |
| 31 | + |
| 32 | +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' |
| 33 | +import { join, resolve } from 'node:path' |
| 34 | +import { fileURLToPath } from 'node:url' |
| 35 | + |
| 36 | +import { parseSessionLine } from '../src/session-log.ts' |
| 37 | +import { REPLAY_NOTES } from '../src/replay-schema.ts' |
| 38 | +import { verifyRecord, type ReplayStatus } from '../src/replay-verify.ts' |
| 39 | +import { checkTrace, renderTrace, traceFileName } from '../src/decision-trace.ts' |
| 40 | +import { recordFromTrace, traceAtOf } from '../src/decision-trace.ts' |
| 41 | + |
| 42 | +const B = (s: string) => `\x1b[1m${s}\x1b[0m` |
| 43 | +const D = (s: string) => `\x1b[2m${s}\x1b[0m` |
| 44 | +const G = (s: string) => `\x1b[32m${s}\x1b[0m` |
| 45 | +const R = (s: string) => `\x1b[31m${s}\x1b[0m` |
| 46 | +const Y = (s: string) => `\x1b[33m${s}\x1b[0m` |
| 47 | + |
| 48 | +const STATUS_COLOR: Record<ReplayStatus, (s: string) => string> = { |
| 49 | + verified: G, |
| 50 | + partial: Y, |
| 51 | + unverifiable: Y, |
| 52 | + mismatch: R, |
| 53 | +} |
| 54 | + |
| 55 | +const DEFAULT_OUT = 'agent/decisions' |
| 56 | + |
| 57 | +/** 落盘的每一行 → 一条可渲染的记录。认不出、不是判定的行**计数**,不静默丢 */ |
| 58 | +interface Traced { |
| 59 | + line: unknown |
| 60 | + record: NonNullable<ReturnType<typeof recordFromTrace>> |
| 61 | + at: number |
| 62 | +} |
| 63 | + |
| 64 | +function collect(raw: string): { traced: Traced[]; skipped: number; undated: number } { |
| 65 | + const traced: Traced[] = [] |
| 66 | + let skipped = 0 |
| 67 | + let undated = 0 |
| 68 | + for (const text of raw.split('\n')) { |
| 69 | + if (!text.trim()) continue |
| 70 | + const parsed = parseSessionLine(text) |
| 71 | + if (!parsed) { |
| 72 | + skipped++ |
| 73 | + continue |
| 74 | + } |
| 75 | + if (parsed.kind === 'turn') continue // v1 遗留:没有事件,没有判定可渲染 |
| 76 | + const record = recordFromTrace(parsed) |
| 77 | + if (!record) continue // `header`,或不是 decision 的事件 —— 都不是「坏行」 |
| 78 | + /* |
| 79 | + ★ 没有时间戳的行照样渲染,但**日期用 0**(于是文件名是 1970-01-01), |
| 80 | + 并且计进 `undated` 报出来 —— 而不是拿当前时间冒充。一个编出来的日期 |
| 81 | + 会让「文件名能不能核对」这条保证当场失效。 |
| 82 | + */ |
| 83 | + const at = traceAtOf(parsed) |
| 84 | + if (at === undefined) undated++ |
| 85 | + traced.push({ line: parsed, record, at: at ?? 0 }) |
| 86 | + } |
| 87 | + return { traced, skipped, undated } |
| 88 | +} |
| 89 | + |
| 90 | +function renderMode(file: string, outDir: string): number { |
| 91 | + const raw = readFileSync(file, 'utf8') |
| 92 | + const { traced, skipped, undated } = collect(raw) |
| 93 | + if (traced.length === 0) { |
| 94 | + console.error(`没有可渲染的判定记录(${skipped} 行认不出)`) |
| 95 | + return 1 |
| 96 | + } |
| 97 | + |
| 98 | + mkdirSync(outDir, { recursive: true }) |
| 99 | + const perDay = new Map<string, number>() |
| 100 | + const written: string[] = [] |
| 101 | + for (const t of traced) { |
| 102 | + const day = traceFileName(t.at, 1).slice(0, 10) |
| 103 | + const n = (perDay.get(day) ?? 0) + 1 |
| 104 | + perDay.set(day, n) |
| 105 | + const name = traceFileName(t.at, n) |
| 106 | + const md = renderTrace(t.record, t.line, verifyRecord(t.record), t.at, n) |
| 107 | + writeFileSync(join(outDir, name), md) |
| 108 | + written.push(name) |
| 109 | + } |
| 110 | + |
| 111 | + console.log(`\n${B('JevLoop · 判定记录 → 可读视图')}`) |
| 112 | + console.log(`${D(' 源 :')} ${file}`) |
| 113 | + console.log(`${D(' 输出 :')} ${outDir}(${written.length} 份)`) |
| 114 | + if (skipped > 0) console.log(`${Y(` ▲ 跳过 ${skipped} 行(坏行或认不出的形状)`)}`) |
| 115 | + if (undated > 0) console.log(`${Y(` ▲ ${undated} 条没有时间戳 —— 文件名里的日期是 1970-01-01`)}`) |
| 116 | + for (const w of written.slice(0, 5)) console.log(`${D(' ·')} ${w}`) |
| 117 | + if (written.length > 5) console.log(`${D(` · …还有 ${written.length - 5} 份`)}`) |
| 118 | + console.log(`\n${D(' 这些 .md **自己**就能复核:')} npm run trace -- --verify ${outDir}\n`) |
| 119 | + return 0 |
| 120 | +} |
| 121 | + |
| 122 | +function verifyMode(dir: string): number { |
| 123 | + let files: string[] |
| 124 | + try { |
| 125 | + files = readdirSync(dir) |
| 126 | + .filter((f) => f.endsWith('.md')) |
| 127 | + .sort() |
| 128 | + } catch (err) { |
| 129 | + console.error(`读不到目录 ${dir}:${(err as Error).message}`) |
| 130 | + return 1 |
| 131 | + } |
| 132 | + if (files.length === 0) { |
| 133 | + console.error(`${dir} 里没有 .md`) |
| 134 | + return 1 |
| 135 | + } |
| 136 | + |
| 137 | + const counts: Record<ReplayStatus, number> = { verified: 0, partial: 0, unverifiable: 0, mismatch: 0 } |
| 138 | + let problematic = 0 |
| 139 | + let unreadable = 0 |
| 140 | + |
| 141 | + console.log(`\n${B('JevLoop · 从 .md 本身复核判定记录')}`) |
| 142 | + console.log(`${D(' 目录 :')} ${dir}\n`) |
| 143 | + |
| 144 | + for (const f of files) { |
| 145 | + const md = readFileSync(join(dir, f), 'utf8') |
| 146 | + /* |
| 147 | + ★ 复核走 `checkTrace`,它查四件事:块取不取得到、块里是不是一条记录、 |
| 148 | + 记录自不自洽、以及**正文与块一致不一致**。 |
| 149 | +
|
| 150 | + 最后那一条是端到端跑出来的教训:第一版只查前三件,于是把表格里的命令 |
| 151 | + 从 `ls -la` 改成 `rm -rf /` 之后仍然报 `verified` —— 因为改的不是那个块。 |
| 152 | + 一份能被人读出错误结论却报 verified 的文件,不算可审计。 |
| 153 | + */ |
| 154 | + const c = checkTrace(md, f) |
| 155 | + if (c.verdict) counts[c.verdict.status]++ |
| 156 | + else unreadable++ |
| 157 | + if (c.problems.length > 0) problematic++ |
| 158 | + |
| 159 | + const status = c.verdict?.status ?? 'unverifiable' |
| 160 | + const paint = STATUS_COLOR[status] |
| 161 | + const who = c.record ? `${c.record.node} → ${c.record.action ?? '(无动作)'}` : '取不回记录' |
| 162 | + console.log(` ${c.ok ? paint('·') : R('✗')} ${f.padEnd(22)} ${paint(status.padEnd(13))}${D(who)}`) |
| 163 | + for (const p of c.problems) console.log(` ${Y(`↳ ${p}`)}`) |
| 164 | + } |
| 165 | + |
| 166 | + console.log('') |
| 167 | + for (const s of ['verified', 'partial', 'unverifiable', 'mismatch'] as ReplayStatus[]) { |
| 168 | + console.log(` ${STATUS_COLOR[s](s.padEnd(13))} ${counts[s]}`) |
| 169 | + } |
| 170 | + if (unreadable > 0) console.log(` ${R('取不回机器可读块')} ${unreadable}`) |
| 171 | + |
| 172 | + console.log(`\n${B(' ── 这一层验证不了什么 ──────────────────────────────────')}`) |
| 173 | + for (const n of REPLAY_NOTES) console.log(Y(` · ${n}`)) |
| 174 | + if (problematic === 0) { |
| 175 | + console.log(G('\n ✓ 这些 .md 文件本身都自洽,正文也没有被改过\n')) |
| 176 | + return 0 |
| 177 | + } |
| 178 | + console.log(R(`\n ✗ ${problematic} 份有问题(见上面的 ↳)\n`)) |
| 179 | + return 1 |
| 180 | +} |
| 181 | + |
| 182 | +function usage(): void { |
| 183 | + console.error('用法:') |
| 184 | + console.error(' npm run trace -- <会话日志.jsonl> [--out <目录>] 渲染成 decisions/*.md') |
| 185 | + console.error(' npm run trace -- --verify <目录> 只从 .md 本身复核') |
| 186 | +} |
| 187 | + |
| 188 | +function main(argv: string[]): number { |
| 189 | + const verifyAt = argv.indexOf('--verify') |
| 190 | + if (verifyAt !== -1) { |
| 191 | + const dir = argv[verifyAt + 1] |
| 192 | + if (!dir) { |
| 193 | + usage() |
| 194 | + return 1 |
| 195 | + } |
| 196 | + return verifyMode(dir) |
| 197 | + } |
| 198 | + const outAt = argv.indexOf('--out') |
| 199 | + const outDir = outAt !== -1 ? argv[outAt + 1] : DEFAULT_OUT |
| 200 | + const file = argv.find((a, i) => !a.startsWith('--') && (outAt === -1 || i !== outAt + 1)) |
| 201 | + if (!file) { |
| 202 | + usage() |
| 203 | + return 1 |
| 204 | + } |
| 205 | + if (!outDir) { |
| 206 | + usage() |
| 207 | + return 1 |
| 208 | + } |
| 209 | + return renderMode(file, outDir) |
| 210 | +} |
| 211 | + |
| 212 | +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { |
| 213 | + process.exit(main(process.argv.slice(2))) |
| 214 | +} |
0 commit comments