dsh-vibe-math 2.4.1 → 2.6.0

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.
Files changed (46) hide show
  1. package/README.en.md +53 -14
  2. package/README.md +54 -14
  3. package/cordis.patch.yml +54 -17
  4. package/docs/AUDIT-CHECKLIST.md +26 -7
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/final-paper.md +171 -0
  7. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
  9. package/docs/test-timing.md +77 -40
  10. package/installer.js +225 -45
  11. package/package.json +6 -3
  12. package/preset-declaration.js +38 -7
  13. package/prompt-corpus-persona/persona-corpus.json +13 -13
  14. package/prompt-corpus-persona/persona-corpus.md +113 -39
  15. package/prompt-corpus-v2/formal-verify-v2.json +128 -92
  16. package/prompt-corpus-v2/formal-verify-v2.md +380 -142
  17. package/prompt-corpus-v3/formal-verify-v3.json +117 -81
  18. package/prompt-corpus-v3/formal-verify-v3.md +693 -1189
  19. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  20. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  21. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  22. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  23. package/tests/audit-installer-compat.test.mjs +70 -4
  24. package/tests/audit-installer-policy.test.mjs +229 -4
  25. package/tests/audit-persona-sensitivity.mjs +68 -8
  26. package/tests/audit-persona-surface.test.mjs +21 -8
  27. package/tests/audit-v5-sensitivity.mjs +47 -6
  28. package/tests/e2e-v5-round2.test.mjs +825 -1
  29. package/tests/formal-verify-v2.test.mjs +57 -2
  30. package/tests/formal-verify-v3.test.mjs +67 -2
  31. package/tests/formal-verify-v4.test.mjs +120 -4
  32. package/tests/prompt-v5-integrity.test.mjs +9 -3
  33. package/tests/run-tests.mjs +104 -23
  34. package/tests/selfdrive-v5.mjs +55 -4
  35. package/vibe-math-v2/agent.cordis.yml +20 -4
  36. package/vibe-math-v2/vibe-math-v2.js +1318 -106
  37. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  38. package/vibe-math-v3/agent.cordis.yml +36 -14
  39. package/vibe-math-v3/vibe-math-v3.js +1348 -179
  40. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  41. package/vibe-math-v4/agent.cordis.yml +36 -8
  42. package/vibe-math-v4/vibe-math-v4.js +1195 -57
  43. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  44. package/vibe-math-v5/agent.cordis.yml +16 -8
  45. package/vibe-math-v5/vibe-math-v5.js +1628 -112
  46. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +143 -41
@@ -184,7 +184,11 @@ function makeHost(WS, opts = {}) {
184
184
  }
185
185
 
186
186
  function makeRoot(id, WS) {
187
- return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, followup() {} }
187
+ // `pushed` records the framework's OWN messages to the main agent (`rootAgent.followup`), i.e. the
188
+ // reportMode=push progress report. It used to be a no-op, which is why that channel had no corpus
189
+ // entry at all (AUDIT §2.4 "框架→人 的反馈与推送类提示").
190
+ const pushed = []
191
+ return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, pushed, followup(m) { const c = (m && m.content) || []; pushed.push(c.map((b) => (b && b.text) || '').join('')) } }
188
192
  }
189
193
 
190
194
  const hosts = []
@@ -1240,6 +1244,44 @@ section("15b a \`used\` reply must NOT downgrade an already-passed object")
1240
1244
  }
1241
1245
  }
1242
1246
 
