dsh-vibe-math 2.3.11 → 2.3.13

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 (50) hide show
  1. package/README.md +17 -40
  2. package/{AUDIT-CHECKLIST.md → docs/AUDIT-CHECKLIST.md} +323 -304
  3. package/docs/COMPAT-AUDIT-ROUND2.md +325 -0
  4. package/docs/generate_framework_diagram_v2.py +114 -0
  5. package/docs/generate_framework_diagram_v3.py +127 -0
  6. package/{RELEASE-NOTES-2.1.0.md → docs/release-notes/RELEASE-NOTES-2.1.0.md} +143 -143
  7. package/{RELEASE-NOTES-2.2.0.md → docs/release-notes/RELEASE-NOTES-2.2.0.md} +266 -266
  8. package/{RELEASE-NOTES-2.2.1.md → docs/release-notes/RELEASE-NOTES-2.2.1.md} +43 -43
  9. package/{RELEASE-NOTES-2.2.2.md → docs/release-notes/RELEASE-NOTES-2.2.2.md} +88 -88
  10. package/{RELEASE-NOTES-2.3.0.md → docs/release-notes/RELEASE-NOTES-2.3.0.md} +207 -207
  11. package/{RELEASE-NOTES-2.3.1.md → docs/release-notes/RELEASE-NOTES-2.3.1.md} +134 -134
  12. package/{RELEASE-NOTES-2.3.10.md → docs/release-notes/RELEASE-NOTES-2.3.10.md} +105 -105
  13. package/{RELEASE-NOTES-2.3.11.md → docs/release-notes/RELEASE-NOTES-2.3.11.md} +57 -57
  14. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.3.13.md +137 -0
  16. package/{RELEASE-NOTES-2.3.2.md → docs/release-notes/RELEASE-NOTES-2.3.2.md} +145 -145
  17. package/{RELEASE-NOTES-2.3.3.md → docs/release-notes/RELEASE-NOTES-2.3.3.md} +115 -115
  18. package/{RELEASE-NOTES-2.3.4.md → docs/release-notes/RELEASE-NOTES-2.3.4.md} +69 -69
  19. package/{RELEASE-NOTES-2.3.5.md → docs/release-notes/RELEASE-NOTES-2.3.5.md} +63 -63
  20. package/{RELEASE-NOTES-2.3.6.md → docs/release-notes/RELEASE-NOTES-2.3.6.md} +66 -66
  21. package/{RELEASE-NOTES-2.3.7.md → docs/release-notes/RELEASE-NOTES-2.3.7.md} +59 -59
  22. package/{RELEASE-NOTES-2.3.8.md → docs/release-notes/RELEASE-NOTES-2.3.8.md} +45 -45
  23. package/{RELEASE-NOTES-2.3.9.md → docs/release-notes/RELEASE-NOTES-2.3.9.md} +70 -70
  24. package/docs/test-timing.md +18 -17
  25. package/installer.js +258 -53
  26. package/package.json +50 -40
  27. package/{audit-formal-sensitivity.mjs → tests/audit-formal-sensitivity.mjs} +342 -342
  28. package/tests/audit-installer-compat.test.mjs +136 -0
  29. package/tests/audit-installer-policy.test.mjs +245 -0
  30. package/{audit-persona-sensitivity.mjs → tests/audit-persona-sensitivity.mjs} +249 -249
  31. package/{audit-persona-surface.test.mjs → tests/audit-persona-surface.test.mjs} +349 -349
  32. package/{audit-prompt-invariants.mjs → tests/audit-prompt-invariants.mjs} +508 -508
  33. package/{audit-spec-traceability.mjs → tests/audit-spec-traceability.mjs} +193 -193
  34. package/{audit-v5-integrity.mjs → tests/audit-v5-integrity.mjs} +448 -448
  35. package/{audit-v5-sensitivity.mjs → tests/audit-v5-sensitivity.mjs} +384 -384
  36. package/{e2e-v5-round2.test.mjs → tests/e2e-v5-round2.test.mjs} +521 -521
  37. package/{formal-verify-v2.test.mjs → tests/formal-verify-v2.test.mjs} +1315 -1315
  38. package/{formal-verify-v3.test.mjs → tests/formal-verify-v3.test.mjs} +1257 -1257
  39. package/{formal-verify-v4.test.mjs → tests/formal-verify-v4.test.mjs} +1082 -1082
  40. package/{formal-verify-v5.test.mjs → tests/formal-verify-v5.test.mjs} +708 -708
  41. package/{prompt-v5-integrity.test.mjs → tests/prompt-v5-integrity.test.mjs} +3 -3
  42. package/{run-tests.mjs → tests/run-tests.mjs} +121 -118
  43. package/{selfdrive-v5.mjs → tests/selfdrive-v5.mjs} +470 -470
  44. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  45. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  46. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +1 -1
  47. package//347/244/272/344/276/213/345/233/276//345/256/236/351/231/205/344/275/277/347/224/250/347/244/272/344/276/213-/351/225/277/346/210/252/345/233/276.png +0 -0
  48. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v2.png +0 -0
  49. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v3.png +0 -0
  50. /package/{RELEASE-NOTES-2.0.22.md → docs/release-notes/RELEASE-NOTES-2.0.22.md} +0 -0
