dsh-vibe-math 2.7.1 → 2.7.3

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 (114) hide show
  1. package/README.en.md +682 -690
  2. package/README.md +19 -27
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +744 -518
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/final-paper.md +172 -172
  8. package/docs/formal-verification.md +588 -588
  9. package/docs/math-computation.md +194 -163
  10. package/docs/parameter-schema.md +37 -0
  11. package/docs/release-notes/README.en.md +49 -0
  12. package/docs/release-notes/README.md +48 -0
  13. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  16. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  17. package/docs/release-notes/TEMPLATE.md +126 -0
  18. package/docs/status-report-fields.md +167 -0
  19. package/docs/test-timing.md +157 -154
  20. package/installer.js +27 -17
  21. package/package.json +55 -3
  22. package/prompt-corpus-persona/persona-corpus.json +9 -9
  23. package/prompt-corpus-persona/persona-corpus.md +225 -33
  24. package/prompt-corpus-v2/formal-verify-v2.json +305 -113
  25. package/prompt-corpus-v2/formal-verify-v2.md +2399 -279
  26. package/prompt-corpus-v3/formal-verify-v3.json +134 -110
  27. package/prompt-corpus-v3/formal-verify-v3.md +409 -141
  28. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  29. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  30. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  31. package/prompt-corpus-v5/prompt-corpus-v5.md +513 -259
  32. package/scripts/update-doc-counts.mjs +71 -0
  33. package/tests/audit-artifact-docs.mjs +72 -0
  34. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  35. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  36. package/tests/audit-installer-compat.test.mjs +52 -4
  37. package/tests/audit-installer-policy.test.mjs +41 -8
  38. package/tests/audit-math-computation-contract.mjs +107 -9
  39. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  40. package/tests/audit-math-computation-parity.mjs +164 -3
  41. package/tests/audit-math-computation-sensitivity.mjs +6 -4
  42. package/tests/audit-package-membership.mjs +57 -0
  43. package/tests/audit-package-membership.mutants.mjs +28 -0
  44. package/tests/audit-participant-set-parity.mjs +160 -0
  45. package/tests/audit-path-discipline.mjs +83 -0
  46. package/tests/audit-path-discipline.mutants.mjs +51 -0
  47. package/tests/audit-persona-sensitivity.mjs +14 -0
  48. package/tests/audit-persona-surface.mutants.mjs +69 -0
  49. package/tests/audit-persona-surface.test.mjs +63 -22
  50. package/tests/audit-prompt-duplication.mjs +143 -0
  51. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  52. package/tests/audit-prompt-invariants.mjs +43 -5
  53. package/tests/audit-readme-bilingual.test.mjs +152 -0
  54. package/tests/audit-readme-counts.mjs +95 -0
  55. package/tests/audit-readme-counts.mutants.mjs +52 -0
  56. package/tests/audit-registration.mjs +78 -0
  57. package/tests/audit-status-report-fields.mjs +172 -0
  58. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  59. package/tests/audit-v3-registration-parity.mjs +156 -0
  60. package/tests/audit-v5-integrity.mjs +300 -13
  61. package/tests/audit-v5-lean-abstention.mjs +392 -0
  62. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  63. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  64. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  65. package/tests/audit-v5-sensitivity.mjs +15 -8
  66. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  67. package/tests/e2e-v5-round2.test.mjs +566 -12
  68. package/tests/formal-verify-v2.test.mjs +91 -5
  69. package/tests/formal-verify-v3.mutants.mjs +117 -0
  70. package/tests/formal-verify-v3.test.mjs +85 -6
  71. package/tests/formal-verify-v4.mutants.mjs +152 -0
  72. package/tests/formal-verify-v4.test.mjs +628 -9
  73. package/tests/formal-verify-v5.test.mjs +68 -9
  74. package/tests/helpers/doc-counts-format.mjs +41 -0
  75. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  76. package/tests/math-computation-a1.mutants.mjs +72 -0
  77. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  78. package/tests/math-computation-discovery.mutants.mjs +76 -0
  79. package/tests/math-computation-shared.test.mjs +396 -2
  80. package/tests/math-computation-v2.test.mjs +767 -0
  81. package/tests/math-computation-v3.test.mjs +759 -0
  82. package/tests/math-computation-v4.mutants.mjs +133 -0
  83. package/tests/math-computation-v4.test.mjs +739 -0
  84. package/tests/prompt-v5-integrity.test.mjs +13 -2
  85. package/tests/run-tests.mjs +139 -9
  86. package/tests/run-tests.mutants.mjs +70 -0
  87. package/tests/selfdrive-v5.mjs +53 -1
  88. package/tests/selfdrive-v5.mutants.mjs +132 -0
  89. package/tests/v2-fix-probes.mutants.mjs +100 -0
  90. package/tests/v2-fix-probes.test.mjs +1039 -0
  91. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  92. package/tests/v3-fix-probes.mutants.mjs +95 -0
  93. package/tests/v3-fix-probes.test.mjs +891 -0
  94. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  95. package/vibe-math-v2/agent.cordis.yml +62 -6
  96. package/vibe-math-v2/math-computation.js +229 -29
  97. package/vibe-math-v2/math-engines.js +12 -12
  98. package/vibe-math-v2/vibe-math-v2.js +458 -119
  99. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  100. package/vibe-math-v3/agent.cordis.yml +72 -6
  101. package/vibe-math-v3/math-computation.js +229 -29
  102. package/vibe-math-v3/math-engines.js +12 -12
  103. package/vibe-math-v3/vibe-math-v3.js +482 -139
  104. package/vibe-math-v4/agent.cordis.yml +44 -8
  105. package/vibe-math-v4/math-computation.js +229 -29
  106. package/vibe-math-v4/math-engines.js +12 -12
  107. package/vibe-math-v4/vibe-math-v4.js +245 -52
  108. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  109. package/vibe-math-v5/agent.cordis.yml +46 -12
  110. package/vibe-math-v5/math-computation.js +229 -29
  111. package/vibe-math-v5/math-engines.js +12 -12
  112. package/vibe-math-v5/vibe-math-v5.js +1307 -243
  113. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1655
  114. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -22,11 +22,16 @@ import { tmpdir } from 'node:os'
22
22
  import { join, dirname, isAbsolute } from 'node:path'
23
23
 
24
24
  // round-7 (fix 2) test isolation: engine discovery now also scans DSH's own runtime trees, and this
25
- // machine HAS a bundled python. This suite injects ONE fake subprocess for everything (LaTeX + math),
26
- // so an unexpected engine probe would be recorded as a compiler call and would even write a fake
27
- // paper.pdf. Pinning DSH_HOME at an empty dir keeps the fixture's premise ("no math engine here")
28
- // true and the suite deterministic; production behaviour is unaffected.
29
- process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'vibe-v5-e2e-nodsh-'))
25
+ // machine HAS a bundled python. round-9 adds the known per-OS INSTALL dirs, and this machine now really
26
+ // has python/R installed. This suite injects ONE fake subprocess for everything (LaTeX + math), so an
27
+ // unexpected engine probe would be recorded as a compiler call and would even write a fake paper.pdf.
28
+ // Pinning all of those roots at an empty dir keeps the fixture's premise ("no math engine here") true
29
+ // and the suite deterministic; production behaviour is unaffected.
30
+ const EMPTY_ENGINE_ROOT = mkdtempSync(join(tmpdir(), 'vibe-v5-e2e-nodsh-'))
31
+ process.env.DSH_HOME = EMPTY_ENGINE_ROOT
32
+ process.env.ProgramFiles = EMPTY_ENGINE_ROOT
33
+ process.env['ProgramFiles(x86)'] = EMPTY_ENGINE_ROOT
34
+ process.env.LOCALAPPDATA = EMPTY_ENGINE_ROOT
30
35
 
31
36
  // V5_PLUGIN lets a sensitivity probe point this suite at a deliberately broken copy.
32
37
  // Without it every probe against this suite silently tested the UNMUTATED plugin and was
