dsh-vibe-math 2.3.15 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.en.md +93 -87
  2. package/README.md +58 -53
  3. package/cordis.patch.yml +1123 -10
  4. package/docs/AUDIT-CHECKLIST.md +107 -43
  5. package/docs/COMPAT-AUDIT-ROUND2.md +3 -0
  6. package/docs/COMPAT-AUDIT-ROUND3.md +139 -0
  7. package/docs/generate_framework_diagram_v2_en.mjs +595 -0
  8. package/docs/generate_framework_diagram_v3_en.mjs +624 -0
  9. package/docs/generate_framework_diagram_v4.mjs +380 -125
  10. package/docs/generate_framework_diagram_v5.mjs +366 -108
  11. package/docs/release-notes/RELEASE-NOTES-2.3.16.md +165 -0
  12. package/docs/release-notes/RELEASE-NOTES-2.4.0.md +172 -0
  13. package/docs/test-timing.md +4 -3
  14. package/docs//346/236/266/346/236/204/345/233/276.md +3 -1
  15. package/installer.js +89 -21
  16. package/package.json +40 -9
  17. package/preset-declaration.js +62 -0
  18. package/prompt-corpus-v5/prompt-corpus-v5.json +7 -7
  19. package/prompt-corpus-v5/prompt-corpus-v5.md +8 -129
  20. package/scripts/build-preset-rows.mjs +146 -0
  21. package/tests/audit-installer-compat.test.mjs +9 -3
  22. package/tests/audit-preset-rows.test.mjs +185 -0
  23. package/tests/audit-v5-integrity.mjs +49 -3
  24. package/tests/e2e-v5-round2.test.mjs +137 -67
  25. package/tests/formal-verify-v5.test.mjs +43 -35
  26. package/tests/prompt-v5-integrity.test.mjs +49 -47
  27. package/tests/selfdrive-v5.mjs +42 -55
  28. package/vibe-math-v2/vibe-math-v2.js +6 -2
  29. package/vibe-math-v3/vibe-math-v3.js +6 -2
  30. package/vibe-math-v4/vibe-math-v4.js +191 -13
  31. package/vibe-math-v5/agent.cordis.yml +8 -8
  32. package/vibe-math-v5/preset.yml +1 -1
  33. package/vibe-math-v5/vibe-math-v5.js +215 -91
  34. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +56 -44
  35. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +24 -22
  36. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v2-en.svg +291 -0
  37. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v3-en.svg +316 -0
  38. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v4-en.svg +187 -0
  39. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5-en.svg +174 -0
  40. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +14 -14
@@ -35,29 +35,12 @@ const section = (t) => console.log('\n[' + t + ']')
35
35
  // ---------------------------------------------------------------
36
36
  // mock host
37
37
  // ---------------------------------------------------------------
38
- function makeProjectionRegistry() {
39
- const units = new Map(); const cells = new Map()
40
- const cellMap = (s) => { const id = String(s.id); let m = cells.get(id); if (!m) { m = new Map(); cells.set(id, m) } return m }
41
- return {
42
- register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
43
- stateOf(session, key) {
44
- const def = units.get(key); if (!def) return undefined
45
- const m = cellMap(session)
46
- if (!m.has(key)) m.set(key, def.init(session.header, session.inheritedEventCount || 0))
47
- return m.get(key)
48
- },
49
- _drive(session, event) {
50
- const m = cellMap(session)
51
- for (const [k, def] of units) {
52
- const cur = m.has(k) ? m.get(k) : def.init(session.header, session.inheritedEventCount || 0)
53
- let next; try { next = def.apply(cur, event) } catch (e) { next = cur }
54
- m.set(k, next)
55
- }
56
- },
57
- }
58
- }
59
-
60
- const projections = makeProjectionRegistry()
38
+ // The host offers NO session services: v5 injects only
39
+ // ['subagents','agents','fs','tools','commands','timer'] and keeps its durable state in the
40
+ // hardened JSON file State/<institute>.v5state.json. Sessions still RECORD anything appended to
41
+ // them, so the suite can assert that the plugin appended nothing (an institute event in the log
42
+ // would make the user session unresumable — DSH refuses to load an unknown event type unless it
43
+ // is marked `ignorable`, which `Session.append` cannot set).
61
44
  const listeners = {}
