Skip to content

Commit aa5de25

Browse files
committed
Render each decision as its own decisions/*.md, and make that file auditable
The presentation form wants one file per decision, named by date. The records already exist (decision-record/v1, one append-only log per session); what was missing was a readable view. This adds it: `npm run trace -- <session> [--out <dir>]` writes `agent/decisions/YYYY-MM-DD-NNN.md`, one per decision. The design decision that matters is not the layout. A digest printed in a file and a digest that can be recomputed are different things: the first is decoration, the second is what makes a record auditable, and third-party auditability is one of only two benefits the positioning document says the contract actually adds. Since the .md is what a person ends up holding, the auditable artifact has to be the .md. So the whole stored line is embedded verbatim in a machine-readable block, and `npm run trace -- --verify <dir>` reads ONLY those .md files, re-derives the digests, and reports the same four verdicts. The prose tables are explicitly declared non-authoritative because they clip long values; the embedded block never does. Two things this got wrong first, both worth recording: · A unit test that pointed the verifier at the embedded block proved the block is checked, not that the file is. Tampering with the rendered command (`ls -la` -> `rm -rf /`) in the human table still reported `verified`. A file a person can read a false conclusion out of, while it reports verified, is not auditable. `checkTrace` now also re-renders the prose from the block and compares byte for byte, so editing any rendered cell is reported. Verified on the real path, not just in a fixture. · The fixture's first version carried invented digests and the test asserted `unverifiable` where the verifier actually said `mismatch` — the round-trip claim would have passed for the wrong reason. Fixtures now compute real digests. Also pinned: merged decisions (`batchIds` > 1) are written out explicitly, because "one file per decision" is exactly the form that would silently lose the fact that a frame was sent for several nodes at once, and `sentFrameDigest != frameDigest` is normal there. Filenames use UTC so the same log lands on the same day on any machine, and the filename's date is checked against the record's timestamp, which makes the name part of the evidence rather than decoration. Scope note, in the module: `memory/*` and `skills/` are not this layer's, and rendering them would claim a memory architecture we do not have.
1 parent e26f11e commit aa5de25

7 files changed

Lines changed: 833 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ npm run external-host # a minimal external host: parse → decide → act, o
135135
npm run adapter-test # the negative fixtures: unknown projection, unhandled action, …
136136
npm run adapter-report # the capability report must match the host's code
137137
npm run replay -- <session> # a real session's decision records must still add up
138+
npm run trace -- <session> # …and render each one as its own readable decisions/*.md
139+
npm run trace -- --verify <dir> # re-derive the digests from those .md files alone
138140
```
139141

140142
> **On the npm status.** `jevloop` is on npm, but the newest published version (`0.2.0`) declares `github.com/Xubqpanda/JevLoop` as its repository, while this tree is `zjunlp/JevLoop` and its `package.json` still reads `0.1.0`. So the published tarball is **not** this revision. To get this one, install from git — `npm install github:zjunlp/JevLoop` — or clone it. `npx jevloop …` is shorthand for the CLI commands above.

‎README.zh-CN.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ node --experimental-strip-types src/cli.ts run "列出目录里的文件并说
135135
npm run external-host # 最小外部宿主:解析 → 判定 → 执行,自带 state 和图
136136
npm run adapter-test # 反向夹具:未知投影、未处理的 action……
137137
npm run adapter-report # 能力报告必须和宿主的代码对得上
138+
npm run replay -- <会话> # 真实会话的判定记录必须仍然自洽
139+
npm run trace -- <会话> # ……并把每一条渲染成一个可读的 decisions/*.md
140+
npm run trace -- --verify <目录> # 只从那批 .md 本身重算指纹(改了正文也会报)
138141
```
139142

140143
> **关于 npm 状态。** `jevloop` 已经在 npm 上,但最新已发布版本(`0.2.0`)声明的仓库是 `github.com/Xubqpanda/JevLoop`,而本仓库是 `zjunlp/JevLoop`,`package.json` 还停在 `0.1.0`。所以那个已发布的 tarball **不是**这个 revision。要拿这一份,从 git 装 —— `npm install github:zjunlp/JevLoop` —— 或者直接 clone。`npx jevloop …` 只是上面那些 CLI 命令的简写。

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"latency": "node --experimental-strip-types bench/latency.ts",
2525
"gate-compare": "node --experimental-strip-types bench/gate-compare.ts",
2626
"gate-derivable": "node --experimental-strip-types bench/gate-derivable-run.ts",
27-
"replay": "node --experimental-strip-types scripts/replay.ts"
27+
"replay": "node --experimental-strip-types scripts/replay.ts",
28+
"trace": "node --experimental-strip-types scripts/trace.ts"
2829
},
2930
"keywords": [
3031
"agent",

‎scripts/check.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,10 @@ const LAYER: Record<string, number> = {
299299
'replay-schema': 0,
300300
// 重放的检查:只 import frame-digest(L0)与 replay-schema(L0)
301301
'replay-verify': 0,
302+
// 判定记录的**可读形态**:一份 .md。纯函数、零 IO —— 渲染一个字符串、
303+
// 再从字符串里取回那条记录。只 import replay-schema(L0)与 replay-verify(L0)
304+
// 的类型,所以放最底下。它不是「呈现层」的额外一层,而是记录格式的一个视图。
305+
'decision-trace': 0,
302306
// `frame-merge` 同它:`mergeConflicts` 跨 L2/L3 共用(决定合并是否合法,
303307
// L2 的 `decide.ts` 必须查得了),而它对领域一无所知 ——
304308
// 只收「看了什么 / 故意不看什么」,出「能不能合」。零 import、纯函数。

‎scripts/trace.ts‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)