dsh-vibe-math 2.7.3 → 2.8.1

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 (106) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -682
  3. package/README.md +683 -683
  4. package/docs/AUDIT-CHECKLIST.md +758 -744
  5. package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/audit-methodology.md +93 -0
  8. package/docs/final-paper.md +172 -172
  9. package/docs/formal-verification.md +613 -588
  10. package/docs/generate_framework_diagram_v2.py +114 -114
  11. package/docs/generate_framework_diagram_v3.py +127 -127
  12. package/docs/math-computation.md +195 -194
  13. package/docs/release-notes/README.en.md +2 -0
  14. package/docs/release-notes/README.md +2 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  16. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  17. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  18. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  19. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  20. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  21. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  22. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  23. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  24. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  25. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  26. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  27. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  28. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  29. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  30. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  31. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  32. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  33. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  34. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  35. package/docs/release-notes/RELEASE-NOTES-2.8.1.en.md +49 -0
  36. package/docs/release-notes/RELEASE-NOTES-2.8.1.md +49 -0
  37. package/docs/release-playbook.md +111 -0
  38. package/docs/slv-playbook.md +117 -0
  39. package/docs/test-timing.md +158 -157
  40. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  41. package/installer.js +792 -792
  42. package/package.json +18 -3
  43. package/prompt-corpus-v2/formal-verify-v2.json +2 -2
  44. package/prompt-corpus-v2/formal-verify-v2.md +4 -0
  45. package/prompt-corpus-v3/formal-verify-v3.json +7 -7
  46. package/prompt-corpus-v3/formal-verify-v3.md +14 -0
  47. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  48. package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
  49. package/scripts/clean-temp.mjs +234 -0
  50. package/scripts/release-check.mjs +181 -0
  51. package/tests/audit-engine-faces.mjs +298 -0
  52. package/tests/audit-formal-sensitivity.mjs +345 -342
  53. package/tests/audit-installer-compat.test.mjs +256 -256
  54. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  55. package/tests/audit-persona-sensitivity.mjs +323 -323
  56. package/tests/audit-persona-surface.test.mjs +431 -431
  57. package/tests/audit-prompt-duplication.mutants.mjs +3 -2
  58. package/tests/audit-prompt-invariants.mjs +589 -546
  59. package/tests/audit-registration.mjs +78 -78
  60. package/tests/audit-spec-traceability.mjs +193 -193
  61. package/tests/audit-status-report-fields.mjs +172 -172
  62. package/tests/audit-v5-integrity.mjs +795 -795
  63. package/tests/audit-v5-sensitivity.mjs +433 -432
  64. package/tests/e2e-v5-round2.test.mjs +2453 -2426
  65. package/tests/formal-verify-v2.test.mjs +1806 -1800
  66. package/tests/formal-verify-v3.test.mjs +1757 -1750
  67. package/tests/formal-verify-v4.test.mjs +2114 -2106
  68. package/tests/formal-verify-v5.test.mjs +774 -767
  69. package/tests/math-computation-discovery.mutants.mjs +3 -3
  70. package/tests/math-computation-shared.test.mjs +122 -5
  71. package/tests/math-computation-v2.test.mjs +14 -2
  72. package/tests/math-computation-v3.test.mjs +14 -2
  73. package/tests/math-computation-v4.test.mjs +739 -739
  74. package/tests/prompt-v5-integrity.test.mjs +1291 -1291
  75. package/tests/release-check.mutants.mjs +78 -0
  76. package/tests/run-tests.mjs +393 -332
  77. package/tests/selfdrive-v5.mjs +560 -560
  78. package/tests/temp-hygiene.mutants.mjs +80 -0
  79. package/tests/v2-fix-probes.mutants.mjs +4 -0
  80. package/tests/v2-fix-probes.test.mjs +1034 -1033
  81. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  82. package/tests/v3-fix-probes.mutants.mjs +99 -95
  83. package/tests/v3-fix-probes.test.mjs +891 -890
  84. package/tests/v4-final-paper.mutants.mjs +116 -0
  85. package/tests/v4-final-paper.test.mjs +628 -0
  86. package/tests/v5-institute-fixes.mutants.mjs +218 -190
  87. package/vibe-math-v2/agent.cordis.yml +376 -376
  88. package/vibe-math-v2/math-computation.js +17 -15
  89. package/vibe-math-v2/preset.yml +2 -2
  90. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  91. package/vibe-math-v2/vibe-math-v2.js +5078 -5011
  92. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
  93. package/vibe-math-v3/agent.cordis.yml +420 -420
  94. package/vibe-math-v3/math-computation.js +17 -15
  95. package/vibe-math-v3/preset.yml +2 -2
  96. package/vibe-math-v3/vibe-math-v3.js +6215 -6149
  97. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  98. package/vibe-math-v4/agent.cordis.yml +438 -438
  99. package/vibe-math-v4/math-computation.js +17 -15
  100. package/vibe-math-v4/preset.yml +2 -2
  101. package/vibe-math-v4/vibe-math-v4.js +4520 -4422
  102. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
  103. package/vibe-math-v5/math-computation.js +17 -15
  104. package/vibe-math-v5/vibe-math-v5.js +93 -11
  105. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
  106. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1,1291 +1,1291 @@
