@surlymochan/evalid 0.5.20 → 0.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to this project are documented here. Detailed notes live in
4
4
  [RELEASE_NOTES.md](RELEASE_NOTES.md).
5
5
 
6
+ ## [0.5.21] - 2026-11-10
7
+
8
+ ### Added
9
+ - **Agent progress streaming through the execution log and SSE, external contribution (#95)**: `xflow workflow run` now streams live agent progress — `codex exec --json` and `claude --output-format stream-json --verbose` feed the execution log as the event bus, `entry_hash` is the resume token, and `xflow serve` pushes the stream over SSE. Appends no longer re-read the whole file (tail cache + 16 KB tail read, expanding to a full line when the last entry exceeds the window); a >16 KB last entry no longer breaks the hash chain on a cold cache. Test evidence: 32/32 across the four affected test files (`test/agent-stream.test.js`, `test/log-tail.test.js`, `test/execution-log.test.js`, `test/server.test.js`) locally; demo `node scripts/demo-agent-stream.mjs` 5 pushed / 5 written / 5 received over SSE, latency 16–419 ms; PR CI runs verify 42761169188 and ledger 42761169204 both pass on the final HEAD; merged main CI run 42762022933 success.
10
+
11
+ Milestone M4 "lite fast track" — **not yet accepted**: this release ships no M4 slice; slices 1–4 remain merged checkpoints and the wall-clock verdict still awaits bench numbers. This release makes no M4 acceptance claim.
12
+
13
+ Acceptance debt, stated plainly: the bench-number acceptance for P1-2 / P1-3 / P2-1 / P2-3 still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), which is at 98/135 with the 15th relaunch in flight. No bench numbers are claimed in this release.
14
+
6
15
  ## [0.5.20] - 2026-11-10
7
16
 
8
17
  ### Added
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Release Notes
2
2
 
3
+ ## 0.5.21 - Agent progress streaming (external contribution #95)
4
+
5
+ Patch release. One merged PR since 0.5.20, verified end to end in CI and locally.
6
+
7
+ **Agent progress streaming through the execution log and SSE (#95, author MAOLVLHL).** `xflow workflow run` now streams live agent progress: `codex exec --json` and `claude --output-format stream-json --verbose` feed the execution log as the event bus, `entry_hash` is the resume token, and `xflow serve` pushes the stream over SSE. Appends no longer re-read the whole file (tail cache + 16 KB tail read, expanding to a full line when the last entry exceeds the window); a >16 KB last entry no longer breaks the hash chain on a cold cache (verified with a cold-cache reproduction: a fresh process appending after a 20 KB entry keeps the chain intact). Test evidence: 32/32 across the four affected test files locally; demo `node scripts/demo-agent-stream.mjs` 5 pushed / 5 written / 5 received over SSE, latency 16–419 ms; PR CI runs verify 42761169188 and ledger 42761169204 both pass on the final HEAD; merged main CI run 42762022933 success. Maintainer fix on the branch: the author's fix commit had dropped the executable bit on `bin/xflow.js`, breaking one CLI usability test — restored mode-only (commit fcd81d8) before merge.
8
+
9
+ Milestone M4 "lite fast track" — **not yet accepted.** This release ships no M4 slice; the wall-clock verdict still awaits bench numbers.
10
+
11
+ Acceptance debt, stated plainly: the bench-number acceptance for P1-2, P1-3, P2-1 and P2-3 — the wall-clock/token/resolved lines from issue #5 — still awaits the MiniMax full-matrix batch (15 instances × 4 arms × reps 3), which is at 98/135 with the 15th relaunch in flight. No bench numbers are claimed in this release.
12
+
3
13
  ## 0.5.20 - M4 lite fast track, slices 3–4 (milestone M4 not yet accepted)
4
14
 
5
15
  Patch release. Two merged PRs since 0.5.19, both verified end to end in CI and locally.
package/bin/xflow.js CHANGED
@@ -42,6 +42,7 @@ import { createHash } from 'crypto';
42
42
  import { tmpdir } from 'os';
43
43
  import { ackPendingGate } from '../src/core/pending-gates.js';
44
44
  import { runAudit, renderAuditReport } from '../src/core/audit.js';
45
+ import { readEntriesFromOffset, readExecutionLogSize } from '../src/core/log-tail.js';
45
46
  import { exportDomRectsToFile, resolveCliPath } from '../src/agent_team/atoms/visual/dom-rect-export.js';
46
47
  import { capturePageEvidence } from '../src/agent_team/atoms/visual/page-evidence-capture.js';
47
48
  import { computeImageDiffMetrics } from '../src/agent_team/atoms/visual/image-diff.js';
@@ -5508,7 +5509,12 @@ async function handleWorkflow(sub, args) {
5508
5509
  changeId,
5509
5510
  input,
5510
5511
  });
