dsh-vibe-math 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,942 @@
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 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 sleep = ms => new Promise(r => setTimeout(r, ms))
50
+ const section = (t) => console.log('\n[' + t + ']')
51
+
52
+ // ---------------------------------------------------------------
53
+ // mock host
54
+ // ---------------------------------------------------------------
55
+ function makeProjectionRegistry() {
56
+ const units = new Map()
57
+ const cells = new Map()
58
+ const cellMap = (sess) => {
59
+ const id = String(sess.id)
60
+ let m = cells.get(id)
61
+ if (!m) { m = new Map(); cells.set(id, m) }
62
+ return m
63
+ }
64
+ return {
65
+ register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
66
+ stateOf(session, key) {
67
+ const def = units.get(key)
68
+ if (!def) return undefined
69
+ const m = cellMap(session)
70
+ if (!m.has(key)) m.set(key, def.init(session.header, session.inheritedEventCount || 0))
71
+ return m.get(key)
72
+ },
73
+ _drive(session, event) {
74
+ const m = cellMap(session)
75
+ for (const [k, def] of units) {
76
+ const cur = m.has(k) ? m.get(k) : def.init(session.header, session.inheritedEventCount || 0)
77
+ let next
78
+ try { next = def.apply(cur, event) } catch (e) { next = cur }
79
+ m.set(k, next)
80
+ }
81
+ },
82
+ }
83
+ }
84
+
85
+ const projections = makeProjectionRegistry()
86
+ const listeners = {}
87
+ const toolRegs = []
88
+ const liveAgents = new Map()
89
+
90
+ const spawns = [] // { label, childId, rootId, persona, prompt, toolFilter }
91
+ const wakes = [] // queued sends not yet handled
92
+ const delivered = [] // sends that drainWakes actually handled
93
+ let failNextStarts = 0
94
+
95
+ function makeMockSession(id, parentSession) {
96
+ const events = []
97
+ const s = {
98
+ id,
99
+ header: { version: 1, id, createdAt: Date.now(), cwd: WS, parentSession, isSeeded: false },
100
+ inheritedEventCount: 0,
101
+ get seq() { return events.length },
102
+ append(type, data) {
103
+ const ev = { type, data, seq: events.length, time: Date.now() }
104
+ events.push(ev)
105
+ projections._drive(s, ev)
106
+ return ev
107
+ },
108
+ deriveMessages() { return [] },
109
+ snapshotEvents(from) { return events.slice(from || 0) },
110
+ ownEvents() { return events.slice() },
111
+ _events: events,
112
+ }
113
+ return s
114
+ }
115
+
116
+ const roots = new Map()
117
+ let rootSeq = 0
118
+ function makeRoot() {
119
+ const id = 'sess-' + String.fromCharCode(65 + rootSeq++)
120
+ const session = makeMockSession(id, undefined)
121
+ const root = { id, options: { provider: 'mock', model: 'm' }, session, ctx: undefined }
122
+ roots.set(id, root)
123
+ return root
124
+ }
125
+
126
+ const ctx = {
127
+ get(name) {
128
+ if (name === 'sessionProjections') return projections
129
+ if (name === 'sandboxPolicy') return undefined
130
+ if (name === 'compaction') return undefined
131
+ if (name === 'subprocess') {
132
+ return {
133
+ async spawn({ argv }) {
134
+ const script = argv[argv.length - 1] || ''
135
+ if (/New-Item/.test(script)) {
136
+ const paths = []
137
+ const re = /'((?:[^']|'')*)'/g
138
+ let m
139
+ while ((m = re.exec(script)) !== null) paths.push(m[1].replace(/''/g, "'"))
140
+ for (const p of paths) if (p && !/^-/.test(p)) mkdirSync(p, { recursive: true })
141
+ }
142
+ return { done: Promise.resolve({ exitCode: 0 }) }
143
+ },
144
+ }
145
+ }
146
+ return undefined
147
+ },
148
+ on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
149
+ effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
150
+ logger: { info() {}, warn() {}, error() {} },
151
+ timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
152
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
153
+ commands: { register() { return () => {} } },
154
+ sessions: { async flush() { return true } },
155
+ subagents: {
156
+ list() { return ['spawn'] },
157
+ async startContinuable({ label, request }) {
158
+ if (failNextStarts > 0) { failNextStarts -= 1; throw new Error('mock provisioning failure') }
159
+ const rootId = (request && request.parent && request.parent.id) || 'sess-A'
160
+ const id = 'c' + (spawns.length + 1)
161
+ liveAgents.set(id, { id, session: makeMockSession(id, rootId), options: request && request.agentOptions })
162
+ spawns.push({
163
+ label, childId: id, rootId,
164
+ persona: request && request.persona,
165
+ prompt: request && request.prompt && request.prompt[0] && request.prompt[0].text,
166
+ toolFilter: request && request.toolFilter,
167
+ })
168
+ return { childId: id, messageId: 'm' + spawns.length }
169
+ },
170
+ async sendMessage(parent, childId, blocks) {
171
+ wakes.push({ childId, rootId: (parent && parent.id) || 'sess-A', prompt: (blocks && blocks[0] && blocks[0].text) || '' })
172
+ return 'w' + (delivered.length + wakes.length)
173
+ },
174
+ interrupt() {},
175
+ async drainContinuableChildren(parent, ids) { for (const i of ids) liveAgents.delete(i) },
176
+ },
177
+ agents: {
178
+ roots() { return [...roots.values()] },
179
+ get(id) { return roots.get(id) || liveAgents.get(id) },
180
+ list() { return [...roots.values(), ...liveAgents.values()] },
181
+ },
182
+ fs: {
183
+ async resolve(rel, opts) {
184
+ const b = (opts && opts.cwd) || WS
185
+ const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/'))
186
+ return { targetKey: p, displayPath: p }
187
+ },
188
+ async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
189
+ async readText(t) { return readFileSync(t.targetKey, 'utf8') },
190
+ async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
191
+ async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
192
+ },
193
+ }
194
+
195
+ const mod = await import(PLUGIN.href + '?t=' + Date.now())
196
+ const plugin = mod.default || mod
197
+ plugin.apply(ctx)
198
+
199
+ // ---------------------------------------------------------------
200
+ // driving helpers
201
+ // ---------------------------------------------------------------
202
+ async function callTool(name, args, agent) {
203
+ const spec = toolRegs.find(x => x.name === name)
204
+ if (!spec) throw new Error('no tool ' + name)
205
+ return JSON.parse(await spec.execute(args || {}, { agent }))
206
+ }
207
+ const childAgent = (childId) => liveAgents.get(childId)
208
+ function fireEnd(childId, reply, stopReason) {
209
+ const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
210
+ for (const h of (listeners['subagent/end'] || [])) {
211
+ h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: stopReason || 'completed', lastAssistantMessage: blocks })
212
+ }
213
+ }
214
+ const settle = async () => { await sleep(30) }
215
+ const memberOfChild = (childId) => {
216
+ const s = spawns.find(x => x.childId === childId)
217
+ const m = s ? /vibe5 (\S+) /.exec(s.label) : null
218
+ return m ? m[1] : ''
219
+ }
220
+ const spawnOf = (root, memberId) => spawns.find(s => s.rootId === root.id && s.label.indexOf('vibe5 ' + memberId + ' ') !== -1)
221
+ const childOf = (root, memberId) => { const s = spawnOf(root, memberId); return s ? s.childId : '' }
222
+ const spawnsFor = (root) => spawns.filter(s => s.rootId === root.id)
223
+
224
+ let votePlan = new Map() // memberId -> verdict number for the next verify prompts
225
+ let replyOverride = new Map() // memberId -> the exact reply its NEXT wake must produce
226
+ // Handle queued sends. `delivered` collects what was actually sent for the case under
227
+ // test, because the queue is consumed here and assertions must not read it afterwards.
228
+ // Wakes belonging to OTHER roots are skipped over rather than allowed to block: a case
229
+ // with a short heartbeat keeps producing its own wakes, and a naive
230
+ // "stop at the first foreign wake" loop would starve every later case.
231
+ async function drainWakes(budget, root) {
232
+ let n = 0
233
+ while (n < budget) {
234
+ const idx = wakes.findIndex(w => !root || w.rootId === root.id)
235
+ if (idx === -1) break
236
+ const w = wakes.splice(idx, 1)[0]
237
+ const owner = memberOfChild(w.childId)
238
+ delivered.push({ prompt: w.prompt, owner, childId: w.childId, rootId: w.rootId })
239
+ let reply
240
+ if (replyOverride.has(owner)) { reply = replyOverride.get(owner); replyOverride.delete(owner) } else if (/【求真表决/.test(w.prompt)) {
241
+ const target = (/"target"\s*:\s*"([^"]+)"/.exec(w.prompt) || [])[1] || ''
242
+ const v = votePlan.has(owner) ? votePlan.get(owner) : 0.5
243
+ reply = { verdict: { target, verdict: v, reason: owner + ' 的判断' }, contextPct: 20 }
244
+ } else if (/【研究所会议/.test(w.prompt)) {
245
+ reply = { input: owner + ':我的意见。', solved: false, contextPct: 20 }
246
+ } else {
247
+ reply = { progress: owner + ':继续推进。', solved: false, contextPct: 20 }
248
+ }
249
+ fireEnd(w.childId, reply)
250
+ n++
251
+ await settle()
252
+ }
253
+ return n
254
+ }
255
+ // A case is over: pause it so it can never generate a wake that would leak into the
256
+ // next case, and drop anything it still had queued.
257
+ async function endCase(root) {
258
+ await callTool('vibe_v5_pause', {}, root)
259
+ for (let i = wakes.length - 1; i >= 0; i--) if (wakes[i].rootId === root.id) wakes.splice(i, 1)
260
+ }
261
+ async function settleInstitute(root, rounds = 14) {
262
+ for (let i = 0; i < rounds; i++) {
263
+ await drainWakes(40, root)
264
+ await sleep(20)
265
+ const st = await callTool('vibe_v5_status', {}, root)
266
+ if (!st.members.some(m => m.busy) && !st.meeting && !st.verify && wakes.length === 0) return st
267
+ }
268
+ await drainWakes(40, root)
269
+ return await callTool('vibe_v5_status', {}, root)
270
+ }
271
+
272
+ // ---------------------------------------------------------------
273
+ // prompt inspection
274
+ // ---------------------------------------------------------------
275
+ const KINDS = ['院士', '常驻研究员', '临时工']
276
+ const reState = new RegExp('\\[状态\\]\\s*你是\\s+(\\S+?)((' + KINDS.join('|') + '))|轮次\\s*(\\d+)|法定票数\\s*m=(\\d+)|有表决权者\\s*(\\d+)\\s*人')
277
+ const reRoster = /\[在册\]\s*(.*)/
278
+ const reAbsent = /\[未就位\]\s*(.*)/
279
+ const reHeader = /^【([^】]*)】/m
280
+
281
+ function headerMember(prompt) {
282
+ const h = (reHeader.exec(prompt) || [])[1]
283
+ if (!h) return { header: '', id: '', kind: '' }
284
+ for (const k of KINDS) {
285
+ let m = new RegExp('——\\s*' + k + '\\s+(\\S+?)\\s*】?$').exec(h)
286
+ if (m) return { header: h, id: m[1], kind: k }
287
+ m = new RegExp('——\\s*' + k + '\\s+(\\S+?)\\s+就对象').exec(h)
288
+ if (m) return { header: h, id: m[1], kind: k }
289
+ }
290
+ return { header: h, id: '', kind: '' }
291
+ }
292
+ function parseState(prompt) {
293
+ const s = reState.exec(prompt)
294
+ if (!s) return null
295
+ return { id: s[1], kind: s[2], round: Number(s[3]), m: Number(s[4]), voters: Number(s[5]) }
296
+ }
297
+ const rosterOf = (prompt) => {
298
+ const r = reRoster.exec(prompt)
299
+ return r ? r[1].split(/[、,]/).map(s => s.trim()).filter(s => s && s !== '(无)') : null
300
+ }
301
+ const absentOf = (prompt) => {
302
+ const r = reAbsent.exec(prompt)
303
+ return r ? r[1].split(/[、,]/).map(s => s.trim()).filter(Boolean) : null
304
+ }
305
+ const stateBlockCount = (prompt) => (prompt.match(/\[状态\]/g) || []).length
306
+ const GARBAGE = [/\bundefined\b/, /\bNaN\b/, /\[object Object\]/, /你是\s*\?/]
307
+ const isVoter = (id) => id === 'acad' || /^r-/.test(id)
308
+
309
+ // Applied to EVERY captured prompt.
310
+ function checkPromptSweep(prompt, owner, where) {
311
+ const problems = []
312
+ if (!prompt) return ['prompt is empty']
313
+ for (const g of GARBAGE) if (g.test(prompt)) problems.push('contains ' + g)
314
+ if (stateBlockCount(prompt) !== 1) problems.push('expected exactly one [状态] block, found ' + stateBlockCount(prompt))
315
+ const st = parseState(prompt)
316
+ if (!st) { problems.push('no parseable [状态] line'); return problems }
317
+ if (st.id !== owner) problems.push('[状态] names ' + st.id + ' but was sent to ' + owner)
318
+ const hd = headerMember(prompt)
319
+ if (hd.id && hd.id !== owner) problems.push('header names ' + hd.id + ' but was sent to ' + owner)
320
+ if (hd.kind && st.kind && hd.kind !== st.kind) problems.push('header kind ' + hd.kind + ' ≠ [状态] kind ' + st.kind)
321
+ const roster = rosterOf(prompt)
322
+ if (!roster) problems.push('[在册] line missing')
323
+ else {
324
+ if (roster.indexOf(owner) === -1) problems.push('the roster omits the reader ' + owner + ' ([' + roster.join('、') + '])')
325
+ if (new Set(roster).size !== roster.length) problems.push('duplicate ids in [在册]')
326
+ for (const a of (absentOf(prompt) || [])) {
327
+ const id = String(a).replace(/(.*$/, '')
328
+ if (roster.indexOf(id) !== -1) problems.push(id + ' is on the roster AND listed as 未就位')
329
+ }
330
+ if (st.voters !== roster.filter(isVoter).length) {
331
+ problems.push('有表决权者 ' + st.voters + ' ≠ voters in [在册] ' + roster.filter(isVoter).length)
332
+ }
333
+ if (st.m !== Math.min(3, st.voters)) problems.push('m=' + st.m + ' ≠ min(quorumCap 3, voters ' + st.voters + ')')
334
+ }
335
+ for (const other of KINDS) {
336
+ const hits = prompt.match(new RegExp('你是\\s+\\S+?(' + other + ')', 'g')) || []
337
+ if (hits.length > 1) problems.push('more than one identity claim: ' + hits.join(' / '))
338
+ }
339
+ if (problems.length) console.error(' !! ' + where + ' → ' + problems.join('; '))
340
+ return problems
341
+ }
342
+
343
+ // ---------------------------------------------------------------
344
+ // corpus recorder
345
+ // ---------------------------------------------------------------
346
+ const corpus = []
347
+ const scrub = (s) => String(s == null ? '' : s).split(WS).join('<WS>')
348
+ function record(kind, owner, prompt, persona, extra) {
349
+ corpus.push({
350
+ kind, owner,
351
+ sentToLabel: (extra && extra.label) || '',
352
+ persona: persona === undefined ? null : scrub(persona),
353
+ prompt: scrub(prompt),
354
+ toolFilter: (extra && extra.toolFilter) || null,
355
+ })
356
+ }
357
+ function recordAndCheck(kind, owner, prompt, opts) {
358
+ record(kind, owner, prompt, opts && opts.persona, opts)
359
+ const problems = checkPromptSweep(prompt, owner, kind + ' prompt for ' + owner)
360
+ assert(problems.length === 0, kind + ' prompt for ' + owner + ' is identity/roster coherent')
361
+ return problems
362
+ }
363
+ function checkPersona(kind, owner, persona, checks) {
364
+ for (const [re, label] of checks) assert(re.test(persona), kind + ': ' + owner + "'s charter " + label)
365
+ }
366
+
367
+ // ===============================================================
368
+ console.log('-- V5 prompt & interaction integrity --')
369
+
370
+ // =============== CASE 1: founding briefs =========================================
371
+ section('1 founding — every induction brief describes the member that receives it')
372
+ const RA = makeRoot()
373
+ const started = await callTool('vibe_v5_start', { problem: '求 3N^2-2=b^2 与 3N^2+2=5a^2 的全部整数解', researcherCount: 3 }, RA)
374
+ assert(started.ok === true, 'institute founded')
375
+ const FOUND_ORDER = ['acad', 'r-1', 'r-2', 'r-3']
376
+ const founding = spawnsFor(RA)
377
+ assert(founding.length === 4, 'four founding members were started (got ' + founding.length + ')')
378
+ for (let i = 0; i < founding.length; i++) {
379
+ const sp = founding[i]
380
+ const owner = memberOfChild(sp.childId)
381
+ assert(owner === FOUND_ORDER[i], 'founding #' + i + ' started ' + owner + ' (expected ' + FOUND_ORDER[i] + ')')
382
+ recordAndCheck('founding', owner, sp.prompt, sp)
383
+ const st = parseState(sp.prompt) || {}
384
+ const roster = rosterOf(sp.prompt) || []
385
+ assert(JSON.stringify(roster) === JSON.stringify(FOUND_ORDER.slice(0, i + 1)),
386
+ 'the founding brief of ' + owner + ' shows the roster INCLUDING itself: ' + JSON.stringify(roster))
387
+ assert(st.round === 1, owner + "'s founding brief is round 1 (got " + st.round + ')')
388
+ assert(st.voters === i + 1, owner + ' sees ' + (i + 1) + ' voter(s) (got ' + st.voters + ')')
389
+ assert(st.m === Math.min(3, i + 1), owner + ' sees m=min(3,' + (i + 1) + ')=' + Math.min(3, i + 1) + ' (got ' + st.m + ')')
390
+ assert(sp.prompt.indexOf('【入职首轮') === 0, owner + "'s first prompt is framed as an induction")
391
+ assert(sp.prompt.indexOf('你刚刚加入本所') !== -1, owner + "'s induction asks for its own first view")
392
+ }
393
+ assert(founding[0].prompt.indexOf('[状态] 你是 acad(院士)') !== -1, 'the academician brief says 你是 acad(院士) — never "?"')
394
+ assert(!/你是 \?/.test(founding[0].prompt), 'no "你是 ?" placeholder')
395
+ checkPersona('founding', 'acad', founding[0].persona, [
396
+ [/在册院士:acad/, 'lists itself as the sitting academician'],
397
+ [/在册常驻研究员:(无)/, 'shows no researchers at that instant'],
398
+ [/你是「institute」的\*\*院士\*\*/, 'opens by naming its office'],
399
+ [/Members\/acad\//, 'points at its own library'],
400
+ ])
401
+ checkPersona('founding', 'r-3', founding[3].persona, [
402
+ [/在册院士:acad/, 'names the sitting academician'],
403
+ [/在册常驻研究员:r-1、r-2、r-3/, 'lists r-1、r-2、r-3 as the sitting researchers'],
404
+ [/在册临时工:(无)/, 'shows no temps'],
405
+ [/代号 r-3。/, 'states its own 代号'],
406
+ [/Members\/r-3\//, 'points at its own library'],
407
+ [/progress.md/, 'documents Progress/progress.md'],
408
+ ])
409
+ checkPersona('founding', 'r-1', founding[1].persona, [[/一名常驻研究员/, 'opens as 常驻研究员']])
410
+ for (const sp of founding) {
411
+ const owner = memberOfChild(sp.childId)
412
+ assert(sp.persona.indexOf('Members/' + owner + '/') !== -1, owner + "'s charter points at Members/" + owner + '/')
413
+ }
414
+ for (const sp of founding) { sp._ended = true; fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
415
+ await settleInstitute(RA)
416
+ await endCase(RA)
417
+
418
+ // =============== CASE 2: round prompts (normal + checkpoint) =====================
419
+ section('2 rounds — normal and checkpoint prompts keep the identity straight')
420
+ const RB = makeRoot()
421
+ await callTool('vibe_v5_start', { problem: '无领头人情形下的组织', researcherCount: 2 }, RB)
422
+ for (const sp of spawnsFor(RB)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
423
+ await settleInstitute(RB)
424
+ // A short idle window makes the heartbeat prompt reachable inside a test run.
425
+ await callTool('vibe_v5_set', { activityTimeoutMs: 80, maxParallel: 6, chatDigestMax: 1 }, RB)
426
+ // (a) an addressed message must produce a NORMAL round prompt carrying the framed inbox
427
+ delivered.length = 0
428
+ await callTool('vibe_v5_say', { to: 'r-2', text: '请把你手上的结论同步给我。' }, childAgent(childOf(RB, 'r-1')))
429
+ await settle(); await drainWakes(10, RB)
430
+ const normalWakes = delivered.slice()
431
+ assert(normalWakes.length > 0, 'the addressed message produced a wake (' + normalWakes.length + ')')
432
+ for (const w of normalWakes) recordAndCheck('normal', w.owner, w.prompt)
433
+ assert(normalWakes.some(w => w.owner === 'r-2' && w.prompt.indexOf('【研究所·私信 from r-1】') !== -1),
434
+ 'r-2 is woken with its inbox containing the DM framed from r-1')
435
+ assert(normalWakes.filter(w => w.owner === 'r-2').every(w => w.prompt.indexOf('【研究所·私信 from r-2】') === -1),
436
+ 'r-2 never receives the DM framed as coming from itself')
437
+ // (b) the heartbeat must produce a CHECKPOINT prompt
438
+ delivered.length = 0
439
+ await sleep(260); await settle(); await drainWakes(10, RB)
440
+ let checkpointWakes = delivered.filter(w => /【心跳检查/.test(w.prompt))
441
+ if (!checkpointWakes.length) { await sleep(260); await settle(); await drainWakes(10, RB); checkpointWakes = delivered.filter(w => /【心跳检查/.test(w.prompt)) }
442
+ assert(checkpointWakes.length > 0, 'the heartbeat produced a checkpoint prompt (' + checkpointWakes.length + ')')
443
+ for (const w of checkpointWakes) {
444
+ recordAndCheck('checkpoint', w.owner, w.prompt)
445
+ // The heartbeat body may be preceded by a delivered inbox or the core-rules recap
446
+ // after a real compaction, so match anywhere rather than at offset 0.
447
+ assert(w.prompt.indexOf('【心跳检查 —— ') !== -1, w.owner + "'s heartbeat prompt names its own office and id")
448
+ assert(new RegExp('【心跳检查 —— (院士|常驻研究员|临时工) ' + w.owner + '】').test(w.prompt),
449
+ w.owner + "'s heartbeat header carries its own kind and id")
450
+ }
451
+ await endCase(RB)
452
+
453
+ // =============== CASE 3: interaction framing ====================================
454
+ section('3 interaction framing — every message names its true sender and kind')
455
+ const RC = makeRoot()
456
+ await callTool('vibe_v5_start', { problem: '交互框架测试', researcherCount: 2 }, RC)
457
+ for (const sp of spawnsFor(RC)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
458
+ await settleInstitute(RC)
459
+ await callTool('vibe_v5_set', { maxParallel: 8, chatDigestMax: 1 }, RC)
460
+ const r1 = childOf(RC, 'r-1'), acad = childOf(RC, 'acad')
461
+ const send = async (kind, fn) => {
462
+ delivered.length = 0
463
+ const r = await fn()
464
+ await settle(); await drainWakes(20, RC)
465
+ const handled = delivered.slice()
466
+ for (const w of handled) recordAndCheck(kind, w.owner, w.prompt).length
467
+ return { r, prompts: handled.map(w => w.prompt).join('\n'), owners: handled.map(w => w.owner), count: handled.length }
468
+ }
469
+ const dm = await send('inbox-dm', () => callTool('vibe_v5_say', { to: 'r-2', text: '私下问你一下。' }, childAgent(r1)))
470
+ const voters = await send('inbox-voters', () => callTool('vibe_v5_say', { to: 'voters', text: '请全体表决者注意。' }, childAgent(r1)))
471
+ const chat = await send('inbox-chat', () => callTool('vibe_v5_say', { text: '各位,我建议先做最小反例归约。' }, childAgent(r1)))
472
+ const office = await send('inbox-office', () => callTool('vibe_v5_message', { to: 'all', content: '所办通知:请按计划推进。' }, RC))
473
+ const assign = await send('inbox-assign', () => callTool('vibe_v5_assign', { subject: '核验模 9 情形', to: 'r-2', why: '你最熟同余', acceptance: '给出模 9 全表' }, childAgent(acad)))
474
+ const nudge = await send('inbox-nudge', () => callTool('vibe_v5_nudge', { to: 'r-2', why: '进度偏慢', next_step: '先交一份模 9 表' }, childAgent(acad)))
475
+ assert(dm.r.ok === true && dm.r.delivered === 1, 'a DM reaches exactly its addressee')
476
+ assert(voters.r.ok === true && voters.r.delivered === 2, 'a voters-only broadcast reaches every voter but the sender')
477
+ assert(chat.r.ok === true && chat.r.delivered === 2, 'group chat fans out to every other member')
478
+ assert(assign.r.ok === true, "the ACADEMICIAN's assignment succeeded (" + JSON.stringify(assign.r).slice(0, 90) + ')')
479
+ assert(/【研究所·私信 from r-1】/.test(dm.prompts), 'a DM is framed by its true sender (【研究所·私信 from r-1】)')
480
+ assert(/【研究所·致全体表决者 from r-1】/.test(voters.prompts), 'a voters-only broadcast is framed as such, not as a DM')
481
+ assert(/【研究所·群聊】r-1:/.test(chat.prompts), 'group chat is framed with the true speaker')
482
+ assert(/【所办通知】/.test(office.prompts), 'an office notice is framed 所办通知')
483
+ assert(/【院士分派】/.test(assign.prompts), "the academician's assignment is framed 院士分派")
484
+ assert(/【督办 from acad】/.test(nudge.prompts), 'a nudge is framed 督办 by its true author')
485
+ assert(!/【院士分派】[^\n]*督办/.test(nudge.prompts), 'a nudge is NOT mislabelled as an assignment')
486
+ // THE OFFICE ITSELF must be able to assign, and must not impersonate the academician.
487
+ const officeAssign = await send('inbox-office-assign', () => callTool('vibe_v5_assign', { subject: '所办指派', to: 'r-2', why: '所办决定', acceptance: '给出结论' }, RC))
488
+ 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) + ')')
489
+ assert(/【所办分派】/.test(officeAssign.prompts), 'an OFFICE assignment is framed 所办分派, not 院士分派')
490
+ assert(!/【院士分派】/.test(officeAssign.prompts), 'an office assignment does not impersonate the academician')
491
+ const officeNudge = await send('inbox-office-nudge', () => callTool('vibe_v5_nudge', { to: 'r-2', why: '所办督办一下' }, RC))
492
+ assert(officeNudge.r.ok === true, 'the OFFICE can nudge')
493
+ assert(/【督办 from office】/.test(officeNudge.prompts) && /所办督办/.test(officeNudge.prompts),
494
+ 'an office nudge is labelled 所办督办 by the office, not 院士督办')
495
+ 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)')
496
+ await endCase(RC)
497
+
498
+ // =============== CASE 4: framework feedback delivery ============================
499
+ section('4 framework feedback reaches the member (never dropped as a self-message)')
500
+ const RD = makeRoot()
501
+ await callTool('vibe_v5_start', { problem: '框架反馈投递测试', researcherCount: 2 }, RD)
502
+ for (const sp of spawnsFor(RD)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
503
+ await settleInstitute(RD)
504
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RD)
505
+ const rd1 = childOf(RD, 'r-1'), rd2 = childOf(RD, 'r-2')
506
+ // A member can only be answered while a turn of its own is in flight, so each case
507
+ // below first WAKES r-1 and then lets its reply be the malformed one.
508
+ const wakeR1With = async (kind, reply) => {
509
+ delivered.length = 0
510
+ replyOverride.set('r-1', reply)
511
+ await callTool('vibe_v5_say', { to: 'r-1', text: '请就当前状态给个结论。' }, childAgent(rd2))
512
+ await settle(); await drainWakes(20, RD)
513
+ const handled = delivered.slice()
514
+ for (const w of handled) if (/【框架提示】/.test(w.prompt)) recordAndCheck(kind, w.owner, w.prompt).length
515
+ return handled.map(w => w.prompt).join('\n')
516
+ }
517
+ const badVerdictText = await wakeR1With('notice', { verdict: { target: 'p-ghost', verdict: 'not-a-number', reason: 'x' }, contextPct: 20 })
518
+ assert(/【框架提示】/.test(badVerdictText), 'a malformed verdict produces a real 【框架提示】 delivery')
519
+ assert(/verdict 必须是 0-1 的数值/.test(badVerdictText), 'the notice says what was wrong')
520
+ assert(/【框架提示】[^\n]*verdict/.test(badVerdictText), 'the notice is framed by the framework, not by the member itself')
521
+ const badClaimText = await wakeR1With('notice-claim', { task_claim: 't-999', contextPct: 20 })
522
+ assert(/【框架提示】/.test(badClaimText) && /没有任务 t-999/.test(badClaimText), 'a refused claim is reported back to the claimer')
523
+ // A CAS refusal reported through the JSON reply must also come back.
524
+ const t = await callTool('vibe_v5_task_create', { subject: '一个没人认领的任务' }, childAgent(rd1))
525
+ const staleText = await wakeR1With('notice-task', { task_update: { task_id: t.task.id, expected_revision: 99, action: 'claim' }, contextPct: 20 })
526
+ assert(/【框架提示】/.test(staleText) && /V5_TASK_STALE_REVISION/.test(staleText), 'a stale CAS reported through the reply channel is echoed back')
527
+ const selfFramed = corpus.filter(c => new RegExp('【研究所·私信 from ' + c.owner + '】').test(c.prompt))
528
+ assert(selfFramed.length === 0, 'no member ever receives a message framed as coming from itself')
529
+ await endCase(RD)
530
+
531
+ // =============== CASE 5: temp workers ===========================================
532
+ section('5 a hired temp worker is told its own name, employer and purpose')
533
+ const RE = makeRoot()
534
+ await callTool('vibe_v5_start', { problem: '临时工入职测试', researcherCount: 2 }, RE)
535
+ for (const sp of spawnsFor(RE)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
536
+ await settleInstitute(RE)
537
+ const hired = await callTool('vibe_v5_hire', { purpose: '核对文献引理', initial_task: '核对第 3 节引理' }, childAgent(childOf(RE, 'r-1')))
538
+ assert(hired.ok === true, 'r-1 hired a temp worker (' + JSON.stringify(hired).slice(0, 80) + ')')
539
+ const tempSpawn = spawnOf(RE, hired.id)
540
+ recordAndCheck('founding-temp', hired.id, tempSpawn.prompt, tempSpawn)
541
+ {
542
+ const st = parseState(tempSpawn.prompt) || {}
543
+ assert(st.kind === '临时工', 'the temp brief calls it 临时工 (got ' + st.kind + ')')
544
+ assert((rosterOf(tempSpawn.prompt) || []).indexOf(hired.id) !== -1, 'the temp brief lists the temp itself on the roster')
545
+ checkPersona('founding-temp', hired.id, tempSpawn.persona, [
546
+ [new RegExp('代号 ' + hired.id + ',由 r-1 雇入'), 'names itself and its true employer'],
547
+ [/用途:核对文献引理/, 'states its purpose'],
548
+ [new RegExp('Members/' + hired.id + '/'), 'points at its own library'],
549
+ [/你的雇主:r-1/, 'names its employer'],
550
+ ])
551
+ assert(/【入职首轮 —— 临时工 t-\d+】/.test(tempSpawn.prompt), 'the temp brief is framed as its induction')
552
+ assert(/你的初始任务\/用途:/.test(tempSpawn.prompt) && /核对第 3 节引理/.test(tempSpawn.prompt), 'the temp brief carries its initial task')
553
+ assert(!/"verdict":/.test(tempSpawn.prompt) || /"verdict" 字段对你不适用/.test(tempSpawn.prompt), 'the temp brief states it has no vote')
554
+ assert(!/"hire":/.test(tempSpawn.prompt), 'the temp brief does not offer hire')
555
+ }
556
+ 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() } }
557
+ await settleInstitute(RE)
558
+
559
+ // =============== CASE 6: failed provisioning is visible =========================
560
+ section('6 a member that failed to provision is visible, not a phantom')
561
+ failNextStarts = 1
562
+ const failedHire = await callTool('vibe_v5_hire', { purpose: '注定失败', initial_task: 'x' }, childAgent(childOf(RE, 'r-1')))
563
+ assert(failedHire.ok === false, 'a provisioning failure is reported to the hirer (' + JSON.stringify(failedHire).slice(0, 100) + ')')
564
+ const stFail = await callTool('vibe_v5_status', {}, RE)
565
+ const failedMember = stFail.members.find(m => m.phase === 'failed')
566
+ assert(!!failedMember, 'the member is recorded as failed rather than left active')
567
+ assert(!failedMember || failedMember.busy !== true, 'a failed member is not left marked busy')
568
+ delivered.length = 0
569
+ await callTool('vibe_v5_say', { to: 'r-2', text: '看下编制。' }, childAgent(childOf(RE, 'r-1')))
570
+ await settle(); await drainWakes(20, RE)
571
+ const failPrompts = delivered.slice()
572
+ for (const w of failPrompts) recordAndCheck('after-failure', w.owner, w.prompt).length
573
+ const failText = failPrompts.map(w => w.prompt).join('\n')
574
+ assert(failPrompts.length > 0, 'a member was woken after the failure (' + failPrompts.length + ')')
575
+ assert(new RegExp('\\[未就位\\][^\\n]*' + failedMember.id).test(failText), 'the failed member appears in [未就位] with its id')
576
+ assert(!new RegExp('\\[在册\\][^\\n]*' + failedMember.id).test(failText), 'the failed member is NOT listed as if it were on the roster')
577
+ await endCase(RE)
578
+
579
+ // =============== CASE 7: session rebuild ========================================
580
+ section('7 a rebuilt session is told it was rebuilt, not that it just joined')
581
+ const RF = makeRoot()
582
+ await callTool('vibe_v5_start', { problem: '会话重建测试', researcherCount: 2 }, RF)
583
+ for (const sp of spawnsFor(RF)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
584
+ await settleInstitute(RF)
585
+ const personasBefore = {}
586
+ for (const sp of spawnsFor(RF)) personasBefore[memberOfChild(sp.childId)] = sp.persona
587
+ await callTool('vibe_v5_stop', {}, RF)
588
+ await settle()
589
+ const spawnCountBefore = spawns.length
590
+ const resumed = await callTool('vibe_v5_resume', {}, RF)
591
+ assert(resumed.ok === true, 'the institute resumed (' + JSON.stringify(resumed).slice(0, 120) + ')')
592
+ const resumeSpawns = spawns.slice(spawnCountBefore).filter(s => s.rootId === RF.id)
593
+ assert(resumeSpawns.length > 0, 'resume rebuilt at least one member session (' + resumeSpawns.length + ')')
594
+ for (const sp of resumeSpawns) {
595
+ const owner = memberOfChild(sp.childId)
596
+ recordAndCheck('resume', owner, sp.prompt, sp)
597
+ assert(sp.prompt.indexOf('【会话重建 —— ') === 0, owner + "'s rebuilt session is framed 会话重建, not 入职首轮")
598
+ assert(sp.prompt.indexOf('你刚刚加入本所') === -1, owner + ' is NOT told "你刚刚加入本所" on resume')
599
+ assert(sp.prompt.indexOf('不要从头再来') !== -1, owner + ' is told to read back its progress instead of restarting')
600
+ assert(sp.persona === personasBefore[owner], owner + "'s charter is the FROZEN hire-time one, not a resumed-time rewrite")
601
+ }
602
+ await endCase(RF)
603
+
604
+ // =============== CASE 8: leaderless institute ===================================
605
+ section('8 with academician:false no charter invents a leader')
606
+ const RG = makeRoot()
607
+ const l2 = await callTool('vibe_v5_start', { problem: '无院士建所', researcherCount: 2, academician: false }, RG)
608
+ assert(l2.ok === true, 'a leaderless institute can be founded (' + JSON.stringify(l2).slice(0, 100) + ')')
609
+ const l2spawns = spawnsFor(RG)
610
+ assert(l2spawns.length === 2, 'two researchers were founded and no academician (got ' + l2spawns.length + ')')
611
+ for (let i = 0; i < l2spawns.length; i++) {
612
+ const sp = l2spawns[i]
613
+ const owner = memberOfChild(sp.childId)
614
+ recordAndCheck('founding-leaderless', owner, sp.prompt, sp)
615
+ const st = parseState(sp.prompt) || {}
616
+ assert(st.m === Math.min(3, i + 1), owner + ': m is computed over the leaderless roster INCLUDING itself (m=' + st.m + ')')
617
+ assert(st.kind === '常驻研究员', owner + ' is a 常驻研究员 (got ' + st.kind + ')')
618
+ assert(/在册院士:(无)/.test(sp.persona), owner + "'s charter records that there is no academician")
619
+ assert(/本所当前\*\*没有在册院士\*\*/.test(sp.persona), owner + "'s charter says so in the organization section")
620
+ assert(!/本所的领头人是\*\*院士/.test(sp.persona), owner + "'s charter does NOT claim a leader exists")
621
+ assert(!/院士 acad/.test(sp.persona), owner + "'s charter never names a non-existent 院士 acad")
622
+ assert(!/主动向院士汇报/.test(sp.persona), owner + "'s charter does not tell it to report to a non-existent academician")
623
+ assert(!/院士也可以给你派活/.test(sp.persona), owner + "'s charter does not promise assignments from a non-existent academician")
624
+ assert(!/院士可以直接分派任务/.test(sp.persona), owner + "'s charter does not promise academician assignment powers")
625
+ }
626
+ const l2status = await callTool('vibe_v5_status', {}, RG)
627
+ assert(l2status.quorum.voters.indexOf('acad') === -1, 'the leaderless institute has no academician among its voters')
628
+ await endCase(RG)
629
+
630
+ // =============== CASE 8b: the JSON contract offered matches what is honoured =====
631
+ section('8b the reply spec documents exactly the fields the framework honours')
632
+ {
633
+ const specKinds = corpus.filter(c => ['founding', 'founding-temp', 'founding-leaderless', 'normal', 'checkpoint'].indexOf(c.kind) !== -1)
634
+ assert(specKinds.length >= 6, 'the corpus has round prompts to check the reply spec on (' + specKinds.length + ')')
635
+ for (const c of specKinds) {
636
+ const isTemp = /^t-/.test(c.owner)
637
+ const isAcad = c.owner === 'acad'
638
+ if (isTemp) {
639
+ assert(c.prompt.indexOf('"verdict" 字段对你不适用') !== -1, c.owner + ' (temp) is told it has no vote')
640
+ assert(!/"hire":/.test(c.prompt) && !/"fire":/.test(c.prompt), c.owner + ' (temp) is not offered hire/fire')
641
+ } else {
642
+ assert(/"verdict":/.test(c.prompt), c.owner + ' is offered the verdict field')
643
+ assert(/"hire":/.test(c.prompt) && /"fire":/.test(c.prompt), c.owner + ' is offered hire/fire')
644
+ }
645
+ if (isAcad) {
646
+ assert(/"assign":/.test(c.prompt) && /"prioritize":/.test(c.prompt) && /"nudge":/.test(c.prompt) && /"convene_meeting":/.test(c.prompt),
647
+ c.owner + ' (academician) is offered its organizational fields')
648
+ } else {
649
+ assert(!/"assign":/.test(c.prompt) && !/"prioritize":/.test(c.prompt), c.owner + ' is not offered academician-only fields')
650
+ }
651
+ // A field the framework HONOURS but never documents is an unreachable channel: the
652
+ // member cannot object to an assignment, close a task, or fill a meeting input.
653
+ assert(/"reject_assign":/.test(c.prompt), c.owner + ' is told about reject_assign (the objection channel is reachable)')
654
+ assert(/"task_done":/.test(c.prompt), c.owner + ' is told about task_done')
655
+ assert(/"input":/.test(c.prompt), c.owner + ' is told about the meeting "input" field')
656
+ }
657
+ }
658
+
659
+ // =============== CASE 9: verification prompts ===================================
660
+ section('9 verification — voters are asked by name about the right object')
661
+ const RH = makeRoot()
662
+ await callTool('vibe_v5_start', { problem: '表决提示词测试', researcherCount: 2 }, RH)
663
+ for (const sp of spawnsFor(RH)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
664
+ await settleInstitute(RH)
665
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RH)
666
+ 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')))
667
+ delivered.length = 0
668
+ const proposed = await callTool('vibe_v5_propose_verify', { target: 'p-lemma-a', kind: 'proposition', reason: '已有证明' }, childAgent(childOf(RH, 'r-1')))
669
+ assert(proposed.ok === true, 'the object was proposed for verification')
670
+ await settle(); await drainWakes(3, RH)
671
+ const verifyPrompts = delivered.filter(w => /【求真表决/.test(w.prompt))
672
+ assert(verifyPrompts.length === 3, 'exactly the three voters were asked, and no temp/non-voter (' + verifyPrompts.length + ')')
673
+ for (const w of verifyPrompts) {
674
+ recordAndCheck('verify', w.owner, w.prompt)
675
+ assert(w.prompt.indexOf('【求真表决 —— ') === 0, w.owner + "'s voting prompt is framed as a vote")
676
+ assert(w.prompt.indexOf(' ' + w.owner + ' 就对象 p-lemma-a 投票】') !== -1, w.owner + "'s voting prompt names itself and the object")
677
+ assert(w.prompt.indexOf('引理甲') !== -1 || w.prompt.indexOf('若 n>2 则不存在整数解') !== -1, w.owner + "'s voting prompt shows the object statement")
678
+ assert(/"target"\s*:\s*"p-lemma-a"/.test(w.prompt), w.owner + "'s voting prompt ends with the exact JSON the plugin parses")
679
+ assert(w.prompt.indexOf('verdict = 1') !== -1 && w.prompt.indexOf('verdict = 0') !== -1, w.owner + ' is told the boolean rule')
680
+ }
681
+ // Now actually reach a debate round, to exercise the DEBATE-stage prompt.
682
+ await callTool('vibe_v5_set', { verdictMaxRounds: 3 }, RH)
683
+ votePlan = new Map([['acad', 0.5], ['r-1', 1], ['r-2', 0.5]])
684
+ delivered.length = 0
685
+ await drainWakes(3, RH) // round 1: not enough boolean votes -> debate
686
+ const stillOpen = await callTool('vibe_v5_status', {}, RH)
687
+ assert(!!stillOpen.verify, 'the verification is still open after abstentions')
688
+ delivered.length = 0
689
+ await drainWakes(3, RH) // round 2 (debate) is asked
690
+ const debatePrompts = delivered.filter(w => /【求真表决/.test(w.prompt))
691
+ assert(debatePrompts.length === 3, 'the debate round re-asks every voter (' + debatePrompts.length + ')')
692
+ for (const w of debatePrompts) {
693
+ recordAndCheck('verify-debate', w.owner, w.prompt)
694
+ assert(/### 上一轮各成员的意见/.test(w.prompt), w.owner + "'s debate prompt publishes the previous round's opinions")
695
+ assert(/verdict=1/.test(w.prompt) && /verdict=0\.5/.test(w.prompt), w.owner + "'s debate prompt shows the real per-member verdicts")
696
+ assert(w.prompt.indexOf('- ' + w.owner + ':') !== -1, w.owner + "'s debate prompt shows its OWN previous vote so it can revise it")
697
+ const hist = /### 上一轮各成员的意见[\s\S]*?(?:\n\n|$)/.exec(w.prompt)
698
+ const histIds = hist ? (hist[0].match(/^- (\S+?):/gm) || []).map(s => s.slice(2, -1)) : []
699
+ assert(histIds.slice().sort().join(',') === 'acad,r-1,r-2', w.owner + "'s debate prompt publishes exactly the voters' opinions (got " + histIds.join('、') + ')')
700
+ }
701
+ await endCase(RH)
702
+
703
+ // =============== CASE 9b: only ONE verification at a time =========================
704
+ section('9b a second proposal QUEUES; it never starts a concurrent verification')
705
+ const RL = makeRoot()
706
+ await callTool('vibe_v5_start', { problem: '并发表决测试', researcherCount: 1 }, RL)
707
+ for (const sp of spawnsFor(RL)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
708
+ await settleInstitute(RL)
709
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RL)
710
+ const rl1 = childOf(RL, 'r-1')
711
+ await callTool('vibe_v5_record_proposition', { id: 'p-first', statement: '第一个对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(rl1))
712
+ await callTool('vibe_v5_record_proposition', { id: 'p-second', statement: '第二个对象', value: 0.6, motive: 'm', p: 0.7 }, childAgent(rl1))
713
+ await callTool('vibe_v5_propose_verify', { target: 'p-first', kind: 'proposition', reason: '先做这个' }, childAgent(rl1))
714
+ await settle()
715
+ const stq0 = await callTool('vibe_v5_status', {}, RL)
716
+ assert(!!stq0.verify && stq0.verify.target === 'p-first', 'the first object is under verification')
717
+ // Proposing a second object while one is in flight must QUEUE it. The whole point of the
718
+ // one-at-a-time rule is that consensus is never split across two live ballots; a
719
+ // regression here would silently start a second ballot and drop the object from the queue.
720
+ await callTool('vibe_v5_propose_verify', { target: 'p-second', kind: 'proposition', reason: '排后面' }, childAgent(rl1))
721
+ await settle()
722
+ const stq1 = await callTool('vibe_v5_status', {}, RL)
723
+ assert(!!stq1.verify && stq1.verify.target === 'p-first', 'the in-flight ballot is still the first object')
724
+ assert((stq1.verifyQueue || []).indexOf('p-second') !== -1,
725
+ 'the second proposal is still QUEUED, not begun concurrently (queue=' + JSON.stringify(stq1.verifyQueue) + ')')
726
+ assert(stq1.undecided.length === 0 && stq1.verified.length === 0, 'nothing was settled by merely proposing')
727
+ // Once the first ballot settles, the queued one starts on its own.
728
+ await callTool('vibe_v5_set', { verdictMaxRounds: 1 }, RL)
729
+ delivered.length = 0
730
+ await drainWakes(20, RL)
731
+ const stq2 = await callTool('vibe_v5_status', {}, RL)
732
+ assert(stq2.verify === null || stq2.verify.target === 'p-second',
733
+ 'the queued object took over after the first ballot closed (now: ' + JSON.stringify(stq2.verify && stq2.verify.target) + ')')
734
+ await endCase(RL)
735
+
736
+ // =============== CASE 9c: a solve vote OUTSIDE a meeting ==========================
737
+ section('9c a unanimous solve vote landing outside a meeting still stops the institute')
738
+ const RM = makeRoot()
739
+ await callTool('vibe_v5_start', { problem: '会外表决停工测试', researcherCount: 1 }, RM)
740
+ for (const sp of spawnsFor(RM)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
741
+ await settleInstitute(RM)
742
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RM)
743
+ const solvedReply = { vote_solved: true, solved: true, progress: '我认为原问题已解决。', contextPct: 20 }
744
+ replyOverride.set('acad', solvedReply)
745
+ replyOverride.set('r-1', solvedReply)
746
+ await callTool('vibe_v5_say', { to: 'acad', text: '请你就"是否已解决"表态。' }, childAgent(childOf(RM, 'r-1')))
747
+ await settle(); await drainWakes(4, RM)
748
+ await callTool('vibe_v5_say', { to: 'r-1', text: '请你就"是否已解决"表态。' }, childAgent(childOf(RM, 'acad')))
749
+ await settle(); await drainWakes(4, RM)
750
+ const stSolved = await callTool('vibe_v5_status', {}, RM)
751
+ assert(stSolved.solveVotes.length >= 2, 'both voters recorded a solve vote outside any meeting (' + JSON.stringify(stSolved.solveVotes) + ')')
752
+ assert(stSolved.autoDone === true,
753
+ 'the institute STOPPED on a unanimous solve vote that arrived outside a meeting ' + JSON.stringify({ autoDone: stSolved.autoDone, solveVotes: stSolved.solveVotes }))
754
+ await endCase(RM)
755
+
756
+ // =============== CASE 10: meeting prompts =======================================
757
+ section('10 meeting — real speakers, real transcript keys')
758
+ const RI = makeRoot()
759
+ await callTool('vibe_v5_start', { problem: '会议提示词测试', researcherCount: 1 }, RI)
760
+ for (const sp of spawnsFor(RI)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
761
+ await settleInstitute(RI)
762
+ await callTool('vibe_v5_set', { maxParallel: 8 }, RI)
763
+ delivered.length = 0
764
+ const mtg = await callTool('vibe_v5_meeting', { agenda: '分工与下一步', kind: 'sync' }, childAgent(childOf(RI, 'acad')))
765
+ assert(mtg.ok === true, 'the academician convened a meeting (' + JSON.stringify(mtg).slice(0, 90) + ')')
766
+ await settle(); await drainWakes(20, RI)
767
+ const meetingOne = delivered.filter(w => /【研究所会议/.test(w.prompt))
768
+ assert(meetingOne.length >= 2, 'both members were asked to speak (' + meetingOne.length + ')')
769
+ for (const w of meetingOne) {
770
+ recordAndCheck('meeting', w.owner, w.prompt)
771
+ assert(w.prompt.indexOf('【研究所会议 mt-1 进行中 —— ') === 0, w.owner + "'s meeting prompt is framed with the meeting id")
772
+ assert(w.prompt.indexOf('分工与下一步') !== -1, w.owner + "'s meeting prompt carries the agenda")
773
+ assert(w.prompt.indexOf('"input"') !== -1, w.owner + "'s meeting prompt documents the input field it must fill")
774
+ }
775
+ await settleInstitute(RI)
776
+ const stMtg = await callTool('vibe_v5_status', {}, RI)
777
+ assert(stMtg.meeting === null, 'the meeting finished instead of deadlocking')
778
+ const minutes = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'Meetings', 'mt-1.md')
779
+ assert(existsSync(minutes), 'the meeting minutes were written')
780
+ if (existsSync(minutes)) {
781
+ const t = readFileSync(minutes, 'utf8')
782
+ assert(/### acad/.test(t) && /### r-1/.test(t), 'the minutes key each speech by its real member id')
783
+ assert(/有表决权者:acad、r-1/.test(t), 'the minutes list the real voting members')
784
+ }
785
+ // A non-academician may only PROPOSE a meeting; the relay must be signed by the proposer.
786
+ delivered.length = 0
787
+ const propMtg = await callTool('vibe_v5_meeting', { agenda: '我提议讨论路线', kind: 'sync' }, childAgent(childOf(RI, 'r-1')))
788
+ assert(propMtg.ok === true && propMtg.proposed === true, 'a researcher can only PROPOSE a meeting (' + JSON.stringify(propMtg).slice(0, 80) + ')')
789
+ await settle(); await drainWakes(20, RI)
790
+ const propText = delivered.map(w => w.prompt).join('\n')
791
+ for (const w of delivered) record('meeting-proposal', w.owner, w.prompt)
792
+ assert(/【研究所·致全体表决者 from r-1】[^\n]*提议开会/.test(propText),
793
+ 'the meeting proposal is relayed SIGNED BY ITS TRUE PROPOSER r-1, not by whoever was woken last')
794
+ await endCase(RI)
795
+
796
+ // =============== CASE 11: no unpaced re-wake loop ===============================
797
+ section('11 a task owner is pushed on a PACED cadence, not in a tight loop')
798
+ const RJ = makeRoot()
799
+ await callTool('vibe_v5_start', { problem: '调度节奏测试', researcherCount: 1 }, RJ)
800
+ for (const sp of spawnsFor(RJ)) { fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
801
+ await settleInstitute(RJ)
802
+ const asg = await callTool('vibe_v5_assign', { subject: '一个长任务', to: 'r-1', why: '你最合适', acceptance: '给出结果' }, childAgent(childOf(RJ, 'acad')))
803
+ assert(asg.ok === true, 'a task was assigned to r-1')
804
+ await settle(); await drainWakes(6, RJ)
805
+ const afterAssign = await callTool('vibe_v5_status', {}, RJ)
806
+ assert(afterAssign.tasks.some(t => t.status === 'in_progress' && t.ownerId === 'r-1'), 'r-1 still owns in-progress work')
807
+ assert(!afterAssign.members.some(m => m.busy), 'r-1 is idle again after answering')
808
+ // A task owner must NOT be re-woken the moment its turn ends: the work push is paced by
809
+ // activityTimeoutMs (120 s here). Without the pace, one unfinished task became an
810
+ // unbounded wake -> turn -> wake chain that burned tokens with no backoff at all.
811
+ await sleep(500); await settle()
812
+ const unpaced = wakes.filter(w => w.rootId === RJ.id)
813
+ assert(unpaced.length === 0, 'no unpaced re-wake of the task owner within the idle window (got ' + unpaced.length + ')')
814
+ await endCase(RJ)
815
+
816
+ // =============== PART: full-corpus sweep ========================================
817
+ section('12 full-corpus sweep over every prompt ever sent')
818
+ {
819
+ let swept = 0
820
+ for (const sp of spawns) {
821
+ if (!sp.prompt) continue
822
+ swept++
823
+ checkPromptSweep(sp.prompt, memberOfChild(sp.childId), 'corpus spawn ' + memberOfChild(sp.childId))
824
+ }
825
+ assert(swept >= 12, 'the corpus inspected every founding/resume prompt in the process (' + swept + ')')
826
+ const owners = new Set(corpus.map(c => c.owner))
827
+ assert(owners.has('acad') && owners.has('r-1') && owners.has('r-2'), 'the corpus covers academician and researchers (' + [...owners].join('、') + ')')
828
+ assert([...owners].some(o => /^t-/.test(o)), 'the corpus covers a temp worker')
829
+ const kinds = new Set(corpus.map(c => c.kind))
830
+ for (const need of ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
831
+ 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
832
+ 'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure']) {
833
+ assert(kinds.has(need), 'the corpus contains a ' + need + ' prompt')
834
+ }
835
+ assert(corpus.every(c => c.prompt && c.prompt.length > 200), 'no captured prompt is suspiciously short')
836
+ assert(corpus.every(c => !GARBAGE.some(g => g.test(c.prompt + (c.persona || '')))), 'no prompt or charter contains undefined/NaN/? garbage')
837
+ // A single prompt must not deliver the same message twice. The inbox used to be
838
+ // prepended AND re-emitted from the [状态] block, so a member read every new message
839
+ // twice in one prompt.
840
+ for (const c of corpus) {
841
+ const bodies = c.prompt.match(/【[^】]*】[^\n]{20,}/g) || []
842
+ for (const frame of new Set(bodies)) {
843
+ const n = bodies.filter(b => b === frame).length
844
+ if (n > 1) { assert(false, 'message delivered ' + n + '× in one prompt (' + c.kind + '/' + c.owner + '): ' + frame.slice(0, 60)); break }
845
+ }
846
+ const inboxHeads = (c.prompt.match(/\[新到的消息/g) || []).length
847
+ assert(inboxHeads <= 1, c.kind + '/' + c.owner + ': at most one inbox section per prompt (found ' + inboxHeads + ')')
848
+ }
849
+ assert(true, 'no prompt delivers the same framed message twice, and no prompt has two inbox sections')
850
+ // The identity claim inside a prompt must agree with the persona shipped alongside it.
851
+ for (const c of corpus) {
852
+ if (!c.persona) continue
853
+ const st = parseState(c.prompt)
854
+ if (!st) continue
855
+ assert(c.persona.indexOf('Members/' + st.id + '/') !== -1,
856
+ c.kind + ': the charter shipped with ' + st.id + "'s prompt points at Members/" + st.id + '/')
857
+ }
858
+ }
859
+
860
+ // =============== corpus dump ====================================================
861
+ section('13 the full prompt corpus is preserved for human review')
862
+ mkdirSync(CORPUS_DIR, { recursive: true })
863
+ const md = []
864
+ md.push('# Vibe Math V5 — 提示词与交互语料(自动生成,请勿手改)')
865
+ md.push('')
866
+ md.push('由 `prompt-v5-integrity.test.mjs` 在每次运行时重写。这里保存的是**框架真正发给每个')
867
+ md.push('成员的提示词原文**,用于人工复核提示词分配、成员代号与交互内容的正确性。')
868
+ md.push('')
869
+ md.push('- 生成时刻的工作区路径被替换为 `<WS>`,因此内容是确定性的、可 diff 的。')
870
+ md.push('- `owner` 是这条提示词**实际发给的成员**;`kind` 是提示词类型。')
871
+ md.push('- 人设(charter/persona)按成员只完整打印一次,其余条目只记录字符数。')
872
+ md.push('- 这是提示词正确性的人工复核入口:任何“成员代号/职位/在册名单/交互署名”问题')
873
+ md.push(' 都能在这里一眼看出,而不必去翻会话日志。')
874
+ md.push('')
875
+ const seenPersona = new Set()
876
+ const order = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
877
+ 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
878
+ 'inbox-office', 'inbox-assign', 'inbox-nudge', 'notice', 'notice-claim', 'after-failure']
879
+ const sorted = corpus.slice().sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind))
880
+ for (let i = 0; i < sorted.length; i++) {
881
+ const c = sorted[i]
882
+ md.push('---')
883
+ md.push('')
884
+ md.push('## [' + (i + 1) + '] kind=`' + c.kind + '` owner=`' + c.owner + '`')
885
+ md.push('')
886
+ if (c.toolFilter) md.push('- toolFilter: `' + JSON.stringify(c.toolFilter) + '`')
887
+ md.push('- charter: ' + (c.persona == null ? '(本次唤醒不带人设)' : c.persona.length + ' 字符'))
888
+ md.push('')
889
+ if (c.persona != null && !seenPersona.has(c.owner)) {
890
+ seenPersona.add(c.owner)
891
+ md.push('### 人设 / 规章(' + c.owner + ',仅首次完整打印)')
892
+ md.push('')
893
+ md.push('```text')
894
+ md.push(c.persona)
895
+ md.push('```')
896
+ md.push('')
897
+ }
898
+ md.push('### 提示词原文')
899
+ md.push('')
900
+ md.push('```text')
901
+ md.push(c.prompt)
902
+ md.push('```')
903
+ md.push('')
904
+ }
905
+ const byKind = {}
906
+ for (const c of corpus) byKind[c.kind] = (byKind[c.kind] || 0) + 1
907
+ md.push('---')
908
+ md.push('')
909
+ md.push('## 统计')
910
+ md.push('')
911
+ for (const k of Object.keys(byKind).sort()) md.push('- `' + k + '`: ' + byKind[k])
912
+ md.push('')
913
+ md.push('- 合计:' + corpus.length + ' 条提示词')
914
+ md.push('')
915
+ const mdPath = join(CORPUS_DIR, 'prompt-corpus-v5.md')
916
+ writeFileSync(mdPath, md.join('\n'), 'utf8')
917
+ writeFileSync(join(CORPUS_DIR, 'prompt-corpus-v5.json'), JSON.stringify({
918
+ note: 'Vibe Math V5 prompt/interaction corpus — generated by prompt-v5-integrity.test.mjs. <WS> = the run workspace.',
919
+ counts: byKind, total: corpus.length,
920
+ prompts: corpus.map(c => ({
921
+ kind: c.kind, owner: c.owner, sentToLabel: c.sentToLabel,
922
+ charterChars: c.persona == null ? null : c.persona.length,
923
+ charter: c.persona, toolFilter: c.toolFilter, prompt: c.prompt,
924
+ })),
925
+ }, null, 2), 'utf8')
926
+ assert(existsSync(mdPath), 'the prompt corpus Markdown was written')
927
+ assert(existsSync(join(CORPUS_DIR, 'prompt-corpus-v5.json')), 'the prompt corpus JSON was written')
928
+ const corpusMd = readFileSync(mdPath, 'utf8')
929
+ assert(corpusMd.length > 30000, 'the corpus is substantial (' + corpusMd.length + ' chars) — the real prompt text is preserved')
930
+ assert(corpusMd.indexOf('[状态] 你是 acad(院士)') !== -1, 'a human can verify the academician brief verbatim')
931
+ assert(corpusMd.indexOf('[状态] 你是 r-2') !== -1, 'a human can verify a researcher brief naming itself')
932
+ assert(corpusMd.indexOf('【框架提示】') !== -1, 'the corpus contains the framework-feedback interaction')
933
+ assert(corpusMd.indexOf('【会话重建 —— ') !== -1, 'the corpus contains a resume brief')
934
+ assert(corpusMd.indexOf('【所办分派】') !== -1, 'the corpus contains an office assignment')
935
+ assert(!/你是 \?/.test(corpusMd), 'the corpus contains NO wrong-identity "?" brief')
936
+
937
+ console.log('')
938
+ console.log('corpus: ' + mdPath)
939
+ console.log('passed=' + passed + ' failed=' + failed)
940
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
941
+ console.log('ALL GREEN')
942
+ process.exit(0)