dsh-vibe-math 2.4.1 → 2.6.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 (46) hide show
  1. package/README.en.md +53 -14
  2. package/README.md +54 -14
  3. package/cordis.patch.yml +54 -17
  4. package/docs/AUDIT-CHECKLIST.md +26 -7
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/final-paper.md +171 -0
  7. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
  9. package/docs/test-timing.md +77 -40
  10. package/installer.js +225 -45
  11. package/package.json +6 -3
  12. package/preset-declaration.js +38 -7
  13. package/prompt-corpus-persona/persona-corpus.json +13 -13
  14. package/prompt-corpus-persona/persona-corpus.md +113 -39
  15. package/prompt-corpus-v2/formal-verify-v2.json +128 -92
  16. package/prompt-corpus-v2/formal-verify-v2.md +380 -142
  17. package/prompt-corpus-v3/formal-verify-v3.json +117 -81
  18. package/prompt-corpus-v3/formal-verify-v3.md +693 -1189
  19. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  20. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  21. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  22. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  23. package/tests/audit-installer-compat.test.mjs +70 -4
  24. package/tests/audit-installer-policy.test.mjs +229 -4
  25. package/tests/audit-persona-sensitivity.mjs +68 -8
  26. package/tests/audit-persona-surface.test.mjs +21 -8
  27. package/tests/audit-v5-sensitivity.mjs +47 -6
  28. package/tests/e2e-v5-round2.test.mjs +825 -1
  29. package/tests/formal-verify-v2.test.mjs +57 -2
  30. package/tests/formal-verify-v3.test.mjs +67 -2
  31. package/tests/formal-verify-v4.test.mjs +120 -4
  32. package/tests/prompt-v5-integrity.test.mjs +9 -3
  33. package/tests/run-tests.mjs +104 -23
  34. package/tests/selfdrive-v5.mjs +55 -4
  35. package/vibe-math-v2/agent.cordis.yml +20 -4
  36. package/vibe-math-v2/vibe-math-v2.js +1318 -106
  37. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  38. package/vibe-math-v3/agent.cordis.yml +36 -14
  39. package/vibe-math-v3/vibe-math-v3.js +1348 -179
  40. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  41. package/vibe-math-v4/agent.cordis.yml +36 -8
  42. package/vibe-math-v4/vibe-math-v4.js +1195 -57
  43. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  44. package/vibe-math-v5/agent.cordis.yml +16 -8
  45. package/vibe-math-v5/vibe-math-v5.js +1628 -112
  46. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +143 -41
@@ -52,7 +52,6 @@
52
52
  // field — so the old projection-based primary made the user's own session unresumable.
53
53
  export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
54
54
 
55
- const PROJECTION_KEY = 'vibeMathV5'
56
55
  const PROJECTION_VERSION = 1
57
56
  const EV = {
58
57
  institute: 'vibe5/institute',
@@ -69,6 +68,34 @@ const EV = {
69
68
  formal: 'vibe5/formal',
70
69
  }
71
70
 
71
+ // ── final paper (docs/final-paper.md) ─────────────────────────────────────────
72
+ // The run's LAST deliverable. The permanent staff each write their own part, cross-review
73
+ // each other's part, and the editor named by `paperEditor` finalises — but ONLY after the
74
+ // whole institute has agreed the parts are deliverable, and (when the office edits) only
75
+ // after the office has consulted the institute. The phase runs BEFORE the completion flags
76
+ // are flipped (docs/final-paper.md §3): after `phase='solved'` the machinery refuses to wake
77
+ // members (`wakeIfIdle`), to convene meetings (`startMeeting`) or to dispatch an end.
78
+ const PAPER_MAX_ROUNDS = 3
79
+ // Chinese first (docs/final-paper.md §8): xelatex covers CJK out of the box; English prefers pdflatex.
80
+ const PAPER_ENGINE_ORDER = {
81
+ zh: ['xelatex', 'latexmk', 'pdflatex', 'lualatex', 'tectonic'],
82
+ en: ['pdflatex', 'latexmk', 'lualatex', 'tectonic', 'xelatex'],
83
+ }
84
+ // The repair pass keeps only what the text itself needs; everything else (hyperref,
85
+ // longtable, booktabs, an unknown package) is dropped before the retry.
86
+ const PAPER_REPAIR_CORE = ['amsmath', 'amssymb', 'amsthm', 'geometry', 'ctex']
87
+ const PAPER_SECTIONS = [
88
+ '标题、作者、日期与摘要',
89
+ '引言与问题背景',
90
+ '原问题的完整解法',
91
+ '已检验通过的命题',
92
+ '已解决的子问题 / 中间成果',
93
+ '创造或发现的有价值之物:方法、理论、思想、经验与数学理解',
94
+ '规律总结',
95
+ '讨论、局限与展望',
96
+ '附录:证据与文件索引',
97
+ ]
98
+
72
99
  // Stable error codes (ported from DSH agent-teams' typed-error discipline).
73
100
  function v5err(code, message) {
74
101
  const e = new Error(message || code)
@@ -164,6 +191,9 @@ export function apply(ctx) {
164
191
  // The fold is shared by BOTH persistence backends, so the state machine is
165
192
  // defined exactly once. It must return a NEW top-level reference whenever
166
193
  // anything changed (the state store compares by Object.is).
194
+ // The acknowledgement ledger is an LRU window, NOT a full history: it exists to answer
195
+ // "was this message delivered?", and the whole snapshot is rewritten on every commit.
196
+ const DELIVERED_CAP = 500
167
197
  function emptyInstitute(key, project, institute) {
168
198
  return {
169
199
  key, project, institute,
@@ -187,6 +217,9 @@ export function apply(ctx) {
187
217
  lastProgressAt: now(),
188
218
  artifactCount: 0,
189
219
  diagnostics: [],
220
+ // The final-paper flow (stage machine + its artifacts state). Durable like everything
221
+ // else, so a restart resumes the same stage instead of writing a second paper.
222
+ paper: null,
190
223
  }
191
224
  }
192
225
  function initState() { return { v: PROJECTION_VERSION, institutes: {}, order: [] } }
@@ -226,6 +259,16 @@ export function apply(ctx) {
226
259
  if (patch.runId !== undefined) n.runId = String(patch.runId)
227
260
  if (patch.lastProgressAt !== undefined) n.lastProgressAt = Number(patch.lastProgressAt) || 0
228
261
  if (patch.artifactCount !== undefined) n.artifactCount = Number(patch.artifactCount) || 0
262
+ // The final-paper state. A FUNCTION is a MUTATION applied inside the fold (the same
263
+ // trick the verify queue uses): the paper stage, its parts and its office-consultation
264
+ // counters are updated from several places (member replies, office messages, meetings),
265
+ // and a whole-object read-modify-write outside the fold would lose updates.
266
+ if (patch.paper !== undefined) {
267
+ const next = typeof patch.paper === 'function'
268
+ ? patch.paper(n.paper === undefined ? null : n.paper)
269
+ : patch.paper
270
+ if (next !== undefined) n.paper = next
271
+ }
229
272
  return n
230
273
  })
231
274
  }
@@ -266,10 +309,16 @@ export function apply(ctx) {
266
309
  for (const id of ids) { if (!set.has(id)) { set.add(id); changed = true } }
267
310
  if (!changed) return inst
268
311
  // Compact: a message that has been delivered may leave `messages` too, so
269
- // the queue never grows without bound over a long run.
312
+ // the queue never grows without bound over a long run. `delivered` itself is
313
+ // bounded the same way `debates`/`meetings` are: it is an acknowledgement LEDGER,
314
+ // and every commit rewrites the whole snapshot, so an ever-growing array of every
315
+ // message id ever acked would make each write progressively more expensive
316
+ // (audit M7). Oldest entries are dropped first; `messages` holds only undelivered
317
+ // ids and is filtered by the same set, so dropping an id can never re-deliver.
270
318
  const delivered = Array.from(set)
319
+ const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered
271
320
  const messages = inst.messages.filter((m) => !set.has(m.id))
272
- return Object.assign({}, inst, { delivered, messages })
321
+ return Object.assign({}, inst, { delivered: capped, messages })
273
322
  })
274
323
  }
275
324
  if (t === EV.meeting) {
@@ -308,8 +357,21 @@ export function apply(ctx) {
308
357
  return Object.assign({}, inst, { formal, todo })
309
358
  })
310
359
  }
360
+ // `d.queue` is normally the new ARRAY, but a queue MUTATION (appending a proposal,
361
+ // taking the head to run) is passed as a FUNCTION instead. Applying it here, inside the
362
+ // fold, makes its read and its write one atomic step of the serial event chain: the old
363
+ // shape (read `inst().queue`, mutate the copy, `await putQueue(copy)`) lost one of two
364
+ // proposals made in the same tick, because both callers read the same array and each
365
+ // committed its own copy.
311
366
  if (t === EV.queue) {
312
- return withInstitute(state, key, (inst) => Object.assign({}, inst, { queue: Array.isArray(d.queue) ? d.queue : [] }))
367
+ return withInstitute(state, key, (inst) => {
368
+ const next = typeof d.queue === 'function' ? d.queue(inst.queue) : d.queue
369
+ const list = Array.isArray(next) ? next : []
370
+ // A mutation that changed nothing (a duplicate proposal) leaves the queue object
371
+ // identical: return the institute unchanged rather than minting a new revision.
372
+ if (list === inst.queue) return inst
373
+ return Object.assign({}, inst, { queue: list })
374
+ })
313
375
  }