62
45
  const toolRegs = []
63
46
  const liveAgents = new Map()
@@ -107,14 +90,14 @@ let subprocess = {
107
90
  },
108
91
  }
109
92
 
110
- function makeMockSession(id, parentSession) {
93
+ function makeMockSession(id, parentSession, cwd) {
111
94
  const events = []
112
95
  const s = {
113
96
  id,
114
- header: { version: 1, id, createdAt: Date.now(), cwd: WS, parentSession, isSeeded: false },
97
+ header: { version: 1, id, createdAt: Date.now(), cwd: cwd || WS, parentSession, isSeeded: false },
115
98
  inheritedEventCount: 0,
116
99
  get seq() { return events.length },
117
- append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); projections._drive(s, ev); return ev },
100
+ append(type, data) { const ev = { type, data, seq: events.length, time: Date.now() }; events.push(ev); return ev },
118
101
  deriveMessages() { return [] },
119
102
  snapshotEvents(from) { return events.slice(from || 0) },
120
103
  ownEvents() { return events.slice() },
@@ -125,9 +108,14 @@ function makeMockSession(id, parentSession) {
125
108
 
126
109
  const roots = new Map()
127
110
  let rootSeq = 0
128
- function makeRoot() {
111
+ // Every case gets its OWN workspace. The institute's durable state is a file under the
112
+ // workspace (State/<institute>.v5state.json), so roots sharing one workspace would share one
113
+ // institute and let one case's state leak into the next. `ws` lets a case open a SECOND root
114
+ // over the same workspace, which is exactly what a reload looks like.
115
+ function makeRoot(ws) {
129
116
  const id = 'sess-' + String.fromCharCode(65 + rootSeq++)
130
- const session = makeMockSession(id, undefined)
117
+ const cwd = ws || mkdtempSync(join(tmpdir(), 'vibe-v5-lean-'))
118
+ const session = makeMockSession(id, undefined, cwd)
131
119
  const root = { id, options: { provider: 'mock', model: 'm' }, session, ctx: undefined }
132
120
  roots.set(id, root)
133
121
  return root
@@ -135,7 +123,6 @@ function makeRoot() {
135
123
 
136
124
  const ctx = {
137
125
  get(name) {
138
- if (name === 'sessionProjections') return projections
139
126
  if (name === 'sandboxPolicy') return undefined
140
127
  if (name === 'compaction') return undefined
141
128
  if (name === 'subprocess') return noSubprocess ? undefined : subprocess
@@ -147,13 +134,12 @@ const ctx = {
147
134
  timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
148
135
  tools: { register(spec) { toolRegs.push(spec); return () => {} } },
149
136
  commands: { register() { return () => {} } },
150
- sessions: { async flush() { return true } },
151
137
  subagents: {
152
138
  list() { return ['spawn'] },
153
139
  async startContinuable({ label, request }) {
154
140
  const rootId = (request && request.parent && request.parent.id) || 'sess-A'
155
141
  const id = 'c' + (spawns.length + 1)
156
- liveAgents.set(id, { id, session: makeMockSession(id, rootId), options: request && request.agentOptions })
142
+ liveAgents.set(id, { id, session: makeMockSession(id, rootId, (request && request.parent && request.parent.session && request.parent.session.header.cwd) || WS), options: request && request.agentOptions })
157
143
  spawns.push({ label, childId: id, rootId, persona: request && request.persona, prompt: request && request.prompt && request.prompt[0] && request.prompt[0].text })
158
144
  return { childId: id, messageId: 'm' + spawns.length }
159
145
  },
@@ -272,8 +258,10 @@ async function wakeAndReply(root, memberId, reply, fromMember) {
272
258
  return w
273
259
  }
274
260
 
275
- const instRootOf = (root) => join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
276
- const vibeRoot = join(WS, 'VibeMath')
261
+ // Paths are per ROOT: each case's workspace holds its own institute.
262
+ const wsOf = (root) => root.session.header.cwd
263
+ const instRootOf = (root) => join(wsOf(root), 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
264
+ const vibeRootOf = (root) => join(wsOf(root), 'VibeMath')
277
265
  const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
278
266
  // Every Lean tool mention in AGENT-FACING text must be the registered name (vibe_v5_lean_*).
279
267
  // An abbreviated `lean_archive` is not a tool: an agent that copies it calls nothing.
@@ -287,6 +275,26 @@ section("1 'off' (default) is a true no-op")
287
275
  const RA = makeRoot()
288
276
  const st0 = await foundInstitute(RA, '形式化开关默认关闭测试')
289
277
  assert(st0.params.formalVerify === 'off', "the default is 'off' (got " + st0.params.formalVerify + ')')
278
+
279
+ // ★ The persistence contract (the DSH 0.2.0 audit fix). v5 used to keep the institute in a
280
+ // host-only session projection: it appended `vibe5/*` events to the user's OWN session log, and
281
+ // DSH's session persistence then REFUSED to load that session (unknown event type without
282
+ // `ignorable: true`, which `Session.append` cannot set) — the user's session became unresumable.
283
+ // The hardened JSON file is now the only authority, so the log must stay empty AND the state must
284
+ // come back from the file in a brand-new root.
285
+ {
286
+ assert(RA.session._events.length === 0,
287
+ '★ the plugin appends NOTHING to the host session log (' + RA.session._events.length + ' events after founding): an institute event would make the session unresumable')
288
+ const stateFile = join(instRootOf(RA), 'State', 'institute.v5state.json')
289
+ for (let i = 0; i < 40 && !existsSync(stateFile); i++) await settle()
290
+ assert(existsSync(stateFile), 'the institute state is on disk in State/institute.v5state.json')
291
+ const before = await callTool('vibe_v5_status', {}, RA)
292
+ const RA2 = makeRoot(wsOf(RA)) // fresh root, fresh backend, same workspace = a reload
293
+ const after = await callTool('vibe_v5_status', {}, RA2)
294
+ assert(after.members.length === before.members.length && after.params.formalVerify === before.params.formalVerify,
295
+ '★ a reload reads the roster and the parameters back out of the JSON file (' + after.members.length + ' members, formalVerify=' + after.params.formalVerify + ')')
296
+ assert(RA2.session._events.length === 0, '★ the reloading root appends nothing to its session log either')
297
+ }
290
298
  {
291
299
  const allPrompts = spawnsFor(RA).map(s => s.prompt || '').join('\n') + '\n' + delivered.filter(d => d.rootId === RA.id).map(d => d.prompt).join('\n')
292
300
  assert(!/Lean/.test(allPrompts), 'no founding/round prompt mentions Lean in off mode')
@@ -498,8 +506,8 @@ await drainWakes(10, RD)
498
506
 
499
507
  // ---------- 6. the reusable cross-project library ----------
500
508
  section('6 reusable definitions and lemmas go to the GLOBAL library')
501
- const libPath = join(vibeRoot, 'Formal', 'Lib')
502
- const provedPath = join(vibeRoot, 'Formal', 'Proved')
509
+ const libPath = join(vibeRootOf(RD), 'Formal', 'Lib')
510
+ const provedPath = join(vibeRootOf(RD), 'Formal', 'Proved')
503
511
  const defRes = await callTool('vibe_v5_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' }, childAgent(childOf(RD, 'r-1')))
504
512
  assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
505
513
  assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under VibeMath/Formal/Lib/ (cross-project, NOT inside the institute)')
@@ -83,37 +83,11 @@ const sleep = async (ms) => { advanceClock(ms); for (let i = 0; i < 3; i++) awai
83
83
  // ---------------------------------------------------------------
84
84
  // mock host
85
85
  // ---------------------------------------------------------------
86
- function makeProjectionRegistry() {
87
- const units = new Map()
88
- const cells = new Map()
89
- const cellMap = (sess) => {
90
- const id = String(sess.id)
91
- let m = cells.get(id)
92
- if (!m) { m = new Map(); cells.set(id, m) }
93
- return m
94
- }
95
- return {
96
- register(def) { units.set(def.key, def); return () => { units.delete(def.key) } },
97
- stateOf(session, key) {
98
- const def = units.get(key)
99
- if (!def) return undefined
100
- const m = cellMap(session)
101
- if (!m.has(key)) m.set(key, def.init(session.header, session.inheritedEventCount || 0))
102
- return m.get(key)
103
- },
104
- _drive(session, event) {
105
- const m = cellMap(session)
106
- for (const [k, def] of units) {
107
- const cur = m.has(k) ? m.get(k) : def.init(session.header, session.inheritedEventCount || 0)
108
- let next
109
- try { next = def.apply(cur, event) } catch (e) { next = cur }
110
- m.set(k, next)
111
- }
112
- },
113
- }
114
- }
115
-
116
- const projections = makeProjectionRegistry()
86
+ // NO session services: v5 injects only ['subagents','agents','fs','tools','commands','timer'] and
87
+ // keeps its durable state in the hardened JSON file State/<institute>.v5state.json. Sessions still
88
+ // record anything appended to them so the suite can assert that the plugin appended NOTHING — an
89
+ // institute event in the user's session log makes that session unresumable (DSH refuses to load an
90
+ // unknown event type unless it is marked `ignorable`, which `Session.append` cannot set).
117
91
  const listeners = {}
118
92
  const toolRegs = []
119
93
  const liveAgents = new Map()
@@ -123,17 +97,25 @@ const wakes = [] // queued sends not yet handled
123
97
  const delivered = [] // sends that drainWakes actually handled
124
98
  let failNextStarts = 0
125
99
 
126
- function makeMockSession(id, parentSession) {
100
+ // One workspace PER CASE. The institute's durable state is a file under the workspace, so a shared
101
+ // workspace would let case 1's institute (and its running state) leak into every later case.
102
+ const WORKSPACES = [WS]
103
+ function newWorkspace() {
104
+ const ws = mkdtempSync(join(tmpdir(), 'vibe-v5-prompt-'))
105
+ WORKSPACES.push(ws)
106
+ return ws
107
+ }
108
+
109
+ function makeMockSession(id, parentSession, cwd) {
127
110
  const events = []
128
111
  const s = {
129
112
  id,
130
- header: { version: 1, id, createdAt: Date.now(), cwd: WS, parentSession, isSeeded: false },
113
+ header: { version: 1, id, createdAt: Date.now(), cwd: cwd || WS, parentSession, isSeeded: false },
131
114
  inheritedEventCount: 0,
132
115
  get seq() { return events.length },
133
116
  append(type, data) {
134
117
  const ev = { type, data, seq: events.length, time: Date.now() }
135
118
  events.push(ev)
136
- projections._drive(s, ev)
137
119
  return ev
138
120
  },
139
121
  deriveMessages() { return [] },
@@ -146,17 +128,19 @@ function makeMockSession(id, parentSession) {
146
128
 
147
129
  const roots = new Map()
148
130
  let rootSeq = 0
149
- function makeRoot() {
131
+ // `ws` opens a SECOND root over an existing workspace, which is what a reload looks like.
132
+ function makeRoot(ws) {
150
133
  const id = 'sess-' + String.fromCharCode(65 + rootSeq++)
151
- const session = makeMockSession(id, undefined)
134
+ const cwd = ws || newWorkspace()
135
+ const session = makeMockSession(id, undefined, cwd)
152
136
  const root = { id, options: { provider: 'mock', model: 'm' }, session, ctx: undefined }
153
137
  roots.set(id, root)
154
138
  return root
155
139
  }
140
+ const wsOf = (root) => root.session.header.cwd
156
141
 
157
142
  const ctx = {
158
143
  get(name) {
159
- if (name === 'sessionProjections') return projections
160
144
  if (name === 'sandboxPolicy') return undefined
161
145
  if (name === 'compaction') return undefined
162
146
  if (name === 'subprocess') {
@@ -201,14 +185,14 @@ const ctx = {
201
185
  },
202
186
  tools: { register(spec) { toolRegs.push(spec); return () => {} } },
203
187
  commands: { register() { return () => {} } },
204
- sessions: { async flush() { return true } },
205
188
  subagents: {
206
189
  list() { return ['spawn'] },
207
190
  async startContinuable({ label, request }) {
208
191
  if (failNextStarts > 0) { failNextStarts -= 1; throw new Error('mock provisioning failure') }
209
192
  const rootId = (request && request.parent && request.parent.id) || 'sess-A'
210
193
  const id = 'c' + (spawns.length + 1)
211
- liveAgents.set(id, { id, session: makeMockSession(id, rootId), options: request && request.agentOptions })
194
+ const cwd = (request && request.parent && request.parent.session && request.parent.session.header.cwd) || WS
195
+ liveAgents.set(id, { id, session: makeMockSession(id, rootId, cwd), options: request && request.agentOptions })
212
196
  spawns.push({
213
197
  label, childId: id, rootId,
214
198
  persona: request && request.persona,
@@ -428,24 +412,26 @@ function checkPromptSweep(prompt, owner, where) {
428
412
  // corpus recorder
429
413
  // ---------------------------------------------------------------
430
414
  const corpus = []
431
- // Normalise the workspace OUT of the corpus. A plain `split(WS)` is not enough on Windows:
415
+ // Normalise every workspace OUT of the corpus. A plain `split(WS)` is not enough on Windows:
432
416
  // the plugin renders paths with forward slashes while os.tmpdir() may hand back a different
433
417
  // CASE ("...\ADMIN\..." vs ".../admin/..."), so the absolute workspace path used to survive
434
418
  // into the SHIPPED corpus — non-deterministic (the temp dir changes every run) and a machine
435
- // path leak. Match case-insensitively, on either separator.
419
+ // path leak. Match case-insensitively, on either separator, for EVERY per-case workspace.
436
420
  const scrub = (s) => {
437
- const t = String(s == null ? '' : s).replace(/\\/g, '/')
438
- const ws = WS.replace(/\\/g, '/')
439
- const re = new RegExp(ws.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
421
+ let t = String(s == null ? '' : s).replace(/\\/g, '/')
440
422
  // Timestamps are part of the prompt a member reads, but not part of what a reviewer needs:
441
423
  // normalise them too. Otherwise the shipped corpus changes on EVERY run — its headings carry
442
424
  // `### YYYY-MM-DD hh:mm:ss|<member>` — and its diffs stop being meaningful.
443
425
  // The VibeMath ROOT gets its own token (not `<WS>/VibeMath`), so v5's corpus can be diffed
444
426
  // side by side with v2/v3/v4's, which render the same root as `<VIBEMATH>`.
445
- return t.replace(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/g, '<TIME>')
427
+ t = t.replace(/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2})?/g, '<TIME>')
446
428
  .replace(/[A-Za-z]:\/[^\s"'`)),。;:]*?[\\/]VibeMath/g, '<VIBEMATH>')
447
429
  .replace(/\/VibeMath/g, '<VIBEMATH>')
448
- .replace(re, '<WS>')
430
+ for (const ws of WORKSPACES) {
431
+ const re = new RegExp(ws.replace(/\\/g, '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
432
+ t = t.replace(re, '<WS>')
433
+ }
434
+ return t
449
435
  }
450
436
  // Every Lean tool mention in agent-facing text must be the REGISTERED name.
451
437
  // `Verified/Lean/` is a contract PATH, not a tool name, and on a case-insensitive reading it
@@ -521,6 +507,22 @@ for (const sp of founding) {
521
507
  }
522
508
  for (const sp of founding) { sp._ended = true; fireEnd(sp.childId, { progress: memberOfChild(sp.childId) + ':初始见解。', solved: false, contextPct: 10 }); await settle() }
523
509
  await settleInstitute(RA)
510
+ // ★ The persistence contract after the DSH 0.2.0 audit: institute state lives in the hardened
511
+ // JSON file State/<institute>.v5state.json and NOTHING is appended to the host session log. The
512
+ // old projection backend wrote `vibe5/*` events into the user's own session, and DSH's session
513
+ // persistence refuses to load a log with an unknown event type unless it is marked
514
+ // `ignorable: true` — which `Session.append` cannot set — so that session became unresumable.
515
+ {
516
+ assert(RA.session._events.length === 0,
517
+ '★ the plugin appends NOTHING to the host session log (' + RA.session._events.length + ' events after a full founding)')
518
+ const stateFile = join(wsOf(RA), 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
519
+ for (let i = 0; i < 40 && !existsSync(stateFile); i++) await settle()
520
+ assert(existsSync(stateFile), 'the institute state is on disk in State/institute.v5state.json')
521
+ const RB0 = makeRoot(wsOf(RA)) // a second root over the same workspace = a reload
522
+ const stReload = await callTool('vibe_v5_status', {}, RB0)
523
+ assert(stReload.members.length === 4, '★ a reload reads the 4-member roster back out of the JSON file (got ' + stReload.members.length + ')')
524
+ assert(RB0.session._events.length === 0, '★ the reloading root appends nothing to its session log either')
525
+ }
524
526
  await endCase(RA)
525
527
 
526
528
  // =============== CASE 2: round prompts (normal + checkpoint) =====================
@@ -901,7 +903,7 @@ for (const w of meetingOne) {
901
903
  await settleInstitute(RI)
902
904
  const stMtg = await callTool('vibe_v5_status', {}, RI)
903
905
  assert(stMtg.meeting === null, 'the meeting finished instead of deadlocking')
904
- const minutes = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'Meetings', 'mt-1.md')
906
+ const minutes = join(wsOf(RI), 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'Meetings', 'mt-1.md')
905
907
  assert(existsSync(minutes), 'the meeting minutes were written')
906
908
  if (existsSync(minutes)) {
907
909
  const t = readFileSync(minutes, 'utf8')
@@ -1,8 +1,10 @@
1
1
  // ============================================================
2
2
  // Vibe-Math-V5 SELF-DRIVING TEST — drives the REAL vibe-math-v5 plugin with a mock
3
- // host, including a faithful mock of the HOST-ONLY session projection unit it relies
4
- // on (register / eager fold on append / stateOf), so the projection storage path is
5
- // actually exercised rather than stubbed out.
3
+ // host. The host deliberately offers NO session services: v5 keeps its durable state in
4
+ // the hardened JSON file State/<institute>.v5state.json and appends NOTHING to the host
5
+ // session log (an unknown event type in the log makes the session unresumable, because
6
+ // DSH's session persistence refuses to load it unless the writer marked it `ignorable`
7
+ // — and `Session.append` cannot set that field).
6
8
  //
7
9
  // Run: node tests/selfdrive-v5.mjs (temp workspace; assertions; non-zero exit on failure)
8
10
  // ============================================================
@@ -20,52 +22,22 @@ const failures = []
20
22
  const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
21
23
  const sleep = ms => new Promise(r => setTimeout(r, ms))
22
24
 
23
- // ---------- mock host: projection registry ----------
24
- // Mirrors the contract the plugin depends on: register returns a disposer, every
25
- // committed event is folded EAGERLY into every unit's cell, stateOf materialises a
26
- // cell lazily with init().
27
- function makeProjectionRegistry() {
28
- const units = new Map()
29
- const cells = new Map()
30
- function cellMap(sess) {
31
- const id = String(sess.id)
32
- let m = cells.get(id)
33
- if (!m) { m = new Map(); cells.set(id, m) }
34
- return m
35
- }
36
- return {
37
- register(def) {
38
- if (!def || typeof def.key !== 'string') throw new Error('projection: key required')
39
- units.set(def.key, def)
40
- return () => { units.delete(def.key) }
41
- },
42
- stateOf(session, key) {
43
- const def = units.get(key)
44
- if (!def) return undefined
45
- const m = cellMap(session)
46
- if (!m.has(key)) m.set(key, def.init(session.header, session.inheritedEventCount || 0))
47
- return m.get(key)
48
- },
49
- checkpoint(session) {
50
- const out = {}
51
- const m = cellMap(session)
52
- for (const [k, def] of units) out[k] = { ver: def.stateVersion, seq: session.seq, val: m.get(k) }
53
- return out
54
- },
55
- _drive(session, event) {
56
- const m = cellMap(session)
57
- for (const [k, def] of units) {
58
- const cur = m.has(k) ? m.get(k) : def.init(session.header, session.inheritedEventCount || 0)
59
- let next
60
- try { next = def.apply(cur, event) } catch (e) { next = cur }
61
- m.set(k, next)
62
- }
63
- },
64
- _units: units,
25
+ // The durable authority. Reading it back is how this suite observes "committed": the
26
+ // plugin no longer writes anything a test could read out of the session log.
27
+ const V5STATE = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
28
+ const readV5State = () => { try { return JSON.parse(readFileSync(V5STATE, 'utf8')) } catch (e) { return null } }
29
+ // Commits are applied to the in-memory snapshot at once but WRITTEN through a deferred per-file
30
+ // chain, so a test that wants to observe a commit must wait for it to land on disk.
31
+ const waitV5State = async (pred, tries = 80) => {
32
+ let s = readV5State()
33
+ for (let i = 0; i < tries; i++) {
34
+ if (s && (!pred || pred(s))) return s
35
+ await sleep(10)
36
+ s = readV5State()
65
37
  }
38
+ return s
66
39
  }
67
40
 
68
- const projections = makeProjectionRegistry()
69
41
  const listeners = {}
70
42
  const toolRegs = []
71
43
  const commandRegs = []
@@ -75,6 +47,7 @@ const interrupts = []
75
47
  const drains = []
76
48
  const liveAgents = new Map()
77
49
 
50
+ // The session records whatever it is handed, so the suite can assert that it was handed NOTHING.
78
51
  function makeMockSession(id, parentSession) {
79
52
  const events = []
80
53
  const s = {
@@ -85,10 +58,9 @@ function makeMockSession(id, parentSession) {
85
58
  append(type, data) {
86
59
  const ev = { type, data, seq: events.length, time: Date.now() }
87
60
  events.push(ev)
88
- projections._drive(s, ev)
89
61
  return ev
90
62
  },
91
- deriveMessages() { return [] }, // non-surface events must never become messages
63
+ deriveMessages() { return [] }, // nothing may enter the model history
92
64
  snapshotEvents(from) { return events.slice(from || 0) },
93
65
  ownEvents() { return events.slice() },
94
66
  _events: events,
@@ -100,7 +72,6 @@ const ROOT = { id: 'sess-A', options: { provider: 'mock', model: 'm' }, session:
100
72
 
101
73
  const ctx = {
102
74
  get(name) {
103
- if (name === 'sessionProjections') return projections
104
75
  if (name === 'sandboxPolicy') return undefined
105
76
  if (name === 'compaction') return undefined
106
77
  if (name === 'subprocess') {
@@ -126,7 +97,6 @@ const ctx = {
126
97
  timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
127
98
  tools: { register(spec) { toolRegs.push(spec); return () => {} } },
128
99
  commands: { register(spec) { commandRegs.push(spec); return () => {} } },
129
- sessions: { async flush() { return true } },
130
100
  subagents: {
131
101
  list() { return ['spawn'] },
132
102
  async startContinuable({ label, request }) {
@@ -280,10 +250,20 @@ assert(spawns[0].persona.indexOf('分派') !== -1 && spawns[0].persona.indexOf('
280
250
  assert(spawns[0].persona.indexOf('m = 3') !== -1 || /至少有 m = \d+ 名有表决权者/.test(spawns[0].persona), 'charter states the m-vote rule with the live m')
281
251
 
282
252
  const st0 = await callTool('vibe_v5_status', {})
283
- assert(st0.backend === 'projection', 'durable state uses the session-projections backend (got ' + st0.backend + ')')
253
+ assert(st0.backend === 'file', 'durable state uses the hardened JSON backend (got ' + st0.backend + ')')
284
254
  assert(st0.members.length === 4 && st0.members.filter(m => m.kind === 'researcher').length === 3, 'status roster is 1 academician + 3 researchers')
285
- assert(ROOT_SESSION._events.some(e => String(e.type).indexOf('vibe5/') === 0), 'institute events are appended to the session log')
286
- assert(ROOT_SESSION.deriveMessages().length === 0, 'institute events never enter the model history (zero context cost)')
255
+ // ★ The whole point of the persistence fix: the plugin used to append `vibe5/*` events to the
256
+ // user's own session log, which made that session UNRESUMABLE (DSH refuses to load a log with an
257
+ // unknown event type unless it is marked `ignorable`, and `Session.append` cannot set that field).
258
+ // The state now lives in the JSON file, so the log must stay COMPLETELY empty.
259
+ assert(ROOT_SESSION._events.filter(e => String(e.type).indexOf('vibe5/') === 0).length === 0,
260
+ '★ the plugin appends NO institute events to the host session log')
261
+ assert(ROOT_SESSION._events.length === 0,
262
+ '★ the host session log is untouched by the whole founding (the user session stays resumable)')
263
+ assert(ROOT_SESSION.deriveMessages().length === 0, 'nothing enters the model history (zero context cost)')
264
+ const st0File = await waitV5State(s => s.institutes['default::institute'].members.length === 4)
265
+ assert(!!st0File && !!st0File.institutes['default::institute'], 'the institute state is on disk in State/institute.v5state.json')
266
+ assert(!!st0File && st0File.institutes['default::institute'].members.length === 4, 'the JSON state holds the same 4 members (got ' + (st0File ? st0File.institutes['default::institute'].members.length : 'none') + ')')
287
267
  assert(st0.quorum.m === 3 && st0.quorum.voterCount === 4, 'm = min(quorumCap=3, P=4) = 3 (got ' + st0.quorum.m + ')')
288
268
  assert(st0.members.every(m => m.busy === true), 'every member is marked in-flight while its founding turn runs')
289
269
 
@@ -429,8 +409,15 @@ if (existsSync(rosterMirror)) {
429
409
  }
430
410
  const boardMirror = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'TaskBoard.md')
431
411
  assert(existsSync(boardMirror), 'the human-readable task board mirror (Shared/TaskBoard.md) was written')
432
- assert(existsSync(join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'README.md')),
433
- 'State/ carries a README stating that the authoritative state is the session-log projection')
412
+ const stateReadme = join(WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'README.md')
413
+ assert(existsSync(stateReadme), 'State/ carries a README')
414
+ if (existsSync(stateReadme)) {
415
+ const readmeText = readFileSync(stateReadme, 'utf8')
416
+ assert(/权威状态/.test(readmeText) && /v5state\.json/.test(readmeText),
417
+ 'the State/ README names State/<研究所>.v5state.json as the authoritative state')
418
+ assert(/不在任何宿主会话日志里/.test(readmeText),
419
+ 'the State/ README states that the authority is NOT in any host session log')
420
+ }
434
421
 
435
422
  // ---------- the institute stops only on a unanimous solve vote ----------
436
423
  solvePlan = false
@@ -2594,13 +2594,17 @@ export function apply(ctx) {
2594
2594
  input: { hint: '[start|resume|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|project [list|new <name>|<name>]|decisions|agents]' },
2595
2595
  handler: async function (invocation) {
2596
2596
  const s = getSession(invocation && invocation.agent)
2597
- if (!s) return { kind: 'success', text: JSON.stringify({ ok: false, error: 'no vibe-math session for this agent' }) }
2597
+ if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no vibe-math session for this agent' }) }
2598
2598
  const line = String(invocation && invocation.rawInput ? invocation.rawInput : '').trim()
2599
2599
  const parts = line.length > 0 ? line.split(/\s+/) : []
2600
2600
  const cmd = parts[0] || ''
2601
2601
  const rest = parts.slice(1)
2602
2602
  const result = await s.dispatchVibeCommand(cmd, rest)
2603
- return { kind: 'success', text: JSON.stringify(result, null, 2) }
2603
+ // A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
2604
+ // CommandResult union distinguishes success from error, and returning 'success' made a rejected
2605
+ // invocation look identical to a successful one in the UI.
2606
+ const failed = result !== null && typeof result === 'object' && result.ok === false
2607
+ return { kind: failed ? 'error' : 'success', text: JSON.stringify(result, null, 2) }
2604
2608
  },
2605
2609
  }))
2606
2610
 
@@ -3772,13 +3772,17 @@ export function apply(ctx) {
3772
3772
  input: { hint: '[start|resume|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|methods|index|plan|lock|project [list|new <name>|<name>]|decisions|agents]' },
3773
3773
  handler: async function (invocation) {
3774
3774
  const s = getSession(invocation && invocation.agent)
3775
- if (!s) return { kind: 'success', text: JSON.stringify({ ok: false, error: 'no vibe-math session for this agent' }) }
3775
+ if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no vibe-math session for this agent' }) }
3776
3776
  const line = String(invocation && invocation.rawInput ? invocation.rawInput : '').trim()
3777
3777
  const parts = line.length > 0 ? line.split(/\s+/) : []
3778
3778
  const cmd = parts[0] || ''
3779
3779
  const rest = parts.slice(1)
3780
3780
  const result = await s.dispatchVibeCommand(cmd, rest)
3781
- return { kind: 'success', text: JSON.stringify(result, null, 2) }
3781
+ // A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
3782
+ // CommandResult union distinguishes success from error, and returning 'success' made a rejected
3783
+ // invocation look identical to a successful one in the UI.
3784
+ const failed = result !== null && typeof result === 'object' && result.ok === false
3785
+ return { kind: failed ? 'error' : 'success', text: JSON.stringify(result, null, 2) }
3782
3786
  },
3783
3787
  }))
3784
3788