@@ -66,7 +71,24 @@ function makeSession(id, parentSession) {
66
71
  function makeHost(opts) {
67
72
  const o = opts || {}
68
73
  const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
69
- const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
74
+ const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = [], sendAttempts = []
75
+ // Successful sends that SURVIVE consumption: `peekWakeOf` splices items out of `wakes`, so a probe that
76
+ // must compare "rounds" against "successful wakes" needs a counter that is never drained.
77
+ let wakeSends = 0
78
+ // G1 seam: force every `subagents.sendMessage` to throw (a real host failure / a member whose
79
+ // continuation is gone). `sendAttempts` records that the wake was TRIED, so a probe can tell
80
+ // "the wake failed" apart from "no wake was scheduled at all".
81
+ let failSend = !!o.failSendMessage
82
+ // spawnMember seam: force every `subagents.startContinuable` to throw (the host cap / a host outage).
83
+ // `startAttempts` records that founding was TRIED, so a probe can tell "the start failed" apart from
84
+ // "no start was attempted".
85
+ let failStart = !!o.failStartContinuable
86
+ const startAttempts = []
87
+ // F6 seam: refuse writes whose resolved path matches (the v4 paper suite's G3 seam, same shape), and —
88
+ // optionally — whose CONTENT matches as well. Every PROVIDED predicate must match, so a probe can target
89
+ // the FINALIZE-time append to paper.log.md without touching the ~15 log writes the flow makes earlier.
90
+ let failWriteOn = o.failWriteOn || null
91
+ let failWriteContent = o.failWriteContent || null
70
92
  // Every effect disposer, so a test can simulate a plugin UNLOAD (the Lean queue's disposer is
71
93
  // registered first: it terminates in-flight compiles and marks them interrupted).
72
94
  const effectDisposers = []
@@ -106,13 +128,26 @@ function makeHost(opts) {
106
128
  subagents: {
107
129
  list() { return ['spawn'] },
108
130
  async startContinuable({ label, request }) {
131
+ startAttempts.push(label)
132
+ if (failStart) {
133
+ // The HOST-CAP shape (not a generic error): `spawnMember` maps it to ACTIVATION_LIMIT_REACHED and
134
+ // the founding loop QUEUES the member, which is the path a healed host retries.
135
+ const e = new Error('subagent limit reached (active child limit: 2); wait for an existing child to finish or complete this work with the current agents')
136
+ e.code = 'ACTIVATION_LIMIT_REACHED'
137
+ throw e
138
+ }
109
139
  const id = 'c' + (spawns.length + 1)
110
140
  const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
111
141
  liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
112
142
  spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
113
143
  return { childId: id, messageId: 'm' + spawns.length }
114
144
  },
115
- async sendMessage(parent, childId, blocks) { wakes.push({ childId, blocks }); return 'w' + wakes.length },
145
+ async sendMessage(parent, childId, blocks) {
146
+ sendAttempts.push(childId)
147
+ if (failSend) throw new Error('mock sendMessage failure (G1 seam)')
148
+ wakeSends += 1
149
+ wakes.push({ childId, blocks }); return 'w' + wakes.length
150
+ },
116
151
  interrupt(childId) { interrupts.push(childId) },
117
152
  async drainContinuableChildren(parent, ids) { drains.push(...ids); for (const i of ids) liveAgents.delete(i) },
118
153
  },
@@ -125,7 +160,15 @@ function makeHost(opts) {
125
160
  },
126
161
  async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
127
162
  async readText(t) { return readFileSync(t.targetKey, 'utf8') },
128
- async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
163
+ async writeText(t, c) {
164
+ // F6 seam: a REAL fs refusal (sandbox denial) rejects, which `writeTextAbs` catches and reports as
165
+ // "not written" — the exact shape a failed required artifact write has in production.
166
+ const refuse = (failWriteOn || failWriteContent) &&
167
+ (!failWriteOn || failWriteOn.test(String(t.targetKey))) &&
168
+ (!failWriteContent || failWriteContent.test(String(c)))
169
+ if (refuse) throw new Error('F6_WRITE_FAIL (injected): ' + t.targetKey)
170
+ mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8')
171
+ },
129
172
  async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
130
173
  },
131
174
  }
@@ -223,7 +266,7 @@ function makeHost(opts) {
223
266
  }
224
267
  return null
225
268
  }