314
376
  if (t === EV.counters) {
315
377
  return withInstitute(state, key, (inst) => Object.assign({}, inst, { counters: Object.assign({}, inst.counters, d.counters || {}) }))
@@ -335,28 +397,84 @@ export function apply(ctx) {
335
397
  // the whole snapshot under a per-file serialization chain; nothing is written to a host session log
336
398
  // (see the PERSISTENCE note at the top of this file for why the projection backend was removed).
337
399
  // `pathOf` is a FUNCTION, not a captured string: the state path depends on the
338
- // project/institute, which the first successful load syncs back into this session —
339
- // a captured path would keep writing to the pre-load guess forever.
400
+ // project/institute, which `configure` may change mid-session — a captured path would
401
+ // keep writing to the pre-load guess forever.
402
+ //
403
+ // The load latch is PER PATH, and a write is only allowed for a path whose load
404
+ // SUCCEEDED. Both are needed to make the state file safe (the 2.4.1 audit's H1):
405
+ // a boolean "we have loaded something" latch let the first tool call of a session
406
+ // (typically `vibe_v5_status`, against the DEFAULT institute) latch the backend to the
407
+ // default path, after which `vibe_v5_configure {institute:'alpha'}` never read alpha's
408
+ // file and wrote an EMPTY institute over it. Switching paths now re-reads the new path
409
+ // first, and if that read fails the backend refuses to write instead of clobbering.
340
410
  function makeFileBackend(readTextAbs, writeTextAbs, pathOf) {
341
411
  let mem = initState()
342
412
  let chain = Promise.resolve(true)
343
- let loaded = false
344
- return {
413
+ let loadedPath // the path whose file is currently folded into `mem`
414
+ let loadOk = false // did the read of `loadedPath` actually succeed?
415
+ let loadPromise = null // the in-flight load (dedupes concurrent callers)
416
+ let loadPendingPath = null
417
+ // Fold ONE path into `mem`. `readTextAbs` returning undefined means "no file yet", which
418
+ // is the only case that licences a later write; anything else (a throw, a parse error, a
419
+ // version mismatch) leaves `loadOk` false so commit() refuses to clobber it.
420
+ async function doLoad(p) {
421
+ let ok = false
422
+ try {
423
+ const raw = await readTextAbs(p)
424
+ if (raw === undefined || raw === null || raw === '') ok = true
425
+ else {
426
+ const parsed = JSON.parse(raw)
427
+ if (parsed && parsed.v === PROJECTION_VERSION) { mem = parsed; ok = true }
428
+ }
429
+ } catch (e) { /* a corrupt/unreadable file is ignored; it is not authoritative */ }
430
+ loadedPath = p
431
+ loadOk = ok
432
+ loadPromise = null
433
+ loadPendingPath = null
434
+ return mem
435
+ }
436
+ const backend = {
345
437
  kind: 'file',
346
438
  async load() {
347
- if (loaded) return mem
348
- loaded = true
349
- try {
350
- const raw = await readTextAbs(pathOf())
351
- if (raw) {
352
- const parsed = JSON.parse(raw)
353
- if (parsed && parsed.v === PROJECTION_VERSION) mem = parsed
354
- }
355
- } catch (e) { /* a corrupt mirror is ignored; it is not authoritative */ }
356
- return mem
439
+ const path = pathOf()
440
+ if (loadedPath === path && (loadOk || loadPromise === null)) return mem
441
+ // A different path (or a previously FAILED load of this one) is read again before it
442
+ // is ever written to. Switching paths starts from the empty initial state: `mem`
443
+ // holds only the fold of the file that is on disk for the CURRENT path.
444
+ if (loadedPath !== path) { mem = initState(); loadPromise = null }
445
+ if (loadPromise && loadPendingPath === path) return await loadPromise
446
+ loadPendingPath = path
447
+ loadPromise = doLoad(path)
448
+ await loadPromise
449
+ if (loadedPath === pathOf()) return mem
450
+ // The path moved again while we were reading: hand back the state for the path
451
+ // that is current NOW rather than one for a directory we are no longer using.
452
+ return await backend.load()
357
453
  },
358
454
  read() { return mem },
455
+ loadedFor() { return { path: loadedPath, ok: loadOk } },
456
+ // Read ONE specific path (used by `configure`, which must know whether the institute
457
+ // it is being pointed at already exists on disk) and report the state belonging to
458
+ // `useKey`. The path latch deliberately stays on the file that was actually read.
459
+ async loadAt(path, useKey) {
460
+ loadedPath = path
461
+ loadOk = false
462
+ loadPromise = null
463
+ loadPendingPath = null
464
+ mem = initState()
465
+ await doLoad(path)
466
+ return { state: mem, institute: useKey === undefined ? undefined : mem.institutes[String(useKey)] }
467
+ },
359
468
  async commit(type, data) {
469
+ // NEVER write a snapshot whose file was not successfully read first: an unread file
470
+ // is indistinguishable from "the read never ran", and whole-snapshot writes make
471
+ // that failure mode destructive.
472
+ const path = pathOf()
473
+ if (loadedPath !== path) await backend.load()
474
+ if (loadedPath !== path || !loadOk) {
475
+ throw v5err('V5_STATE_NOT_LOADED',
476
+ 'refusing to overwrite ' + path + ' : its state file was never read successfully (a failed or unreadable load must not be clobbered)')
477
+ }
360
478
  mem = applyV5Event(mem, { type, data })
361
479
  const snapshot = mem
362
480
  // Serialize writes per file and defer JSON.stringify to execution time, so a
@@ -369,6 +487,7 @@ export function apply(ctx) {
369
487
  return mem
370
488
  },
371
489
  }
490
+ return backend
372
491
  }
373
492
 
374
493
  // ---- session registry --------------------------------------------------
@@ -432,6 +551,22 @@ export function apply(ctx) {
432
551
  leanCommand: 'lean',
433
552
  leanArgs: [],
434
553
  leanTimeoutMs: 120000,
554
+ // ── final paper (docs/final-paper.md; the phase runs BEFORE the completion flags) ──
555
+ // finalPaper — write the final paper when the run concludes (manual /v5 paper
556
+ // still works when this is false, and says so).
557
+ // paperFormat — 'both' | 'md' | 'tex'
558
+ // paperLanguage — 'zh' | 'en'
559
+ // paperCompilePdf — compile a PDF when a LaTeX engine is detected
560
+ // paperEditor — 'academician' (default: the only editor reachable unattended) |
561
+ // 'office' (manual /v5 paper only: the office must consult the
562
+ // institute first — messages + at least one meeting)
563
+ // paperLatexCommand — force ONE engine command instead of auto-detection ('' = auto)
564
+ finalPaper: true,
565
+ paperFormat: 'both',
566
+ paperLanguage: 'zh',
567
+ paperCompilePdf: true,
568
+ paperEditor: 'academician',
569
+ paperLatexCommand: '',
435
570
  // ── model / tools ────────────────────────────────────────────────────
436
571
  provider: '',
437
572
  model: '',
@@ -496,28 +631,53 @@ export function apply(ctx) {
496
631
  async function writeTextAbs(p, content) {
497
632
  try {
498
633
  const t = await fsTargetAbs(p)
499
- await fs.writeText(t, content, undefined, undefined, getPolicy())
500
- return true
501
- } catch (e) { return false }
634
+ // `fs.writeText` RESOLVES with an FsWriteOutcome ({operation, version, before, after})
635
+ // and REJECTS on a real failure, so the old `return true` here meant only "the call did
636
+ // not throw" and discarded the outcome (audit L4). Return the outcome itself: `undefined`
637
+ // now means "not written", an object means "written, and here is what the fs reported".
638
+ // A stub/mock fs that resolves with nothing is reported as an unknown-but-successful
639
+ // write, because the real host never resolves without an outcome.
640
+ const outcome = await fs.writeText(t, content, undefined, undefined, getPolicy())
641
+ return (outcome === undefined || outcome === null) ? { operation: 'unknown' } : outcome
642
+ } catch (e) { return undefined }
502
643
  }
503
644
  async function readTextRel(rel) { return await readTextAbs(instRoot() + '/' + rel) }
504
- async function writeTextRel(rel, content) { return await writeTextAbs(instRoot() + '/' + rel, content) }
645
+ async function writeTextRel(rel, content) { return (await writeTextAbs(instRoot() + '/' + rel, content)) !== undefined }
505
646
 
506
647
  function installBackend() {
507
648
  backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
508
649
  return backend
509
650
  }
651
+ // The ONE call site that awaits the backend's load (the sensitivity probe for
652
+ // "the file fallback must LOAD persisted state before any read" keys on it, and a
653
+ // duplicated copy of it would make that probe ambiguous).
654
+ async function awaitBackendLoad() {
655
+ if (backend.kind === 'file' && typeof backend.load === 'function') await backend.load()
656
+ return true
657
+ }
510
658
  // Every entry point that READS state must await this first. Without it the file
511
659
  // backend's `mem` is still the empty initial state, so a fresh process would report
512
660
  // an empty roster and `resume` would refuse with "no active member to resume" —
513
661
  // i.e. the fallback would silently lose the whole institute across a restart.
514
662
  async function ready() {
515
663
  if (!backend) installBackend()
516
- if (backend.kind === 'file' && typeof backend.load === 'function') await backend.load()
517
- return true
664
+ return await awaitBackendLoad()
665
+ }
666
+ // Load the CURRENT state path before its first read. `state()` is synchronous by design
667
+ // (the whole fold is read through it), so it kicks the load off eagerly; the caller that
668
+ // matters — `commit()` — always awaits it before the snapshot may be written.
669
+ function ensureLoaded() {
670
+ if (!backend) installBackend()
671
+ if (backend.kind !== 'file' || typeof backend.load !== 'function') return
672
+ const p = backend.load()
673
+ if (p && typeof p.then === 'function') p.catch(() => {})
518
674
  }
519
675
  function state() {
520
676
  if (!backend) installBackend()
677
+ // A read must never be served from the empty initial state while the path's file has
678
+ // not been folded in yet (the 2.4.1 audit's H1: a read-less `state()` was half of the
679
+ // overwrite). `ready()`/`commit()` await the same load; here it only has to START.
680
+ ensureLoaded()
521
681
  stateCache = backend.read()
522
682
  return stateCache
523
683
  }
@@ -525,10 +685,46 @@ export function apply(ctx) {
525
685
  const s = state()
526
686
  return s.institutes[key] || emptyInstitute(key, project, instituteName)
527
687
  }
688
+ // The institute stored in ANOTHER (project, institute) state file, or undefined. Used by
689
+ // `configure` before it switches identity: the requested institute may already exist on
690
+ // disk, and adopting it is the only alternative to overwriting it (audit H1).
691
+ async function instituteAt(np, ni, nkey) {
692
+ if (!backend) installBackend()
693
+ const path = (vibeRoot() + '/Projects/' + np + '/Institutes/' + ni + '/State/' + ni + '.v5state.json')
694
+ const r = await backend.loadAt(path, nkey)
695
+ return r.institute
696
+ }
697
+ // A FAILED load of an EXISTING file must never be silent: it is the one state in which
698
+ // the backend refuses to write (audit H1). The note is buffered here and folded into the
699
+ // state on the next commit that IS allowed, so it can never recurse into a commit.
700
+ let pendingLoadNotes = []
701
+ function noteLoadProblem(text) {
702
+ const t = String(text || '').slice(0, 300)
703
+ if (t && pendingLoadNotes.indexOf(t) === -1) pendingLoadNotes.push(t)
704
+ if (pendingLoadNotes.length > 5) pendingLoadNotes = pendingLoadNotes.slice(-5)
705
+ }
706
+ function drainLoadNotes() {
707
+ if (!pendingLoadNotes.length || !stateCache) return
708
+ const list = (stateCache.diagnostics || []).concat(pendingLoadNotes.map((t) => ({ at: now(), type: 'vibe5/load', error: t })))
709
+ pendingLoadNotes = []
710
+ stateCache = Object.assign({}, stateCache, { diagnostics: list.slice(-50) })
711
+ }
528
712
  async function commit(type, data) {
529
713
  if (!backend) installBackend()
530
- if (backend.kind === 'file' && backend.load) await backend.load()
531
- stateCache = await backend.commit(type, Object.assign({ version: PROJECTION_VERSION, key }, data))
714
+ // Explicit call (the audit's H3): `await backend.load` awaited the METHOD OBJECT, so
715
+ // the only load was the one the backend happened to do inside commit.
716
+ await awaitBackendLoad()
717
+ const loaded = backend.kind === 'file' && typeof backend.loadedFor === 'function' ? backend.loadedFor() : { ok: true }
718
+ try {
719
+ stateCache = await backend.commit(type, Object.assign({ version: PROJECTION_VERSION, key }, data))
720
+ } catch (e) {
721
+ // The write was refused because the state file could not be read. Say so loudly
722
+ // instead of clobbering it, and keep the note for the next write that is allowed.
723
+ noteLoadProblem('write REFUSED (a failed state-file load must not be clobbered): ' + String((e && e.message) || e))
724
+ throw e
725
+ }
726
+ if (!loaded.ok) noteLoadProblem('the state file ' + String(loaded.path || '?') + ' could not be read (corrupt, unreadable or version-mismatched); it will never be overwritten')
727
+ drainLoadNotes()
532
728
  const cur = stateCache.institutes[key]
533
729
  if (cur) {
534
730
  phase = cur.phase || phase
@@ -547,7 +743,9 @@ export function apply(ctx) {
547
743
  const putMeeting = (index) => commit(EV.meeting, { index })
548
744
  const putDebate = (index) => commit(EV.debate, { index })
549
745
  const putVerdict = (target, record) => commit(EV.verdict, { target, record })
550
- const putQueue = (queue) => commit(EV.queue, { queue })
746
+ // The verify queue has NO whole-array setter any more: both of its mutations go through
747
+ // `appendToQueue`/`takeQueueHead` below, which hand a FUNCTION to the event fold so the
748
+ // read-modify-write is atomic (the lost-proposal race).
551
749
  const putCounters = (counters) => commit(EV.counters, { counters })
552
750
  const markProgress = async () => {
553
751
  lastProgressAt = now()
@@ -572,6 +770,15 @@ export function apply(ctx) {
572
770
  const a = activeMembers().find((m) => m.kind === 'academician')
573
771
  return a ? a.id : ''
574
772
  }
773
+ // `academicianId()` resolved against a roster that also counts a member which is NOT yet
774
+ // committed (the joiner whose charter is being built). Kept separate so the canonical
775
+ // resolver stays the live-roster one — the sensitivity probe for the leaderless charter
776
+ // mutates the `academicianId()` CALL, and that mutation must keep reaching the text.
777
+ function academicianIdWith(extraMember) {
778
+ if (!extraMember) return academicianId()
779
+ const a = activeMembers().concat([extraMember]).find((m) => m.kind === 'academician')
780
+ return a ? a.id : ''
781
+ }
575
782
 
576
783
  // ---- the institute charter (public regulations) -----------------------
577
784
  // Written into every member's `persona` at hire time. `persona` is part of the
@@ -579,8 +786,12 @@ export function apply(ctx) {
579
786
  // context compaction WITHOUT being re-injected into prompts — which is what
580
787
  // structurally removes v4's "re-anchor the rules after compaction" patch and
581
788
  // the "[核心规则重申]+[CONTEXT COMPACT] every round" leak it caused (§24.1-③).
582
- function rosterLine() {
583
- const ms = activeMembers()
789
+ function rosterLine(extraMember) {
790
+ // Written ONCE, at the member's creation (`newMember`), so the hire-time snapshot is
791
+ // frozen even for a member the host's live-child cap refuses and `resume` re-spawns
792
+ // later (audit M5). The joiner is folded in here so its own charter shows the institute
793
+ // WITH itself on the roster — exactly what the original build-at-spawn code produced.
794
+ const ms = extraMember ? activeMembers().concat([extraMember]) : activeMembers()
584
795
  const acad = ms.filter((m) => m.kind === 'academician').map((m) => m.id)
585
796
  const res = ms.filter((m) => m.kind === 'researcher').map((m) => m.id)
586
797
  const tmp = ms.filter((m) => m.kind === 'temp').map((m) => m.id + '(' + (m.hiredBy || '?') + '雇)')
@@ -694,7 +905,8 @@ export function apply(ctx) {
694
905
  // The leader's REAL id (or '' when the office founded a leaderless institute).
695
906
  // Charter text must never name a leader who is not on staff: a member told to
696
907
  // "report to the academician" when there is none has no one to report to.
697
- const acadId = academicianId()
908
+ // `member` itself is counted as on-staff even before it is committed (see rosterLine).
909
+ const acadId = academicianIdWith(member)
698
910
  const L = []
699
911
  // ── opening ──────────────────────────────────────────────────────────
700
912
  if (kind === 'academician') {
@@ -761,7 +973,7 @@ export function apply(ctx) {
761
973
  L.push(' · **所办(对外接口)** —— 不参与研究、不投票。代表本所与外部沟通并转达外部指令。')
762
974
  }
763
975
  L.push(' 你入职时的在册编制(这是一份**快照**,此后可能变化):')
764
- L.push(rosterLine())
976
+ L.push(rosterLine(member))
765
977
  L.push(' (权威的在册名单与法定票数 m 以每轮提示里的状态块为准;编制可能变化。)')
766
978
  L.push('')
767
979
  // ── 二、general rules ────────────────────────────────────────────────
@@ -1022,6 +1234,12 @@ export function apply(ctx) {
1022
1234
  return { ok: o.exitCode === 0, exitCode: o.exitCode }
1023
1235
  } catch (e) { return { ok: false, error: String((e && e.message) || e) } }
1024
1236
  }
1237
+ // Directory creation must go through the INJECTED `fs` service, not the platform shell:
1238
+ // the shell path passes no policy, so a deployment that confines writes through
1239
+ // `fs` (read-only or narrowed workspace) was not confining this one (audit M8). The
1240
+ // local/sandboxed fs backend creates missing parent directories on write
1241
+ // (`writeFileAtomic` → `mkdir(dirname, {recursive:true})`), so writing a marker file per
1242
+ // directory is what actually creates the tree — inside the policy.
1025
1243
  async function mkdirs() {
1026
1244
  const base = instRoot()
1027
1245
  const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems', 'Formal', 'Verified/Lean']
@@ -1033,10 +1251,26 @@ export function apply(ctx) {
1033
1251
  for (const d of ['Progress', 'Propos', 'Methods', 'Subproblems']) dirs.push('Members/' + m.id + '/' + d)
1034
1252
  }
1035
1253
  const paths = dirs.map((d) => base + '/' + d).concat(globalDirs.map((d) => vibeRoot() + '/' + d))
1254
+ let fsCreated = 0
1255
+ let fsFailed = false
1256
+ try {
1257
+ if (fs && typeof fs.writeText === 'function' && typeof fs.resolve === 'function') {
1258
+ for (const p of paths) {
1259
+ const t = await fsTargetAbs(p + '/.keep')
1260
+ await fs.writeText(t, '', undefined, undefined, getPolicy())
1261
+ fsCreated += 1
1262
+ }
1263
+ } else fsFailed = true
1264
+ } catch (e) { fsFailed = true }
1265
+ if (!fsFailed && fsCreated === paths.length) return { ok: true, dirs: fsCreated, via: 'fs' }
1266
+ // Guarded fallback for a host whose fs backend refuses (or offers no writeText): the
1267
+ // platform shell still creates the tree, exactly as before this change. It is only
1268
+ // reached when the policy-compliant route failed, and it reports which route ran.
1036
1269
  const script = isWindows()
1037
1270
  ? 'New-Item -Force -ItemType Directory -Path ' + paths.map((p) => psQuote(p)).join(',') + ' | Out-Null'
1038
1271
  : 'mkdir -p ' + paths.map((p) => shQuote(p)).join(' ')
1039
- return await runShell(script)
1272
+ const r = await runShell(script)
1273
+ return Object.assign({ via: 'shell' }, r)
1040
1274
  }
1041
1275
 
1042
1276
  // ---- communication (durable per-recipient mailbox) --------------------
@@ -1071,6 +1305,10 @@ export function apply(ctx) {
1071
1305
  }
1072
1306
  counters.message = n
1073
1307
  await putCounters(counters)
1308
+ // The office talking to the institute is HALF of the `paperEditor='office'` consultation
1309
+ // requirement; counting it here (the one place a message really is queued) means the gate
1310
+ // cannot be satisfied by merely intending to consult (docs/final-paper.md §7).
1311
+ if (from === 'office') await notePaperConsult('message')
1074
1312
  notifyActivity()
1075
1313
  // Kick one scheduling pass so an ADDRESSED message (dm/office/assign) wakes its
1076
1314
  // recipient promptly instead of waiting for the digest window. Plain chat stays
@@ -1164,7 +1402,12 @@ export function apply(ctx) {
1164
1402
  hiredBy: (opts && opts.hiredBy) || '',
1165
1403
  term: (opts && opts.term) || '',
1166
1404
  provider: String((opts && opts.provider) || 'spawn'),
1167
- persona: '', // filled at spawn; kept for the durable-seal record
1405
+ // The charter is FROZEN at hire time — it says "你入职时的在册编制(这是一份**快照**)".
1406
+ // It therefore has to be CAPTURED here, not at the first successful spawn: a member
1407
+ // the host's live-child cap refused is recorded `failed` and only spawned later by
1408
+ // `resume`, and rebuilding the charter then would describe the resume-time roster
1409
+ // (`memberPersona(member)` would also silently drop any `staffPersona` change).
1410
+ persona: memberPersona({ id, kind, direction: String((opts && opts.direction) || ''), hiredBy: (opts && opts.hiredBy) || '' }),
1168
1411
  error: '',
1169
1412
  createdAt: now(),
1170
1413
  dismissedAt: 0,
@@ -1268,7 +1511,8 @@ export function apply(ctx) {
1268
1511
  roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
1269
1512
  // The charter is FROZEN at hire time (it is the durable "seal" record and it says
1270
1513
  // "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
1271
- // hire-time snapshot into a resume-time one and make the sentence untrue.
1514
+ // hire-time snapshot into a resume-time one and make the sentence untrue. `newMember`
1515
+ // captures it, so every member has one by the time it can be spawned.
1272
1516
  const persona = member.persona || memberPersona(member)
1273
1517
  member.persona = persona
1274
1518
  const prompt = initialPrompt(member, initialTask, mode)
@@ -1394,8 +1638,7 @@ export function apply(ctx) {
1394
1638
  // 'office' rather than '' so the framing records a real, non-member sender.
1395
1639
  try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }
1396
1640
  // An unrelated child agent: report no member. Member-only writes refuse with
1397
- // V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary), and the
1398
- // office-capable tools treat '' as the office.
1641
+ // V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary).
1399
1642
  return ''
1400
1643
  }
1401
1644
  // No session id at all (a synthetic exec context). Only here may we fall back to
@@ -1403,6 +1646,22 @@ export function apply(ctx) {
1403
1646
  const c = currentMember
1404
1647
  return (c && memberById(c)) ? c : ''
1405
1648
  }
1649
+ // Is this caller PROVABLY the office — the session root itself? `memberIdOfAgent` answers
1650
+ // 'office' both for the root AND for any descendant of the root that is not a member child
1651
+ // (a dismissed member's stale child, a nested helper agent), so it cannot tell them apart;
1652
+ // only the direct root test can (audit L6). `isOffice(id)` no longer treats '' as the office
1653
+ // either, so the office-capable handlers resolve their caller HERE and refuse on ''.
1654
+ function isProvablyOffice(agent) {
1655
+ try { return !!agent && sessionIdOf(agent) !== undefined && rootOf(agent) === agent } catch (e) { return false }
1656
+ }
1657
+ // The caller of an OFFICE-ONLY tool: a real member id, or 'office' when the caller is
1658
+ // provably the session root, or '' when nobody can be identified. The tool handler must
1659
+ // refuse on '' (V5_MEMBER_NOT_FOUND) instead of impersonating the office (audit L6).
1660
+ function officeCaller(agent) {
1661
+ const id = memberIdOfAgent(agent)
1662
+ if (id && id !== 'office') return id
1663
+ return isProvablyOffice(agent) ? 'office' : ''
1664
+ }
1406
1665
 
1407
1666
  // ---- prompts ----------------------------------------------------------
1408
1667
  // The charter lives in `persona` (permanent). Every ROUND prompt therefore
@@ -1438,9 +1697,9 @@ export function apply(ctx) {
1438
1697
  L.push(' "task_update": {"task_id":"t-3","expected_revision":2,"action":"complete|release|reopen|edit|set_dependencies|delete"},')
1439
1698
  L.push(' "input": "本轮会议/辩论的发言正文(会议轮用;也可直接用 say)",')
1440
1699
  if (formalOn()) {
1441
- L.push(' "formal": {"target":"p-x","decision":"used|blocked|defect","file":"Formal/p-x.lean","note":"难度判断/阻塞原因"}')
1700
+ L.push(' "formal": {"target":"p-x","decision":"used|blocked|defect","file":"Formal/p-x.lean","note":"难度判断(used)/ 阻塞原因(blocked)/ 具体偏差(defect)"}')
1442
1701
  L.push(' ← Lean 形式化:' + (formalMode() === 'require'
1443
- ? '**强制**:定论前必须有「Lean 已通过」或显式阻塞原因(note 必填),否则本轮裁定记为未定论,'
1702
+ ? '**强制**:定论前必须有「Lean 已通过」或显式的阻塞记录(decision=\'blocked\' + note;used 的 note 不算),否则本轮裁定记为未定论,'
1444
1703
  : '**鼓励**:按实现难度自行决定;做了就归档,没做就写明难度判断,') + '详见提示词里的【Lean 形式化验证】段')
1445
1704
  }
1446
1705
  L.push(' "reject_assign": {"task_id":"t-3","why":"你对这项分派的异议理由"} ← 有异议时填;理由会被广播给')
@@ -1595,7 +1854,7 @@ export function apply(ctx) {
1595
1854
  // The formal field belongs in the VOTING contract too: voters are exactly the agents
1596
1855
  // who must either formalize the object or record why they judged it infeasible.
1597
1856
  L.push('若你本轮做了形式化或给出难度判断,请一并加上:')
1598
- L.push('{"formal":{"target":"' + vs.target + '","decision":"used|blocked|defect","file":"Formal/' + vs.target + '.lean","note":"难度判断/阻塞原因"}}')
1857
+ L.push('{"formal":{"target":"' + vs.target + '","decision":"used|blocked|defect","file":"Formal/' + vs.target + '.lean","note":"难度判断(used)/ 阻塞原因(blocked)/ 具体偏差(defect)"}}')
1599
1858
  }
1600
1859
  return L.join('\n')
1601
1860
  }
@@ -1834,24 +2093,33 @@ export function apply(ctx) {
1834
2093
  updatedAt: now(),
1835
2094
  }))
1836
2095
  }
1837
- // Withdrawing an archived proof needs a DELETE, but the fs service exposes no unlink and
1838
- // subprocess is optional. Use the shell when it is available; otherwise overwrite the
1839
- // file with an explicit withdrawal notice, so it can never be read as the object's proof.
2096
+ // Withdrawing an archived proof means the file must stop looking like the object's proof.
2097
+ // The POLICY-COMPLIANT route is an overwrite through the injected `fs` service (it carries
2098
+ // `getPolicy()`); the platform shell is only a fallback, because `subprocess.spawn` has no
2099
+ // policy slot and the shell path therefore sat outside a confining deployment's fs policy
2100
+ // (audit M8). A stub host whose shell exits 0 without deleting anything is also handled by
2101
+ // the read-back check.
1840
2102
  async function removeArchivedProof(rel) {
1841
2103
  const abs = leanAbsPath(rel)
1842
2104
  if (abs === null) return false
2105
+ const withdrawn = '-- 已撤回(' + fmtTime() + '):该形式化被认定与命题原文不一致。\n'
2106
+ + '-- 原代码保留在工作文件 Formal/' + String(rel).split('/').pop() + ';修正并重新跑通后重新归档。\n'
2107
+ // 1) the fs service (policy-confined) — an overwritten file is no longer a proof.
2108
+ if ((await writeTextAbs(abs, withdrawn)) !== undefined) {
2109
+ const nowText = await readTextAbs(abs)
2110
+ if (nowText !== undefined && nowText.indexOf('已撤回') !== -1) return true
2111
+ }
2112
+ // 2) guarded fallback: a real DELETE through the platform shell, when it exists.
1843
2113
  const sub = subprocessOf()
1844
2114
  if (sub !== undefined && typeof sub.spawn === 'function') {
1845
2115
  const script = isWindows()
1846
2116
  ? 'Remove-Item -LiteralPath ' + psQuote(abs) + ' -Force -ErrorAction SilentlyContinue'
1847
2117
  : 'rm -f ' + shQuote(abs)
1848
2118
  try { await runShell(script) } catch (e) { /* fall through to the overwrite */ }
1849
- // A shell that exits 0 without removing anything (a stub host, a permissions quirk)
1850
- // must not leave a withdrawn proof where everyone looks for proofs: verify, fall back.
1851
2119
  if (await readTextAbs(abs) === undefined) return true
1852
2120
  }
1853
- return (await writeTextAbs(abs, '-- 已撤回(' + fmtTime() + '):该形式化被认定与命题原文不一致。\n'
1854
- + '-- 原代码保留在工作文件 Formal/' + String(rel).split('/').pop() + ';修正并重新跑通后重新归档。\n')) !== false
2121
+ // 3) last resort: the overwrite again, in case the delete recreated/left the file.
2122
+ return (await writeTextAbs(abs, withdrawn)) !== undefined
1855
2123
  }
1856
2124
  // A formalization that says something else than the proposition is NOT a refutation:
1857
2125
  // withdraw the proof instead of letting the group conclude 假 (contract §4.1).
@@ -1876,9 +2144,12 @@ export function apply(ctx) {
1876
2144
  // overwrite also fails. The record is downgraded either way, so say it out loud:
1877
2145
  // otherwise the stale file stays at the exact path everyone looks for proofs, and
1878
2146
  // nothing in any prompt or index would disclose that the withdrawal was incomplete.
2147
+ // `removed` means the archive no longer reads as a proof — either deleted, or
2148
+ // OVERWRITTEN with the withdrawal notice (the fs-service route). Name both honestly:
2149
+ // "deleted" would be false whenever the policy-confined overwrite was the route.
1879
2150
  const stillThere = removed ? [] : ['|⚠ 归档证明 ', proofRel, ' 未能撤回(宿主删除与覆盖均失败);记录已降级,请不要把它当作该对象的证明。']
1880
2151
  await saveChatLine('【形式化】' + (memberId || '成员') + ' 认定 ' + t + ' 的形式化**不忠实**:' + why
1881
- + ' —— 已撤回「已通过」状态' + (removed ? '并从 Verified/Lean/ 删除归档证明' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
2152
+ + ' —— 已撤回「已通过」状态' + (removed ? ',并已使 Verified/Lean/ 中的归档证明失效(删除或覆盖为撤回声明)' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
1882
2153
  return { ok: true, target: t, status: 'attempted', removed }
1883
2154
  }
1884
2155
  function formalPromptBlock(target) {
@@ -1920,14 +2191,16 @@ export function apply(ctx) {
1920
2191
  L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义 / 对象 / 条件 / 假设 / 结论是否与')
1921
2192
  L.push(' 命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
1922
2193
  if (mode === 'require') {
1923
- L.push(' · **本模式要求**:必须产出 Lean 形式化,或**必须**给出显式的阻塞原因(vibe_v5_lean_archive')
1924
- L.push(' kind=\'blocked\' note=… 或回执 formal.note)。若两者都没有,本次裁定不会生效,')
2194
+ L.push(' · **本模式要求**:必须产出 Lean 形式化,或**必须**给出显式的阻塞原因——用')
2195
+ L.push(" vibe_v5_lean_archive kind='blocked' note=… 记录,或用回执 formal:{decision:'blocked', note:…}。")
2196
+ L.push(' 只有 decision=\'blocked\' 的 note 会写成阻塞记录;decision=\'used\' 的 note 只是难度判断,')
2197
+ L.push(' **不会**打开定论门禁。两者都没有时,本次裁定不会生效,')
1925
2198
  L.push(' 会被记为未定论(原因 formal-required)并进入「形式化待办」。')
1926
2199
  } else {
1927
2200
  L.push(" · 若你判断不值得或无法形式化,可以不做,但请在回执的 formal 字段写明难度判断(decision='blocked' 时必须写明 note)。")
1928
2201
  }
1929
2202
  L.push(' · 归档可复用定义/引理前先跑通(vibe_v5_lean_archive run=true 或先 vibe_v5_lean_run);跑不通不要入库。')
1930
- L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"——这两种都算显式阻塞原因,定论门禁可以据此放行。')
2203
+ L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并把"宿主无 Lean 工具链"写成**阻塞记录**(vibe_v5_lean_archive kind=\'blocked\' note=… 或回执 formal:{decision:\'blocked\', note:…})——这算显式阻塞原因,定论门禁可以据此放行。')
1931
2204
  }
1932
2205
  return L.join('\n')
1933
2206
  }
@@ -2023,8 +2296,8 @@ export function apply(ctx) {
2023
2296
  await writeFormalIndex()
2024
2297
  }
2025
2298
  if (run.ok) {
2026
- await saveChatLine('【形式化】' + (memberId || 'office') + ' 运行 Lean 通过:' + run.file
2027
- + '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target + ' 记为已尝试/已通过' : ''))
2299
+ await saveChatLine('【形式化】' + memberId + ' 运行 Lean 通过:' + run.file
2300
+ + '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target + ' 记为已尝试(若此前已通过/已阻塞则保留原状态)' : ''))
2028
2301
  }
2029
2302
  return Object.assign({ ok: !!run.ok }, run, {
2030
2303
  hint: run.ok
@@ -2115,7 +2388,7 @@ export function apply(ctx) {
2115
2388
  // (b) in the same wake as a soft-compact directive — never on every round. The
2116
2389
  // charter itself lives in `persona` and needs no reinforcement otherwise.
2117
2390
  const CORE_RULES = '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
2118
- '任何对象要进 Verified/ 必须 ≥m 名有表决权者一致给出 1 或 0,否则留库附平均概率;' +
2391
+ '任何对象要进 Verified/,必须至少有 m 名有表决权者投出布尔值(恰好 1 或恰好 0)**且没有任何一张反向票**,否则留库附平均概率;' +
2119
2392
  '你只写自己的库(Members/<你>/),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
2120
2393
  '退出时只输出一个 JSON 对象。'
2121
2394
 
@@ -2135,12 +2408,24 @@ export function apply(ctx) {
2135
2408
  }
2136
2409
 
2137
2410
  // ---- artifact libraries (per member, append/write by the member itself) ----
2138
- const isOffice = (id) => !id || id === 'office'
2411
+ // ONLY the literal 'office' is the office. An EMPTY id means "nobody identifiable" and is
2412
+ // NOT the office: `memberIdOfAgent` answers '' for an unrelated descendant of the root (a
2413
+ // dismissed member's stale child, a nested helper under the office root) and for a synthetic
2414
+ // context with no known member, so `!id || id === 'office'` let such a caller pass EVERY
2415
+ // office gate and sign its writes as the office (audit L6 follow-up). Office-capable tool
2416
+ // handlers now resolve their caller with `officeCaller` and refuse on '' before reaching
2417
+ // here; this predicate fails closed as the second line of defence.
2418
+ const isOffice = (id) => id === 'office'
2139
2419
  const isAcademician = (id) => { const m = memberById(id); return !!m && m.kind === 'academician' }
2140
- function bumpArtifacts() {
2420
+ // MUST be awaited by its callers: `inst()` reads `stateCache`, which `commit` only updates
2421
+ // once its own await resolves. Fire-and-forget here meant two cards written in one reply
2422
+ // (a single reply can carry `record: [ … ]`) both read the same `artifactCount` and
2423
+ // committed the same `n`, and the `meetingKeepEvery` auto-sync could fire twice on one
2424
+ // count (audit M6).
2425
+ async function bumpArtifacts() {
2141
2426
  const inst0 = inst()
2142
2427
  const n = (Number(inst0.artifactCount) || 0) + 1
2143
- commit(EV.progress, { at: now(), artifactCount: n }).catch(() => {})
2428
+ await commit(EV.progress, { at: now(), artifactCount: n })
2144
2429
  // Auto-sync meeting every `meetingKeepEvery` artifacts: the framework only
2145
2430
  // CONVENES it, never assigns work. Deferred while a meeting or verification is
2146
2431
  // already in progress so consensus is never preempted (v4 §26).
@@ -2181,7 +2466,7 @@ export function apply(ctx) {
2181
2466
  '- ID: ' + id,
2182
2467
  '- 类型: ' + (kind === 'proposition' ? '命题' : kind === 'method' ? String(args.type || '方法') : '子问题'),
2183
2468
  '- 状态: ' + (kind === 'proposition' ? '未定论' : kind === 'method' ? '经验' : '求解中'),
2184
- kind === 'proposition' ? '- 概率: ' + clamp01(args.p).toFixed(2) : '- 概率: ' + clamp01(args.p).toFixed(2),
2469
+ '- 概率: ' + clamp01(args.p).toFixed(2),
2185
2470
  '- 价值程度: ' + clamp01(args.value).toFixed(2),
2186
2471
  '- 动机用途计划: ' + String(args.motive),
2187
2472
  '- 记录者: ' + memberId,
@@ -2199,7 +2484,7 @@ export function apply(ctx) {
2199
2484
  }
2200
2485
  const ok = await writeTextRel(rel, head.concat(body).join('\n'))
2201
2486
  if (!ok) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
2202
- bumpArtifacts()
2487
+ await bumpArtifacts()
2203
2488
  notifyActivity()
2204
2489
  return { ok: true, id, file: rel, kind }
2205
2490
  }
@@ -2324,6 +2609,10 @@ export function apply(ctx) {
2324
2609
  return taskView(t)
2325
2610
  }
2326
2611
  async function taskUpdate(memberId, o) {
2612
+ // An unknown caller must be refused HERE as well: `owner = task.ownerId === memberId` is true
2613
+ // for an UNOWNED task (ownerId '') when memberId is '', so without this guard an
2614
+ // unidentifiable caller would count as the owner of every unclaimed task (audit L6 follow-up).
2615
+ if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
2327
2616
  const args = o || {}
2328
2617
  const id = String(args.task_id || args.taskId || '')
2329
2618
  const task = inst().tasks.find((x) => x.id === id)
@@ -2420,6 +2709,7 @@ export function apply(ctx) {
2420
2709
  // it can never make any statement true, and the assignee may object with reasons
2421
2710
  // (the objection is broadcast, not silently swallowed).
2422
2711
  async function taskAssign(memberId, o) {
2712
+ if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
2423
2713
  if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
2424
2714
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks' }
2425
2715
  }
@@ -2463,6 +2753,7 @@ export function apply(ctx) {
2463
2753
  return { ok: true, task: taskView(withMeta) }
2464
2754
  }
2465
2755
  async function taskPrioritize(memberId, o) {
2756
+ if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
2466
2757
  if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
2467
2758
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities' }
2468
2759
  }
@@ -2541,7 +2832,6 @@ export function apply(ctx) {
2541
2832
  }
2542
2833
 
2543
2834
  // ---- consensus verification (m-vote boolean) --------------------------
2544
- function verifyRecords() { return Object.keys(inst().verdicts) }
2545
2835
  function currentVerify() {
2546
2836
  const vs = inst().verdicts
2547
2837
  for (const k of Object.keys(vs)) { if (vs[k] && !vs[k].closed) return vs[k] }
@@ -2622,6 +2912,9 @@ export function apply(ctx) {
2622
2912
  // in the mean. Any opposing assertion BLOCKS the verdict, so a minority can never
2623
2913
  // be out-voted by abstention.
2624
2914
  function judgeVerdict(vs) {
2915
+ // E = the LIVE voter set (roster, recomputed on every call). A ballot cast by someone
2916
+ // who is no longer in E — a dismissed member whose entry survived in `vs.votes` — is
2917
+ // NOT counted, in either mode: `Verified/` may only ever be reached by current voters.
2625
2918
  const E = voters().map((m) => m.id)
2626
2919
  const P = E.length
2627
2920
  const m = quorumM()
@@ -2656,6 +2949,35 @@ export function apply(ctx) {
2656
2949
  if (bFalse >= m && bTrue === 0) return Object.assign(base, { outcome: 'false', reason: bFalse + ' >= m=' + m + ', all assert false' })
2657
2950
  return Object.assign(base, { outcome: 'undecided', reason: 'quorum not met' })
2658
2951
  }
2952
+ // ONE atomic append to the durable verify queue. The duplicate check and the append both run
2953
+ // INSIDE the event fold, so two proposals made in the same tick can no longer both read the
2954
+ // same array and overwrite each other's entry (the audit's lost-proposal race: a
2955
+ // `Promise.all` of two `vibe_v5_propose_verify` calls kept only the second, while both calls
2956
+ // reported `ok:true` and the first target existed in neither the queue nor `verdicts`).
2957
+ async function appendToQueue(entry) {
2958
+ let result = { appended: false, length: 0 }
2959
+ await commit(EV.queue, { queue: (cur) => {
2960
+ const list = Array.isArray(cur) ? cur : []
2961
+ if (list.some((p) => p && p.target === entry.target)) { result = { appended: false, length: list.length }; return list }
2962
+ const next = list.concat([entry])
2963
+ result = { appended: true, length: next.length }
2964
+ return next
2965
+ } })
2966
+ return result
2967
+ }
2968
+ // Remove and return the head in ONE atomic commit: an append landing in between can never be
2969
+ // lost by it, and a removed entry can never be resurrected by an append that read the queue
2970
+ // before the removal.
2971
+ async function takeQueueHead() {
2972
+ let head = null
2973
+ await commit(EV.queue, { queue: (cur) => {
2974
+ const list = Array.isArray(cur) ? cur : []
2975
+ if (!list.length) return list
2976
+ head = list[0]
2977
+ return list.slice(1)
2978
+ } })
2979
+ return head
2980
+ }
2659
2981
  // Queue a proposal UNLESS the same object was just closed as 真/假 (a dedup window
2660
2982
  // prevents several members independently proposing the same object in one tick
2661
2983
  // from running it end-to-end twice — v4 §26 test9).
@@ -2666,18 +2988,20 @@ export function apply(ctx) {
2666
2988
  if (recent !== undefined && (now() - recent) < recoverStallMs()) {
2667
2989
  return { ok: true, deduped: true, message: t + ' 刚刚定论,忽略重复提议' }
2668
2990
  }
2669
- const q = inst().queue.slice()
2670
- if (q.some((p) => p.target === t)) return { ok: true, deduped: true, message: t + ' 已在验证队列中' }
2671
- if (currentVerify() && currentVerify().target === t) return { ok: true, deduped: true, message: t + ' 正在验证中' }
2672
- q.push({ target: t, kind: kind || guessTargetKind(t), proposer: isOffice(proposer) ? 'office' : proposer, reason: String(reason || ''), at: now() })
2673
- await putQueue(q)
2991
+ const cv = currentVerify()
2992
+ if (cv && cv.target === t) return { ok: true, deduped: true, message: t + ' 正在验证中' }
2993
+ const added = await appendToQueue({
2994
+ target: t, kind: kind || guessTargetKind(t),
2995
+ proposer: isOffice(proposer) ? 'office' : proposer, reason: String(reason || ''), at: now(),
2996
+ })
2997
+ if (!added.appended) return { ok: true, deduped: true, message: t + ' 已在验证队列中' }
2674
2998
  notifyActivity()
2675
2999
  // Start it NOW rather than hoping a scheduling pass reaches it. A pass may already
2676
3000
  // be in flight and PAST its arming point, in which case a bare `scheduleNext()`
2677
3001
  // only sets the trampoline flag and the proposal waits for the next iteration.
2678
3002
  await armNextVerify()
2679
3003
  if (!hasVerifyInFlight()) await scheduleNext()
2680
- return { ok: true, queued: t, pendingVerifyCount: q.length, started: hasVerifyInFlight() }
3004
+ return { ok: true, queued: t, pendingVerifyCount: added.length, started: hasVerifyInFlight() }
2681
3005
  }
2682
3006
  async function beginVerify(proposal) {
2683
3007
  dbg.begin += 1
@@ -2865,8 +3189,7 @@ export function apply(ctx) {
2865
3189
  // `maybeQueueVerify` calls this directly (bypassing schedulePass, whose meeting
2866
3190
  // check is what used to hide the asymmetry). The meeting's watchdog clock would
2867
3191
  // then be starved while two coordination processes competed for the same members.
2868
- // The proposal stays in the queue; schedulePass reaches this again once the
2869
- // meeting is over.
3192
+ // The proposal stays in the queue; schedulePass reaches this again once the meeting is over.
2870
3193
  if (meeting) return
2871
3194
  // Only one begin may be in flight. Without this, two callers (a scheduling pass
2872
3195
  // and a fresh proposal) could both pass the `currentVerify()` check before either
@@ -2875,10 +3198,21 @@ export function apply(ctx) {
2875
3198
  if (currentVerify()) return
2876
3199
  beginLock = true
2877
3200
  try {
2878
- const q = inst().queue.slice()
2879
- while (q.length) {
2880
- const p = q.shift()
2881
- await putQueue(q)
3201
+ while (true) {
3202
+ // NEVER remove a proposal we are not about to run: the entry leaves the durable
3203
+ // queue only in the same step that starts it. The old code shifted it out and then
3204
+ // returned because a meeting was live, so the proposal existed in neither the queue
3205
+ // nor `verdicts` until some later pass happened to re-arm it (audit M2). Re-check
3206
+ // the exclusion guards here too: `await putQueue` can let a meeting open.
3207
+ const q = inst().queue.slice()
3208
+ if (!q.length) return
3209
+ if (meeting || currentVerify()) return
3210
+ // Take the head in ONE atomic commit (`takeQueueHead` re-reads the queue inside the
3211
+ // fold). The exclusion guards above are deliberately NOT re-checked inside it: a third
3212
+ // copy of them would make the sensitivity probes that remove the two real copies inert
3213
+ // (they would still hold, and the suite would look like a blind spot).
3214
+ const p = await takeQueueHead()
3215
+ if (!p) return
2882
3216
  const recent = verifiedRecently.get(p.target)
2883
3217
  if (recent !== undefined && (now() - recent) < recoverStallMs()) continue
2884
3218
  const vs = await beginVerify(p)
@@ -3038,6 +3372,11 @@ export function apply(ctx) {
3038
3372
  solveVotes.clear()
3039
3373
  await putMeeting({ id, agenda: meeting.agenda, kind: meeting.kind, at: now(), file: 'Shared/Meetings/' + id + '.md' })
3040
3374
  await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
3375
+ // A meeting that ACTUALLY begins and was convened by the office is the other half of the
3376
+ // `paperEditor='office'` consultation requirement (docs/final-paper.md §7). Counted here, not at the
3377
+ // request: a parked/refused request is not a consultation. A FRAMEWORK-convened stall
3378
+ // meeting (`auto:true`) is not the office's own act, so it does not count either.
3379
+ if (meeting.by === 'office' && opts.auto !== true) await notePaperConsult('meeting')
3041
3380
  await mkdirs()
3042
3381
  await writeTextRel('Shared/Meetings/' + id + '.md', [
3043
3382
  '# 会议纪要|' + id + '|' + instituteName,
@@ -3156,9 +3495,24 @@ export function apply(ctx) {
3156
3495
  // Stop ONLY on a unanimous true solve-vote from every VOTING member. There is no
3157
3496
  // forced/flat/near-consensus closure: any objection keeps the institute working.
3158
3497
  async function checkSolved() {
3498
+ if (autoDone) return true
3159
3499
  const vs = voters().map((m) => m.id)
3160
3500
  if (!vs.length) return false
3161
3501
  if (!vs.every((id) => solveVotes.get(id) === true)) return false
3502
+ // ── THE FINAL PAPER PHASE COMES FIRST (docs/final-paper.md §3) ────────────────────────────────
3503
+ // `finishRun` flips phase='solved' / autoDone / running=false, after which the machinery
3504
+ // REFUSES: `wakeIfIdle` returns false, `startMeeting` rejects a concluded institute and
3505
+ // nothing schedules — so the co-writing, the cross-review and the office consultation
3506
+ // could never run. The run therefore stays alive here until the paper is finalised
3507
+ // (or degraded with a report), and `finalizePaper` calls `finishRun` for us.
3508
+ if (params.finalPaper !== false) {
3509
+ const p = paper()
3510
+ if (!p) { await startPaper('run-complete', {}); return true }
3511
+ if (p.status !== 'finalized') {
3512
+ if (!p.completesRun) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { completesRun: true }) : cur))
3513
+ return true
3514
+ }
3515
+ }
3162
3516
  await finishRun('全体有表决权者一致认为原问题已解决')
3163
3517
  return true
3164
3518
  }
@@ -3196,6 +3550,916 @@ export function apply(ctx) {
3196
3550
  notifyActivity()
3197
3551
  }
3198
3552
 
3553
+ // ================= final paper (docs/final-paper.md) ============
3554
+ // THE PHASE RUNS BEFORE `finishRun` (docs/final-paper.md §3). After `phase='solved'` the machinery
3555
+ // refuses: `wakeIfIdle` (:3257) returns false, `startMeeting` refuses a concluded
3556
+ // institute, and a concluded run has no scheduling at all — so the co-writing, the
3557
+ // cross-review and the office consultation could never run. `checkSolved` therefore
3558
+ // enters this phase while the run is still alive and only calls `finishRun` once the
3559
+ // paper is finalised (or degraded with a report).
3560
+ //
3561
+ // Only evidence that ALREADY exists is written down: verified verdicts, the task board,
3562
+ // the Lean records, the members' libraries and the members' own text. The framework never
3563
+ // invents content — sections 3/6/7/8 are the members' own words, 4/5/9 are derived from
3564
+ // durable state, and undecided/refuted items are always marked as such.
3565
+ const PAPER_FILE = (id, file) => 'Paper/' + id + '/' + file
3566
+ function paper() { return inst().paper || null }
3567
+ function paperActive() { const p = paper(); return !!p && p.status !== 'finalized' }
3568
+ function paperParticipants() {
3569
+ return activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher').map((m) => m.id)
3570
+ }
3571
+ function paperActiveParticipants(p) {
3572
+ return ((p && p.participants) || []).filter((id) => { const m = memberById(id); return !!m && m.phase === 'active' })
3573
+ }
3574
+ function paperConfiguredEditor() { return params.paperEditor === 'office' ? 'office' : 'academician' }
3575
+ function paperIdFromState() {
3576
+ const a = idSafe(instituteName)
3577
+ if (a) return a
3578
+ const b = idSafe(String(runId || (inst() && inst().runId) || ''))
3579
+ return b || 'institute'
3580
+ }
3581
+ async function fileExistsAbs(abs) {
3582
+ try { const t = await fsTargetAbs(abs); return (await fs.stat(t)) !== undefined } catch (e) { return false }
3583
+ }
3584
+ function paperAbs(id, file) { return instRoot() + '/' + PAPER_FILE(id, file) }
3585
+ function paperDirRel(id) { return 'Paper/' + id }
3586
+ async function mutatePaper(fn) {
3587
+ await patchInstitute({ paper: (cur) => { const next = fn(cur === undefined ? null : cur); return next === undefined ? (cur === undefined ? null : cur) : next } })
3588
+ return paper()
3589
+ }
3590
+ async function paperLog(title, body) {
3591
+ const p = paper()
3592
+ const id = (p && p.id) || paperIdFromState()
3593
+ const rel = PAPER_FILE(id, 'paper.log.md')
3594
+ const prev = (await readTextRel(rel)) || ('# 最终论文流程日志|' + id + '\n\n')
3595
+ await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
3596
+ return rel
3597
+ }
3598
+ function paperAskEvery() { return posMs(params.activityTimeoutMs, 120000) }
3599
+ function paperNextStep(p) {
3600
+ if (!p) return 'no paper flow yet: finish the run with finalPaper=true, or trigger /v5 paper manually'
3601
+ if (p.status === 'finalized') return 'finalised — /v5 paper force rewrites it'
3602
+ if (p.status === 'writing') return 'waiting for the staff parts (round ' + p.round + '/' + PAPER_MAX_ROUNDS + ')'
3603
+ if (p.status === 'reviewing') return 'waiting for the cross-reviews (each reviewer judges another part)'
3604
+ if (p.status === 'awaiting-editor' && p.editor === 'office') {
3605
+ return 'awaiting the OFFICE: send >=1 office message and convene >=1 meeting, then call vibe_v5_finalize_paper (consultation so far: messages=' +
3606
+ Number((p.consult || {}).messages || 0) + ', meetings=' + Number((p.consult || {}).meetings || 0) + ')'
3607
+ }
3608
+ if (p.status === 'awaiting-editor') return 'waiting for the academician to finalise'
3609
+ return p.status
3610
+ }
3611
+ function paperAutoNote() {
3612
+ return params.finalPaper === false ? '自动已关闭(finalPaper=false);本次为手动触发,仍会生成论文。' : ''
3613
+ }
3614
+ function paperSummary() {
3615
+ const p = paper()
3616
+ if (!p) return null
3617
+ return {
3618
+ id: p.id, status: p.status, stage: p.stage, round: p.round,
3619
+ editor: p.editor, editorFallback: !!p.editorFallback, completesRun: !!p.completesRun,
3620
+ participants: p.participants || [],
3621
+ parts: Object.keys(p.parts || {}).map((id) => ({ member: id, round: (p.parts[id] || {}).round, title: (p.parts[id] || {}).title || '' })),
3622
+ reviews: Object.keys(p.reviews || {}).map((id) => ({ member: id, of: (p.reviews[id] || {}).of || '', round: (p.reviews[id] || {}).round, deliverable: (p.reviews[id] || {}).deliverable === true })),
3623
+ reviewOf: p.reviewOf || {},
3624
+ consult: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
3625
+ lang: p.lang, format: p.format, compilePdf: p.pdf === true,
3626
+ compile: p.compile || null, engine: p.engine || '', artifacts: p.artifacts || [],
3627
+ warnings: p.warnings || [], disagreement: p.disagreement || [], skipped: p.skipped || [],
3628
+ forcedAfterCap: !!p.forcedAfterCap, finalizedAt: p.finalizedAt || null,
3629
+ dir: 'Paper/' + p.id + '/', next: paperNextStep(p),
3630
+ meta: p.status === 'finalized' ? 'Paper/' + p.id + '/paper.meta.json' : null,
3631
+ }
3632
+ }
3633
+
3634
+ // ---- the three asks ----------------------------------------------------
3635
+ function paperWritePrompt(member, p) {
3636
+ const L = []
3637
+ L.push('【最终论文·撰写 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
3638
+ L.push('本所对原问题的一致结论已经达成,现在撰写**最终论文**(第 ' + p.round + '/' + PAPER_MAX_ROUNDS + ' 轮)。')
3639
+ L.push('请你**只写你自己库里已有证据支撑**的内容:')
3640
+ L.push('- 直接引用你的卡片(Members/' + member.id + '/Propos|Methods|Subproblems/)、Members/' + member.id + '/Progress/progress.md、你参与的表决记录;')
3641
+ L.push('- **不得编造**:没有证据的推测不要写成结论;未决 / 被否证的条目必须显式标注“未定论 / 已被否证”;')
3642
+ L.push('- 在 evidence 里写清证据路径,附录会逐条索引。')
3643
+ if (p.round > 1) {
3644
+ const rv = (p.reviews || {})[member.id]
3645
+ L.push('')
3646
+ L.push('上一轮互审给你(' + member.id + ')部分的意见:' + (rv ? String(rv.comments || '(无具体意见)') : '(无)'))
3647
+ L.push('请据此修订;若不同意该意见,请在 limits 里写明理由。')
3648
+ }
3649
+ L.push('')
3650
+ L.push('------------')
3651
+ L.push(stateBlock(member))
3652
+ L.push('------------')
3653
+ L.push('结束时只输出一个 JSON 对象:')
3654
+ L.push('{ "paper_part": {')
3655
+ L.push(' "title": "你这部分的标题",')
3656
+ L.push(' "solution": "你对**原问题完整解法**的贡献(推理链与结论,只写有证据的)",')
3657
+ L.push(' "methods": "你创造/发现的方法、理论、思想、有价值经验、数学理解",')
3658
+ L.push(' "rules": "你从这些工作中归纳出的可复用规律",')
3659
+ L.push(' "limits": "局限、未决、被否证之处(必须诚实、显式)",')
3660
+ L.push(' "evidence": ["Members/' + member.id + '/Propos/p-x.md"] } }')
3661
+ return L.join('\n')
3662
+ }
3663
+ function paperReviewPrompt(member, p, ofId) {
3664
+ const part = ofId ? (p.parts || {})[ofId] : null
3665
+ const L = []
3666
+ L.push('【最终论文·互审 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
3667
+ L.push('请你审阅 **' + (ofId || '(未指定)') + '** 撰写的部分,判断它是否可以交付(deliverable)。')
3668
+ L.push('要点:证据是否充分;是否与已定论的表决一致;有无编造或未标注的未决项;术语与符号是否清楚。')
3669
+ L.push('')
3670
+ L.push('------------ 待审部分(' + (ofId || '') + ')------------')
3671
+ L.push('标题:' + ((part && part.title) || '(无)'))
3672
+ L.push('【完整解法】' + String((part && part.solution) || '(空)').slice(0, 6000))
3673
+ L.push('【方法/理论/思想/经验/理解】' + String((part && part.methods) || '(空)').slice(0, 4000))
3674
+ L.push('【规律】' + String((part && part.rules) || '(空)').slice(0, 3000))
3675
+ L.push('【局限/未决】' + String((part && part.limits) || '(空)').slice(0, 3000))
3676
+ L.push('【声称的证据】' + ((((part && part.evidence) || []).join('、')) || '(无)'))
3677
+ L.push('------------')
3678
+ L.push('')
3679
+ L.push(stateBlock(member))
3680
+ L.push('')
3681
+ L.push('结束时只输出一个 JSON 对象:')
3682
+ L.push('{ "paper_review": { "of": "' + (ofId || '') + '", "deliverable": true, "comments": "具体意见" } }')
3683
+ L.push('deliverable 必须是 true(可交付)或 false(不可交付,需修改)。只有**全体参与成员**都投 true,定稿代表才能定稿。')
3684
+ return L.join('\n')
3685
+ }
3686
+ function paperFinalPrompt(member, p) {
3687
+ const L = []
3688
+ L.push('【最终论文·定稿(院士)—— ' + member.id + '】')
3689
+ L.push('全体参与成员已在互审中表示“可交付”。请你作为定稿代表做**最后一次**把关:')
3690
+ L.push('核对合并稿与互审意见,确认没有编造、没有未标注的未决项、没有与表决记录矛盾之处,然后给出决定。')
3691
+ L.push('')
3692
+ L.push('------------ 合并稿(各成员部分)------------')
3693
+ for (const id of paperActiveParticipants(p)) {
3694
+ const part = (p.parts || {})[id] || {}
3695
+ L.push('### ' + id + '|' + String(part.title || ''))
3696
+ L.push('【完整解法】' + String(part.solution || '').slice(0, 3000))
3697
+ L.push('【方法/规律】' + (String(part.methods || '') + ' ' + String(part.rules || '')).slice(0, 2000))
3698
+ L.push('【局限】' + String(part.limits || '').slice(0, 1500))
3699
+ L.push('')
3700
+ }
3701
+ L.push('------------ 互审结论 ------------')
3702
+ for (const id of Object.keys(p.reviews || {})) {
3703
+ const rv = p.reviews[id] || {}
3704
+ L.push('- ' + id + ' 审 ' + (rv.of || '?') + ':deliverable=' + (rv.deliverable === true) + '|' + String(rv.comments || '').slice(0, 500))
3705
+ }
3706
+ if ((p.disagreement || []).length) L.push('- ⚠ 已达轮次上限仍有分歧:' + JSON.stringify(p.disagreement[p.disagreement.length - 1]).slice(0, 500))
3707
+ L.push('------------')
3708
+ L.push('')
3709
+ L.push(stateBlock(member))
3710
+ L.push('')
3711
+ L.push('结束时只输出一个 JSON 对象:')
3712
+ L.push('{ "paper_final": { "decision": "deliverable" | "revise",')
3713
+ L.push(' "note": "定稿说明:你如何审阅、统一术语与符号、是否发现并纠正了问题",')
3714
+ L.push(' "conclusion": "(可选)定稿代表对原问题的最终结论(只写有证据的)" } }')
3715
+ L.push('decision="revise" 会退回继续修订(有轮次上限)。')
3716
+ return L.join('\n')
3717
+ }
3718
+
3719
+ // ---- the stage driver --------------------------------------------------
3720
+ let paperLock = false
3721
+ async function paperStep() {
3722
+ const p = paper()
3723
+ if (!p || p.status === 'finalized' || p.status === 'awaiting-editor') return false
3724
+ const active = paperActiveParticipants(p)
3725
+ if (!active.length) return await paperAdvance(p, active)
3726
+ const parts = p.parts || {}
3727
+ const reviews = p.reviews || {}
3728
+ const missing = p.status === 'writing'
3729
+ ? active.filter((id) => !(parts[id] && parts[id].round === p.round))
3730
+ : active.filter((id) => !(reviews[id] && reviews[id].round === p.round))
3731
+ if (!missing.length) return await paperAdvance(p, active)
3732
+ const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
3733
+ let asked = 0
3734
+ for (const id of missing) {
3735
+ if (asked >= budget) break
3736
+ if (busy.has(id)) continue
3737
+ const m = memberById(id)
3738
+ if (!m || m.phase !== 'active') continue
3739
+ const last = (p.lastAskAt || {})[id] || 0
3740
+ if (last && (now() - last) < paperAskEvery()) continue
3741
+ const prompt = p.status === 'writing'
3742
+ ? paperWritePrompt(m, p)
3743
+ : paperReviewPrompt(m, p, (p.reviewOf || {})[id])
3744
+ // `wakeMember`, not `wakeIfIdle`: the paper phase deliberately drives members with a
3745
+ // dedicated ask, and a research round must never be mistaken for a paper answer.
3746
+ const ok = await wakeMember(m, prompt, 'paper')
3747
+ if (ok) {
3748
+ asked += 1
3749
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3750
+ lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now(),
3751
+ }) : cur))
3752
+ }
3753
+ }
3754
+ // Nothing to advance yet. ALWAYS re-arm: the pass that got here may have skipped an ask
3755
+ // because its member is busy or still inside the ask window, and `schedulePass` returns
3756
+ // from the paper branch BEFORE its own heartbeat arming — without this a paper whose ask
3757
+ // was paced out would stall until some unrelated member turn ended.
3758
+ if (asked) { armHeartbeat(); return false }
3759
+ // Stall watchdog (same clock as meetings/verifications): a member whose turn never ends
3760
+ // must not wedge the paper forever. The stuck members are recorded as non-participants.
3761
+ const stale = now() - Number(p.updatedAt || p.createdAt || now())
3762
+ if (stale >= recoverStallMs()) {
3763
+ const stuck = missing.filter((id) => busy.has(id))
3764
+ const name = stuck.length ? stuck : missing
3765
+ const warn = '看门狗:' + name.join('、') + ' 在 ' + Math.round(stale / 1000) + 's 内没有交稿,已按“未参与”处理并记入附录。'
3766
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3767
+ warnings: (cur.warnings || []).concat([warn]),
3768
+ skipped: Array.from(new Set((cur.skipped || []).concat(name))), updatedAt: now(),
3769
+ }) : cur))
3770
+ await paperLog('看门狗', warn)
3771
+ return await paperAdvance(paper(), paperActiveParticipants(paper()))
3772
+ }
3773
+ armHeartbeat()
3774
+ return false
3775
+ }
3776
+ async function paperAdvance(p, active) {
3777
+ if (p.status === 'writing') {
3778
+ const reviewOf = {}
3779
+ for (let i = 0; i < active.length; i++) reviewOf[active[i]] = active[(i + 1) % active.length]
3780
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3781
+ status: 'reviewing', stage: 'reviewing', reviewOf, reviews: {}, lastAskAt: {}, updatedAt: now(),
3782
+ }) : cur))
3783
+ await paperLog('进入互审(第 ' + p.round + ' 轮)', active.map((a) => a + ' 审 ' + reviewOf[a]).join(';') || '(无参与成员)')
3784
+ return true
3785
+ }
3786
+ if (p.status === 'reviewing') {
3787
+ const reviews = p.reviews || {}
3788
+ const refusers = active.filter((id) => !(reviews[id] && reviews[id].deliverable === true))
3789
+ if (!refusers.length) {
3790
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now() }) : cur))
3791
+ await paperLog('互审一致:可交付', '全体参与成员(' + active.join('、') + ')均表示可交付。定稿代表:' + p.editor)
3792
+ return true
3793
+ }
3794
+ const detail = refusers.map((id) => id + ':' + String((reviews[id] || {}).comments || '未说明')).join(';')
3795
+ const entry = { round: p.round, members: refusers, detail, at: now() }
3796
+ if (p.round < PAPER_MAX_ROUNDS) {
3797
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3798
+ status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
3799
+ disagreement: (cur.disagreement || []).concat([entry]),
3800
+ }) : cur))
3801
+ await paperLog('互审未达成一致 → 进入第 ' + (p.round + 1) + ' 轮', detail)
3802
+ return true
3803
+ }
3804
+ const warn = '互审在 ' + PAPER_MAX_ROUNDS + ' 轮上限仍未达成全体一致(' + refusers.join('、') + ');分歧已写入论文附录。'
3805
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3806
+ status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now(),
3807
+ warnings: (cur.warnings || []).concat([warn]), disagreement: (cur.disagreement || []).concat([entry]), forcedAfterCap: true,
3808
+ }) : cur))
3809
+ await paperLog('达到轮次上限:转交定稿代表', warn)
3810
+ return true
3811
+ }
3812
+ return false
3813
+ }
3814
+ async function paperAwaitEditor(p) {
3815
+ if (p.editor !== 'academician') return false // office: waits for vibe_v5_finalize_paper
3816
+ const id = academicianId()
3817
+ if (!id) {
3818
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3819
+ editor: 'office', editorFallback: true, updatedAt: now(),
3820
+ warnings: (cur.warnings || []).concat(['定稿代表 academician 不在册:已回退为 office(需人工定稿)。']),
3821
+ }) : cur))
3822
+ await paperLog('定稿代表回退', 'academician 不在册,回退为 office:用 /v5 paper 或 vibe_v5_finalize_paper 定稿(仍需先与全所交流并开会)。')
3823
+ return false
3824
+ }
3825
+ const m = memberById(id)
3826
+ if (!m || m.phase !== 'active') return false
3827
+ if (busy.has(id)) { armHeartbeat(); return false }
3828
+ const last = (p.lastAskAt || {})[id] || 0
3829
+ if (last && (now() - last) < paperAskEvery()) { armHeartbeat(); return false }
3830
+ const ok = await wakeMember(m, paperFinalPrompt(m, p), 'paper')
3831
+ if (ok) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now() }) : cur))
3832
+ armHeartbeat()
3833
+ return false
3834
+ }
3835
+ // The ONE driver. Called from `onMemberEnd` (after the reply is folded), from the manual
3836
+ // tools/command, from the resume path and from `schedulePass`; `paperLock` keeps it single.
3837
+ async function paperPass() {
3838
+ if (paperLock) return false
3839
+ paperLock = true
3840
+ try {
3841
+ for (let i = 0; i < 6; i++) {
3842
+ const p = paper()
3843
+ if (!p || p.status === 'finalized') return false
3844
+ if (p.status === 'awaiting-editor') { await paperAwaitEditor(p); return false }
3845
+ const advanced = await paperStep()
3846
+ if (!advanced) return false
3847
+ }
3848
+ return false
3849
+ } catch (e) {
3850
+ console.error('vibe-math-v5: paper pass: ' + String((e && e.stack) || e))
3851
+ return false
3852
+ } finally { paperLock = false }
3853
+ }
3854
+
3855
+ // ---- start / record / consult / finalize -------------------------------
3856
+ async function startPaper(trigger, opts) {
3857
+ const o = opts || {}
3858
+ const trg = String(trigger || 'manual')
3859
+ const existing = paper()
3860
+ if (existing && existing.status === 'finalized' && !o.force) {
3861
+ const refill = await refillPaperArtifacts(existing)
3862
+ return { ok: true, alreadyFinalized: true, id: existing.id, dir: 'Paper/' + existing.id + '/', refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
3863
+ }
3864
+ if (existing && existing.status !== 'finalized' && !o.force) {
3865
+ return { ok: true, alreadyRunning: true, id: existing.id, status: existing.status, stage: existing.stage, round: existing.round, next: paperNextStep(existing), autoDisabled: params.finalPaper === false, note: paperAutoNote() }
3866
+ }
3867
+ const participants = paperParticipants()
3868
+ // A per-call editor override (`/v5 paper editor=office`) is ONE-SHOT: it is not written
3869
+ // back into the persisted params. An AUTOMATIC (run-complete) trigger always uses the
3870
+ // academician — the office is the root session and has no wake path (docs/final-paper.md §7).
3871
+ const configured = (o.editor === 'office' || o.editor === 'academician') ? o.editor : paperConfiguredEditor()
3872
+ const editor = trg === 'run-complete' ? 'academician' : configured
3873
+ const configWantsOffice = configured === 'office'
3874
+ const editorNote = (trg === 'run-complete' && configWantsOffice)
3875
+ ? 'paperEditor=office 已配置,但自动(收口)触发必须由**可唤醒**的院士定稿(所办是根会话,没有唤醒路径);本次自动流程用 academician。要所办定稿请在收尾后用 /v5 paper 手动触发。'
3876
+ : (editor === 'office' ? '所办定稿:必须先与全所交流(>=1 条所办消息)并至少召开一次会议,结论写进定稿说明,然后调用 vibe_v5_finalize_paper。' : '')
3877
+ const lang = (o.lang === 'en' || o.lang === 'zh') ? o.lang : (params.paperLanguage === 'en' ? 'en' : 'zh')
3878
+ const format = ['both', 'md', 'tex'].indexOf(o.format) !== -1 ? o.format : (['both', 'md', 'tex'].indexOf(params.paperFormat) !== -1 ? params.paperFormat : 'both')
3879
+ const p = {
3880
+ id: paperIdFromState(), status: 'writing', stage: 'writing', round: 1,
3881
+ trigger: trg, createdAt: now(), updatedAt: now(),
3882
+ participants, parts: {}, reviews: {}, reviewOf: {}, final: null,
3883
+ editor, editorNote, completesRun: trg === 'run-complete',
3884
+ lang, format, pdf: params.paperCompilePdf !== false,
3885
+ consult: { messages: 0, meetings: 0 },
3886
+ lastAskAt: {}, warnings: editorNote ? [editorNote] : [], disagreement: [], skipped: [], artifacts: [],
3887
+ forced: !!o.force, forceReason: o.force ? String(o.reason || 'manual force') : '',
3888
+ }
3889
+ await patchInstitute({ paper: p })
3890
+ await paperLog('流程开始(' + (trg === 'run-complete' ? '收口自动触发' : '手动触发') + ')', [
3891
+ '- 目录:Paper/' + p.id + '/',
3892
+ '- 语言:' + p.lang + '|格式:' + p.format + '|编译 PDF:' + p.pdf,
3893
+ '- 参与成员:' + (participants.join('、') || '(无常驻成员)'),
3894
+ '- 定稿代表:' + editor + (editorNote ? '(' + editorNote + ')' : ''),
3895
+ ].join('\n'))
3896
+ await paperPass()
3897
+ return {
3898
+ ok: true, started: true, id: p.id, dir: 'Paper/' + p.id + '/', status: 'writing',
3899
+ participants, editor, next: paperNextStep(paper()),
3900
+ autoDisabled: params.finalPaper === false, note: paperAutoNote(),
3901
+ }
3902
+ }
3903
+ async function paperRecordPart(memberId, raw) {
3904
+ const p = paper()
3905
+ if (!p || p.status !== 'writing') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is not in its writing stage (now: ' + (p ? p.status : 'none') + ')' }
3906
+ if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a writing participant of this paper' }
3907
+ const part = {
3908
+ member: memberId, round: p.round,
3909
+ title: String(raw.title || '').slice(0, 300),
3910
+ solution: String(raw.solution || '').slice(0, 20000),
3911
+ methods: String(raw.methods || '').slice(0, 20000),
3912
+ rules: String(raw.rules || '').slice(0, 12000),
3913
+ limits: String(raw.limits || '').slice(0, 12000),
3914
+ evidence: Array.isArray(raw.evidence) ? raw.evidence.map(String).slice(0, 50) : [],
3915
+ at: now(),
3916
+ }
3917
+ if (!part.title && !part.solution && !part.methods && !part.rules && !part.limits) {
3918
+ await notice(memberId, 'paper_part 是空的:请至少给出 title 与你的贡献正文(solution/methods/rules/limits),只写库里已有证据支撑的内容。')
3919
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_part carries no content' }
3920
+ }
3921
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { parts: Object.assign({}, cur.parts || {}, { [memberId]: part }), updatedAt: now() }) : cur))
3922
+ await paperLog('收到 ' + memberId + ' 的部分(第 ' + p.round + ' 轮)', '- 标题:' + (part.title || '(无)') + '\n- 声称证据:' + (part.evidence.length || 0) + ' 条')
3923
+ return { ok: true, recorded: memberId, round: p.round }
3924
+ }
3925
+ async function paperRecordReview(memberId, raw) {
3926
+ const p = paper()
3927
+ if (!p || p.status !== 'reviewing') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is not in its cross-review stage (now: ' + (p ? p.status : 'none') + ')' }
3928
+ if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a review participant of this paper' }
3929
+ if (raw.deliverable !== true && raw.deliverable !== false) {
3930
+ await notice(memberId, 'paper_review.deliverable 必须是 true 或 false(你敢不敢交付?未表态不算可交付)。')
3931
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_review.deliverable must be a boolean' }
3932
+ }
3933
+ const assigned = (p.reviewOf || {})[memberId]
3934
+ const rev = {
3935
+ member: memberId, of: String(raw.of || assigned || ''), round: p.round,
3936
+ deliverable: raw.deliverable === true,
3937
+ comments: String(raw.comments || '').slice(0, 8000), at: now(),
3938
+ }
3939
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { reviews: Object.assign({}, cur.reviews || {}, { [memberId]: rev }), updatedAt: now() }) : cur))
3940
+ await paperLog('互审意见|' + memberId + ' → ' + rev.of, '- deliverable:' + rev.deliverable + '\n- 意见:' + (rev.comments || '(无)').slice(0, 2000))
3941
+ return { ok: true, recorded: memberId, of: rev.of, deliverable: rev.deliverable }
3942
+ }
3943
+ async function paperRecordFinal(memberId, raw) {
3944
+ const p = paper()
3945
+ if (!p || p.status !== 'awaiting-editor') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper is not awaiting its editor (now: ' + (p ? p.status : 'none') + ')' }
3946
+ if (p.editor !== 'academician' || memberId !== academicianId()) {
3947
+ return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'paperEditor is "' + p.editor + '": the academician does not finalise this paper' }
3948
+ }
3949
+ const decision = String(raw.decision || '')
3950
+ if (decision !== 'deliverable' && decision !== 'revise') {
3951
+ await notice(memberId, "paper_final.decision 必须是 'deliverable' 或 'revise'(收到 " + decision + ')。')
3952
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'decision must be deliverable|revise' }
3953
+ }
3954
+ const fin = { by: memberId, decision, note: String(raw.note || '').slice(0, 4000), conclusion: String(raw.conclusion || '').slice(0, 20000), at: now() }
3955
+ if (decision === 'deliverable') {
3956
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
3957
+ return await finalizePaper('academician-deliverable')
3958
+ }
3959
+ if (p.round < PAPER_MAX_ROUNDS) {
3960
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3961
+ final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
3962
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
3963
+ }) : cur))
3964
+ await paperLog('定稿代表要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note || '(未说明)')
3965
+ return { ok: true, revised: true, round: p.round + 1 }
3966
+ }
3967
+ const warn = '定稿代表在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
3968
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3969
+ final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
3970
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
3971
+ }) : cur))
3972
+ await paperLog('达到轮次上限:定稿代表仍要求修订', warn)
3973
+ return await finalizePaper('academician-revise-at-cap')
3974
+ }
3975
+ // The office consultation gate (v2 §A6 / the user's requirement): the office may finalise
3976
+ // ONLY after it has actually talked to the institute (>=1 office message) AND convened at
3977
+ // least one meeting; both counts are recorded in the finalisation note and the meta.
3978
+ async function finalizePaperByOffice(o) {
3979
+ const args = o || {}
3980
+ const p = paper()
3981
+ if (!p) return { ok: false, code: 'V5_PAPER_STATE', message: 'no paper flow has been started (finish the run with finalPaper=true, or use /v5 paper)' }
3982
+ if (p.status === 'finalized' && !args.force) {
3983
+ const refill = await refillPaperArtifacts(p)
3984
+ return { ok: true, alreadyFinalized: true, id: p.id, refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
3985
+ }
3986
+ if (p.status !== 'awaiting-editor') {
3987
+ return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is in stage "' + p.status + '"; the editor finalises only after every part is written and cross-reviewed' }
3988
+ }
3989
+ if (p.editor !== 'office') {
3990
+ return { ok: false, code: 'V5_NOT_OFFICE', message: 'paperEditor="academician": the academician finalises from its own round; the office does not' }
3991
+ }
3992
+ const decision = String(args.decision || '')
3993
+ if (decision !== 'deliverable' && decision !== 'revise') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "decision must be 'deliverable' or 'revise'" }
3994
+ const note = String(args.note || '').trim()
3995
+ if (decision === 'deliverable' && !note) {
3996
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '定稿说明(note)是必填的:写明你如何与全所交流、商讨、优化、审查,以及结论' }
3997
+ }
3998
+ const consult = Object.assign({ messages: 0, meetings: 0 }, p.consult || {})
3999
+ if (!(Number(consult.messages) > 0) || !(Number(consult.meetings) > 0)) {
4000
+ return {
4001
+ ok: false, code: 'V5_PAPER_CONSULT_REQUIRED',
4002
+ message: 'paperEditor="office" 要求所办先与全所交流、商讨、优化、审查:至少 1 条所办消息(vibe_v5_message)+ 至少 1 次会议(vibe_v5_meeting),然后才能定稿。当前:messages=' +
4003
+ Number(consult.messages || 0) + ', meetings=' + Number(consult.meetings || 0),
4004
+ consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
4005
+ }
4006
+ }
4007
+ const fin = {
4008
+ by: 'office', decision, note: String(note).slice(0, 4000),
4009
+ conclusion: String(args.conclusion || '').slice(0, 20000),
4010
+ consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
4011
+ at: now(),
4012
+ }
4013
+ if (decision === 'deliverable') {
4014
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
4015
+ return await finalizePaper('office-deliverable')
4016
+ }
4017
+ if (p.round < PAPER_MAX_ROUNDS) {
4018
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4019
+ final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
4020
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
4021
+ }) : cur))
4022
+ await paperLog('所办要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note)
4023
+ return { ok: true, revised: true, round: p.round + 1 }
4024
+ }
4025
+ const warn = '所办在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
4026
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4027
+ final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
4028
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
4029
+ }) : cur))
4030
+ await paperLog('达到轮次上限:所办仍要求修订', warn)
4031
+ return await finalizePaper('office-revise-at-cap')
4032
+ }
4033
+ // Count the office's consultation evidence. Called from the ONE place each act really
4034
+ // happens (`say` for a message, `beginMeeting` for a meeting that actually starts), so the
4035
+ // gate cannot be satisfied by merely intending to consult.
4036
+ async function notePaperConsult(kind) {
4037
+ const p = paper()
4038
+ if (!p || p.status === 'finalized') return
4039
+ const key = kind === 'meeting' ? 'meetings' : 'messages'
4040
+ await mutatePaper((cur) => {
4041
+ if (!cur || cur.status === 'finalized') return cur
4042
+ const consult = Object.assign({ messages: 0, meetings: 0 }, cur.consult || {})
4043
+ consult[key] = Number(consult[key] || 0) + 1
4044
+ return Object.assign({}, cur, { consult, updatedAt: now() })
4045
+ })
4046
+ }
4047
+
4048
+ // ---- artifacts ---------------------------------------------------------
4049
+ function paperEvidence() {
4050
+ const s = inst()
4051
+ const V = s.verdicts || {}
4052
+ const keys = Object.keys(V)
4053
+ const closed = (o) => keys.filter((k) => V[k] && V[k].closed && V[k].outcome === o)
4054
+ const tasks = (s.tasks || []).filter((t) => t && t.status !== 'deleted')
4055
+ const formal = formalRecords()
4056
+ return {
4057
+ s, V,
4058
+ trueV: closed('true'), falseV: closed('false'), undecidedV: closed('undecided'),
4059
+ openV: keys.filter((k) => V[k] && !V[k].closed),
4060
+ tasks, done: tasks.filter((t) => t.status === 'completed'), openT: tasks.filter((t) => t.status !== 'completed'),
4061
+ formal, formalKeys: Object.keys(formal),
4062
+ members: s.members || [],
4063
+ }
4064
+ }
4065
+ function verdictCardRel(kind, id) { return 'Verified/' + (kind === 'subproblem' ? '问题' : kind === 'method' ? '方法' : '命题') + '/' + id + '.md' }
4066
+ function cardDirOf(kind) { return kind === 'method' ? 'Methods' : kind === 'subproblem' ? 'Subproblems' : 'Propos' }
4067
+ function paperTitle(p) {
4068
+ const st = String((inst().problem || {}).statement || '').trim()
4069
+ const head = st ? st.split(/\n/)[0].slice(0, 100) : ''
4070
+ return head || ('研究所成果论文|' + p.id)
4071
+ }
4072
+ function paperSources(p, active, ev) {
4073
+ const out = []
4074
+ out.push('Problems/conclusion.md(结题记录)')
4075
+ if ((inst().problem || {}).id) out.push('Problems/' + inst().problem.id + '.md(原问题卡)')
4076
+ out.push('State/' + instituteName + '.v5state.json(权威状态)')
4077
+ for (const k of ev.trueV.concat(ev.falseV)) out.push(verdictCardRel((ev.V[k] || {}).kind, k) + '(Verified 卡片)')
4078
+ for (const k of ev.trueV.concat(ev.falseV)) out.push('Shared/Debates/' + k + '.md(辩论录)')
4079
+ for (const k of ev.formalKeys) {
4080
+ const r = ev.formal[k] || {}
4081
+ if (r.file) out.push(r.file + '(Lean 工作文件)')
4082
+ if (r.proof) out.push(r.proof + '(归档证明)')
4083
+ }
4084
+ if (ev.formalKeys.length) out.push('Formal/Index.md', 'Formal/TODO.md')
4085
+ for (const m of ev.members) {
4086
+ out.push('Members/' + m.id + '/Progress/progress.md(' + m.id + ' 的研究日志)')
4087
+ out.push('Members/' + m.id + '/{Propos,Methods,Subproblems}/(' + m.id + ' 的卡片)')
4088
+ }
4089
+ out.push('Institutes.md(编制镜像)', 'Shared/TaskBoard.md(任务板镜像)')
4090
+ out.push('Paper/' + p.id + '/paper.log.md(本论文流程往来)')
4091
+ for (const id of active) {
4092
+ const part = (p.parts || {})[id] || {}
4093
+ for (const e of (part.evidence || [])) out.push(String(e) + '(' + id + ' 声称的证据)')
4094
+ }
4095
+ return Array.from(new Set(out))
4096
+ }
4097
+ function paperSections(p, active, skipped, ev, extra) {
4098
+ const o = extra || {}
4099
+ const fin = o.final || p.final || null
4100
+ const prob = ev.s.problem || {}
4101
+ const S = []
4102
+ // 1
4103
+ const b1 = []
4104
+ b1.push({ p: '标题:' + paperTitle(p) })
4105
+ b1.push({ ul: [
4106
+ '作者:' + ((p.participants || []).map((id) => id + '(' + ((memberById(id) || {}).kind === 'academician' ? '院士' : '常驻研究员') + ')').join('、') || '(无在册作者)'),
4107
+ '研究所:' + instituteName + '|项目:' + project + '|运行:' + (ev.s.runId || runId || '(无)'),
4108
+ '日期:' + fmtTime(p.finalizedAt || now()).slice(0, 10) + '|语言:' + p.lang + '|定稿代表:' + p.editor,
4109
+ ] })
4110
+ const mainConclusions = ev.trueV.length
4111
+ ? ('已由表决确立的结论:' + ev.trueV.join('、'))
4112
+ : '尚无经表决确立为真的结论(见第 4、8 节)。'
4113
+ b1.push({ p: '摘要:' + (prob.statement ? String(prob.statement).replace(/\s+/g, ' ').slice(0, 400) : '(未设定原问题)') + '。' + mainConclusions })
4114
+ if (fin && fin.note) b1.push({ p: '定稿说明:' + fin.note + (fin.consult ? '(定稿前与全所交流:消息 ' + fin.consult.messages + ' 条、会议 ' + fin.consult.meetings + ' 次)' : '') })
4115
+ if (fin && fin.conclusion) b1.push({ p: '定稿结论:' + fin.conclusion })
4116
+ S.push({ title: PAPER_SECTIONS[0], blocks: b1 })
4117
+ // 2
4118
+ S.push({ title: PAPER_SECTIONS[1], blocks: [
4119
+ { p: prob.statement ? String(prob.statement) : '(本所未记录原问题的完整陈述——请勿在论文中补写。)' },
4120
+ { p: '问题 ID:' + (prob.id || '(无)') + '|参与成员:' + (p.participants || []).join('、') },
4121
+ ] })
4122
+ // 3
4123
+ const b3 = []
4124
+ const solvers = active.filter((id) => String(((p.parts || {})[id] || {}).solution || '').trim())
4125
+ if (solvers.length) {
4126
+ for (const id of solvers) {
4127
+ const part = (p.parts || {})[id] || {}
4128
+ b3.push({ h: id + '|' + String(part.title || '贡献') })
4129
+ b3.push({ p: String(part.solution) })
4130
+ }
4131
+ } else {
4132
+ b3.push({ p: '(参与成员未提交可引用的完整解法叙述;请以第 4 节的表决结论与证据索引为准。)' })
4133
+ }
4134
+ if (o.conclusionText) b3.push({ h: '结题记录(Problems/conclusion.md)' }, { p: String(o.conclusionText).slice(0, 4000) })
4135
+ S.push({ title: PAPER_SECTIONS[2], blocks: b3 })
4136
+ // 4
4137
+ const b4 = []
4138
+ if (ev.trueV.length) {
4139
+ b4.push({ p: '以下对象经 m 票布尔一致判定为**真**(证据:Verified 卡片 + 辩论录):' })
4140
+ b4.push({ ul: ev.trueV.map((k) => {
4141
+ const v = ev.V[k] || {}
4142
+ return '**' + k + '**(判为真;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';表决者 ' + ((v.voters || []).join('、') || '?') +
4143
+ ';证据:' + verdictCardRel(v.kind, k) + ';辩论:Shared/Debates/' + k + '.md;提出者:' + (v.proposer || '(office)') + ')'
4144
+ }) })
4145
+ } else b4.push({ p: '(没有经表决判定为真的命题。)' })
4146
+ if (ev.falseV.length) {
4147
+ b4.push({ p: '**已被否证(显式标注,不得当作结论使用)**:' })
4148
+ b4.push({ ul: ev.falseV.map((k) => {
4149
+ const v = ev.V[k] || {}
4150
+ return k + '(判为假;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';证据:' + verdictCardRel(v.kind, k) + ')'
4151
+ }) })
4152
+ }
4153
+ S.push({ title: PAPER_SECTIONS[3], blocks: b4 })
4154
+ // 5
4155
+ const b5 = []
4156
+ const subV = ev.trueV.filter((k) => (ev.V[k] || {}).kind === 'subproblem')
4157
+ if (subV.length) b5.push({ ul: subV.map((k) => '子问题 ' + k + '(已判定为真;证据:' + verdictCardRel('subproblem', k) + ')') })
4158
+ if (ev.done.length) b5.push({ ul: ev.done.map((t) => '任务 ' + t.id + ':' + String(t.subject || '') + '(owner=' + (t.ownerId || '?') + ';镜像:Shared/TaskBoard.md)') })
4159
+ if (!subV.length && !ev.done.length) b5.push({ p: '(没有已完成的子问题或任务被记录。)' })
4160
+ S.push({ title: PAPER_SECTIONS[4], blocks: b5 })
4161
+ // 6
4162
+ const b6 = []
4163
+ const methodParts = active.filter((id) => String(((p.parts || {})[id] || {}).methods || '').trim())
4164
+ for (const id of methodParts) b6.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).methods) })
4165
+ if (!methodParts.length) b6.push({ p: '(参与成员未提交方法/理论/思想/经验/数学理解的叙述。)' })
4166
+ S.push({ title: PAPER_SECTIONS[5], blocks: b6 })
4167
+ // 7
4168
+ const b7 = []
4169
+ const ruleParts = active.filter((id) => String(((p.parts || {})[id] || {}).rules || '').trim())
4170
+ for (const id of ruleParts) b7.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).rules) })
4171
+ if (!ruleParts.length) b7.push({ p: '(参与成员未归纳出可复用规律。)' })
4172
+ S.push({ title: PAPER_SECTIONS[6], blocks: b7 })
4173
+ // 8
4174
+ const b8 = []
4175
+ const limitParts = active.filter((id) => String(((p.parts || {})[id] || {}).limits || '').trim())
4176
+ for (const id of limitParts) b8.push({ h: id }, { p: String(((p.parts || {})[id] || {}).limits) })
4177
+ const unresolved = []
4178
+ for (const k of ev.undecidedV) unresolved.push('未定论:' + k + '(平均概率 ' + Number((ev.V[k] || {}).mean || 0).toFixed(2) + ',m=' + ((ev.V[k] || {}).m || '?') + ')')
4179
+ for (const k of ev.openV) unresolved.push('尚未裁决:' + k)
4180
+ for (const t of ev.openT) unresolved.push('未完成任务:' + t.id + '|' + String(t.subject || '') + '(' + t.status + ')')
4181
+ for (const m of ev.members.filter((x) => x.phase === 'failed' || x.phase === 'dismissed')) unresolved.push('成员 ' + m.id + ':' + m.phase + (m.error ? '(' + String(m.error).slice(0, 120) + ')' : ''))
4182
+ for (const id of skipped) unresolved.push('未交稿/未参与:' + id)
4183
+ if (unresolved.length) b8.push({ p: '**未决与未参与(显式标注)**:' }, { ul: unresolved })
4184
+ if ((p.warnings || []).length) b8.push({ p: '流程警告:' }, { ul: (p.warnings || []).map(String) })
4185
+ if (!limitParts.length && !unresolved.length && !(p.warnings || []).length) b8.push({ p: '(无特别记录的局限。)' })
4186
+ S.push({ title: PAPER_SECTIONS[7], blocks: b8 })
4187
+ // 9
4188
+ const b9 = [{ p: '以下路径均相对于研究所根目录(与 State/<institute>.v5state.json 同级)。' }]
4189
+ b9.push({ ul: paperSources(p, active, ev) })
4190
+ if ((p.disagreement || []).length) {
4191
+ b9.push({ h: '分歧记录(未达成全体一致 / 定稿代表要求修订)' })
4192
+ b9.push({ ul: (p.disagreement || []).map((d) => '第 ' + d.round + ' 轮|' + (d.members || []).join('、') + ':' + String(d.detail || '')) })
4193
+ }
4194
+ if (skipped.length) b9.push({ p: '按“未参与”处理的成员:' + skipped.join('、') })
4195
+ S.push({ title: PAPER_SECTIONS[8], blocks: b9 })
4196
+ return S
4197
+ }
4198
+ function renderPaperMd(p, S) {
4199
+ const L = ['# ' + paperTitle(p), '']
4200
+ S.forEach((sec, i) => {
4201
+ L.push('## ' + (i + 1) + '. ' + sec.title)
4202
+ L.push('')
4203
+ for (const b of sec.blocks) {
4204
+ if (b.h) { L.push('### ' + b.h); L.push('') }
4205
+ else if (b.p !== undefined) { L.push(String(b.p)); L.push('') }
4206
+ else if (b.ul) { for (const it of b.ul) L.push('- ' + String(it)); L.push('') }
4207
+ }
4208
+ })
4209
+ return L.join('\n')
4210
+ }
4211
+ const TEX_ESCAPES = { '\\': '\\textbackslash{}', '&': '\\&', '%': '\\%', '$': '\\$', '#': '\\#', '_': '\\_', '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}' }
4212
+ function texEscape(s) { return String(s == null ? '' : s).replace(/[\\&%$#_{}~^]/g, (c) => TEX_ESCAPES[c]) }
4213
+ function renderPaperTex(p, S) {
4214
+ const zh = p.lang !== 'en'
4215
+ const L = [
4216
+ '\\documentclass[11pt]{' + (zh ? 'ctexart' : 'article') + '}',
4217
+ '\\usepackage{amsmath,amssymb,amsthm}',
4218
+ '\\usepackage[margin=2.5cm]{geometry}',
4219
+ '\\usepackage[hidelinks]{hyperref}',
4220
+ '\\usepackage{longtable,booktabs}',
4221
+ '\\title{' + texEscape(paperTitle(p)) + '}',
4222
+ '\\author{' + texEscape(((p.participants || []).join('、')) || (zh ? '研究所' : 'Institute')) + '}',
4223
+ '\\date{' + texEscape(fmtTime(p.finalizedAt || now()).slice(0, 10)) + '}',
4224
+ '\\begin{document}',
4225
+ '\\maketitle',
4226
+ ]
4227
+ S.forEach((sec, i) => {
4228
+ L.push('\\section{' + texEscape((i + 1) + '. ' + sec.title) + '}')
4229
+ for (const b of sec.blocks) {
4230
+ if (b.h) L.push('\\subsection{' + texEscape(b.h) + '}')
4231
+ else if (b.p !== undefined) L.push(texEscape(b.p) + '\n')
4232
+ else if (b.ul) { L.push('\\begin{itemize}'); for (const it of b.ul) L.push('\\item ' + texEscape(it)); L.push('\\end{itemize}') }
4233
+ }
4234
+ })
4235
+ L.push('\\end{document}')
4236
+ return L.join('\n') + '\n'
4237
+ }
4238
+ // The repair pass keeps only the packages the text itself needs (docs/final-paper.md §8):
4239
+ // every optional or unknown package is dropped before the engine swap retry.
4240
+ function texStripOptionalPackages(tex) {
4241
+ return String(tex).split('\n').filter((line) => {
4242
+ if (/^\s*\\(?:input|include)\b/.test(line)) return false
4243
+ const m = /^\s*\\usepackage(?:\[[^\]]*\])?\{([^}]+)\}/.exec(line)
4244
+ if (!m) return true
4245
+ return m[1].split(',').map((x) => x.trim()).some((pk) => PAPER_REPAIR_CORE.indexOf(pk) !== -1)
4246
+ }).join('\n')
4247
+ }
4248
+ function texMinimal(tex, lang) {
4249
+ const body = String(tex).replace(/[\s\S]*?\\begin\{document\}/, '').replace(/\\end\{document\}[\s\S]*$/, '')
4250
+ return '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}\n\\begin{document}\n' + body.trim() + '\n\\end{document}\n'
4251
+ }
4252
+ function paperEngineCandidates(lang) {
4253
+ const order = PAPER_ENGINE_ORDER[lang === 'en' ? 'en' : 'zh'].slice()
4254
+ const forced = String(params.paperLatexCommand || '').trim()
4255
+ if (!forced) return order
4256
+ return [forced].concat(order.filter((n) => n !== forced))
4257
+ }
4258
+ async function latexEngines(lang) {
4259
+ const sub = subprocessOf()
4260
+ if (!sub || typeof sub.resolveExecutable !== 'function') {
4261
+ return { engines: [], reason: '宿主没有 subprocess 服务:无法检测或调用 LaTeX' }
4262
+ }
4263
+ const engines = []
4264
+ for (const name of paperEngineCandidates(lang)) {
4265
+ try { const exe = await sub.resolveExecutable(name); if (exe) engines.push({ name: String(name), exe: String(exe) }) } catch (e) { /* not installed */ }
4266
+ }
4267
+ return { engines, reason: engines.length ? '' : ('未检测到 LaTeX 引擎(' + paperEngineCandidates(lang).join('/') + ')') }
4268
+ }
4269
+ // ONE engine invocation. NEVER throws: every failure becomes a readable result (the Lean
4270
+ // seam's discipline) and a timeout really terminates the process.
4271
+ async function runPaperProcess(argv, cwd) {
4272
+ const sub = subprocessOf()
4273
+ const started = now()
4274
+ if (!sub || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, ms: 0, message: 'no subprocess service' }
4275
+ let handle
4276
+ try {
4277
+ handle = sub.spawn({ argv, cwd, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: 120000 })
4278
+ } catch (e) { return { ok: false, exitCode: null, ms: now() - started, message: String((e && e.message) || e) } }
4279
+ let outcome = null
4280
+ let timer = null
4281
+ try {
4282
+ const ran = Promise.resolve(handle.done).then((v) => ({ settled: true, value: v }), (e) => ({ settled: false, error: e }))
4283
+ const r = await Promise.race([
4284
+ ran,
4285
+ new Promise((resolve) => {
4286
+ timer = ctx.timeout(() => {
4287
+ try { if (typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* the race result is the report */ }
4288
+ resolve({ settled: true, value: { exitCode: null, signal: 'SIGTERM' } })
4289
+ }, 120000)
4290
+ }),
4291
+ ])
4292
+ if (!r.settled) return { ok: false, exitCode: null, ms: now() - started, message: String((r.error && r.error.message) || r.error) }
4293
+ outcome = r.value
4294
+ } finally { if (timer) { try { timer() } catch (e) { /* already settled */ } } }
4295
+ const exitCode = outcome ? outcome.exitCode : null
4296
+ return { ok: exitCode === 0, exitCode, ms: now() - started, message: '' }
4297
+ }
4298
+ // Compile attempts are CAPPED (docs/final-paper.md §8): full → nonstopmode rerun → engine swap with
4299
+ // the optional packages stripped → one minimal-template retry → report and degrade.
4300
+ async function compilePaperTex(id, tex, lang) {
4301
+ if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
4302
+ return { status: 'kept-existing', engine: '', reason: 'Paper/' + id + '/paper.pdf 已存在:不覆盖(docs/final-paper.md §5)', attempts: [] }
4303
+ }
4304
+ const det = await latexEngines(lang)
4305
+ if (!det.engines.length) return { status: 'not-detected', engine: '', reason: det.reason, attempts: [] }
4306
+ const e0 = det.engines[0], e1 = det.engines[1] || e0
4307
+ const plan = [
4308
+ { label: 'full', engine: e0, text: tex, nonstopOnly: false },
4309
+ { label: 'nonstopmode-rerun', engine: e0, text: tex, nonstopOnly: true },
4310
+ { label: 'engine-swap+stripped', engine: e1, text: texStripOptionalPackages(tex), nonstopOnly: true },
4311
+ { label: 'minimal-template', engine: e0, text: texMinimal(tex, lang), nonstopOnly: true },
4312
+ ]
4313
+ const attempts = []
4314
+ for (const a of plan) {
4315
+ const r = await runLatexAttempt(id, a)
4316
+ attempts.push(r)
4317
+ if (r.ok) return { status: 'compiled', engine: a.engine.name, attempts }
4318
+ }
4319
+ return { status: 'failed', engine: '', reason: '编译在 ' + attempts.length + ' 次尝试后仍失败(已保留 paper.tex 与 paper.md)', attempts }
4320
+ }
4321
+ async function runLatexAttempt(id, a) {
4322
+ const dirRel = paperDirRel(id)
4323
+ const dirAbs = instRoot() + '/' + dirRel
4324
+ if (!await writeTextRel(dirRel + '/paper.tex', a.text)) {
4325
+ return { label: a.label, engine: a.engine.name, ok: false, exitCode: null, ms: 0, message: 'tex 写入失败' }
4326
+ }
4327
+ const started = now()
4328
+ const args = a.engine.name === 'tectonic'
4329
+ ? ['-X', 'paper.tex']
4330
+ : (a.nonstopOnly ? ['-interaction=nonstopmode', 'paper.tex'] : ['-interaction=nonstopmode', '-halt-on-error', 'paper.tex'])
4331
+ const argv = [a.engine.exe].concat(args)
4332
+ // TWO passes (docs/final-paper.md §8): the second resolves references/TOC. A failed first pass is not
4333
+ // rerun — the retry PLAN is what varies the command, not a blind repeat.
4334
+ const first = await runPaperProcess(argv, dirAbs)
4335
+ if (!first.ok) return { label: a.label, engine: a.engine.name, ok: false, exitCode: first.exitCode, ms: now() - started, message: first.message || 'first pass failed' }
4336
+ const second = await runPaperProcess(argv, dirAbs)
4337
+ const pdf = await fileExistsAbs(paperAbs(id, 'paper.pdf'))
4338
+ return { label: a.label, engine: a.engine.name, ok: second.ok && pdf, exitCode: second.exitCode, ms: now() - started, message: pdf ? (second.message || '') : '编译器没有产出 paper.pdf' }
4339
+ }
4340
+ function paperMeta(p, extra) {
4341
+ const o = extra || {}
4342
+ const active = paperActiveParticipants(p)
4343
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
4344
+ return {
4345
+ id: p.id,
4346
+ dir: 'Paper/' + p.id + '/',
4347
+ institute: instituteName, project, runId: inst().runId || runId || '',
4348
+ problemId: (inst().problem || {}).id || '',
4349
+ trigger: p.trigger,
4350
+ finalizedAt: o.finalizedAt || (p.finalizedAt || now()),
4351
+ language: p.lang, format: p.format, compilePdf: p.pdf === true,
4352
+ editor: p.editor, editorNote: p.editorNote || '', editorFallback: !!p.editorFallback,
4353
+ finalisation: p.final || null,
4354
+ consultation: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
4355
+ participants: p.participants || [], skipped,
4356
+ rounds: p.round, maxRounds: PAPER_MAX_ROUNDS,
4357
+ parts: Object.keys(p.parts || {}).map((id) => ({ member: id, round: (p.parts[id] || {}).round, title: (p.parts[id] || {}).title || '', evidence: ((p.parts[id] || {}).evidence || []).length })),
4358
+ reviews: Object.keys(p.reviews || {}).map((id) => ({ member: id, of: (p.reviews[id] || {}).of, round: (p.reviews[id] || {}).round, deliverable: (p.reviews[id] || {}).deliverable === true })),
4359
+ disagreement: p.disagreement || [], warnings: o.warnings || p.warnings || [],
4360
+ forcedAfterCap: !!p.forcedAfterCap,
4361
+ sections: PAPER_SECTIONS.length,
4362
+ compile: o.compile || { status: p.compile || 'unknown', engine: p.engine || '', attempts: [] },
4363
+ params: {
4364
+ finalPaper: params.finalPaper, paperFormat: params.paperFormat, paperLanguage: params.paperLanguage,
4365
+ paperCompilePdf: params.paperCompilePdf, paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
4366
+ },
4367
+ files: o.files || [],
4368
+ }
4369
+ }
4370
+ async function refillPaperArtifacts(p) {
4371
+ const id = p.id
4372
+ const active = paperActiveParticipants(p)
4373
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
4374
+ const conclusionText = await readTextRel('Problems/conclusion.md')
4375
+ const S = paperSections(p, active, skipped, paperEvidence(), { final: p.final, conclusionText })
4376
+ const filled = []
4377
+ if (p.format !== 'tex' && (await readTextAbs(paperAbs(id, 'paper.md'))) === undefined) {
4378
+ if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) filled.push('paper.md')
4379
+ }
4380
+ let tex = await readTextAbs(paperAbs(id, 'paper.tex'))
4381
+ if (p.format !== 'md' && tex === undefined) {
4382
+ tex = renderPaperTex(p, S)
4383
+ if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) filled.push('paper.tex')
4384
+ }
4385
+ if (p.format !== 'md' && p.pdf === true && !await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
4386
+ const c = await compilePaperTex(id, tex || renderPaperTex(p, S), p.lang)
4387
+ if (c.status === 'compiled') filled.push('paper.pdf')
4388
+ // Same rule as finalizePaper: the delivered tex is always the canonical generated one.
4389
+ if (tex !== undefined && tex !== null) await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
4390
+ }
4391
+ if ((await readTextAbs(paperAbs(id, 'paper.meta.json'))) === undefined) {
4392
+ if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(paperMeta(p, { refilled: true }), null, 2) + '\n')) filled.push('paper.meta.json')
4393
+ }
4394
+ if ((await readTextAbs(paperAbs(id, 'paper.log.md'))) === undefined) {
4395
+ await paperLog('重复触发:补写缺失产物', '- 本次补写:' + (filled.join('、') || '(无缺失)'))
4396
+ filled.push('paper.log.md')
4397
+ } else if (filled.length) {
4398
+ await paperLog('重复触发:补写缺失产物', '- 本次补写:' + filled.join('、'))
4399
+ }
4400
+ return { filled }
4401
+ }
4402
+ async function finalizePaper(reason) {
4403
+ const p = paper()
4404
+ if (!p) return { ok: false, code: 'V5_PAPER_STATE', message: 'no paper flow' }
4405
+ const id = p.id
4406
+ const active = paperActiveParticipants(p)
4407
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
4408
+ const ev = paperEvidence()
4409
+ const conclusionText = await readTextRel('Problems/conclusion.md')
4410
+ const finalizedAt = now()
4411
+ const S = paperSections(p, active, skipped, ev, { final: p.final, conclusionText })
4412
+ const files = []
4413
+ if (p.format !== 'tex') {
4414
+ if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) files.push('paper.md')
4415
+ }
4416
+ let tex = ''
4417
+ if (p.format !== 'md') {
4418
+ tex = renderPaperTex(p, S)
4419
+ if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) files.push('paper.tex')
4420
+ }
4421
+ // `paperFormat=md` with `paperCompilePdf=true` must NOT warn about a missing tex: no tex
4422
+ // was produced, so compilation is skipped silently (docs/final-paper.md §8).
4423
+ let compile
4424
+ if (p.format === 'md') compile = { status: 'skipped', engine: '', reason: 'paperFormat=md:未产出 tex,跳过编译', attempts: [] }
4425
+ else if (p.pdf !== true) compile = { status: 'skipped', engine: '', reason: 'paperCompilePdf=false', attempts: [] }
4426
+ else compile = await compilePaperTex(id, tex, p.lang)
4427
+ // The repair attempts overwrite `paper.tex` with stripped/minimal variants to get past a
4428
+ // broken engine. Whatever the compile outcome, the DELIVERED tex must be the real,
4429
+ // fully-generated one (the attempts are recorded in the meta either way).
4430
+ if (p.format !== 'md') await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
4431
+ if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) files.push('paper.pdf')
4432
+ const warnings = (p.warnings || []).slice()
4433
+ 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(';'))
4434
+ if (compile.status === 'not-detected') warnings.push('未检测到 LaTeX 引擎(' + (compile.reason || '') + '):只交付 paper.tex 与 paper.md。')
4435
+ const meta = paperMeta(Object.assign({}, p, { finalizedAt, compile: compile.status, engine: compile.engine || '' }), {
4436
+ finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', attempts: compile.attempts || [] }, files,
4437
+ })
4438
+ if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')) files.push('paper.meta.json')
4439
+ await paperLog('定稿(' + reason + ')', [
4440
+ '- 目录:Paper/' + id + '/',
4441
+ '- 产物:' + files.join('、'),
4442
+ '- 编译:' + compile.status + (compile.engine ? '(' + compile.engine + ')' : '') + '|尝试 ' + (compile.attempts || []).length + ' 次',
4443
+ '- 定稿代表:' + p.editor + '|轮次:' + p.round + '/' + PAPER_MAX_ROUNDS,
4444
+ '- 所办交流:消息 ' + Number((p.consult || {}).messages || 0) + ' 条、会议 ' + Number((p.consult || {}).meetings || 0) + ' 次',
4445
+ (p.final && p.final.note ? '- 定稿说明:' + p.final.note : ''),
4446
+ (warnings.length ? '- 警告:\n - ' + warnings.join('\n - ') : ''),
4447
+ ].filter(Boolean).join('\n'))
4448
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4449
+ status: 'finalized', stage: 'finalized', finalizedAt, compile: compile.status,
4450
+ engine: compile.engine || '', warnings, artifacts: files, updatedAt: now(),
4451
+ }) : cur))
4452
+ await saveChatLine('【论文】最终论文已定稿:Paper/' + id + '/(' + (files.join('、') || '(无产物)') + ';编译 ' + compile.status + ')。')
4453
+ notifyActivity()
4454
+ // The run is only now complete (docs/final-paper.md §3): the paper phase ran BEFORE the flags.
4455
+ if (p.completesRun && !autoDone) await finishRun('全体有表决权者一致认为原问题已解决')
4456
+ return {
4457
+ ok: true, finalized: true, id, dir: 'Paper/' + id + '/', files,
4458
+ compile: compile.status, engine: compile.engine || '', attempts: (compile.attempts || []).length,
4459
+ warnings, agreement: !(p.disagreement || []).length, rounds: p.round,
4460
+ }
4461
+ }
4462
+
3199
4463
  // ---- hire / fire -------------------------------------------------------
