@surlymochan/evalid 0.5.13 → 0.5.15

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,7 +3,23 @@
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
- ## [Unreleased]
6
+ ## [0.5.15] - 2026-11-09
7
+
8
+ ### Added
9
+ - **`xflow audit` standalone mode (#78, M2)**: verify any git diff / PR without adopting evalid workflows — `xflow audit --pr <n>` / `--diff <patch> --base <ref>` / `--base <ref> --head <ref>` extracts the test runs the change claims to have passed (English and Chinese claim phrasing, per-file counts summed for multi-file commands, clause-split so one line's two claims don't leak onto each other), replays each in a clean clone at the change's head (deps installed with `npm ci` in the scratch copy, `NODE_TEST_*` env stripped so a nested `node --test` really runs), and compares exit codes and pass/fail counts against the claim. Diffs carrying an evalid proof seal are replayed through the existing i6b validator (seal semantics untouched). Verdicts: `verified` / `mismatch` / `no_claims` / `unverifiable` (exit 0/1/3/1). Test evidence: 11 new tests in `test/xflow-audit.test.js` (claim extraction incl. #75/#68 bodies, diff/seal parsing, replay-count parsing, fixture-repo verified/mismatch/no_claims end-to-end) 11/11 pass; local full `npm test` 475 tests / 474 pass / 0 fail / 1 skipped (pre-existing); PR CI runs ledger 42516967938 and verify 42516967984 both success.
10
+
11
+ Milestone M2 "`xflow audit` standalone mode" acceptance evidence (issue #78, comment 6726208145): audits of real historical PRs #75 / #71 / #68 all `verified` (targeted run 24 passed / 0 failed / 24 total plus seal `valid` on #75; full `npm test` exit 0 on all three; #68's claimed 439/0/440 honestly counts-unparsed — multi-file runner has no single total).
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 stopped on MiniMax Token Plan quota exhaustion. No bench numbers are claimed in this release. Audit gaps carried forward: no PR from a different repo or a non-evalid agent audited yet; pytest / go test / cargo test replay paths are parser-level only.
14
+
15
+ ## [0.5.14] - 2026-11-09
16
+
17
+ ### Added
18
+ - **PR comment ledger (#75)**: the proof-ledger workflow now posts the claim-vs-evidence ledger as a PR comment (`action/ledger-comment.mjs`, `.github/workflows/proof-ledger.yml`). A green proof gets a ✅ comment naming the verified change; a tampered proof fails the `ledger` check and gets a ❌ comment naming the exact break point, so a replay mismatch can no longer pass CI silently. Test evidence: 18 new tests in `test/github-action-ledger-comment.test.js` 18/18 pass; local full `npm test` 464 tests / 463 pass / 0 fail / 1 skipped (pre-existing); PR CI run 42505482112 success.
19
+
20
+ Milestone M1 "GitHub Action end-to-end" acceptance evidence (issue #73, comment 6724800077): ① green path — PR #75 merged af88e2da, proof-ledger run 42505482067 success, ✅ ledger comment 6724656930; ② red path — demo PR #76 (closed unmerged by plan) turned the `ledger` check red (run 42505769769) and the ❌ comment 6724768829 names the break point `green_tree_mismatch: test/demo-tamper.test.js`; ③ CI replay and local replay agree on both verdicts (`valid` green, `green_tree_mismatch` tampered) through the same validator entry.
21
+
22
+ 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 stopped on MiniMax Token Plan quota exhaustion. No bench numbers are claimed in this release.
7
23
 
8
24
  ## [0.5.13] - 2026-11-09
9
25
 
package/README.md CHANGED
@@ -149,6 +149,88 @@ node bin/xflow.js compare codex-goal
149
149
  node bin/xflow.js launch copy
150
150
  ```
151
151
 
152
+ ## GitHub Action: verify proofs in CI
153
+
154
+ evalid ships as a GitHub Action (`action.yml` at the repo root). Add it to a
155
+ workflow to verify a sealed TDD proof on every PR: a valid proof keeps the
156
+ check green, a broken seal (or a test edited after the seal) turns it red —
157
+ and the claim-vs-evidence ledger is posted as a PR comment either way, so
158
+ the reviewer sees the claim next to the replay evidence without opening
159
+ the workflow log.
160
+
161
+ ```yaml
162
+ name: Proof ledger
163
+ on:
164
+ pull_request:
165
+ types: [opened, synchronize, reopened]
166
+ permissions:
167
+ contents: read
168
+ pull-requests: write
169
+ jobs:
170
+ ledger:
171
+ runs-on: ubuntu-24.04
172
+ steps:
173
+ - uses: actions/checkout@v7.0.1
174
+ with:
175
+ fetch-depth: 0 # replay materializes files from the recorded HEAD
176
+ - uses: actions/setup-node@v7.1.0
177
+ with:
178
+ node-version: '22'
179
+ - uses: surlymochan/evalid@main # or a release tag (see CHANGELOG)
180
+ with:
181
+ change-id: my-change
182
+ phase: green
183
+ github-token: ${{ secrets.GITHUB_TOKEN }}
184
+ ```
185
+
186
+ Inputs: `change-id` (required), `phase` (`green`, default), `attempt` (`0`,
187
+ default), `project-root` (`.`, default), `tdd-mode` (auto-detect, default),
188
+ `post-comment` (`true`, default — set `false` to skip the comment),
189
+ `github-token` (defaults to `$GITHUB_TOKEN`), `pr-number` (auto-detects
190
+ from the event payload or ref when empty).
191
+
192
+ The comment is upserted on a stable marker
193
+ (`<!-- evalid-ledger: <change-id>/<phase>/<attempt> -->`), so re-runs
194
+ update the same comment instead of posting a new one per push. A failed
195
+ comment post never masks the verdict: the step still exits on the proof
196
+ result, and `comment_posted` / `comment_url` are exposed as outputs.
197
+ The check fails (exit 1) on `binding_mismatch`, `snapshot_mismatch`,
198
+ `green_tree_mismatch`, replay mismatches, or a missing proof; the comment
199
+ names the breakage point (for example the test files that drifted after
200
+ the seal).
201
+
202
+ Local equivalent of the CI replay — same validator, same entry:
203
+
204
+ ```bash
205
+ node action/verify-proof.mjs --project-root . --change-id my-change --phase green
206
+ ```
207
+
208
+ This repo dogfoods the Action on its own PRs via
209
+ `.github/workflows/proof-ledger.yml`: it discovers every sealed proof
210
+ added or changed by the PR (`specs/changes/*/tdd/*.json`) and verifies
211
+ each one.
212
+
213
+ ## `xflow audit`: verify any PR, no evalid workflow required
214
+
215
+ `xflow audit` checks a change you did *not* produce with evalid: point it
216
+ at any git diff or PR — Claude Code, Codex, or any other agent's output —
217
+ and it extracts the test runs the change claims to have passed, replays
218
+ each one in a clean clone at the change's head, and compares exit codes
219
+ and pass/fail counts against the claim. If the diff carries an evalid
220
+ proof seal, the seal is replayed through the existing i6b validator.
221
+
222
+ ```bash
223
+ xflow audit --pr 75 # audit a GitHub PR (body = claims)
224
+ xflow audit --diff change.patch --base HEAD~1 --claims-file claims.txt
225
+ xflow audit --base main --head feature/x --claims "npm test: exit 0"
226
+ ```
227
+
228
+ Verdicts: `verified` (every claim replayed as claimed), `mismatch` (a
229
+ replay contradicts a claim, or a seal is invalid), `no_claims` (no test
230
+ claims found in the supplied text), `unverifiable` (a claimed command
231
+ could not run). Exit codes are 0 / 1 / 3 / 1 respectively (2 = misuse).
232
+ `--json` prints the machine-readable report; `--out <file>` writes it.
233
+
152
234
  ## Commands
153
235
 
154
236
  The installed binary is `xflow` (`npm install -g evalid` installs the `xflow` CLI). Every
@@ -160,6 +242,7 @@ top-level command below was verified against `xflow <command> --help` at v0.5.7;
160
242
  | `xflow workflow run \| validate <yaml>` | Run a workflow (`yolo` / `corps` / a YAML path), or validate its YAML as preflight |
161
243
  | `xflow corps` | Run the heavy product/multi-agent workflow through its governed entry point |
162
244
  | `xflow proof` | Verify workflow completion evidence and write the proof file |
245
+ | `xflow audit` | Audit any git diff / PR: replay claimed test runs, verdict |
163
246
  | `xflow doctor` | Check local xflow readiness: package scripts, workflow YAMLs, skill topology |
164
247
  | `xflow init` | Create project-local `.as-xflow/config.json` with portable defaults |
165
248
  | `xflow goal show \| set \| audit \| clear` | Manage the project-level `.xflow/GOAL.md` anchor |
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Release Notes
2
2
 
3
+ ## 0.5.15 - xflow audit standalone mode (milestone M2 accepted)
4
+
5
+ Patch release. One new feature since 0.5.14, verified end to end in CI and locally.
6
+
7
+ **`xflow audit` standalone mode (#79).** Verify any git diff / PR without adopting evalid workflows: `xflow audit --pr <n>` extracts the test runs the change claims to have passed, replays each one in a clean clone at the change's head, and compares exit codes and pass/fail counts against the claim; diffs carrying an evalid proof seal are replayed through the existing i6b validator. Test evidence: 11 new tests in `test/xflow-audit.test.js` 11/11 pass; local full `npm test` 475 tests / 474 pass / 0 fail / 1 skipped (pre-existing); PR CI runs ledger 42516967938 and verify 42516967984 both success.
8
+
9
+ **Milestone M2 "`xflow audit` standalone mode" — accepted.** Evidence in issue #78, comment 6726208145: audits of real historical PRs #75 / #71 / #68 all `verified` — #75 targeted run 24 passed / 0 failed / 24 total plus seal `valid`, full `npm test` exit 0 on all three; #68's claimed 439/0/440 honestly counts-unparsed (the multi-file runner has no single total, so the verdict rests on exit code).
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 stopped on MiniMax Token Plan quota exhaustion. No bench numbers are claimed in this release. Audit gaps carried forward: no PR from a different repo or a non-evalid agent audited yet; pytest / go test / cargo test replay paths are parser-level only.
12
+
13
+ ## 0.5.14 - PR comment ledger (milestone M1 accepted)
14
+
15
+ Patch release. One new feature since 0.5.13, verified end to end in CI and locally.
16
+
17
+ **PR comment ledger (#75).** The proof-ledger workflow now posts the claim-vs-evidence ledger as a PR comment: a green proof gets a ✅ comment on the PR, and a tampered proof fails the `ledger` check and gets a ❌ comment naming the exact break point, so a replay mismatch can no longer pass CI silently. Test evidence: 18 new tests in `test/github-action-ledger-comment.test.js` 18/18 pass; local full `npm test` 464 tests / 463 pass / 0 fail / 1 skipped (pre-existing); PR CI run 42505482112 success.
18
+
19
+ **Milestone M1 "GitHub Action end-to-end" — accepted.** Evidence in issue #73, comment 6724800077: ① green path — PR #75 merged (af88e2da), proof-ledger run 42505482067 success, ✅ ledger comment 6724656930; ② red path — demo PR #76 (closed unmerged by plan) turned the `ledger` check red (run 42505769769) and the ❌ comment 6724768829 names the break point `green_tree_mismatch: test/demo-tamper.test.js`; ③ CI replay and local replay agree on both verdicts (`valid` green, `green_tree_mismatch` tampered) through the same validator entry.
20
+
21
+ 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 stopped on MiniMax Token Plan quota exhaustion. No bench numbers are claimed in this release.
22
+
3
23
  ## 0.5.13 - Proof-seal verification action
4
24
 
5
25
  Patch release. One new feature and one dead-code removal since 0.5.12, both verified by the test suite.
package/bin/xflow.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * workflow validate <yaml> Validate workflow YAML against schema
8
8
  * corps Run the heavy corps workflow through its governed entry
9
9
  * proof Verify workflow completion evidence and write proof
10
+ * audit Audit any git diff / PR: replay claimed test runs, verdict
10
11
  * doctor Check local xflow readiness
11
12
  * evaluate Show one-shot external evaluator brief
12
13
  * release status Show release-owner publish status
@@ -40,6 +41,7 @@ import { spawnSync } from 'child_process';
40
41
  import { createHash } from 'crypto';
41
42
  import { tmpdir } from 'os';
42
43
  import { ackPendingGate } from '../src/core/pending-gates.js';
44
+ import { runAudit, renderAuditReport } from '../src/core/audit.js';
43
45
  import { exportDomRectsToFile, resolveCliPath } from '../src/agent_team/atoms/visual/dom-rect-export.js';
44
46
  import { capturePageEvidence } from '../src/agent_team/atoms/visual/page-evidence-capture.js';
45
47
  import { computeImageDiffMetrics } from '../src/agent_team/atoms/visual/image-diff.js';
@@ -83,6 +85,9 @@ async function main() {
83
85
  case 'proof':
84
86
  await handleProof([subcommand, ...rest].filter(Boolean));
85
87
  break;
88
+ case 'audit':
89
+ await handleAudit([subcommand, ...rest].filter(Boolean));
90
+ break;
86
91
  case 'gate':
87
92
  await handleGate(subcommand, rest);
88
93
  break;
@@ -5742,6 +5747,77 @@ async function handleProof(args) {
5742
5747
  if (!proof.ok) process.exit(1);
5743
5748
  }
5744
5749
 
5750
+ // ─── audit ───────────────────────────────────────────────────────────────────
5751
+ // Standalone audit (M2, #78): verify any git diff / PR without adopting
5752
+ // evalid workflows. Claims are extracted from the PR body / claims text,
5753
+ // replayed in a clean clone at the PR head, and compared against the claim;
5754
+ // diffs carrying an evalid proof seal are replayed through the i6b validator
5755
+ // (see src/core/audit.js).
5756
+ async function handleAudit(args) {
5757
+ if (args.some(isHelpRequest)) {
5758
+ printAuditHelp();
5759
+ return;
5760
+ }
5761
+ const parsedArgs = parseCliArgs(args);
5762
+ const repoDir = resolve(parsedArgs.repo || process.cwd());
5763
+ const timeoutMs = parsedArgs.timeout ? Number(parsedArgs.timeout) * 1000 : 300000;
5764
+ let diffText = '';
5765
+ let claimsText = '';
5766
+ let baseRef = parsedArgs.base || null;
5767
+ let headRef = parsedArgs.head || null;
5768
+
5769
+ if (parsedArgs.pr) {
5770
+ const prNumber = String(parsedArgs.pr);
5771
+ const view = spawnSync('gh', ['pr', 'view', prNumber, '--json', 'body,baseRefOid,mergeCommit,headRefOid'], { cwd: repoDir, encoding: 'utf8' });
5772
+ if (view.status !== 0) {
5773
+ console.error(`xflow audit: gh pr view ${prNumber} failed: ${(view.stderr || view.stdout || '').trim().slice(-300)}`);
5774
+ process.exit(2);
5775
+ }
5776
+ let pr;
5777
+ try {
5778
+ pr = JSON.parse(view.stdout);
5779
+ } catch {
5780
+ console.error('xflow audit: could not parse gh pr view output');
5781
+ process.exit(2);
5782
+ }
5783
+ claimsText = pr.body || '';
5784
+ baseRef = pr.baseRefOid || null;
5785
+ headRef = pr.mergeCommit?.oid || pr.headRefOid || null;
5786
+ const diff = spawnSync('gh', ['pr', 'diff', prNumber], { cwd: repoDir, encoding: 'utf8' });
5787
+ if (diff.status !== 0) {
5788
+ console.error(`xflow audit: gh pr diff ${prNumber} failed: ${(diff.stderr || diff.stdout || '').trim().slice(-300)}`);
5789
+ process.exit(2);
5790
+ }
5791
+ diffText = diff.stdout || '';
5792
+ } else {
5793
+ if (parsedArgs.diff) {
5794
+ diffText = parsedArgs.diff === '-'
5795
+ ? readFileSync(0, 'utf8')
5796
+ : readFileSync(resolve(parsedArgs.diff), 'utf8');
5797
+ }
5798
+ if (parsedArgs.claims) claimsText = String(parsedArgs.claims);
5799
+ if (parsedArgs.claims_file) claimsText = readFileSync(resolve(parsedArgs.claims_file), 'utf8');
5800
+ if (!headRef && !baseRef) {
5801
+ console.error('Usage: xflow audit --pr <n> | --diff <patch> --base <ref> | --base <ref> --head <ref>');
5802
+ process.exit(2);
5803
+ }
5804
+ }
5805
+
5806
+ const report = runAudit({
5807
+ repoDir,
5808
+ diffText,
5809
+ baseRef,
5810
+ headRef,
5811
+ claimsText,
5812
+ timeoutMs,
5813
+ keepWorkdir: Boolean(parsedArgs.keep_workdir),
5814
+ });
5815
+ if (parsedArgs.out) writeFileSync(resolve(parsedArgs.out), `${JSON.stringify(report, null, 2)}\n`);
5816
+ if (parsedArgs.json) console.log(JSON.stringify(report, null, 2));
5817
+ else console.log(renderAuditReport(report));
5818
+ process.exit(report.exitCode);
5819
+ }
5820
+
5745
5821
  async function buildWorkflowProof({ track, workflowPath, projectRoot, changeId, write = true }) {
5746
5822
  const { load } = await import('../src/core/workflow-loader.js');
5747
5823
  const { readExecutionLog } = await import('../src/core/execution-log.js');
@@ -6654,6 +6730,37 @@ Options:
6654
6730
  `);
6655
6731
  }
6656
6732
 
6733
+ function printAuditHelp() {
6734
+ console.log(`
6735
+ Usage: xflow audit --pr <n> [--repo <path>] [--json] [--out <file>]
6736
+ xflow audit --diff <patch|-> --base <ref> [--claims-file <file>] [--json]
6737
+ xflow audit --base <ref> --head <ref> [--claims <text>] [--json]
6738
+
6739
+ Audit any git diff / PR without adopting evalid workflows: extract the test
6740
+ runs the change claims to have passed, replay each one in a clean clone at
6741
+ the change's head, and compare exit codes and pass/fail counts against the
6742
+ claim. Diffs carrying an evalid proof seal are replayed through the existing
6743
+ i6b seal validator. No seal semantics, CLI name, or bin path changes.
6744
+
6745
+ Verdicts / exit codes: verified 0, mismatch 1, unverifiable 1, misuse 2,
6746
+ no_claims 3 (no test claims found in the supplied text).
6747
+
6748
+ Options:
6749
+ --pr N Audit a GitHub PR (body = claims, merge commit = head)
6750
+ --diff FILE Unified diff to apply on top of --base ('-' reads stdin)
6751
+ --base REF Base commit for --diff, or diff base for --head
6752
+ --head REF Head commit to check out directly (diff not applied)
6753
+ --repo PATH Local repo to clone from, default: current directory
6754
+ --claims TEXT Claims text (default: PR body for --pr)
6755
+ --claims-file FILE Read claims text from a file
6756
+ --timeout SEC Per-command replay timeout, default: 300
6757
+ --json Print the machine-readable report
6758
+ --out FILE Write the JSON report to a file
6759
+ --keep-workdir Keep the scratch clone for debugging
6760
+ help, -h, --help Show this help message
6761
+ `);
6762
+ }
6763
+
6657
6764
  function printProofHelp() {
6658
6765
  console.log(`
6659
6766
  Usage: xflow proof [--track corps] --change-id <id> [--project-root <path>]
@@ -7148,6 +7255,7 @@ Usage:
7148
7255
  xflow workflow validate <yaml> Validate workflow YAML
7149
7256
  xflow corps --title <title> Run heavy track through governed entry
7150
7257
  xflow proof --track corps Verify final execution proof
7258
+ xflow audit --pr 75 [--json] Audit a PR: replay its claimed test runs
7151
7259
  xflow doctor [--json] Check local xflow readiness
7152
7260
  xflow init [--project-root .] Create project-local xflow config
7153
7261
  xflow goal set "<goal>" Write the project-level .xflow/GOAL.md anchor
@@ -1,6 +1,6 @@
1
1
  # evalid Launch Dossier
2
2
 
3
- Generated: 2026-11-08T16:04:31.497Z
3
+ Generated: 2026-11-08T19:55:19.000Z
4
4
  Mode: post_publish
5
5
  Verdict: conditionally ready: engine and adoption evidence are strong, registry publication remains required for a splash launch
6
6
  Launch ready: not yet
@@ -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.13
120
+ - Package name/version: @surlymochan/evalid@0.5.15
121
121
 
122
122
  ## Passing Evidence
123
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surlymochan/evalid",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
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,497 @@
1
+ /**
2
+ * xflow audit — standalone audit of any git diff / PR, without adopting
3
+ * evalid workflows.
4
+ *
5
+ * An audit answers one question: do the test runs this change *claims* to
6
+ * have passed actually pass when replayed from a clean copy at the change's
7
+ * head? It extracts claimed test commands from the PR body / claims text,
8
+ * replays each command in a scratch clone (base commit + diff applied, or
9
+ * the PR's merge commit checked out directly), and compares exit codes and
10
+ * pass/fail counts against the claim. Diffs that carry an evalid proof seal
11
+ * (specs/changes/<id>/tdd/<phase>-<attempt>.json) are additionally replayed
12
+ * through the existing i6b seal validator — the seal semantics are reused,
13
+ * not rebuilt.
14
+ *
15
+ * Verdicts:
16
+ * verified — every claim replayed as claimed (and every seal valid)
17
+ * mismatch — a replay contradicts a claim, or a seal is invalid
18
+ * no_claims — no test claims found in the supplied text
19
+ * unverifiable — a claimed command could not run (missing runner, timeout)
20
+ *
21
+ * Exit codes mirror the verdicts: 0 verified, 1 mismatch/unverifiable,
22
+ * 2 misuse, 3 no_claims (inconclusive, not a failure of the change).
23
+ */
24
+ import { spawnSync } from 'node:child_process';
25
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { join, resolve } from 'node:path';
28
+
29
+ /** Commands that count as claimed test runs. */
30
+ export const TEST_COMMAND_PATTERNS = [
31
+ /npm\s+test\b/,
32
+ /npm\s+run\s+[\w:.-]+/,
33
+ /pnpm\s+test\b/,
34
+ /yarn\s+test\b/,
35
+ /node\s+--test\b/,
36
+ /pytest\b/,
37
+ /python3?\s+-m\s+pytest\b/,
38
+ /go\s+test\b/,
39
+ /cargo\s+test\b/,
40
+ /mvn\s+(?:-[^ ]+\s+)*test\b/,
41
+ /npx\s+[\w@./-]+/,
42
+ ];
43
+
44
+ const COMMAND_CHARS = String.raw`[\w@./:+\-='"]`;
45
+ const COMMAND_TAIL = new RegExp(
46
+ String.raw`(?:npm\s+test|npm\s+run\s+[\w:.-]+|pnpm\s+test|yarn\s+test|node\s+--test(?:\s+${COMMAND_CHARS}+)*|pytest(?:\s+${COMMAND_CHARS}+)*|python3?\s+-m\s+pytest(?:\s+${COMMAND_CHARS}+)*|go\s+test(?:\s+${COMMAND_CHARS}+)*|cargo\s+test(?:\s+${COMMAND_CHARS}+)*|mvn\s+(?:-[^\s]+\s+)*test|npx\s+[\w@./-]+(?:\s+${COMMAND_CHARS}+)*)`,
47
+ );
48
+
49
+ export function isTestCommand(text) {
50
+ return TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(text));
51
+ }
52
+
53
+ export function isTestFile(path) {
54
+ return /(^|\/)test(s)?\/|\.test\.[cm]?[jt]sx?$|_test\.go$|^test_[^/]*\.py$|_test\.py$|\.spec\.[cm]?[jt]sx?$/.test(path);
55
+ }
56
+
57
+ function normalizeCommand(raw) {
58
+ return String(raw || '').replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ /**
62
+ * Pull pass/fail/total counts out of one line of claim text.
63
+ * Handles English ("18 tests, all pass", "6/6 pass", "439 pass, 0 fail")
64
+ * and the Chinese phrasing used in evalid PR bodies ("440 项、439 通过、0 失败").
65
+ */
66
+ export function parseClaimCounts(line) {
67
+ const text = String(line || '');
68
+ const counts = {};
69
+ let m = text.match(/(\d+)\s*\/\s*(\d+)\s*(?:pass|passed|通过|✔|✅)?/);
70
+ if (m) {
71
+ counts.passed = Number(m[1]);
72
+ counts.total = Number(m[2]);
73
+ counts.failed = counts.total - counts.passed;
74
+ return counts;
75
+ }
76
+ m = text.match(/(\d+)\s*(?:tests?|items?|项)[^0-9]{0,20}?(?:all\s+pass|pass|passed|通过)/i);
77
+ if (m) counts.passed = Number(m[1]);
78
+ // No \b after 通过/失败/项: \b is an ASCII word boundary and never
79
+ // fires after a CJK character, so "439 通过" would silently not match.
80
+ m = text.match(/(\d+)\s*(?:pass|passed|通过)/i);
81
+ if (m && counts.passed === undefined) counts.passed = Number(m[1]);
82
+ // "18 tests (long parenthetical ...) — all pass": the count and the
83
+ // verdict may be far apart, so an explicit all-pass fallback reads the
84
+ // first test/item count on the line as the passed count.
85
+ if (/all\s+pass(?:ing|es)?\b/i.test(text) && counts.passed === undefined) {
86
+ const n = text.match(/(\d+)\s*(?:tests?|items?|项)/);
87
+ if (n) counts.passed = Number(n[1]);
88
+ }
89
+ m = text.match(/(\d+)\s*(?:fail|failed|failures?|失败)/i);
90
+ if (m) counts.failed = Number(m[1]);
91
+ if (/all\s+pass(?:ing|es)?\b/i.test(text) || /全部通过/.test(text)) {
92
+ if (counts.failed === undefined) counts.failed = 0;
93
+ }
94
+ m = text.match(/(\d+)\s*(?:tests?|items?|项)/);
95
+ if (m && counts.total === undefined) counts.total = Number(m[1]);
96
+ return counts;
97
+ }
98
+
99
+ function parseClaimExit(line) {
100
+ const m = String(line || '').match(/exit(?:\s+code)?\s+(\d+)/i);
101
+ return m ? Number(m[1]) : null;
102
+ }
103
+
104
+ /**
105
+ * Extract claimed test runs from free text (a PR body, commit message, or
106
+ * a --claims file). Returns one claim per distinct command, with any
107
+ * counts/exit found on the same line or in the same paragraph attached.
108
+ * A line that names a test file plus counts ("18 tests ... all pass") is
109
+ * attached to the command claim that runs that file.
110
+ */
111
+ export function extractClaims(text) {
112
+ const lines = String(text || '').split('\n');
113
+ const claims = [];
114
+ const byCommand = new Map();
115
+
116
+ function upsert(command, source, counts, exitCode) {
117
+ const key = normalizeCommand(command);
118
+ if (!key) return null;
119
+ let claim = byCommand.get(key);
120
+ if (!claim) {
121
+ claim = { command: key, claimedExit: null, claimedPassed: null, claimedFailed: null, claimedTotal: null, source };
122
+ byCommand.set(key, claim);
123
+ claims.push(claim);
124
+ }
125
+ if (counts) {
126
+ if (counts.passed !== undefined && claim.claimedPassed === null) claim.claimedPassed = counts.passed;
127
+ if (counts.failed !== undefined && claim.claimedFailed === null) claim.claimedFailed = counts.failed;
128
+ if (counts.total !== undefined && claim.claimedTotal === null) claim.claimedTotal = counts.total;
129
+ }
130
+ if (exitCode !== null && exitCode !== undefined && claim.claimedExit === null) claim.claimedExit = exitCode;
131
+ return claim;
132
+ }
133
+
134
+ // Counts/exit attach line-by-line, and within a line only within the
135
+ // same clause: one line may state two claims ("...passed 6/6 after.
136
+ // Full `npm test` passes (exit 0...)") and the first file's counts must
137
+ // not leak onto the second command. Clauses split on ". " / "; ".
138
+ // (File paths contain dots but never ". ", so this does not split them.)
139
+ for (const line of lines) {
140
+ const clauses = line.split(/(?:[.;]\s+|\s+;\s*)/);
141
+ for (const clause of clauses) {
142
+ const commands = new Set();
143
+ const spans = [...clause.matchAll(/`([^`]+)`/g)].map((span) => span[1]);
144
+ for (const span of spans) {
145
+ const found = span.match(COMMAND_TAIL);
146
+ if (found) commands.add(normalizeCommand(found[0]));
147
+ }
148
+ const bare = clause.match(COMMAND_TAIL);
149
+ if (bare) commands.add(normalizeCommand(bare[0]));
150
+ if (commands.size === 0) continue;
151
+ const counts = parseClaimCounts(clause);
152
+ const exitCode = parseClaimExit(clause);
153
+ const hasCounts = Object.keys(counts).length > 0;
154
+ for (const command of commands) {
155
+ upsert(command, clause.trim().slice(0, 200), hasCounts ? counts : null, exitCode);
156
+ }
157
+ }
158
+ }
159
+
160
+ // File-scoped counts: "test/foo.test.js: 18 tests, all pass" attaches to
161
+ // the command claim whose argv names that file. A command that runs
162
+ // several files sums their per-file counts (18 + 6 = 24 for the combined
163
+ // run), and only fills claims that have no same-line counts.
164
+ const fileSums = new Map();
165
+ for (const line of lines) {
166
+ const fileMatch = line.match(/([^\s`'"]+\.test\.[cm]?[jt]sx?|[^\s`'"]+_test\.go|[^\s`'"]+test_[^/\s`'"]*\.py)/);
167
+ if (!fileMatch) continue;
168
+ const counts = parseClaimCounts(line);
169
+ if (Object.keys(counts).length === 0) continue;
170
+ for (const claim of claims) {
171
+ if (!claim.command.includes(fileMatch[1])) continue;
172
+ const sum = fileSums.get(claim.command) || { passed: 0, failed: 0, total: 0, hasPassed: false, hasFailed: false, hasTotal: false };
173
+ if (counts.passed !== undefined) { sum.passed += counts.passed; sum.hasPassed = true; }
174
+ if (counts.failed !== undefined) { sum.failed += counts.failed; sum.hasFailed = true; }
175
+ if (counts.total !== undefined) { sum.total += counts.total; sum.hasTotal = true; }
176
+ fileSums.set(claim.command, sum);
177
+ }
178
+ }
179
+ for (const claim of claims) {
180
+ const sum = fileSums.get(claim.command);
181
+ if (!sum) continue;
182
+ if (claim.claimedPassed === null && sum.hasPassed) claim.claimedPassed = sum.passed;
183
+ if (claim.claimedFailed === null && sum.hasFailed) claim.claimedFailed = sum.failed;
184
+ if (claim.claimedTotal === null && sum.hasTotal) claim.claimedTotal = sum.total;
185
+ }
186
+
187
+ // A claim that says tests pass but states no exit code is a claim of exit 0.
188
+ for (const claim of claims) {
189
+ if (claim.claimedExit === null && (claim.claimedPassed !== null || claim.claimedFailed === 0)) {
190
+ claim.claimedExit = 0;
191
+ }
192
+ }
193
+ return claims;
194
+ }
195
+
196
+ /** Parse a unified diff: touched files, touched test files, added evalid seals. */
197
+ export function parseDiffFiles(diffText) {
198
+ const files = [];
199
+ const seals = [];
200
+ for (const line of String(diffText || '').split('\n')) {
201
+ const m = line.match(/^\+\+\+ b\/(.+)$/) || line.match(/^\+\+\+ (.+)$/);
202
+ if (!m || m[1] === '/dev/null') continue;
203
+ const path = m[1].trim();
204
+ files.push(path);
205
+ const seal = path.match(/^specs\/changes\/([^/]+)\/tdd\/([a-z]+)-(\d+)\.json$/);
206
+ if (seal) seals.push({ changeId: seal[1], phase: seal[2], attempt: Number(seal[3]), path });
207
+ }
208
+ return { files, testFiles: files.filter(isTestFile), seals };
209
+ }
210
+
211
+ /**
212
+ * Parse pass/fail counts out of a test runner's output.
213
+ * node --test ("# pass 18", "# fail 0"), pytest ("18 passed, 0 failed"),
214
+ * mocha ("18 passing"), npm-wrapped node --test (same "# pass" lines).
215
+ */
216
+ export function parseReplayCounts(output, command) {
217
+ const text = String(output || '');
218
+ // node --test prints one summary block per invocation, but multi-file
219
+ // runners (npm test -> run-tests.mjs, one node --test per file) print one
220
+ // per file. A single summary is a comparable count; several are not a
221
+ // total, so report unparsed and let the caller compare on exit code only.
222
+ const nodePasses = [...text.matchAll(/(?:#|ℹ)\s*pass\s+(\d+)/g)];
223
+ const nodeFails = [...text.matchAll(/(?:#|ℹ)\s*fail\s+(\d+)/g)];
224
+ if (nodePasses.length === 1 && nodeFails.length <= 1) {
225
+ const nodePass = Number(nodePasses[0][1]);
226
+ const nodeFail = nodeFails.length === 1 ? Number(nodeFails[0][1]) : null;
227
+ return { passed: nodePass, failed: nodeFail, total: nodeFail !== null ? nodePass + nodeFail : null };
228
+ }
229
+ if (nodePasses.length > 1 || nodeFails.length > 1) return null;
230
+ let m = text.match(/(\d+)\s+passed?(?:,\s*(\d+)\s+failed?)?/);
231
+ if (m) {
232
+ return { passed: Number(m[1]), failed: m[2] !== undefined ? Number(m[2]) : null, total: null };
233
+ }
234
+ m = text.match(/(\d+)\s+passing(?:,\s*(\d+)\s+failing)?/);
235
+ if (m) {
236
+ return { passed: Number(m[1]), failed: m[2] !== undefined ? Number(m[2]) : null, total: null };
237
+ }
238
+ if (/test[s]?\s+passed/i.test(text) && /0\s+failed/i.test(text)) return { passed: null, failed: 0, total: null };
239
+ return null;
240
+ }
241
+
242
+ function splitCommand(command) {
243
+ const parts = [];
244
+ let current = '';
245
+ let quote = null;
246
+ for (const char of String(command)) {
247
+ if (quote) {
248
+ if (char === quote) quote = null;
249
+ else current += char;
250
+ } else if (char === '"' || char === "'") {
251
+ quote = char;
252
+ } else if (/\s/.test(char)) {
253
+ if (current) { parts.push(current); current = ''; }
254
+ } else {
255
+ current += char;
256
+ }
257
+ }
258
+ if (current) parts.push(current);
259
+ return parts;
260
+ }
261
+
262
+ function runShell(cwd, argv, timeoutMs) {
263
+ const started = Date.now();
264
+ // A replay must run in a clean test-runner context: when audit itself runs
265
+ // under `node --test` (unit tests, or CI invoking it from a test file),
266
+ // NODE_TEST_CONTEXT / NODE_TEST_WORKER_ID leak into the child and make
267
+ // `node --test` refuse to run files ("called recursively within a test
268
+ // file", exit 0, zero tests). Strip them so the replay really runs.
269
+ const env = { ...process.env };
270
+ delete env.NODE_TEST_CONTEXT;
271
+ delete env.NODE_TEST_WORKER_ID;
272
+ const result = spawnSync(argv[0], argv.slice(1), { cwd, encoding: 'utf8', timeout: timeoutMs, env });
273
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
274
+ return {
275
+ exit: result.status === null ? null : result.status,
276
+ timedOut: result.status === null && result.error && /timed out/i.test(String(result.error.message || '')),
277
+ spawnError: result.error && result.status === null ? String(result.error.message || result.error) : null,
278
+ output,
279
+ outputTail: output.split('\n').slice(-40).join('\n'),
280
+ durationMs: Date.now() - started,
281
+ };
282
+ }
283
+
284
+ function replaySeal(workdir, repoRoot, seal, python, timeoutMs) {
285
+ const validator = resolve(repoRoot, 'xflow', 'atoms', 'i6b_tdd_proof_validate.py');
286
+ if (!existsSync(validator)) {
287
+ return { ...seal, ok: false, verdict: 'validator_missing', error: `validator not found: ${validator}` };
288
+ }
289
+ const result = spawnSync(python, [
290
+ validator,
291
+ '--project-root', workdir,
292
+ '--change-id', seal.changeId,
293
+ '--phase', seal.phase,
294
+ '--attempt', String(seal.attempt),
295
+ ], { cwd: workdir, encoding: 'utf8', timeout: timeoutMs });
296
+ const lines = String(result.stdout || '').split('\n').reverse();
297
+ let payload = null;
298
+ for (const line of lines) {
299
+ const trimmed = line.trim();
300
+ if (!trimmed.startsWith('{')) continue;
301
+ try {
302
+ const parsed = JSON.parse(trimmed);
303
+ if (parsed && typeof parsed.verdict === 'string') { payload = parsed; break; }
304
+ } catch { /* not the verdict line */ }
305
+ }
306
+ return {
307
+ ...seal,
308
+ ok: payload?.ok === true && result.status === 0,
309
+ verdict: payload?.verdict || 'unparseable_output',
310
+ exit: result.status,
311
+ stderr: String(result.stderr || '').slice(-500),
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Run a full audit. Options:
317
+ * repoDir — local repo to clone from (required)
318
+ * diffText — unified diff to apply on top of baseRef (or '' when headRef is checked out directly)
319
+ * baseRef — commit to check out before applying diffText
320
+ * headRef — commit to check out directly (diffText ignored; used for merged PRs)
321
+ * claimsText — PR body / claims text to extract claims from
322
+ * timeoutMs — per-command timeout (default 300_000)
323
+ * python — python binary for the i6b validator (default 'python3')
324
+ * keepWorkdir — keep the scratch clone for debugging (default false)
325
+ */
326
+ export function runAudit(options = {}) {
327
+ const {
328
+ repoDir,
329
+ diffText = '',
330
+ baseRef = null,
331
+ headRef = null,
332
+ claimsText = '',
333
+ timeoutMs = 300000,
334
+ python = process.env.PYTHON || 'python3',
335
+ keepWorkdir = false,
336
+ } = options;
337
+ if (!repoDir || !existsSync(repoDir)) {
338
+ return { ok: false, verdict: 'misuse', error: `repo not found: ${repoDir}`, exitCode: 2 };
339
+ }
340
+ if (!headRef && !baseRef) {
341
+ return { ok: false, verdict: 'misuse', error: 'one of --head or --base is required', exitCode: 2 };
342
+ }
343
+
344
+ const started = Date.now();
345
+ const claims = extractClaims(claimsText);
346
+ const diff = parseDiffFiles(diffText);
347
+ const workdir = mkdtempSync(join(tmpdir(), 'evalid-audit-'));
348
+ try {
349
+ // --no-hardlinks: /tmp may live on another filesystem than the repo,
350
+ // and a hardlinked local clone then fails with "Invalid cross-device link".
351
+ const clone = runShell(tmpdir(), ['git', 'clone', '--quiet', '--no-hardlinks', '--local', resolve(repoDir), workdir], 120000);
352
+ if (clone.exit !== 0) {
353
+ return { ok: false, verdict: 'unverifiable', error: `git clone failed: ${clone.outputTail.slice(-300)}`, claims, diff, exitCode: 1 };
354
+ }
355
+ const checkoutRef = headRef || baseRef;
356
+ const checkout = runShell(workdir, ['git', 'checkout', '--quiet', checkoutRef], 60000);
357
+ if (checkout.exit !== 0) {
358
+ return { ok: false, verdict: 'unverifiable', error: `git checkout ${checkoutRef} failed: ${checkout.outputTail.slice(-300)}`, claims, diff, exitCode: 1 };
359
+ }
360
+ if (diffText && !headRef) {
361
+ const diffFile = join(workdir, '.audit.diff');
362
+ writeFileSync(diffFile, diffText);
363
+ const apply = runShell(workdir, ['git', 'apply', '--whitespace=nowarn', diffFile], 60000);
364
+ rmSync(diffFile, { force: true });
365
+ if (apply.exit !== 0) {
366
+ return { ok: false, verdict: 'unverifiable', error: `git apply failed: ${apply.outputTail.slice(-300)}`, claims, diff, exitCode: 1 };
367
+ }
368
+ }
369
+
370
+ // Replay setup: a fresh clone has no node_modules, so a claimed command
371
+ // that imports a dependency would fail for environmental reasons, not
372
+ // because the change is broken. Install deps when package.json exists
373
+ // and node_modules is absent (npm ci with a lockfile, npm install
374
+ // without one); fall back to copying the source repo's node_modules.
375
+ // Skipped entirely when there is nothing to replay (no claims, no seals).
376
+ let setup = { deps: 'none' };
377
+ if ((claims.length > 0 || diff.seals.length > 0)
378
+ && existsSync(join(workdir, 'package.json')) && !existsSync(join(workdir, 'node_modules'))) {
379
+ const hasLock = existsSync(join(workdir, 'package-lock.json'));
380
+ const installArgs = hasLock
381
+ ? ['npm', 'ci', '--no-audit', '--no-fund']
382
+ : ['npm', 'install', '--no-audit', '--no-fund'];
383
+ const install = runShell(workdir, installArgs, 300000);
384
+ if (install.exit === 0) {
385
+ setup = { deps: hasLock ? 'npm ci' : 'npm install' };
386
+ } else if (existsSync(join(resolve(repoDir), 'node_modules'))) {
387
+ const copy = runShell(tmpdir(), ['cp', '-r', join(resolve(repoDir), 'node_modules'), join(workdir, 'node_modules')], 120000);
388
+ setup = copy.exit === 0
389
+ ? { deps: 'copied from source repo node_modules' }
390
+ : { deps: 'none', error: `${installArgs[1]} failed and node_modules copy failed` };
391
+ } else {
392
+ setup = { deps: 'none', error: `${installArgs[1]} failed: ${install.outputTail.slice(-300)}` };
393
+ }
394
+ }
395
+ if (setup.error) {
396
+ return { ok: false, verdict: 'unverifiable', error: `replay setup failed (${setup.error})`, claims, diff, setup, exitCode: 1 };
397
+ }
398
+
399
+ const claimResults = [];
400
+ let mismatch = null;
401
+ for (const claim of claims) {
402
+ const argv = splitCommand(claim.command);
403
+ if (argv.length === 0) {
404
+ claimResults.push({ ...claim, replay: null, match: false, note: 'empty command' });
405
+ mismatch = mismatch || 'empty command';
406
+ continue;
407
+ }
408
+ const replay = runShell(workdir, argv, timeoutMs);
409
+ const counts = parseReplayCounts(replay.output, claim.command);
410
+ const expectedExit = claim.claimedExit ?? 0;
411
+ let match = replay.exit === expectedExit && !replay.timedOut && !replay.spawnError;
412
+ let note = null;
413
+ if (match && counts) {
414
+ if (claim.claimedPassed !== null && counts.passed !== null && counts.passed !== claim.claimedPassed) {
415
+ match = false;
416
+ note = `passed count: claimed ${claim.claimedPassed}, replayed ${counts.passed}`;
417
+ }
418
+ if (match && claim.claimedFailed !== null && counts.failed !== null && counts.failed !== claim.claimedFailed) {
419
+ match = false;
420
+ note = `failed count: claimed ${claim.claimedFailed}, replayed ${counts.failed}`;
421
+ }
422
+ } else if (match && !counts && (claim.claimedPassed !== null || claim.claimedFailed !== null)) {
423
+ note = 'counts_unparsed: exit matches, counts could not be parsed from replay output';
424
+ }
425
+ if (replay.timedOut) note = `replay timed out after ${timeoutMs}ms`;
426
+ if (replay.spawnError) note = `could not run claimed command: ${replay.spawnError}`;
427
+ if (!match && !note) note = `exit: claimed ${expectedExit}, replayed ${replay.exit}`;
428
+ claimResults.push({ ...claim, replay: { ...replay, counts }, match, note });
429
+ if (!match) mismatch = mismatch || note || 'claim mismatch';
430
+ }
431
+
432
+ const sealResults = diff.seals.map((seal) => replaySeal(workdir, resolve(repoDir), seal, python, timeoutMs));
433
+ const badSeal = sealResults.find((seal) => !seal.ok);
434
+
435
+ let verdict;
436
+ let exitCode;
437
+ if (claims.length === 0 && sealResults.length === 0) {
438
+ verdict = 'no_claims';
439
+ exitCode = 3;
440
+ } else if (mismatch || badSeal) {
441
+ verdict = 'mismatch';
442
+ exitCode = 1;
443
+ } else if (claimResults.some((entry) => entry.replay && (entry.replay.timedOut || entry.replay.spawnError))) {
444
+ verdict = 'unverifiable';
445
+ exitCode = 1;
446
+ } else {
447
+ verdict = 'verified';
448
+ exitCode = 0;
449
+ }
450
+
451
+ return {
452
+ ok: verdict === 'verified',
453
+ verdict,
454
+ exitCode,
455
+ claims: claimResults,
456
+ seals: sealResults,
457
+ files: diff.files,
458
+ testFiles: diff.testFiles,
459
+ setup,
460
+ workdir: keepWorkdir ? workdir : null,
461
+ durationMs: Date.now() - started,
462
+ };
463
+ } finally {
464
+ if (!keepWorkdir) rmSync(workdir, { recursive: true, force: true });
465
+ }
466
+ }
467
+
468
+ export function auditExitCode(verdict) {
469
+ return { verified: 0, mismatch: 1, unverifiable: 1, misuse: 2, no_claims: 3 }[verdict] ?? 1;
470
+ }
471
+
472
+ export function renderAuditReport(report) {
473
+ const lines = [`xflow audit: ${report.verdict}`];
474
+ if (report.error) lines.push(`error: ${report.error}`);
475
+ if (report.setup?.deps && report.setup.deps !== 'none') lines.push(`replay deps: ${report.setup.deps}`);
476
+ if (report.files) {
477
+ lines.push(`files touched: ${report.files.length} (${report.testFiles?.length ?? 0} test files)`);
478
+ for (const file of report.testFiles || []) lines.push(` test: ${file}`);
479
+ }
480
+ for (const claim of report.claims || []) {
481
+ const replay = claim.replay;
482
+ const replayText = replay
483
+ ? `replay exit ${replay.exit}${replay.counts && replay.counts.passed !== null ? `, pass ${replay.counts.passed}` : ''}${replay.counts && replay.counts.failed !== null ? `, fail ${replay.counts.failed}` : ''}`
484
+ : 'not replayed';
485
+ const claimedText = [
486
+ claim.claimedExit !== null ? `exit ${claim.claimedExit}` : null,
487
+ claim.claimedPassed !== null ? `${claim.claimedPassed} passed` : null,
488
+ claim.claimedFailed !== null ? `${claim.claimedFailed} failed` : null,
489
+ ].filter(Boolean).join(', ') || 'no counts stated';
490
+ lines.push(`- ${claim.match ? '✓' : '✗'} ${claim.command} (claimed ${claimedText}; ${replayText})${claim.note ? ` — ${claim.note}` : ''}`);
491
+ }
492
+ for (const seal of report.seals || []) {
493
+ lines.push(`- ${seal.ok ? '✓' : '✗'} seal ${seal.changeId}/${seal.phase}/${seal.attempt}: ${seal.verdict}`);
494
+ }
495
+ if (report.durationMs !== undefined) lines.push(`duration: ${(report.durationMs / 1000).toFixed(1)}s`);
496
+ return lines.join('\n');
497
+ }