226
- return { WS, ctx, ROOT, ROOT_SESSION, removedServiceQueries, spawns, wakes, interrupts, drains, toolRegs, commandRegs, listeners, effectDisposers, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, peekWakeWhere, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
269
+ return { WS, ctx, ROOT, ROOT_SESSION, removedServiceQueries, spawns, wakes, interrupts, drains, sendAttempts, startAttempts, setFailSend(v) { failSend = !!v }, setFailStart(v) { failStart = !!v }, setFailWriteOn(v) { failWriteOn = v || null }, setFailWriteContent(v) { failWriteContent = v || null }, get wakeSends() { return wakeSends }, toolRegs, commandRegs, listeners, effectDisposers, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, peekWakeWhere, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
227
270
  }
228
271
 
229
272
  const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
@@ -405,6 +448,20 @@ console.log('\n[1] the hardened JSON backend (host with no session services at a
405
448
  const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
406
449
  const inst = parsed.institutes['default::institute']
407
450
  assert(!!inst && inst.members.length === 3, 'the persisted state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
451
+ // MEASURED (R14, FULL runs): under the A5-ids and A5-kinds mutants BOTH count assertions stay GREEN
452
+ // (0 red) while the new ID-SET / KIND assertion necessarily reddens; those runs show 82 / 21 reds
453
+ // overall, and the remaining ones are the CASCADE of the same mutation (downstream cases reference
454
+ // the id prefix or the kind). So the claim is NOT "only the new assertion reddens".
455
+ // A5: the COUNT is only a lead - assert the semantic unit too. The persisted roster must be
456
+ // exactly {acad, r-1, r-2} by ID and {academician:1, researcher:2} by KIND, so a founding that
457
+ // minted a duplicate id (the allocator returning the same value twice), founded a researcher with
458
+ // the academician kind, or dropped/added a member while still totalling 3 now FAILS.
459
+ const ids = inst.members.map((m) => String(m.id)).sort()
460
+ assert(ids.join(',') === ['acad', 'r-1', 'r-2'].sort().join(','), '* A5 the founded ID SET is exactly {acad, r-1, r-2} (got ' + JSON.stringify(ids) + ')')
461
+ const kinds = {}
462
+ for (const m of inst.members) kinds[String(m.kind)] = (kinds[String(m.kind)] || 0) + 1
463
+ const kindList = Object.keys(kinds).sort().map((k) => k + ':' + kinds[k])
464
+ assert(JSON.stringify(kindList) === JSON.stringify(['academician:1', 'researcher:2']), '* A5 the founded KIND MULTISET is exactly {academician:1, researcher:2} (got ' + JSON.stringify(kinds) + ')')
408
465
  }
409
466
  assert(h.ROOT_SESSION._events.length === 0, 'the whole founding wrote NOTHING to the host session log (' + h.ROOT_SESSION._events.length + ' events)')
410
467
  }
@@ -784,12 +841,49 @@ console.log('\n[15] the host live-child cap (ACTIVATION_LIMIT_REACHED) is named,
784
841
  assert(hired.ok === false && hired.code === 'ACTIVATION_LIMIT_REACHED', 'hire reports the host cap by its typed code (' + JSON.stringify(hired).slice(0, 160) + ')')
785
842
  assert(/maxActiveSubagents/.test(hired.message || ''), 'the hire failure names maxActiveSubagents, not the opaque host string')
786
843
  assert(hostCalls === callsBefore, 'the host was NOT asked again once the ceiling was known (skip-before-spawn; hostCalls=' + hostCalls + ')')
787
- // The refused work is QUEUED, not lost: freeing a slot (fire a member) lets the next round retry.
844
+ // The refused work is QUEUED, not lost. G-6: freeing a slot is now picked up by the framework's
845
+ // OWN scheduling pass (`retryPendingSpawns()` is the first line of `schedulePass`), so the
846
+ // PRIMARY assertion here is the TERMINAL STATE — timing-independent, path-independent:
847
+ // baseline: the product's OWN derived queue, snapshotted immediately before the fire
848
+ const preAuto = await h.callTool('vibe_v5_status', {})
849
+ const queuedBeforeAuto = (preAuto.pendingSpawns || []).map(p => p.id)
850
+ assert(queuedBeforeAuto.length >= 1, 'precondition: deferred members are queued before the automatic case (' + JSON.stringify(queuedBeforeAuto) + ')')
788
851
  await h.callTool('vibe_v5_fire', { id: 'r-1', reason: 'cap test' })
789
- const resumed = await h.callTool('vibe_v5_resume', {})
790
- assert(resumed.ok === true && resumed.respawned >= 1, 'a slot freed by firing a member lets resume rebuild a capped member (respawned=' + (resumed && resumed.respawned) + ')')
852
+ await h.settleSpawns()
791
853
  const st2 = await h.callTool('vibe_v5_status', {})
792
854
  assert(st2.members.filter(m => m.phase === 'active' && m.childId).length === CAP, 'the institute is back at the host ceiling (active=' + st2.members.filter(m => m.phase === 'active').map(m => m.id).join(',') + ')')
855
+ // ONE freed slot is spent on the deferred work: the queue SHRINKS by exactly one (more members may
856
+ // be refused than a single freed slot can take — the rest stay queued, visibly).
857
+ const pendingAfterAuto = (st2.pendingSpawns || []).map(p => p.id)
858
+ const rebuiltByAuto = queuedBeforeAuto.filter(id => pendingAfterAuto.indexOf(id) === -1)
859
+ assert(rebuiltByAuto.length === 1 && pendingAfterAuto.length === queuedBeforeAuto.length - 1,
860
+ '★ the freed capacity was spent on exactly ONE deferred member (queued=' + JSON.stringify(queuedBeforeAuto) + ' → pending=' + JSON.stringify(pendingAfterAuto) + ', rebuilt=' + JSON.stringify(rebuiltByAuto) + ')')
861
+ assert(rebuiltByAuto.every(id => (st2.members.find(m => m.id === id) || {}).phase === 'active'),
862
+ '★ the rebuilt deferred member is ACTIVE (' + JSON.stringify(rebuiltByAuto) + ')')
863
+ // The MANUAL `resume` rebuild path stays pinned too — with the automatic retry disabled by the
864
+ // documented test seam, `resume` is the only thing that can rebuild, so this cannot race the
865
+ // scheduler (a disjunction accepting either path would be fine ONLY next to the terminal check).
866
+ process.env.V5_SPAWN_RETRY = 'manual'
867
+ try {
868
+ const beforeManual = await h.callTool('vibe_v5_status', {})
869
+ const queuedBeforeManual = (beforeManual.pendingSpawns || []).map(p => p.id)
870
+ assert(queuedBeforeManual.length >= 1, 'precondition: at least one deferred member is queued before the manual case (' + JSON.stringify(queuedBeforeManual) + ')')
871
+ // free exactly one slot; with the seam on, NOTHING rebuilds it — that is what makes the next
872
+ // assertion attributable to `resume` alone
873
+ const victim = beforeManual.members.find(m => m.phase === 'active' && m.childId)
874
+ await h.callTool('vibe_v5_fire', { id: victim.id, reason: 'manual-resume case' })
875
+ await h.settleSpawns()
876
+ const afterFree = await h.callTool('vibe_v5_status', {})
877
+ assert((afterFree.pendingSpawns || []).length === queuedBeforeManual.length,
878
+ '★ with the automatic retry disabled the freed slot leaves the queue UNTOUCHED (' + JSON.stringify((afterFree.pendingSpawns || []).map(p => p.id)) + ')')
879
+ const resumed = await h.callTool('vibe_v5_resume', {})
880
+ assert(resumed.ok === true && resumed.respawned >= 1,
881
+ 'the MANUAL resume path rebuilds a deferred member on its own (respawned=' + (resumed && resumed.respawned) + ')')
882
+ const st3 = await h.callTool('vibe_v5_status', {})
883
+ const rebuilt = queuedBeforeManual.filter(id => (st3.members.find(m => m.id === id) || {}).phase === 'active')
884
+ assert(rebuilt.length >= 1, '★ the member `resume` was asked to rebuild is ACTIVE now (' + JSON.stringify(rebuilt) + ')')
885
+ assert(st3.members.filter(m => m.phase === 'active' && m.childId).length >= CAP, 'after the manual resume the terminal state holds the ceiling (active=' + st3.members.filter(m => m.phase === 'active').map(m => m.id).join(',') + ')')
886
+ } finally { delete process.env.V5_SPAWN_RETRY }
793
887
  }
794
888
 
795
889
  // ---------- 16. a state file that was never loaded must never be overwritten (audit H1) --------
@@ -1024,6 +1118,24 @@ console.log('\n[21] office-only tools refuse a caller that is neither a member n
1024
1118
  const ghostRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, ghost)
1025
1119
  assert(ghostRm.ok === false && ghostRm.code === 'V5_MEMBER_NOT_FOUND',
1026
1120
  'vibe_v5_remove_researcher is refused for the unidentifiable caller (' + JSON.stringify(ghostRm).slice(0, 90) + ')')
1121
+ // ---- 6.4: the DIAGNOSIS, not just the code -------------------------------------------------
1122
+ const ghostRec = await h.callTool('vibe_v5_record_progress', { content: 'x' }, ghost)
1123
+ assert(ghostRec.ok === false && ghostRec.code === 'V5_MEMBER_NOT_FOUND' && ghostRec.next && ghostRec.next.kind === 'member-call' && ghostRec.next.tool === 'vibe_v5_members',
1124
+ '* 6.4 branch 3 (unidentifiable caller / the office has no member identity): code + next{kind:member-call, tool:vibe_v5_members} (' + JSON.stringify(ghostRec).slice(0, 120) + ')')
1125
+ assert(typeof ghostRec.message === 'string' && /\u6240\u529e|\u6210\u5458/.test(ghostRec.message),
1126
+ '* 6.4 branch 3 EXPLAINS the office/member distinction instead of a bare refusal (' + String(ghostRec.message).slice(0, 90) + ')')
1127
+ const neverH = makeHost({ pluginModule })
1128
+ const neverStarted = await neverH.callTool('vibe_v5_record_progress', { content: 'x' })
1129
+ assert(neverStarted.ok === false && neverStarted.code === 'V5_MEMBER_NOT_FOUND' && neverStarted.next && neverStarted.next.kind === 'start' && neverStarted.next.tool === 'vibe_v5_start',
1130
+ '* 6.4 branch 1 (the institute was never started): code + next{kind:start, tool:vibe_v5_start} (' + JSON.stringify(neverStarted).slice(0, 120) + ')')
1131
+ // Branches 2 (running, zero active) and 4 (id no longer on the roster) are not reachable through
1132
+ // the tool surface from this harness, so they are pinned STRUCTURALLY (each branch with its own
1133
+ // next{} kind), while branches 1 and 3 above are pinned BEHAVIOURALLY.
1134
+ const src5 = readFileSync(PLUGIN, 'utf8')
1135
+ for (const k of ['start', 'staff', 'member-call', 'roster']) {
1136
+ assert(new RegExp("kind: '" + k + "'").test(src5), '* 6.4 memberDiagnosis branch ' + k + ' exists with its own next{} kind')
1137
+ }
1138
+ assert(/vibe_v5_start/.test(src5) && /vibe_v5_add_researcher/.test(src5), '* 6.4 the start/staff branches point at real tools (vibe_v5_start / vibe_v5_add_researcher)')
1027
1139
  const memberRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, h.childAgent(h.childOf('r-1')))
1028
1140
  assert(memberRm.ok === false && memberRm.code === 'V5_NOT_OFFICE',
1029
1141
  '...and refused for a member (the tool says Office only), not just for ghosts (' + JSON.stringify(memberRm).slice(0, 90) + ')')
@@ -1865,6 +1977,448 @@ console.log('\n[42] every Lean prompt site obeys the leanInitiative gate in both
1865
1977
  assert(s3on.indexOf(DAILY) === -1, 'S3 verifyPrompt: …and the daily line is still absent there')
1866
1978
  }