3200
4464
  function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
3201
4465
  // ANY academician or permanently-employed researcher may hire its own temp
@@ -3273,6 +4537,16 @@ export function apply(ctx) {
3273
4537
  roundsSinceCompact.delete(id)
3274
4538
  contextPct.delete(id)
3275
4539
  seeds.delete(id)
4540
+ // A dismissed member is no longer a voter: drop its ballot from every OPEN verdict, so
4541
+ // a verdict that has not closed yet can never be carried by a former member's vote
4542
+ // (audit H2). `judgeVerdict` reads only the live roster anyway; this keeps the durable
4543
+ // record honest too. Already-closed verdicts are history and are left untouched.
4544
+ const openVerdicts = Object.values(inst().verdicts).filter((v) => v && !v.closed && v.votes && v.votes[id] !== undefined)
4545
+ for (const v of openVerdicts) {
4546
+ const votes = Object.assign({}, v.votes)
4547
+ delete votes[id]
4548
+ await putVerdict(v.target, Object.assign({}, v, { votes }))
4549
+ }
3276
4550
  if (meeting) {
3277
4551
  delete meeting.inputs[id]
3278
4552
  delete meeting.extras[id]
@@ -3293,6 +4567,7 @@ export function apply(ctx) {
3293
4567
  return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
3294
4568
  }
3295
4569
  async function nudge(callerId, o) {
4570
+ if (!callerId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
3296
4571
  if (!isOffice(callerId) && !(isAcademician(callerId) && params.academicianLeads)) {
3297
4572
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can nudge members' }
3298
4573
  }
@@ -3372,9 +4647,13 @@ export function apply(ctx) {
3372
4647
  dbg.passes += 1
3373
4648
  clearHeartbeat()
3374
4649
  syncParamsFromState()
3375
- if (meeting) { await continueMeetingRound(); return }
4650
+ // A verification that is ALREADY in flight is served first: the two coordination
4651
+ // processes are mutually exclusive by construction, so the order only decides which
4652
+ // of two impossible states wins — but checking `meeting` first meant a meeting that
4653
+ // can never close starved the verification's own watchdog forever (audit M3).
3376
4654
  const vs = currentVerify()
3377
4655
  if (vs) { await continueVerifyRound(vs); return }
4656
+ if (meeting) { await continueMeetingRound(); return }
3378
4657
  if (!currentVerify()) {
3379
4658
  await armNextVerify()
3380
4659
  if (hasVerifyInFlight()) return
@@ -3385,6 +4664,16 @@ export function apply(ctx) {
3385
4664
  await beginMeeting({ agenda: p.agenda, kind: p.kind, target: p.target, by: p.by })
3386
4665
  return
3387
4666
  }
4667
+ // The final-paper phase (docs/final-paper.md §3) owns the room once it is active: a paper ask is its
4668
+ // own kind of turn, and a research round must not be mistaken for a paper answer. While
4669
+ // the flow waits for the OFFICE the institute keeps working normally — the required
4670
+ // consultation needs office messages and a real meeting to be deliverable.
4671
+ if (paperActive()) {
4672
+ await paperPass()
4673
+ const cur = paper()
4674
+ const waitingForOffice = !!cur && cur.status === 'awaiting-editor' && cur.editor === 'office'
4675
+ if (!waitingForOffice) return
4676
+ }
3388
4677
  const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
3389
4678
  const idleMs = posMs(params.activityTimeoutMs, 120000)
3390
4679
  let filled = 0
@@ -3435,7 +4724,7 @@ export function apply(ctx) {
3435
4724
  const stallMs = posMs(params.stallAutoMeetingMs, 360000)
3436
4725
  if (!meeting && !pendingMeeting && !hasVerifyInFlight() && phase === 'active' &&
3437
4726
  busy.size === 0 && (now() - lastProgressAt) >= stallMs) {
3438
- await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync' })
4727
+ await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync', auto: true })
3439
4728
  return
3440
4729
  }
3441
4730
  // (e) heartbeat: push the longest-idle member to make progress rather than just
@@ -3609,6 +4898,12 @@ export function apply(ctx) {
3609
4898
  }
3610
4899
  // (10) solve votes / personal judgement
3611
4900
  if (p.vote_solved !== undefined) await recordSolveVote(member.id, p.vote_solved === true)
4901
+ // (10b) final-paper channels: the member's own part, a cross-review, or the editor's
4902
+ // decision. Each is only honoured in the stage that asked for it (the record functions
4903
+ // refuse otherwise), so a stray/stale field can never advance the paper.
4904
+ if (p.paper_part && typeof p.paper_part === 'object') await paperRecordPart(member.id, p.paper_part)
4905
+ if (p.paper_review && typeof p.paper_review === 'object') await paperRecordReview(member.id, p.paper_review)
4906
+ if (p.paper_final && typeof p.paper_final === 'object') await paperRecordFinal(member.id, p.paper_final)
3612
4907
  // (11) meeting input collection (keyed on the LIVE member set, so a member who
3613
4908
  // joined mid-meeting still has to speak and a dismissed one stops blocking it)
3614
4909
  if (kind === 'meeting' && meeting) {
@@ -3664,6 +4959,9 @@ export function apply(ctx) {
3664
4959
  try { await maybeRealCompact(childId, member) } catch (e) { /* compaction is best-effort */ }
3665
4960
  wakeKind.delete(member.id)
3666
4961
  if (member.activeMeetingId) delete member.activeMeetingId
4962
+ // The final-paper phase is driven by member turns, exactly like a meeting: the reply was
4963
+ // just folded, so advance the paper BEFORE asking the (possibly inert) scheduler.
4964
+ try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after end: ' + String((e && e.message) || e)) }
3667
4965
  await scheduleNext()
3668
4966
  }
3669
4967
  // ---- toolFilter names the host may not register -------------------------
@@ -3726,11 +5024,15 @@ export function apply(ctx) {
3726
5024
  const ints = ['researcherCount', 'quorumCap', 'verdictMaxRounds', 'maxTempPerMember', 'maxTempTotal',
3727
5025
  'compactThreshold', 'compactAfterRounds', 'maxParallel', 'activityTimeoutMs', 'stallAutoMeetingMs',
3728
5026
  'chatDigestMs', 'chatDigestMax', 'meetingKeepEvery', 'leanTimeoutMs']
3729
- const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign']
3730
- const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand']
5027
+ const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign', 'finalPaper', 'paperCompilePdf']
5028
+ const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand',
5029
+ 'paperFormat', 'paperLanguage', 'paperEditor', 'paperLatexCommand']
3731
5030
  const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny', 'leanArgs']
3732
5031
  for (const k of ints) if (input[k] !== undefined) { const n = Math.floor(Number(input[k])); if (Number.isFinite(n)) out[k] = n }
3733
- for (const k of bools) if (input[k] !== undefined) out[k] = (input[k] === true || input[k] === 'true')
5032
+ // Explicit boolean coercion (docs/final-paper.md §2): the old `=== true || === 'true'` turned a
5033
+ // legitimate `1` / `'yes'` into FALSE and left junk values truthy-looking. An
5034
+ // unrecognised spelling is REJECTED (the default wins) rather than guessed.
5035
+ for (const k of bools) if (input[k] !== undefined) out[k] = coerceBool(input[k], false)
3734
5036
  for (const k of strs) if (input[k] !== undefined) out[k] = String(input[k])
3735
5037
  for (const k of arrs) {
3736
5038
  if (input[k] === undefined) continue
@@ -3744,6 +5046,12 @@ export function apply(ctx) {
3744
5046
  out.formalVerify = ['off', 'encourage', 'require'].indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'
3745
5047
  }
3746
5048
  if (out.leanCommand !== undefined && !String(out.leanCommand).trim()) out.leanCommand = 'lean'
5049
+ // ── final-paper enums (closed sets; an unknown value degrades to the documented
5050
+ // default instead of silently becoming an unreachable fourth mode) ────────────────
5051
+ if (out.paperFormat !== undefined) out.paperFormat = coercePaperEnum('paperFormat', out.paperFormat, ['both', 'md', 'tex'], 'both')
5052
+ if (out.paperLanguage !== undefined) out.paperLanguage = coercePaperEnum('paperLanguage', out.paperLanguage, ['zh', 'en'], 'zh')
5053
+ if (out.paperEditor !== undefined) out.paperEditor = coercePaperEnum('paperEditor', out.paperEditor, ['office', 'academician'], 'academician')
5054
+ if (out.paperLatexCommand !== undefined) out.paperLatexCommand = String(out.paperLatexCommand).trim()
3747
5055
  // Guard every duration against a negative/NaN value: such a value would make a
3748
5056
  // watchdog fire instantly and abandon all consensus (v4 §30-T41).
3749
5057
  for (const k of ['activityTimeoutMs', 'stallAutoMeetingMs', 'chatDigestMs', 'leanTimeoutMs']) {
@@ -3752,6 +5060,14 @@ export function apply(ctx) {
3752
5060
  if (out.leanTimeoutMs !== undefined) out.leanTimeoutMs = Math.max(1000, out.leanTimeoutMs)
3753
5061
  if (out.researcherCount !== undefined && out.researcherCount < 0) out.researcherCount = 0
3754
5062
  if (out.quorumCap !== undefined && out.quorumCap < 1) out.quorumCap = 1
5063
+ // NOT every non-positive number is harmless (audit L3). `compactThreshold <= 0` makes
5064
+ // EVERY round look over the threshold (a permanent compaction directive), so it falls back
5065
+ // to the default like a bad duration; `chatDigestMax < 1` would empty the digest bucket and
5066
+ // `verdictMaxRounds < 1` silently collapsed every debate to a single round, so both floor
5067
+ // at the smallest value that still means what the name says.
5068
+ if (out.compactThreshold !== undefined && !(out.compactThreshold > 0)) delete out.compactThreshold
5069
+ if (out.chatDigestMax !== undefined && out.chatDigestMax < 1) out.chatDigestMax = 1
5070
+ if (out.verdictMaxRounds !== undefined && out.verdictMaxRounds < 1) out.verdictMaxRounds = 1
3755
5071
  return out
3756
5072
  }
3757
5073
  async function setParams(input) {
@@ -3780,12 +5096,22 @@ export function apply(ctx) {
3780
5096
  chatDigestMax: params.chatDigestMax, meetingKeepEvery: params.meetingKeepEvery,
3781
5097
  formalVerify: params.formalVerify, leanCommand: params.leanCommand,
3782
5098
  leanArgs: params.leanArgs, leanTimeoutMs: params.leanTimeoutMs,
5099
+ // ── final paper ──────────────────────────────────────────────────────
5100
+ finalPaper: params.finalPaper, paperFormat: params.paperFormat,
5101
+ paperLanguage: params.paperLanguage, paperCompilePdf: params.paperCompilePdf,
5102
+ paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
3783
5103
  provider: params.provider, model: params.model,
3784
5104
  toolAllow: params.toolAllow, toolDeny: params.toolDeny,
3785
5105
  }
3786
5106
  }
3787
5107
  async function configure(args) {
3788
5108
  const a = args || {}
5109
+ // READ THE FILE FOR THE PATH THIS SESSION WAS ALREADY POINTED AT BEFORE ANY WORK.
5110
+ // configure ends in a whole-snapshot commit; without this load the commit is exactly
5111
+ // the write that destroyed a previously-run institute's state file (audit H1: the
5112
+ // session's first tool call — typically `vibe_v5_status` — latched the DEFAULT path,
5113
+ // after which `configure {institute:'alpha'}` never read alpha's file).
5114
+ await ready()
3789
5115
  // configure is the PRE-START setup tool. Switching project/institute while a run
3790
5116
  // is live would split its state across two trees: the members' libraries and
3791
5117
  // briefs point at the OLD root while every later write goes to the NEW one.
@@ -3801,14 +5127,25 @@ export function apply(ctx) {
3801
5127
  const np = patch.project !== undefined ? patch.project : project
3802
5128
  const ni = patch.institute !== undefined ? patch.institute : instituteName
3803
5129
  const nkey = np + '::' + ni
3804
- if (nkey !== key && !inst().members.length) {
3805
- key = nkey
3806
- project = np
3807
- instituteName = ni
3808
- patch.project = np
3809
- patch.institute = ni
3810
- } else if (nkey !== key) {
3811
- return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
5130
+ if (nkey !== key) {
5131
+ // The requested key lives in a DIFFERENT state file. Read that file first: a
5132
+ // previously-run institute must be adopted, never overwritten with an empty one.
5133
+ const stored = await instituteAt(np, ni, nkey)
5134
+ if (stored && Array.isArray(stored.members) && stored.members.length) {
5135
+ key = nkey
5136
+ project = np
5137
+ instituteName = ni
5138
+ patch.project = np
5139
+ patch.institute = ni
5140
+ } else if (!inst().members.length) {
5141
+ key = nkey
5142
+ project = np
5143
+ instituteName = ni
5144
+ patch.project = np
5145
+ patch.institute = ni
5146
+ } else {
5147
+ return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
5148
+ }
3812
5149
  }
3813
5150
  }
3814
5151
  patch.phase = 'idle'
@@ -3980,6 +5317,8 @@ export function apply(ctx) {
3980
5317
  if (m.childId) continue
3981
5318
  try {
3982
5319
  const seedText = (await readTextRel('Members/' + m.id + '/Progress/progress.md')) || ''
5320
+ // `mode='resume'` is what makes this a RESUME rather than an instruction: the prompt
5321
+ // prints the seed as "恢复说明" (your own log, restored), not as a task to execute.
3983
5322
  await spawnMember(m, seedText ? seedText.slice(-4000) : '(你的 Progress/ 还是空的——请先把当前状态补写进去。)', 'resume')
3984
5323
  respawned += 1
3985
5324
  } catch (e) {
@@ -3995,9 +5334,17 @@ export function apply(ctx) {
3995
5334
  autoDone = false
3996
5335
  phase = 'active'
3997
5336
  await patchInstitute({ phase: 'active', lastProgressAt: now() })
5337
+ // The mirrors must be refreshed on THIS path too (audit M9): resume is the one place
5338
+ // where members' phase/rounds/employer actually change under them, and a staffing table
5339
+ // that contradicts the authoritative roster is the opposite of a mirror's purpose.
5340
+ await writeRosterMirror()
5341
+ await writeTaskboardMirror()
3998
5342
  await saveChatLine('【恢复】研究所继续推进(重建成员 ' + respawned + ' 名)。')
3999
5343
  notifyActivity()
4000
5344
  await scheduleNext()
5345
+ // An interrupted FINAL PAPER resumes its own stage (the paper phase runs before the
5346
+ // completion flags, so a crash there leaves a live, resumable institute).
5347
+ if (paperActive()) { try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after resume: ' + String((e && e.message) || e)) } }
4001
5348
  return { ok: true, resumed: true, members: members.map((m) => m.id), respawned, running: true }
4002
5349
  }
4003
5350
  function setPause() {
@@ -4043,6 +5390,9 @@ export function apply(ctx) {
4043
5390
  institute: instituteName, project, key, phase,
4044
5391
  running, autoDone, runId: s.runId,
4045
5392
  backend: backend ? backend.kind : 'uninitialized',
5393
+ // Skipped/malformed events AND state-file load problems. Without this the two
5394
+ // failure modes that silently drop state were invisible in the operator's view.
5395
+ diagnostics: s.diagnostics || [],
4046
5396
  debug: Object.assign({ scheduling, reschedule }, dbg),
4047
5397
  quorum: { m: quorumM(), mode: params.quorumMode, voters: voters().map((m) => m.id), voterCount: voterCount() },
4048
5398
  members: s.members.map((m) => ({
@@ -4071,6 +5421,10 @@ export function apply(ctx) {
4071
5421
  blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
4072
5422
  todo: formalTodo(),
4073
5423
  },
5424
+ // The final-paper flow: stage, who wrote/reviewed what, the office-consultation
5425
+ // counters and the compile result. Without this an operator cannot tell whether the
5426
+ // run is waiting for the office, for a member, or for nobody.
5427
+ paper: paperSummary(),
4074
5428
  lastProgressAt, params: visibleParams(),
4075
5429
  }
4076
5430
  }
@@ -4121,6 +5475,11 @@ export function apply(ctx) {
4121
5475
  if (pendingMeeting) L.push('- 暂存会议:' + pendingMeeting.agenda)
4122
5476
  L.push('- 历史会议:' + s.meetings.length + ' 次|辩论录:' + s.debates.length + ' 份')
4123
5477
  L.push('- 解决票:' + (solveVotes.size ? Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、') : '(无)'))
5478
+ if ((s.diagnostics || []).length) {
5479
+ L.push('')
5480
+ L.push('## ⚠ 状态诊断(被跳过的事件 / 状态文件读取问题)')
5481
+ for (const d of s.diagnostics.slice(-10)) L.push('- ' + fmtTime(d.at) + '|' + d.type + '|' + d.error)
5482
+ }
4124
5483
  L.push('')
4125
5484
  L.push('## Lean 形式化')
4126
5485
  if (!formalOn()) L.push('- 未启用(`formalVerify` = off;可用 vibe_v5_set 切到 encourage / require)')
@@ -4132,6 +5491,21 @@ export function apply(ctx) {
4132
5491
  L.push('- 可复用库:VibeMath/Formal/{Lib,Proved}/(跨项目)|本所形式化:Formal/|归档证明:Verified/Lean/')
4133
5492
  }
4134
5493
  L.push('')
5494
+ L.push('## 最终论文')
5495
+ {
5496
+ const pp = paper()
5497
+ if (!pp) L.push('- 尚未开始(' + (params.finalPaper === false ? '自动已关闭(finalPaper=false),可用 /v5 paper 手动生成' : '收口时会自动开始;也可用 /v5 paper 手动开始') + ')')
5498
+ else {
5499
+ L.push('- 目录:Paper/' + pp.id + '/|状态:' + pp.status + '|阶段:' + pp.stage + '|轮次:' + pp.round + '/' + PAPER_MAX_ROUNDS)
5500
+ L.push('- 定稿代表:' + pp.editor + (pp.editorFallback ? '(原定代表不在册,已回退)' : '') + '|语言:' + pp.lang + '|格式:' + pp.format)
5501
+ L.push('- 交稿:' + (Object.keys(pp.parts || {}).join('、') || '(无)') + '|互审:' + (Object.keys(pp.reviews || {}).map((k) => k + '→' + ((pp.reviews[k] || {}).of || '?') + (pp.reviews[k] && pp.reviews[k].deliverable === true ? '(可交付)' : '(需修改)')).join('、') || '(无)'))
5502
+ L.push('- 所办交流:消息 ' + Number((pp.consult || {}).messages || 0) + ' 条|会议 ' + Number((pp.consult || {}).meetings || 0) + ' 次')
5503
+ if (pp.compile) L.push('- 编译:' + pp.compile + (pp.engine ? '(' + pp.engine + ')' : ''))
5504
+ if ((pp.warnings || []).length) L.push('- 警告:' + pp.warnings.join(';'))
5505
+ L.push('- 下一步:' + paperNextStep(pp))
5506
+ }
5507
+ }
5508
+ L.push('')
4135
5509
  L.push('## 文件位置')
4136
5510
  L.push('- 根目录:' + instRoot())
4137
5511
  L.push('- 已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
@@ -4198,8 +5572,10 @@ export function apply(ctx) {
4198
5572
  leanRunToolApi: async (relPath, timeoutMs) => await leanRunFile(relPath, timeoutMs),
4199
5573
  // consensus / meetings
4200
5574
  maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
5575
+ // final paper (docs/final-paper.md; the phase runs BEFORE finishRun)
5576
+ startPaper, paperStatus: paperSummary, finalizePaperByOffice,
4201
5577
  // authorization helpers (used by tool handlers)
4202
- memberIdOfAgent, isOffice, isAcademician, memberById, activeMembers,
5578
+ memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
4203
5579
  }
4204
5580
  }
4205
5581
 
@@ -4227,16 +5603,51 @@ export function apply(ctx) {
4227
5603
  }
4228
5604
  const S = { type: 'string' }, N = { type: 'number' }, I = { type: 'integer' }, B = { type: 'boolean' }
4229
5605
  const SA = { type: 'array', items: { type: 'string' } }
5606
+ /**
5607
+ * Resolve the caller of a WRITING tool through `officeCaller` and refuse when nobody can be
5608
+ * identified. `memberIdOfAgent` answers '' for a caller that is neither a member child nor the
5609
+ * session root itself (a dismissed member's stale child, a nested helper under the office root,
5610
+ * a synthetic context with no known member), and the office gates used to treat '' as the office
5611
+ * (`isOffice`), so such a caller could sign assignments/priorities/tasks as 'office' (audit L6
5612
+ * follow-up). A real member id and the provable session root ('office') still pass unchanged.
5613
+ */
5614
+ function withCaller(s, x, what, fn) {
5615
+ const caller = s.officeCaller(x)
5616
+ 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)' }
5617
+ return fn(caller)
5618
+ }
5619
+ /**
5620
+ * Resolve the caller of an OFFICE-ONLY control and refuse anything that is not the PROVABLE
5621
+ * session root. `officeCaller` answers 'office' for that root, a member id for a member child, and
5622
+ * '' for everyone else — an unidentifiable descendant of the root (a nested helper, a dismissed
5623
+ * member's stale child) or a context with no session. A member child and the office share ONE
5624
+ * institute session, so without this every lifecycle control (configure/start/resume/pause/stop/
5625
+ * set) could be driven by a member: it could stop the institute or rewrite quorum/params as the
5626
+ * office (audit L6 follow-up). ABSENCE of a caller is not the office either — that is exactly the
5627
+ * shape an unidentifiable caller has, and the tool wrapper already refuses an agent-less call
5628
+ * ('no session') before this point, so nothing here re-admits one.
5629
+ */
5630
+ function withOffice(s, x, what, fn) {
5631
+ const caller = s.officeCaller(x)
5632
+ if (caller !== 'office') {
5633
+ return {
5634
+ ok: false, code: 'V5_NOT_OFFICE',
5635
+ message: 'only the office (the PROVABLE session root) may ' + what +
5636
+ (caller ? '; the caller is member ' + caller : '; no caller could be identified'),
5637
+ }
5638
+ }
5639
+ return fn()
5640
+ }
4230
5641
 
4231
5642
  // ── office / host controls ────────────────────────────────────────────────
4232
- registerTool('vibe_v5_configure', 'Create/configure the research institute (project, institute name, problem, params) WITHOUT starting it. Use this FIRST, then vibe_v5_start.', objParams({ project: S, institute: S, problem: S, params: { type: 'object' } }), (s, a) => s.configure(a))
4233
- registerTool('vibe_v5_start', 'Found the institute: create the academician + N permanent researchers and begin. They brainstorm independently, then self-organize (the academician organizes and assigns; the framework only facilitates).', objParams({ problem: S, researcherCount: I, academician: B, params: { type: 'object' }, seedDirections: SA }), (s, a) => s.doStart(a))
4234
- registerTool('vibe_v5_resume', 'Resume a persisted institute: reconcile members against their durable sessions, rebuild any missing one from its Progress/, refresh consensus watchdogs, and restart scheduling.', objParams({}), (s) => s.resume())
4235
- registerTool('vibe_v5_pause', 'Pause the institute (in-flight turns finish; no new wakes until resume).', objParams({}), (s) => s.setPause())
4236
- registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s) => s.initStop())
5643
+ registerTool('vibe_v5_configure', 'Create/configure the research institute (project, institute name, problem, params) WITHOUT starting it. Use this FIRST, then vibe_v5_start.', objParams({ project: S, institute: S, problem: S, params: { type: 'object' } }), (s, a, x) => withOffice(s, x, 'configure the institute', () => s.configure(a)))
5644
+ registerTool('vibe_v5_start', 'Found the institute: create the academician + N permanent researchers and begin. They brainstorm independently, then self-organize (the academician organizes and assigns; the framework only facilitates).', objParams({ problem: S, researcherCount: I, academician: B, params: { type: 'object' }, seedDirections: SA }), (s, a, x) => withOffice(s, x, 'found the institute', () => s.doStart(a)))
5645
+ registerTool('vibe_v5_resume', 'Resume a persisted institute: reconcile members against their durable sessions, rebuild any missing one from its Progress/, refresh consensus watchdogs, and restart scheduling.', objParams({}), (s, a, x) => withOffice(s, x, 'resume the institute', () => s.resume()))
5646
+ registerTool('vibe_v5_pause', 'Pause the institute (in-flight turns finish; no new wakes until resume).', objParams({}), (s, a, x) => withOffice(s, x, 'pause the institute', () => s.setPause()))
5647
+ registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s, a, x) => withOffice(s, x, 'stop the institute', () => s.initStop()))
4237
5648
  registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
4238
5649
  registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
4239
- registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record).', objParams({
5650
+ registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); an m-vote passes only when at least m Boolean votes (exactly 1 or exactly 0) exist AND no voter returns an opposing Boolean, so with the default roster it degenerates to unanimity among the current voters; only current voters count (a dismissed member\'s earlier ballot is dropped). quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record). FINAL PAPER: finalPaper (default true) writes the final paper when the run concludes — the paper phase runs BEFORE the run is marked complete, the permanent staff write their own part, cross-review each other, and the editor named by paperEditor finalises; paperFormat "both"|"md"|"tex"; paperLanguage "zh"|"en"; paperCompilePdf compiles a PDF when a LaTeX engine is detected; paperEditor "academician" (default, the only editor an unattended run can reach) | "office" (manual /v5 paper only — the office must first consult the whole institute: >=1 office message AND >=1 meeting, recorded in the finalisation note); paperLatexCommand forces one engine command instead of auto-detection (empty = auto: xelatex -> latexmk -> pdflatex -> lualatex -> tectonic, English prefers pdflatex). Unknown spellings of these enums fall back to the documented default.', objParams({
4240
5651
  academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
4241
5652
  quorumCap: I, quorumMode: S, verdictMaxRounds: I,
4242
5653
  maxTempPerMember: I, maxTempTotal: I,
@@ -4244,21 +5655,63 @@ export function apply(ctx) {
4244
5655
  activityTimeoutMs: I, stallAutoMeetingMs: I, chatDigestMs: I, chatDigestMax: I, meetingKeepEvery: I,
4245
5656
  formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] },
4246
5657
  leanCommand: S, leanArgs: SA, leanTimeoutMs: I,
5658
+ finalPaper: B, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] },
5659
+ paperLanguage: { type: 'string', enum: ['zh', 'en'] },
5660
+ paperCompilePdf: B, paperEditor: { type: 'string', enum: ['office', 'academician'] }, paperLatexCommand: S,
4247
5661
  provider: S, model: S, staffPersona: S, toolAllow: SA, toolDeny: SA, tempToolAllow: SA, tempToolDeny: SA,
4248
- }), (s, a) => s.setParams(a))
4249
- registerTool('vibe_v5_message', 'Relay a message from the office/human into the institute (to a member id, to "all", or to "voters").', objParams({ to: S, content: S }, ['to', 'content']), (s, a) => {
5662
+ }), (s, a, x) => withOffice(s, x, 'tune institute parameters', () => s.setParams(a)))
5663
+ registerTool('vibe_v5_message', 'Relay a message from the office/human into the institute (to a member id, to "all", or to "voters").', objParams({ to: S, content: S }, ['to', 'content']), (s, a, x) => {
5664
+ // The relay is SIGNED as the office (`say('office', …)`), so only the PROVABLE session root may
5665
+ // send it. `memberIdOfAgent` answers '' for an unidentifiable descendant of the root (a nested
5666
+ // helper, a dismissed member's stale child) and for a synthetic context, and a member child is
5667
+ // not the office either — without this check ANY caller's text was delivered as the office
5668
+ // (audit L6 follow-up; the tool's own description says "from the office/human").
5669
+ const caller = s.officeCaller(x)
5670
+ 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)' }
5671
+ 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' }
4250
5672
  const to = String(a.to || 'all')
4251
5673
  return s.say('office', { to, text: String(a.content), kind: to === 'all' || to === 'voters' ? 'office' : 'dm' })
4252
5674
  })
4253
- 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.', objParams({ agenda: S, kind: { type: 'string', enum: ['sync', 'division', 'verify-request', 'solve-vote'] }, target: S }, ['agenda']), (s, a, x) => s.startMeeting(s.memberIdOfAgent(x) || 'office', a))
5675
+ 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) => {
5676
+ const caller = s.officeCaller(x)
5677
+ 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' }
5678
+ return s.startMeeting(caller, a)
5679
+ })
4254
5680
  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 }))
4255
- 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.', objParams({ purpose: S, initial_task: S, direction: S, term: S, to: S }, ['purpose', 'initial_task']), (s, a, x) => {
4256
- const caller = a.to ? String(a.to) : (s.isOffice(s.memberIdOfAgent(x)) ? 'office' : s.memberIdOfAgent(x))
5681
+ 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) => {
5682
+ const caller = a.to ? String(a.to) : s.officeCaller(x)
5683
+ 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' }
4257
5684
  return s.hire(caller, a)
4258
5685
  })
4259
- 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) => s.fire(s.memberIdOfAgent(x) || 'office', a))
4260
- registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => s.addResearcher(s.memberIdOfAgent(x) || 'office', a && a.direction))
4261
- registerTool('vibe_v5_remove_researcher', 'Office only: dismiss a PERMANENT researcher.', objParams({ id: S }, ['id']), (s, a) => s.removeResearcher(a.id))
5686
+ 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) => {
5687
+ const caller = s.officeCaller(x)
5688
+ 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' }
5689
+ return s.fire(caller, a)
5690
+ })
5691
+ registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => {
5692
+ const caller = s.officeCaller(x)
5693
+ 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)' }
5694
+ return s.addResearcher(caller, a && a.direction)
5695
+ })
5696
+ // "Office only" (the tool's own description) must be ENFORCED, not just documented: the handler
5697
+ // used to call `removeResearcher` with no caller at all, so any member child — or an
5698
+ // unidentifiable descendant of the root — could dismiss a PERMANENT researcher (audit L6
5699
+ // follow-up; the academician can only PROPOSE additions/removals).
5700
+ registerTool('vibe_v5_remove_researcher', 'Office only: dismiss a PERMANENT researcher.', objParams({ id: S }, ['id']), (s, a, x) => withCaller(s, x, 'removing a permanent researcher', (caller) => (
5701
+ s.isOffice(caller)
5702
+ ? s.removeResearcher(a.id)
5703
+ : { ok: false, code: 'V5_NOT_OFFICE', message: 'only the office (the session root) may dismiss a permanent researcher' }
5704
+ )))
5705
+ // ── final paper (docs/final-paper.md) ─────────────────────────────────────
5706
+ registerTool('vibe_v5_paper', 'Final paper: start (or re-run) the team-authored paper for this run. The permanent staff write their own part, cross-review another member\'s part, and the editor named by paperEditor finalises. lang/format override paperLanguage/paperFormat for THIS paper; editor overrides paperEditor for THIS paper only (office = the manual path, which parks the flow until the office consults the institute and calls vibe_v5_finalize_paper); force rewrites an already-finalised paper (idempotent otherwise: it only fills artifacts that are missing).', objParams({ lang: S, format: S, editor: { type: 'string', enum: ['office', 'academician'] }, force: B, reason: S }), (s, a, x) => withOffice(s, x, 'write the final paper', () => {
5707
+ const lang = a.lang === undefined ? undefined : String(a.lang)
5708
+ const format = a.format === undefined ? undefined : String(a.format)
5709
+ if (lang !== undefined && lang !== 'zh' && lang !== 'en') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'lang must be zh|en' }
5710
+ if (format !== undefined && ['both', 'md', 'tex'].indexOf(format) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'format must be both|md|tex' }
5711
+ if (a.editor !== undefined && ['office', 'academician'].indexOf(String(a.editor)) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'editor must be office|academician' }
5712
+ return s.startPaper(a.reason === 'run-complete' ? 'run-complete' : 'manual', { lang, format, editor: a.editor, force: a.force === true, reason: a.reason })
5713
+ }))
5714
+ registerTool('vibe_v5_finalize_paper', 'Office: finalise the final paper after consulting the whole institute. Required when paperEditor="office": at least one office message (vibe_v5_message) AND at least one meeting convened by the office must happen first (paper.meta.json records them, and the note must state the conclusion). decision="revise" asks for another writing round (bounded).', objParams({ decision: { type: 'string', enum: ['deliverable', 'revise'] }, note: S, conclusion: S, force: B }, ['decision']), (s, a, x) => withOffice(s, x, 'finalise the final paper', () => s.finalizePaperByOffice(a)))
4262
5715
 