@@ -1,521 +1,521 @@
1
- // ============================================================
2
- // V5 ROUND-2 E2E — the behaviour paths the first suite never exercised:
3
- // · the hardened JSON FALLBACK backend (host without sessionProjections)
4
- // · a simulated PROCESS RESTART (fresh agent + fresh backend over the same workspace)
5
- // · duplicate subagent/end idempotence
6
- // · hire quotas (per-member and institute-wide)
7
- // · quorumMode 'all-unanimous'
8
- // · m recomputed when the roster shrinks (a voter is dismissed)
9
- // · vibe_v5_wait (validation, no-progress shortcut, and real activity wake)
10
- // · vibe_v5_read_library
11
- // · id sanitisation against path traversal
12
- // · configure guard while running
13
- // · the verification watchdog abandoning a stuck round
14
- // · meeting PARKED behind an in-flight verification, then resumed
15
- // · compaction/keepalive directives appear only when they should
16
- // · session-log REPLAY reproduces the institute state in a new session
17
- // Run: node e2e-v5-round2.test.mjs
18
- // ============================================================
19
- import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
20
- import { tmpdir } from 'node:os'
21
- import { join, dirname, isAbsolute } from 'node:path'
22
-
23
- // V5_PLUGIN lets a sensitivity probe point this suite at a deliberately broken copy.
24
- // Without it every probe against this suite silently tested the UNMUTATED plugin and was
25
- // reported as a "detection" only because the probe's own spawn failed — i.e. the whole
26
- // e2e block of the sensitivity audit was vacuous.
27
- const PLUGIN = process.env.V5_PLUGIN
28
- ? new URL('file:///' + String(process.env.V5_PLUGIN).replace(/\\/g, '/'))
29
- : new URL('./vibe-math-v5/vibe-math-v5.js', import.meta.url)
30
- let passed = 0, failed = 0
31
- const failures = []
32
- const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
33
- const sleep = ms => new Promise(r => setTimeout(r, ms))
34
-
35
- function makeProjectionRegistry() {
36
- const units = new Map()
37
- const cells = new Map()
38
- const cellMap = (sess) => { const id = String(sess.id); let m = cells.get(id); if (!m) { m = new Map(); cells.set(id, m) } return m }
39
- return {
40
- register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
41
- stateOf(session, key) {
42
- const def = units.get(key); if (!def) return undefined
43
- const m = cellMap(session)
44
- if (!m.has(key)) m.set(key, def.init(session.header, 0))
45
- return m.get(key)
46
- },
47
- checkpoint(session) { const o = {}; const m = cellMap(session); for (const [k, d] of units) o[k] = { ver: d.stateVersion, seq: session.seq, val: m.get(k) }; return o },
48
- _drive(session, event) {
49
- const m = cellMap(session)
50
- for (const [k, def] of units) {
51
- const cur = m.has(k) ? m.get(k) : def.init(session.header, 0)
52
- let next; try { next = def.apply(cur, event) } catch (e) { next = cur }
53
- m.set(k, next)
54
- }
55
- },
56
- }
57
- }
58
-
59
- function makeSession(id, parentSession) {
60
- const events = []
61
- const s = {
62
- id,
63
- header: { version: 1, id, createdAt: Date.now(), cwd: null, parentSession, isSeeded: false },
64
- inheritedEventCount: 0,
65
- get seq() { return events.length },
66
- append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); this._drive(ev); return ev },
67
- deriveMessages() { return [] },
68
- snapshotEvents(from) { return events.slice(from || 0) },
69
- ownEvents() { return events.slice() },
70
- _events: events,
71
- _drive: null,
72
- }
73
- return s
74
- }
75
-
76
- /** A fresh host+plugin instance. `withProjections:false` exercises the JSON fallback. */
77
- function makeHost(opts) {
78
- const o = opts || {}
79
- const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
80
- const projections = o.withProjections === false ? undefined : makeProjectionRegistry()
81
- const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
82
- const liveAgents = new Map()
83
-
84
- const ctx = {
85
- get(name) {
86
- if (name === 'sessionProjections') return projections
87
- if (name === 'sandboxPolicy') return undefined
88
- if (name === 'compaction') return o.compaction
89
- if (name === 'subprocess') {
90
- return {
91
- async spawn({ argv }) {
92
- const script = argv[argv.length - 1] || ''
93
- if (/New-Item/.test(script)) {
94
- const re = /'((?:[^']|'')*)'/g; let m
95
- while ((m = re.exec(script)) !== null) { const p = m[1].replace(/''/g, "'"); if (p && !/^-/.test(p)) mkdirSync(p, { recursive: true }) }
96
- }
97
- return { done: Promise.resolve({ exitCode: 0 }) }
98
- },
99
- }
100
- }
101
- return undefined
102
- },
103
- on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
104
- effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
105
- logger: { info() {}, warn() {}, error() {} },
106
- timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
107
- tools: { register(spec) { toolRegs.push(spec); return () => {} } },
108
- commands: { register(spec) { commandRegs.push(spec); return () => {} } },
109
- sessions: { async flush() { return true } },
110
- subagents: {
111
- list() { return ['spawn'] },
112
- async startContinuable({ label, request }) {
113
- const id = 'c' + (spawns.length + 1)
114
- const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
115
- liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
116
- spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
117
- if (projections) childSession._drive = (ev) => projections._drive(childSession, ev)
118
- return { childId: id, messageId: 'm' + spawns.length }
119
- },
120
- async sendMessage(parent, childId, blocks) { wakes.push({ childId, blocks }); return 'w' + wakes.length },
121
- interrupt(childId) { interrupts.push(childId) },
122
- async drainContinuableChildren(parent, ids) { drains.push(...ids); for (const i of ids) liveAgents.delete(i) },
123
- },
124
- agents: { roots() { return [ROOT] }, get(id) { return id === ROOT.id ? ROOT : liveAgents.get(id) }, list() { return [ROOT, ...liveAgents.values()] } },
125
- fs: {
126
- async resolve(rel, o2) {
127
- const b = (o2 && o2.cwd) || WS
128
- const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/'))
129
- return { targetKey: p, displayPath: p }
130
- },
131
- async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
132
- async readText(t) { return readFileSync(t.targetKey, 'utf8') },
133
- async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
134
- async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
135
- },
136
- }
137
-
138
- const ROOT_SESSION = makeSession('sess-A', undefined)
139
- ROOT_SESSION.header.cwd = WS
140
- const ROOT = { id: 'sess-A', options: {}, session: ROOT_SESSION, ctx: undefined }
141
- if (projections) ROOT_SESSION._drive = (ev) => projections._drive(ROOT_SESSION, ev)
142
-
143
- const mod = o.pluginModule
144
- mod.apply(ctx)
145
-
146
- async function callTool(name, args, agent) {
147
- const spec = toolRegs.find(x => x.name === name)
148
- if (!spec) throw new Error('no tool ' + name)
149
- return JSON.parse(await spec.execute(args || {}, { agent: agent || ROOT }))
150
- }
151
- const childAgent = (childId) => liveAgents.get(childId) || { id: childId, session: { header: { parentSession: ROOT.id } } }
152
- function fireEnd(childId, reply, stopReason) {
153
- const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
154
- for (const h of (listeners['subagent/end'] || [])) h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: stopReason || 'completed', lastAssistantMessage: blocks })
155
- }
156
- const spawnOf = (m) => spawns.find(s => s.label.indexOf('vibe5 ' + m + ' ') !== -1)
157
- const childOf = (m) => { const s = spawnOf(m); return s ? s.childId : m }
158
- const labelOf = (cid) => { const s = spawns.find(x => x.childId === cid); const m = s ? /vibe5 (\S+) /.exec(s.label) : null; return m ? m[1] : '' }
159
- const kindOf = (p) => /【入职首轮/.test(p) ? 'initial' : /【研究所会议/.test(p) ? 'meeting' : /【求真表决/.test(p) ? 'verify' : /【心跳检查/.test(p) ? 'checkpoint' : 'normal'
160
-
161
- async function settleSpawns() {
162
- for (const sp of spawns.slice()) {
163
- if (sp.ended) continue
164
- sp.ended = true
165
- fireEnd(sp.childId, { progress: labelOf(sp.childId) + ':初始见解已记录。', solved: false, contextPct: 10 })
166
- await sleep(15)
167
- }
168
- }
169
- let plannedVotes = null
170
- let solvePlan = null
171
- async function drain(budget = 60) {
172
- let n = 0
173
- while (wakes.length && n < budget) {
174
- const w = wakes.shift()
175
- const prompt = (w.blocks && w.blocks[0] && w.blocks[0].text) || ''
176
- const kind = kindOf(prompt)
177
- const who = labelOf(w.childId)
178
- let reply
179
- if (kind === 'verify') {
180
- const tm = /"target"\s*:\s*"([^"]+)"/.exec(prompt)
181
- const t = tm ? tm[1] : 'p-r-1'
182
- const v = plannedVotes && plannedVotes.has(who) ? plannedVotes.get(who) : 0.5
183
- reply = { verdict: { target: t, verdict: v, reason: who + ' 判断' }, contextPct: 20 }
184
- } else if (kind === 'meeting') {
185
- reply = { input: who + ':意见已述。', vote_solved: solvePlan === true, solved: solvePlan === true, contextPct: 20 }
186
- } else {
187
- reply = { progress: who + ':继续推进。', solved: false, contextPct: 20 }
188
- }
189
- fireEnd(w.childId, reply)
190
- n++
191
- await sleep(15)
192
- }
193
- return n
194
- }
195
- /** Pull the next queued wake for one member and return its prompt text.
196
- * Wakes for OTHER members are answered benignly so the scheduler keeps cycling —
197
- * otherwise a single unanswered heartbeat would stop all further passes. */
198
- async function peekWakeOf(member, maxWaitMs) {
199
- const t0 = Date.now()
200
- while (Date.now() - t0 < (maxWaitMs || 3000)) {
201
- const i = wakes.findIndex(w => labelOf(w.childId) === member)
202
- if (i !== -1) {
203
- const w = wakes.splice(i, 1)[0]
204
- return { childId: w.childId, text: (w.blocks && w.blocks[0] && w.blocks[0].text) || '' }
205
- }
206
- if (wakes.length) {
207
- const w = wakes.shift()
208
- fireEnd(w.childId, { progress: '其他成员推进中', solved: false, contextPct: 10 })
209
- }
210
- await sleep(20)
211
- }
212
- return null
213
- }
214
- return { WS, ctx, ROOT, ROOT_SESSION, projections, spawns, wakes, interrupts, drains, toolRegs, commandRegs, listeners, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
215
- }
216
-
217
- const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
218
- const PROBLEM = '证明素数有无穷多个'
219
-
220
- // ============================================================
221
- console.log('-- V5 round-2 e2e --')
222
-
223
- // ---------- 1. the hardened JSON FALLBACK backend ----------
224
- console.log('\n[1] fallback backend (host without sessionProjections)')
225
- {
226
- const h = makeHost({ pluginModule, withProjections: false })
227
- const st = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
228
- assert(st.ok === true, 'start works without sessionProjections (fallback path)')
229
- const s0 = await h.callTool('vibe_v5_status', {})
230
- assert(s0.backend === 'file', 'status reports the file backend (got ' + s0.backend + ')')
231
- await h.settleSpawns()
232
- const stFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
233
- // the state file is written lazily (deferred stringify chain) — give it a tick
234
- for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
235
- assert(existsSync(stFile), 'the fallback persisted the institute state to State/institute.v5state.json')
236
- if (existsSync(stFile)) {
237
- const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
238
- const inst = parsed.institutes['default::institute']
239
- assert(!!inst && inst.members.length === 3, 'the persisted fallback state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
240
- }
241
- }
242
-
243
- // ---------- 2. simulated PROCESS RESTART ----------
244
- console.log('\n[2] simulated process restart (fresh host, fresh backend, same workspace)')
245
- {
246
- const WS = mkdtempSync(join(tmpdir(), 'vibe-v5r2-restart-'))
247
- const h1 = makeHost({ pluginModule, withProjections: false, ws: WS })
248
- await h1.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
249
- await h1.settleSpawns()
250
- const stFile = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
251
- for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
252
- assert(existsSync(stFile), 'first process persisted its state')
253
-
254
- // a genuinely fresh host = new backend instance, empty in-memory state
255
- const h2 = makeHost({ pluginModule, withProjections: false, ws: WS })
256
- const s2 = await h2.callTool('vibe_v5_status', {})
257
- assert(s2.members.length === 3, 'a FRESH host reads the persisted roster back (got ' + s2.members.length + ' members)')
258
- const res = await h2.callTool('vibe_v5_resume', {})
259
- assert(res.ok === true, 'resume works on a fresh host over persisted state (got ' + JSON.stringify(res).slice(0, 120) + ')')
260
- assert(h2.spawns.length === 3, 'resume re-spawned the 3 members whose child sessions no longer exist (got ' + h2.spawns.length + ')')
261
- assert(s2.quorum && s2.quorum.m === 3, 'the recovered roster recomputes m correctly (m=' + (s2.quorum ? s2.quorum.m : '?') + ')')
262
- }
263
-
264
- // ---------- 3. duplicate subagent/end is idempotent ----------
265
- console.log('\n[3] duplicate subagent/end idempotence')
266
- {
267
- const h = makeHost({ pluginModule })
268
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
269
- const cid = h.childOf('r-1')
270
- // settle the founding turn normally, then REPLAY the exact same end event
271
- const before = h.ROOT_SESSION._events.length
272
- h.fireEnd(cid, { progress: '唯一一次', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
273
- await sleep(40)
274
- const afterFirst = h.ROOT_SESSION._events.length
275
- h.fireEnd(cid, { progress: '重复投递', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
276
- await sleep(40)
277
- const afterSecond = h.ROOT_SESSION._events.length
278
- assert(afterSecond === afterFirst, 'a replayed end commits NOTHING new (' + afterFirst + ' -> ' + afterSecond + ' events)')
279
- assert(afterFirst > before, 'the first end did commit state')
280
- }
281
-
282
- // ---------- 4. hire quotas ----------
283
- console.log('\n[4] hire quotas')
284
- {
285
- const h = makeHost({ pluginModule })
286
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
287
- await h.settleSpawns()
288
- await h.callTool('vibe_v5_set', { maxTempPerMember: 2, maxTempTotal: 3 })
289
- const a = await h.callTool('vibe_v5_hire', { purpose: 'p1', initial_task: 't1' }, h.childAgent(h.childOf('r-1')))
290
- const b = await h.callTool('vibe_v5_hire', { purpose: 'p2', initial_task: 't2' }, h.childAgent(h.childOf('r-1')))
291
- const c = await h.callTool('vibe_v5_hire', { purpose: 'p3', initial_task: 't3' }, h.childAgent(h.childOf('r-1')))
292
- await h.settleSpawns()
293
- assert(a.ok && b.ok, 'the first two hires succeed (cap 2 per member)')
294
- assert(c.ok === false && c.code === 'V5_MEMBER_LIMIT', 'the third hire is refused by maxTempPerMember (' + (c.code || 'no code') + ')')
295
- const d = await h.callTool('vibe_v5_hire', { purpose: 'p4', initial_task: 't4' }, h.childAgent(h.childOf('acad')))
296
- assert(d.ok === true, 'the academician can still hire (its own per-member budget)')
297
- const e = await h.callTool('vibe_v5_hire', { purpose: 'p5', initial_task: 't5' }, h.childAgent(h.childOf('acad')))
298
- assert(e.ok === false && e.code === 'V5_MEMBER_LIMIT', 'the institute-wide cap maxTempTotal=3 then refuses (' + (e.code || 'no code') + ')')
299
- }
300
-
301
- // ---------- 5. quorumMode all-unanimous ----------
302
- console.log('\n[5] quorumMode all-unanimous')
303
- {
304
- const h = makeHost({ pluginModule })
305
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
306
- await h.settleSpawns()
307
- await h.callTool('vibe_v5_set', { quorumMode: 'all-unanimous' })
308
- await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'r' }, h.childAgent(h.childOf('r-1')))
309
- const s = await h.callTool('vibe_v5_status', {})
310
- assert(!!s.verify, 'verification started in all-unanimous mode')
311
- // only 2 of 3 voters assert true; the third abstains -> must NOT verify
312
- h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 0.5]])
313
- await h.drain(6)
314
- let s2 = await h.callTool('vibe_v5_status', {})
315
- assert(s2.verified.indexOf('p-r-1') === -1, 'all-unanimous: 2/3 true with one abstention does NOT verify')
316
- // now everyone asserts true -> verifies
317
- h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
318
- await h.drain(8)
319
- let s3 = await h.callTool('vibe_v5_status', {})
320
- if (!s3.verified.includes('p-r-1')) { h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]]); await h.drain(8); s3 = await h.callTool('vibe_v5_status', {}) }
321
- assert(s3.verified.indexOf('p-r-1') !== -1, 'all-unanimous: every voter true DOES verify')
322
- }
323
-
324
- // ---------- 6. m recomputed when the roster shrinks ----------
325
- console.log('\n[6] quorum recomputed when a voter is dismissed')
326
- {
327
- const h = makeHost({ pluginModule })
328
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 3 })
329
- await h.settleSpawns()
330
- const s0 = await h.callTool('vibe_v5_status', {})
331
- assert(s0.quorum.m === 3 && s0.quorum.voterCount === 4, 'baseline m=3 over 4 voters')
332
- await h.callTool('vibe_v5_remove_researcher', { id: 'r-3' })
333
- await h.callTool('vibe_v5_remove_researcher', { id: 'r-2' })
334
- const s1 = await h.callTool('vibe_v5_status', {})
335
- assert(s1.quorum.voterCount === 2, 'two researchers dismissed -> 2 voters (got ' + s1.quorum.voterCount + ')')
336
- assert(s1.quorum.m === 2, 'm tracks the roster down to min(cap=3, P=2) = 2 (got ' + s1.quorum.m + ')')
337
- assert(h.drains.length === 2, 'both dismissed researchers had their resident child released')
338
- }
339
-
340
- // ---------- 7. vibe_v5_wait ----------
341
- console.log('\n[7] vibe_v5_wait')
342
- {
343
- const h = makeHost({ pluginModule })
344
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
345
- await h.settleSpawns()
346
- const bad = await h.callTool('vibe_v5_wait', { timeout_ms: 5000 }, h.childAgent(h.childOf('r-1')))
347
- assert(bad.ok === false || bad.code === 'V5_INVALID_TIMEOUT' || String(bad.error || '').indexOf('10000') !== -1,
348
- 'a timeout below 10000ms is rejected (' + JSON.stringify(bad).slice(0, 90) + ')')
349
- // a real wait must be woken by genuine institute activity, not by polling
350
- const waitP = h.callTool('vibe_v5_wait', { timeout_ms: 10000 }, h.childAgent(h.childOf('r-1')))
351
- await sleep(30)
352
- await h.callTool('vibe_v5_say', { text: '有人说话就会唤醒等待者' }, h.childAgent(h.childOf('acad')))
353
- const woke = await Promise.race([waitP, sleep(1500).then(() => ({ timedOut: 'NEVER-RESOLVED' }))])
354
- assert(woke.timedOut === false, 'wait is woken by real activity rather than timing out (' + JSON.stringify(woke).slice(0, 80) + ')')
355
- }
356
-
357
- // ---------- 8. read_library ----------
358
- console.log('\n[8] vibe_v5_read_library')
359
- {
360
- const h = makeHost({ pluginModule })
361
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
362
- await h.callTool('vibe_v5_record_proposition', { id: 'p-x', statement: 'S', value: 0.7, motive: 'M', p: 0.9 }, h.childAgent(h.childOf('r-1')))
363
- h.fireEnd(h.childOf('r-1'), { progress: '进展正文', solved: false })
364
- await sleep(30)
365
- const lib = await h.callTool('vibe_v5_read_library', { member: 'r-1' }, h.childAgent(h.childOf('acad')))
366
- assert(lib.ok === true && lib.count > 0, 'read_library returns entries (got ' + lib.count + ')')
367
- const hasProp = (lib.items || []).some(i => i.kind === 'proposition' && i.id === 'p-x')
368
- const hasProg = (lib.items || []).some(i => i.kind === 'progress' && /进展正文/.test(i.text))
369
- assert(hasProp, 'read_library surfaces another member\'s recorded card (read-only cross-read)')
370
- assert(hasProg, 'read_library surfaces another member\'s progress log')
371
- const one = await h.callTool('vibe_v5_read_library', { member: 'r-1', kind: 'proposition', id: 'p-x' })
372
- assert(one.count === 1 && /- ID: p-x/.test(one.items[0].text), 'read_library can fetch exactly one card by id')
373
- }
374
-
375
- // ---------- 9. id sanitisation (path traversal) ----------
376
- console.log('\n[9] id sanitisation against path traversal')
377
- {
378
- const h = makeHost({ pluginModule })
379
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
380
- await h.settleSpawns()
381
- const r = await h.callTool('vibe_v5_propose_verify', { target: '../../../../evil', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
382
- assert(r.ok === true, 'a traversal-shaped target is accepted but sanitised')
383
- const s = await h.callTool('vibe_v5_status', {})
384
- const t = s.verify ? s.verify.target : (s.verifyQueue[0] || '')
385
- assert(!!t && t.indexOf('/') === -1 && t.indexOf('..') === -1, 'the stored target id contains no separators or ".." (got "' + t + '")')
386
- h.plannedVotes = new Map([['acad', 1], ['r-1', 1]])
387
- await h.drain(8)
388
- const base = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
389
- assert(!existsSync(join(h.WS, 'evil')) && !existsSync(join(h.WS, 'VibeMath', 'evil')), 'nothing was written outside the institute tree')
390
- assert(existsSync(base), 'the institute tree itself is intact')
391
- }
392
-
393
- // ---------- 10. configure guard while running ----------
394
- console.log('\n[10] configure guard')
395
- {
396
- const h = makeHost({ pluginModule })
397
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
398
- const c = await h.callTool('vibe_v5_configure', { institute: 'other' })
399
- assert(c.ok === false && c.code === 'V5_INSTITUTE_STATE', 'reconfiguring while running is refused (' + (c.code || '') + ')')
400
- const after = await h.callTool('vibe_v5_status', {})
401
- assert(after.institute === 'institute', 'the institute was not split onto a second tree')
402
- }
403
-
404
- // ---------- 11. verification watchdog ----------
405
- console.log('\n[11] verification watchdog abandons a stuck round')
406
- {
407
- const h = makeHost({ pluginModule })
408
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
409
- await h.settleSpawns()
410
- await h.callTool('vibe_v5_set', { activityTimeoutMs: 40 }) // recoverStallMs = 80ms
411
- await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
412
- const s0 = await h.callTool('vibe_v5_status', {})
413
- assert(!!s0.verify, 'verification is in flight')
414
- h.wakes.length = 0 // deliberately never answer the voters
415
- await sleep(200) // past recoverStallMs
416
- // any scheduling pass re-runs the watchdog
417
- await h.callTool('vibe_v5_say', { text: 'ping' }, h.childAgent(h.childOf('r-1')))
418
- h.fireEnd(h.childOf('r-1'), { solved: false })
419
- await sleep(80)
420
- let cleared = false
421
- for (let i = 0; i < 20 && !cleared; i++) {
422
- const s = await h.callTool('vibe_v5_status', {})
423
- if (!s.verify) cleared = true
424
- else { h.fireEnd(h.childOf('r-1'), { solved: false }); await sleep(40) }
425
- }
426
- const s1 = await h.callTool('vibe_v5_status', {})
427
- assert(!s1.verify, 'the stuck verification was abandoned instead of blocking forever')
428
- assert(s1.undecided.length >= 1 || s1.verified.indexOf('p-r-1') === -1, 'the abandoned object stayed 未定论 rather than being forced true/false')
429
- }
430
-
431
- // ---------- 12. meeting parked behind a verification ----------
432
- console.log('\n[12] meeting parked behind an in-flight verification')
433
- {
434
- const h = makeHost({ pluginModule })
435
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
436
- await h.settleSpawns()
437
- await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
438
- const s0 = await h.callTool('vibe_v5_status', {})
439
- assert(!!s0.verify, 'a verification is in flight')
440
- const m = await h.callTool('vibe_v5_meeting', { agenda: '协调下一步', kind: 'sync' }, h.childAgent(h.childOf('acad')))
441
- assert(m.ok === true && m.parked === true, 'the meeting is PARKED rather than preempting verification (' + JSON.stringify(m).slice(0, 90) + ')')
442
- const s1 = await h.callTool('vibe_v5_status', {})
443
- assert(!!s1.parkedMeeting, 'status exposes the parked meeting')
444
- // settle the verification, then the parked meeting must actually convene. Drain only
445
- // the verification's own asks: a greedy drain would also consume the meeting's asks
446
- // and finalize the meeting before we can observe it.
447
- h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
448
- await h.drain(3)
449
- await sleep(80)
450
- let met = null
451
- for (let i = 0; i < 20 && !met; i++) {
452
- const s = await h.callTool('vibe_v5_status', {})
453
- if (s.meeting) met = s.meeting
454
- else if (s.verify) { await h.drain(3) }
455
- await sleep(40)
456
- }
457
- assert(!!met, 'the parked meeting convened once verification cleared (meeting=' + (met ? met.id : 'none') + ')')
458
- }
459
-
460
- // ---------- 13. compaction directives only when warranted ----------
461
- console.log('\n[13] compaction directive appears only when warranted')
462
- {
463
- const h = makeHost({ pluginModule })
464
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
465
- await h.settleSpawns()
466
- await h.callTool('vibe_v5_set', { activityTimeoutMs: 30, compactAfterRounds: 50, compactThreshold: 99 })
467
- // NOTE: never clear `h.wakes` here. Discarding a queued wake would leave that member
468
- // marked busy forever (a real host always delivers its message), which would starve
469
- // the heartbeat of candidates. `peekWakeOf` instead answers every OTHER member's wake.
470
- const plain = await h.peekWakeOf('r-1', 4000)
471
- assert(!!plain, 'a heartbeat/round wake reached r-1')
472
- assert(!!plain && plain.text.indexOf('[CONTEXT COMPACT') === -1, 'no compaction directive on an ordinary round')
473
- if (plain) h.fireEnd(plain.childId, { progress: 'x', contextPct: 10 })
474
-
475
- // Let r-1 REPORT a full context window (the documented channel); the very next
476
- // research round must then carry the directive.
477
- const r1w = await h.peekWakeOf('r-1', 4000)
478
- assert(!!r1w, 'r-1 got the next round')
479
- if (r1w) h.fireEnd(r1w.childId, { progress: 'x', contextPct: 100 })
480
-
481
- const forced = await h.peekWakeOf('r-1', 4000)
482
- assert(!!forced && forced.text.indexOf('[CONTEXT COMPACT') !== -1,
483
- 'a member reporting 100% context DOES receive the compaction directive')
484
- assert(!!forced && forced.text.indexOf('[核心规则]') !== -1,
485
- 'the compaction round also re-anchors the short core rules')
486
- if (forced) h.fireEnd(forced.childId, { progress: '浓缩后的自述', compacted: true, contextPct: 15 })
487
-
488
- // The directive must NOT come back on the following round (v4 §24.1-③ regression).
489
- const later = await h.peekWakeOf('r-1', 4000)
490
- assert(!!later, 'r-1 got a further round')
491
- assert(!!later && later.text.indexOf('[CONTEXT COMPACT') === -1, 'the directive does NOT repeat on the next round (v4 §24.1-③ regression)')
492
- if (later) h.fireEnd(later.childId, { progress: 'y', contextPct: 15 })
493
- }
494
-
495
- // ---------- 14. session-log REPLAY reproduces state ----------
496
- console.log('\n[14] session-log replay reproduces the institute in a new session')
497
- {
498
- const h = makeHost({ pluginModule })
499
- await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
500
- await h.settleSpawns()
501
- const s0 = await h.callTool('vibe_v5_status', {})
502
- const events = h.ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0)
503
- assert(events.length > 0, 'the session log holds institute events (' + events.length + ')')
504
- // replay those events into a brand-new session and read the state through a new root
505
- const replayed = makeSession('sess-REPLAY', undefined)
506
- replayed.header.cwd = h.WS
507
- replayed._drive = (ev) => h.projections._drive(replayed, ev)
508
- for (const e of events) replayed.append(e.type, e.data)
509
- const ROOT2 = { id: 'sess-REPLAY', options: {}, session: replayed }
510
- const s1 = await h.callTool('vibe_v5_status', {}, ROOT2)
511
- assert(s1.members.length === s0.members.length, 'replay reproduced the roster (' + s1.members.length + ' vs ' + s0.members.length + ')')
512
- assert(s1.quorum.m === s0.quorum.m, 'replay reproduced the quorum')
513
- assert(s1.tasks.length === s0.tasks.length, 'replay reproduced the task board')
514
- assert(s1.institute === s0.institute && s1.project === s0.project, 'replay reproduced the institute identity')
515
- }
516
-
517
- console.log('')
518
- console.log('passed=' + passed + ' failed=' + failed)
519
- if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
520
- console.log('ALL GREEN')
521
- process.exit(0)
1
+ // ============================================================
2
+ // V5 ROUND-2 E2E — the behaviour paths the first suite never exercised:
3
+ // · the hardened JSON FALLBACK backend (host without sessionProjections)
4
+ // · a simulated PROCESS RESTART (fresh agent + fresh backend over the same workspace)
5
+ // · duplicate subagent/end idempotence
6
+ // · hire quotas (per-member and institute-wide)
7
+ // · quorumMode 'all-unanimous'
8
+ // · m recomputed when the roster shrinks (a voter is dismissed)
9
+ // · vibe_v5_wait (validation, no-progress shortcut, and real activity wake)
10
+ // · vibe_v5_read_library
11
+ // · id sanitisation against path traversal
12
+ // · configure guard while running
13
+ // · the verification watchdog abandoning a stuck round
14
+ // · meeting PARKED behind an in-flight verification, then resumed
15
+ // · compaction/keepalive directives appear only when they should
16
+ // · session-log REPLAY reproduces the institute state in a new session
17
+ // Run: node tests/e2e-v5-round2.test.mjs
18
+ // ============================================================
19
+ import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { join, dirname, isAbsolute } from 'node:path'
22
+
23
+ // V5_PLUGIN lets a sensitivity probe point this suite at a deliberately broken copy.
24
+ // Without it every probe against this suite silently tested the UNMUTATED plugin and was
25
+ // reported as a "detection" only because the probe's own spawn failed — i.e. the whole
26
+ // e2e block of the sensitivity audit was vacuous.
27
+ const PLUGIN = process.env.V5_PLUGIN
28
+ ? new URL('file:///' + String(process.env.V5_PLUGIN).replace(/\\/g, '/'))
29
+ : new URL('../vibe-math-v5/vibe-math-v5.js', import.meta.url)
30
+ let passed = 0, failed = 0
31
+ const failures = []
32
+ const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
33
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
34
+
35
+ function makeProjectionRegistry() {
36
+ const units = new Map()
37
+ const cells = new Map()
38
+ const cellMap = (sess) => { const id = String(sess.id); let m = cells.get(id); if (!m) { m = new Map(); cells.set(id, m) } return m }
39
+ return {
40
+ register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
41
+ stateOf(session, key) {
42
+ const def = units.get(key); if (!def) return undefined
43
+ const m = cellMap(session)
44
+ if (!m.has(key)) m.set(key, def.init(session.header, 0))
45
+ return m.get(key)
46
+ },
47
+ checkpoint(session) { const o = {}; const m = cellMap(session); for (const [k, d] of units) o[k] = { ver: d.stateVersion, seq: session.seq, val: m.get(k) }; return o },
48
+ _drive(session, event) {
49
+ const m = cellMap(session)
50
+ for (const [k, def] of units) {
51
+ const cur = m.has(k) ? m.get(k) : def.init(session.header, 0)
52
+ let next; try { next = def.apply(cur, event) } catch (e) { next = cur }
53
+ m.set(k, next)
54
+ }
55
+ },
56
+ }
57
+ }
58
+
59
+ function makeSession(id, parentSession) {
60
+ const events = []
61
+ const s = {
62
+ id,
63
+ header: { version: 1, id, createdAt: Date.now(), cwd: null, parentSession, isSeeded: false },
64
+ inheritedEventCount: 0,
65
+ get seq() { return events.length },
66
+ append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); this._drive(ev); return ev },
67
+ deriveMessages() { return [] },
68
+ snapshotEvents(from) { return events.slice(from || 0) },
69
+ ownEvents() { return events.slice() },
70
+ _events: events,
71
+ _drive: null,
72
+ }
73
+ return s
74
+ }
75
+
76
+ /** A fresh host+plugin instance. `withProjections:false` exercises the JSON fallback. */
77
+ function makeHost(opts) {
78
+ const o = opts || {}
79
+ const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
80
+ const projections = o.withProjections === false ? undefined : makeProjectionRegistry()
81
+ const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
82
+ const liveAgents = new Map()
83
+
84
+ const ctx = {
85
+ get(name) {
86
+ if (name === 'sessionProjections') return projections
87
+ if (name === 'sandboxPolicy') return undefined
88
+ if (name === 'compaction') return o.compaction
89
+ if (name === 'subprocess') {
90
+ return {
91
+ async spawn({ argv }) {
92
+ const script = argv[argv.length - 1] || ''
93
+ if (/New-Item/.test(script)) {
94
+ const re = /'((?:[^']|'')*)'/g; let m
95
+ while ((m = re.exec(script)) !== null) { const p = m[1].replace(/''/g, "'"); if (p && !/^-/.test(p)) mkdirSync(p, { recursive: true }) }
96
+ }
97
+ return { done: Promise.resolve({ exitCode: 0 }) }
98
+ },
99
+ }
100
+ }
101
+ return undefined
102
+ },
103
+ on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
104
+ effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
105
+ logger: { info() {}, warn() {}, error() {} },
106
+ timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
107
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
108
+ commands: { register(spec) { commandRegs.push(spec); return () => {} } },
109
+ sessions: { async flush() { return true } },
110
+ subagents: {
111
+ list() { return ['spawn'] },
112
+ async startContinuable({ label, request }) {
113
+ const id = 'c' + (spawns.length + 1)
114
+ const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
115
+ liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
116
+ spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
117
+ if (projections) childSession._drive = (ev) => projections._drive(childSession, ev)
118
+ return { childId: id, messageId: 'm' + spawns.length }
119
+ },
120
+ async sendMessage(parent, childId, blocks) { wakes.push({ childId, blocks }); return 'w' + wakes.length },
121
+ interrupt(childId) { interrupts.push(childId) },
122
+ async drainContinuableChildren(parent, ids) { drains.push(...ids); for (const i of ids) liveAgents.delete(i) },
123
+ },
124
+ agents: { roots() { return [ROOT] }, get(id) { return id === ROOT.id ? ROOT : liveAgents.get(id) }, list() { return [ROOT, ...liveAgents.values()] } },
125
+ fs: {
126
+ async resolve(rel, o2) {
127
+ const b = (o2 && o2.cwd) || WS
128
+ const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/'))
129
+ return { targetKey: p, displayPath: p }
130
+ },
131
+ async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
132
+ async readText(t) { return readFileSync(t.targetKey, 'utf8') },
133
+ async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
134
+ async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
135
+ },
136
+ }
137
+
138
+ const ROOT_SESSION = makeSession('sess-A', undefined)
139
+ ROOT_SESSION.header.cwd = WS
140
+ const ROOT = { id: 'sess-A', options: {}, session: ROOT_SESSION, ctx: undefined }
141
+ if (projections) ROOT_SESSION._drive = (ev) => projections._drive(ROOT_SESSION, ev)
142
+
143
+ const mod = o.pluginModule
144
+ mod.apply(ctx)
145
+
146
+ async function callTool(name, args, agent) {
147
+ const spec = toolRegs.find(x => x.name === name)
148
+ if (!spec) throw new Error('no tool ' + name)
149
+ return JSON.parse(await spec.execute(args || {}, { agent: agent || ROOT }))
150
+ }
151
+ const childAgent = (childId) => liveAgents.get(childId) || { id: childId, session: { header: { parentSession: ROOT.id } } }
152
+ function fireEnd(childId, reply, stopReason) {
153
+ const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
154
+ for (const h of (listeners['subagent/end'] || [])) h({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason: stopReason || 'completed', lastAssistantMessage: blocks })
155
+ }
156
+ const spawnOf = (m) => spawns.find(s => s.label.indexOf('vibe5 ' + m + ' ') !== -1)
157
+ const childOf = (m) => { const s = spawnOf(m); return s ? s.childId : m }
158
+ const labelOf = (cid) => { const s = spawns.find(x => x.childId === cid); const m = s ? /vibe5 (\S+) /.exec(s.label) : null; return m ? m[1] : '' }
159
+ const kindOf = (p) => /【入职首轮/.test(p) ? 'initial' : /【研究所会议/.test(p) ? 'meeting' : /【求真表决/.test(p) ? 'verify' : /【心跳检查/.test(p) ? 'checkpoint' : 'normal'
160
+
161
+ async function settleSpawns() {
162
+ for (const sp of spawns.slice()) {
163
+ if (sp.ended) continue
164
+ sp.ended = true
165
+ fireEnd(sp.childId, { progress: labelOf(sp.childId) + ':初始见解已记录。', solved: false, contextPct: 10 })
166
+ await sleep(15)
167
+ }
168
+ }
169
+ let plannedVotes = null
170
+ let solvePlan = null
171
+ async function drain(budget = 60) {
172
+ let n = 0
173
+ while (wakes.length && n < budget) {
174
+ const w = wakes.shift()
175
+ const prompt = (w.blocks && w.blocks[0] && w.blocks[0].text) || ''
176
+ const kind = kindOf(prompt)
177
+ const who = labelOf(w.childId)
178
+ let reply
179
+ if (kind === 'verify') {
180
+ const tm = /"target"\s*:\s*"([^"]+)"/.exec(prompt)
181
+ const t = tm ? tm[1] : 'p-r-1'
182
+ const v = plannedVotes && plannedVotes.has(who) ? plannedVotes.get(who) : 0.5
183
+ reply = { verdict: { target: t, verdict: v, reason: who + ' 判断' }, contextPct: 20 }
184
+ } else if (kind === 'meeting') {
185
+ reply = { input: who + ':意见已述。', vote_solved: solvePlan === true, solved: solvePlan === true, contextPct: 20 }
186
+ } else {
187
+ reply = { progress: who + ':继续推进。', solved: false, contextPct: 20 }
188
+ }
189
+ fireEnd(w.childId, reply)
190
+ n++
191
+ await sleep(15)
192
+ }
193
+ return n
194
+ }
195
+ /** Pull the next queued wake for one member and return its prompt text.
196
+ * Wakes for OTHER members are answered benignly so the scheduler keeps cycling —
197
+ * otherwise a single unanswered heartbeat would stop all further passes. */
198
+ async function peekWakeOf(member, maxWaitMs) {
199
+ const t0 = Date.now()
200
+ while (Date.now() - t0 < (maxWaitMs || 3000)) {
201
+ const i = wakes.findIndex(w => labelOf(w.childId) === member)
202
+ if (i !== -1) {
203
+ const w = wakes.splice(i, 1)[0]
204
+ return { childId: w.childId, text: (w.blocks && w.blocks[0] && w.blocks[0].text) || '' }
205
+ }
206
+ if (wakes.length) {
207
+ const w = wakes.shift()
208
+ fireEnd(w.childId, { progress: '其他成员推进中', solved: false, contextPct: 10 })
209
+ }
210
+ await sleep(20)
211
+ }
212
+ return null
213
+ }
214
+ return { WS, ctx, ROOT, ROOT_SESSION, projections, spawns, wakes, interrupts, drains, toolRegs, commandRegs, listeners, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
215
+ }
216
+
217
+ const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
218
+ const PROBLEM = '证明素数有无穷多个'
219
+
220
+ // ============================================================
221
+ console.log('-- V5 round-2 e2e --')
222
+
223
+ // ---------- 1. the hardened JSON FALLBACK backend ----------
224
+ console.log('\n[1] fallback backend (host without sessionProjections)')
225
+ {
226
+ const h = makeHost({ pluginModule, withProjections: false })
227
+ const st = await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
228
+ assert(st.ok === true, 'start works without sessionProjections (fallback path)')
229
+ const s0 = await h.callTool('vibe_v5_status', {})
230
+ assert(s0.backend === 'file', 'status reports the file backend (got ' + s0.backend + ')')
231
+ await h.settleSpawns()
232
+ const stFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
233
+ // the state file is written lazily (deferred stringify chain) — give it a tick
234
+ for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
235
+ assert(existsSync(stFile), 'the fallback persisted the institute state to State/institute.v5state.json')
236
+ if (existsSync(stFile)) {
237
+ const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
238
+ const inst = parsed.institutes['default::institute']
239
+ assert(!!inst && inst.members.length === 3, 'the persisted fallback state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
240
+ }
241
+ }
242
+
243
+ // ---------- 2. simulated PROCESS RESTART ----------
244
+ console.log('\n[2] simulated process restart (fresh host, fresh backend, same workspace)')
245
+ {
246
+ const WS = mkdtempSync(join(tmpdir(), 'vibe-v5r2-restart-'))
247
+ const h1 = makeHost({ pluginModule, withProjections: false, ws: WS })
248
+ await h1.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
249
+ await h1.settleSpawns()
250
+ const stFile = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
251
+ for (let i = 0; i < 40 && !existsSync(stFile); i++) await sleep(10)
252
+ assert(existsSync(stFile), 'first process persisted its state')
253
+
254
+ // a genuinely fresh host = new backend instance, empty in-memory state
255
+ const h2 = makeHost({ pluginModule, withProjections: false, ws: WS })
256
+ const s2 = await h2.callTool('vibe_v5_status', {})
257
+ assert(s2.members.length === 3, 'a FRESH host reads the persisted roster back (got ' + s2.members.length + ' members)')
258
+ const res = await h2.callTool('vibe_v5_resume', {})
259
+ assert(res.ok === true, 'resume works on a fresh host over persisted state (got ' + JSON.stringify(res).slice(0, 120) + ')')
260
+ assert(h2.spawns.length === 3, 'resume re-spawned the 3 members whose child sessions no longer exist (got ' + h2.spawns.length + ')')
261
+ assert(s2.quorum && s2.quorum.m === 3, 'the recovered roster recomputes m correctly (m=' + (s2.quorum ? s2.quorum.m : '?') + ')')
262
+ }
263
+
264
+ // ---------- 3. duplicate subagent/end is idempotent ----------
265
+ console.log('\n[3] duplicate subagent/end idempotence')
266
+ {
267
+ const h = makeHost({ pluginModule })
268
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
269
+ const cid = h.childOf('r-1')
270
+ // settle the founding turn normally, then REPLAY the exact same end event
271
+ const before = h.ROOT_SESSION._events.length
272
+ h.fireEnd(cid, { progress: '唯一一次', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
273
+ await sleep(40)
274
+ const afterFirst = h.ROOT_SESSION._events.length
275
+ h.fireEnd(cid, { progress: '重复投递', record: [{ kind: 'proposition', id: 'dup-x', statement: 's', value: 0.5, motive: 'm', p: 0.5 }] })
276
+ await sleep(40)
277
+ const afterSecond = h.ROOT_SESSION._events.length
278
+ assert(afterSecond === afterFirst, 'a replayed end commits NOTHING new (' + afterFirst + ' -> ' + afterSecond + ' events)')
279
+ assert(afterFirst > before, 'the first end did commit state')
280
+ }
281
+
282
+ // ---------- 4. hire quotas ----------
283
+ console.log('\n[4] hire quotas')
284
+ {
285
+ const h = makeHost({ pluginModule })
286
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
287
+ await h.settleSpawns()
288
+ await h.callTool('vibe_v5_set', { maxTempPerMember: 2, maxTempTotal: 3 })
289
+ const a = await h.callTool('vibe_v5_hire', { purpose: 'p1', initial_task: 't1' }, h.childAgent(h.childOf('r-1')))
290
+ const b = await h.callTool('vibe_v5_hire', { purpose: 'p2', initial_task: 't2' }, h.childAgent(h.childOf('r-1')))
291
+ const c = await h.callTool('vibe_v5_hire', { purpose: 'p3', initial_task: 't3' }, h.childAgent(h.childOf('r-1')))
292
+ await h.settleSpawns()
293
+ assert(a.ok && b.ok, 'the first two hires succeed (cap 2 per member)')
294
+ assert(c.ok === false && c.code === 'V5_MEMBER_LIMIT', 'the third hire is refused by maxTempPerMember (' + (c.code || 'no code') + ')')
295
+ const d = await h.callTool('vibe_v5_hire', { purpose: 'p4', initial_task: 't4' }, h.childAgent(h.childOf('acad')))
296
+ assert(d.ok === true, 'the academician can still hire (its own per-member budget)')
297
+ const e = await h.callTool('vibe_v5_hire', { purpose: 'p5', initial_task: 't5' }, h.childAgent(h.childOf('acad')))
298
+ assert(e.ok === false && e.code === 'V5_MEMBER_LIMIT', 'the institute-wide cap maxTempTotal=3 then refuses (' + (e.code || 'no code') + ')')
299
+ }
300
+
301
+ // ---------- 5. quorumMode all-unanimous ----------
302
+ console.log('\n[5] quorumMode all-unanimous')
303
+ {
304
+ const h = makeHost({ pluginModule })
305
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
306
+ await h.settleSpawns()
307
+ await h.callTool('vibe_v5_set', { quorumMode: 'all-unanimous' })
308
+ await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'r' }, h.childAgent(h.childOf('r-1')))
309
+ const s = await h.callTool('vibe_v5_status', {})
310
+ assert(!!s.verify, 'verification started in all-unanimous mode')
311
+ // only 2 of 3 voters assert true; the third abstains -> must NOT verify
312
+ h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 0.5]])
313
+ await h.drain(6)
314
+ let s2 = await h.callTool('vibe_v5_status', {})
315
+ assert(s2.verified.indexOf('p-r-1') === -1, 'all-unanimous: 2/3 true with one abstention does NOT verify')
316
+ // now everyone asserts true -> verifies
317
+ h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
318
+ await h.drain(8)
319
+ let s3 = await h.callTool('vibe_v5_status', {})
320
+ if (!s3.verified.includes('p-r-1')) { h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]]); await h.drain(8); s3 = await h.callTool('vibe_v5_status', {}) }
321
+ assert(s3.verified.indexOf('p-r-1') !== -1, 'all-unanimous: every voter true DOES verify')
322
+ }
323
+
324
+ // ---------- 6. m recomputed when the roster shrinks ----------
325
+ console.log('\n[6] quorum recomputed when a voter is dismissed')
326
+ {
327
+ const h = makeHost({ pluginModule })
328
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 3 })
329
+ await h.settleSpawns()
330
+ const s0 = await h.callTool('vibe_v5_status', {})
331
+ assert(s0.quorum.m === 3 && s0.quorum.voterCount === 4, 'baseline m=3 over 4 voters')
332
+ await h.callTool('vibe_v5_remove_researcher', { id: 'r-3' })
333
+ await h.callTool('vibe_v5_remove_researcher', { id: 'r-2' })
334
+ const s1 = await h.callTool('vibe_v5_status', {})
335
+ assert(s1.quorum.voterCount === 2, 'two researchers dismissed -> 2 voters (got ' + s1.quorum.voterCount + ')')
336
+ assert(s1.quorum.m === 2, 'm tracks the roster down to min(cap=3, P=2) = 2 (got ' + s1.quorum.m + ')')
337
+ assert(h.drains.length === 2, 'both dismissed researchers had their resident child released')
338
+ }
339
+
340
+ // ---------- 7. vibe_v5_wait ----------
341
+ console.log('\n[7] vibe_v5_wait')
342
+ {
343
+ const h = makeHost({ pluginModule })
344
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
345
+ await h.settleSpawns()
346
+ const bad = await h.callTool('vibe_v5_wait', { timeout_ms: 5000 }, h.childAgent(h.childOf('r-1')))
347
+ assert(bad.ok === false || bad.code === 'V5_INVALID_TIMEOUT' || String(bad.error || '').indexOf('10000') !== -1,
348
+ 'a timeout below 10000ms is rejected (' + JSON.stringify(bad).slice(0, 90) + ')')
349
+ // a real wait must be woken by genuine institute activity, not by polling
350
+ const waitP = h.callTool('vibe_v5_wait', { timeout_ms: 10000 }, h.childAgent(h.childOf('r-1')))
351
+ await sleep(30)
352
+ await h.callTool('vibe_v5_say', { text: '有人说话就会唤醒等待者' }, h.childAgent(h.childOf('acad')))
353
+ const woke = await Promise.race([waitP, sleep(1500).then(() => ({ timedOut: 'NEVER-RESOLVED' }))])
354
+ assert(woke.timedOut === false, 'wait is woken by real activity rather than timing out (' + JSON.stringify(woke).slice(0, 80) + ')')
355
+ }
356
+
357
+ // ---------- 8. read_library ----------
358
+ console.log('\n[8] vibe_v5_read_library')
359
+ {
360
+ const h = makeHost({ pluginModule })
361
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
362
+ await h.callTool('vibe_v5_record_proposition', { id: 'p-x', statement: 'S', value: 0.7, motive: 'M', p: 0.9 }, h.childAgent(h.childOf('r-1')))
363
+ h.fireEnd(h.childOf('r-1'), { progress: '进展正文', solved: false })
364
+ await sleep(30)
365
+ const lib = await h.callTool('vibe_v5_read_library', { member: 'r-1' }, h.childAgent(h.childOf('acad')))
366
+ assert(lib.ok === true && lib.count > 0, 'read_library returns entries (got ' + lib.count + ')')
367
+ const hasProp = (lib.items || []).some(i => i.kind === 'proposition' && i.id === 'p-x')
368
+ const hasProg = (lib.items || []).some(i => i.kind === 'progress' && /进展正文/.test(i.text))
369
+ assert(hasProp, 'read_library surfaces another member\'s recorded card (read-only cross-read)')
370
+ assert(hasProg, 'read_library surfaces another member\'s progress log')
371
+ const one = await h.callTool('vibe_v5_read_library', { member: 'r-1', kind: 'proposition', id: 'p-x' })
372
+ assert(one.count === 1 && /- ID: p-x/.test(one.items[0].text), 'read_library can fetch exactly one card by id')
373
+ }
374
+
375
+ // ---------- 9. id sanitisation (path traversal) ----------
376
+ console.log('\n[9] id sanitisation against path traversal')
377
+ {
378
+ const h = makeHost({ pluginModule })
379
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
380
+ await h.settleSpawns()
381
+ const r = await h.callTool('vibe_v5_propose_verify', { target: '../../../../evil', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
382
+ assert(r.ok === true, 'a traversal-shaped target is accepted but sanitised')
383
+ const s = await h.callTool('vibe_v5_status', {})
384
+ const t = s.verify ? s.verify.target : (s.verifyQueue[0] || '')
385
+ assert(!!t && t.indexOf('/') === -1 && t.indexOf('..') === -1, 'the stored target id contains no separators or ".." (got "' + t + '")')
386
+ h.plannedVotes = new Map([['acad', 1], ['r-1', 1]])
387
+ await h.drain(8)
388
+ const base = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
389
+ assert(!existsSync(join(h.WS, 'evil')) && !existsSync(join(h.WS, 'VibeMath', 'evil')), 'nothing was written outside the institute tree')
390
+ assert(existsSync(base), 'the institute tree itself is intact')
391
+ }
392
+
393
+ // ---------- 10. configure guard while running ----------
394
+ console.log('\n[10] configure guard')
395
+ {
396
+ const h = makeHost({ pluginModule })
397
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
398
+ const c = await h.callTool('vibe_v5_configure', { institute: 'other' })
399
+ assert(c.ok === false && c.code === 'V5_INSTITUTE_STATE', 'reconfiguring while running is refused (' + (c.code || '') + ')')
400
+ const after = await h.callTool('vibe_v5_status', {})
401
+ assert(after.institute === 'institute', 'the institute was not split onto a second tree')
402
+ }
403
+
404
+ // ---------- 11. verification watchdog ----------
405
+ console.log('\n[11] verification watchdog abandons a stuck round')
406
+ {
407
+ const h = makeHost({ pluginModule })
408
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
409
+ await h.settleSpawns()
410
+ await h.callTool('vibe_v5_set', { activityTimeoutMs: 40 }) // recoverStallMs = 80ms
411
+ await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
412
+ const s0 = await h.callTool('vibe_v5_status', {})
413
+ assert(!!s0.verify, 'verification is in flight')
414
+ h.wakes.length = 0 // deliberately never answer the voters
415
+ await sleep(200) // past recoverStallMs
416
+ // any scheduling pass re-runs the watchdog
417
+ await h.callTool('vibe_v5_say', { text: 'ping' }, h.childAgent(h.childOf('r-1')))
418
+ h.fireEnd(h.childOf('r-1'), { solved: false })
419
+ await sleep(80)
420
+ let cleared = false
421
+ for (let i = 0; i < 20 && !cleared; i++) {
422
+ const s = await h.callTool('vibe_v5_status', {})
423
+ if (!s.verify) cleared = true
424
+ else { h.fireEnd(h.childOf('r-1'), { solved: false }); await sleep(40) }
425
+ }
426
+ const s1 = await h.callTool('vibe_v5_status', {})
427
+ assert(!s1.verify, 'the stuck verification was abandoned instead of blocking forever')
428
+ assert(s1.undecided.length >= 1 || s1.verified.indexOf('p-r-1') === -1, 'the abandoned object stayed 未定论 rather than being forced true/false')
429
+ }
430
+
431
+ // ---------- 12. meeting parked behind a verification ----------
432
+ console.log('\n[12] meeting parked behind an in-flight verification')
433
+ {
434
+ const h = makeHost({ pluginModule })
435
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
436
+ await h.settleSpawns()
437
+ await h.callTool('vibe_v5_propose_verify', { target: 'p-r-1', kind: 'proposition', reason: 'x' }, h.childAgent(h.childOf('r-1')))
438
+ const s0 = await h.callTool('vibe_v5_status', {})
439
+ assert(!!s0.verify, 'a verification is in flight')
440
+ const m = await h.callTool('vibe_v5_meeting', { agenda: '协调下一步', kind: 'sync' }, h.childAgent(h.childOf('acad')))
441
+ assert(m.ok === true && m.parked === true, 'the meeting is PARKED rather than preempting verification (' + JSON.stringify(m).slice(0, 90) + ')')
442
+ const s1 = await h.callTool('vibe_v5_status', {})
443
+ assert(!!s1.parkedMeeting, 'status exposes the parked meeting')
444
+ // settle the verification, then the parked meeting must actually convene. Drain only
445
+ // the verification's own asks: a greedy drain would also consume the meeting's asks
446
+ // and finalize the meeting before we can observe it.
447
+ h.plannedVotes = new Map([['acad', 1], ['r-1', 1], ['r-2', 1]])
448
+ await h.drain(3)
449
+ await sleep(80)
450
+ let met = null
451
+ for (let i = 0; i < 20 && !met; i++) {
452
+ const s = await h.callTool('vibe_v5_status', {})
453
+ if (s.meeting) met = s.meeting
454
+ else if (s.verify) { await h.drain(3) }
455
+ await sleep(40)
456
+ }
457
+ assert(!!met, 'the parked meeting convened once verification cleared (meeting=' + (met ? met.id : 'none') + ')')
458
+ }
459
+
460
+ // ---------- 13. compaction directives only when warranted ----------
461
+ console.log('\n[13] compaction directive appears only when warranted')
462
+ {
463
+ const h = makeHost({ pluginModule })
464
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
465
+ await h.settleSpawns()
466
+ await h.callTool('vibe_v5_set', { activityTimeoutMs: 30, compactAfterRounds: 50, compactThreshold: 99 })
467
+ // NOTE: never clear `h.wakes` here. Discarding a queued wake would leave that member
468
+ // marked busy forever (a real host always delivers its message), which would starve
469
+ // the heartbeat of candidates. `peekWakeOf` instead answers every OTHER member's wake.
470
+ const plain = await h.peekWakeOf('r-1', 4000)
471
+ assert(!!plain, 'a heartbeat/round wake reached r-1')
472
+ assert(!!plain && plain.text.indexOf('[CONTEXT COMPACT') === -1, 'no compaction directive on an ordinary round')
473
+ if (plain) h.fireEnd(plain.childId, { progress: 'x', contextPct: 10 })
474
+
475
+ // Let r-1 REPORT a full context window (the documented channel); the very next
476
+ // research round must then carry the directive.
477
+ const r1w = await h.peekWakeOf('r-1', 4000)
478
+ assert(!!r1w, 'r-1 got the next round')
479
+ if (r1w) h.fireEnd(r1w.childId, { progress: 'x', contextPct: 100 })
480
+
481
+ const forced = await h.peekWakeOf('r-1', 4000)
482
+ assert(!!forced && forced.text.indexOf('[CONTEXT COMPACT') !== -1,
483
+ 'a member reporting 100% context DOES receive the compaction directive')
484
+ assert(!!forced && forced.text.indexOf('[核心规则]') !== -1,
485
+ 'the compaction round also re-anchors the short core rules')
486
+ if (forced) h.fireEnd(forced.childId, { progress: '浓缩后的自述', compacted: true, contextPct: 15 })
487
+
488
+ // The directive must NOT come back on the following round (v4 §24.1-③ regression).
489
+ const later = await h.peekWakeOf('r-1', 4000)
490
+ assert(!!later, 'r-1 got a further round')
491
+ assert(!!later && later.text.indexOf('[CONTEXT COMPACT') === -1, 'the directive does NOT repeat on the next round (v4 §24.1-③ regression)')
492
+ if (later) h.fireEnd(later.childId, { progress: 'y', contextPct: 15 })
493
+ }
494
+
495
+ // ---------- 14. session-log REPLAY reproduces state ----------
496
+ console.log('\n[14] session-log replay reproduces the institute in a new session')
497
+ {
498
+ const h = makeHost({ pluginModule })
499
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
500
+ await h.settleSpawns()
501
+ const s0 = await h.callTool('vibe_v5_status', {})
502
+ const events = h.ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0)
503
+ assert(events.length > 0, 'the session log holds institute events (' + events.length + ')')
504
+ // replay those events into a brand-new session and read the state through a new root
505
+ const replayed = makeSession('sess-REPLAY', undefined)
506
+ replayed.header.cwd = h.WS
507
+ replayed._drive = (ev) => h.projections._drive(replayed, ev)
508
+ for (const e of events) replayed.append(e.type, e.data)
509
+ const ROOT2 = { id: 'sess-REPLAY', options: {}, session: replayed }
510
+ const s1 = await h.callTool('vibe_v5_status', {}, ROOT2)
511
+ assert(s1.members.length === s0.members.length, 'replay reproduced the roster (' + s1.members.length + ' vs ' + s0.members.length + ')')
512
+ assert(s1.quorum.m === s0.quorum.m, 'replay reproduced the quorum')
513
+ assert(s1.tasks.length === s0.tasks.length, 'replay reproduced the task board')
514
+ assert(s1.institute === s0.institute && s1.project === s0.project, 'replay reproduced the institute identity')
515
+ }
516
+
517
+ console.log('')
518
+ console.log('passed=' + passed + ' failed=' + failed)
519
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
520
+ console.log('ALL GREEN')
521
+ process.exit(0)