1247
+ // ---------- 15c. the framework's OWN feedback channels (push report + gate notice) ----------
1248
+ // Every corpus entry so far was a child prompt (spawn/wake). The framework also speaks to the main
1249
+ // agent/human on two other channels — the `reportMode=push` progress report and the activity log's
1250
+ // formal-gate notice — and neither had any reviewable artefact (AUDIT §2.4 "框架→人 的反馈与推送类
1251
+ // 提示"). Both are captured DETERMINISTICALLY (no wall-clock, no timing-dependent counts):
1252
+ // · push = the forced push of the strict-termination branch on an EMPTY project (fixed counters);
1253
+ // · feedback = the FIRST formal-gate activity line of one controlled require-mode deferral.
1254
+ section('15c the framework push report and the gate notice are captured')
1255
+ {
1256
+ const h = await makeCase('push-report')
1257
+ await h.call('vibe_math_set_params', { reportMode: 'push', tickIntervalMs: 200 })
1258
+ await h.call('vibe_math_start', {})
1259
+ const pushed = await waitFor(() => (h.root.pushed && h.root.pushed.length > 0 ? h.root.pushed : undefined), 80, 100)
1260
+ assert(!!pushed, 'push-report: the scheduler pushed a progress report to the main agent')
1261
+ assert(!!pushed && /\[Vibe Math V2\] 进度更新/.test(pushed[0]), '★★ the push text is the real framework→main-agent prompt (reportMode=push)')
1262
+ assert(!!pushed && /vibe_math_report/.test(pushed[0]), 'and it names the FULL tool the main agent must call')
1263
+ await h.call('vibe_math_pause', {})
1264
+
1265
+ const g = await makeCase('gate-feedback')
1266
+ await g.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1267
+ await g.call('vibe_math_add_proposition', { id: 'pFeed', 概述: '门禁反馈语料', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1268
+ await startScheduler(g)
1269
+ const gvs = await waitFor(() => { const x = verifiersOf(g, 'r-pFeed'); return x.length >= 2 ? x : undefined }, 60, 250)
1270
+ assert(!!gvs, 'gate-feedback: verifiers were spawned for the proposition')
1271
+ if (gvs) for (let i = 0; i < gvs.length && i < 2; i++) replyFrom(g, gvs[i].childId, { Result: 1, Reason: '门禁反馈用例' })
1272
+ let gline
1273
+ for (let i = 0; i < 60 && !gline; i++) {
1274
+ const acts = (await g.call('vibe_math_status', {})).recentActivity || []
1275
+ const hit = acts.find((a) => /formal-gate/.test(String(a.event)))
1276
+ if (hit) gline = String(hit.event) + ': ' + String(hit.detail)
1277
+ else await sleep(150)
1278
+ }
1279
+ assert(!!gline && /require 模式搁置/.test(gline), '★★ the require-gate withholding reaches the human-readable channel (captured verbatim)')
1280
+ assert(!!gline && /Formal\/TODO\.md/.test(gline), 'and the notice says where the withholding was recorded')
1281
+ g.feedbackLine = gline || ''
1282
+ await g.call('vibe_math_pause', {})
1283
+ }
1284
+
1243
1285
  section('16 the captured prompt corpus is written for human review')
1244
1286
  {
1245
1287
  // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
@@ -1253,6 +1295,9 @@ section('16 the captured prompt corpus is written for human review')
1253
1295
  .split(ws + '\\VibeMath').join('<VIBEMATH>')
1254
1296
  .split(slash).join('<WS>')
1255
1297
  .split(ws).join('<WS>')
1298
+ // Epoch-ms in a verification-log FILE NAME is run metadata: the final-paper material embeds the
1299
+ // evidence index verbatim, so it must be normalised or the shipped corpus changes every run.
1300
+ .replace(/_[0-9]{10,16}\.json/g, '_<TIME>.json')
1256
1301
  }
1257
1302
  const entries = []
1258
1303
  for (const h of hosts) {
@@ -1261,6 +1306,13 @@ section('16 the captured prompt corpus is written for human review')
1261
1306
  const owner = h.spawns.find((s) => s.childId === f.childId)
1262
1307
  entries.push({ kind: 'wake', case: h.label, label: owner ? owner.label : f.childId, prompt: scrub(h, f.prompt) })
1263
1308
  }
1309
+ // The framework's own messages to the main agent (reportMode=push). Only the FIRST push per case is
1310
+ // kept: whether a second identical push follows depends on tick/reportDirty timing, and the corpus
1311
+ // must stay byte-identical across runs.
1312
+ if (h.root && Array.isArray(h.root.pushed) && h.root.pushed.length > 0) entries.push({ kind: 'push', case: h.label, label: 'main-agent', prompt: scrub(h, h.root.pushed[0]) })
1313
+ // The framework's human-readable feedback channel: the activity-log line that carries the require
1314
+ // gate's withholding notice. It is captured once, in case 15c, for determinism.
1315
+ if (typeof h.feedbackLine === 'string' && h.feedbackLine) entries.push({ kind: 'feedback', case: h.label, label: 'activity-log', prompt: scrub(h, h.feedbackLine) })
1264
1316
  }
1265
1317
  mkdirSync(CORPUS_DIR, { recursive: true })
1266
1318
  writeFileSync(join(CORPUS_DIR, 'formal-verify-v2.json'), JSON.stringify({ entries: entries }, null, 2), 'utf8')
@@ -1268,7 +1320,8 @@ section('16 the captured prompt corpus is written for human review')
1268
1320
  '> 由 `formal-verify-v2.test.mjs` 落盘:框架**真正发出**的每一条提示词原文。',
1269
1321
  '> 工作区路径归一化为 `<WS>`、VibeMath 根归一化为 `<VIBEMATH>`,因此可 diff、不泄露本机路径。',
1270
1322
  '> 覆盖:off 档(无任何 Lean 文字)、encourage 与 require 的表决初评/辩论、passed 后的忠实性分支、',
1271
- '> 以及平时工作轮的「顺手形式化」段落与 formal 回执契约。', '']
1323
+ '> 平时工作轮的「顺手形式化」段落与 formal 回执契约,以及框架自己的两条反馈通道:',
1324
+ '> `reportMode=push` 的推送汇报(kind=push)与 require 门禁搁置的活动日志公告(kind=feedback)。', '']
1272
1325
  for (let i = 0; i < entries.length; i++) {
1273
1326
  const e = entries[i]
1274
1327
  md.push('## [' + i + '] ' + e.kind + ' · ' + e.label + ' · case=' + e.case)
@@ -1288,6 +1341,8 @@ section('16 the captured prompt corpus is written for human review')
1288
1341
  assert(entries.some((e) => /【顺手形式化/.test(e.prompt) && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(e.prompt)), 'the corpus contains the formal reply contract line')
1289
1342
  assert(entries.some((e) => !/Lean|形式化/.test(e.prompt)), 'the corpus contains off-mode prompts with no Lean text at all')
1290
1343
  assert(entries.some((e) => e.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')
1344
+ assert(entries.some((e) => e.kind === 'push' && /\[Vibe Math V2\] 进度更新/.test(e.prompt)), '★ the corpus carries the framework\'s PUSH report (reportMode=push)')
1345
+ assert(entries.some((e) => e.kind === 'feedback' && /require 模式搁置/.test(e.prompt)), '★ and the require-gate withholding notice (the framework→human feedback channel)')
1291
1346
  // Generic sweeps over EVERY captured prompt, not spot checks (AUDIT §2.1).
1292
1347
  const joined = entries.map((e) => e.prompt).join('\n')
1293
1348
  const bare = entries.filter((e) => /(^|[^a-z_])lean_(run|archive|lib)/.test(e.prompt))
@@ -72,15 +72,51 @@ const corpus = []
72
72
  // same class of bug is recorded in AUDIT-CHECKLIST §2.4 as a real accident): the random plan id in
73
73
  // the label, epoch-millisecond `at` timestamps, child ids, and the free-slot count. They are run
74
74
  // metadata, not the text under review, so they are normalised to placeholders.
75
- const scrub = (s) => String(s == null ? '' : s)
75
+ //
76
+ // The brief is not only run metadata, it is a LIVE SNAPSHOT of mutable scheduler state
77
+ // (`buildBrief()` reads `agentRegistry`, `activityLog`, the verify candidates and the free-slot
78
+ // count at the instant the planner is spawned). This suite drives the plugin from OUTSIDE, and the
79
+ // plugin's own scheduler keeps running concurrently: `vibe_math_start` fires `scheduleTick()`
80
+ // without awaiting it, and the plugin's 1s tick timer calls `scheduleTick()` on its own schedule, so
81
+ // a background tick can register the explorer (or append its activity event, or close a verify)
82
+ // BETWEEN the forced planner's snapshot and the next one. Two runs then legitimately disagree about
83
+ // `active_agents` (the explorer is in flight or it is not), about `recent_events` (the last-eight
84
+ // window and its order), and about every other instant-dependent field of the brief.
85
+ //
86
+ // A byte-stable corpus therefore cannot carry that snapshot's values — the disagreement is a
87
+ // property of the INSTANT, not of the prompt text a human must review (this is exactly the "语料
88
+ // 每次运行都变" accident §2.4 records). What the corpus carries is the brief's CONTRACT: the same
89
+ // keys in the same order, with the instant-dependent values canonically placeholdered and the
90
+ // stable configuration scalars kept. The prompt TEXT under review — the persona, the ACTION
91
+ // VOCABULARY, and the HARD RULES that name `brief.problems[].running_solver_dirs`,
92
+ // `brief.active_agents` and `brief.free_slots` — is untouched.
93
+ const SNAPSHOT_KEYS = ['problems', 'verify_candidates', 'active_agents', 'methods', 'pending_inventions', 'last_plan', 'recent_events']
94
+ /** Replace every instant-dependent collection of the planner's state brief with a canonical
95
+ * placeholder, keeping the key set, the key order and the configuration scalars. Only called on a
96
+ * valid-JSON brief (`buildBrief` is serialised with JSON.stringify(brief, null, 2)); anything that
97
+ * does not match the exact planner framing is returned unchanged, so the suite can never silently
98
+ * mangle an unrelated prompt. */
99
+ const canonBrief = (s) => {
100
+ const m = s.match(/(CURRENT STATE BRIEF \(JSON\):\n)(\{[\s\S]*?\n\})(\n\nACTION VOCABULARY)/)
101
+ if (!m) return s
102
+ let brief
103
+ try { brief = JSON.parse(m[2]) } catch (e) { return s }
104
+ for (const k of SNAPSHOT_KEYS) if (Object.prototype.hasOwnProperty.call(brief, k)) brief[k] = '<' + k.toUpperCase() + '>'
105
+ return s.slice(0, m.index) + m[1] + JSON.stringify(brief, null, 2) + m[3] + s.slice(m.index + m[0].length)
106
+ }
107
+ const scrub = (s) => canonBrief(String(s == null ? '' : s)
76
108
  .split(VIBE).join('<VIBEMATH>')
77
109
  .split(VIBE.replace(/\\/g, '/')).join('<VIBEMATH>')
78
110
  .split(WS).join('<WS>')
79
- .split(WS.replace(/\\/g, '/')).join('<WS>')
111
+ .split(WS.replace(/\\/g, '/')).join('<WS>'))
80
112
  .replace(/plan-[0-9a-f]{8}/g, 'plan-<ID>')
81
113
  .replace(/("at"\s*:\s*)\d{10,16}/g, '$1"<TIME>"')
82
114
  .replace(/("childId"\s*:\s*")c\d+(")/g, '$1<CHILD>$2')
83
115
  .replace(/("free_slots"\s*:\s*)\d+/g, '$1<SLOTS>')
116
+ // Epoch-ms in a verification-log FILE NAME (`Logs/Verification/<rId>_<Date.now()>.json`) is run
117
+ // metadata too: the final-paper material embeds the evidence index verbatim, so without this the
118
+ // shipped corpus changed on every run (contract §10 item 10 demands byte determinism).
119
+ .replace(/_[0-9]{10,16}\.json/g, '_<TIME>.json')
84
120
 
85
121
  // A fake Lean: a file PASSES unless it still contains `sorry` or the marker `-- FAIL`.
86
122
  // `-- HANG` simulates a toolchain that never returns (the timeout path).
@@ -1246,6 +1282,35 @@ section('10 the captured prompt corpus is written for human review')
1246
1282
  assert(volatile.length === 0, '★ no captured prompt/label keeps volatile run metadata (random plan id / epoch timestamps / child ids / slot count) — the corpus is byte-deterministic: ' + volatile.map((b) => b.label).join(','))
1247
1283
  assert(corpus.some((c) => /"free_slots": <SLOTS>/.test(c.prompt)) && corpus.some((c) => /plan-<ID>/.test(c.label)),
1248
1284
  '★ and the normalisation actually fired (a planner brief and its plan id were captured)')
1285
+ // The brief's instant-dependent snapshot is placeholdered, but its CONTRACT (key set + order) and
1286
+ // the stable configuration scalars stay, and the instruction text that references the brief is
1287
+ // untouched — so the human reviewer still sees the real planner prompt, not a blank.
1288
+ const briefEntries = corpus.filter((c) => /CURRENT STATE BRIEF \(JSON\):/.test(c.prompt))
1289
+ assert(briefEntries.length >= 3 && briefEntries.every((c) => SNAPSHOT_KEYS.every((k) => c.prompt.indexOf('"' + k + '": "<' + k.toUpperCase() + '>"') !== -1)),
1290
+ '★ every captured planner brief keeps all ' + SNAPSHOT_KEYS.length + ' contract keys with canonically placeholdered snapshot values (' + briefEntries.length + ' briefs)')
1291
+ assert(briefEntries.every((c) => /"horizon": \d+/.test(c.prompt) && /"free_slots": <SLOTS>/.test(c.prompt) && /"maxParallelThreshold": \d+/.test(c.prompt)),
1292
+ '★ the brief keeps its real configuration scalars (horizon / free_slots / maxParallelThreshold), not just placeholders')
1293
+ assert(briefEntries.every((c) => /brief\.problems\[\]\.running_solver_dirs and brief\.active_agents/.test(c.prompt) && /ACTION VOCABULARY/.test(c.prompt)),
1294
+ '★ and the planner INSTRUCTION text under review (ACTION VOCABULARY + the HARD RULES naming brief fields) is verbatim')
1295
+ {
1296
+ // Direct witness of the race the normaliser exists for: the two shapes a background tick can
1297
+ // produce (explorer registered before vs. after the forced planner read the brief, activity log
1298
+ // one entry longer, free slots one lower) must normalise to ONE byte-identical text. Without
1299
+ // this the corpus could only be checked by hoping the race fires.
1300
+ const briefText = (agents, events, slots, horizon) => 'PERSONA\nCURRENT STATE BRIEF (JSON):\n' + JSON.stringify({
1301
+ at: 1759200000000, horizon: horizon, free_slots: slots, maxParallelThreshold: 64,
1302
+ problems: [{ id: 'qE', 状态: '求解中', 方向数: agents.length, 活跃方向: agents.length ? ['d1'] : [], running_solver_dirs: [], 最高存活率: agents.length ? 0.7 : null, 解法数: 0 }],
1303
+ verify_candidates: agents.length ? [{ rId: 'r-qE', kind: 'problem', target: 'qE', prob: 0.5, priority: 1 }] : [],
1304
+ active_agents: agents, methods: [], pending_inventions: 0, last_plan: null, recent_events: events,
1305
+ }, null, 2) + '\n\nACTION VOCABULARY:\n- x'
1306
+ const withExplorer = briefText([{ childId: 'c7', role: 'explorer', target: 'qE', direction: '', round: '' }],
1307
+ [{ at: 1759200000001, event: 'start', detail: 'scheduler started' }, { at: 1759200000002, event: 'explorer', detail: 'problem qE explorer spawned (plan)' }], 63, 5)
1308
+ const withoutExplorer = briefText([], [{ at: 1759200000009, event: 'plan', detail: 'planner plan-abcdef12 called with 1 problem(s)' }], 64, 5)
1309
+ assert(scrub(withExplorer) === scrub(withoutExplorer),
1310
+ '★ the planner-brief normaliser maps the two racing snapshots (explorer present/absent, different activity order and free slots) to ONE byte-identical text')
1311
+ assert(scrub(withExplorer) !== withExplorer && /"active_agents": "<ACTIVE_AGENTS>"/.test(scrub(withExplorer)),
1312
+ '★ and it really rewrote the snapshot (the test above cannot pass just because nothing was normalised)')
1313
+ }
1249
1314
  }
1250
1315
 
1251
1316
  console.log('')
@@ -965,6 +965,11 @@ const K = await establish()
965
965
  const scrub = (s) => String(s == null ? '' : s)
966
966
  .split(vibe).join('<VIBEMATH>').split(vibe.replace(/\\/g, '/')).join('<VIBEMATH>')
967
967
  .split(K.WS).join('<WS>').split(K.WS.replace(/\\/g, '/')).join('<WS>')
968
+ // `propose_task` mints `t-<8 hex>` from Math.random (vibe-math-v4.js:1317 + shortId :2582).
969
+ // The task-frame entry embeds that id, so without this the SHIPPED corpus changed on every
970
+ // run (AUDIT-CHECKLIST §2.4: two consecutive runs must hash identically) and `git status`
971
+ // always showed prompt-corpus-v4/* as modified. Normalise it like the machine paths.
972
+ .replace(/\bt-[0-9a-f]{8}\b/g, '<TASKID>')
968
973
  const corpus = []
969
974
  const add = (kind, label, prompt) => corpus.push({ kind, label, prompt: scrub(prompt) })
970
975
  // (a) off: a TRUE no-op must be visible in the corpus, not merely asserted
@@ -1008,6 +1013,91 @@ const K = await establish()
1008
1013
  // (h) the tool hints are injected text too (docs §6 hard requirement 1)
1009
1014
  add('hint', 'lean_run hint (green)', (await K.callTool('vibe_v4_lean_run', { file: 'Formal/p-corpus-passed.lean' }, k1)).hint)
1010
1015
  add('hint', 'lean_lib hint', (await K.callTool('vibe_v4_lean_lib', {}, k1)).hint)
1016
+ // (i) ── THE NON-FORMAL SURFACES (audit M6/F15) ─────────────────────────────────────────────
1017
+ // The corpus used to cover ONLY Lean/formal variants, so the meeting prompt — this preset's ONLY
1018
+ // group-chat / task-allocation / stop-vote carrier — and every message frame had NO reviewable
1019
+ // artefact at all: a defect in `meetingPrompt` (e.g. its stop-vote contract) could not be read by
1020
+ // a human reviewer. These entries close that gap. `st` is passed explicitly so the capture does
1021
+ // not depend on the scheduler's state, and both the meeting's two perspectives (first speaker with
1022
+ // no prior speech / a later speaker seeing a relayed contribution) are captured.
1023
+ {
1024
+ const st = {
1025
+ id: 'mt-corpus', agenda: '分工与是否需要验证', type: 'general', targetId: null, round: 0, asked: [], order: ['r-1', 'r-2'],
1026
+ inputs: { 'r-1': { input: '我建议先验证 p-corpus,并认领引理 A 的整理。', voteSolved: false, propose_verify: 'p-corpus', propose_task: '整理引理 A', task_desc: '把已有引理写成可复用形式', claim_task: null } },
1027
+ transcript: [], at: Date.now(), lastInputAt: Date.now(),
1028
+ }
1029
+ add('meeting', 'meeting/first speaker (no prior speech)', await K.prompts('meeting', 'r-2', { meeting: { agenda: st.agenda, type: st.type, inputs: {}, order: st.order } }))
1030
+ add('meeting', 'meeting/later speaker (others relayed)', await K.prompts('meeting', 'r-2', { meeting: st }))
1031
+ }
1032
+ // (i) ── the private-message frame ──────────────────────────────────────────────────────────────
1033
+ // A resident reads a message in TWO shapes, and both must be reviewable:
1034
+ // · `[MESSAGE from <sender>] <content>` — the frame `deliverNextMailbox` prepends when it wakes an
1035
+ // idle recipient with a QUEUED message (captured here from a REAL delivery, not re-typed);
1036
+ // · `[<sender>] <content>` — the inbox line every prompt's "New items:" block renders.
1037
+ // r-1 is put in flight first (a message to an IDLE resident is delivered immediately as its own
1038
+ // wake instead of queueing), so the second message is queued and the prompt shows the inbox line.
1039
+ {
1040
+ const before = K.followups.length
1041
+ await K.callTool('vibe_v4_message', { to: 'r-1', content: '先做一轮常规工作。' })
1042
+ for (let i = 0; i < 200 && K.followups.length === before; i++) await sleep(10)
1043
+ const inFlight = K.followups[K.followups.length - 1]
1044
+ assert(!!inFlight, 'corpus: r-1 is in flight so the next message is QUEUED (mailbox frame prerequisite)')
1045
+ await K.callTool('vibe_v4_message', { to: 'r-1', content: '请优先核对引理 B 的假设条件。' })
1046
+ add('work', 'normal + queued inbox line ([<sender>] content)', await K.prompts('normal', 'r-1'))
1047
+ K.fireEnd(inFlight.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1048
+ await sleep(60)
1049
+ }
1050
+ // the REAL delivery frames. A message that arrives while its recipient is idle is delivered as a
1051
+ // wake whose body is `<ordinary round>\n\n[MESSAGE from <sender>]\n<content>`, and a message that
1052
+ // was relayed by a teammate (`relayToGroup` posts it with the sender's own id and the `[群聊]`
1053
+ // prefix — the exact same `postMessage` path the human messenger uses) renders as `[群聊] …`.
1054
+ // Each frame is captured from a REAL delivery on its own host, so the corpus text is generated by
1055
+ // the code under review rather than re-typed here.
1056
+ const deliveredFrame = async (content, sender) => {
1057
+ const L = await establish()
1058
+ await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 }) // keep the heartbeat out of the way
1059
+ const before = L.followups.length
1060
+ await (sender === 'facilitator'
1061
+ ? L.callTool('vibe_v4_message', { to: 'r-1', content })
1062
+ : L.callTool('vibe_v4_send_message', { to: 'r-1', content }, L.resAgent(L.childOf(sender))))
1063
+ let hit = null
1064
+ for (let i = 0; i < 300 && !hit; i++) { hit = L.followups.slice(before).find((f) => /\[(NEW )?MESSAGE from /.test(promptOf(f))) || null; if (!hit) await sleep(10) }
1065
+ assert(!!hit, 'corpus: a real delivery wake carrying "' + content.slice(0, 16) + '…" was produced (saw ' + L.followups.slice(before).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1066
+ if (hit) L.fireEnd(hit.childId, { summary: '收到。', solved: false, contextPct: 20 })
1067
+ await sleep(30)
1068
+ return hit ? promptOf(hit) : ''
1069
+ }
1070
+ add('work', 'normal + delivered message frame ([NEW MESSAGE from ...])', await deliveredFrame('请优先核对引理 B 的假设条件。', 'facilitator'))
1071
+ // the group-chat frame: the recipient's inbox line for a message RELAYED by a teammate
1072
+ // (`relayToGroup` pushes `[群聊] <text>` into every other resident's mailbox with the speaker's own
1073
+ // id, and the next delivery renders `[<sender>] [群聊] <text>` — the same `postMessage` path, run
1074
+ // with the sender the resident actually sees). Deterministic on the same host: the speaker's own
1075
+ // end handler is what triggers the relay, and reading the recipient's next prompt shows it.
1076
+ {
1077
+ const L = await establish()
1078
+ await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
1079
+ const before = L.followups.length
1080
+ await L.callTool('vibe_v4_message', { to: 'r-2', content: '先做一轮常规工作。' })
1081
+ for (let i = 0; i < 300 && L.followups.length === before; i++) await sleep(10)
1082
+ const speaker = L.followups[L.followups.length - 1]
1083
+ assert(!!speaker, 'corpus: the relaying resident is in flight')
1084
+ const mark = L.followups.length
1085
+ L.fireEnd(speaker.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1086
+ let relay = null
1087
+ for (let i = 0; i < 300 && !relay; i++) { relay = L.followups.slice(mark).find((f) => /群聊/.test(promptOf(f))) || null; if (!relay) await sleep(10) }
1088
+ assert(!!relay, 'corpus: the relayed group-chat message was really delivered (saw ' + L.followups.slice(mark).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1089
+ add('work', 'normal + group-chat frame ([群聊])', relay ? promptOf(relay) : '')
1090
+ if (relay) L.fireEnd(relay.childId, { summary: '收到。', solved: false, contextPct: 20 })
1091
+ await sleep(30)
1092
+ }
1093
+ // the task-claim frame. The task is claimed while its claimer is idle, so the claim wake carries
1094
+ // `[YOU CLAIMED TASK …]`; the corpus entry is the ordinary round plus that exact frame.
1095
+ {
1096
+ const pt = await K.callTool('vibe_v4_propose_task', { title: '核对引理 B 的假设', description: '确认 n≥1 是否必要' })
1097
+ assert(pt.ok === true && !!pt.id, 'corpus: a task was proposed for the task-frame entry')
1098
+ const claimed = await K.callTool('vibe_v4_claim_task', { id: pt.id }, k1)
1099
+ add('work', 'task frame ([YOU CLAIMED TASK ...])', claimed.ok === true ? (await K.prompts('normal', 'r-1')) + '\n\n[YOU CLAIMED TASK ' + pt.id + '] 核对引理 B 的假设 — 确认 n≥1 是否必要' : '')
1100
+ }
1011
1101
 
1012
1102
  // The corpus is REGENERATED on every run. Dumping it while V4_PLUGIN points at a MUTATED copy
1013
1103
  // would let a sensitivity probe overwrite the SHIPPED corpus with mutated text (the probe's job
@@ -1018,13 +1108,16 @@ const K = await establish()
1018
1108
  if (writeCorpus) {
1019
1109
  mkdirSync(CORPUS_DIR, { recursive: true })
1020
1110
  writeFileSync(join(CORPUS_DIR, 'formal-verify-v4.json'), JSON.stringify({ entries: corpus }, null, 2), 'utf8')
1021
- const md = ['# V4 形式化验证交互语料(prompt corpus)', '',
1022
- '> 由 `formal-verify-v4.test.mjs` 落盘:非 `off` 模式下常驻**真正会读到**的 Lean 提示词原文',
1111
+ const md = ['# V4 交互语料(prompt corpus)', '',
1112
+ '> 由 `formal-verify-v4.test.mjs` 落盘:常驻**真正会读到**的提示词原文',
1023
1113
  '> (`vibe_v4_prompts` 的只读回显 + 一条真实投递的工作轮 + 工具 `hint`)。',
1024
- '> 工作区路径归一化为 `<WS>`,VibeMath 根归一化为 `<VIBEMATH>`:确定、可 diff、不含任何本机路径。', '',
1114
+ '> 工作区路径归一化为 `<WS>`,VibeMath 根归一化为 `<VIBEMATH>`,随机任务号归一化为',
1115
+ '> `<TASKID>`(`propose_task` 的 `t-<8 位 hex>` 来自 `Math.random`):确定、可 diff、不含任何本机路径或随机数。', '',
1025
1116
  '> 覆盖:`off`(无 Lean 文本)、`encourage`、**`require`**、对象 `passed` 后的**忠实性分支**',
1026
1117
  '> (`encourage` / `require` 两种措辞各一份:只有 `require` 会声称"不定论")、',
1027
- '> `blocked` 分支、平时工作轮的「顺手形式化」,以及回执契约里的 `formal` 字段。', '']
1118
+ '> `blocked` 分支、平时工作轮的「顺手形式化」,以及回执契约里的 `formal` 字段;',
1119
+ '> **以及非形式化的交互面**:会议提示(首位发言者 / 已看到他人发言者各一份,含停止表决契约)、',
1120
+ '> 私信框头 `[MESSAGE from …]`、群聊框头 `[群聊]`、认领任务框头 `[YOU CLAIMED TASK …]`。', '']
1028
1121
  for (let i = 0; i < corpus.length; i++) {
1029
1122
  const c = corpus[i]
1030
1123
  md.push('## [' + i + '] ' + c.kind + ' · ' + c.label)
@@ -1049,8 +1142,31 @@ const K = await establish()
1049
1142
  assert(/【顺手形式化(强制)】/.test(byLabel('require/normal').prompt), 'the corpus carries the ordinary work-round line')
1050
1143
  assert(/"decision":"used\|blocked\|defect"/.test(byLabel('formal reply contract (voting prompt)').prompt), '★ the corpus carries the `formal` reply contract line with decision=defect')
1051
1144
  assert(/【顺手形式化/.test(byLabel('require/real work wake').prompt), 'the corpus also keeps a prompt the framework REALLY delivered')
1145
+ // ---- the non-formal interaction surfaces (audit M6 / F15) --------------------------------
1146
+ // The corpus used to be Lean-only, so `meetingPrompt` (the ONLY group-chat / allocation /
1147
+ // stop-vote carrier this preset has) had no reviewable artefact. These assertions pin both the
1148
+ // coverage and the CONTENT of the stop-vote contract that `finalizeMeeting` now enforces.
1149
+ {
1150
+ const first = byLabel('meeting/first speaker (no prior speech)')
1151
+ const later = byLabel('meeting/later speaker (others relayed)')
1152
+ assert(!!first && !!later, '★ the corpus carries the MEETING prompt (two perspectives: no prior speech / others already relayed)')
1153
+ assert(/meeting is in progress/.test(first.prompt) && /meeting is in progress/.test(later.prompt), 'the meeting entries are built by the real meeting prompt builder')
1154
+ assert(/目前还没人发言/.test(first.prompt), 'the first-speaker entry shows the "nobody has spoken yet" branch')
1155
+ assert(/已有发言(他人 input,已转发给你)/.test(later.prompt) && /我建议先验证 p-corpus/.test(later.prompt), '★ the later-speaker entry carries a teammate contribution VERBATIM (the group-chat relay is reviewable)')
1156
+ assert(/\[r-1\]/.test(later.prompt) && !/\[r-2\]/.test(later.prompt), 'the relayed-speech list excludes the reader and names the real sender id')
1157
+ assert(/voteSolved/.test(first.prompt) && /停止表决必须是绝对票/.test(first.prompt), '★ the meeting prompt states the stop-vote contract the code enforces (every speaker must answer true)')
1158
+ assert(!!byLabel('normal + queued inbox line ([<sender>] content)') && /\[facilitator(框架\/人类信使/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt) && /请优先核对引理 B 的假设条件。/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the corpus carries the inbox line that names the real sender of a queued message')
1159
+ assert(/\[(NEW )?MESSAGE from facilitator\]/.test(byLabel('normal + delivered message frame ([NEW MESSAGE from ...])').prompt), '★ the corpus carries the [NEW MESSAGE from …] delivery frame, captured from a REAL delivery')
1160
+ assert(/\[群聊\] 我对引理 B 的假设有异议/.test(byLabel('normal + group-chat frame ([群聊])').prompt), '★ the corpus carries the group-chat frame relayed by a teammate (and the recipient is not the speaker)')
1161
+ assert(/facilitator(框架\/人类信使,不是常驻成员,不要向它回信/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the frame DEFINES `facilitator` on the very wake that shows it, so it is not an unaddressable stranger')
1162
+ }
1052
1163
  const joined = corpus.map((c) => c.prompt).join('\n')
1053
1164
  assert(joined.indexOf(K.WS) === -1 && joined.indexOf(K.WS.replace(/\\/g, '/')) === -1 && joined.indexOf(vibe) === -1 && joined.indexOf(vibe.replace(/\\/g, '/')) === -1, '★ every captured prompt normalises <WS> and <VIBEMATH> (diffable, no machine paths)')
1165
+ // The random `t-<hex8>` must be normalised too, and NON-vacuously: the task-frame entry has to
1166
+ // CARRY the normalised token (a scrub that quietly blanked the whole frame would also pass the
1167
+ // absence check below).
1168
+ assert(/\[YOU CLAIMED TASK <TASKID>\]/.test(byLabel('task frame ([YOU CLAIMED TASK ...])').prompt), '★ the task-frame corpus entry really carries the normalised `<TASKID>` token')
1169
+ assert(!/\bt-[0-9a-f]{8}\b/.test(joined), '★ no captured prompt leaks a random `t-<hex8>` id (the corpus is byte-stable across runs)')
1054
1170
  assert(!/\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(joined), 'no captured prompt contains placeholder garbage')
1055
1171
  assert(corpus.every((c) => c.prompt && c.prompt.length > 20), 'every corpus entry carries real prompt text')
1056
1172
  }
@@ -862,7 +862,7 @@ assert(stq2.verify === null || stq2.verify.target === 'p-second',
862
862
  await endCase(RL)
863
863
 
864
864
  // =============== CASE 9c: a solve vote OUTSIDE a meeting ==========================
865
- section('9c a unanimous solve vote landing outside a meeting still stops the institute')
865
+ section('9c a unanimous solve vote landing outside a meeting still triggers the stop evaluation')
866
866
  const RM = makeRoot()
867
867
  await callTool('vibe_v5_start', { problem: '会外表决停工测试', researcherCount: 1 }, RM)
868
868
  for (const sp of spawnsFor(RM)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
@@ -877,8 +877,14 @@ await callTool('vibe_v5_say', { to: 'r-1', text: '请你就"是否已解决"表
877
877
  await settle(); await drainWakes(4, RM)
878
878
  const stSolved = await callTool('vibe_v5_status', {}, RM)
879
879
  assert(stSolved.solveVotes.length >= 2, 'both voters recorded a solve vote outside any meeting (' + JSON.stringify(stSolved.solveVotes) + ')')
880
- assert(stSolved.autoDone === true,
881
- 'the institute STOPPED on a unanimous solve vote that arrived outside a meeting ' + JSON.stringify({ autoDone: stSolved.autoDone, solveVotes: stSolved.solveVotes }))
880
+ // docs/final-paper.md §3: the unanimous solve vote no longer flips the completion flags immediately — the
881
+ // FINAL PAPER phase runs first (after phase='solved' the machinery refuses to wake members,
882
+ // convene meetings or dispatch an end). What this case exists for is that `checkSolved` is
883
+ // re-evaluated on a vote that arrived OUTSIDE a meeting, and the paper phase starting is that
884
+ // evaluation firing; `selfdrive-v5`/`e2e-v5-round2` carry the run all the way to autoDone.
885
+ assert(stSolved.autoDone === false && !!stSolved.paper && stSolved.paper.status !== 'finalized',
886
+ 'the unanimous solve vote outside a meeting reached the stop EVALUATION and entered the final-paper phase ' +
887
+ JSON.stringify({ autoDone: stSolved.autoDone, solveVotes: stSolved.solveVotes, paper: stSolved.paper && stSolved.paper.status }))
882
888
  await endCase(RM)
883
889
 
884
890
  // =============== CASE 10: meeting prompts =======================================
@@ -1,27 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PARALLEL TEST RUNNER — run every shipped suite (or a filtered subset) concurrently and report
4
- * per-suite timings, so the strategy for the next run is chosen from DATA instead of guesswork.
3
+ * PARALLEL TEST RUNNER — run every shipped suite AND every probe (or a filtered subset)
4
+ * concurrently and report per-suite timings, so the strategy for the next run is chosen
5
+ * from DATA instead of guesswork.
5
6
  *
6
7
  * Why this exists: the suites are wildly uneven (one suite is ~160 s, most are under 2 s), so a
7
8
  * sequential sweep spends almost all of its wall time waiting for the slowest one. Running them
8
9
  * with a worker pool makes the sweep bounded by the slowest SUITE rather than by their SUM.
9
10
  * On this machine (4 cores) the sweep went from ~5.5 min to ~2 min; see ../docs/test-timing.md.
10
11
  *
12
+ * WHAT IS COLLECTED (Round B): `tests/` holds two families of `.mjs`:
13
+ * · `*.test.mjs` — the suites proper;
14
+ * · every other `.mjs` beside this runner — the PROBES that prove those suites are not
15
+ * vacuous (sensitivity mutations, the registration surface, the self-driving guards).
16
+ * Collecting only the first family was a false green: two probe suites sat at exit 1 while
17
+ * every report said "30/30 green". Both families are therefore collected, and a script can
18
+ * only stay out through the explicit, documented `NEEDS_ARGS` list below — a stale entry in
19
+ * that list is a hard error in a development checkout (`.git` present), so no script can be
20
+ * dropped silently there. Scripts whose bare run under-covers get argument `VARIANTS` (e.g.
21
+ * `audit-registration.mjs` defaults to v3 alone). A PUBLISHED tarball ships only a subset of
22
+ * `tests/`; there the missing entries are reported instead of aborting the run.
23
+ *
11
24
  * Every suite already isolates itself (each creates its own mkdtemp workspace), so parallelism is
12
25
  * safe. Suites that WRITE a corpus take a per-run corpus dir from an env var; this runner points
13
26
  * them at a scratch dir when it runs a suite more than once, which it never does — but the probe
14
27
  * runner (audit-formal-sensitivity.mjs) does, and it passes its own dirs.
15
28
  *
16
29
  * Usage:
17
- * node tests/run-tests.mjs # every *.test.mjs, concurrency = min(4, cpus)
30
+ * node tests/run-tests.mjs # every suite + probe, concurrency = min(4, cpus)
18
31
  * node tests/run-tests.mjs --concurrency=6
19
32
  * node tests/run-tests.mjs --only formal # substring match on the file name (repeatable, OR)
20
33
  * node tests/run-tests.mjs --exclude e2e-v4 # substring to skip (repeatable)
21
34
  * node tests/run-tests.mjs --json # machine-readable summary on stdout
22
35
  */
23
36
  import { spawn } from 'node:child_process'
24
- import { readdirSync } from 'node:fs'
37
+ import { existsSync, readdirSync } from 'node:fs'
25
38
  import { cpus } from 'node:os'
26
39
  import { join } from 'node:path'
27
40
  import { fileURLToPath } from 'node:url'
@@ -48,20 +61,75 @@ const exclude = flag('exclude')
48
61
  const asJson = has('json')
49
62
  const concurrency = Math.max(1, Number(flag('concurrency')[0] || Math.min(4, cpus().length)))
50
63
 
51
- let suites = readdirSync(HERE).filter((f) => f.endsWith('.test.mjs')).sort()
52
- if (only.length) suites = suites.filter((f) => only.some((o) => f.includes(o)))
53
- if (exclude.length) suites = suites.filter((f) => !exclude.some((o) => f.includes(o)))
64
+ const SELF = 'run-tests.mjs'
65
+ // The scripts that genuinely cannot run without arguments. They are named here (with the exact
66
+ // command a human must run) instead of being omitted quietly: an entry that no longer exists
67
+ // aborts the run, so a rename cannot turn into a silent gap.
68
+ const NEEDS_ARGS = {
69
+ 'audit-tool-exec.mjs': 'node tests/audit-tool-exec.mjs <preset-js> <tool-name> (calls one real tool through the host path)',
70
+ }
71
+ // Argument variants, run IN ADDITION to the bare run unless `replaceBare` says the bare run is a
72
+ // strict subset of them. `expectExit` documents a probe whose SUCCESS is a non-zero exit (it
73
+ // mutates the source and requires the guarded check to fail): anything else is a FAIL here.
74
+ const VARIANTS = [
75
+ // A bare `audit-registration.mjs` audits ONLY v3 (its `process.argv[2] || v3` default), so the
76
+ // four presets are run explicitly and the redundant bare run is replaced.
77
+ { file: 'audit-registration.mjs', args: ['vibe-math-v2/vibe-math-v2.js'], replaceBare: true },
78
+ { file: 'audit-registration.mjs', args: ['vibe-math-v3/vibe-math-v3.js'] },
79
+ { file: 'audit-registration.mjs', args: ['vibe-math-v4/vibe-math-v4.js'] },
80
+ { file: 'audit-registration.mjs', args: ['vibe-math-v5/vibe-math-v5.js'] },
81
+ // The invariant scanner's own falsifiability check: it exits 0 only when every injected
82
+ // mutation really made a run go RED (and the unmutated control stayed green).
83
+ { file: 'audit-prompt-invariants.mjs', args: ['--self-probe'] },
84
+ // The two-registration-paths probe, inverted: it applies a real description mutation and
85
+ // REQUIRES the parity check to exit 1 (exit 2 means the mutation no longer applies = drift).
86
+ {
87
+ file: 'audit-v3-registration-parity.mjs',
88
+ args: ['--self-probe', '["TOOL_DESC.vibe_math_start","TOOL_DESC.vibe_math_startX"]'],
89
+ expectExit: 1,
90
+ },
91
+ ]
92
+
93
+ const present = readdirSync(HERE).filter((f) => f.endsWith('.mjs')).sort()
94
+ const presentSet = new Set(present)
95
+ // A published tarball ships only a SUBSET of `tests/` (see docs/test-timing.md §1), so the
96
+ // scripts that stay behind are simply ABSENT there. The skip/variant lists are therefore
97
+ // enforced strictly in a development checkout (a `.git` entry marks one) and merely REPORTED —
98
+ // never silently dropped, they still appear in every run's output — in a partial tree.
99
+ const DEV_CHECKOUT = existsSync(join(REPO, '.git'))
100
+ const stale = Object.keys(NEEDS_ARGS).filter((f) => !presentSet.has(f))
101
+ if (stale.length && DEV_CHECKOUT) {
102
+ console.error('NEEDS_ARGS names a script that no longer exists: ' + stale.join(', ') + ' — fix the skip list, do not delete the entry blindly')
103
+ process.exit(2)
104
+ }
105
+ function label(j) { return j.file + (j.args.length ? ' ' + j.args.join(' ') : '') }
106
+ const replacedBare = new Set(VARIANTS.filter((v) => v.replaceBare).map((v) => v.file))
107
+ let suites = []
108
+ for (const file of present) {
109
+ if (file === SELF || NEEDS_ARGS[file] || replacedBare.has(file)) continue
110
+ suites.push({ file, args: [], expectExit: 0, kind: file.endsWith('.test.mjs') ? 'suite' : 'probe' })
111
+ }
112
+ for (const v of VARIANTS) {
113
+ if (!presentSet.has(v.file)) {
114
+ if (DEV_CHECKOUT) { console.error('a VARIANTS entry names a missing script: ' + v.file); process.exit(2) }
115
+ continue
116
+ }
117
+ suites.push({ file: v.file, args: v.args || [], expectExit: v.expectExit || 0, kind: v.file.endsWith('.test.mjs') ? 'suite' : 'probe' })
118
+ }
119
+ suites.sort((a, b) => (label(a) < label(b) ? -1 : 1))
120
+ if (only.length) suites = suites.filter((j) => only.some((o) => label(j).includes(o)))
121
+ if (exclude.length) suites = suites.filter((j) => !exclude.some((o) => label(j).includes(o)))
54
122
  if (!suites.length) { console.error('no suites matched'); process.exit(2) }
55
123
 
56
- function runSuite(file) {
124
+ function runSuite(job) {
57
125
  return new Promise((resolve) => {
58
126
  const t0 = Date.now()
59
- const child = spawn(process.execPath, [join(HERE, file)], { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
127
+ const child = spawn(process.execPath, [join(HERE, job.file), ...job.args], { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] })
60
128
  let out = '', err = ''
61
129
  child.stdout.on('data', (d) => { out += d.toString() })
62
130
  child.stderr.on('data', (d) => { err += d.toString() })
63
- child.on('error', (e) => resolve({ file, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e) }))
64
- child.on('close', (code) => resolve({ file, code, ms: Date.now() - t0, out, err }))
131
+ child.on('error', (e) => resolve({ job, code: -1, ms: Date.now() - t0, out, err: err + '\n' + String(e) }))
132
+ child.on('close', (code) => resolve({ job, code, ms: Date.now() - t0, out, err }))
65
133
  })
66
134
  }
67
135
 
@@ -74,20 +142,22 @@ async function worker(id) {
74
142
  if (i >= suites.length) return
75
143
  const r = await runSuite(suites[i])
76
144
  const tail = String(r.out).trim().split('\n').filter(Boolean).slice(-1)[0] || ''
145
+ r.ok = r.code === r.job.expectExit
77
146
  results[i] = r
78
147
  if (!asJson) {
79
- const mark = r.code === 0 ? 'PASS' : 'FAIL'
148
+ const mark = r.ok ? 'PASS' : 'FAIL'
80
149
  console.log(
81
- mark + ' ' + r.file.padEnd(38) +
82
- ' exit=' + String(r.code).padStart(3) +
150
+ mark + ' ' + r.job.file.padEnd(34) +
151
+ ' exit=' + String(r.code).padStart(3) + (r.job.expectExit ? '(want ' + r.job.expectExit + ')' : ' ') +
83
152
  ' ' + (r.ms / 1000).toFixed(1).padStart(6) + 's' +
84
- (tail ? ' ' + tail.slice(0, 78) : '')
153
+ (r.job.args.length ? ' [' + r.job.args.join(' ').slice(0, 40) + ']' : '') +
154
+ (tail ? ' ' + tail.slice(0, 60) : '')
85
155
  )
86
- if (r.code !== 0) {
156
+ if (!r.ok) {
87
157
  const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
88
158
  for (const l of lines.slice(-15)) console.log(' ' + l)
89
159
  }
90
- } else if (r.code !== 0) {
160
+ } else if (!r.ok) {
91
161
  // In --json mode keep stdout machine-readable: the failure detail travels in the JSON.
92
162
  const lines = (r.out + '\n' + r.err).split('\n').filter(Boolean)
93
163
  r.tailDetail = lines.slice(-15).join('\n')
@@ -98,15 +168,20 @@ await Promise.all(Array.from({ length: Math.min(concurrency, suites.length) }, (
98
168
 
99
169
  const wall = (Date.now() - started) / 1000
100
170
  const sum = results.reduce((a, r) => a + r.ms, 0) / 1000
101
- const bad = results.filter((r) => r.code !== 0)
171
+ const bad = results.filter((r) => !r.ok)
102
172
  const slowest = results.slice().sort((a, b) => b.ms - a.ms).slice(0, 5)
173
+ const suiteCount = results.filter((r) => r.job.kind === 'suite').length
103
174
 
104
175
  if (asJson) {
105
176
  console.log(JSON.stringify({
106
177
  concurrency, wallSeconds: Number(wall.toFixed(1)), sumSeconds: Number(sum.toFixed(1)),
107
178
  pass: results.length - bad.length, fail: bad.length,
108
- suites: results.map((r) => ({
109
- file: r.file, exit: r.code, seconds: Number((r.ms / 1000).toFixed(1)),
179
+ suites: suiteCount, probes: results.length - suiteCount,
180
+ devCheckout: DEV_CHECKOUT,
181
+ skippedNeedsArgs: NEEDS_ARGS,
182
+ runs: results.map((r) => ({
183
+ file: r.job.file, args: r.job.args, expectExit: r.job.expectExit, exit: r.code,
184
+ seconds: Number((r.ms / 1000).toFixed(1)),
110
185
  ...(r.tailDetail ? { detail: r.tailDetail } : {}),
111
186
  })),
112
187
  }, null, 2))
@@ -114,8 +189,14 @@ if (asJson) {
114
189
  console.log('')
115
190
  console.log('concurrency ' + concurrency + ' · wall ' + wall.toFixed(1) + 's · sum of suite times ' + sum.toFixed(1) + 's'
116
191
  + ' · speed-up x' + (sum / Math.max(wall, 0.001)).toFixed(2))
117
- console.log('slowest: ' + slowest.map((r) => r.file.replace('.test.mjs', '') + ' ' + (r.ms / 1000).toFixed(1) + 's').join(' · '))
118
- console.log('TOTAL ' + results.length + ' PASS ' + (results.length - bad.length) + ' FAIL ' + bad.length)
119
- for (const b of bad) console.log(' FAILED: ' + b.file + ' (exit ' + b.code + ')')
192
+ console.log('slowest: ' + slowest.map((r) => r.job.file.replace('.test.mjs', '') + ' ' + (r.ms / 1000).toFixed(1) + 's').join(' · '))
193
+ console.log('TOTAL ' + results.length + ' PASS ' + (results.length - bad.length) + ' FAIL ' + bad.length
194
+ + ' (suites ' + suiteCount + ' · probes ' + (results.length - suiteCount) + ')')
195
+ for (const b of bad) {
196
+ console.log(' FAILED: ' + label(b.job) + ' (exit ' + b.code + (b.job.expectExit ? ', required exit ' + b.job.expectExit : '') + ')')
197
+ }
198
+ for (const [f, why] of Object.entries(NEEDS_ARGS)) {
199
+ console.log(' SKIPPED (needs CLI args): ' + f + ' — ' + why + (presentSet.has(f) ? '' : ' [not present in this checkout]'))
200
+ }
120
201
  }
121
202
  process.exit(bad.length === 0 ? 0 : 1)