dsh-vibe-math 2.7.2 → 2.8.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 (153) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -681
  3. package/README.md +683 -682
  4. package/cordis.patch.yml +112 -16
  5. package/docs/AUDIT-CHECKLIST.md +755 -518
  6. package/docs/COMPAT-AUDIT-ROUND2.md +354 -328
  7. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  8. package/docs/audit-methodology.md +93 -0
  9. package/docs/final-paper.md +172 -172
  10. package/docs/formal-verification.md +613 -588
  11. package/docs/generate_framework_diagram_v2.py +114 -114
  12. package/docs/generate_framework_diagram_v3.py +127 -127
  13. package/docs/math-computation.md +195 -187
  14. package/docs/parameter-schema.md +37 -0
  15. package/docs/release-notes/README.en.md +2 -0
  16. package/docs/release-notes/README.md +2 -0
  17. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  18. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  19. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  20. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  21. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  22. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  23. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  24. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  25. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  26. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  27. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  28. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  29. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  30. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  31. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  32. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  33. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  34. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  35. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
  36. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
  37. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  38. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  39. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  40. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  41. package/docs/release-notes/TEMPLATE.md +6 -0
  42. package/docs/release-playbook.md +103 -0
  43. package/docs/slv-playbook.md +117 -0
  44. package/docs/status-report-fields.md +167 -0
  45. package/docs/test-timing.md +158 -154
  46. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  47. package/installer.js +793 -783
  48. package/package.json +260 -203
  49. package/prompt-corpus-persona/persona-corpus.json +9 -9
  50. package/prompt-corpus-persona/persona-corpus.md +225 -33
  51. package/prompt-corpus-v2/formal-verify-v2.json +295 -115
  52. package/prompt-corpus-v2/formal-verify-v2.md +2290 -282
  53. package/prompt-corpus-v3/formal-verify-v3.json +112 -112
  54. package/prompt-corpus-v3/formal-verify-v3.md +227 -143
  55. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  56. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  57. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  58. package/prompt-corpus-v5/prompt-corpus-v5.md +573 -207
  59. package/scripts/clean-temp.mjs +234 -0
  60. package/scripts/update-doc-counts.mjs +71 -0
  61. package/tests/audit-artifact-docs.mjs +72 -0
  62. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  63. package/tests/audit-engine-faces.mjs +298 -0
  64. package/tests/audit-formal-sensitivity.mjs +345 -342
  65. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  66. package/tests/audit-installer-compat.test.mjs +256 -208
  67. package/tests/audit-installer-policy.test.mjs +41 -8
  68. package/tests/audit-math-computation-contract.mjs +64 -8
  69. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  70. package/tests/audit-math-computation-parity.mjs +164 -3
  71. package/tests/audit-math-computation-sensitivity.mjs +5 -3
  72. package/tests/audit-package-membership.mjs +57 -0
  73. package/tests/audit-package-membership.mutants.mjs +28 -0
  74. package/tests/audit-participant-set-parity.mjs +160 -0
  75. package/tests/audit-path-discipline.mjs +83 -0
  76. package/tests/audit-path-discipline.mutants.mjs +51 -0
  77. package/tests/audit-persona-sensitivity.mjs +323 -309
  78. package/tests/audit-persona-surface.mutants.mjs +69 -0
  79. package/tests/audit-persona-surface.test.mjs +431 -390
  80. package/tests/audit-prompt-duplication.mjs +143 -0
  81. package/tests/audit-prompt-duplication.mutants.mjs +51 -0
  82. package/tests/audit-prompt-invariants.mjs +589 -508
  83. package/tests/audit-readme-bilingual.test.mjs +152 -0
  84. package/tests/audit-readme-counts.mjs +95 -0
  85. package/tests/audit-readme-counts.mutants.mjs +52 -0
  86. package/tests/audit-registration.mjs +78 -0
  87. package/tests/audit-spec-traceability.mjs +193 -193
  88. package/tests/audit-status-report-fields.mjs +172 -0
  89. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  90. package/tests/audit-v3-registration-parity.mjs +156 -0
  91. package/tests/audit-v5-integrity.mjs +795 -508
  92. package/tests/audit-v5-lean-abstention.mjs +392 -0
  93. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  94. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  95. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  96. package/tests/audit-v5-sensitivity.mjs +433 -427
  97. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  98. package/tests/e2e-v5-round2.test.mjs +2453 -1981
  99. package/tests/formal-verify-v2.test.mjs +1778 -1724
  100. package/tests/formal-verify-v3.mutants.mjs +117 -0
  101. package/tests/formal-verify-v3.test.mjs +1724 -1671
  102. package/tests/formal-verify-v4.mutants.mjs +152 -0
  103. package/tests/formal-verify-v4.test.mjs +2114 -1574
  104. package/tests/formal-verify-v5.test.mjs +790 -724
  105. package/tests/helpers/doc-counts-format.mjs +41 -0
  106. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  107. package/tests/math-computation-a1.mutants.mjs +72 -0
  108. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  109. package/tests/math-computation-discovery.mutants.mjs +76 -0
  110. package/tests/math-computation-shared.test.mjs +392 -6
  111. package/tests/math-computation-v2.test.mjs +779 -0
  112. package/tests/math-computation-v3.test.mjs +771 -0
  113. package/tests/math-computation-v4.mutants.mjs +133 -0
  114. package/tests/math-computation-v4.test.mjs +739 -0
  115. package/tests/prompt-v5-integrity.test.mjs +1291 -1280
  116. package/tests/run-tests.mjs +393 -202
  117. package/tests/run-tests.mutants.mjs +70 -0
  118. package/tests/selfdrive-v5.mjs +560 -508
  119. package/tests/selfdrive-v5.mutants.mjs +132 -0
  120. package/tests/temp-hygiene.mutants.mjs +80 -0
  121. package/tests/v2-fix-probes.mutants.mjs +104 -0
  122. package/tests/v2-fix-probes.test.mjs +1040 -0
  123. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  124. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  125. package/tests/v3-fix-probes.mutants.mjs +99 -0
  126. package/tests/v3-fix-probes.test.mjs +892 -0
  127. package/tests/v4-final-paper.mutants.mjs +108 -0
  128. package/tests/v5-institute-fixes.mutants.mjs +218 -0
  129. package/vibe-math-v2/agent.cordis.yml +378 -322
  130. package/vibe-math-v2/math-computation.js +144 -53
  131. package/vibe-math-v2/math-engines.js +12 -12
  132. package/vibe-math-v2/preset.yml +2 -2
  133. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  134. package/vibe-math-v2/vibe-math-v2.js +5097 -4755
  135. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +491 -487
  136. package/vibe-math-v3/agent.cordis.yml +422 -356
  137. package/vibe-math-v3/math-computation.js +144 -53
  138. package/vibe-math-v3/math-engines.js +12 -12
  139. package/vibe-math-v3/preset.yml +2 -2
  140. package/vibe-math-v3/vibe-math-v3.js +6235 -5873
  141. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  142. package/vibe-math-v4/agent.cordis.yml +438 -402
  143. package/vibe-math-v4/math-computation.js +144 -53
  144. package/vibe-math-v4/math-engines.js +12 -12
  145. package/vibe-math-v4/preset.yml +2 -2
  146. package/vibe-math-v4/vibe-math-v4.js +4490 -4269
  147. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1013
  148. package/vibe-math-v5/agent.cordis.yml +46 -12
  149. package/vibe-math-v5/math-computation.js +144 -53
  150. package/vibe-math-v5/math-engines.js +12 -12
  151. package/vibe-math-v5/vibe-math-v5.js +1266 -229
  152. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1660
  153. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -50,6 +50,69 @@
50
50
  // to a host session log: DSH's session persistence refuses to load a session that carries an unknown
51
51
  // event type unless the writer marked it `ignorable: true`, and `Session.append` cannot set that
52
52
  // field — so the old projection-based primary made the user's own session unresumable.