4263
5716
  // ── member-facing controls ────────────────────────────────────────────────
4264
5717
  registerTool('vibe_v5_say', '(member) Speak in the group chat (omit "to"), send a private message ("to":"r-2"), or address only the voters ("to":"voters").', objParams({ text: S, to: S }, ['text']), (s, a, x) => {
@@ -4282,12 +5735,12 @@ export function apply(ctx) {
4282
5735
  registerTool('vibe_v5_record_method', '(member) Record a theory/method/tool in your library. REQUIRES value, motive and p.', objParams({ id: S, title: S, type: S, content: S, notation: S, value: N, motive: S, p: N }, ['content', 'value', 'motive', 'p']), (s, a, x) => s.recordCard(s.memberIdOfAgent(x), 'method', a))
4283
5736
  registerTool('vibe_v5_record_subproblem', '(member) Record a sub-problem in your library. REQUIRES value, motive and p.', objParams({ id: S, title: S, statement: S, value: N, motive: S, p: N }, ['statement', 'value', 'motive', 'p']), (s, a, x) => s.recordCard(s.memberIdOfAgent(x), 'subproblem', a))
4284
5737
  registerTool('vibe_v5_read_library', '(member) Read anyone\'s library (read-only): their progress and recorded cards. Omit member to read everyone.', objParams({ member: S, kind: S, id: S }), (s, a) => s.readLibrary(a))
4285
- registerTool('vibe_v5_propose_verify', '(member) Propose an object for consensus verification. Any member may propose; only voting members decide.', objParams({ target: S, kind: S, reason: S }, ['target']), (s, a, x) => s.maybeQueueVerify(a.target, a.kind, s.memberIdOfAgent(x), a.reason))
5738
+ registerTool('vibe_v5_propose_verify', '(member) Propose an object for consensus verification. Any member may propose; only voting members decide.', objParams({ target: S, kind: S, reason: S }, ['target']), (s, a, x) => withCaller(s, x, 'a verification proposal', (caller) => s.maybeQueueVerify(a.target, a.kind, caller, a.reason)))
4286
5739
  registerTool('vibe_v5_verdict', '(member) Cast your boolean verdict on the object under verification. verdict is [0,1]: exactly 1 = assert true, exactly 0 = assert false, anything in between = abstention (not counted toward m, counted in the mean).', objParams({ target: S, verdict: N, reason: S }, ['verdict']), (s, a, x) => s.castVerdict(s.memberIdOfAgent(x), a.target, a.verdict, a.reason))
4287
- registerTool('vibe_v5_task_create', '(member) Open a task on the shared board (subject, description, optional blockers, advisory write scopes, priority).', objParams({ subject: S, description: S, blocked_by: SA, write_scopes: SA, priority: I }, ['subject']), (s, a, x) => s.taskCreate(s.memberIdOfAgent(x), a))
5740
+ registerTool('vibe_v5_task_create', '(member) Open a task on the shared board (subject, description, optional blockers, advisory write scopes, priority).', objParams({ subject: S, description: S, blocked_by: SA, write_scopes: SA, priority: I }, ['subject']), (s, a, x) => withCaller(s, x, 'creating a task', (caller) => s.taskCreate(caller, a)))
4288
5741
  registerTool('vibe_v5_task_list', '(member) List shared tasks with readiness, owner, revision, blockers and write-scope warnings.', objParams({ status: S, owner: S, ready: B }), (s, a) => ({ ok: true, tasks: s.taskList(a) }))
4289
5742
  registerTool('vibe_v5_task_get', '(member) Read one task\'s latest value BEFORE changing it (the revision is the CAS precondition).', objParams({ task_id: S }, ['task_id']), (s, a) => ({ ok: true, task: s.getTask(a.task_id) }))
4290
- registerTool('vibe_v5_task_update', '(member) Compare-and-set a task action: claim|release|edit|set_dependencies|complete|reopen|reassign|delete. Pass expected_revision from task_get/task_list; a stale revision is refused.', objParams({ task_id: S, expected_revision: I, action: S, subject: S, description: S, blocked_by: SA, write_scopes: SA, owner: S }, ['task_id', 'expected_revision', 'action']), (s, a, x) => s.taskUpdate(s.memberIdOfAgent(x), a))
5743
+ registerTool('vibe_v5_task_update', '(member) Compare-and-set a task action: claim|release|edit|set_dependencies|complete|reopen|reassign|delete. Pass expected_revision from task_get/task_list; a stale revision is refused.', objParams({ task_id: S, expected_revision: I, action: S, subject: S, description: S, blocked_by: SA, write_scopes: SA, owner: S }, ['task_id', 'expected_revision', 'action']), (s, a, x) => withCaller(s, x, 'a task mutation', (caller) => s.taskUpdate(caller, a)))
4291
5744
 
4292
5745
  // ── the academician's organizational tools ────────────────────────────────
4293
5746
  registerTool('vibe_v5_overview', '(academician) Institute-wide view: roster, task board, every member\'s Progress tail, recent chat, and stall warnings. Use it instead of guessing.', objParams({}), async (s) => {
@@ -4329,17 +5782,17 @@ export function apply(ctx) {
4329
5782
  parts.push('- 距上次实质进展:' + Math.round(idleFor / 1000) + ' 秒')
4330
5783
  return { ok: true, overview: parts.join('\n') }
4331
5784
  })
4332
- 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) => s.taskAssign(s.memberIdOfAgent(x), a))
4333
- 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) => s.taskPrioritize(s.memberIdOfAgent(x), a))
4334
- 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) => s.nudge(s.memberIdOfAgent(x), a))
5785
+ 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)))
5786
+ 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)))
5787
+ 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)))
4335
5788
 
4336
5789
  // ── Lean formal verification (docs/formal-verification.md) ────────────────
4337
5790
  // These three tools are registered UNCONDITIONALLY: tool registration is static (a
4338
5791
  // dynamic registration would depend on a runtime knob and break the effect discipline),
4339
5792
  // while the MODE only decides whether the framework TELLS members about them. In 'off'
4340
5793
  // mode they still work if a human or agent calls them deliberately.
4341
- registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. 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, and a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => s.leanRunTool(s.memberIdOfAgent(x), a))
4342
- 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 (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. 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) => s.leanArchive(s.memberIdOfAgent(x), a))
5794
+ registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. 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, and a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
5795
+ 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 (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. 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)))
4343
5796
  registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
4344
5797
  const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
4345
5798
  const st = s.status()
@@ -4356,11 +5809,23 @@ export function apply(ctx) {
4356
5809
  // ── /v5 slash command ────────────────────────────────────────────────────
4357
5810
  ctx.effect(() => commands.register({
4358
5811
  name: 'v5', description: 'control the Vibe Math V5 research institute',
4359
- input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set]' },
5812
+ 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]]' },
4360
5813
  handler: async function (inv) {
4361
5814
  const s = getSession(inv && inv.agent)
4362
- if (!s) return { kind: 'success', text: JSON.stringify({ ok: false, error: 'no session' }) }
5815
+ if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
4363
5816
  await s.ready()
5817
+ // The `/v5` line is the OFFICE/human control surface (README: the institute-office row) and
5818
+ // EVERY subcommand below acts AS the office (`say('office', …)`, `hire('office', …)`,
5819
+ // `removeResearcher`, `setParams`, `initStop` …). It used to accept ANY caller whose session
5820
+ // maps to the institute — a member child maps to the same office session — so the caller is
5821
+ // resolved here too and refused unless it is the PROVABLE session root (audit L6 follow-up:
5822
+ // the command surface had no caller check at all). Read-only subcommands are gated with the
5823
+ // rest: a member reads state with vibe_v5_status / vibe_v5_report / vibe_v5_members.
5824
+ const caller = s.officeCaller(inv && inv.agent)
5825
+ if (caller !== 'office') {
5826
+ const refused = { ok: false, code: 'V5_NOT_OFFICE', message: 'the /v5 control line belongs to the office (the session root); members use the vibe_v5_* tools' }
5827
+ return { kind: 'error', text: JSON.stringify(refused, null, 2) }
5828
+ }
4364
5829
  const line = String(inv && inv.rawInput ? inv.rawInput : '').trim()
4365
5830
  const parts = line.split(/\s+/)
4366
5831
  const cmd = parts[0] || ''
@@ -4380,7 +5845,28 @@ export function apply(ctx) {
4380
5845
  else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
4381
5846
  else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
4382
5847
  else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
4383
- else if (cmd === 'set') {
5848
+ else if (cmd === 'paper') {
5849
+ // `/v5 paper [lang=en] [format=tex] [force] [editor=office|academician]`
5850
+ const o = {}
5851
+ let force = false
5852
+ let bad = ''
5853
+ for (const tok of rest) {
5854
+ if (tok === 'force') { force = true; continue }
5855
+ const eq = tok.indexOf('=')
5856
+ if (eq <= 0) { bad = 'unknown paper argument: ' + tok; break }
5857
+ const k = tok.slice(0, eq), v = tok.slice(eq + 1)
5858
+ if (k === 'lang' || k === 'format' || k === 'editor') o[k] = v
5859
+ else { bad = 'unknown paper option: ' + k + ' (use lang=zh|en format=both|md|tex editor=office|academician force)'; break }
5860
+ }
5861
+ if (!bad && o.lang !== undefined && o.lang !== 'zh' && o.lang !== 'en') bad = 'lang must be zh|en'
5862
+ if (!bad && o.format !== undefined && ['both', 'md', 'tex'].indexOf(o.format) === -1) bad = 'format must be both|md|tex'
5863
+ if (!bad && o.editor !== undefined && ['office', 'academician'].indexOf(o.editor) === -1) bad = 'editor must be office|academician'
5864
+ if (bad) return { kind: 'error', text: JSON.stringify({ ok: false, code: 'V5_INVALID_ARGUMENT', message: bad }, null, 2) }
5865
+ // `editor=office` is the manual path that uses the office as the finalising
5866
+ // representative (docs/final-paper.md §7 — never reachable from an automatic run). One-shot: it
5867
+ // does not rewrite the persisted `paperEditor`.
5868
+ r = await s.startPaper('manual', { lang: o.lang, format: o.format, editor: o.editor, force })
5869
+ } else if (cmd === 'set') {
4384
5870
  const upd = {}
4385
5871
  for (const tok of rest) {
4386
5872
  const eq = tok.indexOf('=')
@@ -4390,8 +5876,12 @@ export function apply(ctx) {
4390
5876
  upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
4391
5877
  }
4392
5878
  r = await s.setParams(upd)
4393
- } else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set' }
4394
- return { kind: 'success', text: JSON.stringify(r, null, 2) }
5879
+ } 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]' }
5880
+ // A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
5881
+ // CommandResult union distinguishes success from error, and returning 'success' made a rejected
5882
+ // invocation look identical to a successful one (same fix as v2/v3).
5883
+ const failed = r !== null && typeof r === 'object' && r.ok === false
5884
+ return { kind: failed ? 'error' : 'success', text: JSON.stringify(r, null, 2) }
4395
5885
  },
4396
5886
  }))