1
- // ============================================================
2
- // Vibe-Math-V5 PROMPT & INTERACTION INTEGRITY SUITE
3
- //
4
- // Why this suite exists
5
- // ---------------------
6
- // The 2026-09 field test (D:\wd\vibemath测试\项目测试1) exposed a fatal class of bug that
7
- // 123 pre-existing assertions could not see: the framework built every member's brief
8
- // from a mutable "currentMember" global, so each member was told it was SOMEBODY ELSE —
9
- // "[状态] 你是 r-2" appeared inside r-3's induction brief, and the academician's brief
10
- // said "你是 ?(常驻研究员)… 有表决权者 0 人". Nothing asserted the TEXT a member
11
- // actually reads, so the entire class was invisible to the test suite.
12
- //
13
- // This suite therefore treats the PROMPT as the product:
14
- // · every prompt the framework sends is captured verbatim;
15
- // · every prompt is checked for IDENTITY coherence (does it name the member that
16
- // receives it, in its header, its [状态] block and its persona?), ROSTER/quorum
17
- // coherence, and INTERACTION coherence (do framed messages name the true sender
18
- // and the true kind?);
19
- // · the FULL prompt corpus is written to prompt-corpus-v5/ so the real interaction
20
- // content is preserved for human review, not just reduced to pass/fail.
21
- //
22
- // Each case runs in its OWN session root, so one case can never leave a meeting or a
23
- // verification in flight to pollute the next one.
24
- //
25
- // Run: node tests/prompt-v5-integrity.test.mjs
26
- // Env: V5_PLUGIN=<abs path> point the suite at a mutated copy (sensitivity probes)
27
- // V5_CORPUS_DIR=<dir> where to write the corpus (default: ./prompt-corpus-v5)
28
- // ============================================================
29
- import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
30
- import { tmpdir } from 'node:os'
31
- import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
32
- import { fileURLToPath } from 'node:url'
33
-
34
- // fileURLToPath, not URL.pathname: a Windows path with non-ASCII characters comes back
35
- // percent-encoded from `.pathname`, which would silently write the corpus into a
36
- // directory literally named "%E5%BC%80...".
37
- const HERE = dirname(fileURLToPath(import.meta.url))
38
- const PLUGIN = process.env.V5_PLUGIN
39
- ? new URL('file:///' + String(process.env.V5_PLUGIN).replace(/\\/g, '/'))
40
- : new URL('../vibe-math-v5/vibe-math-v5.js', import.meta.url)
41
- const CORPUS_DIR = process.env.V5_CORPUS_DIR ? pathResolve(process.env.V5_CORPUS_DIR) : join(HERE, '..', 'prompt-corpus-v5')
42
- const WS = mkdtempSync(join(tmpdir(), 'vibe-v5-prompt-'))
43
-
44
- let passed = 0, failed = 0
45
- const failures = []
46
- const assert = (c, m) => {
47
- if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) }
48
- }
49
- const section = (t) => console.log('\n[' + t + ']')
50
-
51
- // ---------------------------------------------------------------
52
- // VIRTUAL CLOCK
53
- // The institute is driven by `ctx.timeout` + `Date.now()`. On the real clock, WHICH member a timer
54
- // picks (heartbeat / meeting / digest) depends on load and on millisecond deltas, so the shipped
55
- // corpus recorded different members on different runs and changed on every run — which makes it
56
- // undiffable and hides real drift (AUDIT-CHECKLIST §2.4). A virtual clock fires every timer in a
57
- // fixed order regardless of machine speed, and makes the suite faster because `sleep(n)` advances
58
- // virtual time instead of waiting n real milliseconds.
59
- let vnow = 1700000000000
60
- let vtimerSeq = 0
61
- const vtimers = new Map()
62
- Date.now = () => vnow
63
- function advanceClock(ms) {
64
- const target = vnow + Math.max(0, Math.round(Number(ms) || 0))
65
- for (;;) {
66
- let next = null
67
- for (const t of vtimers.values()) {
68
- if (t.at > target) continue
69
- if (!next || t.at < next.at || (t.at === next.at && t.seq < next.seq)) next = t
70
- }
71
- if (!next) break
72
- vtimers.delete(next.id)
73
- vnow = Math.max(vnow, next.at)
74
- try { next.cb() } catch (e) { /* a throwing timer must not stop the clock */ }
75
- }
76
- vnow = target
77
- }
78
- const realSleep = (ms) => new Promise((r) => setTimeout(r, ms))
79
- // Advance the virtual clock, then give the real event loop a few turns so awaited work (fs writes,
80
- // handler chains) can finish before the next assertion reads the state.
81
- const sleep = async (ms) => { advanceClock(ms); for (let i = 0; i < 3; i++) await realSleep(1) }
82
-
83
- // ---------------------------------------------------------------
84
- // mock host
85
- // ---------------------------------------------------------------
86
- // NO session services: v5 injects only ['subagents','agents','fs','tools','commands','timer'] and
87
- // keeps its durable state in the hardened JSON file State/<institute>.v5state.json. Sessions still
88
- // record anything appended to them so the suite can assert that the plugin appended NOTHING — an
89
- // institute event in the user's session log makes that session unresumable (DSH refuses to load an
90
- // unknown event type unless it is marked `ignorable`, which `Session.append` cannot set).
91
- const listeners = {}
92
- const toolRegs = []
93
- const liveAgents = new Map()
94
-
95
- const spawns = [] // { label, childId, rootId, persona, prompt, toolFilter }
96
- const wakes = [] // queued sends not yet handled
97
- const delivered = [] // sends that drainWakes actually handled
98
- let failNextStarts = 0
99
-
100
- // One workspace PER CASE. The institute's durable state is a file under the workspace, so a shared
101
- // workspace would let case 1's institute (and its running state) leak into every later case.
102
- const WORKSPACES = [WS]
103
- function newWorkspace() {
104
- const ws = mkdtempSync(join(tmpdir(), 'vibe-v5-prompt-'))
105
- WORKSPACES.push(ws)
106
- return ws
107
- }
108
-
109
- function makeMockSession(id, parentSession, cwd) {
110
- const events = []
111
- const s = {
112
- id,
113
- header: { version: 1, id, createdAt: Date.now(), cwd: cwd || WS, parentSession, isSeeded: false },
114
- inheritedEventCount: 0,
115
- get seq() { return events.length },
116
- append(type, data) {
117
- const ev = { type, data, seq: events.length, time: Date.now() }
118
- events.push(ev)
119
- return ev
120
- },
121
- deriveMessages() { return [] },
122
- snapshotEvents(from) { return events.slice(from || 0) },
123
- ownEvents() { return events.slice() },
124
- _events: events,
125
- }
126
- return s
127
- }
128
-
129
- const roots = new Map()
130
- let rootSeq = 0
131
- // `ws` opens a SECOND root over an existing workspace, which is what a reload looks like.
132
- function makeRoot(ws) {
133
- const id = 'sess-' + String.fromCharCode(65 + rootSeq++)
134
- const cwd = ws || newWorkspace()
135
- const session = makeMockSession(id, undefined, cwd)
136
- const root = { id, options: { provider: 'mock', model: 'm' }, session, ctx: undefined }
137
- roots.set(id, root)
138
- return root
139
- }
140
- const wsOf = (root) => root.session.header.cwd
141
-
142
- const ctx = {
143
- get(name) {
144
- if (name === 'sandboxPolicy') return undefined
145
- if (name === 'compaction') return undefined
146
- if (name === 'subprocess') {
147
- return {
148
- async resolveExecutable(cmd) { return String(cmd) },
149
- spawn({ argv }) {
150
- const last = argv[argv.length - 1] || ''
151
- // directory creation still goes through the same mock (mkdirs uses a shell)
152
- if (/New-Item/.test(last)) {
153
- const paths = []
154
- const re = /'((?:[^']|'')*)'/g
155
- let m
156
- while ((m = re.exec(last)) !== null) paths.push(m[1].replace(/''/g, "'"))
157
- for (const q of paths) if (q && !/^-/.test(q)) mkdirSync(q, { recursive: true })
158
- return { done: Promise.resolve({ exitCode: 0, signal: null }), collected: {}, terminate() {} }
159
- }
160
- // The fake Lean toolchain: GREEN unless the file still uses sorry / carries -- FAIL.
161
- // Case 12 needs a proof that really passes so the prompt switches to fidelity review.
162
- const text = existsSync(last) ? readFileSync(last, 'utf8') : ''
163
- const bad = /sorry|-- FAIL/.test(text)
164
- const ok = { text: 'ok\n', nextOffset: 3, lossy: false }
165
- const err = { text: bad ? 'error: declaration uses sorry\n' : '', nextOffset: 0, lossy: false }
166
- return {
167
- done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
168
- collected: { stdout: { readFrom: () => ok }, stderr: { readFrom: () => err } },
169
- terminate() {},
170
- }
171
- },
172
- }
173
- }
174
- return undefined
175
- },
176
- on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
177
- effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
178
- logger: { info() {}, warn() {}, error() {} },
179
- // Virtualised: the plugin's timers fire when the suite advances the clock (see the virtual-clock
180
- // block above), which is what makes timer-driven prompt selection reproducible.
181
- timeout(cb, ms) {
182
- const id = ++vtimerSeq
183
- vtimers.set(id, { id, seq: id, at: vnow + Math.max(0, Math.round(Number(ms) || 0)), cb })
184
- return () => { vtimers.delete(id) }
185
- },
186
- tools: { register(spec) { toolRegs.push(spec); return () => {} } },
187
- commands: { register() { return () => {} } },
188
- subagents: {
189
- list() { return ['spawn'] },
190
- async startContinuable({ label, request }) {
191
- if (failNextStarts > 0) { failNextStarts -= 1; throw new Error('mock provisioning failure') }
192
- const rootId = (request && request.parent && request.parent.id) || 'sess-A'
193
- const id = 'c' + (spawns.length + 1)
194
- const cwd = (request && request.parent && request.parent.session && request.parent.session.header.cwd) || WS
195
- liveAgents.set(id, { id, session: makeMockSession(id, rootId, cwd), options: request && request.agentOptions })
196
- spawns.push({
197
- label, childId: id, rootId,
198
- persona: request && request.persona,
199
- prompt: request && request.prompt && request.prompt[0] && request.prompt[0].text,
200
- toolFilter: request && request.toolFilter,
201
- })
202
- return { childId: id, messageId: 'm' + spawns.length }
203
- },
204
- async sendMessage(parent, childId, blocks) {
205
- wakes.push({ childId, rootId: (parent && parent.id) || 'sess-A', prompt: (blocks && blocks[0] && blocks[0].text) || '' })
206
- return 'w' + (delivered.length + wakes.length)
207
- },
208
- interrupt() {},
209
- async drainContinuableChildren(parent, ids) { for (const i of ids) liveAgents.delete(i) },
210
- },
211
- agents: {
212
- roots() { return [...roots.values()] },
213
- get(id) { return roots.get(id) || liveAgents.get(id) },
214
- list() { return [...roots.values(), ...liveAgents.values()] },
215
- },
216
- fs: {
217
- async resolve(rel, opts) {
218
- const b = (opts && opts.cwd) || WS
219
- const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/'))
220
- return { targetKey: p, displayPath: p }
221
- },
222
- async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
223
- async readText(t) { return readFileSync(t.targetKey, 'utf8') },
224
- async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
225
- async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
226
- },
227
- }
228
-
229
- const mod = await import(PLUGIN.href + '?t=' + Date.now())
230
- const plugin = mod.default || mod
231
- plugin.apply(ctx)
232
-
233
- // ---------------------------------------------------------------
234
- // driving helpers
235
- // ---------------------------------------------------------------
236
- async function callTool(name, args, agent) {
237
- const spec = toolRegs.find(x => x.name === name)
238
- if (!spec) throw new Error('no tool ' + name)
239
- return JSON.parse(await spec.execute(args || {}, { agent }))
240
- }
241
- const childAgent = (childId) => liveAgents.get(childId)
242
- function fireEnd(childId, reply, stopReason) {
243
- const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
244
- for (const h of (listeners['subagent/end'] || [])) {
245
- h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: stopReason || 'completed', lastAssistantMessage: blocks })
246
- }
247
- }
248
- const settle = async () => { await sleep(30) }
249
- const memberOfChild = (childId) => {
250
- const s = spawns.find(x => x.childId === childId)
251
- const m = s ? /vibe5 (\S+) /.exec(s.label) : null
252
- return m ? m[1] : ''
253
- }
254
- const spawnOf = (root, memberId) => spawns.find(s => s.rootId === root.id && s.label.indexOf('vibe5 ' + memberId + ' ') !== -1)
255
- const childOf = (root, memberId) => { const s = spawnOf(root, memberId); return s ? s.childId : '' }
256
- const spawnsFor = (root) => spawns.filter(s => s.rootId === root.id)
257
-
258
- // Pull exactly the VOTING prompts for one root. A plain FIFO drain returns whatever was
259
- // queued first (work rounds, heartbeats), which is how an earlier version of this case ended
260
- // up asserting against the wrong prompt entirely.
261
- async function takeVerifyPrompts(root, n) {
262
- const got = []
263
- for (let guard = 0; guard < 400 && got.length < n; guard++) {
264
- const idx = wakes.findIndex(w => w.rootId === root.id && /【求真表决/.test(w.prompt))
265
- if (idx === -1) {
266
- const other = wakes.findIndex(w => w.rootId === root.id)
267
- if (other !== -1) {
268
- const w = wakes.splice(other, 1)[0]
269
- delivered.push({ prompt: w.prompt, owner: memberOfChild(w.childId), rootId: w.rootId })
270
- fireEnd(w.childId, { progress: '(语料采样时略过非表决轮)', contextPct: 20 })
271
- await settle()
272
- continue
273
- }
274
- await settle()
275
- continue
276
- }
277
- const w = wakes.splice(idx, 1)[0]
278
- got.push(w)
279
- delivered.push({ prompt: w.prompt, owner: memberOfChild(w.childId), rootId: w.rootId })
280
- fireEnd(w.childId, { verdict: { target: (/"target"\s*:\s*"([^"]+)"/.exec(w.prompt) || [])[1] || '', verdict: 0.5, reason: '语料采样' }, contextPct: 20 })
281
- await settle()
282
- }
283
- return got
284
- }
285
-
286
- let votePlan = new Map() // memberId -> verdict number for the next verify prompts
287
- let replyOverride = new Map() // memberId -> the exact reply its NEXT wake must produce
288
- // Roots whose MEETING prompts the driver must NOT answer, so the meeting stays in flight
289
- // (case 10b needs a live meeting to test that a verification cannot preempt it).
290
- const hushed = new Set()
291
- // Handle queued sends. `delivered` collects what was actually sent for the case under
292
- // test, because the queue is consumed here and assertions must not read it afterwards.
293
- // Wakes belonging to OTHER roots are skipped over rather than allowed to block: a case
294
- // with a short heartbeat keeps producing its own wakes, and a naive
295
- // "stop at the first foreign wake" loop would starve every later case.
296
- async function drainWakes(budget, root) {
297
- let n = 0
298
- while (n < budget) {
299
- const idx = wakes.findIndex(w => (!root || w.rootId === root.id)
300
- && !(hushed.has(w.rootId) && /【研究所会议/.test(w.prompt)))
301
- if (idx === -1) break
302
- const w = wakes.splice(idx, 1)[0]
303
- const owner = memberOfChild(w.childId)
304
- delivered.push({ prompt: w.prompt, owner, childId: w.childId, rootId: w.rootId })
305
- let reply
306
- if (replyOverride.has(owner)) { reply = replyOverride.get(owner); replyOverride.delete(owner) } else if (/【求真表决/.test(w.prompt)) {
307
- const target = (/"target"\s*:\s*"([^"]+)"/.exec(w.prompt) || [])[1] || ''
308
- const v = votePlan.has(owner) ? votePlan.get(owner) : 0.5
309
- reply = { verdict: { target, verdict: v, reason: owner + ' 的判断' }, contextPct: 20 }
310
- } else if (/【研究所会议/.test(w.prompt)) {
311
- reply = { input: owner + ':我的意见。', solved: false, contextPct: 20 }
312
- } else {
313
- reply = { progress: owner + ':继续推进。', solved: false, contextPct: 20 }
314
- }
315
- fireEnd(w.childId, reply)
316
- n++
317
- await settle()
318
- }
319
- return n
320
- }
321
- // A case is over: pause it so it can never generate a wake that would leak into the
322
- // next case, and drop anything it still had queued.
323
- async function endCase(root) {
324
- await callTool('vibe_v5_pause', {}, root)
325
- for (let i = wakes.length - 1; i >= 0; i--) if (wakes[i].rootId === root.id) wakes.splice(i, 1)
326
- }
327
- async function settleInstitute(root, rounds = 14) {
328
- for (let i = 0; i < rounds; i++) {
329
- await drainWakes(40, root)
330
- await sleep(20)
331
- const st = await callTool('vibe_v5_status', {}, root)
332
- if (!st.members.some(m => m.busy) && !st.meeting && !st.verify && wakes.length === 0) return st
333
- }
334
- await drainWakes(40, root)
335
- return await callTool('vibe_v5_status', {}, root)
336
- }
337
-
338
- // ---------------------------------------------------------------
339
- // prompt inspection
340
- // ---------------------------------------------------------------
341
- const KINDS = ['院士', '常驻研究员', '临时工']
342
- const reState = new RegExp('\\[状态\\]\\s*你是\\s+(\\S+?)((' + KINDS.join('|') + '))|轮次\\s*(\\d+)|法定票数\\s*m=(\\d+)|有表决权者\\s*(\\d+)\\s*人')
343
- const reRoster = /\[在册\]\s*(.*)/
344
- const reAbsent = /\[未就位\]\s*(.*)/
345
- const reHeader = /^【([^】]*)】/m
346
-
347
- function headerMember(prompt) {
348
- const h = (reHeader.exec(prompt) || [])[1]
349
- if (!h) return { header: '', id: '', kind: '' }
350
- for (const k of KINDS) {
351
- let m = new RegExp('——\\s*' + k + '\\s+(\\S+?)\\s*】?$').exec(h)
352
- if (m) return { header: h, id: m[1], kind: k }
353
- m = new RegExp('——\\s*' + k + '\\s+(\\S+?)\\s+就对象').exec(h)
354
- if (m) return { header: h, id: m[1], kind: k }
355
- }
356
- return { header: h, id: '', kind: '' }
357
- }
358
- function parseState(prompt) {
359
- const s = reState.exec(prompt)
360
- if (!s) return null
361
- return { id: s[1], kind: s[2], round: Number(s[3]), m: Number(s[4]), voters: Number(s[5]) }
362
- }
363
- const rosterOf = (prompt) => {
364
- const r = reRoster.exec(prompt)
365
- return r ? r[1].split(/[、,]/).map(s => s.trim()).filter(s => s && s !== '(无)') : null
366
- }
367
- const absentOf = (prompt) => {
368
- const r = reAbsent.exec(prompt)
369
- return r ? r[1].split(/[、,]/).map(s => s.trim()).filter(Boolean) : null
370
- }
371
- const stateBlockCount = (prompt) => (prompt.match(/\[状态\]/g) || []).length
372
- // Data-position garbage only: a bare \bundefined\b also matches legitimate prose
373
- // such as v2's "no undefined symbols" (the v2 suite caught that as a false positive).
374
- const GARBAGE = [/:\s*undefined/, /["']undefined["']/, /undefined\s*[,}\]]/, /\bNaN\b/, /\[object Object\]/, /你是\s*\?/]
375
- const isVoter = (id) => id === 'acad' || /^r-/.test(id)
376
-
377
- // Applied to EVERY captured prompt.
378
- function checkPromptSweep(prompt, owner, where) {
379
- const problems = []
380
- if (!prompt) return ['prompt is empty']
381
- for (const g of GARBAGE) if (g.test(prompt)) problems.push('contains ' + g)
382
- if (stateBlockCount(prompt) !== 1) problems.push('expected exactly one [状态] block, found ' + stateBlockCount(prompt))
383
- const st = parseState(prompt)
384
- if (!st) { problems.push('no parseable [状态] line'); return problems }
385
- if (st.id !== owner) problems.push('[状态] names ' + st.id + ' but was sent to ' + owner)
386
- const hd = headerMember(prompt)
387
- if (hd.id && hd.id !== owner) problems.push('header names ' + hd.id + ' but was sent to ' + owner)
388
- if (hd.kind && st.kind && hd.kind !== st.kind) problems.push('header kind ' + hd.kind + ' ≠ [状态] kind ' + st.kind)
389
- const roster = rosterOf(prompt)
390
- if (!roster) problems.push('[在册] line missing')
391
- else {
392
- if (roster.indexOf(owner) === -1) problems.push('the roster omits the reader ' + owner + ' ([' + roster.join('、') + '])')
393
- if (new Set(roster).size !== roster.length) problems.push('duplicate ids in [在册]')
394
- for (const a of (absentOf(prompt) || [])) {
395
- const id = String(a).replace(/(.*$/, '')
396
- if (roster.indexOf(id) !== -1) problems.push(id + ' is on the roster AND listed as 未就位')
397
- }
398
- if (st.voters !== roster.filter(isVoter).length) {
399
- problems.push('有表决权者 ' + st.voters + ' ≠ voters in [在册] ' + roster.filter(isVoter).length)
400
- }
401
- if (st.m !== Math.min(3, st.voters)) problems.push('m=' + st.m + ' ≠ min(quorumCap 3, voters ' + st.voters + ')')
402
- }
403
- for (const other of KINDS) {
404
- const hits = prompt.match(new RegExp('你是\\s+\\S+?(' + other + ')', 'g')) || []
405
- if (hits.length > 1) problems.push('more than one identity claim: ' + hits.join(' / '))
406
- }
407
- if (problems.length) console.error(' !! ' + where + ' → ' + problems.join('; '))
408
- return problems
409
- }
410
-
411
- // ---------------------------------------------------------------
412
- // corpus recorder
413
- // ---------------------------------------------------------------
414
- const corpus = []
415
- // Normalise every workspace OUT of the corpus. A plain `split(WS)` is not enough on Windows:
416
- // the plugin renders paths with forward slashes while os.tmpdir() may hand back a different
417
- // CASE ("...\ADMIN\..." vs ".../admin/..."), so the absolute workspace path used to survive
418
- // into the SHIPPED corpus — non-deterministic (the temp dir changes every run) and a machine
419
- // path leak. Match case-insensitively, on either separator, for EVERY per-case workspace.
420
- const scrub = (s) => {
421
- let t = String(s == null ? '' : s).replace(/\\/g, '/')
422
- // Timestamps are part of the prompt a member reads, but not part of what a reviewer needs:
423
- // normalise them too. Otherwise the shipped corpus changes on EVERY run — its headings carry
424
- // `### YYYY-MM-DD hh:mm:ss|<member>` — and its diffs stop being meaningful.
425
- // The VibeMath ROOT gets its own token (not `<WS>/VibeMath`), so v5's corpus can be diffed
426
- // side by side with v2/v3/v4's, which render the same root as `<VIBEMATH>`.
427
- t = t.replace(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/g, '<TIME>')
428
- .replace(/[A-Za-z]:\/[^\s"'`)),。;:]*?[\\/]VibeMath/g, '<VIBEMATH>')
429
- .replace(/\/VibeMath/g, '<VIBEMATH>')
430
- for (const ws of WORKSPACES) {
431
- const re = new RegExp(ws.replace(/\\/g, '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
432
- t = t.replace(re, '<WS>')
433
- }
434
- return t
435
- }
436
- // Every Lean tool mention in agent-facing text must be the REGISTERED name.
437
- // `Verified/Lean/` is a contract PATH, not a tool name, and on a case-insensitive reading it
438
- // even contains the substring "lean_"; strip that exact path before the check (the same path
439
- // is written by the plugin, so the prompt builders genuinely can emit it).
440
- const noBareLeanTool = (t) => !/(^|[^a-z_])lean_(run|archive|lib)/.test(
441
- String(t || '').replace(/Verified\/Lean\//g, ''))
442
- function record(kind, owner, prompt, persona, extra) {
443
- corpus.push({
444
- kind, owner,
445
- sentToLabel: (extra && extra.label) || '',
446
- persona: persona === undefined ? null : scrub(persona),
447
- prompt: scrub(prompt),
448
- toolFilter: (extra && extra.toolFilter) || null,
449
- })
450
- }
451
- function recordAndCheck(kind, owner, prompt, opts) {
452
- record(kind, owner, prompt, opts && opts.persona, opts)
453
- const problems = checkPromptSweep(prompt, owner, kind + ' prompt for ' + owner)
454
- assert(problems.length === 0, kind + ' prompt for ' + owner + ' is identity/roster coherent')
455
- return problems
456
- }
457
- function checkPersona(kind, owner, persona, checks) {
458
- for (const [re, label] of checks) assert(re.test(persona), kind + ': ' + owner + "'s charter " + label)
459
- }
460
-
461
- // ===============================================================
462
- console.log('-- V5 prompt & interaction integrity --')
463
-
464
- // =============== CASE 1: founding briefs =========================================
465
- section('1 founding — every induction brief describes the member that receives it')
466
- const RA = makeRoot()
467
- const started = await callTool('vibe_v5_start', { problem: '求 3N^2-2=b^2 与 3N^2+2=5a^2 的全部整数解', researcherCount: 3 }, RA)
468
- assert(started.ok === true, 'institute founded')
469
- const FOUND_ORDER = ['acad', 'r-1', 'r-2', 'r-3']
470
- const founding = spawnsFor(RA)
471
- assert(founding.length === 4, 'four founding members were started (got ' + founding.length + ')')
472
- for (let i = 0; i < founding.length; i++) {
473
- const sp = founding[i]
474
- const owner = memberOfChild(sp.childId)
475
- assert(owner === FOUND_ORDER[i], 'founding #' + i + ' started ' + owner + ' (expected ' + FOUND_ORDER[i] + ')')
476
- recordAndCheck('founding', owner, sp.prompt, sp)
477
- const st = parseState(sp.prompt) || {}
478
- const roster = rosterOf(sp.prompt) || []
479
- assert(JSON.stringify(roster) === JSON.stringify(FOUND_ORDER.slice(0, i + 1)),
480
- 'the founding brief of ' + owner + ' shows the roster INCLUDING itself: ' + JSON.stringify(roster))
481
- assert(st.round === 1, owner + "'s founding brief is round 1 (got " + st.round + ')')
482
- assert(st.voters === i + 1, owner + ' sees ' + (i + 1) + ' voter(s) (got ' + st.voters + ')')
483
- assert(st.m === Math.min(3, i + 1), owner + ' sees m=min(3,' + (i + 1) + ')=' + Math.min(3, i + 1) + ' (got ' + st.m + ')')
484
- assert(sp.prompt.indexOf('【入职首轮') === 0, owner + "'s first prompt is framed as an induction")
485
- assert(sp.prompt.indexOf('你刚刚加入本所') !== -1, owner + "'s induction asks for its own first view")
486
- }
487
- assert(founding[0].prompt.indexOf('[状态] 你是 acad(院士)') !== -1, 'the academician brief says 你是 acad(院士) — never "?"')
488
- assert(!/你是 \?/.test(founding[0].prompt), 'no "你是 ?" placeholder')
489
- checkPersona('founding', 'acad', founding[0].persona, [
490
- [/在册院士:acad/, 'lists itself as the sitting academician'],
491
- [/在册常驻研究员:(无)/, 'shows no researchers at that instant'],
492
- [/你是「institute」的\*\*院士\*\*/, 'opens by naming its office'],
493
- [/Members\/acad\//, 'points at its own library'],
494
- ])
495
- checkPersona('founding', 'r-3', founding[3].persona, [
496
- [/在册院士:acad/, 'names the sitting academician'],
497
- [/在册常驻研究员:r-1、r-2、r-3/, 'lists r-1、r-2、r-3 as the sitting researchers'],
498
- [/在册临时工:(无)/, 'shows no temps'],
499
- [/代号 r-3。/, 'states its own 代号'],
500
- [/Members\/r-3\//, 'points at its own library'],
501
- [/progress.md/, 'documents Progress/progress.md'],
502
- ])
503
- checkPersona('founding', 'r-1', founding[1].persona, [[/一名常驻研究员/, 'opens as 常驻研究员']])
504
- for (const sp of founding) {
505
- const owner = memberOfChild(sp.childId)
506
- assert(sp.persona.indexOf('Members/' + owner + '/') !== -1, owner + "'s charter points at Members/" + owner + '/')
507
- }
508
- for (const sp of founding) { sp._ended = true; fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
509
- await settleInstitute(RA)
510
- // ★ The persistence contract after the DSH 0.2.0 audit: institute state lives in the hardened
511
- // JSON file State/<institute>.v5state.json and NOTHING is appended to the host session log. The
512
- // old projection backend wrote `vibe5/*` events into the user's own session, and DSH's session
513
- // persistence refuses to load a log with an unknown event type unless it is marked
514
- // `ignorable: true` — which `Session.append` cannot set — so that session became unresumable.
515
- {
516
- assert(RA.session._events.length === 0,
517
- '★ the plugin appends NOTHING to the host session log (' + RA.session._events.length + ' events after a full founding)')
518
- const stateFile = join(wsOf(RA), 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
519
- for (let i = 0; i < 40 && !existsSync(stateFile); i++) await settle()
520
- assert(existsSync(stateFile), 'the institute state is on disk in State/institute.v5state.json')
521
- const RB0 = makeRoot(wsOf(RA)) // a second root over the same workspace = a reload
522
- const stReload = await callTool('vibe_v5_status', {}, RB0)
523
- assert(stReload.members.length === 4, '★ a reload reads the 4-member roster back out of the JSON file (got ' + stReload.members.length + ')')
524
- assert(RB0.session._events.length === 0, '★ the reloading root appends nothing to its session log either')
525
- }
526
- await endCase(RA)
527
-
528
- // =============== CASE 2: round prompts (normal + checkpoint) =====================
529
- section('2 rounds — normal and checkpoint prompts keep the identity straight')
530
- const RB = makeRoot()
531
- await callTool('vibe_v5_start', { problem: '无领头人情形下的组织', researcherCount: 2 }, RB)
532
- for (const sp of spawnsFor(RB)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
533
- await settleInstitute(RB)
534
- // A short idle window makes the heartbeat prompt reachable inside a test run.
535
- await callTool('vibe_v5_set', { activityTimeoutMs: 80, maxParallel: 6, chatDigestMax: 1 }, RB)
536
- // (a) an addressed message must produce a NORMAL round prompt carrying the framed inbox
537
- delivered.length = 0
538
- await callTool('vibe_v5_say', { to: 'r-2', text: '请把你手上的结论同步给我。' }, childAgent(childOf(RB, 'r-1')))
539
- await settle(); await drainWakes(10, RB)
540
- const normalWakes = delivered.slice()
541
- assert(normalWakes.length > 0, 'the addressed message produced a wake (' + normalWakes.length + ')')
542
- for (const w of normalWakes) recordAndCheck('normal', w.owner, w.prompt)
543
- assert(normalWakes.some(w => w.owner === 'r-2' && w.prompt.indexOf('【研究所·私信 from r-1】') !== -1),
544
- 'r-2 is woken with its inbox containing the DM framed from r-1')
545
- assert(normalWakes.filter(w => w.owner === 'r-2').every(w => w.prompt.indexOf('【研究所·私信 from r-2】') === -1),
546
- 'r-2 never receives the DM framed as coming from itself')
547
- // (b) the heartbeat must produce a CHECKPOINT prompt. Captured in its OWN single-member institute:
548
- // the heartbeat wakes the most idle non-busy member, so with exactly one member the pick cannot
549
- // depend on real `Date.now()` deltas under load — the earlier shared-root version recorded
550
- // heartbeat prompts for different members on different runs, which made the SHIPPED corpus change
551
- // between runs (AUDIT-CHECKLIST §2.4: corpora must be byte-diffable).
552
- const RHB = makeRoot()
553
- await callTool('vibe_v5_start', { problem: '心跳语料(单成员)', researcherCount: 1, academician: false }, RHB)
554
- for (const sp of spawnsFor(RHB)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
555
- await settleInstitute(RHB)
556
- await callTool('vibe_v5_set', { activityTimeoutMs: 80, maxParallel: 6, chatDigestMax: 1 }, RHB)
557
- let hbQueueIdx = -1
558
- for (let i = 0; i < 15 && hbQueueIdx === -1; i++) {
559
- await sleep(120); await settle()
560
- // Inspect the QUEUE (never drains it): draining answers turns and changes who is idle next.
561
- hbQueueIdx = wakes.findIndex(w => w.rootId === RHB.id && /【心跳检查/.test(w.prompt))
562
- }
563
- assert(hbQueueIdx !== -1, 'the heartbeat produced a checkpoint prompt')
564
- if (hbQueueIdx !== -1) {
565
- const hbWake = wakes.splice(hbQueueIdx, 1)[0]
566
- const hbMember = memberOfChild(hbWake.childId)
567
- assert(hbMember === 'r-1',
568
- '★ the heartbeat picked the only member (r-1) — deterministic, not timing-dependent (got ' + hbMember + ')')
569
- recordAndCheck('checkpoint', hbMember, hbWake.prompt)
570
- // The heartbeat body may be preceded by a delivered inbox or the core-rules recap
571
- // after a real compaction, so match anywhere rather than at offset 0.
572
- assert(hbWake.prompt.indexOf('【心跳检查 —— ') !== -1, hbMember + "'s heartbeat prompt names its own office and id")
573
- assert(new RegExp('【心跳检查 —— (院士|常驻研究员|临时工) ' + hbMember + '】').test(hbWake.prompt),
574
- hbMember + "'s heartbeat header carries its own kind and id")
575
- fireEnd(hbWake.childId, { progress: hbMember + ':继续推进。', solved: false, contextPct: 20 })
576
- await settle()
577
- }
578
- await endCase(RHB)
579
- await endCase(RB)
580
-
581
- // =============== CASE 3: interaction framing ====================================
582
- section('3 interaction framing — every message names its true sender and kind')
583
- const RC = makeRoot()
584
- await callTool('vibe_v5_start', { problem: '交互框架测试', researcherCount: 2 }, RC)
585
- for (const sp of spawnsFor(RC)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
586
- await settleInstitute(RC)
587
- await callTool('vibe_v5_set', { maxParallel: 8, chatDigestMax: 1 }, RC)
588
- const r1 = childOf(RC, 'r-1'), acad = childOf(RC, 'acad')
589
- const send = async (kind, fn) => {
590
- delivered.length = 0
591
- const r = await fn()
592
- await settle(); await drainWakes(20, RC)
593
- const handled = delivered.slice()
594
- for (const w of handled) recordAndCheck(kind, w.owner, w.prompt).length
595
- return { r, prompts: handled.map(w => w.prompt).join('\n'), owners: handled.map(w => w.owner), count: handled.length }
596
- }
597
- const dm = await send('inbox-dm', () => callTool('vibe_v5_say', { to: 'r-2', text: '私下问你一下。' }, childAgent(r1)))
598
- const voters = await send('inbox-voters', () => callTool('vibe_v5_say', { to: 'voters', text: '请全体表决者注意。' }, childAgent(r1)))
599
- const chat = await send('inbox-chat', () => callTool('vibe_v5_say', { text: '各位,我建议先做最小反例归约。' }, childAgent(r1)))
600
- const office = await send('inbox-office', () => callTool('vibe_v5_message', { to: 'all', content: '所办通知:请按计划推进。' }, RC))
601
- const assign = await send('inbox-assign', () => callTool('vibe_v5_assign', { subject: '核验模 9 情形', to: 'r-2', why: '你最熟同余', acceptance: '给出模 9 全表' }, childAgent(acad)))
602
- const nudge = await send('inbox-nudge', () => callTool('vibe_v5_nudge', { to: 'r-2', why: '进度偏慢', next_step: '先交一份模 9 表' }, childAgent(acad)))
603
- assert(dm.r.ok === true && dm.r.delivered === 1, 'a DM reaches exactly its addressee')
604
- assert(voters.r.ok === true && voters.r.delivered === 2, 'a voters-only broadcast reaches every voter but the sender')
605
- assert(chat.r.ok === true && chat.r.delivered === 2, 'group chat fans out to every other member')
606
- assert(assign.r.ok === true, "the ACADEMICIAN's assignment succeeded (" + JSON.stringify(assign.r).slice(0, 90) + ')')
607
- assert(/【研究所·私信 from r-1】/.test(dm.prompts), 'a DM is framed by its true sender (【研究所·私信 from r-1】)')
608
- assert(/【研究所·致全体表决者 from r-1】/.test(voters.prompts), 'a voters-only broadcast is framed as such, not as a DM')
609
- assert(/【研究所·群聊】r-1:/.test(chat.prompts), 'group chat is framed with the true speaker')
610
- assert(/【所办通知】/.test(office.prompts), 'an office notice is framed 所办通知')
611
- assert(/【院士分派】/.test(assign.prompts), "the academician's assignment is framed 院士分派")
612
- assert(/【督办 from acad】/.test(nudge.prompts), 'a nudge is framed 督办 by its true author')
613
- assert(!/【院士分派】[^\n]*督办/.test(nudge.prompts), 'a nudge is NOT mislabelled as an assignment')
614
- // THE OFFICE ITSELF must be able to assign, and must not impersonate the academician.
615
- const officeAssign = await send('inbox-office-assign', () => callTool('vibe_v5_assign', { subject: '所办指派', to: 'r-2', why: '所办决定', acceptance: '给出结论' }, RC))
616
- assert(officeAssign.r.ok === true, 'the OFFICE (session root) can assign — it is resolved as the office, not as a random member (' + JSON.stringify(officeAssign.r).slice(0, 90) + ')')
617
- assert(/【所办分派】/.test(officeAssign.prompts), 'an OFFICE assignment is framed 所办分派, not 院士分派')
618
- assert(!/【院士分派】/.test(officeAssign.prompts), 'an office assignment does not impersonate the academician')
619
- const officeNudge = await send('inbox-office-nudge', () => callTool('vibe_v5_nudge', { to: 'r-2', why: '所办督办一下' }, RC))
620
- assert(officeNudge.r.ok === true, 'the OFFICE can nudge')
621
- assert(/【督办 from office】/.test(officeNudge.prompts) && /所办督办/.test(officeNudge.prompts),
622
- 'an office nudge is labelled 所办督办 by the office, not 院士督办')
623
- assert(await callTool('vibe_v5_prioritize', { order: [], why: 'x' }, RC).then(r => r.ok === false), 'the office still hits argument validation (resolved AS the office)')
624
- await endCase(RC)
625
-
626
- // =============== CASE 4: framework feedback delivery ============================
627
- section('4 framework feedback reaches the member (never dropped as a self-message)')
628
- const RD = makeRoot()
629
- await callTool('vibe_v5_start', { problem: '框架反馈投递测试', researcherCount: 2 }, RD)
630
- for (const sp of spawnsFor(RD)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
631
- await settleInstitute(RD)
632
- await callTool('vibe_v5_set', { maxParallel: 8 }, RD)
633
- const rd1 = childOf(RD, 'r-1'), rd2 = childOf(RD, 'r-2')
634
- // A member can only be answered while a turn of its own is in flight, so each case
635
- // below first WAKES r-1 and then lets its reply be the malformed one.
636
- const wakeR1With = async (kind, reply) => {
637
- delivered.length = 0
638
- replyOverride.set('r-1', reply)
639
- await callTool('vibe_v5_say', { to: 'r-1', text: '请就当前状态给个结论。' }, childAgent(rd2))
640
- await settle(); await drainWakes(20, RD)
641
- const handled = delivered.slice()
642
- for (const w of handled) if (/【框架提示】/.test(w.prompt)) recordAndCheck(kind, w.owner, w.prompt).length
643
- return handled.map(w => w.prompt).join('\n')
644
- }
645
- const badVerdictText = await wakeR1With('notice', { verdict: { target: 'p-ghost', verdict: 'not-a-number', reason: 'x' }, contextPct: 20 })
646
- assert(/【框架提示】/.test(badVerdictText), 'a malformed verdict produces a real 【框架提示】 delivery')
647
- assert(/verdict 必须是 0-1 的数值/.test(badVerdictText), 'the notice says what was wrong')
648
- assert(/【框架提示】[^\n]*verdict/.test(badVerdictText), 'the notice is framed by the framework, not by the member itself')
649
- const badClaimText = await wakeR1With('notice-claim', { task_claim: 't-999', contextPct: 20 })
650
- assert(/【框架提示】/.test(badClaimText) && /没有任务 t-999/.test(badClaimText), 'a refused claim is reported back to the claimer')
651
- // A CAS refusal reported through the JSON reply must also come back.
652
- const t = await callTool('vibe_v5_task_create', { subject: '一个没人认领的任务' }, childAgent(rd1))
653
- const staleText = await wakeR1With('notice-task', { task_update: { task_id: t.task.id, expected_revision: 99, action: 'claim' }, contextPct: 20 })
654
- assert(/【框架提示】/.test(staleText) && /V5_TASK_STALE_REVISION/.test(staleText), 'a stale CAS reported through the reply channel is echoed back')
655
- const selfFramed = corpus.filter(c => new RegExp('【研究所·私信 from ' + c.owner + '】').test(c.prompt))
656
- assert(selfFramed.length === 0, 'no member ever receives a message framed as coming from itself')
657
- await endCase(RD)
658
-
659
- // =============== CASE 5: temp workers ===========================================
660
- section('5 a hired temp worker is told its own name, employer and purpose')
661
- const RE = makeRoot()
662
- await callTool('vibe_v5_start', { problem: '临时工入职测试', researcherCount: 2 }, RE)
663
- for (const sp of spawnsFor(RE)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
664
- await settleInstitute(RE)
665
- const hired = await callTool('vibe_v5_hire', { purpose: '核对文献引理', initial_task: '核对第 3 节引理' }, childAgent(childOf(RE, 'r-1')))
666
- assert(hired.ok === true, 'r-1 hired a temp worker (' + JSON.stringify(hired).slice(0, 80) + ')')
667
- const tempSpawn = spawnOf(RE, hired.id)
668
- recordAndCheck('founding-temp', hired.id, tempSpawn.prompt, tempSpawn)
669
- {
670
- const st = parseState(tempSpawn.prompt) || {}
671
- assert(st.kind === '临时工', 'the temp brief calls it 临时工 (got ' + st.kind + ')')
672
- assert((rosterOf(tempSpawn.prompt) || []).indexOf(hired.id) !== -1, 'the temp brief lists the temp itself on the roster')
673
- checkPersona('founding-temp', hired.id, tempSpawn.persona, [
674
- [new RegExp('代号 ' + hired.id + ',由 r-1 雇入'), 'names itself and its true employer'],
675
- [/用途:核对文献引理/, 'states its purpose'],
676
- [new RegExp('Members/' + hired.id + '/'), 'points at its own library'],
677
- [/你的雇主:r-1/, 'names its employer'],
678
- ])
679
- assert(/【入职首轮 —— 临时工 t-\d+】/.test(tempSpawn.prompt), 'the temp brief is framed as its induction')
680
- assert(/你的初始任务\/用途:/.test(tempSpawn.prompt) && /核对第 3 节引理/.test(tempSpawn.prompt), 'the temp brief carries its initial task')
681
- assert(!/"verdict":/.test(tempSpawn.prompt) || /"verdict" 字段对你不适用/.test(tempSpawn.prompt), 'the temp brief states it has no vote')
682
- assert(!/"hire":/.test(tempSpawn.prompt), 'the temp brief does not offer hire')
683
- }
684
- for (const sp of spawnsFor(RE)) { if (!sp._ended) { sp._ended = true; fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':收到。', solved: false, contextPct: 10 }); await settle() } }
685
- await settleInstitute(RE)
686
-
687
- // =============== CASE 6: failed provisioning is visible =========================
688
- section('6 a member that failed to provision is visible, not a phantom')
689
- failNextStarts = 1
690
- const failedHire = await callTool('vibe_v5_hire', { purpose: '注定失败', initial_task: 'x' }, childAgent(childOf(RE, 'r-1')))
691
- assert(failedHire.ok === false, 'a provisioning failure is reported to the hirer (' + JSON.stringify(failedHire).slice(0, 100) + ')')
692
- const stFail = await callTool('vibe_v5_status', {}, RE)
693
- const failedMember = stFail.members.find(m => m.phase === 'failed')
694
- assert(!!failedMember, 'the member is recorded as failed rather than left active')
695
- assert(!failedMember || failedMember.busy !== true, 'a failed member is not left marked busy')
696
- delivered.length = 0
697
- await callTool('vibe_v5_say', { to: 'r-2', text: '看下编制。' }, childAgent(childOf(RE, 'r-1')))
698
- await settle(); await drainWakes(20, RE)
699
- const failPrompts = delivered.slice()
700
- for (const w of failPrompts) recordAndCheck('after-failure', w.owner, w.prompt).length
701
- const failText = failPrompts.map(w => w.prompt).join('\n')
702
- assert(failPrompts.length > 0, 'a member was woken after the failure (' + failPrompts.length + ')')
703
- assert(new RegExp('\\[未就位\\][^\\n]*' + failedMember.id).test(failText), 'the failed member appears in [未就位] with its id')
704
- assert(!new RegExp('\\[在册\\][^\\n]*' + failedMember.id).test(failText), 'the failed member is NOT listed as if it were on the roster')
705
- await endCase(RE)
706
-
707
- // =============== CASE 7: session rebuild ========================================
708
- section('7 a rebuilt session is told it was rebuilt, not that it just joined')
709
- const RF = makeRoot()
710
- await callTool('vibe_v5_start', { problem: '会话重建测试', researcherCount: 2 }, RF)
711
- for (const sp of spawnsFor(RF)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
712
- await settleInstitute(RF)
713
- const personasBefore = {}
714
- for (const sp of spawnsFor(RF)) personasBefore[memberOfChild(sp.childId)] = sp.persona
715
- await callTool('vibe_v5_stop', {}, RF)
716
- await settle()
717
- const spawnCountBefore = spawns.length
718
- const resumed = await callTool('vibe_v5_resume', {}, RF)
719
- assert(resumed.ok === true, 'the institute resumed (' + JSON.stringify(resumed).slice(0, 120) + ')')
720
- const resumeSpawns = spawns.slice(spawnCountBefore).filter(s => s.rootId === RF.id)
721
- assert(resumeSpawns.length > 0, 'resume rebuilt at least one member session (' + resumeSpawns.length + ')')
722
- for (const sp of resumeSpawns) {
723
- const owner = memberOfChild(sp.childId)
724
- recordAndCheck('resume', owner, sp.prompt, sp)
725
- assert(sp.prompt.indexOf('【会话重建 —— ') === 0, owner + "'s rebuilt session is framed 会话重建, not 入职首轮")
726
- assert(sp.prompt.indexOf('你刚刚加入本所') === -1, owner + ' is NOT told "你刚刚加入本所" on resume')
727
- assert(sp.prompt.indexOf('不要从头再来') !== -1, owner + ' is told to read back its progress instead of restarting')
728
- assert(sp.persona === personasBefore[owner], owner + "'s charter is the FROZEN hire-time one, not a resumed-time rewrite")
729
- }
730
- await endCase(RF)
731
-
732
- // =============== CASE 8: leaderless institute ===================================
733
- section('8 with academician:false no charter invents a leader')
734
- const RG = makeRoot()
735
- const l2 = await callTool('vibe_v5_start', { problem: '无院士建所', researcherCount: 2, academician: false }, RG)
736
- assert(l2.ok === true, 'a leaderless institute can be founded (' + JSON.stringify(l2).slice(0, 100) + ')')
737
- const l2spawns = spawnsFor(RG)
738
- assert(l2spawns.length === 2, 'two researchers were founded and no academician (got ' + l2spawns.length + ')')
739
- for (let i = 0; i < l2spawns.length; i++) {
740
- const sp = l2spawns[i]
741
- const owner = memberOfChild(sp.childId)
742
- recordAndCheck('founding-leaderless', owner, sp.prompt, sp)
743
- const st = parseState(sp.prompt) || {}
744
- assert(st.m === Math.min(3, i + 1), owner + ': m is computed over the leaderless roster INCLUDING itself (m=' + st.m + ')')
745
- assert(st.kind === '常驻研究员', owner + ' is a 常驻研究员 (got ' + st.kind + ')')
746
- assert(/在册院士:(无)/.test(sp.persona), owner + "'s charter records that there is no academician")
747
- assert(/本所当前\*\*没有在册院士\*\*/.test(sp.persona), owner + "'s charter says so in the organization section")
748
- assert(!/本所的领头人是\*\*院士/.test(sp.persona), owner + "'s charter does NOT claim a leader exists")
749
- assert(!/院士 acad/.test(sp.persona), owner + "'s charter never names a non-existent 院士 acad")
750
- assert(!/主动向院士汇报/.test(sp.persona), owner + "'s charter does not tell it to report to a non-existent academician")
751
- assert(!/院士也可以给你派活/.test(sp.persona), owner + "'s charter does not promise assignments from a non-existent academician")
752
- assert(!/院士可以直接分派任务/.test(sp.persona), owner + "'s charter does not promise academician assignment powers")
753
- }
754
- const l2status = await callTool('vibe_v5_status', {}, RG)
755
- assert(l2status.quorum.voters.indexOf('acad') === -1, 'the leaderless institute has no academician among its voters')
756
- await endCase(RG)
757
-
758
- // =============== CASE 8b: the JSON contract offered matches what is honoured =====
759
- section('8b the reply spec documents exactly the fields the framework honours')
760
- {
761
- const specKinds = corpus.filter(c => ['founding', 'founding-temp', 'founding-leaderless', 'normal', 'checkpoint'].indexOf(c.kind) !== -1)
762
- assert(specKinds.length >= 6, 'the corpus has round prompts to check the reply spec on (' + specKinds.length + ')')
763
- for (const c of specKinds) {
764
- const isTemp = /^t-/.test(c.owner)
765
- const isAcad = c.owner === 'acad'
766
- if (isTemp) {
767
- assert(c.prompt.indexOf('"verdict" 字段对你不适用') !== -1, c.owner + ' (temp) is told it has no vote')
768
- assert(!/"hire":/.test(c.prompt) && !/"fire":/.test(c.prompt), c.owner + ' (temp) is not offered hire/fire')
769
- } else {
770
- assert(/"verdict":/.test(c.prompt), c.owner + ' is offered the verdict field')
771
- assert(/"hire":/.test(c.prompt) && /"fire":/.test(c.prompt), c.owner + ' is offered hire/fire')
772
- }
773
- if (isAcad) {
774
- assert(/"assign":/.test(c.prompt) && /"prioritize":/.test(c.prompt) && /"nudge":/.test(c.prompt) && /"convene_meeting":/.test(c.prompt),
775
- c.owner + ' (academician) is offered its organizational fields')
776
- } else {
777
- assert(!/"assign":/.test(c.prompt) && !/"prioritize":/.test(c.prompt), c.owner + ' is not offered academician-only fields')
778
- }
779
- // A field the framework HONOURS but never documents is an unreachable channel: the
780
- // member cannot object to an assignment, close a task, or fill a meeting input.
781
- assert(/"reject_assign":/.test(c.prompt), c.owner + ' is told about reject_assign (the objection channel is reachable)')
782
- assert(/"task_done":/.test(c.prompt), c.owner + ' is told about task_done')
783
- assert(/"input":/.test(c.prompt), c.owner + ' is told about the meeting "input" field')
784
- }
785
- }
786
-
787
- // =============== CASE 9: verification prompts ===================================
788
- section('9 verification — voters are asked by name about the right object')
789
- const RH = makeRoot()
790
- await callTool('vibe_v5_start', { problem: '表决提示词测试', researcherCount: 2 }, RH)
791
- for (const sp of spawnsFor(RH)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
792
- await settleInstitute(RH)
793
- await callTool('vibe_v5_set', { maxParallel: 8 }, RH)
794
- await callTool('vibe_v5_record_proposition', { id: 'p-lemma-a', title: '引理甲', statement: '若 n>2 则不存在整数解。', value: 0.6, motive: '用于归约', p: 0.8 }, childAgent(childOf(RH, 'r-1')))
795
- delivered.length = 0
796
- const proposed = await callTool('vibe_v5_propose_verify', { target: 'p-lemma-a', kind: 'proposition', reason: '已有证明' }, childAgent(childOf(RH, 'r-1')))
797
- assert(proposed.ok === true, 'the object was proposed for verification')
798
- await settle(); await drainWakes(3, RH)
799
- const verifyPrompts = delivered.filter(w => /【求真表决/.test(w.prompt))
800
- assert(verifyPrompts.length === 3, 'exactly the three voters were asked, and no temp/non-voter (' + verifyPrompts.length + ')')
801
- for (const w of verifyPrompts) {
802
- recordAndCheck('verify', w.owner, w.prompt)
803
- assert(w.prompt.indexOf('【求真表决 —— ') === 0, w.owner + "'s voting prompt is framed as a vote")
804
- assert(w.prompt.indexOf(' ' + w.owner + ' 就对象 p-lemma-a 投票】') !== -1, w.owner + "'s voting prompt names itself and the object")
805
- assert(w.prompt.indexOf('引理甲') !== -1 || w.prompt.indexOf('若 n>2 则不存在整数解') !== -1, w.owner + "'s voting prompt shows the object statement")
806
- assert(/"target"\s*:\s*"p-lemma-a"/.test(w.prompt), w.owner + "'s voting prompt ends with the exact JSON the plugin parses")
807
- assert(w.prompt.indexOf('verdict = 1') !== -1 && w.prompt.indexOf('verdict = 0') !== -1, w.owner + ' is told the boolean rule')
808
- }
809
- // Now actually reach a debate round, to exercise the DEBATE-stage prompt.
810
- await callTool('vibe_v5_set', { verdictMaxRounds: 3 }, RH)
811
- votePlan = new Map([['acad', 0.5], ['r-1', 1], ['r-2', 0.5]])
812
- delivered.length = 0
813
- await drainWakes(3, RH) // round 1: not enough boolean votes -> debate
814
- const stillOpen = await callTool('vibe_v5_status', {}, RH)
815
- assert(!!stillOpen.verify, 'the verification is still open after abstentions')
816
- delivered.length = 0
817
- await drainWakes(3, RH) // round 2 (debate) is asked
818
- const debatePrompts = delivered.filter(w => /【求真表决/.test(w.prompt))
819
- assert(debatePrompts.length === 3, 'the debate round re-asks every voter (' + debatePrompts.length + ')')
820
- for (const w of debatePrompts) {
821
- recordAndCheck('verify-debate', w.owner, w.prompt)
822
- assert(/### 上一轮各成员的意见/.test(w.prompt), w.owner + "'s debate prompt publishes the previous round's opinions")
823
- assert(/verdict=1/.test(w.prompt) && /verdict=0\.5/.test(w.prompt), w.owner + "'s debate prompt shows the real per-member verdicts")
824
- assert(w.prompt.indexOf('- ' + w.owner + ':') !== -1, w.owner + "'s debate prompt shows its OWN previous vote so it can revise it")
825
- const hist = /### 上一轮各成员的意见[\s\S]*?(?:\n\n|$)/.exec(w.prompt)
826
- const histIds = hist ? (hist[0].match(/^- (\S+?):/gm) || []).map(s => s.slice(2, -1)) : []
827
- assert(histIds.slice().sort().join(',') === 'acad,r-1,r-2', w.owner + "'s debate prompt publishes exactly the voters' opinions (got " + histIds.join('、') + ')')
828
- }
829
- await endCase(RH)
830
-
831
- // =============== CASE 9b: only ONE verification at a time =========================
832
- section('9b a second proposal QUEUES; it never starts a concurrent verification')
833
- const RL = makeRoot()
834
- await callTool('vibe_v5_start', { problem: '并发表决测试', researcherCount: 1 }, RL)
835
- for (const sp of spawnsFor(RL)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
836
- await settleInstitute(RL)
837
- await callTool('vibe_v5_set', { maxParallel: 8 }, RL)
838
- const rl1 = childOf(RL, 'r-1')
839
- await callTool('vibe_v5_record_proposition', { id: 'p-first', statement: '第一个对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(rl1))
840
- await callTool('vibe_v5_record_proposition', { id: 'p-second', statement: '第二个对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(rl1))
841
- await callTool('vibe_v5_propose_verify', { target: 'p-first', kind: 'proposition', reason: '先做这个' }, childAgent(rl1))
842
- await settle()
843
- const stq0 = await callTool('vibe_v5_status', {}, RL)
844
- assert(!!stq0.verify && stq0.verify.target === 'p-first', 'the first object is under verification')
845
- // Proposing a second object while one is in flight must QUEUE it. The whole point of the
846
- // one-at-a-time rule is that consensus is never split across two live ballots; a
847
- // regression here would silently start a second ballot and drop the object from the queue.
848
- await callTool('vibe_v5_propose_verify', { target: 'p-second', kind: 'proposition', reason: '排后面' }, childAgent(rl1))
849
- await settle()
850
- const stq1 = await callTool('vibe_v5_status', {}, RL)
851
- assert(!!stq1.verify && stq1.verify.target === 'p-first', 'the in-flight ballot is still the first object')
852
- assert((stq1.verifyQueue || []).indexOf('p-second') !== -1,
853
- 'the second proposal is still QUEUED, not begun concurrently (queue=' + JSON.stringify(stq1.verifyQueue) + ')')
854
- assert(stq1.undecided.length === 0 && stq1.verified.length === 0, 'nothing was settled by merely proposing')
855
- // Once the first ballot settles, the queued one starts on its own.
856
- await callTool('vibe_v5_set', { verdictMaxRounds: 1 }, RL)
857
- delivered.length = 0
858
- await drainWakes(20, RL)
859
- const stq2 = await callTool('vibe_v5_status', {}, RL)
860
- assert(stq2.verify === null || stq2.verify.target === 'p-second',
861
- 'the queued object took over after the first ballot closed (now: ' + JSON.stringify(stq2.verify && stq2.verify.target) + ')')
862
- await endCase(RL)
863
-
864
- // =============== CASE 9c: a solve vote OUTSIDE a meeting ==========================
865
- section('9c a unanimous solve vote landing outside a meeting still triggers the stop evaluation')
866
- const RM = makeRoot()
867
- await callTool('vibe_v5_start', { problem: '会外表决停工测试', researcherCount: 1 }, RM)
868
- for (const sp of spawnsFor(RM)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
869
- await settleInstitute(RM)
870
- await callTool('vibe_v5_set', { maxParallel: 8 }, RM)
871
- const solvedReply = { vote_solved: true, solved: true, progress: '我认为原问题已解决。', contextPct: 20 }
872
- replyOverride.set('acad', solvedReply)
873
- replyOverride.set('r-1', solvedReply)
874
- await callTool('vibe_v5_say', { to: 'acad', text: '请你就"是否已解决"表态。' }, childAgent(childOf(RM, 'r-1')))
875
- await settle(); await drainWakes(4, RM)
876
- await callTool('vibe_v5_say', { to: 'r-1', text: '请你就"是否已解决"表态。' }, childAgent(childOf(RM, 'acad')))
877
- await settle(); await drainWakes(4, RM)
878
- const stSolved = await callTool('vibe_v5_status', {}, RM)
879
- assert(stSolved.solveVotes.length >= 2, 'both voters recorded a solve vote outside any meeting (' + JSON.stringify(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 }))
888
- await endCase(RM)
889
-
890
- // =============== CASE 10: meeting prompts =======================================
891
- section('10 meeting — real speakers, real transcript keys')
892
- const RI = makeRoot()
893
- await callTool('vibe_v5_start', { problem: '会议提示词测试', researcherCount: 1 }, RI)
894
- for (const sp of spawnsFor(RI)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
895
- await settleInstitute(RI)
896
- await callTool('vibe_v5_set', { maxParallel: 8 }, RI)
897
- delivered.length = 0
898
- const mtg = await callTool('vibe_v5_meeting', { agenda: '分工与下一步', kind: 'sync' }, childAgent(childOf(RI, 'acad')))
899
- assert(mtg.ok === true, 'the academician convened a meeting (' + JSON.stringify(mtg).slice(0, 90) + ')')
900
- await settle(); await drainWakes(20, RI)
901
- const meetingOne = delivered.filter(w => /【研究所会议/.test(w.prompt))
902
- assert(meetingOne.length >= 2, 'both members were asked to speak (' + meetingOne.length + ')')
903
- for (const w of meetingOne) {
904
- recordAndCheck('meeting', w.owner, w.prompt)
905
- assert(w.prompt.indexOf('【研究所会议 mt-1 进行中 —— ') === 0, w.owner + "'s meeting prompt is framed with the meeting id")
906
- assert(w.prompt.indexOf('分工与下一步') !== -1, w.owner + "'s meeting prompt carries the agenda")
907
- assert(w.prompt.indexOf('"input"') !== -1, w.owner + "'s meeting prompt documents the input field it must fill")
908
- }
909
- await settleInstitute(RI)
910
- const stMtg = await callTool('vibe_v5_status', {}, RI)
911
- assert(stMtg.meeting === null, 'the meeting finished instead of deadlocking')
912
- const minutes = join(wsOf(RI), 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'Meetings', 'mt-1.md')
913
- assert(existsSync(minutes), 'the meeting minutes were written')
914
- if (existsSync(minutes)) {
915
- const t = readFileSync(minutes, 'utf8')
916
- assert(/### acad/.test(t) && /### r-1/.test(t), 'the minutes key each speech by its real member id')
917
- assert(/有表决权者:acad、r-1/.test(t), 'the minutes list the real voting members')
918
- }
919
- // A non-academician may only PROPOSE a meeting; the relay must be signed by the proposer.
920
- delivered.length = 0
921
- const propMtg = await callTool('vibe_v5_meeting', { agenda: '我提议讨论路线', kind: 'sync' }, childAgent(childOf(RI, 'r-1')))
922
- assert(propMtg.ok === true && propMtg.proposed === true, 'a researcher can only PROPOSE a meeting (' + JSON.stringify(propMtg).slice(0, 80) + ')')
923
- await settle(); await drainWakes(20, RI)
924
- const propText = delivered.map(w => w.prompt).join('\n')
925
- for (const w of delivered) record('meeting-proposal', w.owner, w.prompt)
926
- assert(/【研究所·致全体表决者 from r-1】[^\n]*提议开会/.test(propText),
927
- 'the meeting proposal is relayed SIGNED BY ITS TRUE PROPOSER r-1, not by whoever was woken last')
928
- await endCase(RI)
929
-
930
- // =============== CASE 10b: meetings and verifications are mutually exclusive ======
931
- section('10b a verification proposed DURING a meeting must queue, never preempt it')
932
- const RN = makeRoot()
933
- await callTool('vibe_v5_start', { problem: '会议与验证互斥测试', researcherCount: 1 }, RN)
934
- for (const sp of spawnsFor(RN)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
935
- await settleInstitute(RN)
936
- await callTool('vibe_v5_set', { maxParallel: 8 }, RN)
937
- await callTool('vibe_v5_record_proposition', { id: 'p-mid', statement: '会议期间提出的对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(childOf(RN, 'r-1')))
938
- // Convene a meeting and stop before it has collected every input, so it stays in flight.
939
- hushed.add(RN.id)
940
- delivered.length = 0
941
- const convened = await callTool('vibe_v5_meeting', { agenda: '先开这个会', kind: 'sync' }, childAgent(childOf(RN, 'acad')))
942
- assert(convened.ok === true, 'a meeting was convened (' + JSON.stringify(convened).slice(0, 80) + ')')
943
- await settle(); await drainWakes(1, RN)
944
- const during = await callTool('vibe_v5_status', {}, RN)
945
- assert(!!during.meeting, 'the meeting is still in flight (not everyone has spoken)')
946
- // A member proposing a verification mid-meeting must NOT start a second, concurrent
947
- // consensus process: the design says meetings and verifications never overlap, and a
948
- // verification that preempts a meeting starves the meeting's watchdog clock.
949
- const propMid = await callTool('vibe_v5_propose_verify', { target: 'p-mid', kind: 'proposition', reason: '想在会上定' }, childAgent(childOf(RN, 'r-1')))
950
- assert(propMid.ok === true, 'the proposal is accepted (' + JSON.stringify(propMid).slice(0, 90) + ')')
951
- await settle()
952
- const afterProp = await callTool('vibe_v5_status', {}, RN)
953
- assert(!!afterProp.meeting, 'the meeting is STILL in flight after the proposal')
954
- assert(afterProp.verify === null,
955
- 'NO verification started while the meeting was in flight (got ' + JSON.stringify(afterProp.verify && afterProp.verify.target) + ')')
956
- assert((afterProp.verifyQueue || []).indexOf('p-mid') !== -1,
957
- 'the proposal is QUEUED instead (queue=' + JSON.stringify(afterProp.verifyQueue) + ')')
958
- // Once the meeting ends, the queued proposal must run — queueing must not drop it.
959
- hushed.delete(RN.id)
960
- await settleInstitute(RN)
961
- const afterMtg = await callTool('vibe_v5_status', {}, RN)
962
- assert(afterMtg.meeting === null, 'the meeting finished')
963
- assert(afterMtg.verify !== null || afterMtg.undecided.indexOf('p-mid') !== -1 || afterMtg.verified.indexOf('p-mid') !== -1,
964
- 'the queued proposal was started after the meeting ended (verify=' + JSON.stringify(afterMtg.verify && afterMtg.verify.target)
965
- + ', queue=' + JSON.stringify(afterMtg.verifyQueue) + ')')
966
- await endCase(RN)
967
-
968
- // =============== CASE 11: no unpaced re-wake loop ===============================
969
- section('11 a task owner is pushed on a PACED cadence, not in a tight loop')
970
- const RJ = makeRoot()
971
- await callTool('vibe_v5_start', { problem: '调度节奏测试', researcherCount: 1 }, RJ)
972
- for (const sp of spawnsFor(RJ)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
973
- await settleInstitute(RJ)
974
- const asg = await callTool('vibe_v5_assign', { subject: '一个长任务', to: 'r-1', why: '你最合适', acceptance: '给出结果' }, childAgent(childOf(RJ, 'acad')))
975
- assert(asg.ok === true, 'a task was assigned to r-1')
976
- await settle(); await drainWakes(6, RJ)
977
- const afterAssign = await callTool('vibe_v5_status', {}, RJ)
978
- assert(afterAssign.tasks.some(t => t.status === 'in_progress' && t.ownerId === 'r-1'), 'r-1 still owns in-progress work')
979
- assert(!afterAssign.members.some(m => m.busy), 'r-1 is idle again after answering')
980
- // A task owner must NOT be re-woken the moment its turn ends: the work push is paced by
981
- // activityTimeoutMs (120 s here). Without the pace, one unfinished task became an
982
- // unbounded wake -> turn -> wake chain that burned tokens with no backoff at all.
983
- await sleep(500); await settle()
984
- const unpaced = wakes.filter(w => w.rootId === RJ.id)
985
- assert(unpaced.length === 0, 'no unpaced re-wake of the task owner within the idle window (got ' + unpaced.length + ')')
986
- await endCase(RJ)
987
-
988
- // =============== CASE 12: Lean mode prompt text =================================
989
- section('12 Lean formal-verification text enters the prompts (and the corpus)')
990
- const RK = makeRoot()
991
- await callTool('vibe_v5_start', { problem: 'Lean 提示词测试', researcherCount: 2 }, RK)
992
- for (const sp of spawnsFor(RK)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
993
- await settleInstitute(RK)
994
- await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, RK)
995
- // (a) an ordinary work round carries the "formalize reusable things as you go" request
996
- delivered.length = 0
997
- await callTool('vibe_v5_say', { to: 'r-1', text: '继续推进。' }, childAgent(childOf(RK, 'acad')))
998
- await settle(); await drainWakes(3, RK)
999
- for (const w of delivered.filter(d => d.rootId === RK.id)) recordAndCheck('lean-work', w.owner, w.prompt)
1000
- {
1001
- const txt = delivered.filter(d => d.rootId === RK.id).map(d => d.prompt).join('\n')
1002
- assert(/\[形式化\] 鼓励 Lean/.test(txt), 'the state block announces the Lean mode with its counts')
1003
- assert(/【顺手形式化(鼓励)】/.test(txt), 'the work round asks for reusable objects to be formalized as work proceeds')
1004
- }
1005
- // (b) a voting round on an object WITHOUT a proof carries the "decide by difficulty" block
1006
- await callTool('vibe_v5_record_proposition', { id: 'p-lean-a', statement: 'Lean 语料对象甲', value: 0.6, motive: 'm', p: 0.8 }, childAgent(childOf(RK, 'r-1')))
1007
- const propA = await callTool('vibe_v5_propose_verify', { target: 'p-lean-a', kind: 'proposition', reason: '语料' }, childAgent(childOf(RK, 'r-1')))
1008
- assert(propA.ok === true && propA.started === true, 'the Lean corpus ballot for object 甲 actually started (' + JSON.stringify(propA).slice(0, 90) + ')')
1009
- delivered.length = 0
1010
- const vwA = await takeVerifyPrompts(RK, 3)
1011
- assert(vwA.length === 3, 'captured three voting prompts for object 甲 (got ' + vwA.length + ')')
1012
- for (const w of vwA) recordAndCheck('lean-verify', memberOfChild(w.childId), w.prompt)
1013
- {
1014
- const txt = delivered.filter(d => d.rootId === RK.id).map(d => d.prompt).join('\n')
1015
- assert(/【Lean 形式化验证(鼓励模式)】/.test(txt), 'the voting prompt explains the Lean mode')
1016
- assert(/你唯一需要确认的就是忠实性/.test(txt), 'the voting prompt states the fidelity question')
1017
- }
1018
- await endCase(RK)
1019
- // (c) once a proof passes, the voting prompt switches to the fidelity review. This uses its
1020
- // own root: object 甲's ballot may still be in flight above, and a queued proposal would
1021
- // make the drained prompts belong to the WRONG ballot (the assertion would then fail for a
1022
- // reason that has nothing to do with the feature).
1023
- const RL2 = makeRoot()
1024
- await callTool('vibe_v5_start', { problem: 'Lean 忠实性提示词测试', researcherCount: 2 }, RL2)
1025
- for (const sp of spawnsFor(RL2)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1026
- await settleInstitute(RL2)
1027
- await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, RL2)
1028
- await callTool('vibe_v5_record_proposition', { id: 'p-lean-b', statement: 'Lean 语料对象乙', value: 0.6, motive: 'm', p: 0.9 }, childAgent(childOf(RL2, 'r-1')))
1029
- const leanB = await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-b', content: 'theorem p_lean_b : 1 + 1 = 2 := by decide\n' }, childAgent(childOf(RL2, 'r-1')))
1030
- assert(leanB.ok === true && leanB.passed === true, 'object 乙 has a proof that really passes (' + JSON.stringify({ ok: leanB.ok, passed: leanB.passed, code: leanB.run && leanB.run.code }) + ')')
1031
- const propB = await callTool('vibe_v5_propose_verify', { target: 'p-lean-b', kind: 'proposition', reason: '已有证明' }, childAgent(childOf(RL2, 'r-1')))
1032
- assert(propB.ok === true && propB.started === true, 'the Lean corpus ballot for object 乙 actually started (' + JSON.stringify(propB).slice(0, 90) + ')')
1033
- delivered.length = 0
1034
- const vwB = await takeVerifyPrompts(RL2, 3)
1035
- assert(vwB.length === 3, 'captured three voting prompts for object 乙 (got ' + vwB.length + ')')
1036
- for (const w of vwB) recordAndCheck('lean-fidelity', memberOfChild(w.childId), w.prompt)
1037
- {
1038
- const txt = vwB.map(w => w.prompt).join('\n')
1039
- assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(txt), 'the prompt announces the passing proof')
1040
- assert(/你不需要重新检查推导/.test(txt), 'with a proof in hand the prompt tells voters not to re-derive')
1041
- assert(/忠实性审查/.test(txt), 'and asks for a fidelity review instead')
1042
- assert(/不要投 0/.test(txt), '★ it forbids expressing a faithfulness defect as 0 (= 命题为假)')
1043
- assert(/decision:'defect'/.test(txt), 'it names the defect reply channel that withdraws the proof')
1044
- assert(!/偏离 → 0/.test(txt), '★ the old "any deviation → 0" instruction is gone')
1045
- assert(/本档没有门禁:请务必给一个严格介于 0 与 1 之间的弃权值/.test(txt), '★ the encourage branch says there is NO gate and that the abstention is what blocks a conclusion')
1046
- assert(noBareLeanTool(txt), 'no abbreviated tool name appears in the fidelity prompt')
1047
- }
1048
- await drainWakes(10, RL2)
1049
- await endCase(RL2)
1050
-
1051
- // (d) `require` mode adds the conclusion gate to the voting prompt (contract §10 item 10)
1052
- const R_LEANREQ = makeRoot()
1053
- await callTool('vibe_v5_start', { problem: 'Lean require 提示词测试', researcherCount: 2 }, R_LEANREQ)
1054
- for (const sp of spawnsFor(R_LEANREQ)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1055
- await settleInstitute(R_LEANREQ)
1056
- await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'require', leanAsync: false }, R_LEANREQ)
1057
- await callTool('vibe_v5_record_proposition', { id: 'p-lean-req', statement: 'Lean 语料对象丙(require 档)', value: 0.6, motive: 'm', p: 0.8 }, childAgent(childOf(R_LEANREQ, 'r-1')))
1058
- const propR = await callTool('vibe_v5_propose_verify', { target: 'p-lean-req', kind: 'proposition', reason: '语料' }, childAgent(childOf(R_LEANREQ, 'r-1')))
1059
- assert(propR.ok === true && propR.started === true, 'the require-mode ballot started (' + JSON.stringify(propR).slice(0, 90) + ')')
1060
- delivered.length = 0
1061
- const vwR = await takeVerifyPrompts(R_LEANREQ, 3)
1062
- assert(vwR.length === 3, 'captured three require-mode voting prompts (got ' + vwR.length + ')')
1063
- for (const w of vwR) recordAndCheck('lean-require', memberOfChild(w.childId), w.prompt)
1064
- {
1065
- const txt = vwR.map(w => w.prompt).join('\n')
1066
- assert(/【Lean 形式化验证(强制模式)】/.test(txt), 'the require-mode block is labelled 强制')
1067
- assert(/本模式要求/.test(txt) && /formal-required/.test(txt), 'it states the conclusion gate and its reason code')
1068
- assert(/vibe_v5_lean_archive/.test(txt) && /kind='blocked'/.test(txt), 'it names the full archive tool for the blocker route')
1069
- assert(/宿主无 Lean 工具链/.test(txt), 'it also says what to do when the host has no Lean toolchain')
1070
- assert(/LEAN_NOT_FOUND/.test(txt) && /NO_SUBPROCESS/.test(txt), '★ it names BOTH no-toolchain codes (a service-less host must not look like an unknown failure)')
1071
- assert(noBareLeanTool(txt), 'no abbreviated tool name appears in the require-mode prompt')
1072
- }
1073
- await drainWakes(30, R_LEANREQ)
1074
- await endCase(R_LEANREQ)
1075
- // (e) after a fidelity DEFECT the member must see the withdrawal, never a stale "已通过"
1076
- const R_LEANDEF = makeRoot()
1077
- await callTool('vibe_v5_start', { problem: 'Lean 缺陷后提示词测试', researcherCount: 2 }, R_LEANDEF)
1078
- for (const sp of spawnsFor(R_LEANDEF)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1079
- await settleInstitute(R_LEANDEF)
1080
- await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, R_LEANDEF)
1081
- await callTool('vibe_v5_record_proposition', { id: 'p-lean-def', statement: 'Lean 语料对象丁', value: 0.6, motive: 'm', p: 0.9 }, childAgent(childOf(R_LEANDEF, 'r-1')))
1082
- const leanD = await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-def', content: 'theorem p_lean_d : 1 + 1 = 2 := by decide\n' }, childAgent(childOf(R_LEANDEF, 'r-1')))
1083
- assert(leanD.ok === true && leanD.passed === true, 'object 丁 has a passing proof before the defect')
1084
- delivered.length = 0
1085
- await callTool('vibe_v5_say', { to: 'r-1', text: '请核对形式化的忠实性。' }, childAgent(childOf(R_LEANDEF, 'acad')))
1086
- await settle()
1087
- fireEnd(childOf(R_LEANDEF, 'r-1'), { progress: '核对后发现偏差。', formal: { target: 'p-lean-def', decision: 'defect', note: '条件被加强:连续写成了逐点连续' }, contextPct: 20 })
1088
- await settle()
1089
- await drainWakes(8, R_LEANDEF)
1090
- const stRP = await callTool('vibe_v5_status', {}, R_LEANDEF)
1091
- const recRP = (stRP.formal.objects || []).find(o => o.target === 'p-lean-def') || {}
1092
- assert(recRP.status === 'attempted' && !recRP.proof, 'the defect reply withdrew the proof (status=' + recRP.status + ')')
1093
- delivered.length = 0
1094
- await callTool('vibe_v5_say', { to: 'r-1', text: '再继续。' }, childAgent(childOf(R_LEANDEF, 'acad')))
1095
- await settle(); await drainWakes(3, R_LEANDEF)
1096
- for (const w of delivered.filter(d => d.rootId === R_LEANDEF.id)) recordAndCheck('lean-after-defect', w.owner, w.prompt)
1097
- {
1098
- const txt = delivered.filter(d => d.rootId === R_LEANDEF.id).map(d => d.prompt).join('\n')
1099
- assert(!/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(txt), '★ the post-defect prompt no longer claims a passing proof')
1100
- assert(/已通过 0/.test(txt), '★ the state block reports zero Lean-passed objects after the withdrawal')
1101
- }
1102
- await endCase(R_LEANDEF)
1103
-
1104
- // (f) the three Lean tools' own agent-facing strings (hint / LEAN_NOT_FOUND message). A tool
1105
- // `hint` is injected text by the contract's own wording ("工具自己返回的 hint 字段同样算注入文本"),
1106
- // but it reached the corpus only through the prompts that happened to embed it. Capture it
1107
- // directly so a reviewer can check the failure routes without reading the plugin source.
1108
- const R_LEANHINT = makeRoot()
1109
- await callTool('vibe_v5_start', { problem: 'Lean 工具提示语料测试', researcherCount: 2 }, R_LEANHINT)
1110
- for (const sp of spawnsFor(R_LEANHINT)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1111
- await settleInstitute(R_LEANHINT)
1112
- await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, R_LEANHINT)
1113
- {
1114
- const r1 = childAgent(childOf(R_LEANHINT, 'r-1'))
1115
- // Labels are OURS (they name the call that produced the string); only the VALUES are
1116
- // plugin text, so the bare-tool-name check runs on the values, never on the labels.
1117
- const toolTexts = []
1118
- await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-hint', content: 'theorem p_lean_hint : 1 + 1 = 2 := by decide\n' }, r1)
1119
- const green = await callTool('vibe_v5_lean_run', { file: 'Formal/p-lean-hint.lean' }, r1)
1120
- toolTexts.push(['vibe_v5_lean_run hint (green)', String(green.hint || '')])
1121
- await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-hint-red', content: 'theorem p_lean_hint_red : 1 = 2 := by sorry\n' }, r1)
1122
- const red = await callTool('vibe_v5_lean_run', { file: 'Formal/p-lean-hint-red.lean' }, r1)
1123
- toolTexts.push(['vibe_v5_lean_run hint (red)', String(red.hint || '')])
1124
- const missing = await callTool('vibe_v5_lean_run', { file: 'Formal/no-such-file.lean' }, r1)
1125
- toolTexts.push(['vibe_v5_lean_run on a missing file', String(missing.code || '') + '|' + String(missing.message || '')])
1126
- const noName = await callTool('vibe_v5_lean_archive', { kind: 'def', content: 'def x := 1\n' }, r1)
1127
- toolTexts.push(['vibe_v5_lean_archive without a name', String(noName.code || '') + '|' + String(noName.message || '')])
1128
- const noNote = await callTool('vibe_v5_lean_archive', { kind: 'blocked', target: 'p-lean-hint' }, r1)
1129
- toolTexts.push(['vibe_v5_lean_archive blocked without a note', String(noNote.code || '') + '|' + String(noNote.message || '')])
1130
- const lib = await callTool('vibe_v5_lean_lib', {}, r1)
1131
- toolTexts.push(['vibe_v5_lean_lib hint', String(lib.hint || '')])
1132
- const values = toolTexts.map(([, v]) => v).join('\n')
1133
- assert(/vibe_v5_lean_archive/.test(values), 'the tool hints name the REGISTERED archive tool (an abbreviated lean_archive is not callable)')
1134
- assert(noBareLeanTool(values), 'no tool hint or failure message uses an abbreviated lean_* name')
1135
- assert(/V5_NOT_FOUND/.test(values) && /必须写明原因/.test(values), 'the failure routes are explicit (missing file / missing note)')
1136
- // NOT via recordAndCheck: these strings are tool output, not a round prompt, so they carry
1137
- // no [状态] block and the identity sweep does not apply to them.
1138
- record('lean-tool-hint', 'r-1', toolTexts.map(([k, v]) => k + ': ' + v).join('\n'))
1139
- }
1140
- await drainWakes(10, R_LEANHINT)
1141
- await endCase(R_LEANHINT)
1142
-
1143
- // =============== PART: full-corpus sweep ========================================
1144
- section('13 full-corpus sweep over every prompt ever sent')
1145
- {
1146
- let swept = 0
1147
- for (const sp of spawns) {
1148
- if (!sp.prompt) continue
1149
- swept++
1150
- checkPromptSweep(sp.prompt, memberOfChild(sp.childId), 'corpus spawn ' + memberOfChild(sp.childId))
1151
- }
1152
- assert(swept >= 12, 'the corpus inspected every founding/resume prompt in the process (' + swept + ')')
1153
- const owners = new Set(corpus.map(c => c.owner))
1154
- assert(owners.has('acad') && owners.has('r-1') && owners.has('r-2'), 'the corpus covers academician and researchers (' + [...owners].join('、') + ')')
1155
- assert([...owners].some(o => /^t-/.test(o)), 'the corpus covers a temp worker')
1156
- const kinds = new Set(corpus.map(c => c.kind))
1157
- for (const need of ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
1158
- 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
1159
- 'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure',
1160
- 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect',
1161
- 'lean-tool-hint']) {
1162
- assert(kinds.has(need), 'the corpus contains a ' + need + ' prompt')
1163
- }
1164
- assert(corpus.every(c => c.prompt && c.prompt.length > 200), 'no captured prompt is suspiciously short')
1165
- assert(corpus.every(c => !GARBAGE.some(g => g.test(c.prompt + (c.persona || '')))), 'no prompt or charter contains undefined/NaN/? garbage')
1166
- // A single prompt must not deliver the same message twice. The inbox used to be
1167
- // prepended AND re-emitted from the [状态] block, so a member read every new message
1168
- // twice in one prompt.
1169
- // A3 (assertion-quality review): this used to end with a vacuous `assert(true, …)` and to
1170
- // `break` after the FIRST duplicate frame, so (a) a property nobody checked was attributed to an
1171
- // assertion that asserted nothing, and (b) only one duplicate per prompt was ever reported.
1172
- // The loop now COLLECTS every duplicate frame and reports the count, and the summary assertion
1173
- // states exactly what was checked.
1174
- const duplicateFrames = []
1175
- let inboxOffenders = 0
1176
- for (const c of corpus) {
1177
- const bodies = c.prompt.match(/【[^】]*】[^\n]{20,}/g) || []
1178
- for (const frame of new Set(bodies)) {
1179
- const n = bodies.filter(b => b === frame).length
1180
- if (n > 1) duplicateFrames.push({ kind: c.kind, owner: c.owner, n, frame: frame.slice(0, 60) })
1181
- }
1182
- const inboxHeads = (c.prompt.match(/\[新到的消息/g) || []).length
1183
- if (inboxHeads > 1) inboxOffenders += 1
1184
- assert(inboxHeads <= 1, c.kind + '/' + c.owner + ': at most one inbox section per prompt (found ' + inboxHeads + ')')
1185
- }
1186
- assert(duplicateFrames.length === 0,
1187
- 'no prompt delivers the same framed message twice (duplicate frames found: ' + duplicateFrames.length
1188
- + (duplicateFrames.length ? ' — ' + duplicateFrames.slice(0, 5).map(d => d.kind + '/' + d.owner + ' ' + d.n + '× ' + d.frame).join(' | ') : '')
1189
- + '); prompts with two inbox sections: ' + inboxOffenders)
1190
- // The identity claim inside a prompt must agree with the persona shipped alongside it.
1191
- for (const c of corpus) {
1192
- if (!c.persona) continue
1193
- const st = parseState(c.prompt)
1194
- if (!st) continue
1195
- assert(c.persona.indexOf('Members/' + st.id + '/') !== -1,
1196
- c.kind + ': the charter shipped with ' + st.id + "'s prompt points at Members/" + st.id + '/')
1197
- }
1198
- }
1199
-
1200
- // =============== corpus dump ====================================================
1201
- section('14 the full prompt corpus is preserved for human review')
1202
- mkdirSync(CORPUS_DIR, { recursive: true })
1203
- const md = []
1204
- md.push('# Vibe Math V5 — 提示词与交互语料(自动生成,请勿手改)')
1205
- md.push('')
1206
- md.push('由 `prompt-v5-integrity.test.mjs` 在每次运行时重写。这里保存的是**框架真正发给每个')
1207
- md.push('成员的提示词原文**,用于人工复核提示词分配、成员代号与交互内容的正确性。')
1208
- md.push('')
1209
- md.push('- 生成时刻的工作区路径被替换为 `<WS>`,VibeMath 根被替换为 `<VIBEMATH>`(与 v2/v3/v4 的语料一致,可并排 diff),因此内容是确定性的、可 diff 的。')
1210
- md.push('- `owner` 是这条提示词**实际发给的成员**;`kind` 是提示词类型。')
1211
- md.push('- 人设(charter/persona)按成员只完整打印一次,其余条目只记录字符数。')
1212
- md.push('- 这是提示词正确性的人工复核入口:任何“成员代号/职位/在册名单/交互署名”问题')
1213
- md.push(' 都能在这里一眼看出,而不必去翻会话日志。')
1214
- md.push('')
1215
- const seenPersona = new Set()
1216
- const order = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
1217
- 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
1218
- 'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure',
1219
- 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect', 'lean-tool-hint']
1220
- // The sort must be TOTAL, not just by kind: entries of the same kind were emitted in whatever order
1221
- // the asynchronous drain produced them (two meeting prompts, two members' normal rounds), so the
1222
- // shipped corpus still changed between runs even after the clock was virtualised. Sorting by
1223
- // (kind, owner, prompt) makes the file a pure function of the recorded SET.
1224
- const sorted = corpus.slice().sort((a, b) =>
1225
- (order.indexOf(a.kind) - order.indexOf(b.kind)) ||
1226
- (a.owner < b.owner ? -1 : a.owner > b.owner ? 1 : 0) ||
1227
- (a.prompt < b.prompt ? -1 : a.prompt > b.prompt ? 1 : 0) ||
1228
- (String(a.toolFilter || '') < String(b.toolFilter || '') ? -1 : 1))
1229
- for (let i = 0; i < sorted.length; i++) {
1230
- const c = sorted[i]
1231
- md.push('---')
1232
- md.push('')
1233
- md.push('## [' + (i + 1) + '] kind=`' + c.kind + '` owner=`' + c.owner + '`')
1234
- md.push('')
1235
- if (c.toolFilter) md.push('- toolFilter: `' + JSON.stringify(c.toolFilter) + '`')
1236
- md.push('- charter: ' + (c.persona == null ? '(本次唤醒不带人设)' : c.persona.length + ' 字符'))
1237
- md.push('')
1238
- if (c.persona != null && !seenPersona.has(c.owner)) {
1239
- seenPersona.add(c.owner)
1240
- md.push('### 人设 / 规章(' + c.owner + ',仅首次完整打印)')
1241
- md.push('')
1242
- md.push('```text')
1243
- md.push(c.persona)
1244
- md.push('```')
1245
- md.push('')
1246
- }
1247
- md.push('### 提示词原文')
1248
- md.push('')
1249
- md.push('```text')
1250
- md.push(c.prompt)
1251
- md.push('```')
1252
- md.push('')
1253
- }
1254
- const byKind = {}
1255
- for (const c of sorted) byKind[c.kind] = (byKind[c.kind] || 0) + 1
1256
- md.push('---')
1257
- md.push('')
1258
- md.push('## 统计')
1259
- md.push('')
1260
- for (const k of Object.keys(byKind).sort()) md.push('- `' + k + '`: ' + byKind[k])
1261
- md.push('')
1262
- md.push('- 合计:' + corpus.length + ' 条提示词')
1263
- md.push('')
1264
- const mdPath = join(CORPUS_DIR, 'prompt-corpus-v5.md')
1265
- writeFileSync(mdPath, md.join('\n'), 'utf8')
1266
- writeFileSync(join(CORPUS_DIR, 'prompt-corpus-v5.json'), JSON.stringify({
1267
- note: 'Vibe Math V5 prompt/interaction corpus — generated by prompt-v5-integrity.test.mjs. <WS> = the run workspace.',
1268
- counts: byKind, total: sorted.length,
1269
- prompts: sorted.map(c => ({
1270
- kind: c.kind, owner: c.owner, sentToLabel: c.sentToLabel,
1271
- charterChars: c.persona == null ? null : c.persona.length,
1272
- charter: c.persona, toolFilter: c.toolFilter, prompt: c.prompt,
1273
- })),
1274
- }, null, 2), 'utf8')
1275
- assert(existsSync(mdPath), 'the prompt corpus Markdown was written')
1276
- assert(existsSync(join(CORPUS_DIR, 'prompt-corpus-v5.json')), 'the prompt corpus JSON was written')
1277
- const corpusMd = readFileSync(mdPath, 'utf8')
1278
- assert(corpusMd.length > 30000, 'the corpus is substantial (' + corpusMd.length + ' chars) — the real prompt text is preserved')
1279
- assert(corpusMd.indexOf('[状态] 你是 acad(院士)') !== -1, 'a human can verify the academician brief verbatim')
1280
- assert(corpusMd.indexOf('[状态] 你是 r-2') !== -1, 'a human can verify a researcher brief naming itself')
1281
- assert(corpusMd.indexOf('【框架提示】') !== -1, 'the corpus contains the framework-feedback interaction')
1282
- assert(corpusMd.indexOf('【会话重建 —— ') !== -1, 'the corpus contains a resume brief')
1283
- assert(corpusMd.indexOf('【所办分派】') !== -1, 'the corpus contains an office assignment')
1284
- assert(!/你是 \?/.test(corpusMd), 'the corpus contains NO wrong-identity "?" brief')
1285
-
1286
- console.log('')
1287
- console.log('corpus: ' + mdPath)
1288
- console.log('passed=' + passed + ' failed=' + failed)
1289
- if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
1290
- console.log('ALL GREEN')
1291
- process.exit(0)
1
+ // ============================================================
2
+ // Vibe-Math-V5 PROMPT & INTERACTION INTEGRITY SUITE
3
+ //
4
+ // Why this suite exists
5
+ // ---------------------
6
+ // The 2026-09 field test (D:\wd\vibemath测试\项目测试1) exposed a fatal class of bug that
7
+ // 123 pre-existing assertions could not see: the framework built every member's brief
8
+ // from a mutable "currentMember" global, so each member was told it was SOMEBODY ELSE —
9
+ // "[状态] 你是 r-2" appeared inside r-3's induction brief, and the academician's brief
10
+ // said "你是 ?(常驻研究员)… 有表决权者 0 人". Nothing asserted the TEXT a member
11
+ // actually reads, so the entire class was invisible to the test suite.
12
+ //
13
+ // This suite therefore treats the PROMPT as the product:
14
+ // · every prompt the framework sends is captured verbatim;
15
+ // · every prompt is checked for IDENTITY coherence (does it name the member that
16
+ // receives it, in its header, its [状态] block and its persona?), ROSTER/quorum
17
+ // coherence, and INTERACTION coherence (do framed messages name the true sender
18
+ // and the true kind?);
19
+ // · the FULL prompt corpus is written to prompt-corpus-v5/ so the real interaction
20
+ // content is preserved for human review, not just reduced to pass/fail.
21
+ //
22
+ // Each case runs in its OWN session root, so one case can never leave a meeting or a
23
+ // verification in flight to pollute the next one.
24
+ //
25
+ // Run: node tests/prompt-v5-integrity.test.mjs
26
+ // Env: V5_PLUGIN=<abs path> point the suite at a mutated copy (sensitivity probes)
27
+ // V5_CORPUS_DIR=<dir> where to write the corpus (default: ./prompt-corpus-v5)
28
+ // ============================================================
29
+ import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
32
+ import { fileURLToPath } from 'node:url'
33
+
34
+ // fileURLToPath, not URL.pathname: a Windows path with non-ASCII characters comes back
35
+ // percent-encoded from `.pathname`, which would silently write the corpus into a
36
+ // directory literally named "%E5%BC%80...".
37
+ const HERE = dirname(fileURLToPath(import.meta.url))
38
+ const PLUGIN = process.env.V5_PLUGIN
39
+ ? new URL('file:///' + String(process.env.V5_PLUGIN).replace(/\\/g, '/'))
40
+ : new URL('../vibe-math-v5/vibe-math-v5.js', import.meta.url)
41
+ const CORPUS_DIR = process.env.V5_CORPUS_DIR ? pathResolve(process.env.V5_CORPUS_DIR) : join(HERE, '..', 'prompt-corpus-v5')
42
+ const WS = mkdtempSync(join(tmpdir(), 'vibe-v5-prompt-'))
43
+
44
+ let passed = 0, failed = 0
45
+ const failures = []
46
+ const assert = (c, m) => {
47
+ if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) }
48
+ }
49
+ const section = (t) => console.log('\n[' + t + ']')
50
+
51
+ // ---------------------------------------------------------------
52
+ // VIRTUAL CLOCK
53
+ // The institute is driven by `ctx.timeout` + `Date.now()`. On the real clock, WHICH member a timer
54
+ // picks (heartbeat / meeting / digest) depends on load and on millisecond deltas, so the shipped
55
+ // corpus recorded different members on different runs and changed on every run — which makes it
56
+ // undiffable and hides real drift (AUDIT-CHECKLIST §2.4). A virtual clock fires every timer in a
57
+ // fixed order regardless of machine speed, and makes the suite faster because `sleep(n)` advances
58
+ // virtual time instead of waiting n real milliseconds.
59
+ let vnow = 1700000000000
60
+ let vtimerSeq = 0
61
+ const vtimers = new Map()
62
+ Date.now = () => vnow
63
+ function advanceClock(ms) {
64
+ const target = vnow + Math.max(0, Math.round(Number(ms) || 0))
65
+ for (;;) {
66
+ let next = null
67
+ for (const t of vtimers.values()) {
68
+ if (t.at > target) continue
69
+ if (!next || t.at < next.at || (t.at === next.at && t.seq < next.seq)) next = t
70
+ }
71
+ if (!next) break
72
+ vtimers.delete(next.id)
73
+ vnow = Math.max(vnow, next.at)
74
+ try { next.cb() } catch (e) { /* a throwing timer must not stop the clock */ }
75
+ }
76
+ vnow = target
77
+ }
78
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms))
79
+ // Advance the virtual clock, then give the real event loop a few turns so awaited work (fs writes,
80
+ // handler chains) can finish before the next assertion reads the state.
81
+ const sleep = async (ms) => { advanceClock(ms); for (let i = 0; i < 3; i++) await realSleep(1) }
82
+
83
+ // ---------------------------------------------------------------
84
+ // mock host
85
+ // ---------------------------------------------------------------
86
+ // NO session services: v5 injects only ['subagents','agents','fs','tools','commands','timer'] and
87
+ // keeps its durable state in the hardened JSON file State/<institute>.v5state.json. Sessions still
88
+ // record anything appended to them so the suite can assert that the plugin appended NOTHING — an
89
+ // institute event in the user's session log makes that session unresumable (DSH refuses to load an
90
+ // unknown event type unless it is marked `ignorable`, which `Session.append` cannot set).
91
+ const listeners = {}
92
+ const toolRegs = []
93
+ const liveAgents = new Map()
94
+
95
+ const spawns = [] // { label, childId, rootId, persona, prompt, toolFilter }
96
+ const wakes = [] // queued sends not yet handled
97
+ const delivered = [] // sends that drainWakes actually handled
98
+ let failNextStarts = 0
99
+
100
+ // One workspace PER CASE. The institute's durable state is a file under the workspace, so a shared
101
+ // workspace would let case 1's institute (and its running state) leak into every later case.
102
+ const WORKSPACES = [WS]
103
+ function newWorkspace() {
104
+ const ws = mkdtempSync(join(tmpdir(), 'vibe-v5-prompt-'))
105
+ WORKSPACES.push(ws)
106
+ return ws
107
+ }
108
+
109
+ function makeMockSession(id, parentSession, cwd) {
110
+ const events = []
111
+ const s = {
112
+ id,
113
+ header: { version: 1, id, createdAt: Date.now(), cwd: cwd || WS, parentSession, isSeeded: false },
114
+ inheritedEventCount: 0,
115
+ get seq() { return events.length },
116
+ append(type, data) {
117
+ const ev = { type, data, seq: events.length, time: Date.now() }
118
+ events.push(ev)
119
+ return ev
120
+ },
121
+ deriveMessages() { return [] },
122
+ snapshotEvents(from) { return events.slice(from || 0) },
123
+ ownEvents() { return events.slice() },
124
+ _events: events,
125
+ }
126
+ return s
127
+ }
128
+
129
+ const roots = new Map()
130
+ let rootSeq = 0
131
+ // `ws` opens a SECOND root over an existing workspace, which is what a reload looks like.
132
+ function makeRoot(ws) {
133
+ const id = 'sess-' + String.fromCharCode(65 + rootSeq++)
134
+ const cwd = ws || newWorkspace()
135
+ const session = makeMockSession(id, undefined, cwd)
136
+ const root = { id, options: { provider: 'mock', model: 'm' }, session, ctx: undefined }
137
+ roots.set(id, root)
138
+ return root
139
+ }
140
+ const wsOf = (root) => root.session.header.cwd
141
+
142
+ const ctx = {
143
+ get(name) {
144
+ if (name === 'sandboxPolicy') return undefined
145
+ if (name === 'compaction') return undefined
146
+ if (name === 'subprocess') {
147
+ return {
148
+ async resolveExecutable(cmd) { return String(cmd) },
149
+ spawn({ argv }) {
150
+ const last = argv[argv.length - 1] || ''
151
+ // directory creation still goes through the same mock (mkdirs uses a shell)
152
+ if (/New-Item/.test(last)) {
153
+ const paths = []
154
+ const re = /'((?:[^']|'')*)'/g
155
+ let m
156
+ while ((m = re.exec(last)) !== null) paths.push(m[1].replace(/''/g, "'"))
157
+ for (const q of paths) if (q && !/^-/.test(q)) mkdirSync(q, { recursive: true })
158
+ return { done: Promise.resolve({ exitCode: 0, signal: null }), collected: {}, terminate() {} }
159
+ }
160
+ // The fake Lean toolchain: GREEN unless the file still uses sorry / carries -- FAIL.
161
+ // Case 12 needs a proof that really passes so the prompt switches to fidelity review.
162
+ const text = existsSync(last) ? readFileSync(last, 'utf8') : ''
163
+ const bad = /sorry|-- FAIL/.test(text)
164
+ const ok = { text: 'ok\n', nextOffset: 3, lossy: false }
165
+ const err = { text: bad ? 'error: declaration uses sorry\n' : '', nextOffset: 0, lossy: false }
166
+ return {
167
+ done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
168
+ collected: { stdout: { readFrom: () => ok }, stderr: { readFrom: () => err } },
169
+ terminate() {},
170
+ }
171
+ },
172
+ }
173
+ }
174
+ return undefined
175
+ },
176
+ on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
177
+ effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
178
+ logger: { info() {}, warn() {}, error() {} },
179
+ // Virtualised: the plugin's timers fire when the suite advances the clock (see the virtual-clock
180
+ // block above), which is what makes timer-driven prompt selection reproducible.
181
+ timeout(cb, ms) {
182
+ const id = ++vtimerSeq
183
+ vtimers.set(id, { id, seq: id, at: vnow + Math.max(0, Math.round(Number(ms) || 0)), cb })
184
+ return () => { vtimers.delete(id) }
185
+ },
186
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
187
+ commands: { register() { return () => {} } },
188
+ subagents: {
189
+ list() { return ['spawn'] },
190
+ async startContinuable({ label, request }) {
191
+ if (failNextStarts > 0) { failNextStarts -= 1; throw new Error('mock provisioning failure') }
192
+ const rootId = (request && request.parent && request.parent.id) || 'sess-A'
193
+ const id = 'c' + (spawns.length + 1)
194
+ const cwd = (request && request.parent && request.parent.session && request.parent.session.header.cwd) || WS
195
+ liveAgents.set(id, { id, session: makeMockSession(id, rootId, cwd), options: request && request.agentOptions })
196
+ spawns.push({
197
+ label, childId: id, rootId,
198
+ persona: request && request.persona,
199
+ prompt: request && request.prompt && request.prompt[0] && request.prompt[0].text,
200
+ toolFilter: request && request.toolFilter,
201
+ })
202
+ return { childId: id, messageId: 'm' + spawns.length }
203
+ },
204
+ async sendMessage(parent, childId, blocks) {
205
+ wakes.push({ childId, rootId: (parent && parent.id) || 'sess-A', prompt: (blocks && blocks[0] && blocks[0].text) || '' })
206
+ return 'w' + (delivered.length + wakes.length)
207
+ },
208
+ interrupt() {},
209
+ async drainContinuableChildren(parent, ids) { for (const i of ids) liveAgents.delete(i) },
210
+ },
211
+ agents: {
212
+ roots() { return [...roots.values()] },
213
+ get(id) { return roots.get(id) || liveAgents.get(id) },
214
+ list() { return [...roots.values(), ...liveAgents.values()] },
215
+ },
216
+ fs: {
217
+ async resolve(rel, opts) {
218
+ const b = (opts && opts.cwd) || WS
219
+ const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/'))
220
+ return { targetKey: p, displayPath: p }
221
+ },
222
+ async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
223
+ async readText(t) { return readFileSync(t.targetKey, 'utf8') },
224
+ async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
225
+ async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
226
+ },
227
+ }
228
+
229
+ const mod = await import(PLUGIN.href + '?t=' + Date.now())
230
+ const plugin = mod.default || mod
231
+ plugin.apply(ctx)
232
+
233
+ // ---------------------------------------------------------------
234
+ // driving helpers
235
+ // ---------------------------------------------------------------
236
+ async function callTool(name, args, agent) {
237
+ const spec = toolRegs.find(x => x.name === name)
238
+ if (!spec) throw new Error('no tool ' + name)
239
+ return JSON.parse(await spec.execute(args || {}, { agent }))
240
+ }
241
+ const childAgent = (childId) => liveAgents.get(childId)
242
+ function fireEnd(childId, reply, stopReason) {
243
+ const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
244
+ for (const h of (listeners['subagent/end'] || [])) {
245
+ h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: stopReason || 'completed', lastAssistantMessage: blocks })
246
+ }
247
+ }
248
+ const settle = async () => { await sleep(30) }
249
+ const memberOfChild = (childId) => {
250
+ const s = spawns.find(x => x.childId === childId)
251
+ const m = s ? /vibe5 (\S+) /.exec(s.label) : null
252
+ return m ? m[1] : ''
253
+ }
254
+ const spawnOf = (root, memberId) => spawns.find(s => s.rootId === root.id && s.label.indexOf('vibe5 ' + memberId + ' ') !== -1)
255
+ const childOf = (root, memberId) => { const s = spawnOf(root, memberId); return s ? s.childId : '' }
256
+ const spawnsFor = (root) => spawns.filter(s => s.rootId === root.id)
257
+
258
+ // Pull exactly the VOTING prompts for one root. A plain FIFO drain returns whatever was
259
+ // queued first (work rounds, heartbeats), which is how an earlier version of this case ended
260
+ // up asserting against the wrong prompt entirely.
261
+ async function takeVerifyPrompts(root, n) {
262
+ const got = []
263
+ for (let guard = 0; guard < 400 && got.length < n; guard++) {
264
+ const idx = wakes.findIndex(w => w.rootId === root.id && /【求真表决/.test(w.prompt))
265
+ if (idx === -1) {
266
+ const other = wakes.findIndex(w => w.rootId === root.id)
267
+ if (other !== -1) {
268
+ const w = wakes.splice(other, 1)[0]
269
+ delivered.push({ prompt: w.prompt, owner: memberOfChild(w.childId), rootId: w.rootId })
270
+ fireEnd(w.childId, { progress: '(语料采样时略过非表决轮)', contextPct: 20 })
271
+ await settle()
272
+ continue
273
+ }
274
+ await settle()
275
+ continue
276
+ }
277
+ const w = wakes.splice(idx, 1)[0]
278
+ got.push(w)
279
+ delivered.push({ prompt: w.prompt, owner: memberOfChild(w.childId), rootId: w.rootId })
280
+ fireEnd(w.childId, { verdict: { target: (/"target"\s*:\s*"([^"]+)"/.exec(w.prompt) || [])[1] || '', verdict: 0.5, reason: '语料采样' }, contextPct: 20 })
281
+ await settle()
282
+ }
283
+ return got
284
+ }
285
+
286
+ let votePlan = new Map() // memberId -> verdict number for the next verify prompts
287
+ let replyOverride = new Map() // memberId -> the exact reply its NEXT wake must produce
288
+ // Roots whose MEETING prompts the driver must NOT answer, so the meeting stays in flight
289
+ // (case 10b needs a live meeting to test that a verification cannot preempt it).
290
+ const hushed = new Set()
291
+ // Handle queued sends. `delivered` collects what was actually sent for the case under
292
+ // test, because the queue is consumed here and assertions must not read it afterwards.
293
+ // Wakes belonging to OTHER roots are skipped over rather than allowed to block: a case
294
+ // with a short heartbeat keeps producing its own wakes, and a naive
295
+ // "stop at the first foreign wake" loop would starve every later case.
296
+ async function drainWakes(budget, root) {
297
+ let n = 0
298
+ while (n < budget) {
299
+ const idx = wakes.findIndex(w => (!root || w.rootId === root.id)
300
+ && !(hushed.has(w.rootId) && /【研究所会议/.test(w.prompt)))
301
+ if (idx === -1) break
302
+ const w = wakes.splice(idx, 1)[0]
303
+ const owner = memberOfChild(w.childId)
304
+ delivered.push({ prompt: w.prompt, owner, childId: w.childId, rootId: w.rootId })
305
+ let reply
306
+ if (replyOverride.has(owner)) { reply = replyOverride.get(owner); replyOverride.delete(owner) } else if (/【求真表决/.test(w.prompt)) {
307
+ const target = (/"target"\s*:\s*"([^"]+)"/.exec(w.prompt) || [])[1] || ''
308
+ const v = votePlan.has(owner) ? votePlan.get(owner) : 0.5
309
+ reply = { verdict: { target, verdict: v, reason: owner + ' 的判断' }, contextPct: 20 }
310
+ } else if (/【研究所会议/.test(w.prompt)) {
311
+ reply = { input: owner + ':我的意见。', solved: false, contextPct: 20 }
312
+ } else {
313
+ reply = { progress: owner + ':继续推进。', solved: false, contextPct: 20 }
314
+ }
315
+ fireEnd(w.childId, reply)
316
+ n++
317
+ await settle()
318
+ }
319
+ return n
320
+ }
321
+ // A case is over: pause it so it can never generate a wake that would leak into the
322
+ // next case, and drop anything it still had queued.
323
+ async function endCase(root) {
324
+ await callTool('vibe_v5_pause', {}, root)
325
+ for (let i = wakes.length - 1; i >= 0; i--) if (wakes[i].rootId === root.id) wakes.splice(i, 1)
326
+ }
327
+ async function settleInstitute(root, rounds = 14) {
328
+ for (let i = 0; i < rounds; i++) {
329
+ await drainWakes(40, root)
330
+ await sleep(20)
331
+ const st = await callTool('vibe_v5_status', {}, root)
332
+ if (!st.members.some(m => m.busy) && !st.meeting && !st.verify && wakes.length === 0) return st
333
+ }
334
+ await drainWakes(40, root)
335
+ return await callTool('vibe_v5_status', {}, root)
336
+ }
337
+
338
+ // ---------------------------------------------------------------
339
+ // prompt inspection
340
+ // ---------------------------------------------------------------
341
+ const KINDS = ['院士', '常驻研究员', '临时工']
342
+ const reState = new RegExp('\\[状态\\]\\s*你是\\s+(\\S+?)((' + KINDS.join('|') + '))|轮次\\s*(\\d+)|法定票数\\s*m=(\\d+)|有表决权者\\s*(\\d+)\\s*人')
343
+ const reRoster = /\[在册\]\s*(.*)/
344
+ const reAbsent = /\[未就位\]\s*(.*)/
345
+ const reHeader = /^【([^】]*)】/m
346
+
347
+ function headerMember(prompt) {
348
+ const h = (reHeader.exec(prompt) || [])[1]
349
+ if (!h) return { header: '', id: '', kind: '' }
350
+ for (const k of KINDS) {
351
+ let m = new RegExp('——\\s*' + k + '\\s+(\\S+?)\\s*】?$').exec(h)
352
+ if (m) return { header: h, id: m[1], kind: k }
353
+ m = new RegExp('——\\s*' + k + '\\s+(\\S+?)\\s+就对象').exec(h)
354
+ if (m) return { header: h, id: m[1], kind: k }
355
+ }
356
+ return { header: h, id: '', kind: '' }
357
+ }
358
+ function parseState(prompt) {
359
+ const s = reState.exec(prompt)
360
+ if (!s) return null
361
+ return { id: s[1], kind: s[2], round: Number(s[3]), m: Number(s[4]), voters: Number(s[5]) }
362
+ }
363
+ const rosterOf = (prompt) => {
364
+ const r = reRoster.exec(prompt)
365
+ return r ? r[1].split(/[、,]/).map(s => s.trim()).filter(s => s && s !== '(无)') : null
366
+ }
367
+ const absentOf = (prompt) => {
368
+ const r = reAbsent.exec(prompt)
369
+ return r ? r[1].split(/[、,]/).map(s => s.trim()).filter(Boolean) : null
370
+ }
371
+ const stateBlockCount = (prompt) => (prompt.match(/\[状态\]/g) || []).length
372
+ // Data-position garbage only: a bare \bundefined\b also matches legitimate prose
373
+ // such as v2's "no undefined symbols" (the v2 suite caught that as a false positive).
374
+ const GARBAGE = [/:\s*undefined/, /["']undefined["']/, /undefined\s*[,}\]]/, /\bNaN\b/, /\[object Object\]/, /你是\s*\?/]
375
+ const isVoter = (id) => id === 'acad' || /^r-/.test(id)
376
+
377
+ // Applied to EVERY captured prompt.
378
+ function checkPromptSweep(prompt, owner, where) {
379
+ const problems = []
380
+ if (!prompt) return ['prompt is empty']
381
+ for (const g of GARBAGE) if (g.test(prompt)) problems.push('contains ' + g)
382
+ if (stateBlockCount(prompt) !== 1) problems.push('expected exactly one [状态] block, found ' + stateBlockCount(prompt))
383
+ const st = parseState(prompt)
384
+ if (!st) { problems.push('no parseable [状态] line'); return problems }
385
+ if (st.id !== owner) problems.push('[状态] names ' + st.id + ' but was sent to ' + owner)
386
+ const hd = headerMember(prompt)
387
+ if (hd.id && hd.id !== owner) problems.push('header names ' + hd.id + ' but was sent to ' + owner)
388
+ if (hd.kind && st.kind && hd.kind !== st.kind) problems.push('header kind ' + hd.kind + ' ≠ [状态] kind ' + st.kind)
389
+ const roster = rosterOf(prompt)
390
+ if (!roster) problems.push('[在册] line missing')
391
+ else {
392
+ if (roster.indexOf(owner) === -1) problems.push('the roster omits the reader ' + owner + ' ([' + roster.join('、') + '])')
393
+ if (new Set(roster).size !== roster.length) problems.push('duplicate ids in [在册]')
394
+ for (const a of (absentOf(prompt) || [])) {
395
+ const id = String(a).replace(/(.*$/, '')
396
+ if (roster.indexOf(id) !== -1) problems.push(id + ' is on the roster AND listed as 未就位')
397
+ }
398
+ if (st.voters !== roster.filter(isVoter).length) {
399
+ problems.push('有表决权者 ' + st.voters + ' ≠ voters in [在册] ' + roster.filter(isVoter).length)
400
+ }
401
+ if (st.m !== Math.min(3, st.voters)) problems.push('m=' + st.m + ' ≠ min(quorumCap 3, voters ' + st.voters + ')')
402
+ }
403
+ for (const other of KINDS) {
404
+ const hits = prompt.match(new RegExp('你是\\s+\\S+?(' + other + ')', 'g')) || []
405
+ if (hits.length > 1) problems.push('more than one identity claim: ' + hits.join(' / '))
406
+ }
407
+ if (problems.length) console.error(' !! ' + where + ' → ' + problems.join('; '))
408
+ return problems
409
+ }
410
+
411
+ // ---------------------------------------------------------------
412
+ // corpus recorder
413
+ // ---------------------------------------------------------------
414
+ const corpus = []
415
+ // Normalise every workspace OUT of the corpus. A plain `split(WS)` is not enough on Windows:
416
+ // the plugin renders paths with forward slashes while os.tmpdir() may hand back a different
417
+ // CASE ("...\ADMIN\..." vs ".../admin/..."), so the absolute workspace path used to survive
418
+ // into the SHIPPED corpus — non-deterministic (the temp dir changes every run) and a machine
419
+ // path leak. Match case-insensitively, on either separator, for EVERY per-case workspace.
420
+ const scrub = (s) => {
421
+ let t = String(s == null ? '' : s).replace(/\\/g, '/')
422
+ // Timestamps are part of the prompt a member reads, but not part of what a reviewer needs:
423
+ // normalise them too. Otherwise the shipped corpus changes on EVERY run — its headings carry
424
+ // `### YYYY-MM-DD hh:mm:ss|<member>` — and its diffs stop being meaningful.
425
+ // The VibeMath ROOT gets its own token (not `<WS>/VibeMath`), so v5's corpus can be diffed
426
+ // side by side with v2/v3/v4's, which render the same root as `<VIBEMATH>`.
427
+ t = t.replace(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/g, '<TIME>')
428
+ .replace(/[A-Za-z]:\/[^\s"'`)),。;:]*?[\\/]VibeMath/g, '<VIBEMATH>')
429
+ .replace(/\/VibeMath/g, '<VIBEMATH>')
430
+ for (const ws of WORKSPACES) {
431
+ const re = new RegExp(ws.replace(/\\/g, '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
432
+ t = t.replace(re, '<WS>')
433
+ }
434
+ return t
435
+ }
436
+ // Every Lean tool mention in agent-facing text must be the REGISTERED name.
437
+ // `Verified/Lean/` is a contract PATH, not a tool name, and on a case-insensitive reading it
438
+ // even contains the substring "lean_"; strip that exact path before the check (the same path
439
+ // is written by the plugin, so the prompt builders genuinely can emit it).
440
+ const noBareLeanTool = (t) => !/(^|[^a-z_])lean_(run|archive|lib)/.test(
441
+ String(t || '').replace(/Verified\/Lean\//g, ''))
442
+ function record(kind, owner, prompt, persona, extra) {
443
+ corpus.push({
444
+ kind, owner,
445
+ sentToLabel: (extra && extra.label) || '',
446
+ persona: persona === undefined ? null : scrub(persona),
447
+ prompt: scrub(prompt),
448
+ toolFilter: (extra && extra.toolFilter) || null,
449
+ })
450
+ }
451
+ function recordAndCheck(kind, owner, prompt, opts) {
452
+ record(kind, owner, prompt, opts && opts.persona, opts)
453
+ const problems = checkPromptSweep(prompt, owner, kind + ' prompt for ' + owner)
454
+ assert(problems.length === 0, kind + ' prompt for ' + owner + ' is identity/roster coherent')
455
+ return problems
456
+ }
457
+ function checkPersona(kind, owner, persona, checks) {
458
+ for (const [re, label] of checks) assert(re.test(persona), kind + ': ' + owner + "'s charter " + label)
459
+ }
460
+
461
+ // ===============================================================
462
+ console.log('-- V5 prompt & interaction integrity --')
463
+
464
+ // =============== CASE 1: founding briefs =========================================
465
+ section('1 founding — every induction brief describes the member that receives it')
466
+ const RA = makeRoot()
467
+ const started = await callTool('vibe_v5_start', { problem: '求 3N^2-2=b^2 与 3N^2+2=5a^2 的全部整数解', researcherCount: 3 }, RA)
468
+ assert(started.ok === true, 'institute founded')
469
+ const FOUND_ORDER = ['acad', 'r-1', 'r-2', 'r-3']
470
+ const founding = spawnsFor(RA)
471
+ assert(founding.length === 4, 'four founding members were started (got ' + founding.length + ')')
472
+ for (let i = 0; i < founding.length; i++) {
473
+ const sp = founding[i]
474
+ const owner = memberOfChild(sp.childId)
475
+ assert(owner === FOUND_ORDER[i], 'founding #' + i + ' started ' + owner + ' (expected ' + FOUND_ORDER[i] + ')')
476
+ recordAndCheck('founding', owner, sp.prompt, sp)
477
+ const st = parseState(sp.prompt) || {}
478
+ const roster = rosterOf(sp.prompt) || []
479
+ assert(JSON.stringify(roster) === JSON.stringify(FOUND_ORDER.slice(0, i + 1)),
480
+ 'the founding brief of ' + owner + ' shows the roster INCLUDING itself: ' + JSON.stringify(roster))
481
+ assert(st.round === 1, owner + "'s founding brief is round 1 (got " + st.round + ')')
482
+ assert(st.voters === i + 1, owner + ' sees ' + (i + 1) + ' voter(s) (got ' + st.voters + ')')
483
+ assert(st.m === Math.min(3, i + 1), owner + ' sees m=min(3,' + (i + 1) + ')=' + Math.min(3, i + 1) + ' (got ' + st.m + ')')
484
+ assert(sp.prompt.indexOf('【入职首轮') === 0, owner + "'s first prompt is framed as an induction")
485
+ assert(sp.prompt.indexOf('你刚刚加入本所') !== -1, owner + "'s induction asks for its own first view")
486
+ }
487
+ assert(founding[0].prompt.indexOf('[状态] 你是 acad(院士)') !== -1, 'the academician brief says 你是 acad(院士) — never "?"')
488
+ assert(!/你是 \?/.test(founding[0].prompt), 'no "你是 ?" placeholder')
489
+ checkPersona('founding', 'acad', founding[0].persona, [
490
+ [/在册院士:acad/, 'lists itself as the sitting academician'],
491
+ [/在册常驻研究员:(无)/, 'shows no researchers at that instant'],
492
+ [/你是「institute」的\*\*院士\*\*/, 'opens by naming its office'],
493
+ [/Members\/acad\//, 'points at its own library'],
494
+ ])
495
+ checkPersona('founding', 'r-3', founding[3].persona, [
496
+ [/在册院士:acad/, 'names the sitting academician'],
497
+ [/在册常驻研究员:r-1、r-2、r-3/, 'lists r-1、r-2、r-3 as the sitting researchers'],
498
+ [/在册临时工:(无)/, 'shows no temps'],
499
+ [/代号 r-3。/, 'states its own 代号'],
500
+ [/Members\/r-3\//, 'points at its own library'],
501
+ [/progress.md/, 'documents Progress/progress.md'],
502
+ ])
503
+ checkPersona('founding', 'r-1', founding[1].persona, [[/一名常驻研究员/, 'opens as 常驻研究员']])
504
+ for (const sp of founding) {
505
+ const owner = memberOfChild(sp.childId)
506
+ assert(sp.persona.indexOf('Members/' + owner + '/') !== -1, owner + "'s charter points at Members/" + owner + '/')
507
+ }
508
+ for (const sp of founding) { sp._ended = true; fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
509
+ await settleInstitute(RA)
510
+ // ★ The persistence contract after the DSH 0.2.0 audit: institute state lives in the hardened
511
+ // JSON file State/<institute>.v5state.json and NOTHING is appended to the host session log. The
512
+ // old projection backend wrote `vibe5/*` events into the user's own session, and DSH's session
513
+ // persistence refuses to load a log with an unknown event type unless it is marked
514
+ // `ignorable: true` — which `Session.append` cannot set — so that session became unresumable.
515
+ {
516
+ assert(RA.session._events.length === 0,
517
+ '★ the plugin appends NOTHING to the host session log (' + RA.session._events.length + ' events after a full founding)')
518
+ const stateFile = join(wsOf(RA), 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
519
+ for (let i = 0; i < 40 && !existsSync(stateFile); i++) await settle()
520
+ assert(existsSync(stateFile), 'the institute state is on disk in State/institute.v5state.json')
521
+ const RB0 = makeRoot(wsOf(RA)) // a second root over the same workspace = a reload
522
+ const stReload = await callTool('vibe_v5_status', {}, RB0)
523
+ assert(stReload.members.length === 4, '★ a reload reads the 4-member roster back out of the JSON file (got ' + stReload.members.length + ')')
524
+ assert(RB0.session._events.length === 0, '★ the reloading root appends nothing to its session log either')
525
+ }
526
+ await endCase(RA)
527
+
528
+ // =============== CASE 2: round prompts (normal + checkpoint) =====================
529
+ section('2 rounds — normal and checkpoint prompts keep the identity straight')
530
+ const RB = makeRoot()
531
+ await callTool('vibe_v5_start', { problem: '无领头人情形下的组织', researcherCount: 2 }, RB)
532
+ for (const sp of spawnsFor(RB)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
533
+ await settleInstitute(RB)
534
+ // A short idle window makes the heartbeat prompt reachable inside a test run.
535
+ await callTool('vibe_v5_set', { activityTimeoutMs: 80, maxParallel: 6, chatDigestMax: 1 }, RB)
536
+ // (a) an addressed message must produce a NORMAL round prompt carrying the framed inbox
537
+ delivered.length = 0
538
+ await callTool('vibe_v5_say', { to: 'r-2', text: '请把你手上的结论同步给我。' }, childAgent(childOf(RB, 'r-1')))
539
+ await settle(); await drainWakes(10, RB)
540
+ const normalWakes = delivered.slice()
541
+ assert(normalWakes.length > 0, 'the addressed message produced a wake (' + normalWakes.length + ')')
542
+ for (const w of normalWakes) recordAndCheck('normal', w.owner, w.prompt)
543
+ assert(normalWakes.some(w => w.owner === 'r-2' && w.prompt.indexOf('【研究所·私信 from r-1】') !== -1),
544
+ 'r-2 is woken with its inbox containing the DM framed from r-1')
545
+ assert(normalWakes.filter(w => w.owner === 'r-2').every(w => w.prompt.indexOf('【研究所·私信 from r-2】') === -1),
546
+ 'r-2 never receives the DM framed as coming from itself')
547
+ // (b) the heartbeat must produce a CHECKPOINT prompt. Captured in its OWN single-member institute:
548
+ // the heartbeat wakes the most idle non-busy member, so with exactly one member the pick cannot
549
+ // depend on real `Date.now()` deltas under load — the earlier shared-root version recorded
550
+ // heartbeat prompts for different members on different runs, which made the SHIPPED corpus change
551
+ // between runs (AUDIT-CHECKLIST §2.4: corpora must be byte-diffable).
552
+ const RHB = makeRoot()
553
+ await callTool('vibe_v5_start', { problem: '心跳语料(单成员)', researcherCount: 1, academician: false }, RHB)
554
+ for (const sp of spawnsFor(RHB)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
555
+ await settleInstitute(RHB)
556
+ await callTool('vibe_v5_set', { activityTimeoutMs: 80, maxParallel: 6, chatDigestMax: 1 }, RHB)
557
+ let hbQueueIdx = -1
558
+ for (let i = 0; i < 15 && hbQueueIdx === -1; i++) {
559
+ await sleep(120); await settle()
560
+ // Inspect the QUEUE (never drains it): draining answers turns and changes who is idle next.
561
+ hbQueueIdx = wakes.findIndex(w => w.rootId === RHB.id && /【心跳检查/.test(w.prompt))
562
+ }
563
+ assert(hbQueueIdx !== -1, 'the heartbeat produced a checkpoint prompt')
564
+ if (hbQueueIdx !== -1) {
565
+ const hbWake = wakes.splice(hbQueueIdx, 1)[0]
566
+ const hbMember = memberOfChild(hbWake.childId)
567
+ assert(hbMember === 'r-1',
568
+ '★ the heartbeat picked the only member (r-1) — deterministic, not timing-dependent (got ' + hbMember + ')')
569
+ recordAndCheck('checkpoint', hbMember, hbWake.prompt)
570
+ // The heartbeat body may be preceded by a delivered inbox or the core-rules recap
571
+ // after a real compaction, so match anywhere rather than at offset 0.
572
+ assert(hbWake.prompt.indexOf('【心跳检查 —— ') !== -1, hbMember + "'s heartbeat prompt names its own office and id")
573
+ assert(new RegExp('【心跳检查 —— (院士|常驻研究员|临时工) ' + hbMember + '】').test(hbWake.prompt),
574
+ hbMember + "'s heartbeat header carries its own kind and id")
575
+ fireEnd(hbWake.childId, { progress: hbMember + ':继续推进。', solved: false, contextPct: 20 })
576
+ await settle()
577
+ }
578
+ await endCase(RHB)
579
+ await endCase(RB)
580
+
581
+ // =============== CASE 3: interaction framing ====================================
582
+ section('3 interaction framing — every message names its true sender and kind')
583
+ const RC = makeRoot()
584
+ await callTool('vibe_v5_start', { problem: '交互框架测试', researcherCount: 2 }, RC)
585
+ for (const sp of spawnsFor(RC)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
586
+ await settleInstitute(RC)
587
+ await callTool('vibe_v5_set', { maxParallel: 8, chatDigestMax: 1 }, RC)
588
+ const r1 = childOf(RC, 'r-1'), acad = childOf(RC, 'acad')
589
+ const send = async (kind, fn) => {
590
+ delivered.length = 0
591
+ const r = await fn()
592
+ await settle(); await drainWakes(20, RC)
593
+ const handled = delivered.slice()
594
+ for (const w of handled) recordAndCheck(kind, w.owner, w.prompt).length
595
+ return { r, prompts: handled.map(w => w.prompt).join('\n'), owners: handled.map(w => w.owner), count: handled.length }
596
+ }
597
+ const dm = await send('inbox-dm', () => callTool('vibe_v5_say', { to: 'r-2', text: '私下问你一下。' }, childAgent(r1)))
598
+ const voters = await send('inbox-voters', () => callTool('vibe_v5_say', { to: 'voters', text: '请全体表决者注意。' }, childAgent(r1)))
599
+ const chat = await send('inbox-chat', () => callTool('vibe_v5_say', { text: '各位,我建议先做最小反例归约。' }, childAgent(r1)))
600
+ const office = await send('inbox-office', () => callTool('vibe_v5_message', { to: 'all', content: '所办通知:请按计划推进。' }, RC))
601
+ const assign = await send('inbox-assign', () => callTool('vibe_v5_assign', { subject: '核验模 9 情形', to: 'r-2', why: '你最熟同余', acceptance: '给出模 9 全表' }, childAgent(acad)))
602
+ const nudge = await send('inbox-nudge', () => callTool('vibe_v5_nudge', { to: 'r-2', why: '进度偏慢', next_step: '先交一份模 9 表' }, childAgent(acad)))
603
+ assert(dm.r.ok === true && dm.r.delivered === 1, 'a DM reaches exactly its addressee')
604
+ assert(voters.r.ok === true && voters.r.delivered === 2, 'a voters-only broadcast reaches every voter but the sender')
605
+ assert(chat.r.ok === true && chat.r.delivered === 2, 'group chat fans out to every other member')
606
+ assert(assign.r.ok === true, "the ACADEMICIAN's assignment succeeded (" + JSON.stringify(assign.r).slice(0, 90) + ')')
607
+ assert(/【研究所·私信 from r-1】/.test(dm.prompts), 'a DM is framed by its true sender (【研究所·私信 from r-1】)')
608
+ assert(/【研究所·致全体表决者 from r-1】/.test(voters.prompts), 'a voters-only broadcast is framed as such, not as a DM')
609
+ assert(/【研究所·群聊】r-1:/.test(chat.prompts), 'group chat is framed with the true speaker')
610
+ assert(/【所办通知】/.test(office.prompts), 'an office notice is framed 所办通知')
611
+ assert(/【院士分派】/.test(assign.prompts), "the academician's assignment is framed 院士分派")
612
+ assert(/【督办 from acad】/.test(nudge.prompts), 'a nudge is framed 督办 by its true author')
613
+ assert(!/【院士分派】[^\n]*督办/.test(nudge.prompts), 'a nudge is NOT mislabelled as an assignment')
614
+ // THE OFFICE ITSELF must be able to assign, and must not impersonate the academician.
615
+ const officeAssign = await send('inbox-office-assign', () => callTool('vibe_v5_assign', { subject: '所办指派', to: 'r-2', why: '所办决定', acceptance: '给出结论' }, RC))
616
+ assert(officeAssign.r.ok === true, 'the OFFICE (session root) can assign — it is resolved as the office, not as a random member (' + JSON.stringify(officeAssign.r).slice(0, 90) + ')')
617
+ assert(/【所办分派】/.test(officeAssign.prompts), 'an OFFICE assignment is framed 所办分派, not 院士分派')
618
+ assert(!/【院士分派】/.test(officeAssign.prompts), 'an office assignment does not impersonate the academician')
619
+ const officeNudge = await send('inbox-office-nudge', () => callTool('vibe_v5_nudge', { to: 'r-2', why: '所办督办一下' }, RC))
620
+ assert(officeNudge.r.ok === true, 'the OFFICE can nudge')
621
+ assert(/【督办 from office】/.test(officeNudge.prompts) && /所办督办/.test(officeNudge.prompts),
622
+ 'an office nudge is labelled 所办督办 by the office, not 院士督办')
623
+ assert(await callTool('vibe_v5_prioritize', { order: [], why: 'x' }, RC).then(r => r.ok === false), 'the office still hits argument validation (resolved AS the office)')
624
+ await endCase(RC)
625
+
626
+ // =============== CASE 4: framework feedback delivery ============================
627
+ section('4 framework feedback reaches the member (never dropped as a self-message)')
628
+ const RD = makeRoot()
629
+ await callTool('vibe_v5_start', { problem: '框架反馈投递测试', researcherCount: 2 }, RD)
630
+ for (const sp of spawnsFor(RD)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
631
+ await settleInstitute(RD)
632
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RD)
633
+ const rd1 = childOf(RD, 'r-1'), rd2 = childOf(RD, 'r-2')
634
+ // A member can only be answered while a turn of its own is in flight, so each case
635
+ // below first WAKES r-1 and then lets its reply be the malformed one.
636
+ const wakeR1With = async (kind, reply) => {
637
+ delivered.length = 0
638
+ replyOverride.set('r-1', reply)
639
+ await callTool('vibe_v5_say', { to: 'r-1', text: '请就当前状态给个结论。' }, childAgent(rd2))
640
+ await settle(); await drainWakes(20, RD)
641
+ const handled = delivered.slice()
642
+ for (const w of handled) if (/【框架提示】/.test(w.prompt)) recordAndCheck(kind, w.owner, w.prompt).length
643
+ return handled.map(w => w.prompt).join('\n')
644
+ }
645
+ const badVerdictText = await wakeR1With('notice', { verdict: { target: 'p-ghost', verdict: 'not-a-number', reason: 'x' }, contextPct: 20 })
646
+ assert(/【框架提示】/.test(badVerdictText), 'a malformed verdict produces a real 【框架提示】 delivery')
647
+ assert(/verdict 必须是 0-1 的数值/.test(badVerdictText), 'the notice says what was wrong')
648
+ assert(/【框架提示】[^\n]*verdict/.test(badVerdictText), 'the notice is framed by the framework, not by the member itself')
649
+ const badClaimText = await wakeR1With('notice-claim', { task_claim: 't-999', contextPct: 20 })
650
+ assert(/【框架提示】/.test(badClaimText) && /没有任务 t-999/.test(badClaimText), 'a refused claim is reported back to the claimer')
651
+ // A CAS refusal reported through the JSON reply must also come back.
652
+ const t = await callTool('vibe_v5_task_create', { subject: '一个没人认领的任务' }, childAgent(rd1))
653
+ const staleText = await wakeR1With('notice-task', { task_update: { task_id: t.task.id, expected_revision: 99, action: 'claim' }, contextPct: 20 })
654
+ assert(/【框架提示】/.test(staleText) && /V5_TASK_STALE_REVISION/.test(staleText), 'a stale CAS reported through the reply channel is echoed back')
655
+ const selfFramed = corpus.filter(c => new RegExp('【研究所·私信 from ' + c.owner + '】').test(c.prompt))
656
+ assert(selfFramed.length === 0, 'no member ever receives a message framed as coming from itself')
657
+ await endCase(RD)
658
+
659
+ // =============== CASE 5: temp workers ===========================================
660
+ section('5 a hired temp worker is told its own name, employer and purpose')
661
+ const RE = makeRoot()
662
+ await callTool('vibe_v5_start', { problem: '临时工入职测试', researcherCount: 2 }, RE)
663
+ for (const sp of spawnsFor(RE)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
664
+ await settleInstitute(RE)
665
+ const hired = await callTool('vibe_v5_hire', { purpose: '核对文献引理', initial_task: '核对第 3 节引理' }, childAgent(childOf(RE, 'r-1')))
666
+ assert(hired.ok === true, 'r-1 hired a temp worker (' + JSON.stringify(hired).slice(0, 80) + ')')
667
+ const tempSpawn = spawnOf(RE, hired.id)
668
+ recordAndCheck('founding-temp', hired.id, tempSpawn.prompt, tempSpawn)
669
+ {
670
+ const st = parseState(tempSpawn.prompt) || {}
671
+ assert(st.kind === '临时工', 'the temp brief calls it 临时工 (got ' + st.kind + ')')
672
+ assert((rosterOf(tempSpawn.prompt) || []).indexOf(hired.id) !== -1, 'the temp brief lists the temp itself on the roster')
673
+ checkPersona('founding-temp', hired.id, tempSpawn.persona, [
674
+ [new RegExp('代号 ' + hired.id + ',由 r-1 雇入'), 'names itself and its true employer'],
675
+ [/用途:核对文献引理/, 'states its purpose'],
676
+ [new RegExp('Members/' + hired.id + '/'), 'points at its own library'],
677
+ [/你的雇主:r-1/, 'names its employer'],
678
+ ])
679
+ assert(/【入职首轮 —— 临时工 t-\d+】/.test(tempSpawn.prompt), 'the temp brief is framed as its induction')
680
+ assert(/你的初始任务\/用途:/.test(tempSpawn.prompt) && /核对第 3 节引理/.test(tempSpawn.prompt), 'the temp brief carries its initial task')
681
+ assert(!/"verdict":/.test(tempSpawn.prompt) || /"verdict" 字段对你不适用/.test(tempSpawn.prompt), 'the temp brief states it has no vote')
682
+ assert(!/"hire":/.test(tempSpawn.prompt), 'the temp brief does not offer hire')
683
+ }
684
+ for (const sp of spawnsFor(RE)) { if (!sp._ended) { sp._ended = true; fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':收到。', solved: false, contextPct: 10 }); await settle() } }
685
+ await settleInstitute(RE)
686
+
687
+ // =============== CASE 6: failed provisioning is visible =========================
688
+ section('6 a member that failed to provision is visible, not a phantom')
689
+ failNextStarts = 1
690
+ const failedHire = await callTool('vibe_v5_hire', { purpose: '注定失败', initial_task: 'x' }, childAgent(childOf(RE, 'r-1')))
691
+ assert(failedHire.ok === false, 'a provisioning failure is reported to the hirer (' + JSON.stringify(failedHire).slice(0, 100) + ')')
692
+ const stFail = await callTool('vibe_v5_status', {}, RE)
693
+ const failedMember = stFail.members.find(m => m.phase === 'failed')
694
+ assert(!!failedMember, 'the member is recorded as failed rather than left active')
695
+ assert(!failedMember || failedMember.busy !== true, 'a failed member is not left marked busy')
696
+ delivered.length = 0
697
+ await callTool('vibe_v5_say', { to: 'r-2', text: '看下编制。' }, childAgent(childOf(RE, 'r-1')))
698
+ await settle(); await drainWakes(20, RE)
699
+ const failPrompts = delivered.slice()
700
+ for (const w of failPrompts) recordAndCheck('after-failure', w.owner, w.prompt).length
701
+ const failText = failPrompts.map(w => w.prompt).join('\n')
702
+ assert(failPrompts.length > 0, 'a member was woken after the failure (' + failPrompts.length + ')')
703
+ assert(new RegExp('\\[未就位\\][^\\n]*' + failedMember.id).test(failText), 'the failed member appears in [未就位] with its id')
704
+ assert(!new RegExp('\\[在册\\][^\\n]*' + failedMember.id).test(failText), 'the failed member is NOT listed as if it were on the roster')
705
+ await endCase(RE)
706
+
707
+ // =============== CASE 7: session rebuild ========================================
708
+ section('7 a rebuilt session is told it was rebuilt, not that it just joined')
709
+ const RF = makeRoot()
710
+ await callTool('vibe_v5_start', { problem: '会话重建测试', researcherCount: 2 }, RF)
711
+ for (const sp of spawnsFor(RF)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
712
+ await settleInstitute(RF)
713
+ const personasBefore = {}
714
+ for (const sp of spawnsFor(RF)) personasBefore[memberOfChild(sp.childId)] = sp.persona
715
+ await callTool('vibe_v5_stop', {}, RF)
716
+ await settle()
717
+ const spawnCountBefore = spawns.length
718
+ const resumed = await callTool('vibe_v5_resume', {}, RF)
719
+ assert(resumed.ok === true, 'the institute resumed (' + JSON.stringify(resumed).slice(0, 120) + ')')
720
+ const resumeSpawns = spawns.slice(spawnCountBefore).filter(s => s.rootId === RF.id)
721
+ assert(resumeSpawns.length > 0, 'resume rebuilt at least one member session (' + resumeSpawns.length + ')')
722
+ for (const sp of resumeSpawns) {
723
+ const owner = memberOfChild(sp.childId)
724
+ recordAndCheck('resume', owner, sp.prompt, sp)
725
+ assert(sp.prompt.indexOf('【会话重建 —— ') === 0, owner + "'s rebuilt session is framed 会话重建, not 入职首轮")
726
+ assert(sp.prompt.indexOf('你刚刚加入本所') === -1, owner + ' is NOT told "你刚刚加入本所" on resume')
727
+ assert(sp.prompt.indexOf('不要从头再来') !== -1, owner + ' is told to read back its progress instead of restarting')
728
+ assert(sp.persona === personasBefore[owner], owner + "'s charter is the FROZEN hire-time one, not a resumed-time rewrite")
729
+ }
730
+ await endCase(RF)
731
+
732
+ // =============== CASE 8: leaderless institute ===================================
733
+ section('8 with academician:false no charter invents a leader')
734
+ const RG = makeRoot()
735
+ const l2 = await callTool('vibe_v5_start', { problem: '无院士建所', researcherCount: 2, academician: false }, RG)
736
+ assert(l2.ok === true, 'a leaderless institute can be founded (' + JSON.stringify(l2).slice(0, 100) + ')')
737
+ const l2spawns = spawnsFor(RG)
738
+ assert(l2spawns.length === 2, 'two researchers were founded and no academician (got ' + l2spawns.length + ')')
739
+ for (let i = 0; i < l2spawns.length; i++) {
740
+ const sp = l2spawns[i]
741
+ const owner = memberOfChild(sp.childId)
742
+ recordAndCheck('founding-leaderless', owner, sp.prompt, sp)
743
+ const st = parseState(sp.prompt) || {}
744
+ assert(st.m === Math.min(3, i + 1), owner + ': m is computed over the leaderless roster INCLUDING itself (m=' + st.m + ')')
745
+ assert(st.kind === '常驻研究员', owner + ' is a 常驻研究员 (got ' + st.kind + ')')
746
+ assert(/在册院士:(无)/.test(sp.persona), owner + "'s charter records that there is no academician")
747
+ assert(/本所当前\*\*没有在册院士\*\*/.test(sp.persona), owner + "'s charter says so in the organization section")
748
+ assert(!/本所的领头人是\*\*院士/.test(sp.persona), owner + "'s charter does NOT claim a leader exists")
749
+ assert(!/院士 acad/.test(sp.persona), owner + "'s charter never names a non-existent 院士 acad")
750
+ assert(!/主动向院士汇报/.test(sp.persona), owner + "'s charter does not tell it to report to a non-existent academician")
751
+ assert(!/院士也可以给你派活/.test(sp.persona), owner + "'s charter does not promise assignments from a non-existent academician")
752
+ assert(!/院士可以直接分派任务/.test(sp.persona), owner + "'s charter does not promise academician assignment powers")
753
+ }
754
+ const l2status = await callTool('vibe_v5_status', {}, RG)
755
+ assert(l2status.quorum.voters.indexOf('acad') === -1, 'the leaderless institute has no academician among its voters')
756
+ await endCase(RG)
757
+
758
+ // =============== CASE 8b: the JSON contract offered matches what is honoured =====
759
+ section('8b the reply spec documents exactly the fields the framework honours')
760
+ {
761
+ const specKinds = corpus.filter(c => ['founding', 'founding-temp', 'founding-leaderless', 'normal', 'checkpoint'].indexOf(c.kind) !== -1)
762
+ assert(specKinds.length >= 6, 'the corpus has round prompts to check the reply spec on (' + specKinds.length + ')')
763
+ for (const c of specKinds) {
764
+ const isTemp = /^t-/.test(c.owner)
765
+ const isAcad = c.owner === 'acad'
766
+ if (isTemp) {
767
+ assert(c.prompt.indexOf('"verdict" 字段对你不适用') !== -1, c.owner + ' (temp) is told it has no vote')
768
+ assert(!/"hire":/.test(c.prompt) && !/"fire":/.test(c.prompt), c.owner + ' (temp) is not offered hire/fire')
769
+ } else {
770
+ assert(/"verdict":/.test(c.prompt), c.owner + ' is offered the verdict field')
771
+ assert(/"hire":/.test(c.prompt) && /"fire":/.test(c.prompt), c.owner + ' is offered hire/fire')
772
+ }
773
+ if (isAcad) {
774
+ assert(/"assign":/.test(c.prompt) && /"prioritize":/.test(c.prompt) && /"nudge":/.test(c.prompt) && /"convene_meeting":/.test(c.prompt),
775
+ c.owner + ' (academician) is offered its organizational fields')
776
+ } else {
777
+ assert(!/"assign":/.test(c.prompt) && !/"prioritize":/.test(c.prompt), c.owner + ' is not offered academician-only fields')
778
+ }
779
+ // A field the framework HONOURS but never documents is an unreachable channel: the
780
+ // member cannot object to an assignment, close a task, or fill a meeting input.
781
+ assert(/"reject_assign":/.test(c.prompt), c.owner + ' is told about reject_assign (the objection channel is reachable)')
782
+ assert(/"task_done":/.test(c.prompt), c.owner + ' is told about task_done')
783
+ assert(/"input":/.test(c.prompt), c.owner + ' is told about the meeting "input" field')
784
+ }
785
+ }
786
+
787
+ // =============== CASE 9: verification prompts ===================================
788
+ section('9 verification — voters are asked by name about the right object')
789
+ const RH = makeRoot()
790
+ await callTool('vibe_v5_start', { problem: '表决提示词测试', researcherCount: 2 }, RH)
791
+ for (const sp of spawnsFor(RH)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
792
+ await settleInstitute(RH)
793
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RH)
794
+ await callTool('vibe_v5_record_proposition', { id: 'p-lemma-a', title: '引理甲', statement: '若 n>2 则不存在整数解。', value: 0.6, motive: '用于归约', p: 0.8 }, childAgent(childOf(RH, 'r-1')))
795
+ delivered.length = 0
796
+ const proposed = await callTool('vibe_v5_propose_verify', { target: 'p-lemma-a', kind: 'proposition', reason: '已有证明' }, childAgent(childOf(RH, 'r-1')))
797
+ assert(proposed.ok === true, 'the object was proposed for verification')
798
+ await settle(); await drainWakes(3, RH)
799
+ const verifyPrompts = delivered.filter(w => /【求真表决/.test(w.prompt))
800
+ assert(verifyPrompts.length === 3, 'exactly the three voters were asked, and no temp/non-voter (' + verifyPrompts.length + ')')
801
+ for (const w of verifyPrompts) {
802
+ recordAndCheck('verify', w.owner, w.prompt)
803
+ assert(w.prompt.indexOf('【求真表决 —— ') === 0, w.owner + "'s voting prompt is framed as a vote")
804
+ assert(w.prompt.indexOf(' ' + w.owner + ' 就对象 p-lemma-a 投票】') !== -1, w.owner + "'s voting prompt names itself and the object")
805
+ assert(w.prompt.indexOf('引理甲') !== -1 || w.prompt.indexOf('若 n>2 则不存在整数解') !== -1, w.owner + "'s voting prompt shows the object statement")
806
+ assert(/"target"\s*:\s*"p-lemma-a"/.test(w.prompt), w.owner + "'s voting prompt ends with the exact JSON the plugin parses")
807
+ assert(w.prompt.indexOf('verdict = 1') !== -1 && w.prompt.indexOf('verdict = 0') !== -1, w.owner + ' is told the boolean rule')
808
+ }
809
+ // Now actually reach a debate round, to exercise the DEBATE-stage prompt.
810
+ await callTool('vibe_v5_set', { verdictMaxRounds: 3 }, RH)
811
+ votePlan = new Map([['acad', 0.5], ['r-1', 1], ['r-2', 0.5]])
812
+ delivered.length = 0
813
+ await drainWakes(3, RH) // round 1: not enough boolean votes -> debate
814
+ const stillOpen = await callTool('vibe_v5_status', {}, RH)
815
+ assert(!!stillOpen.verify, 'the verification is still open after abstentions')
816
+ delivered.length = 0
817
+ await drainWakes(3, RH) // round 2 (debate) is asked
818
+ const debatePrompts = delivered.filter(w => /【求真表决/.test(w.prompt))
819
+ assert(debatePrompts.length === 3, 'the debate round re-asks every voter (' + debatePrompts.length + ')')
820
+ for (const w of debatePrompts) {
821
+ recordAndCheck('verify-debate', w.owner, w.prompt)
822
+ assert(/### 上一轮各成员的意见/.test(w.prompt), w.owner + "'s debate prompt publishes the previous round's opinions")
823
+ assert(/verdict=1/.test(w.prompt) && /verdict=0\.5/.test(w.prompt), w.owner + "'s debate prompt shows the real per-member verdicts")
824
+ assert(w.prompt.indexOf('- ' + w.owner + ':') !== -1, w.owner + "'s debate prompt shows its OWN previous vote so it can revise it")
825
+ const hist = /### 上一轮各成员的意见[\s\S]*?(?:\n\n|$)/.exec(w.prompt)
826
+ const histIds = hist ? (hist[0].match(/^- (\S+?):/gm) || []).map(s => s.slice(2, -1)) : []
827
+ assert(histIds.slice().sort().join(',') === 'acad,r-1,r-2', w.owner + "'s debate prompt publishes exactly the voters' opinions (got " + histIds.join('、') + ')')
828
+ }
829
+ await endCase(RH)
830
+
831
+ // =============== CASE 9b: only ONE verification at a time =========================
832
+ section('9b a second proposal QUEUES; it never starts a concurrent verification')
833
+ const RL = makeRoot()
834
+ await callTool('vibe_v5_start', { problem: '并发表决测试', researcherCount: 1 }, RL)
835
+ for (const sp of spawnsFor(RL)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
836
+ await settleInstitute(RL)
837
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RL)
838
+ const rl1 = childOf(RL, 'r-1')
839
+ await callTool('vibe_v5_record_proposition', { id: 'p-first', statement: '第一个对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(rl1))
840
+ await callTool('vibe_v5_record_proposition', { id: 'p-second', statement: '第二个对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(rl1))
841
+ await callTool('vibe_v5_propose_verify', { target: 'p-first', kind: 'proposition', reason: '先做这个' }, childAgent(rl1))
842
+ await settle()
843
+ const stq0 = await callTool('vibe_v5_status', {}, RL)
844
+ assert(!!stq0.verify && stq0.verify.target === 'p-first', 'the first object is under verification')
845
+ // Proposing a second object while one is in flight must QUEUE it. The whole point of the
846
+ // one-at-a-time rule is that consensus is never split across two live ballots; a
847
+ // regression here would silently start a second ballot and drop the object from the queue.
848
+ await callTool('vibe_v5_propose_verify', { target: 'p-second', kind: 'proposition', reason: '排后面' }, childAgent(rl1))
849
+ await settle()
850
+ const stq1 = await callTool('vibe_v5_status', {}, RL)
851
+ assert(!!stq1.verify && stq1.verify.target === 'p-first', 'the in-flight ballot is still the first object')
852
+ assert((stq1.verifyQueue || []).indexOf('p-second') !== -1,
853
+ 'the second proposal is still QUEUED, not begun concurrently (queue=' + JSON.stringify(stq1.verifyQueue) + ')')
854
+ assert(stq1.undecided.length === 0 && stq1.verified.length === 0, 'nothing was settled by merely proposing')
855
+ // Once the first ballot settles, the queued one starts on its own.
856
+ await callTool('vibe_v5_set', { verdictMaxRounds: 1 }, RL)
857
+ delivered.length = 0
858
+ await drainWakes(20, RL)
859
+ const stq2 = await callTool('vibe_v5_status', {}, RL)
860
+ assert(stq2.verify === null || stq2.verify.target === 'p-second',
861
+ 'the queued object took over after the first ballot closed (now: ' + JSON.stringify(stq2.verify && stq2.verify.target) + ')')
862
+ await endCase(RL)
863
+
864
+ // =============== CASE 9c: a solve vote OUTSIDE a meeting ==========================
865
+ section('9c a unanimous solve vote landing outside a meeting still triggers the stop evaluation')
866
+ const RM = makeRoot()
867
+ await callTool('vibe_v5_start', { problem: '会外表决停工测试', researcherCount: 1 }, RM)
868
+ for (const sp of spawnsFor(RM)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
869
+ await settleInstitute(RM)
870
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RM)
871
+ const solvedReply = { vote_solved: true, solved: true, progress: '我认为原问题已解决。', contextPct: 20 }
872
+ replyOverride.set('acad', solvedReply)
873
+ replyOverride.set('r-1', solvedReply)
874
+ await callTool('vibe_v5_say', { to: 'acad', text: '请你就"是否已解决"表态。' }, childAgent(childOf(RM, 'r-1')))
875
+ await settle(); await drainWakes(4, RM)
876
+ await callTool('vibe_v5_say', { to: 'r-1', text: '请你就"是否已解决"表态。' }, childAgent(childOf(RM, 'acad')))
877
+ await settle(); await drainWakes(4, RM)
878
+ const stSolved = await callTool('vibe_v5_status', {}, RM)
879
+ assert(stSolved.solveVotes.length >= 2, 'both voters recorded a solve vote outside any meeting (' + JSON.stringify(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 }))
888
+ await endCase(RM)
889
+
890
+ // =============== CASE 10: meeting prompts =======================================
891
+ section('10 meeting — real speakers, real transcript keys')
892
+ const RI = makeRoot()
893
+ await callTool('vibe_v5_start', { problem: '会议提示词测试', researcherCount: 1 }, RI)
894
+ for (const sp of spawnsFor(RI)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
895
+ await settleInstitute(RI)
896
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RI)
897
+ delivered.length = 0
898
+ const mtg = await callTool('vibe_v5_meeting', { agenda: '分工与下一步', kind: 'sync' }, childAgent(childOf(RI, 'acad')))
899
+ assert(mtg.ok === true, 'the academician convened a meeting (' + JSON.stringify(mtg).slice(0, 90) + ')')
900
+ await settle(); await drainWakes(20, RI)
901
+ const meetingOne = delivered.filter(w => /【研究所会议/.test(w.prompt))
902
+ assert(meetingOne.length >= 2, 'both members were asked to speak (' + meetingOne.length + ')')
903
+ for (const w of meetingOne) {
904
+ recordAndCheck('meeting', w.owner, w.prompt)
905
+ assert(w.prompt.indexOf('【研究所会议 mt-1 进行中 —— ') === 0, w.owner + "'s meeting prompt is framed with the meeting id")
906
+ assert(w.prompt.indexOf('分工与下一步') !== -1, w.owner + "'s meeting prompt carries the agenda")
907
+ assert(w.prompt.indexOf('"input"') !== -1, w.owner + "'s meeting prompt documents the input field it must fill")
908
+ }
909
+ await settleInstitute(RI)
910
+ const stMtg = await callTool('vibe_v5_status', {}, RI)
911
+ assert(stMtg.meeting === null, 'the meeting finished instead of deadlocking')
912
+ const minutes = join(wsOf(RI), 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'Meetings', 'mt-1.md')
913
+ assert(existsSync(minutes), 'the meeting minutes were written')
914
+ if (existsSync(minutes)) {
915
+ const t = readFileSync(minutes, 'utf8')
916
+ assert(/### acad/.test(t) && /### r-1/.test(t), 'the minutes key each speech by its real member id')
917
+ assert(/有表决权者:acad、r-1/.test(t), 'the minutes list the real voting members')
918
+ }
919
+ // A non-academician may only PROPOSE a meeting; the relay must be signed by the proposer.
920
+ delivered.length = 0
921
+ const propMtg = await callTool('vibe_v5_meeting', { agenda: '我提议讨论路线', kind: 'sync' }, childAgent(childOf(RI, 'r-1')))
922
+ assert(propMtg.ok === true && propMtg.proposed === true, 'a researcher can only PROPOSE a meeting (' + JSON.stringify(propMtg).slice(0, 80) + ')')
923
+ await settle(); await drainWakes(20, RI)
924
+ const propText = delivered.map(w => w.prompt).join('\n')
925
+ for (const w of delivered) record('meeting-proposal', w.owner, w.prompt)
926
+ assert(/【研究所·致全体表决者 from r-1】[^\n]*提议开会/.test(propText),
927
+ 'the meeting proposal is relayed SIGNED BY ITS TRUE PROPOSER r-1, not by whoever was woken last')
928
+ await endCase(RI)
929
+
930
+ // =============== CASE 10b: meetings and verifications are mutually exclusive ======
931
+ section('10b a verification proposed DURING a meeting must queue, never preempt it')
932
+ const RN = makeRoot()
933
+ await callTool('vibe_v5_start', { problem: '会议与验证互斥测试', researcherCount: 1 }, RN)
934
+ for (const sp of spawnsFor(RN)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
935
+ await settleInstitute(RN)
936
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RN)
937
+ await callTool('vibe_v5_record_proposition', { id: 'p-mid', statement: '会议期间提出的对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(childOf(RN, 'r-1')))
938
+ // Convene a meeting and stop before it has collected every input, so it stays in flight.
939
+ hushed.add(RN.id)
940
+ delivered.length = 0
941
+ const convened = await callTool('vibe_v5_meeting', { agenda: '先开这个会', kind: 'sync' }, childAgent(childOf(RN, 'acad')))
942
+ assert(convened.ok === true, 'a meeting was convened (' + JSON.stringify(convened).slice(0, 80) + ')')
943
+ await settle(); await drainWakes(1, RN)
944
+ const during = await callTool('vibe_v5_status', {}, RN)
945
+ assert(!!during.meeting, 'the meeting is still in flight (not everyone has spoken)')
946
+ // A member proposing a verification mid-meeting must NOT start a second, concurrent
947
+ // consensus process: the design says meetings and verifications never overlap, and a
948
+ // verification that preempts a meeting starves the meeting's watchdog clock.
949
+ const propMid = await callTool('vibe_v5_propose_verify', { target: 'p-mid', kind: 'proposition', reason: '想在会上定' }, childAgent(childOf(RN, 'r-1')))
950
+ assert(propMid.ok === true, 'the proposal is accepted (' + JSON.stringify(propMid).slice(0, 90) + ')')
951
+ await settle()
952
+ const afterProp = await callTool('vibe_v5_status', {}, RN)
953
+ assert(!!afterProp.meeting, 'the meeting is STILL in flight after the proposal')
954
+ assert(afterProp.verify === null,
955
+ 'NO verification started while the meeting was in flight (got ' + JSON.stringify(afterProp.verify && afterProp.verify.target) + ')')
956
+ assert((afterProp.verifyQueue || []).indexOf('p-mid') !== -1,
957
+ 'the proposal is QUEUED instead (queue=' + JSON.stringify(afterProp.verifyQueue) + ')')
958
+ // Once the meeting ends, the queued proposal must run — queueing must not drop it.
959
+ hushed.delete(RN.id)
960
+ await settleInstitute(RN)
961
+ const afterMtg = await callTool('vibe_v5_status', {}, RN)
962
+ assert(afterMtg.meeting === null, 'the meeting finished')
963
+ assert(afterMtg.verify !== null || afterMtg.undecided.indexOf('p-mid') !== -1 || afterMtg.verified.indexOf('p-mid') !== -1,
964
+ 'the queued proposal was started after the meeting ended (verify=' + JSON.stringify(afterMtg.verify && afterMtg.verify.target)
965
+ + ', queue=' + JSON.stringify(afterMtg.verifyQueue) + ')')
966
+ await endCase(RN)
967
+
968
+ // =============== CASE 11: no unpaced re-wake loop ===============================
969
+ section('11 a task owner is pushed on a PACED cadence, not in a tight loop')
970
+ const RJ = makeRoot()
971
+ await callTool('vibe_v5_start', { problem: '调度节奏测试', researcherCount: 1 }, RJ)
972
+ for (const sp of spawnsFor(RJ)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
973
+ await settleInstitute(RJ)
974
+ const asg = await callTool('vibe_v5_assign', { subject: '一个长任务', to: 'r-1', why: '你最合适', acceptance: '给出结果' }, childAgent(childOf(RJ, 'acad')))
975
+ assert(asg.ok === true, 'a task was assigned to r-1')
976
+ await settle(); await drainWakes(6, RJ)
977
+ const afterAssign = await callTool('vibe_v5_status', {}, RJ)
978
+ assert(afterAssign.tasks.some(t => t.status === 'in_progress' && t.ownerId === 'r-1'), 'r-1 still owns in-progress work')
979
+ assert(!afterAssign.members.some(m => m.busy), 'r-1 is idle again after answering')
980
+ // A task owner must NOT be re-woken the moment its turn ends: the work push is paced by
981
+ // activityTimeoutMs (120 s here). Without the pace, one unfinished task became an
982
+ // unbounded wake -> turn -> wake chain that burned tokens with no backoff at all.
983
+ await sleep(500); await settle()
984
+ const unpaced = wakes.filter(w => w.rootId === RJ.id)
985
+ assert(unpaced.length === 0, 'no unpaced re-wake of the task owner within the idle window (got ' + unpaced.length + ')')
986
+ await endCase(RJ)
987
+
988
+ // =============== CASE 12: Lean mode prompt text =================================
989
+ section('12 Lean formal-verification text enters the prompts (and the corpus)')
990
+ const RK = makeRoot()
991
+ await callTool('vibe_v5_start', { problem: 'Lean 提示词测试', researcherCount: 2 }, RK)
992
+ for (const sp of spawnsFor(RK)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
993
+ await settleInstitute(RK)
994
+ await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, RK)
995
+ // (a) an ordinary work round carries the "formalize reusable things as you go" request
996
+ delivered.length = 0
997
+ await callTool('vibe_v5_say', { to: 'r-1', text: '继续推进。' }, childAgent(childOf(RK, 'acad')))
998
+ await settle(); await drainWakes(3, RK)
999
+ for (const w of delivered.filter(d => d.rootId === RK.id)) recordAndCheck('lean-work', w.owner, w.prompt)
1000
+ {
1001
+ const txt = delivered.filter(d => d.rootId === RK.id).map(d => d.prompt).join('\n')
1002
+ assert(/\[形式化\] 鼓励 Lean/.test(txt), 'the state block announces the Lean mode with its counts')
1003
+ assert(/【顺手形式化(鼓励)】/.test(txt), 'the work round asks for reusable objects to be formalized as work proceeds')
1004
+ }
1005
+ // (b) a voting round on an object WITHOUT a proof carries the "decide by difficulty" block
1006
+ await callTool('vibe_v5_record_proposition', { id: 'p-lean-a', statement: 'Lean 语料对象甲', value: 0.6, motive: 'm', p: 0.8 }, childAgent(childOf(RK, 'r-1')))
1007
+ const propA = await callTool('vibe_v5_propose_verify', { target: 'p-lean-a', kind: 'proposition', reason: '语料' }, childAgent(childOf(RK, 'r-1')))
1008
+ assert(propA.ok === true && propA.started === true, 'the Lean corpus ballot for object 甲 actually started (' + JSON.stringify(propA).slice(0, 90) + ')')
1009
+ delivered.length = 0
1010
+ const vwA = await takeVerifyPrompts(RK, 3)
1011
+ assert(vwA.length === 3, 'captured three voting prompts for object 甲 (got ' + vwA.length + ')')
1012
+ for (const w of vwA) recordAndCheck('lean-verify', memberOfChild(w.childId), w.prompt)
1013
+ {
1014
+ const txt = delivered.filter(d => d.rootId === RK.id).map(d => d.prompt).join('\n')
1015
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(txt), 'the voting prompt explains the Lean mode')
1016
+ assert(/你唯一需要确认的就是忠实性/.test(txt), 'the voting prompt states the fidelity question')
1017
+ }
1018
+ await endCase(RK)
1019
+ // (c) once a proof passes, the voting prompt switches to the fidelity review. This uses its
1020
+ // own root: object 甲's ballot may still be in flight above, and a queued proposal would
1021
+ // make the drained prompts belong to the WRONG ballot (the assertion would then fail for a
1022
+ // reason that has nothing to do with the feature).
1023
+ const RL2 = makeRoot()
1024
+ await callTool('vibe_v5_start', { problem: 'Lean 忠实性提示词测试', researcherCount: 2 }, RL2)
1025
+ for (const sp of spawnsFor(RL2)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1026
+ await settleInstitute(RL2)
1027
+ await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, RL2)
1028
+ await callTool('vibe_v5_record_proposition', { id: 'p-lean-b', statement: 'Lean 语料对象乙', value: 0.6, motive: 'm', p: 0.9 }, childAgent(childOf(RL2, 'r-1')))
1029
+ const leanB = await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-b', content: 'theorem p_lean_b : 1 + 1 = 2 := by decide\n' }, childAgent(childOf(RL2, 'r-1')))
1030
+ assert(leanB.ok === true && leanB.passed === true, 'object 乙 has a proof that really passes (' + JSON.stringify({ ok: leanB.ok, passed: leanB.passed, code: leanB.run && leanB.run.code }) + ')')
1031
+ const propB = await callTool('vibe_v5_propose_verify', { target: 'p-lean-b', kind: 'proposition', reason: '已有证明' }, childAgent(childOf(RL2, 'r-1')))
1032
+ assert(propB.ok === true && propB.started === true, 'the Lean corpus ballot for object 乙 actually started (' + JSON.stringify(propB).slice(0, 90) + ')')
1033
+ delivered.length = 0
1034
+ const vwB = await takeVerifyPrompts(RL2, 3)
1035
+ assert(vwB.length === 3, 'captured three voting prompts for object 乙 (got ' + vwB.length + ')')
1036
+ for (const w of vwB) recordAndCheck('lean-fidelity', memberOfChild(w.childId), w.prompt)
1037
+ {
1038
+ const txt = vwB.map(w => w.prompt).join('\n')
1039
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(txt), 'the prompt announces the passing proof')
1040
+ assert(/你不需要重新检查推导/.test(txt), 'with a proof in hand the prompt tells voters not to re-derive')
1041
+ assert(/忠实性审查/.test(txt), 'and asks for a fidelity review instead')
1042
+ assert(/不要投 0/.test(txt), '★ it forbids expressing a faithfulness defect as 0 (= 命题为假)')
1043
+ assert(/decision:'defect'/.test(txt), 'it names the defect reply channel that withdraws the proof')
1044
+ assert(!/偏离 → 0/.test(txt), '★ the old "any deviation → 0" instruction is gone')
1045
+ assert(/本档没有门禁:请务必给一个严格介于 0 与 1 之间的弃权值/.test(txt), '★ the encourage branch says there is NO gate and that the abstention is what blocks a conclusion')
1046
+ assert(noBareLeanTool(txt), 'no abbreviated tool name appears in the fidelity prompt')
1047
+ }
1048
+ await drainWakes(10, RL2)
1049
+ await endCase(RL2)
1050
+
1051
+ // (d) `require` mode adds the conclusion gate to the voting prompt (contract §10 item 10)
1052
+ const R_LEANREQ = makeRoot()
1053
+ await callTool('vibe_v5_start', { problem: 'Lean require 提示词测试', researcherCount: 2 }, R_LEANREQ)
1054
+ for (const sp of spawnsFor(R_LEANREQ)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1055
+ await settleInstitute(R_LEANREQ)
1056
+ await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'require', leanAsync: false }, R_LEANREQ)
1057
+ await callTool('vibe_v5_record_proposition', { id: 'p-lean-req', statement: 'Lean 语料对象丙(require 档)', value: 0.6, motive: 'm', p: 0.8 }, childAgent(childOf(R_LEANREQ, 'r-1')))
1058
+ const propR = await callTool('vibe_v5_propose_verify', { target: 'p-lean-req', kind: 'proposition', reason: '语料' }, childAgent(childOf(R_LEANREQ, 'r-1')))
1059
+ assert(propR.ok === true && propR.started === true, 'the require-mode ballot started (' + JSON.stringify(propR).slice(0, 90) + ')')
1060
+ delivered.length = 0
1061
+ const vwR = await takeVerifyPrompts(R_LEANREQ, 3)
1062
+ assert(vwR.length === 3, 'captured three require-mode voting prompts (got ' + vwR.length + ')')
1063
+ for (const w of vwR) recordAndCheck('lean-require', memberOfChild(w.childId), w.prompt)
1064
+ {
1065
+ const txt = vwR.map(w => w.prompt).join('\n')
1066
+ assert(/【Lean 形式化验证(强制模式)】/.test(txt), 'the require-mode block is labelled 强制')
1067
+ assert(/本模式要求/.test(txt) && /formal-required/.test(txt), 'it states the conclusion gate and its reason code')
1068
+ assert(/vibe_v5_lean_archive/.test(txt) && /kind='blocked'/.test(txt), 'it names the full archive tool for the blocker route')
1069
+ assert(/宿主无 Lean 工具链/.test(txt), 'it also says what to do when the host has no Lean toolchain')
1070
+ assert(/LEAN_NOT_FOUND/.test(txt) && /NO_SUBPROCESS/.test(txt), '★ it names BOTH no-toolchain codes (a service-less host must not look like an unknown failure)')
1071
+ assert(noBareLeanTool(txt), 'no abbreviated tool name appears in the require-mode prompt')
1072
+ }
1073
+ await drainWakes(30, R_LEANREQ)
1074
+ await endCase(R_LEANREQ)
1075
+ // (e) after a fidelity DEFECT the member must see the withdrawal, never a stale "已通过"
1076
+ const R_LEANDEF = makeRoot()
1077
+ await callTool('vibe_v5_start', { problem: 'Lean 缺陷后提示词测试', researcherCount: 2 }, R_LEANDEF)
1078
+ for (const sp of spawnsFor(R_LEANDEF)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1079
+ await settleInstitute(R_LEANDEF)
1080
+ await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, R_LEANDEF)
1081
+ await callTool('vibe_v5_record_proposition', { id: 'p-lean-def', statement: 'Lean 语料对象丁', value: 0.6, motive: 'm', p: 0.9 }, childAgent(childOf(R_LEANDEF, 'r-1')))
1082
+ const leanD = await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-def', content: 'theorem p_lean_d : 1 + 1 = 2 := by decide\n' }, childAgent(childOf(R_LEANDEF, 'r-1')))
1083
+ assert(leanD.ok === true && leanD.passed === true, 'object 丁 has a passing proof before the defect')
1084
+ delivered.length = 0
1085
+ await callTool('vibe_v5_say', { to: 'r-1', text: '请核对形式化的忠实性。' }, childAgent(childOf(R_LEANDEF, 'acad')))
1086
+ await settle()
1087
+ fireEnd(childOf(R_LEANDEF, 'r-1'), { progress: '核对后发现偏差。', formal: { target: 'p-lean-def', decision: 'defect', note: '条件被加强:连续写成了逐点连续' }, contextPct: 20 })
1088
+ await settle()
1089
+ await drainWakes(8, R_LEANDEF)
1090
+ const stRP = await callTool('vibe_v5_status', {}, R_LEANDEF)
1091
+ const recRP = (stRP.formal.objects || []).find(o => o.target === 'p-lean-def') || {}
1092
+ assert(recRP.status === 'attempted' && !recRP.proof, 'the defect reply withdrew the proof (status=' + recRP.status + ')')
1093
+ delivered.length = 0
1094
+ await callTool('vibe_v5_say', { to: 'r-1', text: '再继续。' }, childAgent(childOf(R_LEANDEF, 'acad')))
1095
+ await settle(); await drainWakes(3, R_LEANDEF)
1096
+ for (const w of delivered.filter(d => d.rootId === R_LEANDEF.id)) recordAndCheck('lean-after-defect', w.owner, w.prompt)
1097
+ {
1098
+ const txt = delivered.filter(d => d.rootId === R_LEANDEF.id).map(d => d.prompt).join('\n')
1099
+ assert(!/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(txt), '★ the post-defect prompt no longer claims a passing proof')
1100
+ assert(/已通过 0/.test(txt), '★ the state block reports zero Lean-passed objects after the withdrawal')
1101
+ }
1102
+ await endCase(R_LEANDEF)
1103
+
1104
+ // (f) the three Lean tools' own agent-facing strings (hint / LEAN_NOT_FOUND message). A tool
1105
+ // `hint` is injected text by the contract's own wording ("工具自己返回的 hint 字段同样算注入文本"),
1106
+ // but it reached the corpus only through the prompts that happened to embed it. Capture it
1107
+ // directly so a reviewer can check the failure routes without reading the plugin source.
1108
+ const R_LEANHINT = makeRoot()
1109
+ await callTool('vibe_v5_start', { problem: 'Lean 工具提示语料测试', researcherCount: 2 }, R_LEANHINT)
1110
+ for (const sp of spawnsFor(R_LEANHINT)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
1111
+ await settleInstitute(R_LEANHINT)
1112
+ await callTool('vibe_v5_set', { maxParallel: 8, formalVerify: 'encourage', leanAsync: false }, R_LEANHINT)
1113
+ {
1114
+ const r1 = childAgent(childOf(R_LEANHINT, 'r-1'))
1115
+ // Labels are OURS (they name the call that produced the string); only the VALUES are
1116
+ // plugin text, so the bare-tool-name check runs on the values, never on the labels.
1117
+ const toolTexts = []
1118
+ await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-hint', content: 'theorem p_lean_hint : 1 + 1 = 2 := by decide\n' }, r1)
1119
+ const green = await callTool('vibe_v5_lean_run', { file: 'Formal/p-lean-hint.lean' }, r1)
1120
+ toolTexts.push(['vibe_v5_lean_run hint (green)', String(green.hint || '')])
1121
+ await callTool('vibe_v5_lean_archive', { kind: 'proof', target: 'p-lean-hint-red', content: 'theorem p_lean_hint_red : 1 = 2 := by sorry\n' }, r1)
1122
+ const red = await callTool('vibe_v5_lean_run', { file: 'Formal/p-lean-hint-red.lean' }, r1)
1123
+ toolTexts.push(['vibe_v5_lean_run hint (red)', String(red.hint || '')])
1124
+ const missing = await callTool('vibe_v5_lean_run', { file: 'Formal/no-such-file.lean' }, r1)
1125
+ toolTexts.push(['vibe_v5_lean_run on a missing file', String(missing.code || '') + '|' + String(missing.message || '')])
1126
+ const noName = await callTool('vibe_v5_lean_archive', { kind: 'def', content: 'def x := 1\n' }, r1)
1127
+ toolTexts.push(['vibe_v5_lean_archive without a name', String(noName.code || '') + '|' + String(noName.message || '')])
1128
+ const noNote = await callTool('vibe_v5_lean_archive', { kind: 'blocked', target: 'p-lean-hint' }, r1)
1129
+ toolTexts.push(['vibe_v5_lean_archive blocked without a note', String(noNote.code || '') + '|' + String(noNote.message || '')])
1130
+ const lib = await callTool('vibe_v5_lean_lib', {}, r1)
1131
+ toolTexts.push(['vibe_v5_lean_lib hint', String(lib.hint || '')])
1132
+ const values = toolTexts.map(([, v]) => v).join('\n')
1133
+ assert(/vibe_v5_lean_archive/.test(values), 'the tool hints name the REGISTERED archive tool (an abbreviated lean_archive is not callable)')
1134
+ assert(noBareLeanTool(values), 'no tool hint or failure message uses an abbreviated lean_* name')
1135
+ assert(/V5_NOT_FOUND/.test(values) && /必须写明原因/.test(values), 'the failure routes are explicit (missing file / missing note)')
1136
+ // NOT via recordAndCheck: these strings are tool output, not a round prompt, so they carry
1137
+ // no [状态] block and the identity sweep does not apply to them.
1138
+ record('lean-tool-hint', 'r-1', toolTexts.map(([k, v]) => k + ': ' + v).join('\n'))
1139
+ }
1140
+ await drainWakes(10, R_LEANHINT)
1141
+ await endCase(R_LEANHINT)
1142
+
1143
+ // =============== PART: full-corpus sweep ========================================
1144
+ section('13 full-corpus sweep over every prompt ever sent')
1145
+ {
1146
+ let swept = 0
1147
+ for (const sp of spawns) {
1148
+ if (!sp.prompt) continue
1149
+ swept++
1150
+ checkPromptSweep(sp.prompt, memberOfChild(sp.childId), 'corpus spawn ' + memberOfChild(sp.childId))
1151
+ }
1152
+ assert(swept >= 12, 'the corpus inspected every founding/resume prompt in the process (' + swept + ')')
1153
+ const owners = new Set(corpus.map(c => c.owner))
1154
+ assert(owners.has('acad') && owners.has('r-1') && owners.has('r-2'), 'the corpus covers academician and researchers (' + [...owners].join('、') + ')')
1155
+ assert([...owners].some(o => /^t-/.test(o)), 'the corpus covers a temp worker')
1156
+ const kinds = new Set(corpus.map(c => c.kind))
1157
+ for (const need of ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
1158
+ 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
1159
+ 'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure',
1160
+ 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect',
1161
+ 'lean-tool-hint']) {
1162
+ assert(kinds.has(need), 'the corpus contains a ' + need + ' prompt')
1163
+ }
1164
+ assert(corpus.every(c => c.prompt && c.prompt.length > 200), 'no captured prompt is suspiciously short')
1165
+ assert(corpus.every(c => !GARBAGE.some(g => g.test(c.prompt + (c.persona || '')))), 'no prompt or charter contains undefined/NaN/? garbage')
1166
+ // A single prompt must not deliver the same message twice. The inbox used to be
1167
+ // prepended AND re-emitted from the [状态] block, so a member read every new message
1168
+ // twice in one prompt.
1169
+ // A3 (assertion-quality review): this used to end with a vacuous `assert(true, …)` and to
1170
+ // `break` after the FIRST duplicate frame, so (a) a property nobody checked was attributed to an
1171
+ // assertion that asserted nothing, and (b) only one duplicate per prompt was ever reported.
1172
+ // The loop now COLLECTS every duplicate frame and reports the count, and the summary assertion
1173
+ // states exactly what was checked.
1174
+ const duplicateFrames = []
1175
+ let inboxOffenders = 0
1176
+ for (const c of corpus) {
1177
+ const bodies = c.prompt.match(/【[^】]*】[^\n]{20,}/g) || []
1178
+ for (const frame of new Set(bodies)) {
1179
+ const n = bodies.filter(b => b === frame).length
1180
+ if (n > 1) duplicateFrames.push({ kind: c.kind, owner: c.owner, n, frame: frame.slice(0, 60) })
1181
+ }
1182
+ const inboxHeads = (c.prompt.match(/\[新到的消息/g) || []).length
1183
+ if (inboxHeads > 1) inboxOffenders += 1
1184
+ assert(inboxHeads <= 1, c.kind + '/' + c.owner + ': at most one inbox section per prompt (found ' + inboxHeads + ')')
1185
+ }
1186
+ assert(duplicateFrames.length === 0,
1187
+ 'no prompt delivers the same framed message twice (duplicate frames found: ' + duplicateFrames.length
1188
+ + (duplicateFrames.length ? ' — ' + duplicateFrames.slice(0, 5).map(d => d.kind + '/' + d.owner + ' ' + d.n + '× ' + d.frame).join(' | ') : '')
1189
+ + '); prompts with two inbox sections: ' + inboxOffenders)
1190
+ // The identity claim inside a prompt must agree with the persona shipped alongside it.
1191
+ for (const c of corpus) {
1192
+ if (!c.persona) continue
1193
+ const st = parseState(c.prompt)
1194
+ if (!st) continue
1195
+ assert(c.persona.indexOf('Members/' + st.id + '/') !== -1,
1196
+ c.kind + ': the charter shipped with ' + st.id + "'s prompt points at Members/" + st.id + '/')
1197
+ }
1198
+ }
1199
+
1200
+ // =============== corpus dump ====================================================
1201
+ section('14 the full prompt corpus is preserved for human review')
1202
+ mkdirSync(CORPUS_DIR, { recursive: true })
1203
+ const md = []
1204
+ md.push('# Vibe Math V5 — 提示词与交互语料(自动生成,请勿手改)')
1205
+ md.push('')
1206
+ md.push('由 `prompt-v5-integrity.test.mjs` 在每次运行时重写。这里保存的是**框架真正发给每个')
1207
+ md.push('成员的提示词原文**,用于人工复核提示词分配、成员代号与交互内容的正确性。')
1208
+ md.push('')
1209
+ md.push('- 生成时刻的工作区路径被替换为 `<WS>`,VibeMath 根被替换为 `<VIBEMATH>`(与 v2/v3/v4 的语料一致,可并排 diff),因此内容是确定性的、可 diff 的。')
1210
+ md.push('- `owner` 是这条提示词**实际发给的成员**;`kind` 是提示词类型。')
1211
+ md.push('- 人设(charter/persona)按成员只完整打印一次,其余条目只记录字符数。')
1212
+ md.push('- 这是提示词正确性的人工复核入口:任何“成员代号/职位/在册名单/交互署名”问题')
1213
+ md.push(' 都能在这里一眼看出,而不必去翻会话日志。')
1214
+ md.push('')
1215
+ const seenPersona = new Set()
1216
+ const order = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
1217
+ 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
1218
+ 'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure',
1219
+ 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect', 'lean-tool-hint']
1220
+ // The sort must be TOTAL, not just by kind: entries of the same kind were emitted in whatever order
1221
+ // the asynchronous drain produced them (two meeting prompts, two members' normal rounds), so the
1222
+ // shipped corpus still changed between runs even after the clock was virtualised. Sorting by
1223
+ // (kind, owner, prompt) makes the file a pure function of the recorded SET.
1224
+ const sorted = corpus.slice().sort((a, b) =>
1225
+ (order.indexOf(a.kind) - order.indexOf(b.kind)) ||
1226
+ (a.owner < b.owner ? -1 : a.owner > b.owner ? 1 : 0) ||
1227
+ (a.prompt < b.prompt ? -1 : a.prompt > b.prompt ? 1 : 0) ||
1228
+ (String(a.toolFilter || '') < String(b.toolFilter || '') ? -1 : 1))
1229
+ for (let i = 0; i < sorted.length; i++) {
1230
+ const c = sorted[i]
1231
+ md.push('---')
1232
+ md.push('')
1233
+ md.push('## [' + (i + 1) + '] kind=`' + c.kind + '` owner=`' + c.owner + '`')
1234
+ md.push('')
1235
+ if (c.toolFilter) md.push('- toolFilter: `' + JSON.stringify(c.toolFilter) + '`')
1236
+ md.push('- charter: ' + (c.persona == null ? '(本次唤醒不带人设)' : c.persona.length + ' 字符'))
1237
+ md.push('')
1238
+ if (c.persona != null && !seenPersona.has(c.owner)) {
1239
+ seenPersona.add(c.owner)
1240
+ md.push('### 人设 / 规章(' + c.owner + ',仅首次完整打印)')
1241
+ md.push('')
1242
+ md.push('```text')
1243
+ md.push(c.persona)
1244
+ md.push('```')
1245
+ md.push('')
1246
+ }
1247
+ md.push('### 提示词原文')
1248
+ md.push('')
1249
+ md.push('```text')
1250
+ md.push(c.prompt)
1251
+ md.push('```')
1252
+ md.push('')
1253
+ }
1254
+ const byKind = {}
1255
+ for (const c of sorted) byKind[c.kind] = (byKind[c.kind] || 0) + 1
1256
+ md.push('---')
1257
+ md.push('')
1258
+ md.push('## 统计')
1259
+ md.push('')
1260
+ for (const k of Object.keys(byKind).sort()) md.push('- `' + k + '`: ' + byKind[k])
1261
+ md.push('')
1262
+ md.push('- 合计:' + corpus.length + ' 条提示词')
1263
+ md.push('')
1264
+ const mdPath = join(CORPUS_DIR, 'prompt-corpus-v5.md')
1265
+ writeFileSync(mdPath, md.join('\n'), 'utf8')
1266
+ writeFileSync(join(CORPUS_DIR, 'prompt-corpus-v5.json'), JSON.stringify({
1267
+ note: 'Vibe Math V5 prompt/interaction corpus — generated by prompt-v5-integrity.test.mjs. <WS> = the run workspace.',
1268
+ counts: byKind, total: sorted.length,
1269
+ prompts: sorted.map(c => ({
1270
+ kind: c.kind, owner: c.owner, sentToLabel: c.sentToLabel,
1271
+ charterChars: c.persona == null ? null : c.persona.length,
1272
+ charter: c.persona, toolFilter: c.toolFilter, prompt: c.prompt,
1273
+ })),
1274
+ }, null, 2), 'utf8')
1275
+ assert(existsSync(mdPath), 'the prompt corpus Markdown was written')
1276
+ assert(existsSync(join(CORPUS_DIR, 'prompt-corpus-v5.json')), 'the prompt corpus JSON was written')
1277
+ const corpusMd = readFileSync(mdPath, 'utf8')
1278
+ assert(corpusMd.length > 30000, 'the corpus is substantial (' + corpusMd.length + ' chars) — the real prompt text is preserved')
1279
+ assert(corpusMd.indexOf('[状态] 你是 acad(院士)') !== -1, 'a human can verify the academician brief verbatim')
1280
+ assert(corpusMd.indexOf('[状态] 你是 r-2') !== -1, 'a human can verify a researcher brief naming itself')
1281
+ assert(corpusMd.indexOf('【框架提示】') !== -1, 'the corpus contains the framework-feedback interaction')
1282
+ assert(corpusMd.indexOf('【会话重建 —— ') !== -1, 'the corpus contains a resume brief')
1283
+ assert(corpusMd.indexOf('【所办分派】') !== -1, 'the corpus contains an office assignment')
1284
+ assert(!/你是 \?/.test(corpusMd), 'the corpus contains NO wrong-identity "?" brief')
1285
+
1286
+ console.log('')
1287
+ console.log('corpus: ' + mdPath)
1288
+ console.log('passed=' + passed + ' failed=' + failed)
1289
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
1290
+ console.log('ALL GREEN')
1291
+ process.exit(0)