53
+
54
+ // ── task-4: known-location fallback (math-engine parity) ────────────────────────────────────────────
55
+ // Resolution order: an EXPLICIT command (leanCommand / paperLatexCommand) is used alone and never
56
+ // guessed; otherwise the host resolver (PATH) is tried first, then the known install locations for this
57
+ // platform. Provenance is returned so the caller can report \`leanFoundVia: 'explicit'|'path'|'known-install'\`,
58
+ // and the probed list travels on failure ("I looked here too"), never just "not found on PATH".
59
+ async function resolveKnownTool(sub, opts) {
60
+ const name = String((opts && opts.name) || '')
61
+ const explicit = String((opts && opts.explicit) || '').trim()
62
+ const kind = String((opts && opts.kind) || 'lean')
63
+ const tried = []
64
+ const env = (typeof process !== 'undefined' && process.env) || {}
65
+ const win = !!(typeof process !== 'undefined' && process.platform === 'win32')
66
+ const home = String(env.USERPROFILE || env.HOME || (win ? 'C:/Users/Default' : '/root'))
67
+ const local = String(env.LOCALAPPDATA || '')
68
+ const pf = String(env.ProgramFiles || 'C:/Program Files')
69
+ const pf86 = String(env['ProgramFiles(x86)'] || 'C:/Program Files (x86)')
70
+ const elan = String(env.ELAN_HOME || '')
71
+ const exts = win ? ['', '.exe', '.cmd', '.bat'] : ['']
72
+ const resolvePath = async (p) => {
73
+ tried.push(p)
74
+ try { const fs = await import('node:fs'); for (const e of exts) { const q = p + e; try { if (fs.existsSync(q) && fs.statSync(q).isFile()) return q } catch (err) { /* keep looking */ } } } catch (err) { /* no fs */ }
75
+ return null
76
+ }
77
+ const listDirs = async (p) => { try { const fs = await import('node:fs'); return fs.readdirSync(p, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch (err) { return [] } }
78
+ const knownLean = win
79
+ ? [elan ? elan + '/bin' : null, home + '/.elan/bin', local ? local + '/Programs/lean' : null, pf + '/lean/bin']
80
+ : [elan ? elan + '/bin' : null, home + '/.elan/bin', home + '/.local/bin', '/usr/local/bin', '/opt/lean/bin']
81
+ const knownTex = win
82
+ ? [pf + '/texlive', pf86 + '/texlive', local ? local + '/Programs/MiKTeX/miktex/bin/x64' : null, pf + '/MiKTeX/miktex/bin/x64']
83
+ : [home + '/Library/TeX/texbin', '/usr/local/texlive', home + '/.TinyTeX/bin', '/opt/texlive']
84
+ const list = kind === 'tex' ? knownTex : knownLean
85
+ // 1) EXPLICIT: only it, never a guess.
86
+ if (explicit) {
87
+ if (typeof sub.resolveExecutable === 'function') { try { const p = await sub.resolveExecutable(explicit); if (p) return { exe: String(p), via: 'explicit', tried, name: explicit } } catch (e) { /* fall through to PATH */ } }
88
+ const p = await resolvePath(explicit)
89
+ if (p) return { exe: p, via: 'explicit', tried, name: explicit }
90
+ return { exe: null, via: null, tried, name: explicit, reason: 'explicit command not resolvable: ' + explicit }
91
+ }
92
+ // 2) PATH via the host resolver.
93
+ if (typeof sub.resolveExecutable === 'function') {
94
+ try { const p = await sub.resolveExecutable(name); if (p) return { exe: String(p), via: 'path', tried, name } } catch (e) { /* PATH miss is expected */ }
95
+ }
96
+ // 3) KNOWN INSTALL LOCATIONS (Windows TeX roots are globbed per release: <root>/<year>/bin/windows).
97
+ for (const base of list) {
98
+ if (!base) continue
99
+ if (kind === 'tex' && !/bin/i.test(base)) {
100
+ for (const year of await listDirs(base)) {
101
+ for (const sub2 of ['bin/windows', 'bin/x86_64-linux', 'bin/universal-darwin', 'bin']) {
102
+ const p = await resolvePath(base + '/' + year + '/' + sub2 + '/' + name)
103
+ if (p) return { exe: p, via: 'known-install', tried, name }
104
+ }
105
+ }
106
+ continue
107
+ }
108
+ const p = await resolvePath(base + '/' + name)
109
+ if (p) return { exe: p, via: 'known-install', tried, name }
110
+ }
111
+ return { exe: null, via: null, tried, name, reason: 'not found on PATH or in the known install locations' }
112
+ }
113
+
114
+ export { resolveKnownTool }
115
+
53
116
  export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
54
117
 
55
118
  // ── math_computation (docs/math-computation.md) ──────────────────────────────
@@ -86,7 +149,7 @@ const EV = {
86
149
  debate: 'vibe5/debate',
87
150
  verdict: 'vibe5/verdict',
88
151
  queue: 'vibe5/queue',
89
- counters: 'vibe5/counters',
152
+ solve: 'vibe5/solve',
90
153
  progress: 'vibe5/progress',
91
154
  formal: 'vibe5/formal',
92
155
  }
@@ -330,26 +393,47 @@ export function apply(ctx) {
330
393
  formal: {},
331
394
  todo: [],
332
395
  queue: [],
396
+ // Per-member answers to the "is the original problem solved?" question (HIGH 3). It used to
397
+ // live in an in-memory Map: the tally gated `solved` and was PUBLISHED by status/report, but
398
+ // a restart or a second session over the same institute lost it, so cross-session unanimity
399
+ // was impossible and the published view was wrong. Durable like `verdicts`.
400
+ solve: {},
333
401
  counters: { academician: 0, researcher: 0, temp: 0, task: 0, meeting: 0, message: 0, verify: 0 },
334
402
  runId: '',
335
403
  lastProgressAt: now(),
336
404
  artifactCount: 0,
337
405
  diagnostics: [],
406
+ // F6 (status/report review): the durable marker of a meeting that STARTED but never
407
+ // FINALIZED. The meeting index entry is appended at `beginMeeting`, so before this marker a
408
+ // restart made an unfinished meeting look like ordinary history (minutes never written).
409
+ meetingOpen: null,
410
+ // F4 (status/report review): how many office-addressed requests the fold had to drop because
411
+ // of `OFFICE_REQUEST_CAP`. Without a counter the truncation was invisible in status/report.
412
+ officeRequestsDropped: 0,
338
413
  // The final-paper flow (stage machine + its artifacts state). Durable like everything
339
414
  // else, so a restart resumes the same stage instead of writing a second paper.
340
415
  paper: null,
341
416
  }
342
417
  }
343
- function initState() { return { v: PROJECTION_VERSION, institutes: {}, order: [] } }
418
+ // LOW (deep review): `state.order` used to be maintained here and preserved in the diagnostics
419
+ // fallback, but NOTHING ever read it — dead payload written into every snapshot. Removed: the
420
+ // institutes map is the index (`Object.keys(state.institutes)`), and old files that still carry
421
+ // an `order` key are simply ignored.
422
+ function initState() { return { v: PROJECTION_VERSION, institutes: {} } }
344
423
 
424
+ // S3 (deep-review 4): the returned state now PRESERVES every unknown TOP-LEVEL key from the
425
+ // loaded file (`Object.assign({}, state, …)`) instead of rebuilding `{v, institutes,
426
+ // diagnostics}` and thereby silently erasing anything a NEWER revision wrote at that level. The
427
+ // institute level already behaved that way (the fold copies the institute object), so this makes
428
+ // the two levels consistent. `order` stays gone for good (it was written-but-never-read and was
429
+ // removed in the previous round), so nothing is re-added here.
345
430
  function withInstitute(state, key, mut) {
346
431
  const cur = state.institutes[key] || emptyInstitute(key, '', '')
347
432
  const nextInst = mut(cur)
348
433
  if (nextInst === cur) return state
349
434
  const institutes = Object.assign({}, state.institutes)
350
435
  institutes[key] = nextInst
351
- const order = state.order.indexOf(key) === -1 ? state.order.concat([key]) : state.order
352
- return { v: state.v, institutes, order, diagnostics: state.diagnostics }
436
+ return Object.assign({}, state, { institutes })
353
437
  }
354
438
 
355
439
  // Fold ONE event. Unknown/malformed events are SKIPPED and recorded in
@@ -357,6 +441,41 @@ export function apply(ctx) {
357
441
  // log purism, and a malformed event is a code defect that tests must catch.
358
442
  // (DSH's own team projection latches `state.failure` forever instead — a shape
359
443
  // v5 deliberately does not copy.)
444
+ // ── in-fold id allocation (deep-review HIGH 1) ───────────────────────────────
445
+ // Ids used to be minted OUTSIDE the fold (read `counters` → `msg-N` → `await putMessage`), so
446
+ // two same-tick writers could mint the SAME id; the fold then deduped (`messages`) or upserted
447
+ // (`members`/`tasks`), silently dropping or overwriting one object while BOTH callers reported
448
+ // success. An event may now carry `d.make(alloc)` instead of a ready object: it runs INSIDE the
449
+ // fold, allocates its id from the fold's OWN counters, and returns the object(s) to
450
+ // append/upsert — the fold applies the object AND the bumped counters in the same step, so two
451
+ // concurrent appends cannot collide. Already-identified objects (`d.member`/`d.task`/
452
+ // `d.message`/`d.index`, the update paths) keep working unchanged.
453
+ const ID_PREFIX = { message: 'msg-', task: 't-', meeting: 'mt-', researcher: 'r-', temp: 't-' }
454
+ // MEDIUM 4 (deep review): the OFFICE is not a member, so `to:'voters'` never reaches it — yet
455
+ // `addResearcher`/`startMeeting` told the caller "已向所办提议" while only voters were addressed
456
+ // (and only the office may approve either). Office-addressed requests are now a first-class
457
+ // target: a durable message under this pseudo-id, surfaced by `status().officeRequests` and by
458
+ // `report()`, capped so the queue cannot grow without bound.
459
+ const OFFICE_INBOX = 'office'
460
+ const OFFICE_REQUEST_CAP = 10
461
+ function makeIdAllocator(inst) {
462
+ const counters = Object.assign({}, inst.counters)
463
+ let dirty = false
464
+ return {
465
+ next(kind) {
466
+ if (kind === 'academician') {
467
+ counters.academician = Math.max(1, Number(counters.academician) || 0)
468
+ dirty = true
469
+ return 'acad'
470
+ }
471
+ const n = Math.max(0, Number(counters[kind]) || 0) + 1
472
+ counters[kind] = n
473
+ dirty = true
474
+ return (ID_PREFIX[kind] || kind + '-') + n
475
+ },
476
+ counters() { return dirty ? counters : inst.counters },
477
+ }
478
+ }
360
479
  function applyV5Event(state, event) {
361
480
  try {
362
481
  if (!event || typeof event.type !== 'string') return state
@@ -377,6 +496,9 @@ export function apply(ctx) {
377
496
  if (patch.runId !== undefined) n.runId = String(patch.runId)
378
497
  if (patch.lastProgressAt !== undefined) n.lastProgressAt = Number(patch.lastProgressAt) || 0
379
498
  if (patch.artifactCount !== undefined) n.artifactCount = Number(patch.artifactCount) || 0
499
+ // F6 / F4 (status/report review): two durable markers the published surfaces read.
500
+ if (patch.meetingOpen !== undefined) n.meetingOpen = patch.meetingOpen === null ? null : Object.assign({}, patch.meetingOpen)
501
+ if (patch.officeRequestsDropped !== undefined) n.officeRequestsDropped = Number(patch.officeRequestsDropped) || 0
380
502
  // The final-paper state. A FUNCTION is a MUTATION applied inside the fold (the same
381
503
  // trick the verify queue uses): the paper stage, its parts and its office-consultation
382
504
  // counters are updated from several places (member replies, office messages, meetings),
@@ -392,30 +514,79 @@ export function apply(ctx) {
392
514
  }
393
515
  if (t === EV.member) {
394
516
  return withInstitute(state, key, (inst) => {
395
- const m = d.member
396
- if (!m || typeof m.id !== 'string') return inst
517
+ const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
518
+ const made = alloc ? d.make(alloc) : d.member
519
+ const list = (Array.isArray(made) ? made : [made]).filter(Boolean)
520
+ if (!list.length) return inst
397
521
  const members = inst.members.slice()
398
- const i = members.findIndex((x) => x.id === m.id)
399
- if (i === -1) members.push(m); else members[i] = m
400
- return Object.assign({}, inst, { members })
522
+ let changed = false
523
+ for (const m of list) {
524
+ if (!m || typeof m.id !== 'string' || !m.id) continue
525
+ const i = members.findIndex((x) => x.id === m.id)
526
+ if (i === -1) members.push(m); else members[i] = m
527
+ changed = true
528
+ }
529
+ if (!changed) return inst
530
+ const out = Object.assign({}, inst, { members })
531
+ if (alloc) out.counters = alloc.counters()
532
+ return out
401
533
  })
402
534
  }
403
535
  if (t === EV.task) {
404
536
  return withInstitute(state, key, (inst) => {
405
- const task = d.task
406
- if (!task || typeof task.id !== 'string') return inst
537
+ const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
538
+ const made = alloc ? d.make(alloc) : d.task
539
+ const list = (Array.isArray(made) ? made : [made]).filter(Boolean)
540
+ if (!list.length) return inst
407
541
  const tasks = inst.tasks.slice()
408
- const i = tasks.findIndex((x) => x.id === task.id)
409
- if (i === -1) tasks.push(task); else tasks[i] = task
410
- return Object.assign({}, inst, { tasks })
542
+ let changed = false
543
+ for (const task of list) {
544
+ if (!task || typeof task.id !== 'string' || !task.id) continue
545
+ const i = tasks.findIndex((x) => x.id === task.id)
546
+ if (i === -1) tasks.push(task); else tasks[i] = task
547
+ changed = true
548
+ }
549
+ if (!changed) return inst
550
+ const out = Object.assign({}, inst, { tasks })
551
+ if (alloc) out.counters = alloc.counters()
552
+ return out
411
553
  })
412
554
  }
413
555
  if (t === EV.message) {
414
556
  return withInstitute(state, key, (inst) => {
415
- const msg = d.message
416
- if (!msg || typeof msg.id !== 'string') return inst
417
- if (inst.messages.some((x) => x.id === msg.id)) return inst
418
- return Object.assign({}, inst, { messages: inst.messages.concat([msg]) })
557
+ const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
558
+ const made = alloc ? d.make(alloc) : d.message
559
+ const list = (Array.isArray(made) ? made : [made]).filter(Boolean)
560
+ if (!list.length) return inst
561
+ const messages = inst.messages.slice()
562
+ let changed = false
563
+ for (const msg of list) {
564
+ if (!msg || typeof msg.id !== 'string' || !msg.id) continue
565
+ if (messages.some((x) => x.id === msg.id)) continue
566
+ messages.push(msg)
567
+ changed = true
568
+ }
569
+ if (!changed) return inst
570
+ // MEDIUM 4 (deep review): office-addressed requests (a member proposal that ONLY the
571
+ // office can approve) are never "delivered" — the office is not a member and has no
572
+ // inbox drain — so they are capped here, newest kept, oldest dropped. Without that cap
573
+ // `messages` would grow for the whole run.
574
+ let self = messages
575
+ let dropped = 0
576
+ const officeMsgs = self.filter((m) => m.to === OFFICE_INBOX)
577
+ if (officeMsgs.length > OFFICE_REQUEST_CAP) {
578
+ const drop = new Set(officeMsgs.slice(0, officeMsgs.length - OFFICE_REQUEST_CAP).map((m) => m.id))
579
+ self = self.filter((m) => !drop.has(m.id))
580
+ dropped = drop.size
581
+ }
582
+ // F4 (status/report review): the truncation is COUNTED, so `status()`/`report()` can say
583
+ // "显示最新 N / 累计丢弃 K" instead of presenting a truncated list as complete.
584
+ const out = Object.assign({}, inst, {
585
+ messages: self,
586
+ officeRequestsDropped: Number(inst.officeRequestsDropped || 0) + dropped,
587
+ })
588
+ if (alloc) out.counters = alloc.counters()
589
+ return out
419
590
  })
420
591
  }
421
592
  if (t === EV.delivered) {
@@ -441,11 +612,14 @@ export function apply(ctx) {
441
612
  }
442
613
  if (t === EV.meeting) {
443
614
  return withInstitute(state, key, (inst) => {
444
- const idx = d.index
445
- if (!idx || typeof idx.id !== 'string') return inst
615
+ const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
616
+ const idx = alloc ? d.make(alloc) : d.index
617
+ if (!idx || typeof idx.id !== 'string' || !idx.id) return inst
446
618
  if (inst.meetings.some((x) => x.id === idx.id)) return inst
447
619
  const meetings = inst.meetings.concat([idx])
448
- return Object.assign({}, inst, { meetings: meetings.length > 200 ? meetings.slice(meetings.length - 200) : meetings })
620
+ const out = Object.assign({}, inst, { meetings: meetings.length > 200 ? meetings.slice(meetings.length - 200) : meetings })
621
+ if (alloc) out.counters = alloc.counters()
622
+ return out
449
623
  })
450
624
  }
451
625
  if (t === EV.debate) {
@@ -469,9 +643,23 @@ export function apply(ctx) {
469
643
  return withInstitute(state, key, (inst) => {
470
644
  if (!d.target || typeof d.target !== 'string') return inst
471
645
  const formal = Object.assign({}, inst.formal)
472
- if (d.record === null) delete formal[d.target]
646
+ // MEDIUM 6 (deep review): `record` and `todo` accept a FUNCTION (a mutation applied
647
+ // inside the fold), exactly like `EV.queue`/`patch.paper`. The old shape built them from
648
+ // reads taken OUTSIDE the fold (`formalTodo().filter(...)`) and replaced the durable
649
+ // value wholesale, so two same-tick writers for different targets lost one entry —
650
+ // the lost-update class the queue fix at this file's queue branch already removed.
651
+ if (typeof d.record === 'function') {
652
+ const target = d.target
653
+ const next = d.record(formal[target] === undefined ? null : formal[target])
654
+ if (next === null || next === undefined) delete formal[target]
655
+ else formal[target] = next
656
+ } else if (d.record === null) delete formal[d.target]
473
657
  else formal[d.target] = d.record
474
- const todo = d.todo === undefined ? (inst.todo || []) : (Array.isArray(d.todo) ? d.todo : (inst.todo || []))
658
+ const todo = d.todo === undefined
659
+ ? (inst.todo || [])
660
+ : (typeof d.todo === 'function'
661
+ ? (d.todo(inst.todo || []) || inst.todo || [])
662
+ : (Array.isArray(d.todo) ? d.todo : (inst.todo || [])))
475
663
  return Object.assign({}, inst, { formal, todo })
476
664
  })
477
665
  }
@@ -491,8 +679,19 @@ export function apply(ctx) {
491
679
  return Object.assign({}, inst, { queue: list })
492
680
  })
493
681
  }
494
- if (t === EV.counters) {
495
- return withInstitute(state, key, (inst) => Object.assign({}, inst, { counters: Object.assign({}, inst.counters, d.counters || {}) }))
682
+ if (t === EV.solve) {
683
+ // HIGH 3: one durable place for the "is it solved?" tally. `d.clear` resets it (a new
684
+ // solve question), `d.member`+`d.value` records/withdraws ONE member's answer (`null`
685
+ // withdraws, mirroring how `fire` drops a dismissed member's ballot).
686
+ return withInstitute(state, key, (inst) => {
687
+ if (d.clear) return Object.assign({}, inst, { solve: {} })
688
+ const member = String(d.member || '')
689
+ if (!member) return inst
690
+ const cur = Object.assign({}, inst.solve || {})
691
+ if (d.value === null || d.value === undefined) delete cur[member]
692
+ else cur[member] = d.value === true
693
+ return Object.assign({}, inst, { solve: cur })
694
+ })
496
695
  }
497
696
  if (t === EV.progress) {
498
697
  return withInstitute(state, key, (inst) => Object.assign({}, inst, {
@@ -505,7 +704,8 @@ export function apply(ctx) {
505
704
  // Never throw out of the fold: one bad event must not break every later read.
506
705
  try {
507
706
  const diagnostics = (state.diagnostics || []).concat([{ at: now(), type: String(event && event.type), error: String((e && e.message) || e) }])
508
- return { v: state.v, institutes: state.institutes, order: state.order, diagnostics: diagnostics.slice(-50) }
707
+ // S3: keep every unknown top-level key here too (see `withInstitute`).
708
+ return Object.assign({}, state, { v: state.v, institutes: state.institutes, diagnostics: diagnostics.slice(-50) })
509
709
  } catch (e2) { return state }
510
710
  }
511
711
  }
@@ -525,7 +725,7 @@ export function apply(ctx) {
525
725
  // default path, after which `vibe_v5_configure {institute:'alpha'}` never read alpha's
526
726
  // file and wrote an EMPTY institute over it. Switching paths now re-reads the new path
527
727
  // first, and if that read fails the backend refuses to write instead of clobbering.
528
- function makeFileBackend(readTextAbs, writeTextAbs, pathOf) {
728
+ function makeFileBackend(readTextAbs, writeTextAbs, pathOf, onWriteFailure, onLoadProblem) {
529
729
  let mem = initState()
530
730
  let chain = Promise.resolve(true)
531
731
  let loadedPath // the path whose file is currently folded into `mem`
@@ -535,22 +735,40 @@ export function apply(ctx) {
535
735
  // Fold ONE path into `mem`. `readTextAbs` returning undefined means "no file yet", which
536
736
  // is the only case that licences a later write; anything else (a throw, a parse error, a
537
737
  // version mismatch) leaves `loadOk` false so commit() refuses to clobber it.
738
+ // S2 (deep-review 4): the REASON is recorded (`loadProblem`), because "no file" and "a file we
739
+ // could not use" used to look identical to every reader — an operator saw an empty institute
740
+ // with no hint that their state file exists and was rejected, and the note only appeared after
741
+ // a WRITE was attempted.
742
+ let loadProblem = ''
538
743
  async function doLoad(p) {
539
744
  let ok = false
745
+ loadProblem = ''
540
746
  try {
541
747
  const raw = await readTextAbs(p)
542
748
  if (raw === undefined || raw === null || raw === '') ok = true
543
749
  else {
544
- const parsed = JSON.parse(raw)
545
- if (parsed && parsed.v === PROJECTION_VERSION) { mem = parsed; ok = true }
750
+ let parsed = null
751
+ try { parsed = JSON.parse(raw) } catch (e) { loadProblem = 'invalid JSON (truncated or corrupt)' }
752
+ if (parsed) {
753
+ if (parsed.v === PROJECTION_VERSION) { mem = parsed; ok = true }
754
+ else loadProblem = 'schema version mismatch: file has v=' + JSON.stringify(parsed.v) + ', this build reads v=' + PROJECTION_VERSION
755
+ }
546
756
  }
547
- } catch (e) { /* a corrupt/unreadable file is ignored; it is not authoritative */ }
757
+ } catch (e) { loadProblem = 'unreadable: ' + String((e && e.message) || e) }
548
758
  loadedPath = p
549
759
  loadOk = ok
550
760
  loadPromise = null
551
761
  loadPendingPath = null
552
762
  return mem
553
763
  }
764
+ // S2: the actionable text for a file that EXISTS but could not be used (vs "no file yet").
765
+ function loadProblemText(where) {
766
+ if (!loadProblem) return ''
767
+ return 'the state file ' + where + ' exists but could NOT be used (' + loadProblem + '); '
768
+ + 'writes are refused so it is never clobbered. Fix: back it up, then either restore a v'
769
+ + PROJECTION_VERSION + ' file or remove/rename it to start a fresh institute in that path '
770
+ + '(the current file is left untouched).'
771
+ }
554
772
  const backend = {
555
773
  kind: 'file',
556
774
  async load() {
@@ -564,6 +782,9 @@ export function apply(ctx) {
564
782
  loadPendingPath = path
565
783
  loadPromise = doLoad(path)
566
784
  await loadPromise
785
+ // S2: a file that EXISTS but was rejected is reported on the READ path too (not only when
786
+ // a write is refused), so `/v5 status` can never silently look like "nothing here yet".
787
+ if (!loadOk && loadProblem) { try { onLoadProblem(loadProblemText(path)) } catch (e) { /* diagnostics must not break the load */ } }
567
788
  if (loadedPath === pathOf()) return mem
568
789
  // The path moved again while we were reading: hand back the state for the path
569
790
  // that is current NOW rather than one for a directory we are no longer using.
@@ -599,7 +820,17 @@ export function apply(ctx) {
599
820
  // late writer always lands the FULL newest state and can never overwrite with
600
821
  // a stale subset (v4 §27 writeJson defect).
601
822
  chain = chain.then(async () => {
602
- try { await writeTextAbs(pathOf(), JSON.stringify(snapshot, null, 2)) } catch (e) { /* best effort */ }
823
+ // MEDIUM 7 (deep review): the write used to be wrapped in `catch { /* best effort */ }`,
824
+ // and `writeTextAbs` already swallows its own failures (it returns `undefined`), so a
825
+ // failed write was INVISIBLE: the in-memory state advanced, every caller reported
826
+ // success, and the operator believed the run was persisted. The fold cannot be rolled
827
+ // back here, so the failure is made durable + visible instead (`onWriteFailure` feeds the
828
+ // same `diagnostics` list that `report()` prints under `## ⚠ 状态诊断`) and also logged.
829
+ const outcome = await writeTextAbs(pathOf(), JSON.stringify(snapshot, null, 2))
830
+ if (outcome === undefined) {
831
+ try { onWriteFailure(pathOf()) } catch (e) { /* diagnostics must never break the chain */ }
832
+ try { console.error('vibe-math-v5: state write FAILED for ' + pathOf() + ' — the in-memory state advanced but the file is STALE') } catch (e) { /* ignore */ }
833
+ }
603
834
  return true
604
835
  })
605
836
  return mem
@@ -741,6 +972,15 @@ export function apply(ctx) {
741
972
  const roundsSinceCompact = new Map()
742
973
  const contextPct = new Map()
743
974
  const needReanchor = new Set()
975
+ // G1 (mailbox): ids already PREPENDED to a prompt for this member but not yet acked. The prompt can
976
+ // legitimately be built more than once before the wake (that was the v4 §24 duplicate-read defect),
977
+ // so the injection mark keeps "at most one inbox section per prompt" while the messages stay pending
978
+ // until the send actually succeeds. Cleared on every concluded wake attempt.
979
+ const inboxInjected = new Map()
980
+ // G1: members whose mail `promptFor` is prepending IN THIS BUILD — `briefBlock` must then omit its
981
+ // own `[新到的消息/通知]` block, or the same messages appear twice in one prompt (the duplicate-read
982
+ // defect the prompt-v5-integrity suite pins). Only set while a block is actually being prepended.
983
+ const inboxSuppressed = new Set()
744
984
  const seeds = new Map() // memberId -> condensed self-summary seed
745
985
  const lastActiveAt = new Map()
746
986
  let currentMember = ''
@@ -760,10 +1000,43 @@ export function apply(ctx) {
760
1000
  let backend = null
761
1001
  let stateCache = null
762
1002
 
1003
+ // G-7 (installer ROUND2, safety): the host's `sandboxPolicy.resolve()` may fall back to a WIDER
1004
+ // root than this session's workspace (the host lib catches and resolves `{}` on its own errors).
1005
+ // v5 cannot override the host fence — it passes the policy straight to `fs.writeText` — but it
1006
+ // CAN compare the root it believes in (the session cwd, which every member-facing path is built
1007
+ // from) with the root the policy advertises, and surface a NAMED warning when they disagree.
1008
+ // Recorded once per session (a diagnostic, never a write refusal: refusing would silently stop
1009
+ // persisting legitimate work, and v5 has no way to widen or narrow the host's fence itself).
1010
+ let sandboxPolicyChecked = false
1011
+ let sandboxPolicyMismatch = null
1012
+ function sessionCwd() {
1013
+ try { if (rootAgent && rootAgent.session && rootAgent.session.header && rootAgent.session.header.cwd) return String(rootAgent.session.header.cwd) } catch (e) { /* unknown */ }
1014
+ return ''
1015
+ }
1016
+ const normRoot = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
1017
+ function checkSandboxPolicyRoot() {
1018
+ if (sandboxPolicyChecked) return
1019
+ sandboxPolicyChecked = true
1020
+ try {
1021
+ const sp = sandboxPolicyOf()
1022
+ const policyRoot = sp && sp.workspaceRoot ? String(sp.workspaceRoot) : ''
1023
+ const expected = sessionCwd()
1024
+ if (!policyRoot || !expected) return
1025
+ if (normRoot(policyRoot) !== normRoot(expected)) {
1026
+ sandboxPolicyMismatch = {
1027
+ at: now(), expected, policyRoot,
1028
+ note: '沙箱策略声明的可写根与会话工作目录不一致:v5 用会话工作目录拼所有路径(成员提示词/状态/Formal/Verified),'
1029
+ + '若宿主按策略根解析写操作,产物可能落在预期之外的那棵树。请核对 sandbox 策略的 workspaceRoot。',
1030
+ }
1031
+ console.error('vibe-math-v5: ⚠ 沙箱策略根与会话工作目录不一致(策略=' + policyRoot + ',会话=' + expected + '):写盘位置可能不是你预期的那棵树')
1032
+ }
1033
+ } catch (e) { /* a diagnostic must never break path resolution */ }
1034
+ }
763
1035
  function workspaceRoot() {
764
- try { if (rootAgent && rootAgent.session && rootAgent.session.header && rootAgent.session.header.cwd) return rootAgent.session.header.cwd } catch (e) { /* fall through */ }
1036
+ const cwd = sessionCwd()
1037
+ if (cwd) { checkSandboxPolicyRoot(); return cwd }
765
1038
  const sp = sandboxPolicyOf()
766
- if (sp && sp.workspaceRoot) return sp.workspaceRoot
1039
+ if (sp && sp.workspaceRoot) { checkSandboxPolicyRoot(); return sp.workspaceRoot }
767
1040
  return '.'
768
1041
  }
769
1042
  const vibeRoot = () => (workspaceRoot() + '/VibeMath').replace(/\\/g, '/')
@@ -777,6 +1050,49 @@ export function apply(ctx) {
777
1050
  // writes keep going through `instRoot()`. `instRel()` is the one place that composes it.
778
1051
  const instRootRel = () => 'VibeMath/Projects/' + project + '/Institutes/' + instituteName
779
1052
  const instRel = (rel) => instRootRel() + '/' + String(rel == null ? '' : rel).replace(/^\/+/, '')
1053
+ // CLASS GUARD (lead's re-verification round): "documented member write paths must be paths the
1054
+ // framework scans/reads". `instRootRel()` (what the member-facing text advertises) and
1055
+ // `instRoot()` (what `writeTextRel`/`readTextRel`/`readLibrary`/`findCardRel`/`mkdirs` actually
1056
+ // use) are two COMPOSITIONS of the same layout: if a future edit changes one and not the other,
1057
+ // the text advertises a directory nothing writes to (exactly the v4 defect found in 58fff7f).
1058
+ // Comparing the two compositions — instead of trusting two hand-copied strings — is what makes
1059
+ // that class impossible to reintroduce silently.
1060
+ function memberPathContractOk() {
1061
+ const probe = 'Members/__probe__/Progress/progress.md'
1062
+ const documented = instRel(probe)
1063
+ const abs = (instRoot() + '/' + probe).replace(/\\/g, '/')
1064
+ const ws = String(workspaceRoot() || '').replace(/\\/g, '/')
1065
+ if (!ws || ws === '.') return true // no cwd to strip: nothing to compare
1066
+ const actual = abs.indexOf(ws) === 0 ? abs.slice(ws.length).replace(/^\/+/, '') : abs
1067
+ if (documented !== actual) {
1068
+ console.error('vibe-math-v5: member library path contract BROKEN — the member-facing text says "'
1069
+ + documented + '" but the framework writes/reads "' + actual + '"')
1070
+ return false
1071
+ }
1072
+ return true
1073
+ }
1074
+ // LEAN CLASS GUARD (L1 of the Lean deep review, same spirit as `memberPathContractOk`): the path
1075
+ // the Lean prompts tell a member to write must be RESOLVABLE BY THE TOOL THAT CONSUMES IT.
1076
+ // `leanAbsPath` is called long after this point, so it is looked up lazily via the closure (it is
1077
+ // a function declaration inside this scope, hence hoisted) and the comparison is between the
1078
+ // COMPOSITIONS on both sides — never two hand-copied strings.
1079
+ function leanPathContractOk() {
1080
+ const probe = 'Formal/__probe__.lean'
1081
+ const expected = normalizeAbsPath(instRoot() + '/' + probe)
1082
+ // The EXACT composition the prompt prints (`formalRootRel()`), plus the two spellings a member
1083
+ // may pass to a tool. All three must name the file the framework uses.
1084
+ const printedProbe = formalRootRel().replace(/\/+$/, '') + '/__probe__.lean'
1085
+ const viaPrinted = leanAbsPath(printedProbe)
1086
+ const viaDocumented = leanAbsPath(instRel(probe)) // what the prompt advertises (cwd-relative)
1087
+ const viaShort = leanAbsPath(probe) // the tools' short form (institute-relative)
1088
+ if (viaPrinted !== expected || viaDocumented !== expected || viaShort !== expected) {
1089
+ console.error('vibe-math-v5: Lean path contract BROKEN — printed "' + printedProbe
1090
+ + '" resolves to ' + String(viaPrinted) + ', documented "' + instRel(probe) + '" to '
1091
+ + String(viaDocumented) + ', short form to ' + String(viaShort) + ', but the framework uses ' + expected)
1092
+ return false
1093
+ }
1094
+ return true
1095
+ }
780
1096
 
781
1097
  function getPolicy() {
782
1098
  const sp = sandboxPolicyOf()
@@ -803,7 +1119,9 @@ export function apply(ctx) {
803
1119
  async function writeTextRel(rel, content) { return (await writeTextAbs(instRoot() + '/' + rel, content)) !== undefined }
804
1120
 
805
1121
  function installBackend() {
806
- backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
1122
+ backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json',
1123
+ (p) => noteWriteProblem(p), // MEDIUM 7: a failed write must be visible, never swallowed
1124
+ (text) => { lastLoadProblem = text; noteLoadProblem(text) }) // S2: a rejected file is visible on READS too
807
1125
  return backend
808
1126
  }
809
1127
  // The ONE call site that awaits the backend's load (the sensitivity probe for
@@ -817,9 +1135,24 @@ export function apply(ctx) {
817
1135
  // backend's `mem` is still the empty initial state, so a fresh process would report
818
1136
  // an empty roster and `resume` would refuse with "no active member to resume" —
819
1137
  // i.e. the fallback would silently lose the whole institute across a restart.
1138
+ // MEDIUM 8 (deep review): "a read must be preceded by an awaited load" was only a CONVENTION.
1139
+ // `state()` stays synchronous by design (the whole fold reads through it), so it can only
1140
+ // START the load — a read that lands before that load settles sees the empty/previous `mem`.
1141
+ // Enforcement is impossible without making every read async, so the rule is now explicit and
1142
+ // OBSERVABLE:
1143
+ // • SAFE (they all `await ready()` first): every `registerTool` handler, the `/v5` command,
1144
+ // `commit()` (it awaits `awaitBackendLoad()` before touching `mem`) and `schedulePass()`
1145
+ // (see its `ready()` call).
1146
+ // • UNSAFE by construction: any other synchronous consumer of the session API
1147
+ // (`inst()`/`state()` called from inside ANOTHER plugin, or from a raw host callback that
1148
+ // does not go through a tool wrapper). Those get a counter (`prematureReads`) surfaced by
1149
+ // `status()`/`report()` so the gap is visible instead of silent.
1150
+ let loadSettled = false
1151
+ let prematureReads = 0
820
1152
  async function ready() {
821
1153
  if (!backend) installBackend()
822
1154
  await awaitBackendLoad()
1155
+ loadSettled = true
823
1156
  // Crash recovery for the Lean queue runs ONCE per session, after the state file is
824
1157
  // folded in: a leftover Formal/Jobs/*.json is re-queued or explicitly interrupted, and
825
1158
  // is NEVER silently turned into `passed` (docs/formal-verification.md §7-7).
@@ -829,6 +1162,11 @@ export function apply(ctx) {
829
1162
  // The math availability line is derived at session start (and on every tuning that
830
1163
  // touches the six math_* keys); `probeMathEngines` caches per host, so this is cheap.
831
1164
  try { await refreshMathLine() } catch (e) { console.error('vibe-math-v5: math probe: ' + String((e && e.message) || e)) }
1165
+ // The member-library path contract (documented root == the root the framework writes/reads).
1166
+ // A broken contract is recorded in `diagnostics` so `report()` shows it instead of leaving a
1167
+ // silent "members write where nothing reads" state.
1168
+ try { if (!memberPathContractOk()) noteLoadProblem('member library path contract broken (documented root != framework root)') } catch (e) { /* never break ready() */ }
1169
+ try { if (!leanPathContractOk()) noteLoadProblem('Lean path contract broken (a documented Lean path is not resolvable by the tool that consumes it)') } catch (e) { /* never break ready() */ }
832
1170
  return true
833
1171
  }
834
1172
  // Load the CURRENT state path before its first read. `state()` is synchronous by design
@@ -846,6 +1184,9 @@ export function apply(ctx) {
846
1184
  // not been folded in yet (the 2.4.1 audit's H1: a read-less `state()` was half of the
847
1185
  // overwrite). `ready()`/`commit()` await the same load; here it only has to START.
848
1186
  ensureLoaded()
1187
+ // MEDIUM 8: a read that lands before the first successful load is counted (and reported),
1188
+ // instead of silently answering from the empty/previous snapshot.
1189
+ if (!loadSettled) prematureReads += 1
849
1190
  stateCache = backend.read()
850
1191
  return stateCache
851
1192
  }
@@ -871,8 +1212,28 @@ export function apply(ctx) {
871
1212
  if (t && pendingLoadNotes.indexOf(t) === -1) pendingLoadNotes.push(t)
872
1213
  if (pendingLoadNotes.length > 5) pendingLoadNotes = pendingLoadNotes.slice(-5)
873
1214
  }
1215
+ // MEDIUM 7 (deep review): a FAILED state write cannot be rolled back (the fold already
1216
+ // advanced the in-memory state), so it must at least be impossible to miss. Counted for
1217
+ // `status()`/`report()` AND queued for the next allowed commit's diagnostics.
1218
+ let stateWriteFailures = 0
1219
+ // S2: the ACTIONABLE reason a state file that exists was rejected (empty while everything is
1220
+ // fine). Surfaced by `status()`/`report()` on the read path, not only after a refused write.
1221
+ let lastLoadProblem = ''
1222
+ // F1 (status/report review): a SESSION-LEVEL log of load/write problems. `drainLoadNotes` used to
1223
+ // write them into `stateCache` only — which the very next `state()` read replaced with the
1224
+ // backend's `mem` — so they could vanish between commits and, worse, they were never the array
1225
+ // `report()` printed (`report()` read the INSTITUTE-level diagnostics). Keeping a session log is
1226
+ // what lets the diagnostics section show the sources its heading advertises.
1227
+ let loadProblemLog = []
1228
+ function noteWriteProblem(p) {
1229
+ stateWriteFailures += 1
1230
+ noteLoadProblem('state write FAILED (the file is STALE): ' + String(p || '?'))
1231
+ }
874
1232
  function drainLoadNotes() {
875
- if (!pendingLoadNotes.length || !stateCache) return
1233
+ if (!pendingLoadNotes.length) return
1234
+ loadProblemLog = loadProblemLog.concat(pendingLoadNotes.map((t) => ({ at: now(), type: 'vibe5/load', error: t })))
1235
+ if (loadProblemLog.length > 10) loadProblemLog = loadProblemLog.slice(-10)
1236
+ if (!stateCache) { pendingLoadNotes = []; return }
876
1237
  const list = (stateCache.diagnostics || []).concat(pendingLoadNotes.map((t) => ({ at: now(), type: 'vibe5/load', error: t })))
877
1238
  pendingLoadNotes = []
878
1239
  stateCache = Object.assign({}, stateCache, { diagnostics: list.slice(-50) })
@@ -896,6 +1257,12 @@ export function apply(ctx) {
896
1257
  const cur = stateCache.institutes[key]
897
1258
  if (cur) {
898
1259
  phase = cur.phase || phase
1260
+ // S1 (deep-review 4 / repro: `_oneoff/s1-solved-reload.mjs`): the CONCLUDED marker is
1261
+ // durable state, not a session memory. `autoDone` used to live only in memory, so a fresh
1262
+ // session over a `phase:'solved'` file had `autoDone=false` and `/v5 start` re-founded the
1263
+ // concluded institute (new runId, new members on top of the old roster). Mirroring the
1264
+ // persisted phase makes every existing `autoDone` guard correct again on reload.
1265
+ autoDone = String(phase) === 'solved'
899
1266
  // Restored params must go through the SAME normalisation as `vibe_v5_set`: a hand-edited
900
1267
  // or foreign-shaped state file (e.g. mathEngines as a comma STRING, a bogus enum, a
901
1268
  // sub-1000 timeout) would otherwise reach `visibleParams()` and v5's own
@@ -913,15 +1280,22 @@ export function apply(ctx) {
913
1280
  const patchInstitute = (patch) => commit(EV.institute, { patch })
914
1281
  const putMember = (member) => commit(EV.member, { member })
915
1282
  const putTask = (task) => commit(EV.task, { task })
916
- const putMessage = (message) => commit(EV.message, { message })
1283
+ // HIGH 1: creation goes through an in-fold allocator — the callback runs INSIDE the fold, so
1284
+ // the id it hands out and the object it returns are committed in one step (makeIdAllocator).
1285
+ // The old whole-object creation setters (`putMessage`/`putMeeting`/`putCounters`) are GONE on
1286
+ // purpose: reusing one would mint an id from a read taken outside the fold and reintroduce the
1287
+ // silent-loss race this fix removes. Updates use `putMember`/`putTask` (already-identified
1288
+ // objects) and, for messages, the same allocator.
1289
+ const putMessageMake = (make) => commit(EV.message, { make })
1290
+ // HIGH 3: the "is it solved?" tally is durable state, not an in-memory Map.
1291
+ const putSolve = (patch) => commit(EV.solve, patch || {})
917
1292
  const ackDelivered = (ids) => commit(EV.delivered, { ids })
918
- const putMeeting = (index) => commit(EV.meeting, { index })
919
1293
  const putDebate = (index) => commit(EV.debate, { index })
920
1294
  const putVerdict = (target, record) => commit(EV.verdict, { target, record })
921
1295
  // The verify queue has NO whole-array setter any more: both of its mutations go through
922
1296
  // `appendToQueue`/`takeQueueHead` below, which hand a FUNCTION to the event fold so the
923
- // read-modify-write is atomic (the lost-proposal race).
924
- const putCounters = (counters) => commit(EV.counters, { counters })
1297
+ // read-modify-write is atomic (the lost-proposal race). Counters are bumped by the object
1298
+ // folds themselves (`makeIdAllocator`), so there is no `counters` setter either.
925
1299
  const markProgress = async () => {
926
1300
  lastProgressAt = now()
927
1301
  await commit(EV.progress, { at: lastProgressAt, artifactCount: inst().artifactCount })
@@ -1063,7 +1437,7 @@ export function apply(ctx) {
1063
1437
  ].join('\n')
1064
1438
  }
1065
1439
  const LIB_SPEC = [
1066
- ' · Progress/<你>/progress.md —— **你的研究日志**(叙述体,可追加)。',
1440
+ ' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。',
1067
1441
  ' 主要内容是:尝试过的各方法、路线、历程、进度;当前研究进展/进度;将来的计划与打算;',
1068
1442
  ' 及各路线、过程中遇到的障碍及其原因;对各路线、方法的看法、可行性评估;自己研究过程',
1069
1443
  ' 中的一些有价值看法、感想、猜想、理解。以及其它各种你认为有价值的值得记录的事物、',
@@ -1077,20 +1451,20 @@ export function apply(ctx) {
1077
1451
  ' ▸ 写法建议:按时间追加,每次记一小节;把"结论/进展"与"理由/证据"分开写;',
1078
1452
  ' 悬而未决的问题明确标出。',
1079
1453
  '',
1080
- ' · Propos/<你>/<id>.md —— **你的命题/引理**。格式:',
1454
+ ' · Members/<你>/Propos/<id>.md —— **你的命题/引理**。格式:',
1081
1455
  ' - ID: p-<id>; - 状态: 未定论; - 概率: <0-1>; - 价值程度: <0-1>; - 动机用途计划: <为何重要/打算怎么用>',
1082
1456
  ' 然后 ## 陈述 <完整陈述>;## 证明尝试;## 证伪尝试。',
1083
- ' · Methods/<你>/<id>.md —— **你的理论/方法/工具**。格式:',
1457
+ ' · Members/<你>/Methods/<id>.md —— **你的理论/方法/工具**。格式:',
1084
1458
  ' - ID: m-<id>; - 状态: 经验; - 可信断言: []; - 价值程度: <0-1>; - 动机用途计划: ...',
1085
1459
  ' 然后 ## 核心内容;## 定义与记号;## 应用记录;## 改进历史。',
1086
- ' · Subproblems/<你>/<id>.md —— **你的子问题**。格式:',
1460
+ ' · Members/<你>/Subproblems/<id>.md —— **你的子问题**。格式:',
1087
1461
  ' - ID: s-<id>; - 状态: 求解中; - 价值程度: <0-1>; - 动机用途计划: ...',
1088
1462
  ' 然后 ## 陈述;## 进度。',
1089
1463
  '',
1090
1464
  ' 三条硬要求:',
1091
1465
  ' ① 凡入库必须写明 **价值程度 / 动机用途计划 / 你对该对象为真的概率估计**(缺一不可);',
1092
1466
  ' ② **只写自己的库**;读别人的库是允许且被鼓励的;',
1093
- ' ③ 推荐**直接用 fs 写你自己的文件**;vibe_v5_record_* 只是便捷记录器,不是必需。',
1467
+ ' ③ 推荐**直接用 fs 写你自己的文件**(路径必须带成员库根,见上:Members/<你>/…);vibe_v5_record_* 只是便捷记录器,不是必需。',
1094
1468
  ].join('\n')
1095
1469
 
1096
1470
  // Computed per hire (a FUNCTION, not a frozen const): the text names the live
@@ -1259,8 +1633,8 @@ export function apply(ctx) {
1259
1633
  // ── 三、libraries ───────────────────────────────────────────────────
1260
1634
  L.push('【三、你的资料库、progress 与卡片格式】')
1261
1635
  L.push(' 你的资料库根目录(**相对会话工作目录**):' + instRel('Members/' + member.id + '/'))
1262
- L.push(' (以下路径都相对该目录。你**只写这里**,但可以读任何人的对应目录。)')
1263
- L.push(' ⚠ 路径基准:你自己的文件工具的**相对路径以会话工作目录为基准**,所以直接读写文件时必须用上面这条完整路径;')
1636
+ L.push(' (`<你>` = 你自己的成员 id:下面每条都已带完整库根,可直接照抄。你**只写自己的库**,但可以读任何人的对应目录。)')
1637
+ L.push(' ⚠ 路径基准:你自己的文件工具的**相对路径以会话工作目录为基准**,所以直接读写文件时必须用**带库根的完整路径**(把下面每条里的 `Members/<你>/` 展开成你自己的 id,例如 Members/acad/Progress/progress.md);')
1264
1638
  L.push(' 若只想记录成果,直接用 vibe_v5_record_progress / vibe_v5_record_proposition / vibe_v5_record_method / vibe_v5_record_subproblem,框架会写到正确位置。')
1265
1639
  L.push('')
1266
1640
  L.push(LIB_SPEC)
@@ -1392,13 +1766,17 @@ export function apply(ctx) {
1392
1766
  // only AFTER the subagent had already been started, every member's induction brief
1393
1767
  // named the PREVIOUSLY founded member (the academician was told it was "?"). The
1394
1768
  // model's whole self-model, its library path and its vote were therefore wrong.
1395
- function briefBlock(member) {
1769
+ // `roundNo` is an explicit override for the round this block is being built FOR. The founding
1770
+ // prompt needs it: `spawnMember` now counts the round only after `startContinuable` succeeds, so
1771
+ // without the override the very first prompt would announce 轮次 0 instead of 轮次 1 (spawnMember/
1772
+ // G2-class: a failed start must not consume a round, but the first prompt must still show round 1).
1773
+ function briefBlock(member, roundNo) {
1396
1774
  if (!member || typeof member.id !== 'string' || !member.id) {
1397
1775
  throw v5err('V5_INTERNAL', 'briefBlock: a member is required (a status block must never be built for an unknown identity)')
1398
1776
  }
1399
1777
  const ms = activeMembers()
1400
1778
  const b = []
1401
- b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (rounds.get(member.id) || 0) +
1779
+ b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (roundNo !== undefined ? roundNo : (rounds.get(member.id) || 0)) +
1402
1780
  '|法定票数 m=' + (voterCount() > 0 ? quorumM() : '未启动') + '|有表决权者 ' + voterCount() + ' 人')
1403
1781
  b.push('[在册] ' + (ms.length ? ms.map((x) => x.id).join('、') : '(无)'))
1404
1782
  // Members that are on the books but NOT on the floor. Silently omitting them made a
@@ -1431,7 +1809,7 @@ export function apply(ctx) {
1431
1809
  if (t.description) b.push(' ' + String(t.description).split('\n')[0])
1432
1810
  }
1433
1811
  }
1434
- const pending = pendingFor(member.id)
1812
+ const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)
1435
1813
  if (pending.length) {
1436
1814
  b.push('[新到的消息/通知]')
1437
1815
  for (const p of pending) b.push(' ' + p.line)
@@ -1555,22 +1933,28 @@ export function apply(ctx) {
1555
1933
  let targets
1556
1934
  if (to === 'all' || to === '') targets = activeMembers().filter((m) => m.id !== from)
1557
1935
  else if (to === 'voters') targets = voters().filter((m) => m.id !== from)
1558
- else {
1936
+ else if (to === OFFICE_INBOX) {
1937
+ // MEDIUM 4: an office-addressed request. The office has no member id and is never woken,
1938
+ // so this is a durable note the office reads from `status()`/`report()` on its next turn.
1939
+ targets = [{ id: OFFICE_INBOX }]
1940
+ } else {
1559
1941
  const t = memberById(to)
1560
1942
  if (!t || t.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + to + '" not found' }
1561
1943
  if (t.id === from) return { ok: false, code: 'V5_SELF_MESSAGE', message: 'cannot message yourself' }
1562
1944
  targets = [t]
1563
1945
  }
1564
1946
  if (!targets.length) return { ok: true, delivered: 0, note: 'no other active member' }
1565
- const counters = Object.assign({}, inst().counters)
1566
- let n = Math.max(Number(counters.message) || 0, 0)
1567
1947
  const at = now()
1568
- for (const t of targets) {
1569
- n += 1
1570
- await putMessage({ id: 'msg-' + n, from, to: t.id, kind, text, at })
1571
- }
1572
- counters.message = n
1573
- await putCounters(counters)
1948
+ // HIGH 1 (deep review): the ids are allocated INSIDE the fold and all recipients go into ONE
1949
+ // commit. The old shape read `counters` here, minted `msg-N` locally and awaited one
1950
+ // `putMessage` per recipient: a second same-tick `say` minted the SAME ids, and the
1951
+ // `EV.message` fold deduped them away — the message vanished while this function still
1952
+ // returned `ok:true`.
1953
+ const made = []
1954
+ await putMessageMake((alloc) => {
1955
+ for (const t of targets) made.push({ id: alloc.next('message'), from, to: t.id, kind, text, at })
1956
+ return made
1957
+ })
1574
1958
  // The office talking to the institute is HALF of the `paperEditor='office'` consultation
1575
1959
  // requirement; counting it here (the one place a message really is queued) means the gate
1576
1960
  // cannot be satisfied by merely intending to consult (docs/final-paper.md §7).
@@ -1580,7 +1964,7 @@ export function apply(ctx) {
1580
1964
  // recipient promptly instead of waiting for the digest window. Plain chat stays
1581
1965
  // batched because deliveryDecision gates it — the kick only starts the pass.
1582
1966
  scheduleNext().catch(() => {})
1583
- return { ok: true, delivered: targets.length, to: targets.map((t) => t.id).join(',') }
1967
+ return { ok: true, delivered: targets.length, to: targets.map((t) => t.id).join(','), ids: made.map((m) => m.id) }
1584
1968
  }
1585
1969
  // A framework NOTICE to one member. This must NOT be sent as the member itself:
1586
1970
  // `say()` refuses a self-addressed message (V5_SELF_MESSAGE), so the previous
@@ -1654,14 +2038,11 @@ export function apply(ctx) {
1654
2038
  // DSH's "names are immortal" rule) so a re-hired temp can never inherit a
1655
2039
  // dismissed member's archives or task ownership.
1656
2040
  async function newMember(kind, opts) {
1657
- const counters = Object.assign({}, inst().counters)
1658
- let id
1659
- if (kind === 'academician') { counters.academician = Math.max(1, Number(counters.academician) || 0); id = 'acad' }
1660
- else if (kind === 'researcher') { counters.researcher = (Number(counters.researcher) || 0) + 1; id = 'r-' + counters.researcher }
1661
- else { counters.temp = (Number(counters.temp) || 0) + 1; id = 't-' + counters.temp }
1662
- await putCounters(counters)
1663
2041
  const member = {
1664
- id, kind,
2042
+ // HIGH 1: the id is EMPTY here on purpose — the fold's allocator fills it (and bumps
2043
+ // `counters.<kind>`) inside the same commit, so two concurrent hires cannot mint the same
2044
+ // `r-N`/`t-N` and silently overwrite each other's roster entry.
2045
+ id: '', kind,
1665
2046
  childId: '',
1666
2047
  phase: 'provisioning',
1667
2048
  direction: String((opts && opts.direction) || ''),
@@ -1669,17 +2050,21 @@ export function apply(ctx) {
1669
2050
  term: (opts && opts.term) || '',
1670
2051
  provider: String((opts && opts.provider) || 'spawn'),
1671
2052
  // The charter is FROZEN at hire time — it says "你入职时的在册编制(这是一份**快照**)".
1672
- // It therefore has to be CAPTURED here, not at the first successful spawn: a member
2053
+ // It therefore has to be CAPTURED inside the fold, right after the id is known: a member
1673
2054
  // the host's live-child cap refused is recorded `failed` and only spawned later by
1674
2055
  // `resume`, and rebuilding the charter then would describe the resume-time roster
1675
2056
  // (`memberPersona(member)` would also silently drop any `staffPersona` change).
1676
- persona: memberPersona({ id, kind, direction: String((opts && opts.direction) || ''), hiredBy: (opts && opts.hiredBy) || '' }),
2057
+ persona: '',
1677
2058
  error: '',
1678
2059
  createdAt: now(),
1679
2060
  dismissedAt: 0,
1680
2061
  dismissReason: '',
1681
2062
  }
1682
- await putMember(member)
2063
+ await commit(EV.member, { make: (alloc) => {
2064
+ member.id = alloc.next(kind)
2065
+ member.persona = memberPersona(member)
2066
+ return member
2067
+ } })
1683
2068
  return member
1684
2069
  }
1685
2070
  function memberPersona(member) {
@@ -1773,15 +2158,18 @@ export function apply(ctx) {
1773
2158
  wakeKind.set(member.id, kind)
1774
2159
  currentMember = member.id
1775
2160
  lastActiveAt.set(member.id, now())
1776
- rounds.set(member.id, (rounds.get(member.id) || 0) + 1)
1777
- roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
2161
+ // G2-class (spawnMember): the counters are COMPUTED here but applied only after `startContinuable`
2162
+ // succeeds (see below). `startRound` is also passed into `initialPrompt` explicitly, so the founding
2163
+ // prompt announces the round it is actually starting instead of 0.
2164
+ const startRound = (rounds.get(member.id) || 0) + 1
2165
+ const startRoundsSinceCompact = (roundsSinceCompact.get(member.id) || 0) + 1
1778
2166
  // The charter is FROZEN at hire time (it is the durable "seal" record and it says
1779
2167
  // "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
1780
2168
  // hire-time snapshot into a resume-time one and make the sentence untrue. `newMember`
1781
2169
  // captures it, so every member has one by the time it can be spawned.
1782
2170
  const persona = member.persona || memberPersona(member)
1783
2171
  member.persona = persona
1784
- const prompt = initialPrompt(member, initialTask, mode)
2172
+ const prompt = initialPrompt(member, initialTask, mode, startRound)
1785
2173
  await putMember(member)
1786
2174
  await mkdirs()
1787
2175
  await writeRosterMirror()
@@ -1803,10 +2191,11 @@ export function apply(ctx) {
1803
2191
  // Roll the in-memory marks back so a failed provisioning leaves no phantom
1804
2192
  // "busy, round 1" member behind; the member record itself goes to `failed` and
1805
2193
  // the caller's catch reports it.
2194
+ // The counters are NOT touched: they were never applied on this attempt (they are set only
2195
+ // after a successful start), and the previous rollback used `rounds.delete(...)`, which also
2196
+ // ERASED the count of a member being RESUMED — a failed resume then restarted its numbering.
1806
2197
  busy.delete(member.id)
1807
2198
  wakeKind.delete(member.id)
1808
- rounds.delete(member.id)
1809
- roundsSinceCompact.delete(member.id)
1810
2199
  // The host's live-child cap is a HOST limit (maxActiveSubagents on the `subagent` row), not
1811
2200
  // a defect in this member: remember the ceiling, report it BY NAME instead of relaying the
1812
2201
  // opaque host string, and throw the TYPED error so the founding loop / hire / addResearcher
@@ -1814,12 +2203,16 @@ export function apply(ctx) {
1814
2203
  const limitHit = isActivationLimitReached(e)
1815
2204
  if (limitHit) { noteChildLimit(e); noteSpawnLimitOnce(member) }
1816
2205
  const message = limitHit ? activationLimitText(hostChildLimit) : String((e && e.message) || e)
1817
- await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: message }))
2206
+ await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: message, failReason: limitHit ? 'activation-limit' : 'other' }))
1818
2207
  if (limitHit) throw v5err('ACTIVATION_LIMIT_REACHED', message)
1819
2208
  throw e
1820
2209
  }
1821
2210
  member.childId = started.childId
1822
2211
  childOwner.set(started.childId, sessionId)
2212
+ // G2-class (spawnMember): the child really exists now, so THIS is the point where the round counts.
2213
+ // A failed/invalid start above left both counters untouched (and no longer erased a resume's count).
2214
+ rounds.set(member.id, startRound)
2215
+ roundsSinceCompact.set(member.id, startRoundsSinceCompact)
1823
2216
  // Register the FOUNDING turn as in-flight, exactly like a normal wake does.
1824
2217
  // Without this the child's first `subagent/end` has no token to match, so
1825
2218
  // onMemberEnd would ignore it: the founding round would never be processed and
@@ -1844,8 +2237,11 @@ export function apply(ctx) {
1844
2237
  wakeKind.set(member.id, kind || 'normal')
1845
2238
  currentMember = member.id
1846
2239
  lastActiveAt.set(member.id, now())
1847
- rounds.set(member.id, (rounds.get(member.id) || 0) + 1)
1848
- roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
2240
+ // G2 (F10, ported from v4:1930-1931 — "a failed send must not consume a round"): the two counters
2241
+ // are applied AFTER the successful send (below), not here. The values the soft-compact comparison
2242
+ // would have seen under the old ordering are computed here so the trigger boundary is unchanged.
2243
+ const nextRound = (rounds.get(member.id) || 0) + 1
2244
+ const nextRoundsSinceCompact = (roundsSinceCompact.get(member.id) || 0) + 1
1849
2245
  // Context directives. TWO distinct needs, and confusing them is what made
1850
2246
  // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the head of nearly every prompt
1851
2247
  // (v4 §24.1-③):
@@ -1862,16 +2258,17 @@ export function apply(ctx) {
1862
2258
  // are excluded because their replies are a different shape, and a directive there
1863
2259
  // could never be acknowledged.
1864
2260
  const wake = kind || 'normal'
2261
+ let softInjected = false
1865
2262
  if (wake === 'normal' || wake === 'checkpoint') {
1866
2263
  const soft = (contextPct.get(member.id) || 0) >= Number(params.compactThreshold) ||
1867
- (roundsSinceCompact.get(member.id) || 0) >= Number(params.compactAfterRounds)
2264
+ nextRoundsSinceCompact >= Number(params.compactAfterRounds)
1868
2265
  if (soft) {
1869
2266
  prompt = coreRules() + '\n[CONTEXT COMPACT — 你的对话已接近上限。不要重新推导历史。\n' +
1870
2267
  '请把当前工作状态浓缩成一段自述(已有发现、当前方向、已记录的关键成果、下一步具体动作、未决问题),' +
1871
2268
  '然后照常以 JSON 回答本轮。请在回复里填 "contextPct": 15 与 "compacted": true。]\n\n' + prompt
1872
- // Reset the counter WITH the injection so the directive cannot repeat on the
1873
- // very next round even if the member forgets to report `compacted`.
1874
- roundsSinceCompact.set(member.id, 0)
2269
+ // G2: the reset is applied only AFTER the send succeeds (below). A directive that never
2270
+ // reached the member must not clear the trigger it was asking the member to answer.
2271
+ softInjected = true
1875
2272
  }
1876
2273
  }
1877
2274
  if (needReanchor.has(member.id)) {
@@ -1881,6 +2278,11 @@ export function apply(ctx) {
1881
2278
  try {
1882
2279
  if (typeof subagents.sendMessage !== 'function') throw new Error('no subagents.sendMessage continuation API')
1883
2280
  await subagents.sendMessage(rootAgent, member.childId, [textBlock(prompt)], { signal: makeSignal(params.activityTimeoutMs) })
2281
+ // G2 (F10): the turn is really in flight NOW — only count it here, so a failed/invalid send
2282
+ // leaves both counters untouched (the old code counted before the send AND cleared the
2283
+ // soft-compact trigger on a send that never happened).
2284
+ rounds.set(member.id, nextRound)
2285
+ roundsSinceCompact.set(member.id, softInjected ? 0 : nextRoundsSinceCompact)
1884
2286
  return true
1885
2287
  } catch (e) {
1886
2288
  console.error('vibe-math-v5: wake ' + member.id + ' failed: ' + String((e && e.message) || e))
@@ -1937,6 +2339,12 @@ export function apply(ctx) {
1937
2339
  function replySpec(kind) {
1938
2340
  const L = []
1939
2341
  L.push('结束时请**只**输出一个 JSON 对象(放在 ```json 围栏内,围栏外不要有文字)。支持以下字段,除特别说明外都可省略:')
2342
+ // F2 (deep-review 5): the block below is a FIELD CATALOGUE — it carries `←` explanations and
2343
+ // `|`/`或` value hints, so it is deliberately NOT parseable JSON, while the sentence above
2344
+ // demands "only a JSON object". Saying that out loud (and giving a strictly valid template at
2345
+ // the end of the catalogue) removes the contradiction instead of leaving a model to guess.
2346
+ L.push('(下面是**字段目录**:`←` 后是说明,`|`/`或` 是取值提示,因此这一段并非合法 JSON;'
2347
+ + '**你真正要输出的对象必须严格合法**,最小形态见目录末尾的样例。)')
1940
2348
  L.push('{')
1941
2349
  L.push(' "say": "你想对全所说的话(群聊)" 或 {"to":"r-2","text":"…"}(私信) 或 {"to":"voters","text":"…"}(只对表决者),')
1942
2350
  L.push(' "progress": "本轮进展叙述(会被追加到你的 Progress/progress.md)",')
@@ -1972,22 +2380,37 @@ export function apply(ctx) {
1972
2380
  L.push(' 全体表决者(任务仍会执行,但你的理由不会被埋掉),')
1973
2381
  if (kind !== 'temp') {
1974
2382
  L.push(' "hire": {"purpose":"…","initial_task":"…","direction":"…"} ← 雇佣一名临时工(说明用途与初始任务),')
1975
- L.push(' "fire": {"id":"t-2","reason":"…"} ← 解雇(雇主/院士;你只能解雇你雇的),')
1976
- }
1977
- L.push(' "vote_solved": true|false, ← 你是否认为**原问题已解决**(会议/结题表决用;必须诚实)')
2383
+ // MEDIUM 5 (deep review): the hint must match the authority matrix EXACTLY. It used to say
2384
+ // "雇主/院士" without the `academicianLeads` precondition, and implied the academician could
2385
+ // dismiss a PERMANENT researcher (only the office can — `fire` refuses otherwise).
2386
+ L.push(' "fire": {"id":"t-2","reason":"…"} ← 解雇临时工(仅三种人:雇它的雇主本人、所办、以及 academicianLeads=true 时的院士;**常驻研究员只能由所办解聘**,成员只能向所办提议),')
2387
+ }
2388
+ // P1a (deep-review 4, re-scoped after the lead's correction): the unanimity rule DOES live
2389
+ // in the delivered charter (【十、停止】) and in the meeting frame (:2268), but NOT next to
2390
+ // this per-round field description — a member reading only the reply spec can misjudge what
2391
+ // a partial yes does. One truthful clause, right where the field is described.
2392
+ L.push(' "vote_solved": true|false, ← 你是否认为**原问题已解决**(会议/结题表决用;必须诚实)。'
2393
+ + '**只要有一位有表决权者没有填 true(漏填或填 false)就不会结题**——本所继续推进;'
2394
+ + '只有全体有表决权者都 true 时才会停止。')
1978
2395
  L.push(' "solved": false, ← 你这一轮的个人判断(框架据此了解全所收敛度)')
1979
2396
  L.push(' "contextPct": 40, ← 你当前上下文的占用百分比(0-100)')
1980
2397
  L.push(' "compacted": false ← 若框架要求你压缩,填 true 并在 progress 里写下浓缩后的工作状态')
1981
2398
  L.push('}')
2399
+ // F2: a strictly valid template, so "only a JSON object" has a concrete, parseable shape even
2400
+ // though the catalogue above is not JSON. Keep it minimal and legal (no comments, no unions).
2401
+ L.push('最小合法样例(可直接照抄,字段可增删):')
2402
+ L.push('```json')
2403
+ L.push('{"say":"…","progress":"…","solved":false,"contextPct":40}')
2404
+ L.push('```')
1982
2405
  return L.join('\n')
1983
2406
  }
1984
2407
  // Every prompt builder below passes the member it is addressing. There is
1985
2408
  // deliberately NO fallback to "the last member we happened to touch": guessing the
1986
2409
  // identity is what produced the wrong-identity briefs in the first place.
1987
- function stateBlock(member) {
1988
- return briefBlock(member)
2410
+ function stateBlock(member, roundNo) {
2411
+ return briefBlock(member, roundNo)
1989
2412
  }
1990
- function initialPrompt(member, initialTask, mode) {
2413
+ function initialPrompt(member, initialTask, mode, roundNo) {
1991
2414
  const L = []
1992
2415
  const resume = mode === 'resume'
1993
2416
  L.push(resume
@@ -2007,8 +2430,9 @@ export function apply(ctx) {
2007
2430
  if (initialTask) { L.push(resume ? '恢复说明:' : '你的初始任务/用途:'); L.push(' ' + initialTask); L.push('') }
2008
2431
  if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
2009
2432
  mathPushLine(L)
2433
+ paperPushLine(L)
2010
2434
  L.push('------------')
2011
- L.push(stateBlock(member))
2435
+ L.push(stateBlock(member, roundNo))
2012
2436
  L.push('------------')
2013
2437
  L.push(replySpec(member.kind))
2014
2438
  return L.join('\n')
@@ -2026,6 +2450,7 @@ export function apply(ctx) {
2026
2450
  }
2027
2451
  if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
2028
2452
  mathPushLine(L)
2453
+ paperPushLine(L)
2029
2454
  L.push('')
2030
2455
  L.push('------------')
2031
2456
  L.push(stateBlock(member))
@@ -2043,6 +2468,7 @@ export function apply(ctx) {
2043
2468
  L.push('如果你确实已无路可走或认为原问题接近解决,请说明你的判断与理由。')
2044
2469
  if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
2045
2470
  mathPushLine(L)
2471
+ paperPushLine(L)
2046
2472
  L.push('')
2047
2473
  L.push('------------')
2048
2474
  L.push(stateBlock(member))
@@ -2063,6 +2489,14 @@ export function apply(ctx) {
2063
2489
  L.push('')
2064
2490
  } else {
2065
2491
  L.push('(你是本次会议的第一位发言者,目前还没有别人发言。)')
2492
+ // F3 (deep-review 5; v4's counterpart states this): "first speaker" alone left two things
2493
+ // unsaid — when the others' opinions arrive, and how the meeting ends. Both statements below
2494
+ // match the code: `askMeetingRound` wakes up to `maxParallel` members per round and
2495
+ // `continueMeetingRound` keeps collecting until nobody is missing (the stall watchdog
2496
+ // handles a member that never answers), then `finalizeMeeting` writes the minutes.
2497
+ L.push('(流程:框架**每轮最多同时征询 maxParallel 名成员**(默认 3),把**已经收集到的**发言'
2498
+ + '附在唤醒提示里,直到所有在册成员都发过言;卡住的成员由会议看门狗处理。'
2499
+ + '收束时纪要与结论写入 Shared/Meetings/<会议id>.md 并同步到群聊。)')
2066
2500
  L.push('')
2067
2501
  }
2068
2502
  L.push('请就议程发表你的意见。分工、优先级、下一步做什么、是否认为原问题已解决,都可以说。')
@@ -2070,6 +2504,7 @@ export function apply(ctx) {
2070
2504
  L.push('如果你认为原问题已解决,请填 "vote_solved": true —— 只有当**全体有表决权者**都')
2071
2505
  L.push('一致认为是真时,本所才会停下来。')
2072
2506
  mathPushLine(L)
2507
+ paperPushLine(L)
2073
2508
  L.push('')
2074
2509
  L.push('------------')
2075
2510
  L.push(stateBlock(member))
@@ -2115,6 +2550,7 @@ export function apply(ctx) {
2115
2550
  L.push('**不要为了配合别人而改票,也不要为了让流程往前走而给出你不相信的 1 或 0。**')
2116
2551
  L.push('本所宁可留下未定论,也不要一个骗人的结论。')
2117
2552
  mathPushLine(L)
2553
+ paperPushLine(L)
2118
2554
  L.push('')
2119
2555
  L.push('------------')
2120
2556
  L.push(stateBlock(member))
@@ -2220,6 +2656,11 @@ export function apply(ctx) {
2220
2656
  const leanInitiative = () => (['off', 'normal', 'eager'].indexOf(String(params.leanInitiative)) !== -1 ? String(params.leanInitiative) : 'normal')
2221
2657
  const leanDailyOn = () => leanInitiative() !== 'off' && (formalOn() || leanInitiative() === 'eager')
2222
2658
  const formalRoot = () => instRoot() + '/Formal'
2659
+ // L1 (deep-review 5): the CWD-RELATIVE spelling of the institute's Lean work directory — the
2660
+ // one a member can actually produce with their own file tools (which resolve against the
2661
+ // session cwd). The prompts advertise THIS, and `leanAbsPath` accepts it as well as the
2662
+ // institute-relative short form (`Formal/x.lean`).
2663
+ const formalRootRel = () => instRootRel() + '/Formal'
2223
2664
  const formalLibRoot = () => vibeRoot() + '/Formal/Lib'
2224
2665
  const formalProvedRoot = () => vibeRoot() + '/Formal/Proved'
2225
2666
  const verifiedLeanRoot = () => instRoot() + '/Verified/Lean'
@@ -2264,7 +2705,14 @@ export function apply(ctx) {
2264
2705
  function leanAbsPath(rel) {
2265
2706
  const raw = String(rel == null ? '' : rel).trim()
2266
2707
  if (!raw) return null
2267
- const abs = (raw.charAt(0) === '/' || /^[a-z]:/i.test(raw)) ? raw : instRoot() + '/' + raw.replace(/^\.\//, '')
2708
+ // L1 (deep-review 5): BOTH accepted spellings must name the SAME file, because the member
2709
+ // text documents the CWD-relative complete path (what a member's own file tools produce)
2710
+ // while the tools' own short form is institute-relative. Stripping the documented prefix here
2711
+ // is what makes the prompt's path resolvable by the tool that consumes it.
2712
+ const documentedPrefix = instRootRel() + '/'
2713
+ let body = raw.replace(/^\.\//, '')
2714
+ if (body.indexOf(documentedPrefix) === 0) body = body.slice(documentedPrefix.length)
2715
+ const abs = (body.charAt(0) === '/' || /^[a-z]:/i.test(body)) ? body : instRoot() + '/' + body
2268
2716
  const norm = normalizeAbsPath(abs)
2269
2717
  const root = normalizeAbsPath(vibeRoot())
2270
2718
  if (norm !== root && norm.indexOf(root + '/') !== 0) return null
@@ -2293,9 +2741,10 @@ export function apply(ctx) {
2293
2741
  return { ok: false, code: 'NO_SUBPROCESS', message: 'the host exposes no subprocess service; Lean cannot be executed here', file: rel, ms: 0 }
2294
2742
  }
2295
2743
  const cap = Math.max(1000, Number(timeoutMs) || Number(params.leanTimeoutMs) || 120000)
2744
+ let leanVia = null
2296
2745
  let exe
2297
2746
  try {
2298
- exe = await sub.resolveExecutable(String(params.leanCommand || 'lean'))
2747
+ { const _lc = String(params.leanCommand || 'lean').trim(); const _r = await resolveKnownTool(sub, { name: 'lean', explicit: (_lc && _lc !== 'lean') ? _lc : '', kind: 'lean' }); exe = _r.exe; leanVia = _r.via; if (!exe) return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + _lc + '": ' + String(_r.reason || 'not found') + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, ms: now() - started, next: { kind: 'note', tried: _r.tried } } }
2299
2748
  } catch (e) {
2300
2749
  return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(params.leanCommand || 'lean') + '": ' + String((e && e.message) || e) + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, ms: now() - started }
2301
2750
  }
@@ -2360,7 +2809,7 @@ export function apply(ctx) {
2360
2809
  const ok = exitCode === 0
2361
2810
  return {
2362
2811
  ok, exitCode, signal: (outcome && outcome.signal) || null, ms,
2363
- command: argv.join(' '), file: rel,
2812
+ command: argv.join(' '), file: rel, leanFoundVia: leanVia,
2364
2813
  stdout: tail(out, 4000), stderr: tail(err, 4000),
2365
2814
  timedOut: timedOut || ms >= cap,
2366
2815
  code: ok ? undefined : ((timedOut || ms >= cap) ? 'LEAN_TIMEOUT' : 'LEAN_FAILED'),
@@ -2369,18 +2818,24 @@ export function apply(ctx) {
2369
2818
  async function formalSetRun(target, run) {
2370
2819
  const t = idSafe(String(target || ''))
2371
2820
  if (!t) return
2372
- const prev = formalOf(t)
2821
+ // MEDIUM 6 (deep review): the record is built INSIDE the fold (`record` as a function), so a
2822
+ // concurrent writer for the SAME target (e.g. the async Lean queue completing while a sync
2823
+ // run reports) can no longer overwrite it from a stale read.
2824
+ //
2373
2825
  // Running a file NEVER changes an already-decided status: a green run does not by
2374
2826
  // itself make an object `passed` (only archiving a proof does), and a failing scratch
2375
2827
  // run must not silently erase a recorded `passed`/`blocked` decision. Everything else
2376
2828
  // becomes `attempted`, which is the honest "we tried, see the compiler output" state.
2377
- const keep = (prev.status === 'passed' || prev.status === 'blocked') ? prev.status : 'attempted'
2378
- await putFormal(t, Object.assign({}, prev, {
2379
- status: keep,
2380
- file: run.file || prev.file || '',
2381
- run: { at: now(), ok: !!run.ok, exitCode: run.exitCode === undefined ? null : run.exitCode, ms: run.ms || 0, stdoutTail: tail(run.stdout, 800), stderrTail: tail(run.stderr, 800) },
2382
- updatedAt: now(),
2383
- }))
2829
+ await putFormal(t, (prev0) => {
2830
+ const prev = prev0 || { status: 'none' }
2831
+ const keep = (prev.status === 'passed' || prev.status === 'blocked') ? prev.status : 'attempted'
2832
+ return Object.assign({}, prev, {
2833
+ status: keep,
2834
+ file: run.file || prev.file || '',
2835
+ run: { at: now(), ok: !!run.ok, exitCode: run.exitCode === undefined ? null : run.exitCode, ms: run.ms || 0, stdoutTail: tail(run.stdout, 800), stderrTail: tail(run.stderr, 800) },
2836
+ updatedAt: now(),
2837
+ })
2838
+ })
2384
2839
  }
2385
2840
  // Withdrawing an archived proof means the file must stop looking like the object's proof.
2386
2841
  // The POLICY-COMPLIANT route is an overwrite through the injected `fs` service (it carries
@@ -2417,16 +2872,25 @@ export function apply(ctx) {
2417
2872
  const why = String(note || '').trim()
2418
2873
  if (!t) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'target is required' }
2419
2874
  if (!why) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "decision='defect' 必须写明 note(具体偏差)" }
2420
- const prev = formalOf(t)
2421
- const proofRel = String(prev.proof || ('Verified/Lean/' + t + '.lean'))
2422
- await putFormal(t, Object.assign({}, prev, {
2875
+ // MEDIUM 6 (deep review): BOTH halves are mutations applied inside the fold — the record
2876
+ // (built from the record the fold sees) and the TODO list (the old shape read
2877
+ // `formalTodo()`, pushed and wrote the array back, so a concurrent writer for another
2878
+ // target lost an entry).
2879
+ const proofRel = String(formalOf(t).proof || ('Verified/Lean/' + t + '.lean'))
2880
+ // Adv-verify S3 (found by `tests/audit-v5-lean-abstention.mjs`): this used to REPLACE the
2881
+ // whole `fidelity` object, so a SECOND member reporting a defect in the same round wiped the
2882
+ // first member's enforced-abstention marker (the audit's "marker length 2" assertion caught
2883
+ // it). The defect fields are updated, the marker history is preserved.
2884
+ await putFormal(t, (prev0) => Object.assign({}, prev0 || { status: 'none' }, {
2423
2885
  status: 'attempted', proof: '', decision: 'defect', note: why,
2424
- fidelity: { at: now(), by: String(memberId || ''), note: why }, updatedAt: now(),
2425
- }))
2886
+ fidelity: Object.assign({}, (prev0 && prev0.fidelity) || {}, { at: now(), by: String(memberId || ''), note: why }),
2887
+ updatedAt: now(),
2888
+ }), (todo0) => {
2889
+ const list = (todo0 || []).filter((x) => x.id !== t)
2890
+ list.push({ id: t, at: now(), why: '形式化不合格(忠实性缺陷):' + why })
2891
+ return list
2892
+ })
2426
2893
  const removed = await removeArchivedProof(proofRel)
2427
- const list = formalTodo().filter((x) => x.id !== t)
2428
- list.push({ id: t, at: now(), why: '形式化不合格(忠实性缺陷):' + why })
2429
- await putFormal(t, formalOf(t), list)
2430
2894
  await writeFormalTodo()
2431
2895
  await writeFormalIndex()
2432
2896
  // `removeArchivedProof` can fail on a host whose shell cannot delete AND whose
@@ -2564,7 +3028,7 @@ export function apply(ctx) {
2564
3028
  if (sub === undefined || typeof sub.spawn !== 'function') {
2565
3029
  return { ok: false, code: 'NO_SUBPROCESS', message: 'the host exposes no subprocess service; Lean cannot be executed here', file: rel }
2566
3030
  }
2567
- try { await sub.resolveExecutable(String(params.leanCommand || 'lean')) } catch (e) {
3031
+ try { const _lc2 = String(params.leanCommand || 'lean').trim(); const _r2 = await resolveKnownTool(sub, { name: 'lean', explicit: (_lc2 && _lc2 !== 'lean') ? _lc2 : '', kind: 'lean' }); if (!_r2.exe) return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + _lc2 + '": ' + String(_r2.reason || 'not found') + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, next: { kind: 'note', tried: _r2.tried } } } catch (e) {
2568
3032
  return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(params.leanCommand || 'lean') + '": ' + String((e && e.message) || e) + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel }
2569
3033
  }
2570
3034
  return { ok: true, rel, abs }
@@ -2656,6 +3120,12 @@ export function apply(ctx) {
2656
3120
  file: job.rel,
2657
3121
  proof: settledOk ? 'Verified/Lean/' + target + '.lean' : '',
2658
3122
  decision: 'used',
3123
+ // L7 (deep-review 5): `decision:'used'` is written by BOTH the member's reply and this
3124
+ // settle path; `decisionSource` says which one. Fallout #4 (F1d): it used to be
3125
+ // WRITE-ONLY — nothing read or asserted it — so it is now part of the readable surfaces
3126
+ // (`status().formal.objects[]`, `vibe_v5_lean_lib.objects`, the report's 形式化决定 line)
3127
+ // and it is asserted by `_oneoff/decision-source.mjs`.
3128
+ decisionSource: 'job-settle',
2659
3129
  note: (String(prev.note || '') + stale).trim(),
2660
3130
  run: job.run, async: asyncRec, updatedAt: now(),
2661
3131
  })
@@ -2699,6 +3169,14 @@ export function apply(ctx) {
2699
3169
  // (the "announcement point is the next prompt" discipline).
2700
3170
  function takeLeanNoticesFor(memberId) {
2701
3171
  if (!leanNotices.length) return []
3172
+ // L8 (deep-review 5): a notice whose member is no longer active can NEVER be delivered — it
3173
+ // used to sit in the 20-slot queue until a newer result evicted it. Prune those first; their
3174
+ // content stays reachable through `status().leanNotices`, `Formal/Jobs/<jobId>.json` and
3175
+ // `vibe_v5_lean_job {jobId}` (see L4), so nothing is lost, only the slot is freed.
3176
+ for (let i = leanNotices.length - 1; i >= 0; i--) {
3177
+ const m = memberById(leanNotices[i].member)
3178
+ if (!m || m.phase !== 'active') leanNotices.splice(i, 1)
3179
+ }
2702
3180
  const mine = leanNotices.filter((n) => n.member === memberId)
2703
3181
  if (!mine.length) return []
2704
3182
  for (const n of mine) { const i = leanNotices.indexOf(n); if (i !== -1) leanNotices.splice(i, 1) }
@@ -3053,6 +3531,15 @@ export function apply(ctx) {
3053
3531
  // is the "off means zero mention" discipline).
3054
3532
  function mathPushLine(L) { const b = mathPromptBlock('zh'); if (b) L.push(b) }
3055
3533
 
3534
+ // task-9 2b: the LaTeX-missing guidance (four bounded steps + three hard boundaries). Kept
3535
+ // BYTE-IDENTICAL with the other presets; per-preset voice may only follow this block.
3536
+ function paperHintBlock(lang) {
3537
+ return lang === 'en'
3538
+ ? '\nWhen no LaTeX engine is detected: (1) probe only the documented common TeX roots and PATH (e.g. where xelatex, latexmk --version) - never scan whole drives; (2) once the absolute path is found, write it into paperLatexCommand, re-detect, then continue; (3) if it is still missing, ask the user once (installing TeX requires the user\'s explicit approval); (4) with no answer, degrade exactly as today (deliver paper.tex and paper.md only). Hard boundaries: never auto-install; never write outside the workspace; never treat "not detected" as a failure.'
3539
+ : '\n检测不到 LaTeX 引擎时:① 只在文档化的常见 TeX 根与 PATH 上做有界核查(如 where xelatex、latexmk --version),不要全盘扫描;② 找到绝对路径后写入 paperLatexCommand 并重新检测,再继续;③ 仍找不到就向用户问一次(安装 TeX 需用户明确同意);④ 无应答则照旧降级(只交付 paper.tex 与 paper.md)。硬边界:绝不自动安装;绝不写工作区之外;绝不把"未检测到"当失败。'
3540
+ }
3541
+ function paperPushLine(L) { const b = paperHintBlock('zh'); if (b) L.push(b) }
3542
+
3056
3543
  function formalPromptBlock(target) {
3057
3544
  if (!formalOn()) return ''
3058
3545
  const mode = formalMode()
@@ -3090,7 +3577,12 @@ export function apply(ctx) {
3090
3577
  L.push(' · 形式化只写你有把握的版本;没把握就记 blocked 并写清难点——不要用形式化掩盖不确定。')
3091
3578
  L.push(' · 该对象若已有后台编译在队列中,**不得**在它落地前声称已通过或走忠实性审查;等 vibe_v5_lean_lib 显示 passed 再审。')
3092
3579
  L.push(' · 工具:vibe_v5_lean_run(执行)· vibe_v5_lean_archive(归档)· vibe_v5_lean_lib(查已有可复用库)')
3093
- L.push(' · 工作目录:Formal/(相对研究所根);可复用定义放 ' + formalLibRoot().replace(/\\/g, '/') + '/,')
3580
+ // L1 (deep-review 5): the work directory is documented as the CWD-RELATIVE complete path
3581
+ // (what the member's own file tools produce), and the tools accept it as well as the short
3582
+ // `Formal/…` form — the two name the same file (`leanAbsPath` strips the prefix).
3583
+ L.push(' · 工作目录(**相对会话工作目录**,你自己的文件工具按这个基准解析):' + formalRootRel() + '/')
3584
+ L.push(' (= 研究所根下的 Formal/;v5 工具的 file 参数两种写法都接受:上面这条完整路径,或短的 Formal/xxx.lean。)')
3585
+ L.push(' 可复用定义放 ' + formalLibRoot().replace(/\\/g, '/') + '/,')
3094
3586
  L.push(' 已证引理放 ' + formalProvedRoot().replace(/\\/g, '/') + '/;写之前先 vibe_v5_lean_lib 查重。')
3095
3587
  L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义 / 对象 / 条件 / 假设 / 结论是否与')
3096
3588
  L.push(' 命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
@@ -3225,7 +3717,9 @@ export function apply(ctx) {
3225
3717
  return {
3226
3718
  ok: true, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId,
3227
3719
  file: pre.rel, target: target || undefined,
3228
- message: '已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在下一轮提示里公告;也可用 vibe_v5_lean_lib 的 jobs 字段随时查看。',
3720
+ message: '已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在**你**下一轮提示里公告一次;'
3721
+ + '长期查询用 vibe_v5_lean_job {jobId} 或 Formal/Jobs/<jobId>.json(vibe_v5_lean_lib 的 jobs 字段也会列出)。'
3722
+ + '注意:公告只投给发起者、且重启后不再补发,所以别的成员(或重启后的你)请用上面两条长期路径查看。',
3229
3723
  }
3230
3724
  }
3231
3725
  const run = await leanRunFile(rel, args.timeout_ms)
@@ -3351,7 +3845,7 @@ export function apply(ctx) {
3351
3845
  // Dedupe (§5.3): the object is already `passed` AND the work file still holds EXACTLY
3352
3846
  // this content ⇒ the compile result is already known: skip rewrite + recompile.
3353
3847
  if (prev.status === 'passed' && (await leanHashFile(workRel)) === sha) {
3354
- return { ok: true, deduped: true, kind, target, file: workRel, sha256: sha, proof: prev.proof || ('Verified/Lean/' + target + '.lean'), note: '内容与已归档版本一致,跳过重写与重编译' }
3848
+ return { ok: true, deduped: true, kind, target, file: workRel, sha256: sha, proof: prev.proof || ('Verified/Lean/' + target + '.lean'), fidelity: prev.fidelity || null, abstainedCount: Number((prev.fidelity || {}).abstainedCount || 0), note: '内容与已归档版本一致,跳过重写与重编译' }
3355
3849
  }
3356
3850
  if (!await writeTextRel(workRel, body)) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + workRel }
3357
3851
  if (params.leanAsync !== false) {
@@ -3376,7 +3870,7 @@ export function apply(ctx) {
3376
3870
  await rebuildLeanLibIndexes()
3377
3871
  await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
3378
3872
  + '(已入队后台编译;落地为“通过”之前**不会**写 Verified/Lean/、也不会转为忠实性审查)')
3379
- return { ok: true, kind, target, file: workRel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, status: rec0.status, note: '已入队后台编译;只有作业落地为“通过”才会写 Verified/Lean/ 并转为忠实性审查' }
3873
+ return { ok: true, kind, target, file: workRel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, status: rec0.status, fidelity: rec0.fidelity || prev.fidelity || null, abstainedCount: Number(((rec0.fidelity || prev.fidelity) || {}).abstainedCount || 0), note: '已入队后台编译;只有作业落地为“通过”才会写 Verified/Lean/ 并转为忠实性审查' }
3380
3874
  }
3381
3875
  // No toolchain: fall through to the synchronous path (honest NO_SUBPROCESS record).
3382
3876
  }
@@ -3402,7 +3896,7 @@ export function apply(ctx) {
3402
3896
  await rebuildLeanLibIndexes()
3403
3897
  await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
3404
3898
  + '(运行 ' + (passed ? '**通过**,已归档到 ' + rec.proof + ',验证转为忠实性审查' : '**未通过**:' + tail(run.stderr || run.message, 160)) + ')')
3405
- return { ok: true, kind, target, file: workRel, sha256: sha, proof: rec.proof, passed, run, status: rec.status, async: null }
3899
+ return { ok: true, kind, target, file: workRel, sha256: sha, proof: rec.proof, passed, run, status: rec.status, async: null, fidelity: rec.fidelity || prev.fidelity || null, abstainedCount: Number(((rec.fidelity || prev.fidelity) || {}).abstainedCount || 0) }
3406
3900
  }
3407
3901
  if (kind === 'blocked') {
3408
3902
  const target = idSafe(String(args.target || ''))
@@ -3564,10 +4058,9 @@ export function apply(ctx) {
3564
4058
  }
3565
4059
 
3566
4060
  // ---- task board (compare-and-set DAG, ported from DSH agent-teams) ----
3567
- function nextTaskId() {
3568
- const c = Number(inst().counters.task) || 0
3569
- return { id: 't-' + (c + 1), n: c + 1 }
3570
- }
4061
+ // `t-N` ids are minted by the fold's allocator inside `taskCreate` (HIGH 1); there is no
4062
+ // read-side id preview any more, because an id read outside the fold is exactly what allowed
4063
+ // two concurrent creates to mint the same one.
3571
4064
  // DAG validation: self-reference, duplicates, and missing/deleted blockers are
3572
4065
  // refused up front; a cycle is detected over the WHOLE candidate graph, exactly
3573
4066
  // like the DSH original, so a bad dependency can never be stored.
@@ -3615,11 +4108,8 @@ export function apply(ctx) {
3615
4108
  if (scopes.indexOf(n) === -1) scopes.push(n)
3616
4109
  }
3617
4110
  const blockedBy = (args.blocked_by || args.blockedBy || []).map(String)
3618
- const { id, n } = nextTaskId()
3619
- validateDeps(id, blockedBy)
3620
- const counters = Object.assign({}, inst().counters); counters.task = n
3621
4111
  const task = {
3622
- id, revision: 1, subject, description,
4112
+ id: '', revision: 1, subject, description,
3623
4113
  status: 'pending', ownerId: '',
3624
4114
  blockedBy, writeScopes: scopes,
3625
4115
  priority: Number.isFinite(Number(args.priority)) ? Number(args.priority) : 0,
@@ -3627,8 +4117,17 @@ export function apply(ctx) {
3627
4117
  assignedBy: '', why: '', acceptance: '',
3628
4118
  createdAt: now(), updatedAt: now(),
3629
4119
  }
3630
- await putCounters(counters)
3631
- await putTask(task)
4120
+ // HIGH 1: the id and the `counters.task` bump happen INSIDE the fold. The dependency check
4121
+ // runs there too, against the FINAL id; a rejection returns null so the fold writes nothing
4122
+ // (an earlier shape allocated the id here and committed the bumped counter separately, so
4123
+ // two same-tick creates could mint the same `t-N` and the fold's upsert overwrote one).
4124
+ const got = { err: null }
4125
+ await commit(EV.task, { make: (alloc) => {
4126
+ task.id = alloc.next('task')
4127
+ try { validateDeps(task.id, blockedBy) } catch (e) { got.err = e; return null }
4128
+ return task
4129
+ } })
4130
+ if (got.err) throw got.err
3632
4131
  await writeTaskboardMirror()
3633
4132
  await markProgress()
3634
4133
  notifyActivity()
@@ -3648,7 +4147,10 @@ export function apply(ctx) {
3648
4147
  if (!t) throw v5err('V5_TASK_NOT_FOUND', 'task ' + id + ' not found')
3649
4148
  return taskView(t)
3650
4149
  }
3651
- async function taskUpdate(memberId, o) {
4150
+ // `meta` is an INTERNAL positional argument (not part of the tool's `args`, so the advertised key
4151
+ // surface of `vibe_v5_task_update` is unchanged): it lets an in-product caller fold extra fields into
4152
+ // the SAME compare-and-set write instead of issuing a second, unprotected one.
4153
+ async function taskUpdate(memberId, o, meta) {
3652
4154
  // An unknown caller must be refused HERE as well: `owner = task.ownerId === memberId` is true
3653
4155
  // for an UNOWNED task (ownerId '') when memberId is '', so without this guard an
3654
4156
  // unidentifiable caller would count as the owner of every unclaimed task (audit L6 follow-up).
@@ -3661,7 +4163,10 @@ export function apply(ctx) {
3661
4163
  const expected = Number(args.expected_revision !== undefined ? args.expected_revision : args.expectedRevision)
3662
4164
  if (!Number.isFinite(expected)) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'expected_revision is required' }
3663
4165
  if (expected !== task.revision) {
3664
- return { ok: false, code: 'V5_TASK_STALE_REVISION', message: 'task ' + id + ' is at revision ' + task.revision + ', not ' + expected + ' — re-read it with vibe_v5_task_get' }
4166
+ // F6 (deep-review 5): this message is routed into Chinese frames (`【框架提示】…`), where a
4167
+ // raw English diagnostic reads as noise. The CODE stays (`V5_TASK_STALE_REVISION`) and the
4168
+ // revision numbers stay, but the sentence is Chinese now for the member reading it.
4169
+ return { ok: false, code: 'V5_TASK_STALE_REVISION', message: '任务 ' + id + ' 当前 revision 是 ' + task.revision + ',不是 ' + expected + ' —— 请先用 vibe_v5_task_get 重新读取(code: V5_TASK_STALE_REVISION)' }
3665
4170
  }
3666
4171
  const action = String(args.action || '')
3667
4172
  const office = isOffice(memberId)
@@ -3736,6 +4241,15 @@ export function apply(ctx) {
3736
4241
  } catch (e) {
3737
4242
  return { ok: false, code: e.code || 'V5_INVALID_ARGUMENT', message: String((e && e.message) || e) }
3738
4243
  }
4244
+ // G4: assignment metadata travels INSIDE this CAS-protected write. The old shape wrote the task a
4245
+ // second time afterwards (`putTask(withMeta)`), reusing the revision it had just read and never
4246
+ // bumping it — a concurrent `task_update` landing in that window was silently swallowed and left no
4247
+ // revision trace. Gated to `reassign` so no other action can be given out-of-band fields.
4248
+ if (meta && action === 'reassign') {
4249
+ if (meta.assignedBy !== undefined) next.assignedBy = String(meta.assignedBy)
4250
+ if (meta.why !== undefined) next.why = String(meta.why)
4251
+ if (meta.acceptance !== undefined) next.acceptance = String(meta.acceptance)
4252
+ }
3739
4253
  next.revision = task.revision + 1
3740
4254
  next.updatedAt = now()
3741
4255
  await putTask(next)
@@ -3776,12 +4290,12 @@ export function apply(ctx) {
3776
4290
  if (cur && (cur.blockedBy || []).length && !taskReady(cur)) {
3777
4291
  return { ok: false, code: 'V5_TASK_BLOCKED', message: 'task ' + taskId + ' still has incomplete blockers' }
3778
4292
  }
3779
- const r = await taskUpdate(memberId, { task_id: taskId, expected_revision: (cur ? cur.revision : 1), action: 'reassign', owner: to })
4293
+ const r = await taskUpdate(memberId, { task_id: taskId, expected_revision: (cur ? cur.revision : 1), action: 'reassign', owner: to },
4294
+ { assignedBy: isOffice(memberId) ? 'office' : memberId, why, acceptance })
3780
4295
  if (!r.ok) return r
3781
- const after = inst().tasks.find((t) => t.id === taskId)
3782
- const withMeta = Object.assign({}, after, { assignedBy: isOffice(memberId) ? 'office' : memberId, why, acceptance })
3783
- await putTask(withMeta)
3784
- await writeTaskboardMirror()
4296
+ // G4: NO second write here — the metadata went into the CAS write above (one task commit, so an
4297
+ // interleaved task_update can neither be swallowed nor silently lose its revision).
4298
+ const withMeta = inst().tasks.find((t) => t.id === taskId) || cur
3785
4299
  const assignerIsOffice = isOffice(memberId)
3786
4300
  await say(assignerIsOffice ? 'office' : memberId, {
3787
4301
  to, kind: 'assign',
@@ -3846,7 +4360,7 @@ export function apply(ctx) {
3846
4360
  const lines = ['# 研究所编制表(人读镜像)|' + instituteName + '|' + fmtTime(), '',
3847
4361
  '> 权威状态在 State/<研究所>.v5state.json 里;本文件只是快照,勿手改。', '']
3848
4362
  lines.push('- 求真门槛:' + (voterCount() > 0 ? 'm = ' + quorumM() : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
3849
- lines.push('- 阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
4363
+ lines.push('- ' + phaseFactLine())
3850
4364
  lines.push('')
3851
4365
  lines.push('| 代号 | 职位 | 状态 | 雇主 | 方向/用途 | 轮次 | 上下文% |')
3852
4366
  lines.push('|---|---|---|---|---|---|---|')
@@ -4170,16 +4684,27 @@ export function apply(ctx) {
4170
4684
  // reason, put the object on the formalization TODO, and say so in the group chat. The
4171
4685
  // object keeps its mean probability and its debate record, so nothing is lost.
4172
4686
  async function deferForFormal(vs, j, isTrue) {
4173
- const rec = formalOf(vs.target)
4174
- const why = 'formal-required:尚未取得 Lean 形式化通过,也没有显式阻塞记录(当前状态 ' + (rec.status || 'none') + ')'
4687
+ // MEDIUM 6 (deep review): both the record and the TODO list are built INSIDE the fold, so a
4688
+ // concurrent writer (a Lean run finishing for another target while this verdict settles)
4689
+ // cannot lose the other's entry.
4690
+ const why = 'formal-required:尚未取得 Lean 形式化通过,也没有显式阻塞记录(当前状态 '
4691
+ + String((formalOf(vs.target).status) || 'none') + ')'
4175
4692
  await writeDebateDoc(vs, false, j)
4176
4693
  await putVerdict(vs.target, Object.assign({}, vs, {
4177
4694
  closed: true, outcome: 'undecided', reason: why, mean: j.mean, formalDeferred: true,
4178
4695
  m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
4696
+ // LOW (deep review): record the ELECTORATE too. judgeVerdict had it (ase.voters) and
4697
+ // the closed record used to drop it, so a completed decision could not be audited for who
4698
+ // was counted once the roster changed.
4699
+ voters: j.voters,
4179
4700
  }))
4180
- const list = formalTodo().filter((t) => t.id !== vs.target)
4181
- list.push({ id: vs.target, at: now(), why, verdict: isTrue ? 1 : 0 })
4182
- await putFormal(vs.target, rec.status === 'none' ? { status: 'none', deferredAt: now() } : rec, list)
4701
+ await putFormal(vs.target,
4702
+ (prev0) => ((prev0 && prev0.status && prev0.status !== 'none') ? prev0 : { status: 'none', deferredAt: now() }),
4703
+ (todo0) => {
4704
+ const list = (todo0 || []).filter((t) => t.id !== vs.target)
4705
+ list.push({ id: vs.target, at: now(), why, verdict: isTrue ? 1 : 0 })
4706
+ return list
4707
+ })
4183
4708
  await writeFormalTodo()
4184
4709
  await writeFormalIndex()
4185
4710
  await saveChatLine('【形式化】' + vs.target + ' 的表决结果为 ' + (isTrue ? '真' : '假')
@@ -4197,6 +4722,10 @@ export function apply(ctx) {
4197
4722
  await putVerdict(vs.target, Object.assign({}, vs, {
4198
4723
  closed: true, outcome: 'undecided', reason: j.reason, mean: j.mean,
4199
4724
  m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
4725
+ // LOW (deep review): record the ELECTORATE too. judgeVerdict had it (ase.voters) and
4726
+ // the closed record used to drop it, so a completed decision could not be audited for who
4727
+ // was counted once the roster changed.
4728
+ voters: j.voters,
4200
4729
  }))
4201
4730
  await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: 'undecided' })
4202
4731
  await saveChatLine('【求真表决】' + vs.target + ' 未达门槛(' + j.reason + ');留库为未定论,平均概率 ' +
@@ -4214,6 +4743,10 @@ export function apply(ctx) {
4214
4743
  await putVerdict(vs.target, Object.assign({}, vs, {
4215
4744
  closed: true, outcome: isTrue ? 'true' : 'false', mean: isTrue ? 1 : 0,
4216
4745
  m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
4746
+ // LOW (deep review): record the ELECTORATE too. judgeVerdict had it (ase.voters) and
4747
+ // the closed record used to drop it, so a completed decision could not be audited for who
4748
+ // was counted once the roster changed.
4749
+ voters: j.voters,
4217
4750
  }))
4218
4751
  await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: isTrue ? 'true' : 'false' })
4219
4752
  await saveChatLine('【求真结论】' + vs.target + ' 经 ' + (isTrue ? j.bTrue : j.bFalse) + ' 名有表决权者一致判' +
@@ -4347,13 +4880,22 @@ export function apply(ctx) {
4347
4880
  }
4348
4881
 
4349
4882
  // ---- chat log / meeting plumbing --------------------------------------
4883
+ // LOW (deep review): the day's chat MIRROR was an unsynchronized read-modify-write — two lines
4884
+ // produced in the same tick both read `prev`, and the second write dropped the first. Appends
4885
+ // are now serialized through one promise chain (the authoritative record is `inst().messages`;
4886
+ // this file is the human-readable mirror, which is why this is a LOW).
4887
+ let chatChain = Promise.resolve(true)
4350
4888
  async function saveChatLine(text) {
4351
4889
  const line = String(text || '').trim()
4352
4890
  if (!line) return false
4353
4891
  const day = fmtTime().slice(0, 10)
4354
4892
  const rel = 'Shared/Chat/' + day + '.md'
4355
- const prev = (await readTextRel(rel)) || ('# 研究所群聊记录|' + instituteName + '|' + day + '\n\n')
4356
- return await writeTextRel(rel, prev + '- ' + fmtTime().slice(11) + '|' + line + '\n')
4893
+ const run = async () => {
4894
+ const prev = (await readTextRel(rel)) || ('# 研究所群聊记录|' + instituteName + '|' + day + '\n\n')
4895
+ return await writeTextRel(rel, prev + '- ' + fmtTime().slice(11) + '|' + line + '\n')
4896
+ }
4897
+ chatChain = chatChain.then(run, run)
4898
+ return await chatChain
4357
4899
  }
4358
4900
  // Wake a member only when it is not already running. Never called while paused
4359
4901
  // (a paused institute must not be nudged into new work — v4 §29-T36).
@@ -4368,7 +4910,12 @@ export function apply(ctx) {
4368
4910
  // meeting request is PARKED (first one wins; later requests do not overwrite it)
4369
4911
  // and resumed once the verification clears. The watchdog clock starts only when
4370
4912
  // the meeting ACTUALLY begins (v4 §26/§27).
4371
- const solveVotes = new Map() // memberId -> boolean, for the current solve question
4913
+ // HIGH 3 (deep review): the "is the original problem solved?" tally is DURABLE state
4914
+ // (`inst().solve`), not an in-memory Map. It gates 结题 and is published by status/report, so a
4915
+ // restart or a second session must see the same answers; the old Map made cross-session
4916
+ // unanimity impossible and let the published view disagree with the durable one.
4917
+ const solveVotesOf = () => inst().solve || {}
4918
+ const solveVotesList = () => Object.entries(solveVotesOf()).map(([k, v]) => k + '=' + v)
4372
4919
  async function startMeeting(callerId, opts) {
4373
4920
  const o = opts || {}
4374
4921
  const agenda = String(o.agenda || '').trim()
@@ -4385,8 +4932,11 @@ export function apply(ctx) {
4385
4932
  // r-2 and the voters replied to the wrong person.
4386
4933
  if (callerId) {
4387
4934
  await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })
4935
+ // MEDIUM 4: the office is the only other actor allowed to convene, so the proposal is
4936
+ // ALSO addressed to it (voters alone never reached it).
4937
+ await say(callerId, { to: OFFICE_INBOX, kind: 'office-request', text: '请所办裁定:是否召开会议「' + agenda + '」(类型:' + kind + '|提议人:' + callerId + ')' })
4388
4938
  }
4389
- return { ok: true, proposed: true, message: '已向院士/所办提议开会(只有院士或所办可以直接召开)' }
4939
+ return { ok: true, proposed: true, message: '已向院士/所办提议开会(只有院士或所办可以直接召开;所办会在 status/report 的 officeRequests 里看到该提议)' }
4390
4940
  }
4391
4941
  if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute has already concluded; start a new run to convene again' }
4392
4942
  if (!running) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
@@ -4399,21 +4949,39 @@ export function apply(ctx) {
4399
4949
  }
4400
4950
  return await beginMeeting({ agenda, kind, target: String(o.target || ''), by: office ? 'office' : callerId })
4401
4951
  }
4952
+ // F13 (status/report review): ONE composition for the phase/running/concluded facts, used by
4953
+ // both `report()` and `State/README.md` (`writeStateReadme`) — the two documents used to print
4954
+ // the same three facts with different wording, so a reader had to compare them by hand.
4955
+ function phaseFactLine() {
4956
+ return '阶段:' + phase + '|运行中:' + (running ? '是' : '否') + '|已结题:' + (autoDone ? '是' : '否')
4957
+ }
4402
4958
  async function beginMeeting(opts) {
4403
- const counters = Object.assign({}, inst().counters)
4404
- counters.meeting = (Number(counters.meeting) || 0) + 1
4405
- await putCounters(counters)
4406
- const id = 'mt-' + counters.meeting
4407
4959
  const order = activeMembers().map((m) => m.id)
4408
4960
  // Rotate who speaks first: with a fixed order the same member always speaks
4409
4961
  // before it can see the others (v4 §24.1-④).
4410
4962
  for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = order[i]; order[i] = order[j]; order[j] = t }
4963
+ const idx = { id: '', agenda: opts.agenda, kind: opts.kind || 'sync', at: 0, file: '' }
4964
+ // HIGH 1: `mt-N` and the `counters.meeting` bump are allocated inside the fold (the old
4965
+ // shape read the counter here and committed it separately, so two concurrent convenings
4966
+ // could both become `mt-3` and the fold's dedupe dropped the second one).
4967
+ await commit(EV.meeting, { make: (alloc) => {
4968
+ idx.id = alloc.next('meeting')
4969
+ idx.at = now()
4970
+ idx.file = 'Shared/Meetings/' + idx.id + '.md'
4971
+ return idx
4972
+ } })
4973
+ const id = idx.id
4411
4974
  meeting = {
4412
4975
  id, agenda: opts.agenda, kind: opts.kind || 'sync', target: opts.target || '',
4413
4976
  by: opts.by || 'office', order, inputs: {}, extras: {}, lastInputAt: now(), startedAt: now(),
4414
4977
  }
4415
- solveVotes.clear()
4416
- await putMeeting({ id, agenda: meeting.agenda, kind: meeting.kind, at: now(), file: 'Shared/Meetings/' + id + '.md' })
4978
+ // F6 (status/report review): mark the meeting OPEN durably. `finalizeMeeting` clears it, so a
4979
+ // meeting that never finished (crash/restart/stop) stays visible as "未收束" instead of
4980
+ // silently counting as history with no minutes.
4981
+ await patchInstitute({ meetingOpen: { id, at: now(), agenda: meeting.agenda } })
4982
+ // HIGH 3: a NEW solve question starts with an empty DURABLE tally (a clear has to be
4983
+ // persisted too, otherwise a reload would resurrect the previous question's answers).
4984
+ await putSolve({ clear: true })
4417
4985
  await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
4418
4986
  // A meeting that ACTUALLY begins and was convened by the office is the other half of the
4419
4987
  // `paperEditor='office'` consultation requirement (docs/final-paper.md §7). Counted here, not at the
@@ -4497,6 +5065,8 @@ export function apply(ctx) {
4497
5065
  }
4498
5066
  async function finalizeMeeting(mn) {
4499
5067
  meeting = null
5068
+ // F6: the meeting is closed ⇒ clear the durable OPEN marker (see `beginMeeting`).
5069
+ try { await patchInstitute({ meetingOpen: null }) } catch (e) { /* the minutes below matter more */ }
4500
5070
  const lines = []
4501
5071
  for (const id of mn.order) {
4502
5072
  const text = mn.inputs[id]
@@ -4541,7 +5111,8 @@ export function apply(ctx) {
4541
5111
  if (autoDone) return true
4542
5112
  const vs = voters().map((m) => m.id)
4543
5113
  if (!vs.length) return false
4544
- if (!vs.every((id) => solveVotes.get(id) === true)) return false
5114
+ const sv = solveVotesOf()
5115
+ if (!vs.every((id) => sv[id] === true)) return false
4545
5116
  // ── THE FINAL PAPER PHASE COMES FIRST (docs/final-paper.md §3) ────────────────────────────────
4546
5117
  // `finishRun` flips phase='solved' / autoDone / running=false, after which the machinery
4547
5118
  // REFUSES: `wakeIfIdle` returns false, `startMeeting` rejects a concluded institute and
@@ -4563,7 +5134,9 @@ export function apply(ctx) {
4563
5134
  const m = memberById(memberId)
4564
5135
  if (!m) return
4565
5136
  if (m.kind === 'temp') return // no vote
4566
- solveVotes.set(memberId, val === true)
5137
+ // HIGH 3: the answer is committed to the durable tally (it survives a reload and is visible
5138
+ // to a second session), not to a process-local Map.
5139
+ await putSolve({ member: memberId, value: val === true })
4567
5140
  // Evaluate the stop condition on EVERY solve vote, not only when a meeting
4568
5141
  // finalizes. A vote that lands after the meeting closed — a late reply, or an
4569
5142
  // ordinary round carrying vote_solved — would otherwise be recorded and never
@@ -4630,13 +5203,23 @@ export function apply(ctx) {
4630
5203
  await patchInstitute({ paper: (cur) => { const next = fn(cur === undefined ? null : cur); return next === undefined ? (cur === undefined ? null : cur) : next } })
4631
5204
  return paper()
4632
5205
  }
5206
+ // F6 (v4-G3's v5 cousin): ONE named warning per failed REQUIRED artifact write. `files` stays the
5207
+ // honest list of what actually landed, and the recovery path is the idempotent refill
5208
+ // (`refillPaperArtifacts`, reached by re-triggering the paper) which only writes what is missing.
5209
+ function paperWriteFailureWarning(names) {
5210
+ return '产物写入失败(未落盘):' + names.join('、') + ' —— 它们不在本次定稿的 files 产物清单里;' +
5211
+ '重跑同一篇论文(/v5 paper,幂等)会由 refillPaperArtifacts 只补写缺失产物。'
5212
+ }
4633
5213
  async function paperLog(title, body) {
4634
5214
  const p = paper()
4635
5215
  const id = (p && p.id) || paperIdFromState()
4636
5216
  const rel = PAPER_FILE(id, 'paper.log.md')
4637
5217
  const prev = (await readTextRel(rel)) || ('# 最终论文流程日志|' + id + '\n\n')
4638
- await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
4639
- return rel
5218
+ // F6: the outcome is RETURNED so the finalisation can turn a failure into a named warning. It used
5219
+ // to be discarded, which is how a "finalized" paper could be announced without its log and with no
5220
+ // trace in `warnings`. No caller read the old `rel` return value.
5221
+ const ok = await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
5222
+ return { rel, ok }
4640
5223
  }
4641
5224
  function paperAskEvery() { return posMs(params.activityTimeoutMs, 120000) }
4642
5225
  function paperNextStep(p) {
@@ -5298,18 +5881,22 @@ export function apply(ctx) {
5298
5881
  const order = PAPER_ENGINE_ORDER[lang === 'en' ? 'en' : 'zh'].slice()
5299
5882
  const forced = String(params.paperLatexCommand || '').trim()
5300
5883
  if (!forced) return order
5301
- return [forced].concat(order.filter((n) => n !== forced))
5884
+ // task-10: an EXPLICIT paperLatexCommand is used ALONE (docs/final-paper.md §B) - no fall-through.
5885
+ return [forced]
5302
5886
  }
5303
5887
  async function latexEngines(lang) {
5304
5888
  const sub = subprocessOf()
5305
- if (!sub || typeof sub.resolveExecutable !== 'function') {
5306
- return { engines: [], reason: '宿主没有 subprocess 服务:无法检测或调用 LaTeX' }
5889
+ // task-4: only a missing subprocess short-circuits - a host without resolveExecutable is still
5890
+ // served by the known-location stage (resolveKnownTool tolerates that host).
5891
+ if (!sub) {
5892
+ return { engines: [], reason: '宿主没有 subprocess 服务:无法检测或调用 LaTeX', triedPaths: [] }
5307
5893
  }
5308
5894
  const engines = []
5895
+ const triedPaths = [] // task-9: union of the known-root candidates probed across THIS detection
5309
5896
  for (const name of paperEngineCandidates(lang)) {
5310
- try { const exe = await sub.resolveExecutable(name); if (exe) engines.push({ name: String(name), exe: String(exe) }) } catch (e) { /* not installed */ }
5897
+ try { const _vr = await resolveKnownTool(sub, { name, explicit: '', kind: 'tex' }); triedPaths.push(..._vr.tried); const exe = _vr.exe; if (exe) engines.push({ name: String(name), exe: String(exe), via: _vr.via }) } catch (e) { /* not installed */ }
5311
5898
  }
5312
- return { engines, reason: engines.length ? '' : ('未检测到 LaTeX 引擎(' + paperEngineCandidates(lang).join('/') + ')') }
5899
+ return { engines, triedPaths, reason: engines.length ? '' : ('未检测到 LaTeX 引擎(' + paperEngineCandidates(lang).join('/') + ')') }
5313
5900
  }
5314
5901
  // ONE engine invocation. NEVER throws: every failure becomes a readable result (the Lean
5315
5902
  // seam's discipline) and a timeout really terminates the process.
@@ -5347,7 +5934,7 @@ export function apply(ctx) {
5347
5934
  return { status: 'kept-existing', engine: '', reason: 'Paper/' + id + '/paper.pdf 已存在:不覆盖(docs/final-paper.md §5)', attempts: [] }
5348
5935
  }
5349
5936
  const det = await latexEngines(lang)
5350
- if (!det.engines.length) return { status: 'not-detected', engine: '', reason: det.reason, attempts: [] }
5937
+ if (!det.engines.length) return { status: 'not-detected', engine: '', reason: det.reason, triedPaths: det.triedPaths || [], attempts: [] }
5351
5938
  const e0 = det.engines[0], e1 = det.engines[1] || e0
5352
5939
  const plan = [
5353
5940
  { label: 'full', engine: e0, text: tex, nonstopOnly: false },
@@ -5455,13 +6042,18 @@ export function apply(ctx) {
5455
6042
  const finalizedAt = now()
5456
6043
  const S = paperSections(p, active, skipped, ev, { final: p.final, conclusionText })
5457
6044
  const files = []
6045
+ // F6: every REQUIRED artifact write is checked. A failure does NOT block finalisation (the paper
6046
+ // is still delivered from whatever landed) but it must be NAMED, not silently dropped from `files`.
6047
+ const writeFailures = []
5458
6048
  if (p.format !== 'tex') {
5459
6049
  if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) files.push('paper.md')
6050
+ else writeFailures.push('paper.md')
5460
6051
  }
5461
6052
  let tex = ''
5462
6053
  if (p.format !== 'md') {
5463
6054
  tex = renderPaperTex(p, S)
5464
6055
  if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) files.push('paper.tex')
6056
+ else writeFailures.push('paper.tex')
5465
6057
  }
5466
6058
  // `paperFormat=md` with `paperCompilePdf=true` must NOT warn about a missing tex: no tex
5467
6059
  // was produced, so compilation is skipped silently (docs/final-paper.md §8).
@@ -5476,12 +6068,18 @@ export function apply(ctx) {
5476
6068
  if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) files.push('paper.pdf')
5477
6069
  const warnings = (p.warnings || []).slice()
5478
6070
  if (compile.status === 'failed') warnings.push('LaTeX 编译失败(已保留 paper.tex 与 paper.md,不阻塞定稿):' + (compile.attempts || []).map((a) => a.engine + '/' + a.label + ' exit=' + a.exitCode + (a.message ? '(' + a.message + ')' : '')).join(';'))
5479
- if (compile.status === 'not-detected') warnings.push('未检测到 LaTeX 引擎(' + (compile.reason || '') + '):只交付 paper.tex 与 paper.md。')
6071
+ if (compile.status === 'not-detected') warnings.push('未检测到 LaTeX 引擎(' + (compile.reason || '') + '):只交付 paper.tex 与 paper.md。已探测 PATH 与文档化的常见 TeX 根;可用 paperLatexCommand 指定绝对路径。')
6072
+ // F6: the failed REQUIRED writes are named ONCE (for this finalisation), before the meta is built,
6073
+ // so the warning lands in `meta.warnings`, in the flow log, in the state and in the return value.
6074
+ if (writeFailures.length) warnings.push(paperWriteFailureWarning(writeFailures))
5480
6075
  const meta = paperMeta(Object.assign({}, p, { finalizedAt, compile: compile.status, engine: compile.engine || '' }), {
5481
- finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', attempts: compile.attempts || [] }, files,
6076
+ finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', triedPaths: compile.triedPaths || [], attempts: compile.attempts || [] }, files,
5482
6077
  })
5483
6078
  if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')) files.push('paper.meta.json')
5484
- await paperLog('定稿(' + reason + ')', [
6079
+ // `meta.warnings` is the SAME array, so a meta write failure still reaches the state, the log and
6080
+ // the tool's return value even though the file that should have carried it is the one missing.
6081
+ else warnings.push(paperWriteFailureWarning(['paper.meta.json']))
6082
+ const logRes = await paperLog('定稿(' + reason + ')', [
5485
6083
  '- 目录:Paper/' + id + '/',
5486
6084
  '- 产物:' + files.join('、'),
5487
6085
  '- 编译:' + compile.status + (compile.engine ? '(' + compile.engine + ')' : '') + '|尝试 ' + (compile.attempts || []).length + ' 次',
@@ -5490,6 +6088,14 @@ export function apply(ctx) {
5490
6088
  (p.final && p.final.note ? '- 定稿说明:' + p.final.note : ''),
5491
6089
  (warnings.length ? '- 警告:\n - ' + warnings.join('\n - ') : ''),
5492
6090
  ].filter(Boolean).join('\n'))
6091
+ // F6: a failed flow-log write is named too — the log is a required artifact of the paper flow. The
6092
+ // meta is REWRITTEN here because it was serialised BEFORE this attempt: without the rewrite the
6093
+ // durable meta would be the one artifact whose `warnings` does not mention the missing log. (If the
6094
+ // meta path is refused as well the rewrite fails silently — its own warning is already recorded.)
6095
+ if (!logRes.ok) {
6096
+ warnings.push(paperWriteFailureWarning(['paper.log.md']))
6097
+ await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')
6098
+ }
5493
6099
  await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
5494
6100
  status: 'finalized', stage: 'finalized', finalizedAt, compile: compile.status,
5495
6101
  engine: compile.engine || '', warnings, artifacts: files, updatedAt: now(),
@@ -5506,6 +6112,10 @@ export function apply(ctx) {
5506
6112
  }
5507
6113
 
5508
6114
  // ---- hire / fire -------------------------------------------------------
6115
+ // INVARIANT (HIGH 2): an ACTIVE temp always has an ACTIVE employer — `fire` dismisses a
6116
+ // member's temps with it (`dismissTempsOf`), and the office idempotently catches up on state
6117
+ // written before that cascade existed. `maxTempTotal` is therefore counted exactly over the
6118
+ // temps a running institute can still use.
5509
6119
  function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
5510
6120
  // ANY academician or permanently-employed researcher may hire its own temp
5511
6121
  // workers, and may fire the ones it hired. This is the requirement the official
@@ -5535,11 +6145,12 @@ export function apply(ctx) {
5535
6145
  try {
5536
6146
  await spawnMember(member, initialTask)
5537
6147
  } catch (e) {
5538
- await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
6148
+ await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
5539
6149
  // Name the host's live-child cap (maxActiveSubagents) instead of relaying its opaque
5540
6150
  // "subagent limit reached (active child limit: N)" string, and use the typed code so the
5541
- // hirer can tell a HOST ceiling from a broken provider.
5542
- if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: '临时工创建失败:' + activationLimitText(hostChildLimit) } }
6151
+ // hirer can tell a HOST ceiling from a broken provider. The durable `failReason` above is
6152
+ // what `retryPendingSpawns()` (G-6) reads to re-create this temp when capacity frees up.
6153
+ if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: '临时工创建失败:' + activationLimitText(hostChildLimit) + '(已登记为待补建:容量释放后框架会在下一次调度轮次自动重试)' } }
5543
6154
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '临时工创建失败:' + String((e && e.message) || e) }
5544
6155
  }
5545
6156
  await saveChatLine('【雇佣】' + (office ? '所办' : callerId) + ' 雇入临时工 ' + member.id + ',用途:' + purpose)
@@ -5547,26 +6158,55 @@ export function apply(ctx) {
5547
6158
  notifyActivity()
5548
6159
  return { ok: true, id: member.id, kind: 'temp', purpose, note: '现在可以用 vibe_v5_say {to:"' + member.id + '"} 或 vibe_v5_assign 给它派活' }
5549
6160
  }
6161
+ // HIGH 2 (deep review): a dismissed member's TEMP WORKERS are dismissed WITH IT. They carry
6162
+ // `hiredBy: <employer id>`, so once the employer is dismissed the employer check
6163
+ // (`target.hiredBy === callerId`) can never match again — only the office could ever clear
6164
+ // them — while `employedTemps()` kept counting them against `maxTempTotal` and the scheduler
6165
+ // kept waking them (a dismissed member's team quietly burning budget). The rule is explicit:
6166
+ // **a member's temps belong to that member's tenure**. The cascade is one level deep because
6167
+ // temps may not hire (`hire` refuses a temp caller), so no deeper recursion is possible.
6168
+ async function dismissTempsOf(employerId, callerId, note) {
6169
+ const mine = inst().members.filter((m) => m.kind === 'temp' && m.phase === 'active' && m.hiredBy === employerId)
6170
+ const done = []
6171
+ for (const t of mine) {
6172
+ const r = await fire(callerId, { id: t.id, reason: note })
6173
+ if (r && r.ok) done.push(t.id)
6174
+ }
6175
+ return done
6176
+ }
5550
6177
  // Firing is REAL: the current turn is cancelled, the resident continuable child is
5551
- // released, its tasks are reclaimed, its queued mail is dropped and it is marked
5552
- // dismissed. Its id is never reused, so a re-hire can never inherit its archives.
6178
+ // released, its tasks are reclaimed, its queued mail is dropped, its own temp workers are
6179
+ // dismissed with it, and it is marked dismissed. Its id is never reused, so a re-hire can
6180
+ // never inherit its archives.
5553
6181
  async function fire(callerId, o) {
5554
6182
  const args = o || {}
5555
6183
  const id = String(args.id || args.member || '').trim()
5556
6184
  const target = memberById(id)
5557
6185
  if (!target) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member ' + id }
5558
- if (target.phase === 'dismissed') return { ok: true, already: true, message: id + ' 已被解雇' }
6186
+ if (target.phase === 'dismissed') {
6187
+ // Idempotent cleanup for state written before the cascade existed (or a hand-edited
6188
+ // file): only the office may act here, because a dismissed non-temp target skips the
6189
+ // permission check below.
6190
+ const cleaned = isOffice(callerId) ? await dismissTempsOf(id, callerId, '雇主已被解雇(补做级联)') : []
6191
+ return { ok: true, already: true, cascadedTemps: cleaned, message: id + ' 已被解雇' }
6192
+ }
5559
6193
  const office = isOffice(callerId)
5560
6194
  const acad = isAcademician(callerId)
5561
6195
  const allowed = office || (acad && params.academicianLeads && target.kind === 'temp') || (target.kind === 'temp' && target.hiredBy === callerId)
5562
6196
  if (!allowed) {
5563
- return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: '你只能解雇你雇的临时工;解雇他人雇的或常驻研究员需由院士/所办执行' }
6197
+ // MEDIUM 5 (deep review): the refusal names the ACTUAL matrix. The old text promised the
6198
+ // academician a power it only has when `academicianLeads` is on, and implied it could
6199
+ // dismiss a permanent researcher (it cannot — see the next guard).
6200
+ return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: '拒绝:能解雇它的只有 ①所办 ②该临时工的雇主本人 ③academicianLeads=true 时的院士(且仅限临时工);常驻研究员只能由所办解聘,成员只能向所办提议' }
5564
6201
  }
5565
6202
  if (target.kind !== 'temp' && !office) {
5566
6203
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)' }
5567
6204
  }
5568
6205
  const reason = String(args.reason || '').trim()
5569
6206
  const reclaimed = await releaseTasksOf(id, 'dismissed: ' + reason)
6207
+ // Cascade BEFORE the employer is marked dismissed: the temps are still reachable and the
6208
+ // caller's authority (office / leading academician) is the same one that dismissed them.
6209
+ const cascaded = target.kind === 'temp' ? [] : await dismissTempsOf(id, callerId, '雇主 ' + id + ' 被解雇(级联)')
5570
6210
  if (target.childId) {
5571
6211
  try { if (typeof subagents.interrupt === 'function') subagents.interrupt(target.childId, { kind: 'ancestor', agent: rootAgent }) } catch (e) { /* fire-and-return */ }
5572
6212
  try {
@@ -5577,7 +6217,9 @@ export function apply(ctx) {
5577
6217
  liveAgents.delete(target.childId)
5578
6218
  }
5579
6219
  busy.delete(id)
5580
- solveVotes.delete(id)
6220
+ // HIGH 3: withdraw the dismissed member's durable solve answer, exactly like its ballot is
6221
+ // dropped from every open verdict below (an answer from a former member must not carry).
6222
+ await putSolve({ member: id, value: null })
5581
6223
  rounds.delete(id)
5582
6224
  roundsSinceCompact.delete(id)
5583
6225
  contextPct.delete(id)
@@ -5605,11 +6247,12 @@ export function apply(ctx) {
5605
6247
  }))
5606
6248
  await writeRosterMirror()
5607
6249
  await saveChatLine('【解雇】' + id + ' 已由 ' + (office ? '所办' : callerId) + ' 解雇(原因:' + (reason || '未说明') +
5608
- ')。代号永不复用;其未完成任务已收回' + (reclaimed.length ? '(' + reclaimed.join('、') + ')' : '') + '。')
6250
+ ')。代号永不复用;其未完成任务已收回' + (reclaimed.length ? '(' + reclaimed.join('、') + ')' : '') +
6251
+ (cascaded.length ? ';随其解雇的临时工 ' + cascaded.join('、') + '(雇主离场 ⇒ 其临时工一并解雇)' : '') + '。')
5609
6252
  await markProgress()
5610
6253
  notifyActivity()
5611
6254
  await scheduleNext()
5612
- return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
6255
+ return { ok: true, dismissed: id, reclaimedTasks: reclaimed, cascadedTemps: cascaded, reason }
5613
6256
  }
5614
6257
  async function nudge(callerId, o) {
5615
6258
  if (!callerId) return memberDiagnosis('督办(vibe_v5_nudge)', callerId)
@@ -5643,13 +6286,36 @@ export function apply(ctx) {
5643
6286
  // prepended inbox block and once inside its own [状态] block.
5644
6287
  async function promptFor(member, baseFn) {
5645
6288
  const pending = pendingFor(member.id)
5646
- const inbox = pending.length ? composeInbox(pending) : ''
5647
- if (pending.length) await ackPending(pending)
5648
- const base = typeof baseFn === 'function' ? baseFn() : baseFn
5649
- return (inbox ? inbox + '\n\n' : '') + base
6289
+ // G1: only NEWLY pending messages are prepended. A second build before the wake must not repeat the
6290
+ // inbox block (the duplicate-read defect), and the mark is what makes that true while the ack is
6291
+ // deferred to the wake.
6292
+ const seen = inboxInjected.get(member.id) || new Set()
6293
+ const fresh = pending.filter((p) => !seen.has(p.id))
6294
+ const inbox = fresh.length ? composeInbox(fresh) : ''
6295
+ if (fresh.length) { for (const p of fresh) seen.add(p.id); inboxInjected.set(member.id, seen) }
6296
+ // While a block IS being prepended, the base build must not embed the same mail a second time
6297
+ // (briefBlock's `[新到的消息/通知]`). When nothing is prepended the base block still shows whatever
6298
+ // is pending — so a member reads each message exactly once either way.
6299
+ if (inbox) inboxSuppressed.add(member.id)
6300
+ let base
6301
+ try { base = typeof baseFn === 'function' ? baseFn() : baseFn }
6302
+ finally { inboxSuppressed.delete(member.id) }
6303
+ // G1 (mailbox): the pending list is RETURNED, not acked here — the ack moved to wakeWithInbox,
6304
+ // after the send actually succeeded. Acking at prompt-build time removed the messages from
6305
+ // `messages` (EV.delivered) BEFORE the send, so a failed/invalid wake lost them silently.
6306
+ return { text: (inbox ? inbox + '\n\n' : '') + base, pending }
5650
6307
  }
5651
6308
  async function wakeWithInbox(member, baseFn, kind) {
5652
- return await wakeMember(member, await promptFor(member, baseFn), kind)
6309
+ // G1 (mailbox) invariant: "not acked => still redeliverable". `wakeMember` returns false when it
6310
+ // never sent (no childId / member not active) or when `sendMessage` threw, so acking only on
6311
+ // `ok` keeps the message in the durable queue for the next successful wake.
6312
+ const prompt = await promptFor(member, baseFn)
6313
+ const ok = await wakeMember(member, prompt.text, kind)
6314
+ // The attempt is concluded either way: drop the injection mark so the NEXT attempt re-prepends
6315
+ // whatever is still pending (a failed wake must not leave the message suppressed for ever).
6316
+ inboxInjected.delete(member.id)
6317
+ if (ok && prompt.pending.length) await ackPending(prompt.pending)
6318
+ return ok
5653
6319
  }
5654
6320
 
5655
6321
  // ---- scheduling / graded keep-alive (ported and upgraded from v4 §25) ---
@@ -5668,7 +6334,13 @@ export function apply(ctx) {
5668
6334
  }
5669
6335
  if (cur && cur.project) project = cur.project
5670
6336
  if (cur && cur.institute) instituteName = cur.institute
5671
- if (cur) phase = cur.phase || phase
6337
+ if (cur) {
6338
+ phase = cur.phase || phase
6339
+ // S1: keep the concluded marker in sync with the persisted phase on EVERY sync (this is
6340
+ // the path `status()`/`report()`/`configure()` go through), so no view can show a
6341
+ // concluded institute as ongoing, or vice versa.
6342
+ autoDone = String(phase) === 'solved'
6343
+ }
5672
6344
  }
5673
6345
  // Scheduling TRAMPOLINE. The consensus finalizers call `scheduleNext()` themselves
5674
6346
  // (a settled verification should immediately drive whatever comes next), so a
@@ -5694,8 +6366,78 @@ export function apply(ctx) {
5694
6366
  scheduling = false
5695
6367
  }
5696
6368
  }
6369
+ // ---- G-6 (installer review): refused member spawns are DEFERRED, not lost ----------------
6370
+ // v4 queued a cap-refused spawn in `pendingSpawns` and retried it on the next heartbeat; v5 had
6371
+ // no such queue (only the manual `resume` path retried cap-class failures), so a refusal whose
6372
+ // capacity later freed up produced "nothing happens". The queue here is DERIVED from durable
6373
+ // state instead of being a second source of truth: a refused member is already persisted as
6374
+ // `phase:'failed'` with `failReason:'activation-limit'`, so a restart cannot lose the intent and
6375
+ // a successful retry clears it by construction (the member becomes `active`). Only the bounded
6376
+ // attempt counter is session memory.
6377
+ // Only the attempt counter is session memory; the queue itself derives from durable state. The
6378
+ // retry is bounded by the STOPPING RULE, not by a session counter (v4's shape): a still-capped
6379
+ // member IS retried on the next pass — that attempt is free, because `spawnMember` answers from
6380
+ // the recorded ceiling without asking the host (v5:2037) — while any NON-capacity provisioning
6381
+ // error stops the auto-retry for that member (a broken provider must not be resurrected).
6382
+ const spawnRetryAttempts = new Map()
6383
+ // ONE predicate for "this member was refused by the host's live-child CAP" — used by the
6384
+ // automatic retry here AND by `resume`'s rebuild path, so the two can never disagree about
6385
+ // which failures are deferred work (an ordinary broken-provider failure must never be
6386
+ // resurrected). The explicit durable `failReason` is checked first; the recorded error text is
6387
+ // the fallback for records written before it existed / by paths that only had the message.
6388
+ function isCapRefusedMember(m) {
6389
+ if (!m || m.phase !== 'failed') return false
6390
+ if (m.failReason === 'activation-limit') return true
6391
+ // TEST SEAM (documented; NOT a product surface): with the text fallback disabled, ONLY the
6392
+ // durable reason counts — running the same scenario with `V5_SPAWN_RETRY_STRICT=1` proves that
6393
+ // every cap-refusal path records `failReason` and that no behaviour depends on error-string
6394
+ // matching. Read per call so a suite can toggle it around one scenario.
6395
+ if (String(process.env.V5_SPAWN_RETRY_STRICT || '') === '1') return false
6396
+ return /active child limit|maxActiveSubagents/.test(String(m.error || ''))
6397
+ }
6398
+ function pendingSpawnMembers() {
6399
+ return inst().members.filter((m) => isCapRefusedMember(m) && !m.childId)
6400
+ }
6401
+ async function retryPendingSpawns() {
6402
+ // TEST SEAM (documented; NOT a product surface): `V5_SPAWN_RETRY=manual` disables the
6403
+ // automatic retry for this process, so a suite can pin the MANUAL `resume` rebuild path
6404
+ // without racing the scheduler. Read per call, so a test can toggle it around one section.
6405
+ if (String(process.env.V5_SPAWN_RETRY || '').toLowerCase() === 'manual') return 0
6406
+ if (!running || autoDone) return 0
6407
+ const pending = pendingSpawnMembers()
6408
+ if (!pending.length) return 0
6409
+ let spawned = 0
6410
+ for (const m of pending) {
6411
+ const n = spawnRetryAttempts.get(m.id) || 0
6412
+ spawnRetryAttempts.set(m.id, n + 1)
6413
+ try {
6414
+ await spawnMember(m, null)
6415
+ spawnRetryAttempts.delete(m.id)
6416
+ spawned += 1
6417
+ await saveChatLine('【编制】容量已释放:重试创建成员 ' + m.id + ' 成功(第 ' + (n + 1) + ' 次尝试)。')
6418
+ await markProgress()
6419
+ } catch (e) {
6420
+ // Still capped: keep it queued and re-check on the next pass. Any OTHER provisioning error
6421
+ // is not a capacity problem — stop auto-retrying it (the operator gets a notice instead).
6422
+ if (!isActivationLimitReached(e)) {
6423
+ spawnRetryAttempts.delete(m.id)
6424
+ await notice('office', '成员 ' + m.id + ' 的自动重试已停止(非容量类失败):' + String((e && e.message) || e))
6425
+ }
6426
+ }
6427
+ }
6428
+ return spawned
6429
+ }
5697
6430
  async function schedulePass() {
5698
6431
  dbg.passes += 1
6432
+ // MEDIUM 8 (deep review): EVERY scheduling pass reads state, and a pass can be armed by a
6433
+ // timer or an event callback that never went through a tool wrapper. Awaiting the load here
6434
+ // is what makes the internal paths safe by construction instead of by convention (a pass
6435
+ // fired before the state file was folded in would otherwise see an empty roster and could
6436
+ // schedule against it).
6437
+ if (!loadSettled) { try { await ready() } catch (e) { /* the pass still runs; status reports it */ } }
6438
+ // G-6: deferred spawns are retried BEFORE anything else is scheduled, so freed capacity is
6439
+ // spent on the work the cap previously refused (v4's `retryPendingSpawns()` position).
6440
+ try { await retryPendingSpawns() } catch (e) { console.error('vibe-math-v5: pending-spawn retry failed: ' + String((e && e.message) || e)) }
5699
6441
  clearHeartbeat()
5700
6442
  syncParamsFromState()
5701
6443
  // A verification that is ALREADY in flight is served first: the two coordination
@@ -5824,6 +6566,10 @@ export function apply(ctx) {
5824
6566
  // one path and silently dropped on another.
5825
6567
  async function handleReply(member, parsed, kind) {
5826
6568
  const p = parsed || {}
6569
+ // L2 option A (user decision): objects for which THIS reply recorded a fidelity defect. The
6570
+ // ballot for such an object counts as an abstention (see the verdict branch below). Scoped to
6571
+ // one reply on purpose — no cross-round state.
6572
+ const defectTargetsThisReply = new Set()
5827
6573
  postmark(member, p)
5828
6574
  // (1) speech (named `speech`, not `s`: `s` is the session API in this scope)
5829
6575
  const speech = p.say
@@ -5877,17 +6623,51 @@ export function apply(ctx) {
5877
6623
  // `formalOn()` gates it: off mode is a TRUE no-op, and the field is not even offered in the
5878
6624
  // reply contract there, so a stray / stale / hallucinated reply must not create Lean state.
5879
6625
  // (The TOOLS stay usable in off mode on purpose — a tool call is deliberate.)
5880
- if (formalOn() && p.formal && typeof p.formal === 'object') {
5881
- const f = p.formal
5882
- const target = idSafe(String(f.target || ''))
5883
- if (target) {
5884
- const decision = String(f.decision || '').trim()
6626
+ // Adv-verify F2 (LOW): `formal` may be an ARRAY of entries (a member recording defects for two
6627
+ // objects in one reply). The old code read `p.formal.target` only, so an array was silently
6628
+ // ignored — no notice, no enforced abstention, and the same reply's `1` counted (exactly the
6629
+ // hole L2 closed). The array form is now SUPPORTED entry by entry; a non-object entry is
6630
+ // refused loudly instead of being dropped in silence.
6631
+ // NOTE: the gate below keeps its literal two-term shape on purpose — `audit-prompt-invariants`
6632
+ // I8 matches that EXACT source text ("off must be a TRUE no-op"), and the first version of this
6633
+ // fix (a ternary on `formalOn()`) broke the invariant while behaving identically. The array
6634
+ // normalisation therefore lives INSIDE the guarded block, after the gate.
6635
+ // (This comment deliberately does NOT spell the pattern out: I8 reads the RAW source, so a
6636
+ // comment quoting the gate would satisfy the invariant even with the gate removed — the mutant
6637
+ // `M1` in `tests/audit-v5-lean-abstention.mutants.mjs` is what keeps this honest.)
6638
+ if (formalOn() && p.formal) {
6639
+ const formalEntries = Array.isArray(p.formal) ? p.formal : (typeof p.formal === 'object' ? [p.formal] : [])
6640
+ for (const f of formalEntries) {
6641
+ if (!f || typeof f !== 'object') {
6642
+ await notice(member.id, 'formal 的每个条目都必须是对象(收到 ' + JSON.stringify(f) + ')——本条已忽略。')
6643
+ continue
6644
+ }
6645
+ const target = idSafe(String(f.target || ''))
6646
+ if (!target) {
6647
+ await notice(member.id, 'formal 条目缺少 target(对象 id)——本条已忽略。')
6648
+ continue
6649
+ }
6650
+ {
6651
+ const decision = String(f.decision || '').trim()
5885
6652
  const note = String(f.note || '').trim()
5886
6653
  if (decision === 'blocked' || decision === 'defect') {
5887
6654
  if (!note) await notice(member.id, "formal.decision='" + decision + "' 必须写明 note(难度判断/阻塞原因/具体偏差)——本次未记录。")
5888
6655
  else if (decision === 'defect') {
5889
6656
  const r = await recordFidelityDefect(member.id, target, note)
5890
6657
  if (r.ok === false) await notice(member.id, '记录忠实性缺陷失败(' + (r.code || '') + '):' + (r.message || ''))
6658
+ else {
6659
+ // L2 option A (user decision): a reply that RECORDS a fidelity defect for an object
6660
+ // must not also assert that object true in the same round. The framework therefore
6661
+ // counts THIS member's verdict for THIS object as an ABSTENTION (the verdict branch
6662
+ // below reads `defectTargetsThisReply`). Only the declaring member and only this
6663
+ // round are affected: no cross-round state, no gate change, other ballots untouched.
6664
+ defectTargetsThisReply.add(target)
6665
+ // Observable note (the member must not be silently overridden): member notice + a
6666
+ // durable marker in the formal record, which `lean_lib`/`report()` surface via `note`.
6667
+ await notice(member.id, '框架规则:同一条回复里记录了忠实性缺陷(对象 ' + target + '),'
6668
+ + '你**对该对象的 verdict 已按弃权计入**(不投真也不投假);缺陷本身照常撤回证明。'
6669
+ + '若你确认命题为假,请在**下一轮**用独立理由投 0。')
6670
+ }
5891
6671
  } else {
5892
6672
  const r = await leanArchive(member.id, { kind: 'blocked', target, note })
5893
6673
  if (r.ok === false) await notice(member.id, '记录形式化阻塞失败(' + (r.code || '') + '):' + (r.message || ''))
@@ -5897,7 +6677,7 @@ export function apply(ctx) {
5897
6677
  const prev = formalOf(target)
5898
6678
  await putFormal(target, Object.assign({}, prev, {
5899
6679
  status: prev.status === 'passed' || prev.status === 'blocked' ? prev.status : 'attempted',
5900
- file, decision: 'used', note: String(f.note || prev.note || ''), updatedAt: now(),
6680
+ file, decision: 'used', decisionSource: 'member-reply', note: String(f.note || prev.note || ''), updatedAt: now(),
5901
6681
  }))
5902
6682
  await writeFormalIndex()
5903
6683
  } else if (decision) {
@@ -5905,6 +6685,7 @@ export function apply(ctx) {
5905
6685
  }
5906
6686
  }
5907
6687
  }
6688
+ }
5908
6689
  if (p.propose_verify) {
5909
6690
  const pv = typeof p.propose_verify === 'string' ? { target: p.propose_verify } : p.propose_verify
5910
6691
  if (pv && pv.target) {
@@ -5913,10 +6694,42 @@ export function apply(ctx) {
5913
6694
  }
5914
6695
  }
5915
6696
  if (p.verdict && typeof p.verdict === 'object') {
5916
- const n = normVerdictNumber(p.verdict.verdict)
5917
- if (n === undefined) await notice(member.id, 'verdict 必须是 0-1 的数值;本轮的票未被记录。')
6697
+ const declared = normVerdictNumber(p.verdict.verdict)
6698
+ if (declared === undefined) await notice(member.id, 'verdict 必须是 0-1 的数值;本轮的票未被记录。')
5918
6699
  else {
5919
- const r = await castVerdict(member.id, String(p.verdict.target || ''), n, p.verdict.reason)
6700
+ const vTarget = idSafe(String(p.verdict.target || ''))
6701
+ // L2 option A: the same reply declared a fidelity defect for this object ⇒ this member's
6702
+ // ballot counts as an ABSTENTION (0.5) instead of a boolean assertion. A value that is
6703
+ // ALREADY a proper abstention stays exactly as sent (no double counting); the durable
6704
+ // note is attached to the formal record so the override is visible in the receipt.
6705
+ // Adv-verify S7: only an ACTUAL voter has a boolean ballot to abstain from — a temp
6706
+ // worker's opinion is relayed, never counted, so it must NOT get an "abstention" marker
6707
+ // (that would claim the framework changed a vote that never existed).
6708
+ const isVoterHere = voters().some((m) => m.id === member.id)
6709
+ const enforced = isVoterHere && defectTargetsThisReply.has(vTarget) && (declared === 0 || declared === 1)
6710
+ const n = enforced ? 0.5 : declared
6711
+ if (enforced) {
6712
+ try {
6713
+ await putFormal(vTarget, (prev0) => {
6714
+ const fid = Object.assign({}, (prev0 && prev0.fidelity) || {})
6715
+ // Adv-verify F1 (MED): the marker used to be a SINGLE value, so two members
6716
+ // recording a defect in the same round overwrote each other and only the last was
6717
+ // auditable. It is an ARRAY now (old object-shaped records are folded in), with a
6718
+ // matching `abstainedCount`.
6719
+ const prevList = Array.isArray(fid.voteAbstainedByFramework)
6720
+ ? fid.voteAbstainedByFramework
6721
+ : (fid.voteAbstainedByFramework ? [fid.voteAbstainedByFramework] : [])
6722
+ const list = prevList.concat([{ at: now(), by: member.id, declared }])
6723
+ fid.voteAbstainedByFramework = list
6724
+ fid.abstainedCount = list.length
6725
+ return Object.assign({}, prev0 || { status: 'none' }, {
6726
+ fidelity: fid,
6727
+ note: (String((prev0 && prev0.note) || '') + ';本轮同回复的 verdict=' + declared + ' 已由框架按弃权计入').trim(),
6728
+ })
6729
+ })
6730
+ } catch (e) { /* the vote still counts as an abstention even if the note cannot be stored */ }
6731
+ }
6732
+ const r = await castVerdict(member.id, vTarget, n, p.verdict.reason)
5920
6733
  if (r && r.ok === false) await notice(member.id, '本轮的票未被记录(' + (r.code || '') + '):' + (r.message || ''))
5921
6734
  }
5922
6735
  }
@@ -5976,7 +6789,7 @@ export function apply(ctx) {
5976
6789
  '- 时间: ' + fmtTime(),
5977
6790
  '- 记录者: ' + member.id,
5978
6791
  '- 该成员认为原问题已解决: ' + (p.solved === true),
5979
- '- 有表决权的解决票: ' + Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、'),
6792
+ '- 有表决权的解决票: ' + (solveVotesList().join('、') || '(无)'),
5980
6793
  '',
5981
6794
  ].join('\n'))
5982
6795
  }
@@ -6179,7 +6992,15 @@ export function apply(ctx) {
6179
6992
  // round-4 item 4: only a NEWLY recorded entry warns, and the record goes through the same
6180
6993
  // retention as every other diagnostics writer in this preset (slice(-50)).
6181
6994
  if (duplicate) return keys
6182
- target.diagnostics.push({ kind: 'state-dropped-keys', where: where, keys: keys })
6995
+ // F1 (status/report review): the entry carries the SAME field names as every other
6996
+ // diagnostics record (`at`/`type`/`error`) plus its own `kind`/`where`/`keys`. It used to be
6997
+ // `{kind, where, keys}` only, so `report()` printed `undefined|undefined` for a record it
6998
+ // was supposed to explain.
6999
+ target.diagnostics.push({
7000
+ at: now(), type: 'vibe5/dropped-keys',
7001
+ error: '忽略未知参数键(未写入状态):' + keys.join(', ') + '(来源:' + where + ')',
7002
+ kind: 'state-dropped-keys', where: where, keys: keys,
7003
+ })
6183
7004
  while (target.diagnostics.length > 50) target.diagnostics.shift()
6184
7005
  }
6185
7006
  console.warn('vibe-math-v5: 忽略未知参数键(不静默丢失,已记入 diagnostics):' + keys.join(', ') + '(来源:' + where + ')')
@@ -6246,10 +7067,23 @@ export function apply(ctx) {
6246
7067
  return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'cannot reconfigure while the institute is running (pause/stop first; use vibe_v5_set to tune params)' }
6247
7068
  }
6248
7069
  const patch = {}
7070
+ let identitySwitched = false
6249
7071
  if (a.project !== undefined && String(a.project).trim()) patch.project = String(a.project).trim()
6250
7072
  if (a.institute !== undefined && String(a.institute).trim()) patch.institute = String(a.institute).trim()
6251
7073
  if (a.problem !== undefined) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
6252
7074
  if (a.params && typeof a.params === 'object') patch.params = Object.assign({}, params, normalizeParams(a.params))
7075
+ // C1 (deep-review 4): a configure with NOTHING to apply used to still commit
7076
+ // `patch.phase = 'idle'` below — a no-arg `/v5 configure` silently regressed a CONCLUDED
7077
+ // institute (`report()` then showed 已结题 false). A no-op configure is now a true no-op:
7078
+ // no commit, no phase change, and the answer says exactly that + the usage.
7079
+ if (patch.project === undefined && patch.institute === undefined && patch.problem === undefined && patch.params === undefined) {
7080
+ return {
7081
+ ok: true, noop: true, project, institute: instituteName,
7082
+ phase: String(inst().phase || phase),
7083
+ note: 'configure 没有收到任何参数:未改动任何状态(当前研究所 ' + instituteName + ',phase=' + String(inst().phase || phase) + ')。'
7084
+ + '用法:/v5 configure <研究所名> <问题…>(可在同一行里带 problem;切换研究所名会用一个新的所)。',
7085
+ }
7086
+ }
6253
7087
  if (patch.project !== undefined || patch.institute !== undefined) {
6254
7088
  const np = patch.project !== undefined ? patch.project : project
6255
7089
  const ni = patch.institute !== undefined ? patch.institute : instituteName
@@ -6264,18 +7098,29 @@ export function apply(ctx) {
6264
7098
  instituteName = ni
6265
7099
  patch.project = np
6266
7100
  patch.institute = ni
7101
+ identitySwitched = true
6267
7102
  } else if (!inst().members.length) {
6268
7103
  key = nkey
6269
7104
  project = np
6270
7105
  instituteName = ni
6271
7106
  patch.project = np
6272
7107
  patch.institute = ni
7108
+ identitySwitched = true
6273
7109
  } else {
6274
7110
  return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
6275
7111
  }
6276
7112
  }
6277
7113
  }
6278
- patch.phase = 'idle'
7114
+ // F5 (status/report review, S1 class): `phase='idle'` belongs to FOUNDING THE SETUP, not to
7115
+ // every configure. A partial configure (`problem`-only / `params`-only / institute-only with
7116
+ // the SAME name) may still apply its patch, but it must never rewrite the phase of an
7117
+ // institute that already has a life of its own — that is what made a concluded institute read
7118
+ // "阶段:idle|已结题:false". The phase is reset only when this call is actually (re)founding:
7119
+ // a REAL identity switch (`nkey !== key`), or a session that holds no institute yet.
7120
+ // (`institute-only` with an unchanged name is therefore also a no-phase-change: `patch.institute`
7121
+ // being present is not the same as the identity having moved.)
7122
+ const holdsInstitute = inst().members.length > 0 || String(inst().phase || '') !== 'idle'
7123
+ if (identitySwitched || !holdsInstitute) patch.phase = 'idle'
6279
7124
  await patchInstitute(patch)
6280
7125
  syncParamsFromState()
6281
7126
  await mkdirs()
@@ -6320,7 +7165,17 @@ export function apply(ctx) {
6320
7165
  async function doStart(args) {
6321
7166
  const a = args || {}
6322
7167
  if (running && !autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'already running' }
6323
- if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this institute already concluded; configure a new one in a new session' }
7168
+ // S1 (repro `_oneoff/s1-solved-reload.mjs`): the concluded state is durable (`phase:'solved'`
7169
+ // in the state file), so the guard checks the PERSISTED phase as well as the in-memory flag.
7170
+ // The refusal names the concrete next step instead of only the code.
7171
+ if (autoDone || String(phase) === 'solved') {
7172
+ return {
7173
+ ok: false, code: 'V5_INSTITUTE_STATE',
7174
+ message: '本所已结题(状态文件里的 phase = solved,runId = ' + String(inst().runId || '') + '),不能再 start:'
7175
+ + '结题运行的结论/论文/成员都已归档在这个所里。要继续工作请二选一 —— ①在新会话里 `/v5 configure <新研究所名> <问题>` 再 `/v5 start`(推荐);'
7176
+ + '②同一会话里也要先 `/v5 configure <新研究所名> <问题>`(本所已有成员,故 configure 只接受切换到一个**新的**研究所名)。',
7177
+ }
7178
+ }
6324
7179
  const patch = {}
6325
7180
  if (a.problem !== undefined && String(a.problem).trim()) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
6326
7181
  const p = Object.assign({}, params, normalizeParams(a.params || {}))
@@ -6370,7 +7225,11 @@ export function apply(ctx) {
6370
7225
  spawned.push(m.id)
6371
7226
  } catch (e) {
6372
7227
  if (isActivationLimitReached(e)) limitRefused = true
6373
- await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
7228
+ // G-6b (installer follow-up): the founding catch RE-writes the record that `spawnMember`'s
7229
+ // own catch already wrote — it must not drop the durable cap marker, otherwise the queue
7230
+ // would depend on matching the error TEXT for this path (the exact divergence the shared
7231
+ // predicate exists to prevent).
7232
+ await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
6374
7233
  }
6375
7234
  }
6376
7235
  for (let i = 0; i < params.researcherCount; i++) {
@@ -6381,7 +7240,7 @@ export function apply(ctx) {
6381
7240
  spawned.push(m.id)
6382
7241
  } catch (e) {
6383
7242
  if (isActivationLimitReached(e)) limitRefused = true
6384
- await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
7243
+ await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
6385
7244
  }
6386
7245
  }
6387
7246
  if (!spawned.length) {
@@ -6436,7 +7295,7 @@ export function apply(ctx) {
6436
7295
  // "queued" half of the cap handling, retried in this round — gated on the RECORDED cap error
6437
7296
  // so an ordinary failed member (broken provider, rejected tool filter) is never resurrected
6438
7297
  // by a resume. `members` keeps its meaning for the return value below.
6439
- const queued = inst().members.filter((m) => m.phase === 'failed' && /active child limit|maxActiveSubagents/.test(String(m.error || '')))
7298
+ const queued = inst().members.filter((m) => isCapRefusedMember(m))
6440
7299
  if (!members.length && !queued.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
6441
7300
  let respawned = 0
6442
7301
  beginSpawnRound() // the whole re-spawn loop is ONE round for the cap notice
@@ -6499,7 +7358,9 @@ export function apply(ctx) {
6499
7358
  // (v4 §30-T38).
6500
7359
  meeting = null
6501
7360
  pendingMeeting = null
6502
- solveVotes.clear()
7361
+ // HIGH 3: a NEW solve question starts with an empty DURABLE tally (a clear has to be
7362
+ // persisted too, otherwise a reload would resurrect the previous question's answers).
7363
+ await putSolve({ clear: true })
6503
7364
  busy.clear()
6504
7365
  wakeKind.clear()
6505
7366
  currentMember = ''
@@ -6518,6 +7379,19 @@ export function apply(ctx) {
6518
7379
  ok: true,
6519
7380
  institute: instituteName, project, key, phase,
6520
7381
  running, autoDone, runId: s.runId,
7382
+ // L4 (deep-review 5): the `【形式化结果】` announcements are delivered ONCE, to the job's
7383
+ // initiator, and live in memory. F2 (status/report review): the field is therefore labelled
7384
+ // as SESSION-scoped, because after a restart it is empty although the durable per-job
7385
+ // records in Formal/Jobs/<jobId>.json are still there.
7386
+ leanNotices: leanNotices.map((n) => ({ member: n.member, jobId: n.jobId, line: n.line })),
7387
+ leanNoticesScope: 'session-memory(重启后为空;耐久事实在 Formal/Jobs/<jobId>.json 与 vibe_v5_lean_job)',
7388
+ // F2/F3 (status/report review): one place that names the SESSION-scoped fields inside this
7389
+ // payload, so a reader (or an operator comparing two sessions) cannot mistake them for
7390
+ // durable state. Durable = derived from the state file; session = rebuilt on load.
7391
+ fieldScopes: {
7392
+ session: ['running', 'autoDone(session mirror of phase)', 'leanNotices', 'debug', 'members[].rounds', 'members[].busy', 'members[].contextPct', 'members[].childId', 'meeting', 'parkedMeeting', 'persistence.writeFailures', 'persistence.prematureReads', 'persistence.loadProblem', 'pendingSpawns[].attempts'],
7393
+ durable: ['phase', 'runId', 'quorum', 'members[] (except the three session fields)', 'members[].failReason', 'tasks', 'failedMembers', 'pendingSpawns[] (derived from durable failed members)', 'chat', 'officeRequests', 'verify', 'verifyQueue', 'verified', 'undecided', 'solveVotes', 'formal', 'paper', 'lastProgressAt', 'params'],
7394
+ },
6521
7395
  backend: backend ? backend.kind : 'uninitialized',
6522
7396
  // Skipped/malformed events AND state-file load problems. Without this the two
6523
7397
  // failure modes that silently drop state were invisible in the operator's view.
@@ -6528,23 +7402,72 @@ export function apply(ctx) {
6528
7402
  id: m.id, kind: m.kind, phase: m.phase, direction: m.direction, hiredBy: m.hiredBy,
6529
7403
  rounds: rounds.get(m.id) || 0, busy: busy.has(m.id), contextPct: contextPct.get(m.id) || 0,
6530
7404
  childId: m.childId ? m.childId.slice(0, 12) : '', error: m.error || '',
7405
+ // G-6b: the durable reason a failed member is deferred work (`activation-limit` = the host
7406
+ // cap refused it, and the framework will retry it) — needed to audit the queue without
7407
+ // re-deriving it from the error text.
7408
+ failReason: m.failReason || '',
6531
7409
  })),
6532
7410
  tasks: listTasks(),
7411
+ // LOW (deep review): a member whose provisioning failed stays on the roster forever (ids
7412
+ // are never reused, so the entry must stay) — but `resume` only retries the ones refused by
7413
+ // the HOST's live-child cap. Everything else is surfaced here with the remedy, instead of
7414
+ // being a silent, permanent `phase:'failed'` row an operator has to notice by luck.
7415
+ failedMembers: s.members.filter((m) => m.phase === 'failed').map((m) => ({ id: m.id, kind: m.kind, failReason: m.failReason || '', error: String(m.error || '').slice(0, 200) })),
7416
+ failedMembersNote: 'resume 只重试「live-child 上限」类失败;其它 provisioning 失败请由所办重新招聘(新 id,旧 id 永不复用)',
7417
+ // G-6 (installer review): the DEFERRED spawns — refused by the host's live-child cap, queued
7418
+ // by construction (durable `phase:'failed'` + `failReason:'activation-limit'`) and retried on
7419
+ // every scheduling pass until capacity frees up or the session's attempt budget runs out.
7420
+ // v4 exposed the same fact as `pendingSpawns`; without it a refusal was invisible work.
7421
+ pendingSpawns: pendingSpawnMembers().map((m) => ({
7422
+ id: m.id, kind: m.kind, attempts: spawnRetryAttempts.get(m.id) || 0,
7423
+ error: String(m.error || '').slice(0, 200),
7424
+ })),
7425
+ pendingSpawnsNote: '容量(宿主 live-child 上限)拒绝的成员:已登记为待补建,框架在每次调度轮次自动重试(容量未释放时该次重试不询问宿主,零成本);成功后该成员转为 active,本列表自动清空。非容量类的 provisioning 失败不会自动重试(会通知所办)。',
7426
+ // F8 (status/report review): the two counts have different scopes — `pending` is the
7427
+ // INSTITUTE-WIDE number of not-yet-acknowledged messages (not "my unread"), and `delivered`
7428
+ // is a CAPPED acknowledgement ledger. Both are named accordingly here.
6533
7429
  chat: { pending: s.messages.length, delivered: s.delivered.length },
7430
+ chatScope: 'pending = 全所未确认投递的消息数(非"我的未读");delivered = 封顶账本(DELIVERED_CAP,到顶后不再增长)',
7431
+ // MEDIUM 4 (deep review): the requests only the OFFICE can approve. `to:'voters'` never
7432
+ // reaches the office (it is not a member), so an unapproved proposal used to be invisible
7433
+ // to the one actor able to act on it.
7434
+ officeRequests: s.messages.filter((m) => m.to === OFFICE_INBOX).map((m) => ({ id: m.id, from: m.from, text: String(m.text || ''), at: m.at })),
7435
+ // F4 (status/report review): the list above is truncated at OFFICE_REQUEST_CAP, so the
7436
+ // truncation is published instead of pretending the list is complete.
7437
+ officeRequestsShown: s.messages.filter((m) => m.to === OFFICE_INBOX).length,
7438
+ officeRequestsCap: OFFICE_REQUEST_CAP,
7439
+ officeRequestsDropped: Number(s.officeRequestsDropped || 0),
7440
+ officeRequestsTruncated: s.messages.filter((m) => m.to === OFFICE_INBOX).length >= OFFICE_REQUEST_CAP,
7441
+ // MEDIUM 7/8 (deep review): persistence health is part of the status. `stateWriteFailures`
7442
+ // > 0 means the in-memory state is AHEAD of the file; `prematureReads` > 0 means some
7443
+ // consumer read state before the first load settled (see the note on `state()`).
7444
+ // F2/F9 (status/report review): both counters are SESSION-scoped (a restart resets them), so
7445
+ // the scope is stated here too, not only in the report line.
7446
+ persistence: { writeFailures: stateWriteFailures, prematureReads: prematureReads, loadSettled: loadSettled, loadProblem: lastLoadProblem, scope: 'session(重启后归零)', sandboxPolicyMismatch: sandboxPolicyMismatch },
6534
7447
  meeting: meeting ? { id: meeting.id, agenda: meeting.agenda, kind: meeting.kind, spoke: Object.keys(meeting.inputs), order: meeting.order } : null,
6535
7448
  parkedMeeting: pendingMeeting ? { agenda: pendingMeeting.agenda, kind: pendingMeeting.kind } : null,
6536
7449
  verify: cv ? { target: cv.target, kind: cv.kind, stage: cv.stage, round: cv.round, voted: Object.keys(cv.votes), m: quorumM(), P: voterCount() } : null,
6537
7450
  verifyQueue: s.queue.map((q) => q.target),
7451
+ // F7 (status/report review): the old `verified` meant "concluded (真 OR 假)" while its name
7452
+ // said "verified". It is kept for compatibility but the honest split is published next to it.
6538
7453
  verified: Object.keys(s.verdicts).filter((k) => s.verdicts[k] && s.verdicts[k].closed && s.verdicts[k].outcome !== 'undecided'),
7454
+ verifiedTrue: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'true' }),
7455
+ concludedFalse: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'false' }),
7456
+ verifiedNote: 'verified = 已定论(真或假,历史名,保留兼容);请用 verifiedTrue / concludedFalse / undecided 三个精确字段',
6539
7457
  undecided: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'undecided' }),
6540
- solveVotes: Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v),
7458
+ solveVotes: solveVotesList(),
6541
7459
  // Lean formal verification: mode + per-object status + the formalization TODO. This
6542
7460
  // is how an office/human audits "did we really get strict proofs, or only consensus?"
6543
7461
  formal: {
6544
7462
  mode: formalMode(),
6545
7463
  objects: Object.keys(formalRecords()).map((k) => {
6546
7464
  const r = formalRecords()[k] || {}
6547
- return { target: k, status: r.status, file: r.file || '', proof: r.proof || '', note: r.note || '', run: r.run ? { ok: r.run.ok, exitCode: r.run.exitCode, ms: r.run.ms } : null, async: r.async || null }
7465
+ // F1d (fallout #4): `decision`/`decisionSource` are READ here (member-reply vs
7466
+ // job-settle) instead of being a write-only field justified by a comment.
7467
+ // Adv-verify F3: `fidelity` (the defect record + the enforced-abstention marker) is
7468
+ // exposed too — before this it lived only in the state file, so a tool-surface audit
7469
+ // could not see WHY a ballot was counted as an abstention.
7470
+ return { target: k, status: r.status, decision: r.decision || '', decisionSource: r.decisionSource || '', fidelity: r.fidelity || null, abstainedCount: Number((r.fidelity || {}).abstainedCount || 0) || (Array.isArray((r.fidelity || {}).voteAbstainedByFramework) ? (r.fidelity.voteAbstainedByFramework.length) : ((r.fidelity || {}).voteAbstainedByFramework ? 1 : 0)), file: r.file || '', proof: r.proof || '', note: r.note || '', run: r.run ? { ok: r.run.ok, exitCode: r.run.exitCode, ms: r.run.ms } : null, async: r.async || null }
6548
7471
  }),
6549
7472
  passed: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'passed'),
6550
7473
  blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
@@ -6566,9 +7489,9 @@ export function apply(ctx) {
6566
7489
  const L = []
6567
7490
  L.push('# 「' + instituteName + '」研究所汇报')
6568
7491
  L.push('')
6569
- L.push('- 项目:' + project + '|阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
7492
+ L.push('- 项目:' + project + '|' + phaseFactLine())
6570
7493
  L.push('- 研究对象:' + (s.problem.statement ? s.problem.statement.slice(0, 200) : '(未设定)'))
6571
- L.push('- 求真门槛:' + (qv.started ? 'm = ' + qv.m : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + qv.voterCount + ' 人|rosterVersion ' + qv.rosterVersion)
7494
+ L.push('- 求真门槛:' + (qv.started ? 'm = ' + qv.m : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + qv.voterCount + ' 人|rosterVersion(名册版本,多个视图不一致时用它判断谁过期)' + qv.rosterVersion)
6572
7495
  L.push('')
6573
7496
  L.push('## 编制')
6574
7497
  if (!s.members.length) L.push('(暂无成员)')
@@ -6576,7 +7499,7 @@ export function apply(ctx) {
6576
7499
  L.push('- ' + m.id + '|' + kindLabel(m.kind) + '|' + m.phase +
6577
7500
  (m.hiredBy ? '|雇主 ' + m.hiredBy : '') +
6578
7501
  (m.direction ? '|方向:' + m.direction : '') +
6579
- '|轮次 ' + (rounds.get(m.id) || 0) +
7502
+ '|轮次 ' + (rounds.get(m.id) || 0) + '(本会话计数,重启归零)' +
6580
7503
  (m.error ? '|⚠ ' + m.error : ''))
6581
7504
  }
6582
7505
  L.push('')
@@ -6598,19 +7521,63 @@ export function apply(ctx) {
6598
7521
  '|平均概率 ' + Number(v.mean || 0).toFixed(2) + '|' + (v.reason || '') + ')')
6599
7522
  }
6600
7523
  const cv = currentVerify()
6601
- if (cv) L.push('- 进行中:' + cv.target + '|' + cv.stage + ' 第 ' + cv.round + ' 轮|已投 ' + Object.keys(cv.votes).join('、'))
7524
+ if (cv) L.push('- 进行中:' + cv.target + '|' + cv.stage + ' 第 ' + cv.round + ' 轮' + (Object.keys(cv.votes).length ? '|已投 ' + Object.keys(cv.votes).join('、') : '|尚无人投票'))
6602
7525
  if (s.queue.length) L.push('- 队列:' + s.queue.map((q) => q.target).join('、'))
6603
7526
  L.push('')
6604
7527
  L.push('## 群聊 / 会议')
6605
- L.push('- 未读消息:' + s.messages.length + '|已投递:' + s.delivered.length)
6606
- if (meeting) L.push('- 进行中会议:' + meeting.id + '|' + meeting.agenda + '|已发言 ' + Object.keys(meeting.inputs).join('、'))
7528
+ L.push('- 未投递消息(全所):' + s.messages.length + '|已确认投递(封顶账本):' + s.delivered.length)
7529
+ // MEDIUM 4 + F4: the office's own inbox — requests that only the office can approve, with the
7530
+ // truncation published (the list is capped at OFFICE_REQUEST_CAP).
7531
+ {
7532
+ const reqs = s.messages.filter((m) => m.to === OFFICE_INBOX)
7533
+ const dropped = Number(s.officeRequestsDropped || 0)
7534
+ L.push('- 待所办裁定:' + (reqs.length
7535
+ ? '显示最新 ' + reqs.length + ' 条' + (dropped ? '(上限 ' + OFFICE_REQUEST_CAP + ',累计已丢弃最旧 ' + dropped + ' 条)' : '') + ';最新:' + String(reqs[reqs.length - 1].text || '').slice(0, 120)
7536
+ : '(无)'))
7537
+ }
7538
+ if (meeting) L.push('- 进行中会议:' + meeting.id + '|' + meeting.agenda + (Object.keys(meeting.inputs).length ? '|已发言 ' + Object.keys(meeting.inputs).join('、') : '|尚无人发言'))
6607
7539
  if (pendingMeeting) L.push('- 暂存会议:' + pendingMeeting.agenda)
6608
- L.push('- 历史会议:' + s.meetings.length + ' 次|辩论录:' + s.debates.length + ' 份')
6609
- L.push('- 解决票:' + (solveVotes.size ? Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、') : '(无)'))
6610
- if ((s.diagnostics || []).length) {
6611
- L.push('')
6612
- L.push('## ⚠ 状态诊断(被跳过的事件 / 状态文件读取问题)')
6613
- for (const d of s.diagnostics.slice(-10)) L.push('- ' + fmtTime(d.at) + '|' + d.type + '|' + d.error)
7540
+ // F6 (status/report review): a meeting whose index entry exists but which never FINALIZED is
7541
+ // not history. `meetingOpen` is durable (set in `beginMeeting`, cleared in `finalizeMeeting`),
7542
+ // so a restart cannot silently drop the minutes while the count still looks normal.
7543
+ {
7544
+ const open = s.meetingOpen || null
7545
+ const finalized = (s.meetings || []).filter((x) => !open || x.id !== open.id)
7546
+ L.push('- 历史会议:' + finalized.length + ' 次|辩论录:' + s.debates.length + ' 份'
7547
+ + (open ? '|⚠ 有 1 场会议未收束:' + open.id + '(开始于 ' + fmtTime(open.at) + ';本会话未恢复它的发言,纪要可能缺失)' : ''))
7548
+ }
7549
+ // G-6: deferred spawns (host live-child cap refused them; the framework retries each pass).
7550
+ {
7551
+ const pend = pendingSpawnMembers()
7552
+ if (pend.length) {
7553
+ L.push('- 待补建成员:' + pend.map((m) => m.id + '(' + kindLabel(m.kind) + ',已重试 ' + (spawnRetryAttempts.get(m.id) || 0) + ' 次)').join('、')
7554
+ + '|容量释放后框架会在下一次调度轮次自动重试(容量未释放时该次重试不询问宿主);成功后该成员转为 active 并从本行消失')
7555
+ }
7556
+ }
7557
+ L.push('- 解决票:' + (solveVotesList().length ? solveVotesList().join('、') : '(无)'))
7558
+ // F1 (status/report review, HIGH): the section must read the sources its heading advertises —
7559
+ // (a) skipped/malformed EVENTS (top-level `state.diagnostics`, written by `applyV5Event`'s
7560
+ // catch), (b) state-FILE problems (the session log `loadProblemLog` / `lastLoadProblem`), and
7561
+ // (c) ignored keys (institute-level `s.diagnostics`, written by `reportDroppedStateKeys`).
7562
+ // Every field is printed with an explicit fallback, so the literal `undefined` can never
7563
+ // appear. The section is omitted entirely when there is nothing to report (the
7564
+ // "assemble only when a value exists" rule).
7565
+ const topDiag = (() => { try { return (state().diagnostics || []) } catch (e) { return [] } })()
7566
+ const droppedDiag = (s.diagnostics || []).filter((d) => d && d.kind === 'state-dropped-keys')
7567
+ const eventDiag = topDiag.filter((d) => d && d.type !== 'vibe5/load')
7568
+ const loadDiag = topDiag.filter((d) => d && d.type === 'vibe5/load').concat(loadProblemLog)
7569
+ {
7570
+ const fmtDiag = (d) => fmtTime(d.at) + '|' + String(d.type || '(无 type)') + '|' + String(d.error || '(无 error)')
7571
+ const lines = []
7572
+ for (const d of eventDiag.slice(-10)) lines.push('- [被跳过的事件] ' + fmtDiag(d))
7573
+ for (const d of loadDiag.slice(-10)) lines.push('- [状态文件] ' + fmtDiag(d))
7574
+ for (const d of droppedDiag.slice(-10)) lines.push('- [忽略的键] ' + fmtDiag(d))
7575
+ if (lastLoadProblem && !loadDiag.some((d) => String(d.error || '') === lastLoadProblem)) lines.push('- [状态文件] ' + lastLoadProblem)
7576
+ if (lines.length) {
7577
+ L.push('')
7578
+ L.push('## ⚠ 状态诊断(被跳过的事件 / 状态文件读取问题 / 忽略的键)')
7579
+ for (const ln of lines) L.push(ln)
7580
+ }
6614
7581
  }
6615
7582
  L.push('')
6616
7583
  L.push('## Lean 形式化')
@@ -6620,6 +7587,23 @@ export function apply(ctx) {
6620
7587
  L.push('- 已通过:' + (Object.keys(s.formal || {}).filter((k) => (s.formal || {})[k].status === 'passed').join('、') || '(无)'))
6621
7588
  L.push('- 已记录阻塞:' + (Object.keys(s.formal || {}).filter((k) => (s.formal || {})[k].status === 'blocked').join('、') || '(无)'))
6622
7589
  L.push('- 形式化待办:' + ((s.todo || []).map((t) => t.id).join('、') || '(无)'))
7590
+ // F1d (fallout #4): the member-reported vs job-settled distinction, readable.
7591
+ {
7592
+ const dec = Object.keys(s.formal || {}).filter((k) => (s.formal[k] || {}).decision).map((k) => k + '=' + s.formal[k].decision + '(' + (s.formal[k].decisionSource || '来源未记录') + ')')
7593
+ if (dec.length) L.push('- 形式化决定:' + dec.join('、') + '(来源 member-reply=成员自述,job-settle=后台作业落地)')
7594
+ // Adv-verify F3: the enforced-abstention marker is readable here too (how many ballots the
7595
+ // framework turned into abstentions because the same reply recorded a fidelity defect).
7596
+ const enforced = Object.keys(s.formal || {}).filter((k) => {
7597
+ const fid = (s.formal[k] || {}).fidelity || {}
7598
+ return Number(fid.abstainedCount || 0) > 0 || (Array.isArray(fid.voteAbstainedByFramework) && fid.voteAbstainedByFramework.length) || fid.voteAbstainedByFramework
7599
+ }).map((k) => {
7600
+ const fid = (s.formal[k] || {}).fidelity || {}
7601
+ const n = Number(fid.abstainedCount || 0) || (Array.isArray(fid.voteAbstainedByFramework) ? fid.voteAbstainedByFramework.length : 1)
7602
+ const who = (Array.isArray(fid.voteAbstainedByFramework) ? fid.voteAbstainedByFramework : [fid.voteAbstainedByFramework]).filter(Boolean).map((x) => x.by || '?').join('/')
7603
+ return k + '×' + n + (who ? '(' + who + ')' : '')
7604
+ })
7605
+ if (enforced.length) L.push('- 框架强制弃权:' + enforced.join('、') + '(同回复记录忠实性缺陷 ⇒ 该成员对该对象的布尔票按弃权计入)')
7606
+ }
6623
7607
  L.push('- 可复用库:VibeMath/Formal/{Lib,Proved}/(跨项目)|本所形式化:Formal/|归档证明:Verified/Lean/')
6624
7608
  }
6625
7609
  L.push('')
@@ -6640,7 +7624,15 @@ export function apply(ctx) {
6640
7624
  L.push('')
6641
7625
  L.push('## 文件位置')
6642
7626
  L.push('- 根目录:' + instRoot())
6643
- L.push('- 工作目录相对路径(你的文件工具的基准):' + instRootRel() + '/|已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
7627
+ L.push('- 工作目录相对路径(你的文件工具的基准):' + instRootRel() + '/|已确立:Verified/|成员库:Members/<id>/(成员的库文件路径 = Members/<id>/Progress/progress.md 等;文件工具按会话 cwd 解析 ⇒ 请用完整路径)|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
7628
+ // MEDIUM 7/8 + F9: the two persistence facts an operator must not have to guess — with the
7629
+ // SESSION scope stated (a restart resets both counters) and no raw boolean in the sentence.
7630
+ L.push('- 持久化(本会话计数):写失败 ' + stateWriteFailures + ' 次|首次载入前被读取 ' + prematureReads + ' 次|状态文件已载入:' + (loadSettled ? '是' : '否'))
7631
+ if (lastLoadProblem) L.push('- ⚠ 状态文件问题:' + lastLoadProblem)
7632
+ // G-7 (installer ROUND2): the sandbox fence's root vs the session workspace. A named warning,
7633
+ // not a refusal: v5 cannot change the host's fence, and refusing would stop persisting work.
7634
+ if (sandboxPolicyMismatch) L.push('- ⚠ 沙箱策略根与会话工作目录不一致:策略=' + sandboxPolicyMismatch.policyRoot + '|会话=' + sandboxPolicyMismatch.expected
7635
+ + '(v5 用会话工作目录拼所有路径;若宿主按策略根解析写操作,产物可能落在预期之外的那棵树——请核对 sandbox 策略的 workspaceRoot)')
6644
7636
  return { ok: true, report: L.join('\n'), quorum: qv }
6645
7637
  }
6646
7638
  // Adding/removing a PERMANENT researcher is a change to the institute's public
@@ -6648,15 +7640,18 @@ export function apply(ctx) {
6648
7640
  async function addResearcher(callerId, direction) {
6649
7641
  if (!isOffice(callerId)) {
6650
7642
  await say(callerId, { to: 'voters', kind: 'voters', text: '提议增聘一名常驻研究员(方向:' + String(direction || '未指定') + ')' })
6651
- return { ok: true, proposed: true, message: '已向所办提议增聘常驻研究员(编制变更需所办批准)' }
7643
+ // MEDIUM 4: the office is the ONLY actor that can execute this, and `to:'voters'` never
7644
+ // reaches it — the proposal is now addressed to the office as well.
7645
+ await say(callerId, { to: OFFICE_INBOX, kind: 'office-request', text: '请所办裁定:增聘一名常驻研究员(方向:' + String(direction || '未指定') + '|提议人:' + callerId + ')' })
7646
+ return { ok: true, proposed: true, message: '已向所办提议增聘常驻研究员(编制变更需所办批准;所办会在 status/report 的 officeRequests 里看到该提议)' }
6652
7647
  }
6653
7648
  if (!running || autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
6654
7649
  const m = await newMember('researcher', { direction: String(direction || '') })
6655
7650
  try {
6656
7651
  await spawnMember(m, null)
6657
7652
  } catch (e) {
6658
- await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
6659
- if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: activationLimitText(hostChildLimit) } }
7653
+ await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
7654
+ if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: activationLimitText(hostChildLimit) + '(已登记为待补建:容量释放后框架会在下一次调度轮次自动重试)' } }
6660
7655
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: String((e && e.message) || e) }
6661
7656
  }
6662
7657
  await saveChatLine('【编制】所办增聘常驻研究员 ' + m.id + (direction ? '(方向:' + direction + ')' : '') +
@@ -6698,6 +7693,9 @@ export function apply(ctx) {
6698
7693
  taskCreate, taskList: listTasks, getTask, taskUpdate, taskAssign, taskPrioritize,
6699
7694
  // libraries
6700
7695
  publishProgress, recordCard, readLibrary,
7696
+ // path helpers: the tool face advertises the CWD-relative spellings (L1), so they must be
7697
+ // reachable from the registration block too.
7698
+ instRel, instRootRel, formalRootRel,
6701
7699
  // Lean formal verification (docs/formal-verification.md)
6702
7700
  formalMode, formalOn, formalRecords, formalTodo, formalOf, rebuildLeanLibIndexes,
6703
7701
  leanArchive, leanRunTool, writeFormalIndex, writeFormalTodo, recordFidelityDefect,
@@ -6752,7 +7750,7 @@ export function apply(ctx) {
6752
7750
  */
6753
7751
  function withCaller(s, x, what, fn) {
6754
7752
  const caller = s.officeCaller(x)
6755
- if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: ' + what + ' needs a resolved member id or the office (the session root)' }
7753
+ if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: ' + what + ' needs a resolved member id or the office (the session root)', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
6756
7754
  return fn(caller)
6757
7755
  }
6758
7756
 
@@ -6860,30 +7858,30 @@ export function apply(ctx) {
6860
7858
  // not the office either — without this check ANY caller's text was delivered as the office
6861
7859
  // (audit L6 follow-up; the tool's own description says "from the office/human").
6862
7860
  const caller = s.officeCaller(x)
6863
- if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: relaying a message as the office needs the office (the session root)' }
7861
+ if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: relaying a message as the office needs the office (the session root)', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
6864
7862
  if (caller !== 'office') return { ok: false, code: 'V5_NOT_OFFICE', message: 'vibe_v5_message relays the OFFICE voice; a member speaks with vibe_v5_say' }
6865
7863
  const to = String(a.to || 'all')
6866
7864
  return s.say('office', { to, text: String(a.content), kind: to === 'all' || to === 'voters' ? 'office' : 'dm' })
6867
7865
  })
6868
7866
  registerTool('vibe_v5_meeting', 'Convene a meeting (office/academician) or propose one (any other member — relayed to the academician/office). Parked automatically while a verification is in flight. kind picks the meeting type: "sync" = routine coordination (default), "division" = split the work, "verify-request" = ask the group to verify an object, "solve-vote" = put "is the original problem solved?" to a vote; target names the object for verify-request/solve-vote.', objParams({ agenda: S, kind: { type: 'string', enum: ['sync', 'division', 'verify-request', 'solve-vote'] }, target: S }, ['agenda']), (s, a, x) => {
6869
7867
  const caller = s.officeCaller(x)
6870
- if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may convene/propose a meeting' }
7868
+ if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may convene/propose a meeting', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
6871
7869
  return s.startMeeting(caller, a)
6872
7870
  })
6873
7871
  registerTool('vibe_v5_members', 'List the institute roster (office, employer, phase, direction, rounds).', objParams({}), (s) => ({ ok: true, members: s.status().members, quorum: s.status().quorum }))
6874
7872
  registerTool('vibe_v5_hire', 'Hire one temp worker (office; the academician and every permanent researcher may also hire their own). Requires purpose and initial_task. term (optional) is the task deadline/period text the worker is told at onboarding; direction (optional) states what it should work on.', objParams({ purpose: S, initial_task: S, direction: S, term: S, to: S }, ['purpose', 'initial_task']), (s, a, x) => {
6875
7873
  const caller = a.to ? String(a.to) : s.officeCaller(x)
6876
- if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may hire' }
7874
+ if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may hire', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
6877
7875
  return s.hire(caller, a)
6878
7876
  })
6879
7877
  registerTool('vibe_v5_fire', 'Dismiss a temp worker for real: cancel its turn, release its resident child, reclaim its tasks, drop its mail, and mark it dismissed (its id is never reused).', objParams({ id: S, reason: S }, ['id']), (s, a, x) => {
6880
7878
  const caller = s.officeCaller(x)
6881
- if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or the employer may fire' }
7879
+ if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or the employer may fire', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
6882
7880
  return s.fire(caller, a)
6883
7881
  })
6884
7882
  registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => {
6885
7883
  const caller = s.officeCaller(x)
6886
- if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: adding a permanent researcher is the office\'s decision (the session root)' }
7884
+ if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: adding a permanent researcher is the office\'s decision (the session root)', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
6887
7885
  return s.addResearcher(caller, a && a.direction)
6888
7886
  })
6889
7887
  // "Office only" (the tool's own description) must be ENFORCED, not just documented: the handler
@@ -6975,26 +7973,36 @@ export function apply(ctx) {
6975
7973
  parts.push('- 距上次实质进展:' + Math.round(idleFor / 1000) + ' 秒')
6976
7974
  return { ok: true, overview: parts.join('\n') }
6977
7975
  })
6978
- registerTool('vibe_v5_assign', '(academician) ASSIGN work: create or pick a task and give it to a specific member (including temp workers), stating WHY and the acceptance criteria. The assignee executes by default and may object with reasons (which are broadcast).', objParams({ task_id: S, subject: S, description: S, to: S, why: S, acceptance: S, priority: I, write_scopes: SA }, ['to', 'why', 'acceptance']), (s, a, x) => withCaller(s, x, 'an assignment', (caller) => s.taskAssign(caller, a)))
6979
- registerTool('vibe_v5_prioritize', '(academician) Set institute-wide priorities: an ordered list of {task_id, priority} plus WHY. This orders work only — it never changes what is true.', objParams({ order: { type: 'array', items: { type: 'object' } }, why: S }), (s, a, x) => withCaller(s, x, 'setting priorities', (caller) => s.taskPrioritize(caller, a)))
6980
- registerTool('vibe_v5_nudge', '(academician) Supervise: wake one member with a stated reason and a concrete suggested next step. Prefer a specific next step over a bare "hurry up".', objParams({ to: S, why: S, next_step: S }, ['to', 'why']), (s, a, x) => withCaller(s, x, 'a nudge', (caller) => s.nudge(caller, a)))
7976
+ registerTool('vibe_v5_assign', '(office, or the academician when academicianLeads) ASSIGN work: create or pick a task and give it to a specific member (including temp workers), stating WHY and the acceptance criteria. The assignee executes by default and may object with reasons (which are broadcast).', objParams({ task_id: S, subject: S, description: S, to: S, why: S, acceptance: S, priority: I, write_scopes: SA }, ['to', 'why', 'acceptance']), (s, a, x) => withCaller(s, x, 'an assignment', (caller) => s.taskAssign(caller, a)))
7977
+ registerTool('vibe_v5_prioritize', '(office, or the academician when academicianLeads) Set institute-wide priorities: an ordered list of {task_id, priority} plus WHY. This orders work only — it never changes what is true.', objParams({ order: { type: 'array', items: { type: 'object' } }, why: S }), (s, a, x) => withCaller(s, x, 'setting priorities', (caller) => s.taskPrioritize(caller, a)))
7978
+ registerTool('vibe_v5_nudge', '(office, or the academician when academicianLeads) Supervise: wake one member with a stated reason and a concrete suggested next step. Prefer a specific next step over a bare "hurry up".', objParams({ to: S, why: S, next_step: S }, ['to', 'why']), (s, a, x) => withCaller(s, x, 'a nudge', (caller) => s.nudge(caller, a)))
6981
7979
 
6982
7980
  // ── Lean formal verification (docs/formal-verification.md) ────────────────
6983
7981
  // These three tools are registered UNCONDITIONALLY: tool registration is static (a
6984
7982
  // dynamic registration would depend on a runtime knob and break the effect discipline),
6985
7983
  // while the MODE only decides whether the framework TELLS members about them. In 'off'
6986
7984
  // mode they still work if a human or agent calls them deliberately.
6987
- registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. With leanAsync (default true) the compile is ENQUEUED and this returns immediately with async:{jobId,state} — nothing is compiled yet; inspect it via vibe_v5_lean_lib.jobs or wait for the next round\'s 【形式化结果】 line. With leanAsync=false it blocks and returns the compiler output (exitCode/stdout/stderr). Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT. Pass target=<object id> to also record the run against that object. The framework appends `--search-path <VibeMath root>` before the file name (unless leanArgs already sets a search root).', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
6988
- registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (only once the queued compile settles ok) also Verified/Lean/<target>.lean, marking the object Lean-passed. Re-archiving IDENTICAL content is de-duplicated (deduped:true, no rewrite/recompile). kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
7985
+ registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Parameters: file (required; `Formal/x.lean` or the full CWD-relative path — both name the same file), target (optional object id to record the run against), timeout_ms (optional per-run budget). There is NO run/run=false switch here — run-vs-archive are separate tools; use vibe_v5_lean_archive {run:false} to archive WITHOUT compiling. With leanAsync (default true) the compile is ENQUEUED and this returns immediately with async:{jobId,state} — nothing is compiled yet; the result is durable at Formal/Jobs/<jobId>.json (vibe_v5_lean_job {jobId} reads it), is listed by vibe_v5_lean_lib.jobs, and the initiator also gets ONE 【形式化结果】 announcement in its next round. With leanAsync=false it blocks and returns the compiler output (exitCode/stdout/stderr). Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT. The framework appends `--search-path <VibeMath root>` before the file name (unless leanArgs already sets a search root).', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
7986
+ registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (only once the queued compile settles ok) also Verified/Lean/<target>.lean, marking the object Lean-passed. Re-archiving IDENTICAL content is de-duplicated (deduped:true, no rewrite/recompile). kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required). Optional `run` (default true): whether to COMPILE after writing — `run:false` archives the text only (useful when this host has no toolchain; the object then stays `attempted`/unset until something compiles it).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
6989
7987
  registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md (with an import-dependency column), plus the global cross-project Formal/Lib and Formal/Proved indexes, the background-compile jobs (jobs) and the injected search path (paths.searchPath). Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
6990
7988
  const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
6991
7989
  const st = s.status()
6992
7990
  return {
6993
7991
  ok: true, mode: s.formalMode(), rebuilt: !(a && a.refresh === false),
6994
7992
  counts: r, todo: s.formalTodo(), jobs: s.leanJobsView(),
6995
- objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note, async: (s.formalRecords()[k] || {}).async || null })),
6996
- paths: { project: 'Formal/(相对研究所根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/', searchPath: s.leanSearchRootView() },
6997
- hint: '复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'。复用已归档内容:import Formal.Lib.<name> / import Formal.Proved.<name>,或用 vibe_v5_lean_read {name} 取原文逐字复制。同内容重复归档会自动去重。',
7993
+ objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, decision: (s.formalRecords()[k] || {}).decision || '', decisionSource: (s.formalRecords()[k] || {}).decisionSource || '', fidelity: (s.formalRecords()[k] || {}).fidelity || null, abstainedCount: Number(((s.formalRecords()[k] || {}).fidelity || {}).abstainedCount || 0), file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note, async: (s.formalRecords()[k] || {}).async || null })),
7994
+ // L1/L5/L6 (deep-review 5): ONE basis for every entry (cwd-relative, i.e. what a member's own
7995
+ // file tools resolve), an explicit `toolShortForm` for the institute-relative spelling the
7996
+ // tools also accept, and the bare `Lib/` hint replaced by the real library root.
7997
+ paths: {
7998
+ basis: 'all paths below are relative to the SESSION CWD (what your own file tools use)',
7999
+ project: s.instRel('Formal') + '/', toolShortForm: 'Formal/',
8000
+ lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/',
8001
+ proofs: s.instRel('Verified/Lean') + '/', searchPath: s.leanSearchRootView(),
8002
+ },
8003
+ hint: '复用优先:先在 ' + s.instRel('Formal/Lib') + '/ 里找现成定义(vibe_v5_lean_read {name} 取原文);'
8004
+ + '新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'。'
8005
+ + '复用已归档内容:import Formal.Lib.<name> / import Formal.Proved.<name>。同内容重复归档会自动去重。',
6998
8006
  verify: st.verify ? st.verify.target : null,
6999
8007
  }
7000
8008
  })
@@ -7010,7 +8018,7 @@ export function apply(ctx) {
7010
8018
  // ── /v5 slash command ────────────────────────────────────────────────────
7011
8019
  ctx.effect(() => commands.register({
7012
8020
  name: 'v5', description: 'control the Vibe Math V5 research institute',
7013
- input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=en] [format=tex] [editor=office] [force]]' },
8021
+ input: { hint: '[configure <研究所名> <问题…>|start|resume|pause|stop|status|report|members|message <收件人|all> <正文…>|meeting <议程…>|hire <用途> <初始任务…>|fire <成员id> [理由…]|add [方向…]|remove <成员id>|set <键>=<值> …|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]]' },
7014
8022
  handler: async function (inv) {
7015
8023
  const s = getSession(inv && inv.agent)
7016
8024
  if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
@@ -7042,7 +8050,20 @@ export function apply(ctx) {
7042
8050
  else if (cmd === 'members') r = { ok: true, members: s.status().members }
7043
8051
  else if (cmd === 'message') r = await s.say('office', { to: rest[0] || 'all', text: rest.slice(1).join(' '), kind: 'office' })
7044
8052
  else if (cmd === 'meeting') r = await s.startMeeting('office', { agenda: rest.join(' '), kind: 'sync' })
7045
- else if (cmd === 'hire') r = await s.hire('office', { purpose: rest[0] || '', initial_task: rest.slice(1).join(' ') || rest[0] || '' })
8053
+ // C3 (deep-review 4): `/v5 hire X` used to FABRICATE `initial_task` from the purpose, while
8054
+ // the tool path requires both (`hire 必须写明 purpose/initial_task`). The CLI now requires
8055
+ // both too, so the member can never receive a meaningless initial task behind an `ok:true`.
8056
+ else if (cmd === 'hire') {
8057
+ if (!rest.length || !rest.slice(1).join(' ').trim()) {
8058
+ r = {
8059
+ ok: false, code: 'V5_INVALID_ARGUMENT',
8060
+ message: '用法:/v5 hire <用途 purpose> <初始任务 initial_task…>(两者都必填,与 vibe_v5_hire 一致)。'
8061
+ + '例如:/v5 hire 验算大整数分解的边界情形 用 30 分钟写出 50 位以内合数的试除脚本并归档回执。',
8062
+ }
8063
+ } else {
8064
+ r = await s.hire('office', { purpose: rest[0], initial_task: rest.slice(1).join(' ') })
8065
+ }
8066
+ }
7046
8067
  else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
7047
8068
  else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
7048
8069
  else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
@@ -7069,15 +8090,31 @@ export function apply(ctx) {
7069
8090
  r = await s.startPaper('manual', { lang: o.lang, format: o.format, editor: o.editor, force })
7070
8091
  } else if (cmd === 'set') {
7071
8092
  const upd = {}
8093
+ // C2 (deep-review 4): a token WITHOUT `=` used to be skipped silently, so
8094
+ // `/v5 set mathMode typed` answered `ok:true` and changed NOTHING. Every malformed token
8095
+ // is now refused with the accepted shape, instead of a success that lies.
8096
+ const badTokens = []
7072
8097
  for (const tok of rest) {
7073
8098
  const eq = tok.indexOf('=')
7074
- if (eq <= 0) continue
8099
+ if (eq <= 0) { badTokens.push(tok); continue }
7075
8100
  const k = tok.slice(0, eq), v = tok.slice(eq + 1)
7076
8101
  const n = Number(v)
7077
8102
  upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
7078
8103
  }
7079
- r = await s.setParams(upd)
7080
- } else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]' }
8104
+ if (badTokens.length) {
8105
+ r = {
8106
+ ok: false, code: 'V5_INVALID_ARGUMENT',
8107
+ message: '这些参数缺少 "="(未被解析):' + badTokens.join('、') + '。正确写法:/v5 set <键>=<值> …(例如 mathMode=typed quorumCap=3 formalVerify=require)',
8108
+ }
8109
+ } else if (!Object.keys(upd).length) {
8110
+ r = {
8111
+ ok: false, code: 'V5_INVALID_ARGUMENT',
8112
+ message: '/v5 set 需要至少一个 <键>=<值>(例如 mathMode=typed)。可用键见 vibe_v5_set 的描述与 /v5 status 的 params。',
8113
+ }
8114
+ } else {
8115
+ r = await s.setParams(upd)
8116
+ }
8117
+ } else r = { ok: false, usage: 'configure <研究所名> <问题…>|start|resume|pause|stop|status|report|members|message <收件人|all> <正文…>|meeting <议程…>|hire <用途> <初始任务…>|fire <成员id> [理由…]|add [方向…]|remove <成员id>|set <键>=<值> …|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]' }
7081
8118
  // A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
7082
8119
  // CommandResult union distinguishes success from error, and returning 'success' made a rejected
7083
8120
  // invocation look identical to a successful one (same fix as v2/v3).