5511
- await executor.run();
5512
+ const progress = args.includes('--progress') ? startProgressTail(projectRoot) : null;
5513
+ try {
5514
+ await executor.run();
5515
+ } finally {
5516
+ if (progress) await progress.stop();
5517
+ }
5512
5518
  }
5513
5519
  break;
5514
5520
  }
@@ -5538,6 +5544,72 @@ async function handleWorkflow(sub, args) {
5538
5544
  }
5539
5545
  }
5540
5546
 
5547
+ // ─── progress tail ────────────────────────────────────────────────────────────
5548
+
5549
+ const PROGRESS_TICK_MS = 200;
5550
+
5551
+ /**
5552
+ * Follow the execution log on stderr while a workflow runs.
5553
+ *
5554
+ * Opt-in (`--progress`) so the default CLI output stays byte-identical, and stderr-only so it
5555
+ * cannot corrupt anything a caller pipes out of stdout.
5556
+ */
5557
+ function startProgressTail(projectRoot, { tickMs = PROGRESS_TICK_MS } = {}) {
5558
+ let offset = null;
5559
+ let stopped = false;
5560
+ let draining = Promise.resolve();
5561
+
5562
+ const drain = async () => {
5563
+ if (offset === null) offset = await readExecutionLogSize(projectRoot);
5564
+ // A run that emits faster than one tick writes more than a single batch, so keep reading
5565
+ // until the log is caught up: one capped read left the tail of a chatty run unprinted.
5566
+ for (;;) {
5567
+ const from = offset;
5568
+ const batch = await readEntriesFromOffset(projectRoot, from, { limit: 100 });
5569
+ offset = batch.nextOffset;
5570
+ for (const entry of batch.entries) {
5571
+ process.stderr.write(`${formatProgressEntry(entry)}\n`);
5572
+ }
5573
+ // Stop when the log is caught up, or when the last batch could not advance past a
5574
+ // half-written entry (re-read on the next tick or on shutdown).
5575
+ if (!batch.hasMore || offset <= from) return;
5576
+ }
5577
+ };
5578
+
5579
+ draining = drain().catch(() => {});
5580
+ const timer = setInterval(() => {
5581
+ if (stopped) return;
5582
+ draining = draining.then(drain).catch(() => {});
5583
+ }, tickMs);
5584
+
5585
+ return {
5586
+ async stop() {
5587
+ stopped = true;
5588
+ clearInterval(timer);
5589
+ await draining.catch(() => {});
5590
+ // Drain whatever was written between the last tick and shutdown.
5591
+ await drain().catch(() => {});
5592
+ },
5593
+ };
5594
+ }
5595
+
5596
+ function formatProgressEntry(entry) {
5597
+ const time = String(entry.timestamp || '').slice(11, 19);
5598
+ if (entry.kind === 'agent_event') {
5599
+ const detail = entry.tool || entry.text || entry.reason || entry.event_type || '';
5600
+ return `[progress] ${time} agent ${entry.event}${detail ? ' · ' + singleLine(detail) : ''}`;
5601
+ }
5602
+ const subject = entry.atom_id || entry.phase_id || null;
5603
+ const parts = [entry.kind || 'event'];
5604
+ if (subject && subject !== entry.kind) parts.push(subject);
5605
+ if (entry.status) parts.push(entry.status);
5606
+ return `[progress] ${time} ${parts.join(' ')}`;
5607
+ }
5608
+
5609
+ function singleLine(value) {
5610
+ return String(value).replace(/\s+/g, ' ').slice(0, 120);
5611
+ }
5612
+
5541
5613
  const CORPS_PROOF_ARTIFACTS = [
5542
5614
  'status.json',
5543
5615
  'proposal.md',
@@ -6703,7 +6775,8 @@ function printWorkflowHelp() {
6703
6775
  Usage: xflow workflow <command>
6704
6776
 
6705
6777
  Commands:
6706
- run <yaml|yolo|corps> Run a workflow (add --dry-run to preview)
6778
+ run <yaml|yolo|corps> Run a workflow (add --dry-run to preview,
6779
+ --progress to follow agent events on stderr)
6707
6780
  validate <yaml|yolo|corps> Validate workflow YAML as preflight only
6708
6781
 
6709
6782
  Options:
@@ -117,7 +117,7 @@ Evidence:
117
117
  - Adoption records: 1; status: pass
118
118
  - Goal audit: pass; goal: Keep evalid credible as an open-source agent delivery runtime: repo-owned workflow contracts, executable verification gates, durable project context, and honest benchmark evidence.
119
119
  - Package evidence: missing; source: registry_json
120
- - Package name/version: @surlymochan/evalid@0.5.20
120
+ - Package name/version: @surlymochan/evalid@0.5.21
121
121
 
122
122
  ## Passing Evidence
123
123
 
@@ -17,7 +17,8 @@
17
17
  9. [CLI 参考](#9-cli-参考)
18
18
  10. [本地 Release Checklist](#10-本地-release-checklist)
19
19
  11. [环境变量](#11-环境变量)
20
- 12. [常见问题](#12-常见问题)
20
+ 12. [实时事件流(agent 进度)](#12-实时事件流agent-进度)
21
+ 13. [常见问题](#13-常见问题)
21
22
 
22
23
  ---
23
24
 
@@ -1077,7 +1078,109 @@ sh xflow/scripts/check_installed_xflow_skill_sync.sh
1077
1078
 
1078
1079
  ---
1079
1080
 
1080
- ## 12. 常见问题
1081
+ ## 12. 实时事件流(agent 进度)
1082
+
1083
+ `agent_invoke` 一次调用默认最长 300 秒(heavy CLI 1800 秒),期间 agent 的 stdout 以前只累加进字符串,运行结束时才有一条 `atom_run`。现在这段时间里发生的事会以 `agent_event` 条目写入 `.as-xflow/logs/execution.ndjson`,并通过 `/api/events` 增量推给看板。
1084
+
1085
+ ### 数据通路
1086
+
1087
+ ```
1088
+ codex exec --json / claude -p --output-format stream-json
1089
+ │ stdout(NDJSON,逐行)
1090
+ ▼
1091
+ src/core/agent-stream.js createLineSplitter → normalizeAgentEvent → 200ms 合并
1092
+ │ appendExecutionLog({ kind: 'agent_event', … })
1093
+ ▼
1094
+ .as-xflow/logs/execution.ndjson (既有 hash 链,一条一条追加)
1095
+ │ src/core/log-tail.js readEntriesFromOffset(offset)
1096
+ ▼
1097
+ GET /api/events event: log, id: <entry_hash>
1098
+ ```
1099
+
1100
+ 写入侧只做归一化与截断,读侧只做字节偏移 tail;两侧都复用既有的执行日志,没有新增文件、没有新增依赖。
1101
+
1102
+ 只有能逐行输出 NDJSON 的 adapter 会接上这条通路:`codex_cli`(`codex exec --json`)与 `claude_cli`(`claude -p --output-format stream-json`)。`pencil_cli` 没有对应的 JSON 行协议,`stub` 根本没有子进程,因此这两者不产生 `agent_event`,其进度只能看到 phase/atom 级的 `atom_run` 记录——这是刻意的,不是遗漏。
1103
+
1104
+ ### 事件类型
1105
+
1106
+ | event | 来源 | 关键字段 |
1107
+ |---|---|---|
1108
+ | `agent_start` | codex `thread.started` / claude `system:init` | `adapter` |
1109
+ | `tool_start` | codex `item.started`:`command_execution`(→ `tool`=`shell`)、`mcp_tool_call`、`file_change`、`web_search`;claude `tool_use` | `event_type`(CLI 原始 item 类型), `tool`, `args_preview`(≤160 字符), `args_digest`(sha256 前 16 位) |
1110
+ | `tool_end` | codex `item.completed` 的同四种 item 类型(`event_type` 区分);claude 侧没有配对事件 | `event_type`, `tool`, `exit_code`, `status` |
1111
+ | `message_end` | codex `agent_message` / claude assistant `text` | `text`(≤2048 字符,超出以 `…` 结尾) |
1112
+ | `agent_end` | codex `turn.completed` / claude `result` | `usage`{`input`,`output`,`cache_read`,`cache_write`}, `cost_usd`, `duration_ms` |
1113
+ | `raw` | 无法识别、无法解析、或超长被截断的行;CLI 报错(`turn.failed` / `error`) | `reason`(`invalid_json` / `non_object_payload` / `line_truncated`), `event_type`, `level`(`error`), `truncated` |
1114
+
1115
+ 每条记录的完整形状:
1116
+
1117
+ ```json
1118
+ {
1119
+ "timestamp": "2026-11-09T04:12:33.412Z",
1120
+ "kind": "agent_event",
1121
+ "event": "tool_start",
1122
+ "phase": "implementation",
1123
+ "atom": "I6a.tdd.run",
1124
+ "adapter": "codex_cli",
1125
+ "seq": 3,
1126
+ "event_type": "command_execution",
1127
+ "tool": "shell",
1128
+ "args_preview": "npm test",
1129
+ "args_digest": "9f2c1e0b7d4a5c88",
1130
+ "change_id": "chg-124",
1131
+ "mission_id": "mis-9",
1132
+ "prev_hash": "…",
1133
+ "entry_hash": "…"
1134
+ }
1135
+ ```
1136
+
1137
+ 未知的 CLI 事件类型一律降级为 `raw` 或被忽略,不抛错:CLI 升级不会让整条 workflow 失败。
1138
+
1139
+ ### 订阅
1140
+
1141
+ ```javascript
1142
+ const events = new EventSource('/api/events');
1143
+ const seen = new Set();
1144
+
1145
+ events.addEventListener('snapshot', () => { /* 首帧仍是全量快照,可整体重置 UI */ });
1146
+ events.addEventListener('log', (message) => {
1147
+ const entry = JSON.parse(message.data);
1148
+ if (entry.entry_hash && seen.has(entry.entry_hash)) return; // 快照与增量可能重叠
1149
+ if (entry.entry_hash) seen.add(entry.entry_hash);
1150
+ renderActivity(entry);
1151
+ });
1152
+ ```
1153
+
1154
+ - 首帧永远是 `event: snapshot`(与旧行为一致),之后才是增量。
1155
+ - 增量帧带 `id: <entry_hash>`;浏览器断线重连会自动带上 `Last-Event-ID`,服务端据此回放缺失条目,不丢也不重。
1156
+ - 若该 id 对应的日志已被删除或轮转,服务端回退为正常跟随(客户端拿不到历史,只收到之后的新条目)。
1157
+ - `event: snapshot` 仍然每 3 秒重发一次。增量流只解决了“一条条新条目怎么到达”,没有取消全量快照:看板的 summary、pending gates、pending tasks 都由快照刷新,增量帧里没有这些字段。也就是说常驻连接的主要带宽仍是快照;要再降,需要把 summary/gates 也改成增量事件,那是另一个 PR 的边界。
1158
+ - 排序一律以文件顺序为准,不要用 `timestamp` 排序:同一毫秒内可能有多条。
1159
+
1160
+ ### CLI 进度输出
1161
+
1162
+ ```bash
1163
+ CHANGE_ID=<id> xflow workflow run workflows/yolo.yaml --progress
1164
+ ```
1165
+
1166
+ `--progress` 是 opt-in 的:不加时终端输出与以前逐字节一致。开启后每 200ms 把新条目打印到 stderr(stdout 留给 workflow 自己的输出),例如:
1167
+
1168
+ ```
1169
+ [progress] 12:13:02 agent tool_start · shell
1170
+ [progress] 12:13:41 agent message_end · Tests pass; writing the artifact.
1171
+ [progress] 12:13:44 atom_run I6a.tdd.run completed
1172
+ ```
1173
+
1174
+ ### 性能约束
1175
+
1176
+ - 写入:每 200ms 合并一次,单条 `text` ≤2048 字符、`args_preview` ≤160 字符;队列上限 500,超出丢最旧条目并计入 `stats.dropped`。
1177
+ - 读取:只从字节偏移往后读,单次 ≤4MB;未写完的尾行留到下次;游标只停在换行边界。
1178
+ - 哈希链:追加时从文件尾部 16KB 起求 `prev_hash`;若窗口里没有完整行(单条记录大于 16KB),窗口按 4 倍扩大直到找到为止,否则会写出 `prev_hash: null` 把链断掉。缓存按 `(size, mtimeMs)` 失效,避免同尺寸的轮转或截断命中陈旧哈希。
1179
+ - 订阅:每个 SSE 连接各自持有一个 200ms 的 tailer,一次 tick 是“stat + 从上次偏移读一小段”,单个看板标签页可以忽略;若将来有大量客户端同时观看同一 run,更省的形状是**一个共享 tailer 广播给所有连接**(当前版本未做)。
1180
+
1181
+ ---
1182
+
1183
+ ## 13. 常见问题
1081
1184
 
1082
1185
  ### workflow 停在 human gate 怎么恢复?
1083
1186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surlymochan/evalid",
3
- "version": "0.5.20",
3
+ "version": "0.5.21",
4
4
  "description": "Claimed != verified: a runtime verification layer for coding agents. It seals each test run and replays it from a clean snapshot, with a zero-judge gate pipeline (spec clarification, code review, TDD proof) and the xflow CLI.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * demo-agent-stream.mjs — proves the agent progress path end to end.
4
+ *
5
+ * Feeds synthetic codex events through `createAgentEventSink` (the same sink
6
+ * `agent_invoke` uses), then prints each one as `xflow serve` delivers it over
7
+ * /api/events, together with the observed latency. No agent CLI is required.
8
+ *
9
+ * node scripts/demo-agent-stream.mjs
10
+ */
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ import startServer from '../src/server.js';
16
+ import { createAgentEventSink } from '../src/core/agent-stream.js';
17
+
18
+ const PUSH_INTERVAL_MS = 200;
19
+ const FIXTURE = [
20
+ '{"type":"thread.started","thread_id":"thread_demo"}',
21
+ '{"type":"item.started","item":{"id":"i1","type":"command_execution","command":"npm test"}}',
22
+ '{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"npm test","exit_code":0,"status":"completed"}}',
23
+ '{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"Tests pass; writing the artifact."}}',
24
+ '{"type":"turn.completed","usage":{"input_tokens":1200,"output_tokens":340}}',
25
+ ];
26
+
27
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
28
+
29
+ function labelOf(entry) {
30
+ if (entry.event === 'agent_start') return 'agent_start';
31
+ if (entry.event === 'agent_end') return 'agent_end';
32
+ if (entry.tool) return `${entry.event} ${entry.tool}`;
33
+ return `${entry.event} ${String(entry.text || '').slice(0, 40)}`;
34
+ }
35
+
36
+ const projectRoot = mkdtempSync(join(tmpdir(), 'xflow-demo-stream-'));
37
+ const server = await startServer({ projectRoot, port: 0 });
38
+ const base = `http://127.0.0.1:${server.address().port}`;
39
+ const startedAt = Date.now();
40
+ const sentAt = new Map();
41
+
42
+ console.log(`[demo] project root: ${projectRoot}`);
43
+ console.log(`[demo] dashboard: ${base}/`);
44
+ console.log(`[demo] pushing ${FIXTURE.length} synthetic codex events, one every ${PUSH_INTERVAL_MS}ms\n`);
45
+
46
+ const sink = createAgentEventSink({
47
+ projectRoot,
48
+ changeId: 'chg-demo-stream',
49
+ missionId: 'mis-demo',
50
+ phase: 'demo phase',
51
+ atom: 'demo.atom',
52
+ adapter: 'codex_cli',
53
+ });
54
+
55
+ const streamResponse = await fetch(`${base}/api/events`);
56
+ const reader = streamResponse.body.getReader();
57
+ const decoder = new TextDecoder();
58
+ const latencies = [];
59
+ let buffer = '';
60
+ let received = 0;
61
+
62
+ const pump = (async () => {
63
+ while (true) {
64
+ const { value, done } = await reader.read();
65
+ if (done) break;
66
+ buffer += decoder.decode(value, { stream: true });
67
+ let frameEnd = buffer.indexOf('\n\n');
68
+ while (frameEnd !== -1) {
69
+ const frame = buffer.slice(0, frameEnd);
70
+ buffer = buffer.slice(frameEnd + 2);
71
+ frameEnd = buffer.indexOf('\n\n');
72
+ // Frames carry an `id:` line first, so match the event name rather than the first line.
73
+ if (!frame.includes('event: log')) continue;
74
+ const data = frame.split('\n').find((line) => line.startsWith('data: '));
75
+ if (!data) continue;
76
+ const entry = JSON.parse(data.slice(6));
77
+ const label = labelOf(entry);
78
+ const elapsed = Date.now() - startedAt;
79
+ const latency = sentAt.has(label) ? Date.now() - sentAt.get(label) : null;
80
+ if (latency !== null) latencies.push(latency);
81
+ received += 1;
82
+ console.log(
83
+ `[demo] +${String(elapsed).padStart(5)}ms ${label.padEnd(26)} ` +
84
+ `phase=${entry.phase} atom=${entry.atom} id=${String(entry.entry_hash).slice(0, 8)}…` +
85
+ (latency === null ? '' : ` latency=${latency}ms`),
86
+ );
87
+ }
88
+ }
89
+ })();
90
+
91
+ for (const line of FIXTURE) {
92
+ const entry = JSON.parse(line);
93
+ sentAt.set(labelOf({
94
+ event: entry.type === 'thread.started' ? 'agent_start'
95
+ : entry.type === 'turn.completed' ? 'agent_end'
96
+ : entry.item?.type === 'command_execution' ? 'tool_start'
97
+ : 'message_end',
98
+ tool: entry.item?.type === 'command_execution' ? 'shell' : null,
99
+ text: entry.item?.text,
100
+ }), Date.now());
101
+ sink.push(`${line}\n`);
102
+ await sleep(PUSH_INTERVAL_MS);
103
+ }
104
+ await sink.close();
105
+ await sleep(500);
106
+ await reader.cancel();
107
+
108
+ const stats = sink.stats;
109
+ console.log(`\n[demo] events pushed: ${stats.events}, written: ${stats.written}, received over SSE: ${received}`);
110
+ if (latencies.length) {
111
+ const min = Math.min(...latencies);
112
+ const max = Math.max(...latencies);
113
+ console.log(`[demo] end-to-end latency (push -> SSE): min ${min}ms, max ${max}ms over ${latencies.length} events`);
114
+ }
115
+ console.log('[demo] open the dashboard URL above while this script runs to watch the same stream live.');
116
+
117
+ await pump.catch(() => {});
118
+ await new Promise((resolveClose) => server.close(resolveClose));
119
+ rmSync(projectRoot, { recursive: true, force: true });