4397
5887
 
@@ -4481,6 +5971,32 @@ function slugify(s) {
4481
5971
  return t || ''
4482
5972
  }
4483
5973
 
5974
+ // Explicit parameter coercion (docs/final-paper.md §2). A boolean parameter spelled `'false'`, `0`, `'no'`
5975
+ // or `'off'` must be false — the old `=== true || === 'true'` silently treated `1`/`'yes'` as
5976
+ // false. An unrecognised spelling is REJECTED (the caller's default wins), never guessed.
5977
+ function coerceBool(v, def) {
5978
+ if (v === undefined) return def
5979
+ if (v === true || v === 1 || v === '1') return true
5980
+ if (v === false || v === 0 || v === '0') return false
5981
+ const t = String(v).trim().toLowerCase()
5982
+ if (['true', 'yes', 'on', 'y', 't'].indexOf(t) !== -1) return true
5983
+ if (['false', 'no', 'off', 'n', 'f', ''].indexOf(t) !== -1) return false
5984
+ return def
5985
+ }
5986
+
5987
+ // Enum coercion with legacy aliases (.v5 / older flat spellings) and a documented fallback.
5988
+ const ENUM_ALIASES = {
5989
+ paperFormat: { markdown: 'md', latex: 'tex', texonly: 'tex', all: 'both' },
5990
+ paperLanguage: { cn: 'zh', chinese: 'zh', 'zh-cn': 'zh', english: 'en', 'en-us': 'en' },
5991
+ paperEditor: { root: 'office', host: 'office', acad: 'academician', dean: 'academician' },
5992
+ }
5993
+ function coercePaperEnum(key, v, allowed, def) {
5994
+ const t = String(v == null ? '' : v).trim().toLowerCase()
5995
+ if (allowed.indexOf(t) !== -1) return t
5996
+ const alias = (ENUM_ALIASES[key] || {})[t]
5997
+ return alias !== undefined && allowed.indexOf(alias) !== -1 ? alias : def
5998
+ }
5999
+
4484
6000
  function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
4485
6001
 
4486
6002
  function parseReply(text) {