1867
1979
 
1980
+ // ---------- 43. pre-start diagnosis + coherent quorum (architecture self-test defects 1/2) -------
1981
+ // The self-test (§18 of _oneoff/slv-playbook.md(原 method3-scripted-driving.md)) found that before `start`:
1982
+ // (1) record_progress / record_proposition / verdict answered a BARE `V5_MEMBER_NOT_FOUND`
1983
+ // with no hint about why or what to do first;
1984
+ // (2) `vibe_v5_status` reported `quorum.m=1` with `voters=0` — reading like a satisfied quorum.
1985
+ // Both are asserted here, INCLUDING the actionable `next` step, and the normal (post-start) path
1986
+ // is exercised in the same section so a follow-up self-test can see a non-error path.
1987
+ console.log('\n[43] pre-start tools explain themselves; the quorum is coherent before and after start')
1988
+ {
1989
+ const h = makeHost({ pluginModule })
1990
+ const pre = async (name, args) => await h.callTool(name, args, h.ROOT)
1991
+ const rProg = await pre('vibe_v5_record_progress', { content: '自我测试' })
1992
+ assert(rProg.ok === false && rProg.code === 'V5_MEMBER_NOT_FOUND' && /尚未启动/.test(String(rProg.message || '')) &&
1993
+ rProg.next && rProg.next.kind === 'start' && rProg.next.tool === 'vibe_v5_start',
1994
+ '★ pre-start record_progress: the code is unchanged but the answer names the state + the next step (' + JSON.stringify(rProg).slice(0, 200) + ')')
1995
+ const rProp = await pre('vibe_v5_record_proposition', { statement: 's', value: 1, motive: 'm', p: 0.5 })
1996
+ assert(rProp.ok === false && rProp.code === 'V5_MEMBER_NOT_FOUND' && /尚未启动/.test(String(rProp.message || '')) && rProp.next && rProp.next.kind === 'start',
1997
+ '★ pre-start record_proposition: same actionable diagnosis (' + JSON.stringify(rProp).slice(0, 160) + ')')
1998
+ const rVer = await pre('vibe_v5_verdict', { target: 'p-x', verdict: 1 })
1999
+ assert(rVer.ok === false && rVer.code === 'V5_MEMBER_NOT_FOUND' && /尚未启动/.test(String(rVer.message || '')) && rVer.next && rVer.next.kind === 'start',
2000
+ '★ pre-start verdict: same actionable diagnosis (' + JSON.stringify(rVer).slice(0, 160) + ')')
2001
+ const st0 = await pre('vibe_v5_status', {})
2002
+ assert(st0.quorum && st0.quorum.started === false && st0.quorum.m === 0 && st0.quorum.voterCount === 0 && st0.quorum.phase === 'not-started',
2003
+ '★ pre-start quorum is COHERENT: started=false, m=0, voters=0 (' + JSON.stringify(st0.quorum) + ')')
2004
+ assert(st0.quorum.next && st0.quorum.next.kind === 'start' && st0.quorum.next.tool === 'vibe_v5_start',
2005
+ '★ …and it says what to do first (' + JSON.stringify(st0.quorum.next) + ')')
2006
+ assert(st0.params && st0.params.m === 0 && st0.params.started === false,
2007
+ 'the params view agrees: m=0, started=false (' + JSON.stringify({ m: st0.params && st0.params.m, started: st0.params && st0.params.started }) + ')')
2008
+ assert(st0.quorum.m !== 1 || st0.quorum.voterCount !== 0,
2009
+ 'the old inconsistent `m=1 with voters=0` reading is gone')
2010
+ // ── the NORMAL path: after start, quorum is defined and the member tools work ──
2011
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2012
+ await h.settleSpawns()
2013
+ const st1 = await h.callTool('vibe_v5_status', {})
2014
+ assert(st1.quorum && st1.quorum.started === true && st1.quorum.m >= 1 && st1.quorum.voterCount >= 1 && st1.quorum.voters.length === st1.quorum.voterCount,
2015
+ '★ after start the quorum is real: started=true, m>=1, voters listed (' + JSON.stringify(st1.quorum) + ')')
2016
+ const member = h.childAgent(h.childOf('r-1'))
2017
+ const okProg = await h.callTool('vibe_v5_record_progress', { content: '启动后写进度' }, member)
2018
+ assert(okProg.ok === true && /Members\/r-1\/Progress/.test(String(okProg.file || '')),
2019
+ '★ after start the SAME tool succeeds for a member (normal path reachable) (' + JSON.stringify(okProg).slice(0, 120) + ')')
2020
+ }
2021
+
2022
+ // ---------- 44. workspace containment + single-sourced quorum (3rd self-test defects A/B) --------
2023
+ // Defect A: member-facing text said the library root was `Members/<id>/` while a member's OWN
2024
+ // file tools resolve relative paths against the SESSION CWD ⇒ a stray `<cwd>/Members/acad/Propos/
2025
+ // p-*.md` appeared outside `VibeMath/Projects/…`. Every member-facing path is now CWD-relative,
2026
+ // and this section proves that a member card write cannot land outside the project tree.
2027
+ // Defect B: the office digest said m=2 while status/acad said m=3 in the same window (two roster
2028
+ // snapshots). Every view now reads ONE snapshot and carries `rosterVersion`, so all views at one
2029
+ // point in time must agree — including after a roster change.
2030
+ console.log('\n[44] paths stay inside the project root; every quorum view agrees')
2031
+ {
2032
+ const h = makeHost({ pluginModule })
2033
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2034
+ // (A1) the member brief must name the CWD-relative library root (the member's real baseline).
2035
+ const brief = h.spawns.map((sp) => JSON.stringify(sp.request || {})).join('\n')
2036
+ assert(brief.indexOf('VibeMath/Projects/default/Institutes/institute/Members/') !== -1,
2037
+ '★ the member brief carries the CWD-relative library root (' + (brief.match(/Members\/[^"\\]{0,40}/) || [''])[0] + ')')
2038
+ // The contract is ROOT-QUALIFIED declarations: the brief names the CWD-relative root (A1 above)
2039
+ // AND every library declaration it offers carries the member root (`Members/<你>/<section>/…`), so a
2040
+ // member copying one composes a path inside `Members/<id>/`. The legacy institute-relative shapes
2041
+ // (`Progress/<你>/…` and friends) must be gone - those used to send the write outside the member tree.
2042
+ const decls = (brief.match(/Members\/<[^>]*>\/(?:Progress|Propos|Methods|Subproblems)\//g) || [])
2043
+ assert(decls.length >= 4,
2044
+ '* every member-facing library declaration is root-qualified (Members/<你>/<section>/…) (found ' + decls.length + ')')
2045
+ const legacy = (brief.match(/(?:^|[^A-Za-z0-9/_.-])(?:Progress|Propos|Methods|Subproblems)\/<你>\//g) || [])
2046
+ assert(legacy.length === 0,
2047
+ 'no member-facing declaration is left in the legacy institute-relative shape (' + JSON.stringify(legacy) + ')')
2048
+ await h.settleSpawns()
2049
+ // (A2) a member card write must not add anything at the workspace TOP level.
2050
+ const topBefore = readdirSync(h.WS).sort().join(',')
2051
+ const card = await h.callTool('vibe_v5_record_proposition', { id: 'p-stray-check', statement: '包层测试', value: 1, motive: 'm', p: 0.5 }, h.childAgent(h.childOf('r-1')))
2052
+ const topAfter = readdirSync(h.WS).sort().join(',')
2053
+ assert(card.ok === true && /Members\/r-1\/Propos\/p-stray-check\.md/.test(String(card.file || '')),
2054
+ 'the member card is written through the v5 tool (' + JSON.stringify(card).slice(0, 120) + ')')
2055
+ assert(existsSync(join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Members', 'r-1', 'Propos', 'p-stray-check.md')),
2056
+ '…and it exists under VibeMath/Projects/<project>/…')
2057
+ assert(topAfter === topBefore && !existsSync(join(h.WS, 'Members')),
2058
+ '★ NO path outside VibeMath/Projects/… gained files (top level unchanged: ' + topBefore + ' → ' + topAfter + ')')
2059
+ // (B) every quorum view agrees at one point in time, and they move TOGETHER after a hire.
2060
+ const views = async () => {
2061
+ const st = await h.callTool('vibe_v5_status', {})
2062
+ const rp = await h.callTool('vibe_v5_report', {})
2063
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: '请继续推进。' }, h.childAgent(h.childOf('acad')))
2064
+ const w = await h.peekWakeOf('r-1', 3000)
2065
+ const pm = w ? /法定票数 m=(\d+)/.exec(w.text) : null
2066
+ if (w) h.fireEnd(w.childId, { progress: '收到。', solved: false, contextPct: 10 })
2067
+ await sleep(20)
2068
+ return { st, rp, promptM: pm ? Number(pm[1]) : null }
2069
+ }
2070
+ const v1 = await views()
2071
+ assert(v1.st.quorum && v1.rp.quorum && v1.st.quorum.m === v1.rp.quorum.m,
2072
+ '★ status and report agree on m at one point in time (' + JSON.stringify({ status: v1.st.quorum && v1.st.quorum.m, report: v1.rp.quorum && v1.rp.quorum.m }) + ')')
2073
+ assert(v1.st.quorum.rosterVersion === v1.rp.quorum.rosterVersion,
2074
+ 'both views carry the SAME rosterVersion (' + JSON.stringify({ s: v1.st.quorum.rosterVersion, r: v1.rp.quorum.rosterVersion }) + ')')
2075
+ assert(v1.promptM === v1.st.quorum.m,
2076
+ 'the member-facing state block shows the same m (' + JSON.stringify({ prompt: v1.promptM, status: v1.st.quorum.m }) + ')')
2077
+ // A roster change must reach EVERY view (the stale-snapshot bug). Dismissing a permanent
2078
+ // researcher shrinks the voter set synchronously, so m MUST move in every view at once.
2079
+ const rm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' })
2080
+ assert(rm.ok === true, 'precondition: the roster change succeeded (' + JSON.stringify(rm).slice(0, 140) + ')')
2081
+ await sleep(30)
2082
+ const v2 = await views()
2083
+ assert(v2.st.quorum.m === v2.rp.quorum.m,
2084
+ '★ after the roster changes, status and report STILL agree (status=' + v2.st.quorum.m + ', report=' + v2.rp.quorum.m + ')')
2085
+ assert(v2.st.quorum.rosterVersion !== v1.st.quorum.rosterVersion && v2.st.quorum.m !== v1.st.quorum.m,
2086
+ 'the roster change moved both m and rosterVersion (' + JSON.stringify({ before: v1.st.quorum.m, after: v2.st.quorum.m, v: [v1.st.quorum.rosterVersion, v2.st.quorum.rosterVersion] }) + ')')
2087
+ }
2088
+
2089
+ // ---------- 45. F2: paperSummary() semantics (md runs must not claim tex / compilation) ----------
2090
+ console.log('\n[45] paperSummary(): md runs claim no tex, no compilation, no engine')
2091
+ {
2092
+ const h = makeHost({ pluginModule })
2093
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2094
+ const mdZh = await h.callTool('vibe_v5_paper', { lang: 'zh', format: 'md', editor: 'office' })
2095
+ const st = await h.callTool('vibe_v5_status', {})
2096
+ const p = st.paper || {}
2097
+ assert(mdZh.ok === true && p.format === 'md', '* the md run is reported as format=md (' + JSON.stringify({ ok: mdZh.ok, format: p.format }) + ')')
2098
+ const arts = Array.isArray(p.artifacts) ? p.artifacts : []
2099
+ assert(arts.indexOf('tex') === -1 && arts.indexOf('pdf') === -1, '* md runs list NO tex/pdf artifacts (found ' + JSON.stringify(arts) + ')')
2100
+ assert(!p.engine, '* md runs report no LaTeX engine - i.e. no requirement on a LaTeX toolchain (engine=' + JSON.stringify(p.engine) + ')')
2101
+ assert(typeof p.dir === 'string' && p.dir.indexOf('Paper/') === 0, '* status.paper.dir is the institute Paper/ dir (' + JSON.stringify(p.dir) + ')')
2102
+ assert(p.meta === null || p.meta === undefined, '* meta stays empty until the paper is finalized (meta=' + JSON.stringify(p.meta) + ')')
2103
+ // md x language and md x editor combinations
2104
+ const h2 = makeHost({ pluginModule }) // independent host: the first paper is already finalised
2105
+ await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2106
+ const mdEn = await h2.callTool('vibe_v5_paper', { lang: 'en', format: 'md', editor: 'academician' })
2107
+ console.log(' [45] fresh-host vibe_v5_paper -> ' + JSON.stringify(mdEn).slice(0, 300))
2108
+ const p2 = (await h2.callTool('vibe_v5_status', {})).paper || {}
2109
+ assert(p2.format === 'md' && p2.lang === 'en', '* md x language: lang is honoured and the format stays md (' + JSON.stringify({ format: p2.format, lang: p2.lang, call: mdEn }) + ')')
2110
+ const p2arts = Array.isArray(p2.artifacts) ? p2.artifacts : []
2111
+ assert(p2arts.indexOf('tex') === -1 && p2arts.indexOf('pdf') === -1 && !p2.engine, '* md x editor: still no tex/pdf and no engine (editor=member)')
2112
+ // the `editor` closed set is a CONTRACT: a valid value succeeds, an invalid one is refused by code
2113
+ const hOk = makeHost({ pluginModule })
2114
+ await hOk.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2115
+ const okOffice = await hOk.callTool('vibe_v5_paper', { lang: 'zh', format: 'md', editor: 'office' })
2116
+ assert(okOffice.ok === true, '* editor=office is accepted (the closed set is office|academician) (' + JSON.stringify(okOffice).slice(0, 100) + ')')
2117
+ const hBad = makeHost({ pluginModule })
2118
+ await hBad.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2119
+ const badEditor = await hBad.callTool('vibe_v5_paper', { lang: 'zh', format: 'md', editor: 'member' })
2120
+ assert(badEditor.ok === false && badEditor.code === 'V5_INVALID_ARGUMENT', '* an out-of-set editor is refused with V5_INVALID_ARGUMENT, not silently coerced (' + JSON.stringify(badEditor).slice(0, 120) + ')')
2121
+ }
2122
+ // ---------- 46. F6: memberDiagnosis branches — kind AND exact next.tool, exclusive & exhaustive -------
2123
+ console.log('\n[46] memberDiagnosis: per-branch kind + exact next.tool')
2124
+ {
2125
+ const { readFileSync } = await import('node:fs')
2126
+ const { fileURLToPath } = await import('node:url')
2127
+ const src = readFileSync(fileURLToPath(new URL('../vibe-math-v5/vibe-math-v5.js', import.meta.url)), 'utf8')
2128
+ const dStart = src.indexOf('function memberDiagnosis(')
2129
+ const dEnd = src.indexOf('\n function ', dStart + 10)
2130
+ const body = src.slice(dStart, dEnd > 0 ? dEnd : dStart + 4000) // scope: the diagnosis branches only
2131
+ const pairs = [["start","vibe_v5_start"],["staff","vibe_v5_add_researcher"],["member-call","vibe_v5_members"],["roster","vibe_v5_members"]]
2132
+ for (const [kind, tool] of pairs) {
2133
+ assert(new RegExp("kind: '" + kind + "'[^\\n]*?tool: '" + tool + "'").test(body),
2134
+ '* F6 branch ' + kind + ' pairs with next.tool=' + tool + ' (exact - not just ANY next{})')
2135
+ }
2136
+ for (const [kind] of pairs) {
2137
+ const n = (body.match(new RegExp("kind: '" + kind + "'", 'g')) || []).length
2138
+ assert(n === 1, '* F6 kind ' + kind + ' is declared exactly ONCE (mutually exclusive branches, no duplicate/typo kind) - found ' + n)
2139
+ }
2140
+ assert((body.match(/next: \{/g) || []).length >= 4, '* F6 the four branches are exhaustive: every refusal path carries its own next{}')
2141
+ }
2142
+ // ---------- 47. G1: a FAILED wake must not consume the mailbox (ack only after a successful send) ----
2143
+ console.log('\n[47] G1: a failed wake leaves the message PENDING (no ack before the send)')
2144
+ {
2145
+ const h = makeHost({ pluginModule, failSendMessage: true })
2146
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2147
+ await h.settleSpawns()
2148
+ // Every wake is forced to fail while the dm is queued, so this message can never be delivered yet.
2149
+ const attemptsBefore = h.sendAttempts.length
2150
+ const said = await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G1 未送达的消息' }, h.childAgent(h.childOf('acad')))
2151
+ assert(said.ok === true && said.delivered === 1,
2152
+ 'precondition: the dm was accepted for exactly one recipient (' + JSON.stringify(said).slice(0, 120) + ')')
2153
+ // `say` to an addressed recipient kicks a scheduling pass; wait until a wake is ATTEMPTED.
2154
+ for (let i = 0; i < 60 && h.sendAttempts.length === attemptsBefore; i++) await sleep(25)
2155
+ assert(h.sendAttempts.length > attemptsBefore,
2156
+ '★ the wake was attempted while every send fails (attempts ' + attemptsBefore + ' -> ' + h.sendAttempts.length + ')')
2157
+ assert(h.wakes.length === 0, 'no wake was queued while sendMessage throws (got ' + h.wakes.length + ')')
2158
+ // Heal the host, then trigger a wake DETERMINISTICALLY: a second dm kicks a scheduling pass
2159
+ // (v5 `say` -> scheduleNext), so we do not depend on the heartbeat timing. The prompt must carry
2160
+ // BOTH messages — the first one is the assertion (a failed wake must not consume it), the second
2161
+ // is the positive control that a wake really happened.
2162
+ h.setFailSend(false)
2163
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G1 正控消息' }, h.childAgent(h.childOf('acad')))
2164
+ let prompt = ''
2165
+ for (let i = 0; i < 4 && prompt.indexOf('G1 正控消息') === -1; i++) {
2166
+ const w = await h.peekWakeOf('r-1', 4000)
2167
+ if (w && w.text) prompt = w.text
2168
+ }
2169
+ assert(prompt.indexOf('G1 正控消息') !== -1,
2170
+ '★ positive control: the second dm did wake r-1 (prompt=' + JSON.stringify(prompt.slice(0, 160)) + ')')
2171
+ assert(prompt.indexOf('G1 未送达的消息') !== -1,
2172
+ '★★★ [G1] a message whose wake FAILED is still pending on the next successful wake (prompt=' + JSON.stringify(prompt.slice(0, 220)) + ')')
2173
+ // Two-sided: a SUCCESSFUL wake acks both once — neither may be delivered again.
2174
+ h.fireEnd(h.childOf('r-1'), { progress: '收到', solved: false, contextPct: 20 })
2175
+ await sleep(40)
2176
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G1 第三条' }, h.childAgent(h.childOf('acad')))
2177
+ let after = ''
2178
+ for (let i = 0; i < 4 && after.indexOf('G1 第三条') === -1; i++) {
2179
+ const w = await h.peekWakeOf('r-1', 4000)
2180
+ if (w && w.text) after = w.text
2181
+ }
2182
+ assert(after.indexOf('G1 第三条') !== -1,
2183
+ '★ positive control: the third dm woke r-1 again (prompt=' + JSON.stringify(after.slice(0, 160)) + ')')
2184
+ assert(after.indexOf('G1 未送达的消息') === -1 && after.indexOf('G1 正控消息') === -1,
2185
+ '★★ [G1] after a successful wake the message is ACKED exactly once (not redelivered; prompt=' + JSON.stringify(after.slice(0, 220)) + ')')
2186
+ }
2187
+ // ---------- 48. G2: a FAILED send must not consume a round (counters after the send) ----------------
2188
+ console.log('\n[48] G2: a failed wake does not consume a round (rounds == successful sends)')
2189
+ {
2190
+ const h = makeHost({ pluginModule, failSendMessage: true })
2191
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2192
+ await h.settleSpawns()
2193
+ const r1 = h.childOf('r-1')
2194
+ // Addressed mail wakes r-1; while the host is broken every such wake FAILS.
2195
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G2 失败唤醒' }, h.childAgent(h.childOf('acad')))
2196
+ for (let i = 0; i < 60 && h.sendAttempts.filter(c => c === r1).length === 0; i++) await sleep(25)
2197
+ const failedAttempts = h.sendAttempts.filter(c => c === r1).length
2198
+ assert(failedAttempts >= 1, 'precondition: at least one wake for r-1 was attempted and failed (' + failedAttempts + ')')
2199
+ assert(h.wakes.filter(w => w.childId === r1).length === 0, 'precondition: no wake for r-1 succeeded yet')
2200
+ // Heal the host and wake r-1 for real.
2201
+ h.setFailSend(false)
2202
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G2 成功唤醒' }, h.childAgent(h.childOf('acad')))
2203
+ let prompt = ''
2204
+ for (let i = 0; i < 4 && prompt.indexOf('G2 成功唤醒') === -1; i++) {
2205
+ const w = await h.peekWakeOf('r-1', 4000)
2206
+ if (w && w.text) prompt = w.text
2207
+ }
2208
+ assert(prompt.indexOf('G2 成功唤醒') !== -1, '★ positive control: the healed host woke r-1 (' + JSON.stringify(prompt.slice(0, 140)) + ')')
2209
+ // `wakeSends` survives `peekWakeOf`'s splice; the status line is built BEFORE the send, so the round it
2210
+ // shows is the one being started: it must equal the number of successes (not attempts).
2211
+ const succeeded = h.wakeSends
2212
+ const mm = /轮次 (\d+)/.exec(prompt)
2213
+ const shown = mm ? Number(mm[1]) : -1
2214
+ assert(succeeded >= 1 && failedAttempts >= 1,
2215
+ 'precondition: at least one success and one failure for r-1 (successes=' + succeeded + ', failures=' + failedAttempts + ')')
2216
+ assert(shown === succeeded,
2217
+ '★★★ [G2] a failed send must not consume a round: 轮次=' + shown + ' vs 成功发送=' + succeeded +
2218
+ ' (失败尝试=' + failedAttempts + ') — attempts must not count')
2219
+ }
2220
+ // ---------- 49. G4: assignment metadata travels INSIDE the CAS write (no second task write) ---------
2221
+ console.log('\n[49] G4: the assign writes the task exactly once, and a stale CAS is refused')
2222
+ {
2223
+ const h = makeHost({ pluginModule })
2224
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
2225
+ await h.settleSpawns()
2226
+ const created = await h.callTool('vibe_v5_assign', { subject: 'G4 任务', to: 'r-1', why: 'G4 理由', acceptance: 'G4 验收' })
2227
+ assert(created.ok === true && created.task && created.task.id, 'the office can assign a task (' + JSON.stringify(created).slice(0, 140) + ')')
2228
+ const got = await h.callTool('vibe_v5_task_get', { task_id: created.task.id })
2229
+ const t = (got && got.task) || got
2230
+ assert(t && t.assignedBy === 'office' && t.why === 'G4 理由' && t.acceptance === 'G4 验收',
2231
+ '★ the metadata is part of the SAME task value (' + JSON.stringify([t && t.assignedBy, t && t.why, t && t.acceptance]) + ')')
2232
+ assert(t && t.status === 'in_progress' && t.ownerId === 'r-1' && t.revision === 2,
2233
+ '★ the reassign landed as exactly one revision step (revision=' + (t && t.revision) + ', owner=' + (t && t.ownerId) + ', status=' + (t && t.status) + ')')
2234
+ // The compare-and-set token really is compared: the PRE-assign revision must be refused.
2235
+ const stale = await h.callTool('vibe_v5_task_update', { task_id: created.task.id, expected_revision: 1, action: 'release' }, h.childAgent(h.childOf('acad')))
2236
+ assert(stale.ok === false && stale.code === 'V5_TASK_STALE_REVISION',
2237
+ '★★ a stale expected_revision is refused with V5_TASK_STALE_REVISION (' + JSON.stringify(stale).slice(0, 150) + ')')
2238
+ // …and the reason the lost-update window existed: a second, unprotected write in the assign path.
2239
+ // The source MUST be read through the SAME seam the plugin was loaded from (`PLUGIN`), or a mutant
2240
+ // copy would be invisible here and this assertion would pass against the unmutated repo file.
2241
+ const { readFileSync: rf } = await import('node:fs')
2242
+ const { fileURLToPath: fp } = await import('node:url')
2243
+ const src = rf(fp(PLUGIN), 'utf8')
2244
+ const s0 = src.indexOf('async function taskAssign(')
2245
+ const s1 = src.indexOf('\n async function ', s0 + 10)
2246
+ const body = src.slice(s0, s1 > 0 ? s1 : s0 + 4000)
2247
+ const writes = (body.match(/putTask\(/g) || []).length
2248
+ assert(writes === 0,
2249
+ '★★★ [G4] the assign path issues NO second, unprotected task write (found ' + writes + ' putTask( in taskAssign) — ' +
2250
+ 'a second write reusing the just-read revision is the lost-update window')
2251
+ assert(/assignedBy: isOffice\(memberId\)/.test(body),
2252
+ '★ the metadata goes through the CAS call itself (assignedBy/why/acceptance passed as the internal meta argument)')
2253
+ }
2254
+ // ---------- 50. spawnMember: the founding round counts only on a SUCCESSFUL start -------------------
2255
+ console.log('\n[50] spawnMember: the first prompt shows 轮次 1, and a failed start consumes no round')
2256
+ {
2257
+ const h = makeHost({ pluginModule })
2258
+ const startPromptOf = (m) => {
2259
+ const sp = h.spawnOf(m)
2260
+ const blocks = (sp && sp.request && sp.request.prompt) || []
2261
+ return blocks.map((b) => (b && b.text) || '').join('\n')
2262
+ }
2263
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2264
+ const first = startPromptOf('r-1')
2265
+ assert(h.spawns.length >= 2 && first.length > 0, 'precondition: the founding prompt of r-1 was recorded (' + h.spawns.length + ' spawn(s))')
2266
+ assert(/轮次 1/.test(first), '★ the FOUNDING prompt announces the round it is starting (轮次 1): ' + JSON.stringify((/轮次 \d+/.exec(first) || ['<none>'])[0]))
2267
+ assert(!/轮次 0/.test(first), '★★ the founding prompt never displays 轮次 0 (the counter is applied after the start, so the value must be passed in explicitly)')
2268
+ await h.settleSpawns()
2269
+ // A FAILED founding must not consume a round: the retry's prompt must still say 轮次 1.
2270
+ const h2 = makeHost({ pluginModule, failStartContinuable: true })
2271
+ const startPromptOf2 = (m) => {
2272
+ const sp = h2.spawnOf(m)
2273
+ const blocks = (sp && sp.request && sp.request.prompt) || []
2274
+ return blocks.map((b) => (b && b.text) || '').join('\n')
2275
+ }
2276
+ await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2277
+ assert(h2.startAttempts.length >= 1 && h2.spawns.length === 0,
2278
+ 'precondition: founding was ATTEMPTED and every start failed (attempts=' + h2.startAttempts.length + ', spawns=' + h2.spawns.length + ')')
2279
+ const st0 = await h2.callTool('vibe_v5_status', {})
2280
+ assert(st0.members.some((m) => m.phase === 'failed'), 'the failed founders are recorded as failed, not left active')
2281
+ // Heal the host and rebuild through the documented manual resume path.
2282
+ h2.setFailStart(false)
2283
+ await h2.callTool('vibe_v5_resume', {})
2284
+ await h2.settleSpawns()
2285
+ const retry = startPromptOf2('r-1')
2286
+ assert(retry.length > 0, 'precondition: the resumed member was actually started on the retry')
2287
+ assert(/轮次 1/.test(retry),
2288
+ '★★★ [spawnMember] a FAILED start must not consume a round: the rebuilt member\'s first prompt shows 轮次 1, not 2 (' + JSON.stringify((/轮次 \d+/.exec(retry) || ['<none>'])[0]) + ')')
2289
+ }
2290
+ // ---------- 51. spawnMember: a FAILED resume must not move the round number backwards ----------------
2291
+ console.log('\n[51] spawnMember: a failed resume keeps the existing round number (never restarts it)')
2292
+ {
2293
+ const h = makeHost({ pluginModule })
2294
+ // The FIRST matching spawn is the founding one; after a rebuild the newest entry is the resumed one.
2295
+ const lastStartPromptOf = (m) => {
2296
+ const sp = h.spawns.slice().reverse().find((s) => String(s.label || '').indexOf('vibe5 ' + m + ' ') !== -1)
2297
+ const blocks = (sp && sp.request && sp.request.prompt) || []
2298
+ return blocks.map((b) => (b && b.text) || '').join('\n')
2299
+ }
2300
+ const roundOf = (t) => ((/轮次 (\d+)/.exec(t) || [])[1] || '<none>')
2301
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
2302
+ await h.settleSpawns()
2303
+ assert(roundOf(lastStartPromptOf('r-1')) === '1',
2304
+ 'precondition: the founding prompt starts the member at 轮次 1 (got ' + roundOf(lastStartPromptOf('r-1')) + ')')
2305
+ // Simulate the HOST losing the children (a restart) WITHOUT dismissing the member: its count survives.
2306
+ // (`vibe_v5_fire` deletes the counters on purpose, so it cannot be used for this scenario.)
2307
+ await h.ctx.subagents.drainContinuableChildren(h.ROOT, [h.childOf('r-1')])
2308
+ const attemptsBefore = h.startAttempts.length
2309
+ h.setFailStart(true)
2310
+ await h.callTool('vibe_v5_resume', {})
2311
+ assert(h.startAttempts.length > attemptsBefore,
2312
+ 'precondition: the resume was ATTEMPTED while every start fails (attempts ' + attemptsBefore + ' -> ' + h.startAttempts.length + ')')
2313
+ assert(roundOf(lastStartPromptOf('r-1')) === '1',
2314
+ 'precondition: the failed resume started nobody (the newest prompt is still the founding one)')
2315
+ // Heal and resume again: the count must CONTINUE, not restart.
2316
+ h.setFailStart(false)
2317
+ await h.callTool('vibe_v5_resume', {})
2318
+ await h.settleSpawns()
2319
+ const resumed = lastStartPromptOf('r-1')
2320
+ assert(h.spawns.filter((s) => String(s.label || '').indexOf('vibe5 r-1 ') !== -1).length >= 2,
2321
+ 'precondition: the member was actually rebuilt on the healed resume (' + h.spawns.length + ' spawns total)')
2322
+ assert(roundOf(resumed) === '2',
2323
+ '★★★ [spawnMember] a FAILED resume must not move the round number backwards: the rebuilt prompt says 轮次 ' +
2324
+ roundOf(resumed) + ', expected 轮次 2 (the existing count must survive the failed attempt)')
2325
+ assert(!/轮次 1/.test(resumed), '★★ the existing count was not restarted — no 轮次 1 on the resumed prompt')
2326
+ }
2327
+ // ---------- 52. F6: a failed REQUIRED artifact write is NAMED (once), and `files` stays honest ------
2328
+ console.log('\n[52] F6: a refused required write is named exactly once; a successful one is silent')
2329
+ {
2330
+ const settle = async (h) => {
2331
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
2332
+ await h.settleSpawns()
2333
+ await h.callTool('vibe_v5_paper', {})
2334
+ await drivePaper(h)
2335
+ }
2336
+ // (a) ANTI-VACUITY: a healthy finalisation writes meta and says NOTHING about write failures.
2337
+ const okHost = makeHost({ pluginModule })
2338
+ await settle(okHost)
2339
+ const okSt = await okHost.callTool('vibe_v5_status', {})
2340
+ const okDir = paperDirOf(okHost, 'institute')
2341
+ assert(existsSync(join(okDir, 'paper.meta.json')), 'precondition: the healthy run wrote paper.meta.json')
2342
+ const okWarn = (okSt.paper && okSt.paper.warnings) || []
2343
+ assert(!okWarn.some((w) => /产物写入失败/.test(w)),
2344
+ '★ anti-vacuity: a SUCCESSFUL required write produces no failure warning (' + JSON.stringify(okWarn) + ')')
2345
+ assert((okSt.paper.artifacts || []).indexOf('paper.meta.json') !== -1,
2346
+ '★ the healthy run lists paper.meta.json among its artifacts (' + JSON.stringify(okSt.paper.artifacts) + ')')
2347
+ // (b) THE REFUSAL: paper.meta.json cannot be written.
2348
+ const h = makeHost({ pluginModule, failWriteOn: /paper\.meta\.json$/ })
2349
+ await settle(h)
2350
+ const st = await h.callTool('vibe_v5_status', {})
2351
+ const dir = paperDirOf(h, 'institute')
2352
+ assert(!existsSync(join(dir, 'paper.meta.json')), 'precondition: the injected refusal really prevented the write')
2353
+ const warns = (st.paper && st.paper.warnings) || []
2354
+ const named = warns.filter((w) => /产物写入失败/.test(w) && /paper\.meta\.json/.test(w))
2355
+ assert(named.length === 1,
2356
+ '★★★ [F6] a failed REQUIRED write is named EXACTLY once, by artifact (' + JSON.stringify(warns) + ')')
2357
+ assert(/refillPaperArtifacts|补写/.test(named[0]),
2358
+ '★ the warning names the recovery path, not just the symptom (' + JSON.stringify(named[0].slice(0, 160)) + ')')
2359
+ assert((st.paper.artifacts || []).indexOf('paper.meta.json') === -1,
2360
+ '★ `files` stays HONEST — the artifact that did not land is absent from the list (' + JSON.stringify(st.paper.artifacts) + ')')
2361
+ assert(existsSync(join(dir, 'paper.md')), 'the other artifacts still landed (only the refused one is missing)')
2362
+ // …and the idempotent refill is what repairs it once the host allows writes again.
2363
+ h.setFailWriteOn(null)
2364
+ const refill = await h.callTool('vibe_v5_paper', {})
2365
+ assert(refill.ok === true && existsSync(join(dir, 'paper.meta.json')),
2366
+ '★ the documented recovery path refills the missing artifact (' + JSON.stringify(refill.refill || refill).slice(0, 140) + ')')
2367
+ const st2 = await h.callTool('vibe_v5_status', {})
2368
+ const named2 = ((st2.paper && st2.paper.warnings) || []).filter((w) => /产物写入失败/.test(w) && /paper\.meta\.json/.test(w))
2369
+ assert(named2.length === 1, '★★ the read-only refill does NOT multiply the warning (still exactly one): ' + JSON.stringify(named2))
2370
+ // (c) THE FLOW LOG, scoped to the FINALIZE-time append only (path + content): the ~15 earlier log writes
2371
+ // must still land, so `paper.log.md` exists but lacks the 定稿 section. The content predicate is NOT
2372
+ // anchored — an append carries the whole earlier log in front of the new section.
2373
+ const hl = makeHost({ pluginModule, failWriteOn: /paper\.log\.md$/, failWriteContent: /## 定稿(/ })
2374
+ await settle(hl)
2375
+ const ldir = paperDirOf(hl, 'institute')
2376
+ const logPath = join(ldir, 'paper.log.md')
2377
+ assert(existsSync(logPath), 'precondition: the earlier paper.log.md writes landed (only the final append is refused)')
2378
+ const logText = readFileSync(logPath, 'utf8')
2379
+ assert(logText.indexOf('## 定稿(') === -1, 'precondition: the refused append really did not reach the log')
2380
+ assert(logText.indexOf('## ') !== -1, 'precondition: the log still carries the flow entries written before finalisation')
2381
+ const lst = await hl.callTool('vibe_v5_status', {})
2382
+ const lwarn = ((lst.paper && lst.paper.warnings) || []).filter((w) => /产物写入失败/.test(w) && /paper\.log\.md/.test(w))
2383
+ assert(lwarn.length === 1, '★★ [F6] a failed FINALIZE-time log write is named exactly once (' + JSON.stringify(lst.paper && lst.paper.warnings) + ')')
2384
+ assert(/refillPaperArtifacts|补写/.test(lwarn[0]), '★ the log warning names the recovery path too')
2385
+ const lmeta = existsSync(join(ldir, 'paper.meta.json')) ? JSON.parse(readFileSync(join(ldir, 'paper.meta.json'), 'utf8')) : null
2386
+ assert(!!lmeta && (lmeta.warnings || []).some((w) => /产物写入失败/.test(w) && /paper\.log\.md/.test(w)),
2387
+ '★★ the DURABLE meta is re-written so it also names the missing log (' + JSON.stringify(lmeta && lmeta.warnings) + ')')
2388
+ assert((lst.paper.artifacts || []).indexOf('paper.meta.json') !== -1, 'the meta still landed and stays in the artifact list')
2389
+ // Anti-vacuity for (c): the healthy run's log DOES contain the 定稿 section.
2390
+ const okLog = existsSync(join(okDir, 'paper.log.md')) ? readFileSync(join(okDir, 'paper.log.md'), 'utf8') : ''
2391
+ assert(okLog.indexOf('## 定稿(') !== -1, '★ anti-vacuity: the healthy run\'s log carries the 定稿 section (the seam is what removed it)')
2392
+ }
2393
+ // ---------- 53. v5 L1: a broken Lean path contract is SURFACED in report()'s diagnostics ------------
2394
+ console.log('\n[53] v5 L1 leanPathContractOk: the queued note reaches report() diagnostics')
2395
+ {
2396
+ const { readFileSync: rf1 } = await import('node:fs')
2397
+ const { fileURLToPath: fp1 } = await import('node:url')
2398
+ const src = rf1(fp1(PLUGIN), 'utf8')
2399
+ assert(src.indexOf('function leanPathContractOk() {') !== -1, '★ [v5 L1] the contract checker exists (symbol anchor)')
2400
+ assert(src.indexOf("if (!leanPathContractOk()) noteLoadProblem('Lean path contract broken") !== -1,
2401
+ '★ [v5 L1] the load path CALLS it and queues the named note (source anchor)')
2402
+ assert(src.indexOf('drainLoadNotes()') > src.indexOf('leanPathContractOk()'),
2403
+ '★ [v5 L1] the queued note is DRAINED on the next commit (the drain site follows the check)')
2404
+ const h = makeHost({ pluginModule })
2405
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
2406
+ await h.settleSpawns()
2407
+ // ONE more committing action: `commit()` is what drains the queued load notes into the diagnostics log.
2408
+ await h.callTool('vibe_v5_message', { to: 'all', content: 'L1 probe commit' })
2409
+ // `report()` returns a STRING, so the tool spec is invoked directly (callTool would JSON.parse it).
2410
+ const spec = h.toolRegs.find((t) => t.name === 'vibe_v5_report')
2411
+ const rep = spec ? String(await spec.execute({}, { agent: h.ROOT })) : ''
2412
+ // MEASURED (2026-10, this suite): a healthy host's report() is ~1050 chars and does NOT contain the
2413
+ // diagnostics section — that section is CONDITIONAL (it renders only when there is something to report),
2414
+ // so the anti-vacuity check must be "the report tool was found and returned a substantive string", not
2415
+ // "the diagnostics heading is present". The negative L1 assertion below is what discriminates, and the
2416
+ // shipped family's v5-L1 mutant proves it can redden (12/12, named red on the line beneath).
2417
+ assert(spec !== undefined && rep.length > 200,
2418
+ '★ precondition: the report tool was found and returned a substantive report (' + rep.length + ' chars)')
2419
+ assert(rep.indexOf('Lean path contract broken') === -1,
2420
+ '★★ [v5 L1] a HEALTHY composition surfaces no Lean path contract note in report() diagnostics')
2421
+ }
1868
2422
  console.log('')
1869
2423
  console.log('passed=' + passed + ' failed=' + failed)
1870
2424
  if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }