dsh-vibe-math 2.5.0 → 2.7.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 (59) hide show
  1. package/README.en.md +175 -369
  2. package/README.md +175 -370
  3. package/cordis.patch.yml +78 -22
  4. package/docs/AUDIT-CHECKLIST.md +3 -3
  5. package/docs/final-paper.md +172 -0
  6. package/docs/formal-verification.md +184 -29
  7. package/docs/math-computation.md +121 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
  9. package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
  10. package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
  11. package/docs/test-timing.md +25 -9
  12. package/installer.js +7 -4
  13. package/package.json +21 -3
  14. package/prompt-corpus-persona/persona-corpus.json +16 -16
  15. package/prompt-corpus-persona/persona-corpus.md +164 -52
  16. package/prompt-corpus-v2/formal-verify-v2.json +128 -92
  17. package/prompt-corpus-v2/formal-verify-v2.md +1574 -108
  18. package/prompt-corpus-v3/formal-verify-v3.json +137 -53
  19. package/prompt-corpus-v3/formal-verify-v3.md +1406 -100
  20. package/prompt-corpus-v4/formal-verify-v4.json +14 -14
  21. package/prompt-corpus-v4/formal-verify-v4.md +247 -14
  22. package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
  23. package/prompt-corpus-v5/prompt-corpus-v5.md +570 -3
  24. package/tests/audit-math-computation-contract.mjs +191 -0
  25. package/tests/audit-math-computation-parity.mjs +169 -0
  26. package/tests/audit-math-computation-sensitivity.mjs +231 -0
  27. package/tests/audit-persona-sensitivity.mjs +64 -11
  28. package/tests/audit-persona-surface.test.mjs +53 -16
  29. package/tests/audit-v5-integrity.mjs +14 -0
  30. package/tests/e2e-v5-round2.test.mjs +923 -3
  31. package/tests/formal-verify-v2.test.mjs +295 -6
  32. package/tests/formal-verify-v3.test.mjs +275 -4
  33. package/tests/formal-verify-v4.test.mjs +295 -6
  34. package/tests/formal-verify-v5.test.mjs +9 -1
  35. package/tests/helpers/math-computation-fake-seam.mjs +135 -0
  36. package/tests/math-computation-shared.test.mjs +504 -0
  37. package/tests/prompt-v5-integrity.test.mjs +14 -8
  38. package/tests/selfdrive-v5.mjs +55 -4
  39. package/vibe-math-v2/agent.cordis.yml +36 -14
  40. package/vibe-math-v2/math-computation.js +936 -0
  41. package/vibe-math-v2/math-engines.js +156 -0
  42. package/vibe-math-v2/vibe-math-v2.js +1653 -48
  43. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  44. package/vibe-math-v3/agent.cordis.yml +30 -12
  45. package/vibe-math-v3/math-computation.js +936 -0
  46. package/vibe-math-v3/math-engines.js +156 -0
  47. package/vibe-math-v3/vibe-math-v3.js +1623 -53
  48. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  49. package/vibe-math-v4/agent.cordis.yml +44 -8
  50. package/vibe-math-v4/math-computation.js +936 -0
  51. package/vibe-math-v4/math-engines.js +156 -0
  52. package/vibe-math-v4/vibe-math-v4.js +1592 -37
  53. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  54. package/vibe-math-v5/agent.cordis.yml +46 -10
  55. package/vibe-math-v5/math-computation.js +936 -0
  56. package/vibe-math-v5/math-engines.js +156 -0
  57. package/vibe-math-v5/vibe-math-v5.js +2233 -42
  58. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +153 -4
  59. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
@@ -52,6 +52,27 @@
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
+ // ── math_computation (docs/math-computation.md) ──────────────────────────────
56
+ // The tool CORE lives in a shared module installed byte-identically into all four presets
57
+ // (`_oneoff/mc-P1-ready/INTERFACE-FREEZE.md` §3/§4): this preset only wires it (import +
58
+ // params + one registration + prompt text). The module imports `./math-engines.js` itself.
59
+ import {
60
+ MATH_PARAM_NAMES,
61
+ MATH_PARAM_DEFAULTS,
62
+ MATH_CAPS,
63
+ MATH_SHELL_FALLBACK_MARK,
64
+ MATH_TOOL_DESCRIPTION,
65
+ MATH_PERSONA_TOOL_LINE,
66
+ MATH_RULE_LINES,
67
+ MATH_RULE_LINES_EN,
68
+ MATH_ARCHIVE_WORKFLOW_LINE,
69
+ MATH_ARCHIVE_WORKFLOW_LINE_EN,
70
+ normalizeMathParams,
71
+ probeMathEngines,
72
+ registerMathComputation,
73
+ mathAvailabilityLine,
74
+ } from './math-computation.js'
75
+
55
76
  const PROJECTION_VERSION = 1
56
77
  const EV = {
57
78
  institute: 'vibe5/institute',
@@ -68,6 +89,120 @@ const EV = {
68
89
  formal: 'vibe5/formal',
69
90
  }
70
91
 
92
+ // ── final paper (docs/final-paper.md) ─────────────────────────────────────────
93
+ // The run's LAST deliverable. The permanent staff each write their own part, cross-review
94
+ // each other's part, and the editor named by `paperEditor` finalises — but ONLY after the
95
+ // whole institute has agreed the parts are deliverable, and (when the office edits) only
96
+ // after the office has consulted the institute. The phase runs BEFORE the completion flags
97
+ // are flipped (docs/final-paper.md §3): after `phase='solved'` the machinery refuses to wake
98
+ // members (`wakeIfIdle`), to convene meetings (`startMeeting`) or to dispatch an end.
99
+ const PAPER_MAX_ROUNDS = 3
100
+ // Chinese first (docs/final-paper.md §8): xelatex covers CJK out of the box; English prefers pdflatex.
101
+ const PAPER_ENGINE_ORDER = {
102
+ zh: ['xelatex', 'latexmk', 'pdflatex', 'lualatex', 'tectonic'],
103
+ en: ['pdflatex', 'latexmk', 'lualatex', 'tectonic', 'xelatex'],
104
+ }
105
+ // The repair pass keeps only what the text itself needs; everything else (hyperref,
106
+ // longtable, booktabs, an unknown package) is dropped before the retry.
107
+ const PAPER_REPAIR_CORE = ['amsmath', 'amssymb', 'amsthm', 'geometry', 'ctex']
108
+ const PAPER_SECTIONS = [
109
+ '标题、作者、日期与摘要',
110
+ '引言与问题背景',
111
+ '原问题的完整解法',
112
+ '已检验通过的命题',
113
+ '已解决的子问题 / 中间成果',
114
+ '创造或发现的有价值之物:方法、理论、思想、经验与数学理解',
115
+ '规律总结',
116
+ '讨论、局限与展望',
117
+ '附录:证据与文件索引',
118
+ ]
119
+
120
+ // ── Lean async compile queue + search path (docs/formal-verification.md §1/§5/§7) ──────────
121
+ // The queue is a per-session FIFO driven by the existing heartbeat, so the constants and the
122
+ // pure helpers live at module scope. Concurrency is a CONSTANT, not a parameter: compiles and
123
+ // the model compete for the same CPU, and the parameter surface deliberately gains only
124
+ // `leanAsync` (the per-compile budget reuses `leanTimeoutMs`).
125
+ const LEAN_QUEUE_CONCURRENCY = 1
126
+ // ONE place for the flag spelling: a toolchain that does not know `--search-path` only needs
127
+ // this constant changed (never the interface).
128
+ const LEAN_SEARCH_PATH_FLAG = '--search-path'
129
+ const LEAN_READ_MAX_BYTES = 64 * 1024
130
+ // A user-supplied search root wins: never inject a second one (explicit override first).
131
+ function leanHasSearchFlag(args) {
132
+ return (Array.isArray(args) ? args : []).some((a) => {
133
+ const t = String(a)
134
+ return t === LEAN_SEARCH_PATH_FLAG || t.indexOf(LEAN_SEARCH_PATH_FLAG + '=') === 0 || t === '-R' || t === '--root'
135
+ })
136
+ }
137
+ // Content identity for queue dedupe: CRLF normalised, trailing blank lines dropped.
138
+ function leanHashText(s) {
139
+ return String(s == null ? '' : s).replace(/\r\n?/g, '\n').replace(/[\s\n]+$/, '')
140
+ }
141
+ const SHA256_K = [
142
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
143
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
144
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
145
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
146
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
147
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
148
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
149
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
150
+ ]
151
+ const sha256Rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0
152
+ // SHA-256 (FIPS 180-4), implemented here instead of importing `node:crypto`: this preset is a
153
+ // self-contained file row with ZERO imports, and the digest is only a content identity (queue
154
+ // dedupe + job id). `__testHelpers.sha256Hex` is checked against the published vectors AND
155
+ // against `node:crypto` in the tests, so correctness is proven rather than assumed.
156
+ function sha256Hex(input) {
157
+ const text = String(input == null ? '' : input)
158
+ const bytes = []
159
+ for (let i = 0; i < text.length; i++) {
160
+ const c = text.charCodeAt(i)
161
+ if (c < 0x80) bytes.push(c)
162
+ else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63))
163
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length && text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) {
164
+ const cp = 0x10000 + ((c - 0xd800) << 10) + (text.charCodeAt(i + 1) - 0xdc00)
165
+ i++
166
+ bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63))
167
+ } else bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63))
168
+ }
169
+ const bitLen = bytes.length * 8
170
+ bytes.push(0x80)
171
+ while (bytes.length % 64 !== 56) bytes.push(0)
172
+ const hi = Math.floor(bitLen / 0x100000000)
173
+ bytes.push((hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255)
174
+ bytes.push((bitLen >>> 24) & 255, (bitLen >>> 16) & 255, (bitLen >>> 8) & 255, bitLen & 255)
175
+ let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a
176
+ let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19
177
+ const w = new Array(64)
178
+ for (let off = 0; off < bytes.length; off += 64) {
179
+ for (let i = 0; i < 16; i++) {
180
+ const j = off + i * 4
181
+ w[i] = ((bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3]) >>> 0
182
+ }
183
+ for (let i = 16; i < 64; i++) {
184
+ const x = w[i - 15], y = w[i - 2]
185
+ const s0 = (sha256Rotr(x, 7) ^ sha256Rotr(x, 18) ^ (x >>> 3)) >>> 0
186
+ const s1 = (sha256Rotr(y, 17) ^ sha256Rotr(y, 19) ^ (y >>> 10)) >>> 0
187
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0
188
+ }
189
+ let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7
190
+ for (let i = 0; i < 64; i++) {
191
+ const S1 = (sha256Rotr(e, 6) ^ sha256Rotr(e, 11) ^ sha256Rotr(e, 25)) >>> 0
192
+ const ch = ((e & f) ^ (~e & g)) >>> 0
193
+ const t1 = (h + S1 + ch + SHA256_K[i] + w[i]) >>> 0
194
+ const S0 = (sha256Rotr(a, 2) ^ sha256Rotr(a, 13) ^ sha256Rotr(a, 22)) >>> 0
195
+ const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
196
+ const t2 = (S0 + maj) >>> 0
197
+ h = g; g = f; f = e; e = (d + t1) >>> 0
198
+ d = c; c = b; b = a; a = (t1 + t2) >>> 0
199
+ }
200
+ h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0
201
+ h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0
202
+ }
203
+ return [h0, h1, h2, h3, h4, h5, h6, h7].map((x) => ('00000000' + x.toString(16)).slice(-8)).join('')
204
+ }
205
+
71
206
  // Stable error codes (ported from DSH agent-teams' typed-error discipline).
72
207
  function v5err(code, message) {
73
208
  const e = new Error(message || code)
@@ -110,6 +245,15 @@ export function apply(ctx) {
110
245
  const tools = ctx.tools
111
246
  const commands = ctx.commands
112
247
 
248
+ // A plugin UNLOAD must not leave an orphan compiler behind (docs/formal-verification.md §7-6):
249
+ // every session registers its Lean disposer here, and this effect's disposer terminates what
250
+ // is in flight and marks it `interrupted` (never `passed`). Registered FIRST so a host that
251
+ // records effect disposers (the tests do) can reach it deterministically.
252
+ const leanDisposers = new Set()
253
+ ctx.effect(() => () => {
254
+ for (const d of Array.from(leanDisposers)) { try { d() } catch (e) { /* best effort */ } }
255
+ })
256
+
113
257
  // Optional services are resolved LAZILY at call time, never snapshotted in apply():
114
258
  // a `ctx.get()` snapshot taken here is order-sensitive, so a service provided later
115
259
  // would stay undefined for the whole session.
@@ -189,6 +333,9 @@ export function apply(ctx) {
189
333
  lastProgressAt: now(),
190
334
  artifactCount: 0,
191
335
  diagnostics: [],
336
+ // The final-paper flow (stage machine + its artifacts state). Durable like everything
337
+ // else, so a restart resumes the same stage instead of writing a second paper.
338
+ paper: null,
192
339
  }
193
340
  }
194
341
  function initState() { return { v: PROJECTION_VERSION, institutes: {}, order: [] } }
@@ -228,6 +375,16 @@ export function apply(ctx) {
228
375
  if (patch.runId !== undefined) n.runId = String(patch.runId)
229
376
  if (patch.lastProgressAt !== undefined) n.lastProgressAt = Number(patch.lastProgressAt) || 0
230
377
  if (patch.artifactCount !== undefined) n.artifactCount = Number(patch.artifactCount) || 0
378
+ // The final-paper state. A FUNCTION is a MUTATION applied inside the fold (the same
379
+ // trick the verify queue uses): the paper stage, its parts and its office-consultation
380
+ // counters are updated from several places (member replies, office messages, meetings),
381
+ // and a whole-object read-modify-write outside the fold would lose updates.
382
+ if (patch.paper !== undefined) {
383
+ const next = typeof patch.paper === 'function'
384
+ ? patch.paper(n.paper === undefined ? null : n.paper)
385
+ : patch.paper
386
+ if (next !== undefined) n.paper = next
387
+ }
231
388
  return n
232
389
  })
233
390
  }
@@ -510,6 +667,54 @@ export function apply(ctx) {
510
667
  leanCommand: 'lean',
511
668
  leanArgs: [],
512
669
  leanTimeoutMs: 120000,
670
+ // leanAsync: compile on a per-session background queue (default) instead of blocking the
671
+ // member's turn. `false` restores the previous synchronous `await` verbatim. Only
672
+ // `settled(ok)` (exit 0 AND the file's content hash unchanged) may mark an object
673
+ // `passed`; every other ending stays `attempted`.
674
+ leanAsync: true,
675
+ // leanInitiative: how EAGER the staff are about formalizing during normal work, kept
676
+ // separate from `formalVerify` (which only expresses the VERDICT-time requirement).
677
+ // 'off' = no daily drive (formalize only when verification asks); 'normal' (default) =
678
+ // today's behaviour (the reminder rides with formalVerify); 'eager' = push valuable small
679
+ // lemmas/propositions/definitions into the library even in daily rounds.
680
+ leanInitiative: 'normal',
681
+ // leanSearchPaths: extra `--search-path` roots injected BEFORE the automatic VibeMath
682
+ // root (deduped; an explicit --search-path/-R/--root in leanArgs wins).
683
+ leanSearchPaths: [],
684
+ // leanJobsMaxParallel: how many background compiles may run at once (default 1 = serial).
685
+ leanJobsMaxParallel: 1,
686
+ // ── math_computation (docs/math-computation.md; the shared module owns the semantics) ──
687
+ // mathComputation — 'off' | 'auto' (default) | 'on': whether the tool is available
688
+ // mathMode — 'typed+shell' (default: the host shell may be used as an unarchived
689
+ // fallback) | 'typed' (never mention the shell; engine='cli' refused)
690
+ // mathEngines — allowed engine names (cli is ON by default; drop it to disable)
691
+ // mathTimeoutMs — per-run budget (>=1000)
692
+ // mathPackages — packages/toolboxes a computation may require
693
+ // mathInstallScope — 'user' (default) | 'system' (per call only, never remembered)
694
+ // NOTE: the DEFAULTS come from the shared module so all four presets are byte-comparable;
695
+ // the arrays are COPIED (the module's defaults are frozen and must never be mutated).
696
+ mathComputation: MATH_PARAM_DEFAULTS.mathComputation,
697
+ mathMode: MATH_PARAM_DEFAULTS.mathMode,
698
+ mathEngines: MATH_PARAM_DEFAULTS.mathEngines.slice(),
699
+ mathTimeoutMs: MATH_PARAM_DEFAULTS.mathTimeoutMs,
700
+ mathPackages: MATH_PARAM_DEFAULTS.mathPackages.slice(),
701
+ mathInstallScope: MATH_PARAM_DEFAULTS.mathInstallScope,
702
+ // ── final paper (docs/final-paper.md; the phase runs BEFORE the completion flags) ──
703
+ // finalPaper — write the final paper when the run concludes (manual /v5 paper
704
+ // still works when this is false, and says so).
705
+ // paperFormat — 'both' | 'md' | 'tex'
706
+ // paperLanguage — 'zh' | 'en'
707
+ // paperCompilePdf — compile a PDF when a LaTeX engine is detected
708
+ // paperEditor — 'academician' (default: the only editor reachable unattended) |
709
+ // 'office' (manual /v5 paper only: the office must consult the
710
+ // institute first — messages + at least one meeting)
711
+ // paperLatexCommand — force ONE engine command instead of auto-detection ('' = auto)
712
+ finalPaper: true,
713
+ paperFormat: 'both',
714
+ paperLanguage: 'zh',
715
+ paperCompilePdf: true,
716
+ paperEditor: 'academician',
717
+ paperLatexCommand: '',
513
718
  // ── model / tools ────────────────────────────────────────────────────
514
719
  provider: '',
515
720
  model: '',
@@ -604,7 +809,17 @@ export function apply(ctx) {
604
809
  // i.e. the fallback would silently lose the whole institute across a restart.
605
810
  async function ready() {
606
811
  if (!backend) installBackend()
607
- return await awaitBackendLoad()
812
+ await awaitBackendLoad()
813
+ // Crash recovery for the Lean queue runs ONCE per session, after the state file is
814
+ // folded in: a leftover Formal/Jobs/*.json is re-queued or explicitly interrupted, and
815
+ // is NEVER silently turned into `passed` (docs/formal-verification.md §7-7).
816
+ if (!leanRecoveryDone) {
817
+ try { await recoverLeanJobs() } catch (e) { console.error('vibe-math-v5: lean recovery: ' + String((e && e.message) || e)) }
818
+ }
819
+ // The math availability line is derived at session start (and on every tuning that
820
+ // touches the six math_* keys); `probeMathEngines` caches per host, so this is cheap.
821
+ try { await refreshMathLine() } catch (e) { console.error('vibe-math-v5: math probe: ' + String((e && e.message) || e)) }
822
+ return true
608
823
  }
609
824
  // Load the CURRENT state path before its first read. `state()` is synchronous by design
610
825
  // (the whole fold is read through it), so it kicks the load off eagerly; the caller that
@@ -671,7 +886,14 @@ export function apply(ctx) {
671
886
  const cur = stateCache.institutes[key]
672
887
  if (cur) {
673
888
  phase = cur.phase || phase
674
- params = Object.assign({}, DEFAULT_PARAMS, cur.params || {})
889
+ // Restored params must go through the SAME normalisation as `vibe_v5_set`: a hand-edited
890
+ // or foreign-shaped state file (e.g. mathEngines as a comma STRING, a bogus enum, a
891
+ // sub-1000 timeout) would otherwise reach `visibleParams()` and v5's own
892
+ // `String(params.mathComputation/mathMode)` reads verbatim — the tool path is protected
893
+ // by the module's `effectiveMathParams`, the operator surface was not.
894
+ const droppedLoad = []
895
+ params = Object.assign({}, DEFAULT_PARAMS, normalizeParams(cur.params || {}, droppedLoad))
896
+ reportDroppedStateKeys(cur, droppedLoad, 'restore-load')
675
897
  project = cur.project || project
676
898
  instituteName = cur.institute || instituteName
677
899
  }
@@ -1094,6 +1316,9 @@ export function apply(ctx) {
1094
1316
  b.push('[形式化] ' + (formalMode() === 'require' ? '强制' : '鼓励') + ' Lean|已通过 ' + passed
1095
1317
  + '|已记录阻塞 ' + blocked + (formalTodo().length ? '|形式化待办 ' + formalTodo().length + ' 项(见 Formal/TODO.md)' : ''))
1096
1318
  }
1319
+ // Settled async compiles are announced EXACTLY ONCE, in the member's next prompt
1320
+ // (docs/formal-verification.md §2.4: the announcement point is the next prompt).
1321
+ for (const line of takeLeanNoticesFor(member.id)) b.push(line)
1097
1322
  const tasks = inst().tasks.filter((t) => t.status !== 'deleted')
1098
1323
  const mine = tasks.filter((t) => t.ownerId === member.id && t.status === 'in_progress')
1099
1324
  const ready = tasks.filter((t) => t.status === 'pending' && taskReady(t))
@@ -1185,7 +1410,7 @@ export function apply(ctx) {
1185
1410
  // directory is what actually creates the tree — inside the policy.
1186
1411
  async function mkdirs() {
1187
1412
  const base = instRoot()
1188
- const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems', 'Formal', 'Verified/Lean']
1413
+ const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems', 'Formal', 'Formal/Jobs', 'Verified/Lean', 'Computation']
1189
1414
  // The REUSABLE Lean library is global (cross-project), so it hangs off the VibeMath
1190
1415
  // root rather than the institute root — creating it under instRoot would scatter a
1191
1416
  // second, invisible copy per institute.
@@ -1248,6 +1473,10 @@ export function apply(ctx) {
1248
1473
  }
1249
1474
  counters.message = n
1250
1475
  await putCounters(counters)
1476
+ // The office talking to the institute is HALF of the `paperEditor='office'` consultation
1477
+ // requirement; counting it here (the one place a message really is queued) means the gate
1478
+ // cannot be satisfied by merely intending to consult (docs/final-paper.md §7).
1479
+ if (from === 'office') await notePaperConsult('message')
1251
1480
  notifyActivity()
1252
1481
  // Kick one scheduling pass so an ADDRESSED message (dm/office/assign) wakes its
1253
1482
  // recipient promptly instead of waiting for the digest window. Plain chat stays
@@ -1679,6 +1908,7 @@ export function apply(ctx) {
1679
1908
  L.push('')
1680
1909
  if (initialTask) { L.push(resume ? '恢复说明:' : '你的初始任务/用途:'); L.push(' ' + initialTask); L.push('') }
1681
1910
  if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
1911
+ mathPushLine(L)
1682
1912
  L.push('------------')
1683
1913
  L.push(stateBlock(member))
1684
1914
  L.push('------------')
@@ -1696,7 +1926,8 @@ export function apply(ctx) {
1696
1926
  L.push('作为院士,除了做研究,你还要**统筹全所**:用 vibe_v5_overview 看清谁在做什么、')
1697
1927
  L.push('哪里是瓶颈;把工作拆成任务并用 vibe_v5_assign 分派;必要时用 vibe_v5_nudge 督办。')
1698
1928
  }
1699
- if (formalOn()) { L.push(''); L.push(formalWorkLine()) }
1929
+ if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
1930
+ mathPushLine(L)
1700
1931
  L.push('')
1701
1932
  L.push('------------')
1702
1933
  L.push(stateBlock(member))
@@ -1712,7 +1943,8 @@ export function apply(ctx) {
1712
1943
  L.push('读一读同事的库、推进你的子问题/引理/方法、尝试一条新路线;')
1713
1944
  L.push('或者向团队发消息(say)、开一个议题(propose_meeting)、给某个方向开任务(task_create)。')
1714
1945
  L.push('如果你确实已无路可走或认为原问题接近解决,请说明你的判断与理由。')
1715
- if (formalOn()) { L.push(''); L.push(formalWorkLine()) }
1946
+ if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
1947
+ mathPushLine(L)
1716
1948
  L.push('')
1717
1949
  L.push('------------')
1718
1950
  L.push(stateBlock(member))
@@ -1739,6 +1971,7 @@ export function apply(ctx) {
1739
1971
  L.push('(会议轮请把你的发言同时填进 JSON 的 "input" 字段,框架据此写会议纪要。)')
1740
1972
  L.push('如果你认为原问题已解决,请填 "vote_solved": true —— 只有当**全体有表决权者**都')
1741
1973
  L.push('一致认为是真时,本所才会停下来。')
1974
+ mathPushLine(L)
1742
1975
  L.push('')
1743
1976
  L.push('------------')
1744
1977
  L.push(stateBlock(member))
@@ -1783,6 +2016,7 @@ export function apply(ctx) {
1783
2016
  }
1784
2017
  L.push('**不要为了配合别人而改票,也不要为了让流程往前走而给出你不相信的 1 或 0。**')
1785
2018
  L.push('本所宁可留下未定论,也不要一个骗人的结论。')
2019
+ mathPushLine(L)
1786
2020
  L.push('')
1787
2021
  L.push('------------')
1788
2022
  L.push(stateBlock(member))
@@ -1818,6 +2052,9 @@ export function apply(ctx) {
1818
2052
  const delay = posMs(ms, posMs(params.activityTimeoutMs, 120000))
1819
2053
  heartbeatDisposer = ctx.timeout(() => {
1820
2054
  heartbeatDisposer = null
2055
+ // Drain the Lean compile queue FIRST: it is independent of the scheduler and must run
2056
+ // even when the pass below decides there is nothing to do (docs/formal-verification.md §7).
2057
+ runLeanQueue().catch((e) => console.error('vibe-math-v5: lean queue: ' + String((e && e.message) || e)))
1821
2058
  scheduleNext().catch((e) => console.error('vibe-math-v5: heartbeat: ' + String((e && e.message) || e)))
1822
2059
  }, delay)
1823
2060
  }
@@ -1878,6 +2115,12 @@ export function apply(ctx) {
1878
2115
  const FORMAL_MODES = ['off', 'encourage', 'require']
1879
2116
  const formalMode = () => (FORMAL_MODES.indexOf(String(params.formalVerify)) !== -1 ? String(params.formalVerify) : 'off')
1880
2117
  const formalOn = () => formalMode() !== 'off'
2118
+ // `leanInitiative` is SEPARATE from `formalVerify`: the latter only says how strong the
2119
+ // requirement is AT VERDICT TIME, this says how eager the staff should be about formalizing
2120
+ // during ordinary work. 'off' suppresses the daily reminder; 'eager' adds it even when
2121
+ // `formalVerify` is off; 'normal' keeps today's rule (the reminder rides with formalVerify).
2122
+ const leanInitiative = () => (['off', 'normal', 'eager'].indexOf(String(params.leanInitiative)) !== -1 ? String(params.leanInitiative) : 'normal')
2123
+ const leanDailyOn = () => leanInitiative() !== 'off' && (formalOn() || leanInitiative() === 'eager')
1881
2124
  const formalRoot = () => instRoot() + '/Formal'
1882
2125
  const formalLibRoot = () => vibeRoot() + '/Formal/Lib'
1883
2126
  const formalProvedRoot = () => vibeRoot() + '/Formal/Proved'
@@ -1932,7 +2175,10 @@ export function apply(ctx) {
1932
2175
 
1933
2176
  // Run the toolchain on one file. NEVER throws into the scheduler: every failure mode
1934
2177
  // (no service, no executable, timeout, non-zero exit) becomes a readable result.
1935
- async function leanRunFile(relPath, timeoutMs) {
2178
+ // `hooks.onHandle(handle)` (optional) hands the live process to the caller so a session
2179
+ // dispose can terminate it; it is a hook rather than a return value because the run must
2180
+ // stay a single awaitable result.
2181
+ async function leanRunFile(relPath, timeoutMs, hooks) {
1936
2182
  const started = now()
1937
2183
  const rel = String(relPath || '').trim()
1938
2184
  if (!rel) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'file is required' }
@@ -1955,7 +2201,8 @@ export function apply(ctx) {
1955
2201
  } catch (e) {
1956
2202
  return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(params.leanCommand || 'lean') + '": ' + String((e && e.message) || e) + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, ms: now() - started }
1957
2203
  }
1958
- const argv = [exe].concat((Array.isArray(params.leanArgs) ? params.leanArgs : []).map(String)).concat([abs])
2204
+ const pfx = await leanRunPrefix()
2205
+ const argv = [exe].concat(pfx.prefix.slice(1), [abs])
1959
2206
  let handle
1960
2207
  try {
1961
2208
  handle = sub.spawn({
@@ -1964,6 +2211,11 @@ export function apply(ctx) {
1964
2211
  stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } },
1965
2212
  graceMs: cap,
1966
2213
  })
2214
+ // Hand the live process to the async queue (session dispose terminates it) and the
2215
+ // NORMALISED build prefix (engine + args + search paths, no file) to the settle check —
2216
+ // a job may only settle the build context it was queued under.
2217
+ if (hooks && typeof hooks.onHandle === 'function') hooks.onHandle(handle)
2218
+ if (hooks && typeof hooks.onBuildPrefix === 'function') hooks.onBuildPrefix(pfx.prefix)
1967
2219
  } catch (e) {
1968
2220
  return { ok: false, code: 'LEAN_SPAWN_FAILED', message: String((e && e.message) || e), file: rel, ms: now() - started }
1969
2221
  }
@@ -2091,6 +2343,608 @@ export function apply(ctx) {
2091
2343
  + ' —— 已撤回「已通过」状态' + (removed ? ',并已使 Verified/Lean/ 中的归档证明失效(删除或覆盖为撤回声明)' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
2092
2344
  return { ok: true, target: t, status: 'attempted', removed }
2093
2345
  }
2346
+ // ================= async Lean compile queue (docs/formal-verification.md §7) ==========
2347
+ // ONE compile at a time, per session. A tool call ENQUEUES and returns immediately; the
2348
+ // queue is drained by the existing heartbeat (`armHeartbeat`) and by a 0ms kick armed at
2349
+ // enqueue time, so a paused or concluded institute still drains what it accepted. Every job
2350
+ // is mirrored to Formal/Jobs/<jobId>.json for crash recovery and for `lean_lib.jobs`.
2351
+ // ONLY `settled(ok)` — exit 0 AND the file's content hash unchanged — may mark an object
2352
+ // `passed` and write Verified/Lean/<id>.lean; every other ending stays `attempted`.
2353
+ const leanJobs = new Map()
2354
+ const leanActive = new Map() // jobId -> { job, handle } (up to leanJobsMaxParallel)
2355
+ let leanActiveJob = null // the MOST RECENTLY started job (prompt/dispose reporting)
2356
+ let leanActiveHandle = null
2357
+ let leanDrainTimer = null
2358
+ let leanRecoveryDone = false
2359
+ let leanDisposed = false
2360
+ const leanNotices = [] // settled results not yet announced to a member
2361
+ function leanJobsMax() { return Math.max(1, Math.floor(Number(params.leanJobsMaxParallel) || 1)) }
2362
+ function leanJobRel(jobId) { return 'Formal/Jobs/' + jobId + '.json' }
2363
+ async function leanWriteJob(job) {
2364
+ try { await writeTextRel(leanJobRel(job.jobId), JSON.stringify(job, null, 2) + '\n') } catch (e) { /* best effort */ }
2365
+ }
2366
+ async function leanReadJobMirror(jobId) {
2367
+ try { return JSON.parse((await readTextRel(leanJobRel(jobId))) || 'null') } catch (e) { return null }
2368
+ }
2369
+ function leanJobMirrorSettled(rec) { return !!rec && rec.state === 'settled' && Number(rec.exitCode) === 0 }
2370
+ // Has this EXACT library content already been compiled successfully? The job id digests the
2371
+ // build context, so the check looks at the jobs rather than reconstructing an id: first the
2372
+ // in-memory queue, then the durable mirrors in Formal/Jobs/.
2373
+ async function leanLibArchivedOk(name, sha) {
2374
+ for (const j of leanJobs.values()) {
2375
+ if (j.kind === 'lib' && j.name === name && j.contentSha256 === sha && leanJobMirrorSettled(j)) return true
2376
+ }
2377
+ try {
2378
+ const t = await fs.resolve(instRoot() + '/Formal/Jobs')
2379
+ if (await fs.stat(t) !== undefined) {
2380
+ for (const e of (await fs.listDir(t)) || []) {
2381
+ if (!e || e.type !== 'file' || !/\.json$/.test(String(e.name))) continue
2382
+ const rec = await leanReadJobMirror(String(e.name).replace(/\.json$/, ''))
2383
+ if (rec && rec.kind === 'lib' && rec.name === name && rec.contentSha256 === sha && leanJobMirrorSettled(rec)) return true
2384
+ }
2385
+ }
2386
+ } catch (e) { /* best effort: no mirror ⇒ no dedupe */ }
2387
+ return false
2388
+ }
2389
+ function leanJobsView() {
2390
+ return Array.from(leanJobs.values())
2391
+ .sort((a, b) => (a.enqueuedAt || 0) - (b.enqueuedAt || 0))
2392
+ .map((j) => ({ jobId: j.jobId, state: j.state, rel: j.file || j.rel || '', target: j.target || j.name || '', attempts: j.attempts || 1, settledAt: j.settledAt || 0 }))
2393
+ }
2394
+ function leanNextQueued() {
2395
+ let best = null
2396
+ for (const j of leanJobs.values()) if (j.state === 'queued' && (!best || (j.enqueuedAt || 0) < (best.enqueuedAt || 0))) best = j
2397
+ return best
2398
+ }
2399
+ // The absolute VibeMath root handed to `--search-path` (the compile runs with cwd=instRoot()).
2400
+ function leanSearchRootView() { return vibeRoot().replace(/\\/g, '/') }
2401
+ async function leanSearchRoot() {
2402
+ const root = vibeRoot()
2403
+ if (root.charAt(0) === '/' || /^[a-z]:/i.test(root)) return root.replace(/\\/g, '/')
2404
+ try {
2405
+ const t = await fs.resolve(root)
2406
+ const s = typeof t === 'string' ? t : String((t && (t.targetKey || t.displayPath)) || root)
2407
+ return String(s).replace(/\\/g, '/')
2408
+ } catch (e) { return root.replace(/\\/g, '/') }
2409
+ }
2410
+ async function leanHashFile(rel) {
2411
+ const abs = leanAbsPath(rel)
2412
+ if (abs === null) return undefined
2413
+ const txt = await readTextAbs(abs)
2414
+ return txt === undefined ? undefined : sha256Hex(leanHashText(txt))
2415
+ }
2416
+ function leanJobId(key, sha) {
2417
+ // `sha` is the BUILD digest: content + engine + normalised argv + search paths, so the
2418
+ // same text compiled under different flags gets a different job (amendment §4).
2419
+ return String(key) + '-' + String(sha || '').slice(0, 12)
2420
+ }
2421
+ // The NORMALISED build prefix (engine + user args + injected search flags, WITHOUT the file
2422
+ // name). It is what the job id digests and what the settle re-checks.
2423
+ async function leanRunPrefix() {
2424
+ const userArgs = (Array.isArray(params.leanArgs) ? params.leanArgs : []).map(String)
2425
+ const engine = String(params.leanCommand || 'lean')
2426
+ const searchList = await leanInjectedSearchList(userArgs)
2427
+ const searchArgs = []
2428
+ for (const p of searchList) searchArgs.push(LEAN_SEARCH_PATH_FLAG, p)
2429
+ return { engine, userArgs, searchList, searchArgs, prefix: [engine].concat(userArgs, searchArgs) }
2430
+ }
2431
+ // leanSearchPaths FIRST (the order the user gave), then the automatic VibeMath root; deduped;
2432
+ // empty when leanArgs already carries an explicit search flag (explicit override wins).
2433
+ async function leanInjectedSearchList(userArgs) {
2434
+ if (leanHasSearchFlag(userArgs)) return []
2435
+ const extra = (Array.isArray(params.leanSearchPaths) ? params.leanSearchPaths : [])
2436
+ .map((s) => String(s == null ? '' : s).trim()).filter(Boolean)
2437
+ const root = await leanSearchRoot()
2438
+ return Array.from(new Set(extra.concat([root])))
2439
+ }
2440
+ function leanBuildDigest(contentText, prefix) {
2441
+ return sha256Hex(leanHashText(String(contentText == null ? '' : contentText)) + '|' + JSON.stringify((prefix || []).map(String)))
2442
+ }
2443
+ function leanBuildHash(prefix) { return sha256Hex(JSON.stringify((prefix || []).map(String))) }
2444
+ function armLeanDrain(ms) {
2445
+ if (leanDisposed || leanDrainTimer) return
2446
+ // `posMs` treats 0 as "use the default" (120s!), which would stall the queue: the kick
2447
+ // must be an IMMEDIATE next-tick timer.
2448
+ const delay = (typeof ms === 'number' && ms > 0) ? Math.floor(ms) : 0
2449
+ leanDrainTimer = ctx.timeout(() => {
2450
+ leanDrainTimer = null
2451
+ runLeanQueue().catch((e) => console.error('vibe-math-v5: lean queue: ' + String((e && e.message) || e)))
2452
+ }, delay)
2453
+ }
2454
+ function clearLeanDrain() { if (leanDrainTimer) { try { leanDrainTimer() } catch (e) { /* ignore */ } leanDrainTimer = null } }
2455
+ // The toolchain probe shared by the enqueue path: a host with no subprocess service — or no
2456
+ // reachable executable — must keep today's honest NO_SUBPROCESS / LEAN_NOT_FOUND behaviour
2457
+ // rather than queueing a job that can never settle.
2458
+ async function leanPrecheck(relPath) {
2459
+ const rel = String(relPath == null ? '' : relPath).trim()
2460
+ if (!rel) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'file is required' }
2461
+ const abs = leanAbsPath(rel)
2462
+ if (abs === null) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'Lean file must live under ' + vibeRoot().replace(/\\/g, '/') + '/ (got ' + rel + ')' }
2463
+ if (!/\.lean$/.test(abs)) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'only .lean files can be executed' }
2464
+ if (await readTextAbs(abs) === undefined) return { ok: false, code: 'V5_NOT_FOUND', message: 'no such file: ' + rel }
2465
+ const sub = subprocessOf()
2466
+ if (sub === undefined || typeof sub.spawn !== 'function') {
2467
+ return { ok: false, code: 'NO_SUBPROCESS', message: 'the host exposes no subprocess service; Lean cannot be executed here', file: rel }
2468
+ }
2469
+ try { await sub.resolveExecutable(String(params.leanCommand || 'lean')) } catch (e) {
2470
+ return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(params.leanCommand || 'lean') + '": ' + String((e && e.message) || e) + ' — 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel }
2471
+ }
2472
+ return { ok: true, rel, abs }
2473
+ }
2474
+ async function enqueueLeanJob(spec) {
2475
+ const job = Object.assign({}, spec, {
2476
+ state: 'queued', attempts: 0, enqueuedAt: now(), startedAt: 0, settledAt: 0,
2477
+ exitCode: null, ok: false, timedOut: false, interrupted: false,
2478
+ })
2479
+ leanJobs.set(job.jobId, job)
2480
+ await leanWriteJob(job)
2481
+ // Kick on a LATER tick: the tool must return before any compiler process starts.
2482
+ armLeanDrain()
2483
+ return job
2484
+ }
2485
+ async function runLeanQueue() {
2486
+ let started = false
2487
+ // Up to leanJobsMaxParallel compiles at once (default 1 = strictly serial). Each job runs
2488
+ // to completion in the background; its own settle re-arms the queue.
2489
+ while (leanActive.size < leanJobsMax()) {
2490
+ const job = leanNextQueued()
2491
+ if (!job) break
2492
+ started = true
2493
+ const slot = { job, handle: null }
2494
+ leanActive.set(job.jobId, slot)
2495
+ runLeanJob(job, slot)
2496
+ .catch((e) => console.error('vibe-math-v5: lean job ' + job.jobId + ': ' + String((e && e.message) || e)))
2497
+ .finally(() => {
2498
+ leanActive.delete(job.jobId)
2499
+ if (leanActiveJob === job) { leanActiveJob = null; leanActiveHandle = null }
2500
+ if (leanNextQueued()) armLeanDrain()
2501
+ })
2502
+ }
2503
+ return started
2504
+ }
2505
+ async function runLeanJob(job, slot) {
2506
+ leanActiveJob = job
2507
+ leanActiveHandle = null
2508
+ job.state = 'running'
2509
+ job.startedAt = now()
2510
+ job.attempts = (Number(job.attempts) || 0) + 1
2511
+ await leanWriteJob(job)
2512
+ let run = null
2513
+ let actualPrefix = null
2514
+ try {
2515
+ run = await leanRunFile(job.rel, job.timeoutMs, {
2516
+ onHandle: (h) => { if (slot) slot.handle = h; if (leanActiveJob === job) leanActiveHandle = h },
2517
+ onBuildPrefix: (p) => { actualPrefix = p },
2518
+ })
2519
+ } catch (e) {
2520
+ run = { ok: false, code: 'LEAN_RUN_FAILED', message: String((e && e.message) || e), file: job.rel, ms: 0 }
2521
+ }
2522
+ const hashNow = await leanHashFile(job.rel)
2523
+ const hashMatched = hashNow !== undefined && hashNow === job.contentSha256
2524
+ // The build context must be the one the job was QUEUED under: args/search paths may have
2525
+ // been retuned while it waited, and a compile under different flags settles nothing.
2526
+ const buildMatched = !!actualPrefix && leanBuildHash(actualPrefix) === job.buildHash
2527
+ const settledOk = !!run.ok && hashMatched && buildMatched
2528
+ job.exitCode = run.exitCode === undefined ? null : run.exitCode
2529
+ job.ok = settledOk
2530
+ job.timedOut = !!run.timedOut
2531
+ job.buildMatched = buildMatched
2532
+ job.settledAt = now()
2533
+ job.state = settledOk ? 'settled' : (job.interrupted === true ? 'interrupted' : (job.timedOut ? 'timeout' : 'failed'))
2534
+ job.run = {
2535
+ at: job.settledAt, ok: !!job.ok, exitCode: job.exitCode, ms: run.ms || 0,
2536
+ timedOut: !!job.timedOut, interrupted: !!job.interrupted, buildMatched,
2537
+ stderrTail: tail(run.stderr || (buildMatched ? '' : '构建上下文已改变(leanArgs/搜索路径在入队后被修改),本次结果不用于判定'), 800),
2538
+ }
2539
+ try { await leanApplySettle(job, run, settledOk, hashMatched && buildMatched) } catch (e) { console.error('vibe-math-v5: lean settle ' + job.jobId + ': ' + String((e && e.message) || e)) }
2540
+ await leanWriteJob(job)
2541
+ leanQueueNotice(job, run, hashMatched && buildMatched)
2542
+ }
2543
+ // The ONE place a job's outcome reaches the state — and the ONLY place an object may become
2544
+ // `passed` from a compile (proof jobs, and only when settledOk).
2545
+ async function leanApplySettle(job, run, settledOk, hashMatched) {
2546
+ const asyncRec = {
2547
+ jobId: job.jobId, state: job.state, attempts: job.attempts || 1,
2548
+ enqueuedAt: job.enqueuedAt || 0, startedAt: job.startedAt || 0, settledAt: job.settledAt || 0,
2549
+ exitCode: job.exitCode === undefined ? null : job.exitCode,
2550
+ }
2551
+ const target = idSafe(String(job.target || job.name || ''))
2552
+ if (job.kind === 'proof') {
2553
+ if (!target) return
2554
+ const prev = formalOf(target)
2555
+ const stale = hashMatched ? '' : '(异步落地:编译完成后文件内容已改变,本次结果不用于判定)'
2556
+ const rec = Object.assign({}, prev, {
2557
+ status: settledOk ? 'passed' : 'attempted',
2558
+ file: job.rel,
2559
+ proof: settledOk ? 'Verified/Lean/' + target + '.lean' : '',
2560
+ decision: 'used',
2561
+ note: (String(prev.note || '') + stale).trim(),
2562
+ run: job.run, async: asyncRec, updatedAt: now(),
2563
+ })
2564
+ if (settledOk) await writeTextRel('Verified/Lean/' + target + '.lean', String(job.content == null ? '' : job.content))
2565
+ await putFormal(target, rec)
2566
+ await rebuildLeanLibIndexes()
2567
+ await saveChatLine('【形式化】' + (job.member || '成员') + ' 的后台作业 ' + job.jobId + ' 落地:' + target + ' → ' + rec.status
2568
+ + (settledOk ? '(已归档 ' + rec.proof + ',验证转为忠实性审查)' : '(未通过:' + tail(run.stderr || run.message, 160) + ')'))
2569
+ return
2570
+ }
2571
+ if (job.kind === 'lib') {
2572
+ if (target) {
2573
+ const prev = formalOf(target)
2574
+ await putFormal(target, Object.assign({}, prev, { file: job.file || prev.file || '', run: job.run, async: asyncRec, updatedAt: now() }))
2575
+ }
2576
+ await rebuildLeanLibIndexes()
2577
+ await saveChatLine('【形式化】' + (job.member || '成员') + ' 的后台作业 ' + job.jobId + ' 落地:' + (job.file || job.rel) + ' → ' + (settledOk ? '通过' : '未通过')
2578
+ + (settledOk ? '' : '(' + tail(run.stderr || run.message, 160) + ')'))
2579
+ return
2580
+ }
2581
+ if (target) {
2582
+ await formalSetRun(target, run)
2583
+ const rec = formalOf(target)
2584
+ await putFormal(target, Object.assign({}, rec, { async: asyncRec, updatedAt: now() }))
2585
+ }
2586
+ await writeFormalIndex()
2587
+ await saveChatLine('【形式化】' + (job.member || '成员') + ' 的后台作业 ' + job.jobId + ' 落地:' + (job.file || job.rel) + ' → ' + (settledOk ? '通过' : '未通过')
2588
+ + (settledOk ? '' : '(' + tail(run.stderr || run.message, 160) + ')'))
2589
+ }
2590
+ function leanQueueNotice(job, run, hashMatched) {
2591
+ const secs = ((run && run.ms) || 0) / 1000
2592
+ let line
2593
+ if (job.state === 'settled') line = '【形式化结果】' + job.jobId + ':通过(exit 0,' + secs.toFixed(1) + 's' + (job.kind === 'proof' ? ',已归档 Verified/Lean/' + job.target + '.lean' : '') + ')'
2594
+ else if (job.state === 'timeout') line = '【形式化结果】' + job.jobId + ':超时(leanTimeoutMs=' + posMs(params.leanTimeoutMs, 120000) + ' 已 terminate)'
2595
+ else if (job.state === 'interrupted') line = '【形式化结果】' + job.jobId + ':中断(会话卸载或崩溃,已标记 attempted,可重跑)'
2596
+ else line = '【形式化结果】' + job.jobId + ':失败(exit ' + job.exitCode + (hashMatched ? '' : ';文件内容已变,结果不可用') + ',见 stderr 尾部)'
2597
+ leanNotices.push({ member: String(job.member || ''), jobId: job.jobId, line, at: now() })
2598
+ while (leanNotices.length > 20) leanNotices.shift()
2599
+ }
2600
+ // ONE-SHOT: a member sees each settled result exactly once, in its next round prompt
2601
+ // (the "announcement point is the next prompt" discipline).
2602
+ function takeLeanNoticesFor(memberId) {
2603
+ if (!leanNotices.length) return []
2604
+ const mine = leanNotices.filter((n) => n.member === memberId)
2605
+ if (!mine.length) return []
2606
+ for (const n of mine) { const i = leanNotices.indexOf(n); if (i !== -1) leanNotices.splice(i, 1) }
2607
+ return mine.map((n) => n.line)
2608
+ }
2609
+ // Crash recovery: an object is NEVER silently verified. Only a job record that says
2610
+ // exitCode 0 AND whose content hash still matches may补 the archive; everything else is
2611
+ // downgraded to `attempted` (or re-queued when the interrupted run is safe to retry).
2612
+ async function leanMarkAttempted(rec, why) {
2613
+ const target = idSafe(String(rec.target || ''))
2614
+ if (!target) return
2615
+ const prev = formalOf(target)
2616
+ const sameJob = !!(prev && prev.async && prev.async.jobId === rec.jobId)
2617
+ if (!sameJob && prev.status === 'passed') return // never downgrade a newer pass
2618
+ if (!sameJob && rec.kind !== 'proof') return
2619
+ const note = String(prev.note || '')
2620
+ await putFormal(target, Object.assign({}, prev, {
2621
+ status: prev.status === 'blocked' ? 'blocked' : 'attempted',
2622
+ proof: rec.kind === 'proof' ? '' : prev.proof,
2623
+ async: { jobId: rec.jobId, state: 'interrupted', attempts: Number(rec.attempts) || 1, enqueuedAt: rec.enqueuedAt || 0, startedAt: rec.startedAt || 0, settledAt: now(), exitCode: rec.exitCode === undefined ? null : rec.exitCode },
2624
+ note: (note.indexOf(why) === -1 ? (note + (note ? ' ' : '') + '(' + why + ')') : note).trim(),
2625
+ updatedAt: now(),
2626
+ }))
2627
+ await writeFormalIndex()
2628
+ }
2629
+ async function leanApplyRecovered(rec) {
2630
+ const target = idSafe(String(rec.target || ''))
2631
+ const asyncRec = { jobId: rec.jobId, state: 'settled', attempts: Number(rec.attempts) || 1, enqueuedAt: rec.enqueuedAt || 0, startedAt: rec.startedAt || 0, settledAt: rec.settledAt || now(), exitCode: 0 }
2632
+ if (rec.kind === 'proof' && target) {
2633
+ const prev = formalOf(target)
2634
+ await writeTextRel('Verified/Lean/' + target + '.lean', String(rec.content == null ? '' : rec.content))
2635
+ await putFormal(target, Object.assign({}, prev, {
2636
+ status: 'passed', file: rec.rel, proof: 'Verified/Lean/' + target + '.lean', decision: 'used',
2637
+ run: Object.assign({}, rec.run || {}, { ok: true, exitCode: 0 }), async: asyncRec, updatedAt: now(),
2638
+ }))
2639
+ await rebuildLeanLibIndexes()
2640
+ return true
2641
+ }
2642
+ if (target) {
2643
+ const prev = formalOf(target)
2644
+ await putFormal(target, Object.assign({}, prev, { run: Object.assign({}, rec.run || {}, { ok: true, exitCode: 0 }), async: asyncRec, updatedAt: now() }))
2645
+ }
2646
+ await rebuildLeanLibIndexes()
2647
+ return true
2648
+ }
2649
+ async function recoverLeanJobs() {
2650
+ if (leanRecoveryDone) return { scanned: 0, requeued: 0, attempted: 0 }
2651
+ leanRecoveryDone = true
2652
+ let entries = []
2653
+ try {
2654
+ const t = await fs.resolve(instRoot() + '/Formal/Jobs')
2655
+ if (await fs.stat(t) !== undefined) entries = await fs.listDir(t)
2656
+ } catch (e) { return { scanned: 0, requeued: 0, attempted: 0 } }
2657
+ let scanned = 0, requeued = 0, attempted = 0
2658
+ for (const e of entries || []) {
2659
+ if (!e || e.type !== 'file' || !/\.json$/.test(String(e.name))) continue
2660
+ const jobId = String(e.name).replace(/\.json$/, '')
2661
+ const rec = await leanReadJobMirror(jobId)
2662
+ if (!rec || !rec.jobId || !rec.rel) continue
2663
+ scanned++
2664
+ const hashNow = await leanHashFile(rec.rel)
2665
+ const matched = hashNow !== undefined && hashNow === rec.contentSha256
2666
+ if (rec.state === 'queued') {
2667
+ rec.attempts = Number(rec.attempts) || 0
2668
+ leanJobs.set(rec.jobId, rec)
2669
+ requeued++
2670
+ await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 从未开始,已重新入队。')
2671
+ continue
2672
+ }
2673
+ if (rec.state === 'running' || rec.state === 'interrupted') {
2674
+ rec.interrupted = true
2675
+ await leanMarkAttempted(rec, matched ? '崩溃恢复:上一次运行的结果不可知' : '崩溃恢复:运行期间文件已被改写')
2676
+ attempted++
2677
+ if (matched) {
2678
+ rec.state = 'queued'
2679
+ rec.startedAt = 0
2680
+ leanJobs.set(rec.jobId, rec)
2681
+ requeued++
2682
+ await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 中断且内容未变:已以新 attempts 重新入队(旧结果不可知)。')
2683
+ } else {
2684
+ rec.state = 'failed'
2685
+ await leanWriteJob(rec)
2686
+ await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 运行期间文件已被改写:**不自动重驱**,请手动重跑(对象保持 attempted)。')
2687
+ }
2688
+ continue
2689
+ }
2690
+ if (leanJobMirrorSettled(rec) && matched) {
2691
+ await leanApplyRecovered(rec)
2692
+ await saveChatLine('【形式化·恢复】后台作业 ' + rec.jobId + ' 已通过且内容未变:按作业记录补齐归档与 passed。')
2693
+ } else {
2694
+ await leanMarkAttempted(rec, '崩溃恢复:作业未通过或内容已变')
2695
+ attempted++
2696
+ }
2697
+ }
2698
+ if (leanNextQueued()) armLeanDrain()
2699
+ return { scanned, requeued, attempted }
2700
+ }
2701
+ function leanJobView(job) {
2702
+ if (!job) return null
2703
+ return {
2704
+ jobId: job.jobId, state: job.state, kind: job.kind || 'run',
2705
+ file: job.file || job.rel || '', rel: job.rel || '', target: job.target || job.name || '',
2706
+ attempts: job.attempts || 1, exitCode: job.exitCode === undefined ? null : job.exitCode,
2707
+ ok: job.ok === true, timedOut: !!job.timedOut, interrupted: !!job.interrupted,
2708
+ buildMatched: job.buildMatched !== false,
2709
+ enqueuedAt: job.enqueuedAt || 0, startedAt: job.startedAt || 0, settledAt: job.settledAt || 0,
2710
+ jobFile: leanJobRel(job.jobId),
2711
+ proof: job.kind === 'proof' && job.ok === true ? 'Verified/Lean/' + job.target + '.lean' : '',
2712
+ stderrTail: (job.run && job.run.stderrTail) || '',
2713
+ }
2714
+ }
2715
+ // `lean_job {jobId?, waitMs?}` — the WAIT path for a background compile. It polls the queue
2716
+ // itself (bounded by waitMs) and never touches the heartbeat; with no jobId it lists this
2717
+ // session's jobs. Returns the CURRENT state on timeout rather than throwing.
2718
+ async function leanJobTool(o) {
2719
+ const args = o || {}
2720
+ const jobId = String(args.jobId || '').trim()
2721
+ const waitMs = Math.max(0, Math.min(600000, Math.floor(Number(args.waitMs) || 0)))
2722
+ if (!jobId) return { ok: true, jobs: leanJobsView(), count: leanJobs.size, running: leanActive.size, maxParallel: leanJobsMax() }
2723
+ let job = leanJobs.get(jobId) || await leanReadJobMirror(jobId)
2724
+ if (!job) return { ok: false, code: 'V5_NOT_FOUND', message: 'no such job ' + jobId, jobs: leanJobsView() }
2725
+ const deadline = now() + waitMs
2726
+ let waitedMs = 0
2727
+ while (waitMs > 0 && (job.state === 'queued' || job.state === 'running') && now() < deadline) {
2728
+ const t0 = now()
2729
+ try { await runLeanQueue() } catch (e) { console.error('vibe-math-v5: lean wait: ' + String((e && e.message) || e)) }
2730
+ if (job.state === 'queued' || job.state === 'running') {
2731
+ await new Promise((resolve) => { ctx.timeout(() => resolve(true), Math.min(25, waitMs)) })
2732
+ }
2733
+ waitedMs += now() - t0
2734
+ job = leanJobs.get(jobId) || await leanReadJobMirror(jobId) || job
2735
+ }
2736
+ return { ok: true, job: leanJobView(job), waitedMs, timedOut: job.state === 'queued' || job.state === 'running' }
2737
+ }
2738
+ // Session dispose: terminate EVERY in-flight compile and mark it `interrupted` — never passed.
2739
+ function disposeLeanJobs() {
2740
+ leanDisposed = true
2741
+ clearLeanDrain()
2742
+ const jobs = []
2743
+ for (const slot of leanActive.values()) {
2744
+ const job = slot && slot.job
2745
+ if (job) { job.interrupted = true; job.state = 'interrupted'; job.settledAt = now(); jobs.push(job) }
2746
+ if (slot && slot.handle && typeof slot.handle.terminate === 'function') {
2747
+ try { slot.handle.terminate() } catch (e) { /* best effort */ }
2748
+ }
2749
+ }
2750
+ if (leanActiveHandle && typeof leanActiveHandle.terminate === 'function') {
2751
+ try { leanActiveHandle.terminate() } catch (e) { /* best effort */ }
2752
+ }
2753
+ for (const job of jobs) {
2754
+ leanWriteJob(job).catch(() => {})
2755
+ leanQueueNotice(job, { ms: now() - (job.startedAt || now()) }, true)
2756
+ }
2757
+ return { terminated: jobs.length }
2758
+ }
2759
+ try { leanDisposers.add(disposeLeanJobs) } catch (e) { /* host without the disposer set */ }
2760
+
2761
+ // ============ math_computation seam (docs/math-computation.md) ============
2762
+ // The shared module (`./math-computation.js`) owns detection/argv/receipts/install; this
2763
+ // preset owns the HOST side. Every path the module hands over is institute-relative, and it
2764
+ // is re-guarded here (defence in depth): a '../' path can never escape the institute even if
2765
+ // the module's own guard regresses.
2766
+ function mathRelPath(rel) {
2767
+ const raw = String(rel == null ? '' : rel).trim().replace(/\\/g, '/')
2768
+ if (!raw || raw.charAt(0) === '/' || /^[a-z]:/i.test(raw)) return null
2769
+ const parts = []
2770
+ for (const seg of raw.split('/')) {
2771
+ if (!seg || seg === '.') continue
2772
+ if (seg === '..') { if (!parts.length) return null; parts.pop(); continue }
2773
+ parts.push(seg)
2774
+ }
2775
+ return parts.length ? parts.join('/') : null
2776
+ }
2777
+ async function mathWriteRel(rel, text) {
2778
+ const r = mathRelPath(rel)
2779
+ if (r === null) return false
2780
+ return await writeTextRel(r, String(text == null ? '' : text))
2781
+ }
2782
+ async function mathReadRel(rel) {
2783
+ const r = mathRelPath(rel)
2784
+ if (r === null) return undefined
2785
+ return await readTextRel(r)
2786
+ }
2787
+ async function mathExistsRel(rel) {
2788
+ const r = mathRelPath(rel)
2789
+ if (r === null) return false
2790
+ try {
2791
+ const t = await fs.resolve(instRoot() + '/' + r)
2792
+ return (await fs.stat(t)) !== undefined
2793
+ } catch (e) { return false }
2794
+ }
2795
+ // Optional host seam (P2a): the module uses it for the per-project retention cap. `''`/'.'
2796
+ // means the project root itself.
2797
+ async function mathListDirRel(rel) {
2798
+ const raw = String(rel == null ? '' : rel).trim()
2799
+ const r = raw === '' || raw === '.' ? '' : mathRelPath(raw)
2800
+ if (r === null) return []
2801
+ try {
2802
+ const t = await fs.resolve(instRoot() + (r ? '/' + r : ''))
2803
+ return (await fs.listDir(t)) || []
2804
+ } catch (e) { return [] }
2805
+ }
2806
+ async function mathResolveExecutable(cmd) {
2807
+ const sub = subprocessOf()
2808
+ if (sub === undefined || typeof sub.resolveExecutable !== 'function') throw new Error('NO_SUBPROCESS: the host exposes no subprocess service')
2809
+ const p = await sub.resolveExecutable(String(cmd))
2810
+ if (typeof p === 'string' && p) return p
2811
+ throw new Error('cannot resolve executable: ' + String(cmd))
2812
+ }
2813
+ // The module calls this with {argv, cwd, timeoutMs, stdoutCap, stderrCap} and expects the
2814
+ // FULL captured output (it writes the complete text to disk and only truncates the RETURN
2815
+ // shell itself, MATH_CAPS.stdout = 64KB) — so the stdio caps must be generous, never the
2816
+ // return-shell size. A host without a subprocess service returns null instead of throwing:
2817
+ // the module maps that to MATH_NO_SUBPROCESS (its `if (!r)` branch).
2818
+ async function mathSpawn(opts) {
2819
+ const o = opts || {}
2820
+ const sub = subprocessOf()
2821
+ if (sub === undefined || typeof sub.spawn !== 'function') return null
2822
+ const argv = (o.argv || []).map(String)
2823
+ if (!argv.length) return null
2824
+ const started = now()
2825
+ const cap = Math.max(1000, Math.floor(Number(o.timeoutMs) || Number(params.mathTimeoutMs) || 60000))
2826
+ const outCap = Math.max(MATH_CAPS.stdout, Math.floor(Number(o.stdoutCap) || MATH_CAPS.file))
2827
+ const errCap = Math.max(MATH_CAPS.stderr, Math.floor(Number(o.stderrCap) || MATH_CAPS.file))
2828
+ let handle = null
2829
+ try {
2830
+ handle = sub.spawn({
2831
+ argv,
2832
+ cwd: String(o.cwd || instRoot()),
2833
+ stdio: {
2834
+ stdin: o.stdin === undefined ? 'ignore' : 'pipe',
2835
+ stdout: { maxBytes: outCap },
2836
+ stderr: { maxBytes: errCap },
2837
+ },
2838
+ graceMs: cap,
2839
+ })
2840
+ } catch (e) {
2841
+ return { exit: null, timedOut: false, killed: false, ms: now() - started, stdout: '', stderr: String((e && e.message) || e) }
2842
+ }
2843
+ // A host whose spawn answers nothing usable (no service, refused, null handle) must reach
2844
+ // the module's `if (!r)` branch as MATH_NO_SUBPROCESS instead of throwing here.
2845
+ if (!handle) return null
2846
+ if (o.stdin !== undefined && handle && handle.stdin && typeof handle.stdin.write === 'function') {
2847
+ try { handle.stdin.write(String(o.stdin)); if (typeof handle.stdin.end === 'function') handle.stdin.end() } catch (e) { /* best effort */ }
2848
+ }
2849
+ // A TIMEOUT must actually KILL the process: `graceMs` is only a request to the host.
2850
+ let timerDisposer = null
2851
+ let timedOut = false
2852
+ const ran = Promise.resolve(handle.done).then(
2853
+ (v) => ({ settled: true, value: v }),
2854
+ (e) => ({ settled: false, error: e }))
2855
+ let outcome = null
2856
+ try {
2857
+ const r = await Promise.race([
2858
+ ran,
2859
+ new Promise((resolve) => {
2860
+ timerDisposer = ctx.timeout(() => {
2861
+ timedOut = true
2862
+ try { if (typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* the race result is the report */ }
2863
+ resolve({ settled: true, value: { exitCode: null, signal: 'SIGTERM' } })
2864
+ }, cap)
2865
+ }),
2866
+ ])
2867
+ if (r.settled) outcome = r.value
2868
+ } catch (e) {
2869
+ return { exit: null, timedOut, killed: timedOut, ms: now() - started, stdout: '', stderr: String((e && e.message) || e) }
2870
+ } finally {
2871
+ if (timerDisposer) { try { timerDisposer() } catch (e) { /* already settled */ } }
2872
+ }
2873
+ let out = '', err = ''
2874
+ try { if (handle.collected && handle.collected.stdout) out = handle.collected.stdout.readFrom(0).text } catch (e) { /* best effort */ }
2875
+ try { if (handle.collected && handle.collected.stderr) err = handle.collected.stderr.readFrom(0).text } catch (e) { /* best effort */ }
2876
+ const signal = (outcome && outcome.signal) || null
2877
+ return {
2878
+ exit: outcome && outcome.exitCode !== undefined ? outcome.exitCode : null,
2879
+ timedOut,
2880
+ killed: timedOut || !!signal,
2881
+ ms: now() - started,
2882
+ stdout: out,
2883
+ stderr: err,
2884
+ }
2885
+ }
2886
+ function mathLog(kind, msg) {
2887
+ try { saveChatLine('【计算】' + String(msg == null ? '' : msg)) } catch (e) { /* a log line must never break a run */ }
2888
+ }
2889
+ // The per-session host the module talks to (also used by the availability-line probe, which
2890
+ // runs OUTSIDE a tool call and therefore cannot rely on an ambient "current session").
2891
+ const mathSessionHost = {
2892
+ register: () => {},
2893
+ params: () => Object.assign({}, params),
2894
+ projectRoot: () => instRoot(),
2895
+ designator: 'vibe-math-v5',
2896
+ writeText: (rel, text) => mathWriteRel(rel, text),
2897
+ readText: (rel) => mathReadRel(rel),
2898
+ exists: (rel) => mathExistsRel(rel),
2899
+ resolveExecutable: (cmd) => mathResolveExecutable(cmd),
2900
+ spawn: (opts) => mathSpawn(opts),
2901
+ // P2a optional fields: tell the module up front whether a compiler can run at all (so it
2902
+ // reports MATH_NO_SUBPROCESS before engine detection), and let it check the retention cap.
2903
+ hasSubprocess: () => { const sub = subprocessOf(); return !!(sub && typeof sub.spawn === 'function') },
2904
+ listDir: (rel) => mathListDirRel(rel),
2905
+ log: (kind, msg) => mathLog(kind, msg),
2906
+ }
2907
+ // The DYNAMIC per-round availability line: computed from a (cached) probe when the prompt is
2908
+ // built, never frozen into the persona. `mathComputation:'off'` produces NO line at all
2909
+ // (the zero-mention discipline); the mode decides whether the shell-fallback sentence is in.
2910
+ let mathLineZh = ''
2911
+ let mathLineEn = ''
2912
+ async function refreshMathLine() {
2913
+ if (String(params.mathComputation) === 'off') { mathLineZh = ''; mathLineEn = ''; return '' }
2914
+ try {
2915
+ const probe = await probeMathEngines(mathSessionHost)
2916
+ mathLineZh = mathAvailabilityLine(probe, 'zh', String(params.mathMode))
2917
+ mathLineEn = mathAvailabilityLine(probe, 'en', String(params.mathMode))
2918
+ // Drift guard: the shared line must still carry the frozen rule text (a future module
2919
+ // edit that drops the rules would silently remove a rule from every round prompt).
2920
+ if (mathLineZh.indexOf(MATH_RULE_LINES[0]) === -1) console.error('vibe-math-v5: the math availability line lost the frozen rules')
2921
+ if (mathLineEn.indexOf(MATH_RULE_LINES_EN[0]) === -1) console.error('vibe-math-v5: the EN math availability line lost the frozen rules')
2922
+ // P2a: the archive→edit→re-run workflow rule must reach the prompt too (it is what makes
2923
+ // `scriptChanged` actionable for a member).
2924
+ if (mathLineZh.indexOf(MATH_ARCHIVE_WORKFLOW_LINE) === -1) console.error('vibe-math-v5: the math availability line lost the archive workflow rule')
2925
+ if (mathLineEn.indexOf(MATH_ARCHIVE_WORKFLOW_LINE_EN) === -1) console.error('vibe-math-v5: the EN math availability line lost the archive workflow rule')
2926
+ return mathLineZh
2927
+ } catch (e) {
2928
+ // A probe failure must never break prompt construction: keep whatever we had.
2929
+ return mathLineZh
2930
+ }
2931
+ }
2932
+ // The tool section of a member prompt: the availability line + the rules (both come from
2933
+ // the module, so all four presets stay byte-comparable).
2934
+ function mathPromptBlock(lang) {
2935
+ const line = lang === 'en' ? mathLineEn : mathLineZh
2936
+ if (line) return '\n' + line
2937
+ // Fallback: when the dynamic line could not be built, the member still needs to know the
2938
+ // tool exists, how to cite it, and (P2a) that an edited script needs a NEW receipt.
2939
+ if (String(params.mathComputation) === 'off') return ''
2940
+ return lang === 'en'
2941
+ ? '\n' + MATH_PERSONA_TOOL_LINE + '\n' + MATH_ARCHIVE_WORKFLOW_LINE_EN
2942
+ : '\n' + MATH_PERSONA_TOOL_LINE + '\n' + MATH_ARCHIVE_WORKFLOW_LINE
2943
+ }
2944
+ // Push the tool section into a prompt under construction (a no-op in the 'off' mode, which
2945
+ // is the "off means zero mention" discipline).
2946
+ function mathPushLine(L) { const b = mathPromptBlock('zh'); if (b) L.push(b) }
2947
+
2094
2948
  function formalPromptBlock(target) {
2095
2949
  if (!formalOn()) return ''
2096
2950
  const mode = formalMode()
@@ -2124,6 +2978,9 @@ export function apply(ctx) {
2124
2978
  L.push(' 请复核这个判断是否成立;若你认为其实可以形式化,请指出来并动手做。')
2125
2979
  } else {
2126
2980
  L.push(' · 请先判断该对象的**实现难度**:若能在可接受的工作量内形式化,优先写 Lean 代码并执行。')
2981
+ // D4 criteria + async honesty (verbatim prompt text, docs/formal-verification.md §6).
2982
+ L.push(' · 形式化只写你有把握的版本;没把握就记 blocked 并写清难点——不要用形式化掩盖不确定。')
2983
+ L.push(' · 该对象若已有后台编译在队列中,**不得**在它落地前声称已通过或走忠实性审查;等 vibe_v5_lean_lib 显示 passed 再审。')
2127
2984
  L.push(' · 工具:vibe_v5_lean_run(执行)· vibe_v5_lean_archive(归档)· vibe_v5_lean_lib(查已有可复用库)')
2128
2985
  L.push(' · 工作目录:Formal/(相对研究所根);可复用定义放 ' + formalLibRoot().replace(/\\/g, '/') + '/,')
2129
2986
  L.push(' 已证引理放 ' + formalProvedRoot().replace(/\\/g, '/') + '/;写之前先 vibe_v5_lean_lib 查重。')
@@ -2144,11 +3001,21 @@ export function apply(ctx) {
2144
3001
  return L.join('\n')
2145
3002
  }
2146
3003
  function formalWorkLine() {
2147
- if (!formalOn()) return ''
2148
- return '【顺手形式化(' + (formalMode() === 'require' ? '强制' : '鼓励') + ')】把你工作中常用或可能复用的对象、假设、'
3004
+ if (!leanDailyOn()) return ''
3005
+ const eager = leanInitiative() === 'eager'
3006
+ return '【顺手形式化(' + (formalOn() ? (formalMode() === 'require' ? '强制' : '鼓励') : '主动(leanInitiative=eager)') + ')】'
3007
+ + (eager ? '**主动**把工作中出现的有价值的小引理、命题、定义顺手形式化并存库;' : '')
3008
+ + '把你工作中常用或可能复用的对象、假设、'
2149
3009
  + '新定义用 Lean 形式化定义并归档到全局可复用库(vibe_v5_lean_archive kind=\'def\'),已成立的引理归到 Proved/'
2150
3010
  + '(kind=\'lemma\');写之前先 vibe_v5_lean_lib 查重,避免重复定义。'
2151
3011
  + '归档前先跑通(vibe_v5_lean_run 或 run=true);跑不通的定义不要进可复用库。'
3012
+ + '\n · 判断标准:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)。**没把握的先别入库**——'
3013
+ + '进了 Formal/Proved 的东西会被当成已核对引理;没把握就记 blocked 并写清难点,别用形式化掩盖不确定。'
3014
+ + '\n · 复用优先:写新定义/证明前**先 vibe_v5_lean_lib 查已有库**;复用已归档内容用 '
3015
+ + '`import Formal.Lib.<name>` / `import Formal.Proved.<name>`(模块根 = <VibeMath 根>,框架已把它加进编译搜索路径),'
3016
+ + '或 `vibe_v5_lean_read {name}` 取原文逐字复制。**查不到再新写**;同内容重复归档会自动去重。'
3017
+ + '\n · 编译默认走后台队列(leanAsync=true):入队后你可以继续工作;用 vibe_v5_lean_lib 的 jobs 字段或下一轮提示里的 '
3018
+ + '【形式化结果】行看结果。**在作业落地为“通过”之前,不得把该对象当成已通过。**'
2152
3019
  + (formalMode() === 'require'
2153
3020
  ? '本模式下,任何要定论为真/假的对象都必须先有 Lean 通过或显式阻塞记录。'
2154
3021
  : '这会让后续的验证与证明省掉大量重复工作。')
@@ -2201,7 +3068,10 @@ export function apply(ctx) {
2201
3068
  const txt = (await readTextAbs(dirAbs + '/' + e.name)) || ''
2202
3069
  const name = String(e.name).replace(/\.lean$/, '')
2203
3070
  const first = (txt.split('\n').filter((l) => l.trim() && !/^\s*(\/\/|--|import)/.test(l))[0] || '').trim().slice(0, 110)
2204
- rows.push('| ' + name + ' | ' + rel + ' | ' + kindLabel + ' | ' + first.replace(/\|/g, '/') + ' |')
3071
+ // Dependency column (docs/formal-verification.md §9): what this file imports — the
3072
+ // answer to "can I reuse it, and what does it drag in?".
3073
+ const depend = (txt.split('\n').filter((l) => /^\s*import\s+/.test(l)).map((l) => l.replace(/^\s*import\s+/, '').trim()).join('、')) || '—'
3074
+ rows.push('| ' + name + ' | ' + rel + ' | ' + kindLabel + ' | ' + first.replace(/\|/g, '/') + ' | ' + depend.replace(/\|/g, '/') + ' |')
2205
3075
  }
2206
3076
  } catch (e) { /* listing is best-effort */ }
2207
3077
  return rows
@@ -2209,13 +3079,13 @@ export function apply(ctx) {
2209
3079
  const libRows = await scan(formalLibRoot(), 'Formal/Lib', 'def')
2210
3080
  await writeTextAbs(vibeRoot() + '/Formal/Lib/Index.md', ['# 可复用 Lean 定义库(跨项目)|' + instituteName, '',
2211
3081
  '> 写新定义之前先查这里:能复用就不要重新定义。', '',
2212
- '| 名称 | 文件 | 类别 | 摘要 |', '|---|---|---|---|']
2213
- .concat(libRows.length ? libRows : ['| (暂无) | | | |']).join('\n') + '\n')
3082
+ '| 名称 | 文件 | 类别 | 摘要 | 依赖 |', '|---|---|---|---|---|']
3083
+ .concat(libRows.length ? libRows : ['| (暂无) | | | | |']).join('\n') + '\n')
2214
3084
  const provedRows = await scan(formalProvedRoot(), 'Formal/Proved', 'lemma')
2215
3085
  await writeTextAbs(vibeRoot() + '/Formal/Proved/Index.md', ['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|' + instituteName, '',
2216
3086
  '> 这些文件是通过内核检查的引理,可直接 import 复用。', '',
2217
- '| 名称 | 文件 | 类别 | 陈述 |', '|---|---|---|---|']
2218
- .concat(provedRows.length ? provedRows : ['| (暂无) | | | |']).join('\n') + '\n')
3087
+ '| 名称 | 文件 | 类别 | 陈述 | 依赖 |', '|---|---|---|---|---|']
3088
+ .concat(provedRows.length ? provedRows : ['| (暂无) | | | | |']).join('\n') + '\n')
2219
3089
  await writeFormalIndex()
2220
3090
  await writeFormalTodo()
2221
3091
  return { lib: libRows.length, proved: provedRows.length, objects: Object.keys(formalRecords()).length }
@@ -2227,9 +3097,30 @@ export function apply(ctx) {
2227
3097
  // Execute one Lean file through the toolchain, record the run (optionally against an
2228
3098
  // object), refresh the indexes, and report the outcome verbatim. Deliberately called
2229
3099
  // even from `off` mode: a human debugging their toolchain may want it.
3100
+ // `leanAsync` (default): ENQUEUE and return immediately — the compiler has not been
3101
+ // started when this returns (docs/formal-verification.md §7-2). `false`: today's await.
2230
3102
  async function leanRunTool(memberId, o) {
2231
3103
  const args = o || {}
2232
- const run = await leanRunFile(String(args.file || ''), args.timeout_ms)
3104
+ const rel = String(args.file || '')
3105
+ if (params.leanAsync !== false) {
3106
+ const pre = await leanPrecheck(rel)
3107
+ if (pre.ok === false) return Object.assign({ async: null }, pre)
3108
+ const fileText = await readTextAbs(pre.abs)
3109
+ const text = fileText === undefined ? '' : String(fileText)
3110
+ const pfx = await leanRunPrefix()
3111
+ const target = idSafe(String(args.target || ''))
3112
+ const job = await enqueueLeanJob({
3113
+ jobId: leanJobId(target || pre.rel, leanBuildDigest(text, pfx.prefix)), kind: 'run', rel: pre.rel, file: pre.rel,
3114
+ target, member: memberId, contentSha256: sha256Hex(leanHashText(text)), buildHash: leanBuildHash(pfx.prefix),
3115
+ buildPrefix: pfx.prefix, timeoutMs: args.timeout_ms,
3116
+ })
3117
+ return {
3118
+ ok: true, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId,
3119
+ file: pre.rel, target: target || undefined,
3120
+ message: '已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在下一轮提示里公告;也可用 vibe_v5_lean_lib 的 jobs 字段随时查看。',
3121
+ }
3122
+ }
3123
+ const run = await leanRunFile(rel, args.timeout_ms)
2233
3124
  if (run.ok || run.file) {
2234
3125
  if (String(args.target || '').trim()) await formalSetRun(String(args.target), run)
2235
3126
  await writeFormalIndex()
@@ -2239,11 +3130,46 @@ export function apply(ctx) {
2239
3130
  + '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target + ' 记为已尝试(若此前已通过/已阻塞则保留原状态)' : ''))
2240
3131
  }
2241
3132
  return Object.assign({ ok: !!run.ok }, run, {
3133
+ async: null,
2242
3134
  hint: run.ok
2243
3135
  ? '通过。若是某个对象的证明,请用 vibe_v5_lean_archive kind=\'proof\' 归档(会写入 Verified/Lean/ 并把审查对象变成忠实性);若是可复用定义/引理,用 kind=\'def\'/\'lemma\' 归档到全局库。'
2244
3136
  : '未通过。请按上面的编译器输出修复后重跑;若判断无法完成,用 vibe_v5_lean_archive kind=\'blocked\' 记录原因。',
2245
3137
  })
2246
3138
  }
3139
+ // Read an ARCHIVED definition/lemma verbatim (docs/formal-verification.md §5.4): the reuse
3140
+ // path is `lean_lib` (what exists) → `lean_read` (its exact text) → `import` or copy.
3141
+ async function leanRead(o) {
3142
+ const args = o || {}
3143
+ const raw = String(args.name || '').trim().replace(/\.lean$/i, '')
3144
+ if (!raw) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'name is required' }
3145
+ // Reject a path-shaped name outright: the guard must refuse `../`, absolute paths and
3146
+ // any sub-directory, not silently sanitise them into a different file.
3147
+ if (raw.indexOf('/') !== -1 || raw.indexOf('\\') !== -1 || raw.indexOf('..') !== -1 || /^[a-z]:/i.test(raw)) {
3148
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'name must be a plain archived file name (no path separators, no .., no absolute path)' }
3149
+ }
3150
+ const name = idSafe(raw)
3151
+ if (!name || name !== raw) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'name is not a valid archived file name' }
3152
+ const kindArg = String(args.kind || 'auto')
3153
+ if (['auto', 'lib', 'proved'].indexOf(kindArg) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "kind must be 'auto' | 'lib' | 'proved'" }
3154
+ const dirs = kindArg === 'auto' ? ['Lib', 'Proved'] : [kindArg === 'lib' ? 'Lib' : 'Proved']
3155
+ for (const d of dirs) {
3156
+ const rel = 'Formal/' + d + '/' + name + '.lean'
3157
+ const abs = leanAbsPath(instRootless(rel))
3158
+ if (abs === null) continue
3159
+ const txt = await readTextAbs(abs)
3160
+ if (txt === undefined) continue
3161
+ const full = String(txt)
3162
+ const truncated = full.length > LEAN_READ_MAX_BYTES
3163
+ return {
3164
+ ok: true, name, file: rel, kind: d === 'Lib' ? 'lib' : 'proved',
3165
+ sha256: sha256Hex(leanHashText(full)),
3166
+ bytes: new TextEncoder().encode(full).length,
3167
+ text: truncated ? full.slice(0, LEAN_READ_MAX_BYTES) : full,
3168
+ truncated,
3169
+ }
3170
+ }
3171
+ return { ok: false, code: 'V5_NOT_FOUND', message: 'no archived ' + (kindArg === 'auto' ? 'definition/lemma' : kindArg) + ' named ' + name }
3172
+ }
2247
3173
  async function leanArchive(memberId, o) {
2248
3174
  const args = o || {}
2249
3175
  const kind = String(args.kind || '')
@@ -2260,15 +3186,46 @@ export function apply(ctx) {
2260
3186
  }
2261
3187
  if (body === undefined) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
2262
3188
  const rel = 'Formal/' + (kind === 'def' ? 'Lib' : 'Proved') + '/' + name + '.lean'
3189
+ const sha = sha256Hex(leanHashText(String(body)))
3190
+ // Content-hash dedupe (docs/formal-verification.md §5.3): the file already holds EXACTLY
3191
+ // this content AND the recorded run for that content succeeded ⇒ skip rewrite + compile.
3192
+ const fileSha = await leanHashFile(instRootless(rel))
3193
+ if (fileSha === sha && await leanLibArchivedOk(name, sha)) {
3194
+ return { ok: true, deduped: true, kind, name, file: rel, sha256: sha, note: '内容与已归档版本一致,跳过重写与重编译' }
3195
+ }
2263
3196
  const okWrite = await writeTextAbs(instRootless(rel), body)
2264
3197
  if (!okWrite) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
2265
- // The global library sits beside the project tree, so it must be executed through
2266
- // its ABSOLUTE path (the relative form would resolve inside the institute root).
3198
+ if (args.run !== false && params.leanAsync !== false) {
3199
+ // The global library sits beside the project tree, so it is executed through its
3200
+ // ABSOLUTE path (the relative form would resolve inside the institute root).
3201
+ const pre = await leanPrecheck(instRootless(rel))
3202
+ if (pre.ok === true) {
3203
+ const pfx = await leanRunPrefix()
3204
+ const job = await enqueueLeanJob({
3205
+ jobId: leanJobId(name, leanBuildDigest(String(body), pfx.prefix)), kind: 'lib', rel: instRootless(rel), file: rel, name,
3206
+ member: memberId, contentSha256: sha, buildHash: leanBuildHash(pfx.prefix), buildPrefix: pfx.prefix, content: String(body),
3207
+ })
3208
+ await rebuildLeanLibIndexes()
3209
+ await saveChatLine('【形式化】' + memberId + ' 归档了' + (kind === 'def' ? '可复用定义' : '已证引理') + ' `' + name + '` → ' + rel + '(已入队后台编译)')
3210
+ return { ok: true, kind, name, file: rel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, note: '已并入全局可复用库(后台编译中):用 vibe_v5_lean_lib 的 jobs 字段查看结果' }
3211
+ }
3212
+ // No usable toolchain on this host: fall through to the synchronous path so the honest
3213
+ // NO_SUBPROCESS / LEAN_NOT_FOUND result is recorded exactly as before.
3214
+ }
2267
3215
  const run = args.run === false ? null : await leanRunFile(instRootless(rel))
3216
+ // Mirror the outcome as a job record: later re-archives of the SAME content dedupe
3217
+ // against it (and it is what `Formal/Jobs/` recovery scans).
3218
+ if (run) await leanWriteJob({
3219
+ jobId: leanJobId(name, sha), kind: 'lib', rel: instRootless(rel), file: rel, name,
3220
+ member: memberId, contentSha256: sha, state: run.ok ? 'settled' : (run.timedOut ? 'timeout' : 'failed'),
3221
+ attempts: 1, enqueuedAt: now(), startedAt: now(), settledAt: now(),
3222
+ exitCode: run.exitCode === undefined ? null : run.exitCode, ok: !!run.ok,
3223
+ timedOut: !!run.timedOut, interrupted: false,
3224
+ })
2268
3225
  await rebuildLeanLibIndexes()
2269
3226
  await saveChatLine('【形式化】' + memberId + ' 归档了' + (kind === 'def' ? '可复用定义' : '已证引理') + ' `' + name + '` → ' + rel
2270
3227
  + (run ? '(运行 ' + (run.ok ? '通过' : '未通过') + ')' : ''))
2271
- return { ok: true, kind, name, file: rel, run: run || undefined, note: '已并入全局可复用库,后续项目可直接 import 复用' }
3228
+ return { ok: true, kind, name, file: rel, sha256: sha, async: null, run: run || undefined, note: '已并入全局可复用库,后续项目可直接 import 复用' }
2272
3229
  }
2273
3230
  if (kind === 'proof') {
2274
3231
  const target = idSafe(String(args.target || ''))
@@ -2281,9 +3238,41 @@ export function apply(ctx) {
2281
3238
  }
2282
3239
  if (body === undefined) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
2283
3240
  const workRel = 'Formal/' + target + '.lean'
3241
+ const sha = sha256Hex(leanHashText(String(body)))
3242
+ const prev = formalOf(target)
3243
+ // Dedupe (§5.3): the object is already `passed` AND the work file still holds EXACTLY
3244
+ // this content ⇒ the compile result is already known: skip rewrite + recompile.
3245
+ if (prev.status === 'passed' && (await leanHashFile(workRel)) === sha) {
3246
+ return { ok: true, deduped: true, kind, target, file: workRel, sha256: sha, proof: prev.proof || ('Verified/Lean/' + target + '.lean'), note: '内容与已归档版本一致,跳过重写与重编译' }
3247
+ }
2284
3248
  if (!await writeTextRel(workRel, body)) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + workRel }
3249
+ if (params.leanAsync !== false) {
3250
+ const pre = await leanPrecheck(workRel)
3251
+ if (pre.ok === true) {
3252
+ const pfx = await leanRunPrefix()
3253
+ const job = await enqueueLeanJob({
3254
+ jobId: leanJobId(target, leanBuildDigest(String(body), pfx.prefix)), kind: 'proof', rel: workRel, file: workRel, target,
3255
+ member: memberId, contentSha256: sha, buildHash: leanBuildHash(pfx.prefix), buildPrefix: pfx.prefix, content: String(body),
3256
+ })
3257
+ // The object is `attempted` while the job is queued: it may only become `passed`
3258
+ // when the job settles ok (exit 0 + unchanged hash). A previous proof pointer is
3259
+ // dropped because the file it referred to has just been overwritten.
3260
+ const rec0 = Object.assign({}, prev, {
3261
+ status: prev.status === 'blocked' ? 'blocked' : 'attempted',
3262
+ file: workRel, proof: '',
3263
+ decision: 'used', note: String(args.note || prev.note || ''),
3264
+ async: { jobId: job.jobId, state: 'queued', attempts: 0, enqueuedAt: job.enqueuedAt, startedAt: 0, settledAt: 0, exitCode: null },
3265
+ updatedAt: now(),
3266
+ })
3267
+ await putFormal(target, rec0)
3268
+ await rebuildLeanLibIndexes()
3269
+ await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
3270
+ + '(已入队后台编译;落地为“通过”之前**不会**写 Verified/Lean/、也不会转为忠实性审查)')
3271
+ return { ok: true, kind, target, file: workRel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, status: rec0.status, note: '已入队后台编译;只有作业落地为“通过”才会写 Verified/Lean/ 并转为忠实性审查' }
3272
+ }
3273
+ // No toolchain: fall through to the synchronous path (honest NO_SUBPROCESS record).
3274
+ }
2285
3275
  const run = await leanRunFile(workRel)
2286
- const prev = formalOf(target)
2287
3276
  const passed = !!run.ok
2288
3277
  const rec = Object.assign({}, prev, {
2289
3278
  status: passed ? 'passed' : 'attempted',
@@ -2297,6 +3286,7 @@ export function apply(ctx) {
2297
3286
  decision: 'used',
2298
3287
  note: String(args.note || prev.note || ''),
2299
3288
  run: { at: now(), ok: !!run.ok, exitCode: run.exitCode === undefined ? null : run.exitCode, ms: run.ms || 0, stdoutTail: tail(run.stdout, 800), stderrTail: tail(run.stderr, 800) },
3289
+ async: null,
2300
3290
  updatedAt: now(),
2301
3291
  })
2302
3292
  if (passed) await writeTextRel('Verified/Lean/' + target + '.lean', body)
@@ -2304,7 +3294,7 @@ export function apply(ctx) {
2304
3294
  await rebuildLeanLibIndexes()
2305
3295
  await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
2306
3296
  + '(运行 ' + (passed ? '**通过**,已归档到 ' + rec.proof + ',验证转为忠实性审查' : '**未通过**:' + tail(run.stderr || run.message, 160)) + ')')
2307
- return { ok: true, kind, target, file: workRel, proof: rec.proof, passed, run, status: rec.status }
3297
+ return { ok: true, kind, target, file: workRel, sha256: sha, proof: rec.proof, passed, run, status: rec.status, async: null }
2308
3298
  }
2309
3299
  if (kind === 'blocked') {
2310
3300
  const target = idSafe(String(args.target || ''))
@@ -3311,6 +4301,11 @@ export function apply(ctx) {
3311
4301
  solveVotes.clear()
3312
4302
  await putMeeting({ id, agenda: meeting.agenda, kind: meeting.kind, at: now(), file: 'Shared/Meetings/' + id + '.md' })
3313
4303
  await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
4304
+ // A meeting that ACTUALLY begins and was convened by the office is the other half of the
4305
+ // `paperEditor='office'` consultation requirement (docs/final-paper.md §7). Counted here, not at the
4306
+ // request: a parked/refused request is not a consultation. A FRAMEWORK-convened stall
4307
+ // meeting (`auto:true`) is not the office's own act, so it does not count either.
4308
+ if (meeting.by === 'office' && opts.auto !== true) await notePaperConsult('meeting')
3314
4309
  await mkdirs()
3315
4310
  await writeTextRel('Shared/Meetings/' + id + '.md', [
3316
4311
  '# 会议纪要|' + id + '|' + instituteName,
@@ -3429,9 +4424,24 @@ export function apply(ctx) {
3429
4424
  // Stop ONLY on a unanimous true solve-vote from every VOTING member. There is no
3430
4425
  // forced/flat/near-consensus closure: any objection keeps the institute working.
3431
4426
  async function checkSolved() {
4427
+ if (autoDone) return true
3432
4428
  const vs = voters().map((m) => m.id)
3433
4429
  if (!vs.length) return false
3434
4430
  if (!vs.every((id) => solveVotes.get(id) === true)) return false
4431
+ // ── THE FINAL PAPER PHASE COMES FIRST (docs/final-paper.md §3) ────────────────────────────────
4432
+ // `finishRun` flips phase='solved' / autoDone / running=false, after which the machinery
4433
+ // REFUSES: `wakeIfIdle` returns false, `startMeeting` rejects a concluded institute and
4434
+ // nothing schedules — so the co-writing, the cross-review and the office consultation
4435
+ // could never run. The run therefore stays alive here until the paper is finalised
4436
+ // (or degraded with a report), and `finalizePaper` calls `finishRun` for us.
4437
+ if (params.finalPaper !== false) {
4438
+ const p = paper()
4439
+ if (!p) { await startPaper('run-complete', {}); return true }
4440
+ if (p.status !== 'finalized') {
4441
+ if (!p.completesRun) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { completesRun: true }) : cur))
4442
+ return true
4443
+ }
4444
+ }
3435
4445
  await finishRun('全体有表决权者一致认为原问题已解决')
3436
4446
  return true
3437
4447
  }
@@ -3469,6 +4479,916 @@ export function apply(ctx) {
3469
4479
  notifyActivity()
3470
4480
  }
3471
4481
 
4482
+ // ================= final paper (docs/final-paper.md) ============
4483
+ // THE PHASE RUNS BEFORE `finishRun` (docs/final-paper.md §3). After `phase='solved'` the machinery
4484
+ // refuses: `wakeIfIdle` (:3257) returns false, `startMeeting` refuses a concluded
4485
+ // institute, and a concluded run has no scheduling at all — so the co-writing, the
4486
+ // cross-review and the office consultation could never run. `checkSolved` therefore
4487
+ // enters this phase while the run is still alive and only calls `finishRun` once the
4488
+ // paper is finalised (or degraded with a report).
4489
+ //
4490
+ // Only evidence that ALREADY exists is written down: verified verdicts, the task board,
4491
+ // the Lean records, the members' libraries and the members' own text. The framework never
4492
+ // invents content — sections 3/6/7/8 are the members' own words, 4/5/9 are derived from
4493
+ // durable state, and undecided/refuted items are always marked as such.
4494
+ const PAPER_FILE = (id, file) => 'Paper/' + id + '/' + file
4495
+ function paper() { return inst().paper || null }
4496
+ function paperActive() { const p = paper(); return !!p && p.status !== 'finalized' }
4497
+ function paperParticipants() {
4498
+ return activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher').map((m) => m.id)
4499
+ }
4500
+ function paperActiveParticipants(p) {
4501
+ return ((p && p.participants) || []).filter((id) => { const m = memberById(id); return !!m && m.phase === 'active' })
4502
+ }
4503
+ function paperConfiguredEditor() { return params.paperEditor === 'office' ? 'office' : 'academician' }
4504
+ function paperIdFromState() {
4505
+ const a = idSafe(instituteName)
4506
+ if (a) return a
4507
+ const b = idSafe(String(runId || (inst() && inst().runId) || ''))
4508
+ return b || 'institute'
4509
+ }
4510
+ async function fileExistsAbs(abs) {
4511
+ try { const t = await fsTargetAbs(abs); return (await fs.stat(t)) !== undefined } catch (e) { return false }
4512
+ }
4513
+ function paperAbs(id, file) { return instRoot() + '/' + PAPER_FILE(id, file) }
4514
+ function paperDirRel(id) { return 'Paper/' + id }
4515
+ async function mutatePaper(fn) {
4516
+ await patchInstitute({ paper: (cur) => { const next = fn(cur === undefined ? null : cur); return next === undefined ? (cur === undefined ? null : cur) : next } })
4517
+ return paper()
4518
+ }
4519
+ async function paperLog(title, body) {
4520
+ const p = paper()
4521
+ const id = (p && p.id) || paperIdFromState()
4522
+ const rel = PAPER_FILE(id, 'paper.log.md')
4523
+ const prev = (await readTextRel(rel)) || ('# 最终论文流程日志|' + id + '\n\n')
4524
+ await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
4525
+ return rel
4526
+ }
4527
+ function paperAskEvery() { return posMs(params.activityTimeoutMs, 120000) }
4528
+ function paperNextStep(p) {
4529
+ if (!p) return 'no paper flow yet: finish the run with finalPaper=true, or trigger /v5 paper manually'
4530
+ if (p.status === 'finalized') return 'finalised — /v5 paper force rewrites it'
4531
+ if (p.status === 'writing') return 'waiting for the staff parts (round ' + p.round + '/' + PAPER_MAX_ROUNDS + ')'
4532
+ if (p.status === 'reviewing') return 'waiting for the cross-reviews (each reviewer judges another part)'
4533
+ if (p.status === 'awaiting-editor' && p.editor === 'office') {
4534
+ return 'awaiting the OFFICE: send >=1 office message and convene >=1 meeting, then call vibe_v5_finalize_paper (consultation so far: messages=' +
4535
+ Number((p.consult || {}).messages || 0) + ', meetings=' + Number((p.consult || {}).meetings || 0) + ')'
4536
+ }
4537
+ if (p.status === 'awaiting-editor') return 'waiting for the academician to finalise'
4538
+ return p.status
4539
+ }
4540
+ function paperAutoNote() {
4541
+ return params.finalPaper === false ? '自动已关闭(finalPaper=false);本次为手动触发,仍会生成论文。' : ''
4542
+ }
4543
+ function paperSummary() {
4544
+ const p = paper()
4545
+ if (!p) return null
4546
+ return {
4547
+ id: p.id, status: p.status, stage: p.stage, round: p.round,
4548
+ editor: p.editor, editorFallback: !!p.editorFallback, completesRun: !!p.completesRun,
4549
+ participants: p.participants || [],
4550
+ parts: Object.keys(p.parts || {}).map((id) => ({ member: id, round: (p.parts[id] || {}).round, title: (p.parts[id] || {}).title || '' })),
4551
+ 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 })),
4552
+ reviewOf: p.reviewOf || {},
4553
+ consult: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
4554
+ lang: p.lang, format: p.format, compilePdf: p.pdf === true,
4555
+ compile: p.compile || null, engine: p.engine || '', artifacts: p.artifacts || [],
4556
+ warnings: p.warnings || [], disagreement: p.disagreement || [], skipped: p.skipped || [],
4557
+ forcedAfterCap: !!p.forcedAfterCap, finalizedAt: p.finalizedAt || null,
4558
+ dir: 'Paper/' + p.id + '/', next: paperNextStep(p),
4559
+ meta: p.status === 'finalized' ? 'Paper/' + p.id + '/paper.meta.json' : null,
4560
+ }
4561
+ }
4562
+
4563
+ // ---- the three asks ----------------------------------------------------
4564
+ function paperWritePrompt(member, p) {
4565
+ const L = []
4566
+ L.push('【最终论文·撰写 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
4567
+ L.push('本所对原问题的一致结论已经达成,现在撰写**最终论文**(第 ' + p.round + '/' + PAPER_MAX_ROUNDS + ' 轮)。')
4568
+ L.push('请你**只写你自己库里已有证据支撑**的内容:')
4569
+ L.push('- 直接引用你的卡片(Members/' + member.id + '/Propos|Methods|Subproblems/)、Members/' + member.id + '/Progress/progress.md、你参与的表决记录;')
4570
+ L.push('- **不得编造**:没有证据的推测不要写成结论;未决 / 被否证的条目必须显式标注“未定论 / 已被否证”;')
4571
+ L.push('- 在 evidence 里写清证据路径,附录会逐条索引。')
4572
+ if (p.round > 1) {
4573
+ const rv = (p.reviews || {})[member.id]
4574
+ L.push('')
4575
+ L.push('上一轮互审给你(' + member.id + ')部分的意见:' + (rv ? String(rv.comments || '(无具体意见)') : '(无)'))
4576
+ L.push('请据此修订;若不同意该意见,请在 limits 里写明理由。')
4577
+ }
4578
+ L.push('')
4579
+ L.push('------------')
4580
+ L.push(stateBlock(member))
4581
+ L.push('------------')
4582
+ L.push('结束时只输出一个 JSON 对象:')
4583
+ L.push('{ "paper_part": {')
4584
+ L.push(' "title": "你这部分的标题",')
4585
+ L.push(' "solution": "你对**原问题完整解法**的贡献(推理链与结论,只写有证据的)",')
4586
+ L.push(' "methods": "你创造/发现的方法、理论、思想、有价值经验、数学理解",')
4587
+ L.push(' "rules": "你从这些工作中归纳出的可复用规律",')
4588
+ L.push(' "limits": "局限、未决、被否证之处(必须诚实、显式)",')
4589
+ L.push(' "evidence": ["Members/' + member.id + '/Propos/p-x.md"] } }')
4590
+ return L.join('\n')
4591
+ }
4592
+ function paperReviewPrompt(member, p, ofId) {
4593
+ const part = ofId ? (p.parts || {})[ofId] : null
4594
+ const L = []
4595
+ L.push('【最终论文·互审 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
4596
+ L.push('请你审阅 **' + (ofId || '(未指定)') + '** 撰写的部分,判断它是否可以交付(deliverable)。')
4597
+ L.push('要点:证据是否充分;是否与已定论的表决一致;有无编造或未标注的未决项;术语与符号是否清楚。')
4598
+ L.push('')
4599
+ L.push('------------ 待审部分(' + (ofId || '') + ')------------')
4600
+ L.push('标题:' + ((part && part.title) || '(无)'))
4601
+ L.push('【完整解法】' + String((part && part.solution) || '(空)').slice(0, 6000))
4602
+ L.push('【方法/理论/思想/经验/理解】' + String((part && part.methods) || '(空)').slice(0, 4000))
4603
+ L.push('【规律】' + String((part && part.rules) || '(空)').slice(0, 3000))
4604
+ L.push('【局限/未决】' + String((part && part.limits) || '(空)').slice(0, 3000))
4605
+ L.push('【声称的证据】' + ((((part && part.evidence) || []).join('、')) || '(无)'))
4606
+ L.push('------------')
4607
+ L.push('')
4608
+ L.push(stateBlock(member))
4609
+ L.push('')
4610
+ L.push('结束时只输出一个 JSON 对象:')
4611
+ L.push('{ "paper_review": { "of": "' + (ofId || '') + '", "deliverable": true, "comments": "具体意见" } }')
4612
+ L.push('deliverable 必须是 true(可交付)或 false(不可交付,需修改)。只有**全体参与成员**都投 true,定稿代表才能定稿。')
4613
+ return L.join('\n')
4614
+ }
4615
+ function paperFinalPrompt(member, p) {
4616
+ const L = []
4617
+ L.push('【最终论文·定稿(院士)—— ' + member.id + '】')
4618
+ L.push('全体参与成员已在互审中表示“可交付”。请你作为定稿代表做**最后一次**把关:')
4619
+ L.push('核对合并稿与互审意见,确认没有编造、没有未标注的未决项、没有与表决记录矛盾之处,然后给出决定。')
4620
+ L.push('')
4621
+ L.push('------------ 合并稿(各成员部分)------------')
4622
+ for (const id of paperActiveParticipants(p)) {
4623
+ const part = (p.parts || {})[id] || {}
4624
+ L.push('### ' + id + '|' + String(part.title || ''))
4625
+ L.push('【完整解法】' + String(part.solution || '').slice(0, 3000))
4626
+ L.push('【方法/规律】' + (String(part.methods || '') + ' ' + String(part.rules || '')).slice(0, 2000))
4627
+ L.push('【局限】' + String(part.limits || '').slice(0, 1500))
4628
+ L.push('')
4629
+ }
4630
+ L.push('------------ 互审结论 ------------')
4631
+ for (const id of Object.keys(p.reviews || {})) {
4632
+ const rv = p.reviews[id] || {}
4633
+ L.push('- ' + id + ' 审 ' + (rv.of || '?') + ':deliverable=' + (rv.deliverable === true) + '|' + String(rv.comments || '').slice(0, 500))
4634
+ }
4635
+ if ((p.disagreement || []).length) L.push('- ⚠ 已达轮次上限仍有分歧:' + JSON.stringify(p.disagreement[p.disagreement.length - 1]).slice(0, 500))
4636
+ L.push('------------')
4637
+ L.push('')
4638
+ L.push(stateBlock(member))
4639
+ L.push('')
4640
+ L.push('结束时只输出一个 JSON 对象:')
4641
+ L.push('{ "paper_final": { "decision": "deliverable" | "revise",')
4642
+ L.push(' "note": "定稿说明:你如何审阅、统一术语与符号、是否发现并纠正了问题",')
4643
+ L.push(' "conclusion": "(可选)定稿代表对原问题的最终结论(只写有证据的)" } }')
4644
+ L.push('decision="revise" 会退回继续修订(有轮次上限)。')
4645
+ return L.join('\n')
4646
+ }
4647
+
4648
+ // ---- the stage driver --------------------------------------------------
4649
+ let paperLock = false
4650
+ async function paperStep() {
4651
+ const p = paper()
4652
+ if (!p || p.status === 'finalized' || p.status === 'awaiting-editor') return false
4653
+ const active = paperActiveParticipants(p)
4654
+ if (!active.length) return await paperAdvance(p, active)
4655
+ const parts = p.parts || {}
4656
+ const reviews = p.reviews || {}
4657
+ const missing = p.status === 'writing'
4658
+ ? active.filter((id) => !(parts[id] && parts[id].round === p.round))
4659
+ : active.filter((id) => !(reviews[id] && reviews[id].round === p.round))
4660
+ if (!missing.length) return await paperAdvance(p, active)
4661
+ const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
4662
+ let asked = 0
4663
+ for (const id of missing) {
4664
+ if (asked >= budget) break
4665
+ if (busy.has(id)) continue
4666
+ const m = memberById(id)
4667
+ if (!m || m.phase !== 'active') continue
4668
+ const last = (p.lastAskAt || {})[id] || 0
4669
+ if (last && (now() - last) < paperAskEvery()) continue
4670
+ const prompt = p.status === 'writing'
4671
+ ? paperWritePrompt(m, p)
4672
+ : paperReviewPrompt(m, p, (p.reviewOf || {})[id])
4673
+ // `wakeMember`, not `wakeIfIdle`: the paper phase deliberately drives members with a
4674
+ // dedicated ask, and a research round must never be mistaken for a paper answer.
4675
+ const ok = await wakeMember(m, prompt, 'paper')
4676
+ if (ok) {
4677
+ asked += 1
4678
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4679
+ lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now(),
4680
+ }) : cur))
4681
+ }
4682
+ }
4683
+ // Nothing to advance yet. ALWAYS re-arm: the pass that got here may have skipped an ask
4684
+ // because its member is busy or still inside the ask window, and `schedulePass` returns
4685
+ // from the paper branch BEFORE its own heartbeat arming — without this a paper whose ask
4686
+ // was paced out would stall until some unrelated member turn ended.
4687
+ if (asked) { armHeartbeat(); return false }
4688
+ // Stall watchdog (same clock as meetings/verifications): a member whose turn never ends
4689
+ // must not wedge the paper forever. The stuck members are recorded as non-participants.
4690
+ const stale = now() - Number(p.updatedAt || p.createdAt || now())
4691
+ if (stale >= recoverStallMs()) {
4692
+ const stuck = missing.filter((id) => busy.has(id))
4693
+ const name = stuck.length ? stuck : missing
4694
+ const warn = '看门狗:' + name.join('、') + ' 在 ' + Math.round(stale / 1000) + 's 内没有交稿,已按“未参与”处理并记入附录。'
4695
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4696
+ warnings: (cur.warnings || []).concat([warn]),
4697
+ skipped: Array.from(new Set((cur.skipped || []).concat(name))), updatedAt: now(),
4698
+ }) : cur))
4699
+ await paperLog('看门狗', warn)
4700
+ return await paperAdvance(paper(), paperActiveParticipants(paper()))
4701
+ }
4702
+ armHeartbeat()
4703
+ return false
4704
+ }
4705
+ async function paperAdvance(p, active) {
4706
+ if (p.status === 'writing') {
4707
+ const reviewOf = {}
4708
+ for (let i = 0; i < active.length; i++) reviewOf[active[i]] = active[(i + 1) % active.length]
4709
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4710
+ status: 'reviewing', stage: 'reviewing', reviewOf, reviews: {}, lastAskAt: {}, updatedAt: now(),
4711
+ }) : cur))
4712
+ await paperLog('进入互审(第 ' + p.round + ' 轮)', active.map((a) => a + ' 审 ' + reviewOf[a]).join(';') || '(无参与成员)')
4713
+ return true
4714
+ }
4715
+ if (p.status === 'reviewing') {
4716
+ const reviews = p.reviews || {}
4717
+ const refusers = active.filter((id) => !(reviews[id] && reviews[id].deliverable === true))
4718
+ if (!refusers.length) {
4719
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now() }) : cur))
4720
+ await paperLog('互审一致:可交付', '全体参与成员(' + active.join('、') + ')均表示可交付。定稿代表:' + p.editor)
4721
+ return true
4722
+ }
4723
+ const detail = refusers.map((id) => id + ':' + String((reviews[id] || {}).comments || '未说明')).join(';')
4724
+ const entry = { round: p.round, members: refusers, detail, at: now() }
4725
+ if (p.round < PAPER_MAX_ROUNDS) {
4726
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4727
+ status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
4728
+ disagreement: (cur.disagreement || []).concat([entry]),
4729
+ }) : cur))
4730
+ await paperLog('互审未达成一致 → 进入第 ' + (p.round + 1) + ' 轮', detail)
4731
+ return true
4732
+ }
4733
+ const warn = '互审在 ' + PAPER_MAX_ROUNDS + ' 轮上限仍未达成全体一致(' + refusers.join('、') + ');分歧已写入论文附录。'
4734
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4735
+ status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now(),
4736
+ warnings: (cur.warnings || []).concat([warn]), disagreement: (cur.disagreement || []).concat([entry]), forcedAfterCap: true,
4737
+ }) : cur))
4738
+ await paperLog('达到轮次上限:转交定稿代表', warn)
4739
+ return true
4740
+ }
4741
+ return false
4742
+ }
4743
+ async function paperAwaitEditor(p) {
4744
+ if (p.editor !== 'academician') return false // office: waits for vibe_v5_finalize_paper
4745
+ const id = academicianId()
4746
+ if (!id) {
4747
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4748
+ editor: 'office', editorFallback: true, updatedAt: now(),
4749
+ warnings: (cur.warnings || []).concat(['定稿代表 academician 不在册:已回退为 office(需人工定稿)。']),
4750
+ }) : cur))
4751
+ await paperLog('定稿代表回退', 'academician 不在册,回退为 office:用 /v5 paper 或 vibe_v5_finalize_paper 定稿(仍需先与全所交流并开会)。')
4752
+ return false
4753
+ }
4754
+ const m = memberById(id)
4755
+ if (!m || m.phase !== 'active') return false
4756
+ if (busy.has(id)) { armHeartbeat(); return false }
4757
+ const last = (p.lastAskAt || {})[id] || 0
4758
+ if (last && (now() - last) < paperAskEvery()) { armHeartbeat(); return false }
4759
+ const ok = await wakeMember(m, paperFinalPrompt(m, p), 'paper')
4760
+ if (ok) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now() }) : cur))
4761
+ armHeartbeat()
4762
+ return false
4763
+ }
4764
+ // The ONE driver. Called from `onMemberEnd` (after the reply is folded), from the manual
4765
+ // tools/command, from the resume path and from `schedulePass`; `paperLock` keeps it single.
4766
+ async function paperPass() {
4767
+ if (paperLock) return false
4768
+ paperLock = true
4769
+ try {
4770
+ for (let i = 0; i < 6; i++) {
4771
+ const p = paper()
4772
+ if (!p || p.status === 'finalized') return false
4773
+ if (p.status === 'awaiting-editor') { await paperAwaitEditor(p); return false }
4774
+ const advanced = await paperStep()
4775
+ if (!advanced) return false
4776
+ }
4777
+ return false
4778
+ } catch (e) {
4779
+ console.error('vibe-math-v5: paper pass: ' + String((e && e.stack) || e))
4780
+ return false
4781
+ } finally { paperLock = false }
4782
+ }
4783
+
4784
+ // ---- start / record / consult / finalize -------------------------------
4785
+ async function startPaper(trigger, opts) {
4786
+ const o = opts || {}
4787
+ const trg = String(trigger || 'manual')
4788
+ const existing = paper()
4789
+ if (existing && existing.status === 'finalized' && !o.force) {
4790
+ const refill = await refillPaperArtifacts(existing)
4791
+ return { ok: true, alreadyFinalized: true, id: existing.id, dir: 'Paper/' + existing.id + '/', refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
4792
+ }
4793
+ if (existing && existing.status !== 'finalized' && !o.force) {
4794
+ 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() }
4795
+ }
4796
+ const participants = paperParticipants()
4797
+ // A per-call editor override (`/v5 paper editor=office`) is ONE-SHOT: it is not written
4798
+ // back into the persisted params. An AUTOMATIC (run-complete) trigger always uses the
4799
+ // academician — the office is the root session and has no wake path (docs/final-paper.md §7).
4800
+ const configured = (o.editor === 'office' || o.editor === 'academician') ? o.editor : paperConfiguredEditor()
4801
+ const editor = trg === 'run-complete' ? 'academician' : configured
4802
+ const configWantsOffice = configured === 'office'
4803
+ const editorNote = (trg === 'run-complete' && configWantsOffice)
4804
+ ? 'paperEditor=office 已配置,但自动(收口)触发必须由**可唤醒**的院士定稿(所办是根会话,没有唤醒路径);本次自动流程用 academician。要所办定稿请在收尾后用 /v5 paper 手动触发。'
4805
+ : (editor === 'office' ? '所办定稿:必须先与全所交流(>=1 条所办消息)并至少召开一次会议,结论写进定稿说明,然后调用 vibe_v5_finalize_paper。' : '')
4806
+ const lang = (o.lang === 'en' || o.lang === 'zh') ? o.lang : (params.paperLanguage === 'en' ? 'en' : 'zh')
4807
+ const format = ['both', 'md', 'tex'].indexOf(o.format) !== -1 ? o.format : (['both', 'md', 'tex'].indexOf(params.paperFormat) !== -1 ? params.paperFormat : 'both')
4808
+ const p = {
4809
+ id: paperIdFromState(), status: 'writing', stage: 'writing', round: 1,
4810
+ trigger: trg, createdAt: now(), updatedAt: now(),
4811
+ participants, parts: {}, reviews: {}, reviewOf: {}, final: null,
4812
+ editor, editorNote, completesRun: trg === 'run-complete',
4813
+ lang, format, pdf: params.paperCompilePdf !== false,
4814
+ consult: { messages: 0, meetings: 0 },
4815
+ lastAskAt: {}, warnings: editorNote ? [editorNote] : [], disagreement: [], skipped: [], artifacts: [],
4816
+ forced: !!o.force, forceReason: o.force ? String(o.reason || 'manual force') : '',
4817
+ }
4818
+ await patchInstitute({ paper: p })
4819
+ await paperLog('流程开始(' + (trg === 'run-complete' ? '收口自动触发' : '手动触发') + ')', [
4820
+ '- 目录:Paper/' + p.id + '/',
4821
+ '- 语言:' + p.lang + '|格式:' + p.format + '|编译 PDF:' + p.pdf,
4822
+ '- 参与成员:' + (participants.join('、') || '(无常驻成员)'),
4823
+ '- 定稿代表:' + editor + (editorNote ? '(' + editorNote + ')' : ''),
4824
+ ].join('\n'))
4825
+ await paperPass()
4826
+ return {
4827
+ ok: true, started: true, id: p.id, dir: 'Paper/' + p.id + '/', status: 'writing',
4828
+ participants, editor, next: paperNextStep(paper()),
4829
+ autoDisabled: params.finalPaper === false, note: paperAutoNote(),
4830
+ }
4831
+ }
4832
+ async function paperRecordPart(memberId, raw) {
4833
+ const p = paper()
4834
+ 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') + ')' }
4835
+ if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a writing participant of this paper' }
4836
+ const part = {
4837
+ member: memberId, round: p.round,
4838
+ title: String(raw.title || '').slice(0, 300),
4839
+ solution: String(raw.solution || '').slice(0, 20000),
4840
+ methods: String(raw.methods || '').slice(0, 20000),
4841
+ rules: String(raw.rules || '').slice(0, 12000),
4842
+ limits: String(raw.limits || '').slice(0, 12000),
4843
+ evidence: Array.isArray(raw.evidence) ? raw.evidence.map(String).slice(0, 50) : [],
4844
+ at: now(),
4845
+ }
4846
+ if (!part.title && !part.solution && !part.methods && !part.rules && !part.limits) {
4847
+ await notice(memberId, 'paper_part 是空的:请至少给出 title 与你的贡献正文(solution/methods/rules/limits),只写库里已有证据支撑的内容。')
4848
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_part carries no content' }
4849
+ }
4850
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { parts: Object.assign({}, cur.parts || {}, { [memberId]: part }), updatedAt: now() }) : cur))
4851
+ await paperLog('收到 ' + memberId + ' 的部分(第 ' + p.round + ' 轮)', '- 标题:' + (part.title || '(无)') + '\n- 声称证据:' + (part.evidence.length || 0) + ' 条')
4852
+ return { ok: true, recorded: memberId, round: p.round }
4853
+ }
4854
+ async function paperRecordReview(memberId, raw) {
4855
+ const p = paper()
4856
+ 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') + ')' }
4857
+ if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a review participant of this paper' }
4858
+ if (raw.deliverable !== true && raw.deliverable !== false) {
4859
+ await notice(memberId, 'paper_review.deliverable 必须是 true 或 false(你敢不敢交付?未表态不算可交付)。')
4860
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_review.deliverable must be a boolean' }
4861
+ }
4862
+ const assigned = (p.reviewOf || {})[memberId]
4863
+ const rev = {
4864
+ member: memberId, of: String(raw.of || assigned || ''), round: p.round,
4865
+ deliverable: raw.deliverable === true,
4866
+ comments: String(raw.comments || '').slice(0, 8000), at: now(),
4867
+ }
4868
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { reviews: Object.assign({}, cur.reviews || {}, { [memberId]: rev }), updatedAt: now() }) : cur))
4869
+ await paperLog('互审意见|' + memberId + ' → ' + rev.of, '- deliverable:' + rev.deliverable + '\n- 意见:' + (rev.comments || '(无)').slice(0, 2000))
4870
+ return { ok: true, recorded: memberId, of: rev.of, deliverable: rev.deliverable }
4871
+ }
4872
+ async function paperRecordFinal(memberId, raw) {
4873
+ const p = paper()
4874
+ 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') + ')' }
4875
+ if (p.editor !== 'academician' || memberId !== academicianId()) {
4876
+ return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'paperEditor is "' + p.editor + '": the academician does not finalise this paper' }
4877
+ }
4878
+ const decision = String(raw.decision || '')
4879
+ if (decision !== 'deliverable' && decision !== 'revise') {
4880
+ await notice(memberId, "paper_final.decision 必须是 'deliverable' 或 'revise'(收到 " + decision + ')。')
4881
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'decision must be deliverable|revise' }
4882
+ }
4883
+ const fin = { by: memberId, decision, note: String(raw.note || '').slice(0, 4000), conclusion: String(raw.conclusion || '').slice(0, 20000), at: now() }
4884
+ if (decision === 'deliverable') {
4885
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
4886
+ return await finalizePaper('academician-deliverable')
4887
+ }
4888
+ if (p.round < PAPER_MAX_ROUNDS) {
4889
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4890
+ final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
4891
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
4892
+ }) : cur))
4893
+ await paperLog('定稿代表要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note || '(未说明)')
4894
+ return { ok: true, revised: true, round: p.round + 1 }
4895
+ }
4896
+ const warn = '定稿代表在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
4897
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4898
+ final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
4899
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
4900
+ }) : cur))
4901
+ await paperLog('达到轮次上限:定稿代表仍要求修订', warn)
4902
+ return await finalizePaper('academician-revise-at-cap')
4903
+ }
4904
+ // The office consultation gate (v2 §A6 / the user's requirement): the office may finalise
4905
+ // ONLY after it has actually talked to the institute (>=1 office message) AND convened at
4906
+ // least one meeting; both counts are recorded in the finalisation note and the meta.
4907
+ async function finalizePaperByOffice(o) {
4908
+ const args = o || {}
4909
+ const p = paper()
4910
+ 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)' }
4911
+ if (p.status === 'finalized' && !args.force) {
4912
+ const refill = await refillPaperArtifacts(p)
4913
+ return { ok: true, alreadyFinalized: true, id: p.id, refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
4914
+ }
4915
+ if (p.status !== 'awaiting-editor') {
4916
+ 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' }
4917
+ }
4918
+ if (p.editor !== 'office') {
4919
+ return { ok: false, code: 'V5_NOT_OFFICE', message: 'paperEditor="academician": the academician finalises from its own round; the office does not' }
4920
+ }
4921
+ const decision = String(args.decision || '')
4922
+ if (decision !== 'deliverable' && decision !== 'revise') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "decision must be 'deliverable' or 'revise'" }
4923
+ const note = String(args.note || '').trim()
4924
+ if (decision === 'deliverable' && !note) {
4925
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '定稿说明(note)是必填的:写明你如何与全所交流、商讨、优化、审查,以及结论' }
4926
+ }
4927
+ const consult = Object.assign({ messages: 0, meetings: 0 }, p.consult || {})
4928
+ if (!(Number(consult.messages) > 0) || !(Number(consult.meetings) > 0)) {
4929
+ return {
4930
+ ok: false, code: 'V5_PAPER_CONSULT_REQUIRED',
4931
+ message: 'paperEditor="office" 要求所办先与全所交流、商讨、优化、审查:至少 1 条所办消息(vibe_v5_message)+ 至少 1 次会议(vibe_v5_meeting),然后才能定稿。当前:messages=' +
4932
+ Number(consult.messages || 0) + ', meetings=' + Number(consult.meetings || 0),
4933
+ consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
4934
+ }
4935
+ }
4936
+ const fin = {
4937
+ by: 'office', decision, note: String(note).slice(0, 4000),
4938
+ conclusion: String(args.conclusion || '').slice(0, 20000),
4939
+ consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
4940
+ at: now(),
4941
+ }
4942
+ if (decision === 'deliverable') {
4943
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
4944
+ return await finalizePaper('office-deliverable')
4945
+ }
4946
+ if (p.round < PAPER_MAX_ROUNDS) {
4947
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4948
+ final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
4949
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
4950
+ }) : cur))
4951
+ await paperLog('所办要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note)
4952
+ return { ok: true, revised: true, round: p.round + 1 }
4953
+ }
4954
+ const warn = '所办在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
4955
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4956
+ final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
4957
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
4958
+ }) : cur))
4959
+ await paperLog('达到轮次上限:所办仍要求修订', warn)
4960
+ return await finalizePaper('office-revise-at-cap')
4961
+ }
4962
+ // Count the office's consultation evidence. Called from the ONE place each act really
4963
+ // happens (`say` for a message, `beginMeeting` for a meeting that actually starts), so the
4964
+ // gate cannot be satisfied by merely intending to consult.
4965
+ async function notePaperConsult(kind) {
4966
+ const p = paper()
4967
+ if (!p || p.status === 'finalized') return
4968
+ const key = kind === 'meeting' ? 'meetings' : 'messages'
4969
+ await mutatePaper((cur) => {
4970
+ if (!cur || cur.status === 'finalized') return cur
4971
+ const consult = Object.assign({ messages: 0, meetings: 0 }, cur.consult || {})
4972
+ consult[key] = Number(consult[key] || 0) + 1
4973
+ return Object.assign({}, cur, { consult, updatedAt: now() })
4974
+ })
4975
+ }
4976
+
4977
+ // ---- artifacts ---------------------------------------------------------
4978
+ function paperEvidence() {
4979
+ const s = inst()
4980
+ const V = s.verdicts || {}
4981
+ const keys = Object.keys(V)
4982
+ const closed = (o) => keys.filter((k) => V[k] && V[k].closed && V[k].outcome === o)
4983
+ const tasks = (s.tasks || []).filter((t) => t && t.status !== 'deleted')
4984
+ const formal = formalRecords()
4985
+ return {
4986
+ s, V,
4987
+ trueV: closed('true'), falseV: closed('false'), undecidedV: closed('undecided'),
4988
+ openV: keys.filter((k) => V[k] && !V[k].closed),
4989
+ tasks, done: tasks.filter((t) => t.status === 'completed'), openT: tasks.filter((t) => t.status !== 'completed'),
4990
+ formal, formalKeys: Object.keys(formal),
4991
+ members: s.members || [],
4992
+ }
4993
+ }
4994
+ function verdictCardRel(kind, id) { return 'Verified/' + (kind === 'subproblem' ? '问题' : kind === 'method' ? '方法' : '命题') + '/' + id + '.md' }
4995
+ function cardDirOf(kind) { return kind === 'method' ? 'Methods' : kind === 'subproblem' ? 'Subproblems' : 'Propos' }
4996
+ function paperTitle(p) {
4997
+ const st = String((inst().problem || {}).statement || '').trim()
4998
+ const head = st ? st.split(/\n/)[0].slice(0, 100) : ''
4999
+ return head || ('研究所成果论文|' + p.id)
5000
+ }
5001
+ function paperSources(p, active, ev) {
5002
+ const out = []
5003
+ out.push('Problems/conclusion.md(结题记录)')
5004
+ if ((inst().problem || {}).id) out.push('Problems/' + inst().problem.id + '.md(原问题卡)')
5005
+ out.push('State/' + instituteName + '.v5state.json(权威状态)')
5006
+ for (const k of ev.trueV.concat(ev.falseV)) out.push(verdictCardRel((ev.V[k] || {}).kind, k) + '(Verified 卡片)')
5007
+ for (const k of ev.trueV.concat(ev.falseV)) out.push('Shared/Debates/' + k + '.md(辩论录)')
5008
+ for (const k of ev.formalKeys) {
5009
+ const r = ev.formal[k] || {}
5010
+ if (r.file) out.push(r.file + '(Lean 工作文件)')
5011
+ if (r.proof) out.push(r.proof + '(归档证明)')
5012
+ }
5013
+ if (ev.formalKeys.length) out.push('Formal/Index.md', 'Formal/TODO.md')
5014
+ for (const m of ev.members) {
5015
+ out.push('Members/' + m.id + '/Progress/progress.md(' + m.id + ' 的研究日志)')
5016
+ out.push('Members/' + m.id + '/{Propos,Methods,Subproblems}/(' + m.id + ' 的卡片)')
5017
+ }
5018
+ out.push('Institutes.md(编制镜像)', 'Shared/TaskBoard.md(任务板镜像)')
5019
+ out.push('Paper/' + p.id + '/paper.log.md(本论文流程往来)')
5020
+ for (const id of active) {
5021
+ const part = (p.parts || {})[id] || {}
5022
+ for (const e of (part.evidence || [])) out.push(String(e) + '(' + id + ' 声称的证据)')
5023
+ }
5024
+ return Array.from(new Set(out))
5025
+ }
5026
+ function paperSections(p, active, skipped, ev, extra) {
5027
+ const o = extra || {}
5028
+ const fin = o.final || p.final || null
5029
+ const prob = ev.s.problem || {}
5030
+ const S = []
5031
+ // 1
5032
+ const b1 = []
5033
+ b1.push({ p: '标题:' + paperTitle(p) })
5034
+ b1.push({ ul: [
5035
+ '作者:' + ((p.participants || []).map((id) => id + '(' + ((memberById(id) || {}).kind === 'academician' ? '院士' : '常驻研究员') + ')').join('、') || '(无在册作者)'),
5036
+ '研究所:' + instituteName + '|项目:' + project + '|运行:' + (ev.s.runId || runId || '(无)'),
5037
+ '日期:' + fmtTime(p.finalizedAt || now()).slice(0, 10) + '|语言:' + p.lang + '|定稿代表:' + p.editor,
5038
+ ] })
5039
+ const mainConclusions = ev.trueV.length
5040
+ ? ('已由表决确立的结论:' + ev.trueV.join('、'))
5041
+ : '尚无经表决确立为真的结论(见第 4、8 节)。'
5042
+ b1.push({ p: '摘要:' + (prob.statement ? String(prob.statement).replace(/\s+/g, ' ').slice(0, 400) : '(未设定原问题)') + '。' + mainConclusions })
5043
+ if (fin && fin.note) b1.push({ p: '定稿说明:' + fin.note + (fin.consult ? '(定稿前与全所交流:消息 ' + fin.consult.messages + ' 条、会议 ' + fin.consult.meetings + ' 次)' : '') })
5044
+ if (fin && fin.conclusion) b1.push({ p: '定稿结论:' + fin.conclusion })
5045
+ S.push({ title: PAPER_SECTIONS[0], blocks: b1 })
5046
+ // 2
5047
+ S.push({ title: PAPER_SECTIONS[1], blocks: [
5048
+ { p: prob.statement ? String(prob.statement) : '(本所未记录原问题的完整陈述——请勿在论文中补写。)' },
5049
+ { p: '问题 ID:' + (prob.id || '(无)') + '|参与成员:' + (p.participants || []).join('、') },
5050
+ ] })
5051
+ // 3
5052
+ const b3 = []
5053
+ const solvers = active.filter((id) => String(((p.parts || {})[id] || {}).solution || '').trim())
5054
+ if (solvers.length) {
5055
+ for (const id of solvers) {
5056
+ const part = (p.parts || {})[id] || {}
5057
+ b3.push({ h: id + '|' + String(part.title || '贡献') })
5058
+ b3.push({ p: String(part.solution) })
5059
+ }
5060
+ } else {
5061
+ b3.push({ p: '(参与成员未提交可引用的完整解法叙述;请以第 4 节的表决结论与证据索引为准。)' })
5062
+ }
5063
+ if (o.conclusionText) b3.push({ h: '结题记录(Problems/conclusion.md)' }, { p: String(o.conclusionText).slice(0, 4000) })
5064
+ S.push({ title: PAPER_SECTIONS[2], blocks: b3 })
5065
+ // 4
5066
+ const b4 = []
5067
+ if (ev.trueV.length) {
5068
+ b4.push({ p: '以下对象经 m 票布尔一致判定为**真**(证据:Verified 卡片 + 辩论录):' })
5069
+ b4.push({ ul: ev.trueV.map((k) => {
5070
+ const v = ev.V[k] || {}
5071
+ return '**' + k + '**(判为真;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';表决者 ' + ((v.voters || []).join('、') || '?') +
5072
+ ';证据:' + verdictCardRel(v.kind, k) + ';辩论:Shared/Debates/' + k + '.md;提出者:' + (v.proposer || '(office)') + ')'
5073
+ }) })
5074
+ } else b4.push({ p: '(没有经表决判定为真的命题。)' })
5075
+ if (ev.falseV.length) {
5076
+ b4.push({ p: '**已被否证(显式标注,不得当作结论使用)**:' })
5077
+ b4.push({ ul: ev.falseV.map((k) => {
5078
+ const v = ev.V[k] || {}
5079
+ return k + '(判为假;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';证据:' + verdictCardRel(v.kind, k) + ')'
5080
+ }) })
5081
+ }
5082
+ S.push({ title: PAPER_SECTIONS[3], blocks: b4 })
5083
+ // 5
5084
+ const b5 = []
5085
+ const subV = ev.trueV.filter((k) => (ev.V[k] || {}).kind === 'subproblem')
5086
+ if (subV.length) b5.push({ ul: subV.map((k) => '子问题 ' + k + '(已判定为真;证据:' + verdictCardRel('subproblem', k) + ')') })
5087
+ if (ev.done.length) b5.push({ ul: ev.done.map((t) => '任务 ' + t.id + ':' + String(t.subject || '') + '(owner=' + (t.ownerId || '?') + ';镜像:Shared/TaskBoard.md)') })
5088
+ if (!subV.length && !ev.done.length) b5.push({ p: '(没有已完成的子问题或任务被记录。)' })
5089
+ S.push({ title: PAPER_SECTIONS[4], blocks: b5 })
5090
+ // 6
5091
+ const b6 = []
5092
+ const methodParts = active.filter((id) => String(((p.parts || {})[id] || {}).methods || '').trim())
5093
+ for (const id of methodParts) b6.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).methods) })
5094
+ if (!methodParts.length) b6.push({ p: '(参与成员未提交方法/理论/思想/经验/数学理解的叙述。)' })
5095
+ S.push({ title: PAPER_SECTIONS[5], blocks: b6 })
5096
+ // 7
5097
+ const b7 = []
5098
+ const ruleParts = active.filter((id) => String(((p.parts || {})[id] || {}).rules || '').trim())
5099
+ for (const id of ruleParts) b7.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).rules) })
5100
+ if (!ruleParts.length) b7.push({ p: '(参与成员未归纳出可复用规律。)' })
5101
+ S.push({ title: PAPER_SECTIONS[6], blocks: b7 })
5102
+ // 8
5103
+ const b8 = []
5104
+ const limitParts = active.filter((id) => String(((p.parts || {})[id] || {}).limits || '').trim())
5105
+ for (const id of limitParts) b8.push({ h: id }, { p: String(((p.parts || {})[id] || {}).limits) })
5106
+ const unresolved = []
5107
+ for (const k of ev.undecidedV) unresolved.push('未定论:' + k + '(平均概率 ' + Number((ev.V[k] || {}).mean || 0).toFixed(2) + ',m=' + ((ev.V[k] || {}).m || '?') + ')')
5108
+ for (const k of ev.openV) unresolved.push('尚未裁决:' + k)
5109
+ for (const t of ev.openT) unresolved.push('未完成任务:' + t.id + '|' + String(t.subject || '') + '(' + t.status + ')')
5110
+ 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) + ')' : ''))
5111
+ for (const id of skipped) unresolved.push('未交稿/未参与:' + id)
5112
+ if (unresolved.length) b8.push({ p: '**未决与未参与(显式标注)**:' }, { ul: unresolved })
5113
+ if ((p.warnings || []).length) b8.push({ p: '流程警告:' }, { ul: (p.warnings || []).map(String) })
5114
+ if (!limitParts.length && !unresolved.length && !(p.warnings || []).length) b8.push({ p: '(无特别记录的局限。)' })
5115
+ S.push({ title: PAPER_SECTIONS[7], blocks: b8 })
5116
+ // 9
5117
+ const b9 = [{ p: '以下路径均相对于研究所根目录(与 State/<institute>.v5state.json 同级)。' }]
5118
+ b9.push({ ul: paperSources(p, active, ev) })
5119
+ if ((p.disagreement || []).length) {
5120
+ b9.push({ h: '分歧记录(未达成全体一致 / 定稿代表要求修订)' })
5121
+ b9.push({ ul: (p.disagreement || []).map((d) => '第 ' + d.round + ' 轮|' + (d.members || []).join('、') + ':' + String(d.detail || '')) })
5122
+ }
5123
+ if (skipped.length) b9.push({ p: '按“未参与”处理的成员:' + skipped.join('、') })
5124
+ S.push({ title: PAPER_SECTIONS[8], blocks: b9 })
5125
+ return S
5126
+ }
5127
+ function renderPaperMd(p, S) {
5128
+ const L = ['# ' + paperTitle(p), '']
5129
+ S.forEach((sec, i) => {
5130
+ L.push('## ' + (i + 1) + '. ' + sec.title)
5131
+ L.push('')
5132
+ for (const b of sec.blocks) {
5133
+ if (b.h) { L.push('### ' + b.h); L.push('') }
5134
+ else if (b.p !== undefined) { L.push(String(b.p)); L.push('') }
5135
+ else if (b.ul) { for (const it of b.ul) L.push('- ' + String(it)); L.push('') }
5136
+ }
5137
+ })
5138
+ return L.join('\n')
5139
+ }
5140
+ const TEX_ESCAPES = { '\\': '\\textbackslash{}', '&': '\\&', '%': '\\%', '$': '\\$', '#': '\\#', '_': '\\_', '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}' }
5141
+ function texEscape(s) { return String(s == null ? '' : s).replace(/[\\&%$#_{}~^]/g, (c) => TEX_ESCAPES[c]) }
5142
+ function renderPaperTex(p, S) {
5143
+ const zh = p.lang !== 'en'
5144
+ const L = [
5145
+ '\\documentclass[11pt]{' + (zh ? 'ctexart' : 'article') + '}',
5146
+ '\\usepackage{amsmath,amssymb,amsthm}',
5147
+ '\\usepackage[margin=2.5cm]{geometry}',
5148
+ '\\usepackage[hidelinks]{hyperref}',
5149
+ '\\usepackage{longtable,booktabs}',
5150
+ '\\title{' + texEscape(paperTitle(p)) + '}',
5151
+ '\\author{' + texEscape(((p.participants || []).join('、')) || (zh ? '研究所' : 'Institute')) + '}',
5152
+ '\\date{' + texEscape(fmtTime(p.finalizedAt || now()).slice(0, 10)) + '}',
5153
+ '\\begin{document}',
5154
+ '\\maketitle',
5155
+ ]
5156
+ S.forEach((sec, i) => {
5157
+ L.push('\\section{' + texEscape((i + 1) + '. ' + sec.title) + '}')
5158
+ for (const b of sec.blocks) {
5159
+ if (b.h) L.push('\\subsection{' + texEscape(b.h) + '}')
5160
+ else if (b.p !== undefined) L.push(texEscape(b.p) + '\n')
5161
+ else if (b.ul) { L.push('\\begin{itemize}'); for (const it of b.ul) L.push('\\item ' + texEscape(it)); L.push('\\end{itemize}') }
5162
+ }
5163
+ })
5164
+ L.push('\\end{document}')
5165
+ return L.join('\n') + '\n'
5166
+ }
5167
+ // The repair pass keeps only the packages the text itself needs (docs/final-paper.md §8):
5168
+ // every optional or unknown package is dropped before the engine swap retry.
5169
+ function texStripOptionalPackages(tex) {
5170
+ return String(tex).split('\n').filter((line) => {
5171
+ if (/^\s*\\(?:input|include)\b/.test(line)) return false
5172
+ const m = /^\s*\\usepackage(?:\[[^\]]*\])?\{([^}]+)\}/.exec(line)
5173
+ if (!m) return true
5174
+ return m[1].split(',').map((x) => x.trim()).some((pk) => PAPER_REPAIR_CORE.indexOf(pk) !== -1)
5175
+ }).join('\n')
5176
+ }
5177
+ function texMinimal(tex, lang) {
5178
+ const body = String(tex).replace(/[\s\S]*?\\begin\{document\}/, '').replace(/\\end\{document\}[\s\S]*$/, '')
5179
+ return '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}\n\\begin{document}\n' + body.trim() + '\n\\end{document}\n'
5180
+ }
5181
+ function paperEngineCandidates(lang) {
5182
+ const order = PAPER_ENGINE_ORDER[lang === 'en' ? 'en' : 'zh'].slice()
5183
+ const forced = String(params.paperLatexCommand || '').trim()
5184
+ if (!forced) return order
5185
+ return [forced].concat(order.filter((n) => n !== forced))
5186
+ }
5187
+ async function latexEngines(lang) {
5188
+ const sub = subprocessOf()
5189
+ if (!sub || typeof sub.resolveExecutable !== 'function') {
5190
+ return { engines: [], reason: '宿主没有 subprocess 服务:无法检测或调用 LaTeX' }
5191
+ }
5192
+ const engines = []
5193
+ for (const name of paperEngineCandidates(lang)) {
5194
+ try { const exe = await sub.resolveExecutable(name); if (exe) engines.push({ name: String(name), exe: String(exe) }) } catch (e) { /* not installed */ }
5195
+ }
5196
+ return { engines, reason: engines.length ? '' : ('未检测到 LaTeX 引擎(' + paperEngineCandidates(lang).join('/') + ')') }
5197
+ }
5198
+ // ONE engine invocation. NEVER throws: every failure becomes a readable result (the Lean
5199
+ // seam's discipline) and a timeout really terminates the process.
5200
+ async function runPaperProcess(argv, cwd) {
5201
+ const sub = subprocessOf()
5202
+ const started = now()
5203
+ if (!sub || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, ms: 0, message: 'no subprocess service' }
5204
+ let handle
5205
+ try {
5206
+ handle = sub.spawn({ argv, cwd, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: 120000 })
5207
+ } catch (e) { return { ok: false, exitCode: null, ms: now() - started, message: String((e && e.message) || e) } }
5208
+ let outcome = null
5209
+ let timer = null
5210
+ try {
5211
+ const ran = Promise.resolve(handle.done).then((v) => ({ settled: true, value: v }), (e) => ({ settled: false, error: e }))
5212
+ const r = await Promise.race([
5213
+ ran,
5214
+ new Promise((resolve) => {
5215
+ timer = ctx.timeout(() => {
5216
+ try { if (typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* the race result is the report */ }
5217
+ resolve({ settled: true, value: { exitCode: null, signal: 'SIGTERM' } })
5218
+ }, 120000)
5219
+ }),
5220
+ ])
5221
+ if (!r.settled) return { ok: false, exitCode: null, ms: now() - started, message: String((r.error && r.error.message) || r.error) }
5222
+ outcome = r.value
5223
+ } finally { if (timer) { try { timer() } catch (e) { /* already settled */ } } }
5224
+ const exitCode = outcome ? outcome.exitCode : null
5225
+ return { ok: exitCode === 0, exitCode, ms: now() - started, message: '' }
5226
+ }
5227
+ // Compile attempts are CAPPED (docs/final-paper.md §8): full → nonstopmode rerun → engine swap with
5228
+ // the optional packages stripped → one minimal-template retry → report and degrade.
5229
+ async function compilePaperTex(id, tex, lang) {
5230
+ if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
5231
+ return { status: 'kept-existing', engine: '', reason: 'Paper/' + id + '/paper.pdf 已存在:不覆盖(docs/final-paper.md §5)', attempts: [] }
5232
+ }
5233
+ const det = await latexEngines(lang)
5234
+ if (!det.engines.length) return { status: 'not-detected', engine: '', reason: det.reason, attempts: [] }
5235
+ const e0 = det.engines[0], e1 = det.engines[1] || e0
5236
+ const plan = [
5237
+ { label: 'full', engine: e0, text: tex, nonstopOnly: false },
5238
+ { label: 'nonstopmode-rerun', engine: e0, text: tex, nonstopOnly: true },
5239
+ { label: 'engine-swap+stripped', engine: e1, text: texStripOptionalPackages(tex), nonstopOnly: true },
5240
+ { label: 'minimal-template', engine: e0, text: texMinimal(tex, lang), nonstopOnly: true },
5241
+ ]
5242
+ const attempts = []
5243
+ for (const a of plan) {
5244
+ const r = await runLatexAttempt(id, a)
5245
+ attempts.push(r)
5246
+ if (r.ok) return { status: 'compiled', engine: a.engine.name, attempts }
5247
+ }
5248
+ return { status: 'failed', engine: '', reason: '编译在 ' + attempts.length + ' 次尝试后仍失败(已保留 paper.tex 与 paper.md)', attempts }
5249
+ }
5250
+ async function runLatexAttempt(id, a) {
5251
+ const dirRel = paperDirRel(id)
5252
+ const dirAbs = instRoot() + '/' + dirRel
5253
+ if (!await writeTextRel(dirRel + '/paper.tex', a.text)) {
5254
+ return { label: a.label, engine: a.engine.name, ok: false, exitCode: null, ms: 0, message: 'tex 写入失败' }
5255
+ }
5256
+ const started = now()
5257
+ const args = a.engine.name === 'tectonic'
5258
+ ? ['-X', 'paper.tex']
5259
+ : (a.nonstopOnly ? ['-interaction=nonstopmode', 'paper.tex'] : ['-interaction=nonstopmode', '-halt-on-error', 'paper.tex'])
5260
+ const argv = [a.engine.exe].concat(args)
5261
+ // TWO passes (docs/final-paper.md §8): the second resolves references/TOC. A failed first pass is not
5262
+ // rerun — the retry PLAN is what varies the command, not a blind repeat.
5263
+ const first = await runPaperProcess(argv, dirAbs)
5264
+ 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' }
5265
+ const second = await runPaperProcess(argv, dirAbs)
5266
+ const pdf = await fileExistsAbs(paperAbs(id, 'paper.pdf'))
5267
+ return { label: a.label, engine: a.engine.name, ok: second.ok && pdf, exitCode: second.exitCode, ms: now() - started, message: pdf ? (second.message || '') : '编译器没有产出 paper.pdf' }
5268
+ }
5269
+ function paperMeta(p, extra) {
5270
+ const o = extra || {}
5271
+ const active = paperActiveParticipants(p)
5272
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
5273
+ return {
5274
+ id: p.id,
5275
+ dir: 'Paper/' + p.id + '/',
5276
+ institute: instituteName, project, runId: inst().runId || runId || '',
5277
+ problemId: (inst().problem || {}).id || '',
5278
+ trigger: p.trigger,
5279
+ finalizedAt: o.finalizedAt || (p.finalizedAt || now()),
5280
+ language: p.lang, format: p.format, compilePdf: p.pdf === true,
5281
+ editor: p.editor, editorNote: p.editorNote || '', editorFallback: !!p.editorFallback,
5282
+ finalisation: p.final || null,
5283
+ consultation: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
5284
+ participants: p.participants || [], skipped,
5285
+ rounds: p.round, maxRounds: PAPER_MAX_ROUNDS,
5286
+ 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 })),
5287
+ 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 })),
5288
+ disagreement: p.disagreement || [], warnings: o.warnings || p.warnings || [],
5289
+ forcedAfterCap: !!p.forcedAfterCap,
5290
+ sections: PAPER_SECTIONS.length,
5291
+ compile: o.compile || { status: p.compile || 'unknown', engine: p.engine || '', attempts: [] },
5292
+ params: {
5293
+ finalPaper: params.finalPaper, paperFormat: params.paperFormat, paperLanguage: params.paperLanguage,
5294
+ paperCompilePdf: params.paperCompilePdf, paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
5295
+ },
5296
+ files: o.files || [],
5297
+ }
5298
+ }
5299
+ async function refillPaperArtifacts(p) {
5300
+ const id = p.id
5301
+ const active = paperActiveParticipants(p)
5302
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
5303
+ const conclusionText = await readTextRel('Problems/conclusion.md')
5304
+ const S = paperSections(p, active, skipped, paperEvidence(), { final: p.final, conclusionText })
5305
+ const filled = []
5306
+ if (p.format !== 'tex' && (await readTextAbs(paperAbs(id, 'paper.md'))) === undefined) {
5307
+ if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) filled.push('paper.md')
5308
+ }
5309
+ let tex = await readTextAbs(paperAbs(id, 'paper.tex'))
5310
+ if (p.format !== 'md' && tex === undefined) {
5311
+ tex = renderPaperTex(p, S)
5312
+ if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) filled.push('paper.tex')
5313
+ }
5314
+ if (p.format !== 'md' && p.pdf === true && !await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
5315
+ const c = await compilePaperTex(id, tex || renderPaperTex(p, S), p.lang)
5316
+ if (c.status === 'compiled') filled.push('paper.pdf')
5317
+ // Same rule as finalizePaper: the delivered tex is always the canonical generated one.
5318
+ if (tex !== undefined && tex !== null) await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
5319
+ }
5320
+ if ((await readTextAbs(paperAbs(id, 'paper.meta.json'))) === undefined) {
5321
+ if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(paperMeta(p, { refilled: true }), null, 2) + '\n')) filled.push('paper.meta.json')
5322
+ }
5323
+ if ((await readTextAbs(paperAbs(id, 'paper.log.md'))) === undefined) {
5324
+ await paperLog('重复触发:补写缺失产物', '- 本次补写:' + (filled.join('、') || '(无缺失)'))
5325
+ filled.push('paper.log.md')
5326
+ } else if (filled.length) {
5327
+ await paperLog('重复触发:补写缺失产物', '- 本次补写:' + filled.join('、'))
5328
+ }
5329
+ return { filled }
5330
+ }
5331
+ async function finalizePaper(reason) {
5332
+ const p = paper()
5333
+ if (!p) return { ok: false, code: 'V5_PAPER_STATE', message: 'no paper flow' }
5334
+ const id = p.id
5335
+ const active = paperActiveParticipants(p)
5336
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
5337
+ const ev = paperEvidence()
5338
+ const conclusionText = await readTextRel('Problems/conclusion.md')
5339
+ const finalizedAt = now()
5340
+ const S = paperSections(p, active, skipped, ev, { final: p.final, conclusionText })
5341
+ const files = []
5342
+ if (p.format !== 'tex') {
5343
+ if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) files.push('paper.md')
5344
+ }
5345
+ let tex = ''
5346
+ if (p.format !== 'md') {
5347
+ tex = renderPaperTex(p, S)
5348
+ if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) files.push('paper.tex')
5349
+ }
5350
+ // `paperFormat=md` with `paperCompilePdf=true` must NOT warn about a missing tex: no tex
5351
+ // was produced, so compilation is skipped silently (docs/final-paper.md §8).
5352
+ let compile
5353
+ if (p.format === 'md') compile = { status: 'skipped', engine: '', reason: 'paperFormat=md:未产出 tex,跳过编译', attempts: [] }
5354
+ else if (p.pdf !== true) compile = { status: 'skipped', engine: '', reason: 'paperCompilePdf=false', attempts: [] }
5355
+ else compile = await compilePaperTex(id, tex, p.lang)
5356
+ // The repair attempts overwrite `paper.tex` with stripped/minimal variants to get past a
5357
+ // broken engine. Whatever the compile outcome, the DELIVERED tex must be the real,
5358
+ // fully-generated one (the attempts are recorded in the meta either way).
5359
+ if (p.format !== 'md') await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
5360
+ if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) files.push('paper.pdf')
5361
+ const warnings = (p.warnings || []).slice()
5362
+ 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(';'))
5363
+ if (compile.status === 'not-detected') warnings.push('未检测到 LaTeX 引擎(' + (compile.reason || '') + '):只交付 paper.tex 与 paper.md。')
5364
+ const meta = paperMeta(Object.assign({}, p, { finalizedAt, compile: compile.status, engine: compile.engine || '' }), {
5365
+ finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', attempts: compile.attempts || [] }, files,
5366
+ })
5367
+ if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')) files.push('paper.meta.json')
5368
+ await paperLog('定稿(' + reason + ')', [
5369
+ '- 目录:Paper/' + id + '/',
5370
+ '- 产物:' + files.join('、'),
5371
+ '- 编译:' + compile.status + (compile.engine ? '(' + compile.engine + ')' : '') + '|尝试 ' + (compile.attempts || []).length + ' 次',
5372
+ '- 定稿代表:' + p.editor + '|轮次:' + p.round + '/' + PAPER_MAX_ROUNDS,
5373
+ '- 所办交流:消息 ' + Number((p.consult || {}).messages || 0) + ' 条、会议 ' + Number((p.consult || {}).meetings || 0) + ' 次',
5374
+ (p.final && p.final.note ? '- 定稿说明:' + p.final.note : ''),
5375
+ (warnings.length ? '- 警告:\n - ' + warnings.join('\n - ') : ''),
5376
+ ].filter(Boolean).join('\n'))
5377
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
5378
+ status: 'finalized', stage: 'finalized', finalizedAt, compile: compile.status,
5379
+ engine: compile.engine || '', warnings, artifacts: files, updatedAt: now(),
5380
+ }) : cur))
5381
+ await saveChatLine('【论文】最终论文已定稿:Paper/' + id + '/(' + (files.join('、') || '(无产物)') + ';编译 ' + compile.status + ')。')
5382
+ notifyActivity()
5383
+ // The run is only now complete (docs/final-paper.md §3): the paper phase ran BEFORE the flags.
5384
+ if (p.completesRun && !autoDone) await finishRun('全体有表决权者一致认为原问题已解决')
5385
+ return {
5386
+ ok: true, finalized: true, id, dir: 'Paper/' + id + '/', files,
5387
+ compile: compile.status, engine: compile.engine || '', attempts: (compile.attempts || []).length,
5388
+ warnings, agreement: !(p.disagreement || []).length, rounds: p.round,
5389
+ }
5390
+ }
5391
+
3472
5392
  // ---- hire / fire -------------------------------------------------------
3473
5393
  function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
3474
5394
  // ANY academician or permanently-employed researcher may hire its own temp
@@ -3623,7 +5543,13 @@ export function apply(ctx) {
3623
5543
  // activity wait, which costs no tokens while the institute is genuinely idle.
3624
5544
  function syncParamsFromState() {
3625
5545
  const cur = inst()
3626
- if (cur && cur.params) params = Object.assign({}, DEFAULT_PARAMS, cur.params)
5546
+ // Same rule as the load path above: never adopt a persisted value without normalising it
5547
+ // (a foreign-shaped state file must not leak straight into the runtime params).
5548
+ if (cur && cur.params) {
5549
+ const droppedSync = []
5550
+ params = Object.assign({}, DEFAULT_PARAMS, normalizeParams(cur.params, droppedSync))
5551
+ reportDroppedStateKeys(cur, droppedSync, 'restore-sync')
5552
+ }
3627
5553
  if (cur && cur.project) project = cur.project
3628
5554
  if (cur && cur.institute) instituteName = cur.institute
3629
5555
  if (cur) phase = cur.phase || phase
@@ -3673,6 +5599,16 @@ export function apply(ctx) {
3673
5599
  await beginMeeting({ agenda: p.agenda, kind: p.kind, target: p.target, by: p.by })
3674
5600
  return
3675
5601
  }
5602
+ // The final-paper phase (docs/final-paper.md §3) owns the room once it is active: a paper ask is its
5603
+ // own kind of turn, and a research round must not be mistaken for a paper answer. While
5604
+ // the flow waits for the OFFICE the institute keeps working normally — the required
5605
+ // consultation needs office messages and a real meeting to be deliverable.
5606
+ if (paperActive()) {
5607
+ await paperPass()
5608
+ const cur = paper()
5609
+ const waitingForOffice = !!cur && cur.status === 'awaiting-editor' && cur.editor === 'office'
5610
+ if (!waitingForOffice) return
5611
+ }
3676
5612
  const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
3677
5613
  const idleMs = posMs(params.activityTimeoutMs, 120000)
3678
5614
  let filled = 0
@@ -3723,7 +5659,7 @@ export function apply(ctx) {
3723
5659
  const stallMs = posMs(params.stallAutoMeetingMs, 360000)
3724
5660
  if (!meeting && !pendingMeeting && !hasVerifyInFlight() && phase === 'active' &&
3725
5661
  busy.size === 0 && (now() - lastProgressAt) >= stallMs) {
3726
- await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync' })
5662
+ await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync', auto: true })
3727
5663
  return
3728
5664
  }
3729
5665
  // (e) heartbeat: push the longest-idle member to make progress rather than just
@@ -3897,6 +5833,12 @@ export function apply(ctx) {
3897
5833
  }
3898
5834
  // (10) solve votes / personal judgement
3899
5835
  if (p.vote_solved !== undefined) await recordSolveVote(member.id, p.vote_solved === true)
5836
+ // (10b) final-paper channels: the member's own part, a cross-review, or the editor's
5837
+ // decision. Each is only honoured in the stage that asked for it (the record functions
5838
+ // refuse otherwise), so a stray/stale field can never advance the paper.
5839
+ if (p.paper_part && typeof p.paper_part === 'object') await paperRecordPart(member.id, p.paper_part)
5840
+ if (p.paper_review && typeof p.paper_review === 'object') await paperRecordReview(member.id, p.paper_review)
5841
+ if (p.paper_final && typeof p.paper_final === 'object') await paperRecordFinal(member.id, p.paper_final)
3900
5842
  // (11) meeting input collection (keyed on the LIVE member set, so a member who
3901
5843
  // joined mid-meeting still has to speak and a dismissed one stops blocking it)
3902
5844
  if (kind === 'meeting' && meeting) {
@@ -3952,6 +5894,9 @@ export function apply(ctx) {
3952
5894
  try { await maybeRealCompact(childId, member) } catch (e) { /* compaction is best-effort */ }
3953
5895
  wakeKind.delete(member.id)
3954
5896
  if (member.activeMeetingId) delete member.activeMeetingId
5897
+ // The final-paper phase is driven by member turns, exactly like a meeting: the reply was
5898
+ // just folded, so advance the paper BEFORE asking the (possibly inert) scheduler.
5899
+ try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after end: ' + String((e && e.message) || e)) }
3955
5900
  await scheduleNext()
3956
5901
  }
3957
5902
  // ---- toolFilter names the host may not register -------------------------
@@ -4009,16 +5954,26 @@ export function apply(ctx) {
4009
5954
  }
4010
5955
 
4011
5956
  // ---- control plane -----------------------------------------------------
4012
- function normalizeParams(input) {
5957
+ function normalizeParams(input, dropped) {
4013
5958
  const out = {}
4014
- const ints = ['researcherCount', 'quorumCap', 'verdictMaxRounds', 'maxTempPerMember', 'maxTempTotal',
5959
+ // NOTE: new INT params go BEFORE the last two entries. The cross-preset
5960
+ // prompt-invariants self-probe mutates the exact TAIL of this array (dropping
5961
+ // leanTimeoutMs from the accept-set), so appending a key after it would silently
5962
+ // disarm that guard.
5963
+ const ints = ['leanJobsMaxParallel', 'mathTimeoutMs', 'researcherCount', 'quorumCap', 'verdictMaxRounds', 'maxTempPerMember', 'maxTempTotal',
4015
5964
  'compactThreshold', 'compactAfterRounds', 'maxParallel', 'activityTimeoutMs', 'stallAutoMeetingMs',
4016
5965
  'chatDigestMs', 'chatDigestMax', 'meetingKeepEvery', 'leanTimeoutMs']
4017
- const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign']
4018
- const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand']
4019
- const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny', 'leanArgs']
5966
+ const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign', 'finalPaper', 'paperCompilePdf', 'leanAsync']
5967
+ const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand',
5968
+ 'paperFormat', 'paperLanguage', 'paperEditor', 'paperLatexCommand', 'leanInitiative',
5969
+ 'mathComputation', 'mathMode', 'mathInstallScope']
5970
+ const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny', 'leanArgs', 'leanSearchPaths',
5971
+ 'mathEngines', 'mathPackages']
4020
5972
  for (const k of ints) if (input[k] !== undefined) { const n = Math.floor(Number(input[k])); if (Number.isFinite(n)) out[k] = n }
4021
- for (const k of bools) if (input[k] !== undefined) out[k] = (input[k] === true || input[k] === 'true')
5973
+ // Explicit boolean coercion (docs/final-paper.md §2): the old `=== true || === 'true'` turned a
5974
+ // legitimate `1` / `'yes'` into FALSE and left junk values truthy-looking. An
5975
+ // unrecognised spelling is REJECTED (the default wins) rather than guessed.
5976
+ for (const k of bools) if (input[k] !== undefined) out[k] = coerceBool(input[k], false)
4022
5977
  for (const k of strs) if (input[k] !== undefined) out[k] = String(input[k])
4023
5978
  for (const k of arrs) {
4024
5979
  if (input[k] === undefined) continue
@@ -4032,6 +5987,44 @@ export function apply(ctx) {
4032
5987
  out.formalVerify = ['off', 'encourage', 'require'].indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'
4033
5988
  }
4034
5989
  if (out.leanCommand !== undefined && !String(out.leanCommand).trim()) out.leanCommand = 'lean'
5990
+ // `leanAsync` gets an EXPLICIT branch (not just the shared bool loop): the mode must be
5991
+ // selected only by a real boolean (or the documented spellings) and an unknown value must
5992
+ // keep the DEFAULT (true) — and the string 'false' must never survive as truthy.
5993
+ if (input.leanAsync !== undefined) {
5994
+ out.leanAsync = (input.leanAsync === true || input.leanAsync === false)
5995
+ ? input.leanAsync
5996
+ : coerceBool(input.leanAsync, DEFAULT_PARAMS.leanAsync)
5997
+ }
5998
+ // `leanInitiative` is a CLOSED enum (like quorumMode/formalVerify): a typo degrades to the
5999
+ // documented default ('normal') rather than becoming an unreachable fourth mode.
6000
+ if (out.leanInitiative !== undefined) {
6001
+ out.leanInitiative = ['off', 'normal', 'eager'].indexOf(String(out.leanInitiative)) !== -1 ? String(out.leanInitiative) : 'normal'
6002
+ }
6003
+ // Concurrency floor, same discipline as quorumCap/verdictMaxRounds.
6004
+ if (out.leanJobsMaxParallel !== undefined && out.leanJobsMaxParallel < 1) out.leanJobsMaxParallel = 1
6005
+ // The six math_computation keys are normalised by the SHARED module (one implementation
6006
+ // for all four presets — docs/math-computation.md): explicit enum/array/integer coercion,
6007
+ // so a string 'false', an unknown engine name or a sub-1000 timeout can never leak
6008
+ // through. ONLY the keys the caller actually passed are copied, so a partial update can
6009
+ // never reset the others back to their defaults.
6010
+ if (MATH_PARAM_NAMES.some((k) => input[k] !== undefined)) {
6011
+ for (const k of MATH_PARAM_NAMES) {
6012
+ if (input[k] === undefined) continue
6013
+ // The typed loops above already coerced the SHAPE (a comma string became an array, an
6014
+ // integer was floored); the module then applies the semantic rules (closed enums, known
6015
+ // engines, >=1000 timeout). A value the module rejects falls back to its own default.
6016
+ const src = out[k] !== undefined ? out[k] : input[k]
6017
+ const norm = normalizeMathParams({ [k]: src })
6018
+ const v = norm[k] !== undefined ? norm[k] : MATH_PARAM_DEFAULTS[k]
6019
+ out[k] = Array.isArray(v) ? v.slice() : v
6020
+ }
6021
+ }
6022
+ // ── final-paper enums (closed sets; an unknown value degrades to the documented
6023
+ // default instead of silently becoming an unreachable fourth mode) ────────────────
6024
+ if (out.paperFormat !== undefined) out.paperFormat = coercePaperEnum('paperFormat', out.paperFormat, ['both', 'md', 'tex'], 'both')
6025
+ if (out.paperLanguage !== undefined) out.paperLanguage = coercePaperEnum('paperLanguage', out.paperLanguage, ['zh', 'en'], 'zh')
6026
+ if (out.paperEditor !== undefined) out.paperEditor = coercePaperEnum('paperEditor', out.paperEditor, ['office', 'academician'], 'academician')
6027
+ if (out.paperLatexCommand !== undefined) out.paperLatexCommand = String(out.paperLatexCommand).trim()
4035
6028
  // Guard every duration against a negative/NaN value: such a value would make a
4036
6029
  // watchdog fire instantly and abandon all consensus (v4 §30-T41).
4037
6030
  for (const k of ['activityTimeoutMs', 'stallAutoMeetingMs', 'chatDigestMs', 'leanTimeoutMs']) {
@@ -4048,13 +6041,44 @@ export function apply(ctx) {
4048
6041
  if (out.compactThreshold !== undefined && !(out.compactThreshold > 0)) delete out.compactThreshold
4049
6042
  if (out.chatDigestMax !== undefined && out.chatDigestMax < 1) out.chatDigestMax = 1
4050
6043
  if (out.verdictMaxRounds !== undefined && out.verdictMaxRounds < 1) out.verdictMaxRounds = 1
6044
+ // round-4 item 2: an unknown key used to vanish with NO report (hand-edited state files).
6045
+ // Never lose information silently: collect the accepted-key set's complement and hand it to
6046
+ // the caller's collector (the restore/set sites turn it into a diagnostic + a warning).
6047
+ if (dropped) {
6048
+ const known = new Set(ints.concat(bools, strs, arrs))
6049
+ for (const k of Object.keys(input || {})) if (!known.has(k)) dropped.push(k)
6050
+ }
4051
6051
  return out
4052
6052
  }
6053
+ // round-4 item 2: report keys a state file (or a caller) carried but the whitelist rejected.
6054
+ // `stateObj` is the state the drop belongs to (the load site has it in hand); falls back to the
6055
+ // live institute. Deduplicated so a repeated sync cannot spam the diagnostics.
6056
+ function reportDroppedStateKeys(stateObj, dropped, where) {
6057
+ const keys = Array.isArray(dropped) ? dropped.slice().sort() : []
6058
+ if (!keys.length) return keys
6059
+ const target = (stateObj && Array.isArray(stateObj.diagnostics)) ? stateObj : (inst() || null)
6060
+ if (target && Array.isArray(target.diagnostics)) {
6061
+ const last = target.diagnostics[target.diagnostics.length - 1]
6062
+ const duplicate = !!(last && last.kind === 'state-dropped-keys' && JSON.stringify(last.keys) === JSON.stringify(keys))
6063
+ // round-4 item 4: only a NEWLY recorded entry warns, and the record goes through the same
6064
+ // retention as every other diagnostics writer in this preset (slice(-50)).
6065
+ if (duplicate) return keys
6066
+ target.diagnostics.push({ kind: 'state-dropped-keys', where: where, keys: keys })
6067
+ while (target.diagnostics.length > 50) target.diagnostics.shift()
6068
+ }
6069
+ console.warn('vibe-math-v5: 忽略未知参数键(不静默丢失,已记入 diagnostics):' + keys.join(', ') + '(来源:' + where + ')')
6070
+ return keys
6071
+ }
4053
6072
  async function setParams(input) {
4054
- const patch = normalizeParams(input || {})
6073
+ const droppedSet = []
6074
+ const patch = normalizeParams(input || {}, droppedSet)
6075
+ reportDroppedStateKeys(inst(), droppedSet, 'set')
4055
6076
  const merged = Object.assign({}, params, patch)
4056
6077
  await patchInstitute({ params: merged })
4057
6078
  params = Object.assign({}, DEFAULT_PARAMS, merged)
6079
+ // The math availability line is MODE-DEPENDENT (mathComputation/mathMode/mathEngines), so
6080
+ // a tuning call must re-derive it immediately instead of leaving a stale line in prompts.
6081
+ if (MATH_PARAM_NAMES.some((k) => patch[k] !== undefined)) await refreshMathLine()
4058
6082
  // An already-armed heartbeat keeps the delay it was armed with, so a lowered
4059
6083
  // activityTimeoutMs (or a raised maxParallel) would not take effect until some
4060
6084
  // unrelated event drove a pass. Tuning must apply immediately.
@@ -4076,6 +6100,17 @@ export function apply(ctx) {
4076
6100
  chatDigestMax: params.chatDigestMax, meetingKeepEvery: params.meetingKeepEvery,
4077
6101
  formalVerify: params.formalVerify, leanCommand: params.leanCommand,
4078
6102
  leanArgs: params.leanArgs, leanTimeoutMs: params.leanTimeoutMs,
6103
+ leanAsync: params.leanAsync, leanInitiative: params.leanInitiative,
6104
+ leanSearchPaths: params.leanSearchPaths, leanJobsMaxParallel: params.leanJobsMaxParallel,
6105
+ // `visibleParams` is an EXPLICIT object (landing.md §3): a new key is invisible to
6106
+ // `/v5 status` until it is added here.
6107
+ mathComputation: params.mathComputation, mathMode: params.mathMode,
6108
+ mathEngines: (params.mathEngines || []).slice(), mathTimeoutMs: params.mathTimeoutMs,
6109
+ mathPackages: (params.mathPackages || []).slice(), mathInstallScope: params.mathInstallScope,
6110
+ // ── final paper ──────────────────────────────────────────────────────
6111
+ finalPaper: params.finalPaper, paperFormat: params.paperFormat,
6112
+ paperLanguage: params.paperLanguage, paperCompilePdf: params.paperCompilePdf,
6113
+ paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
4079
6114
  provider: params.provider, model: params.model,
4080
6115
  toolAllow: params.toolAllow, toolDeny: params.toolDeny,
4081
6116
  }
@@ -4318,6 +6353,11 @@ export function apply(ctx) {
4318
6353
  await saveChatLine('【恢复】研究所继续推进(重建成员 ' + respawned + ' 名)。')
4319
6354
  notifyActivity()
4320
6355
  await scheduleNext()
6356
+ // An interrupted FINAL PAPER resumes its own stage (the paper phase runs before the
6357
+ // completion flags, so a crash there leaves a live, resumable institute).
6358
+ if (paperActive()) { try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after resume: ' + String((e && e.message) || e)) } }
6359
+ // Leftover Lean jobs are re-driven (or explicitly interrupted) on the resume path too.
6360
+ try { await recoverLeanJobs() } catch (e) { /* already reported on the ready() path */ }
4321
6361
  return { ok: true, resumed: true, members: members.map((m) => m.id), respawned, running: true }
4322
6362
  }
4323
6363
  function setPause() {
@@ -4388,12 +6428,16 @@ export function apply(ctx) {
4388
6428
  mode: formalMode(),
4389
6429
  objects: Object.keys(formalRecords()).map((k) => {
4390
6430
  const r = formalRecords()[k] || {}
4391
- return { target: k, status: r.status, file: r.file || '', proof: r.proof || '', note: r.note || '', run: r.run ? { ok: r.run.ok, exitCode: r.run.exitCode, ms: r.run.ms } : null }
6431
+ return { target: k, status: r.status, file: r.file || '', proof: r.proof || '', note: r.note || '', run: r.run ? { ok: r.run.ok, exitCode: r.run.exitCode, ms: r.run.ms } : null, async: r.async || null }
4392
6432
  }),
4393
6433
  passed: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'passed'),
4394
6434
  blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
4395
6435
  todo: formalTodo(),
4396
6436
  },
6437
+ // The final-paper flow: stage, who wrote/reviewed what, the office-consultation
6438
+ // counters and the compile result. Without this an operator cannot tell whether the
6439
+ // run is waiting for the office, for a member, or for nobody.
6440
+ paper: paperSummary(),
4397
6441
  lastProgressAt, params: visibleParams(),
4398
6442
  }
4399
6443
  }
@@ -4460,6 +6504,21 @@ export function apply(ctx) {
4460
6504
  L.push('- 可复用库:VibeMath/Formal/{Lib,Proved}/(跨项目)|本所形式化:Formal/|归档证明:Verified/Lean/')
4461
6505
  }
4462
6506
  L.push('')
6507
+ L.push('## 最终论文')
6508
+ {
6509
+ const pp = paper()
6510
+ if (!pp) L.push('- 尚未开始(' + (params.finalPaper === false ? '自动已关闭(finalPaper=false),可用 /v5 paper 手动生成' : '收口时会自动开始;也可用 /v5 paper 手动开始') + ')')
6511
+ else {
6512
+ L.push('- 目录:Paper/' + pp.id + '/|状态:' + pp.status + '|阶段:' + pp.stage + '|轮次:' + pp.round + '/' + PAPER_MAX_ROUNDS)
6513
+ L.push('- 定稿代表:' + pp.editor + (pp.editorFallback ? '(原定代表不在册,已回退)' : '') + '|语言:' + pp.lang + '|格式:' + pp.format)
6514
+ 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('、') || '(无)'))
6515
+ L.push('- 所办交流:消息 ' + Number((pp.consult || {}).messages || 0) + ' 条|会议 ' + Number((pp.consult || {}).meetings || 0) + ' 次')
6516
+ if (pp.compile) L.push('- 编译:' + pp.compile + (pp.engine ? '(' + pp.engine + ')' : ''))
6517
+ if ((pp.warnings || []).length) L.push('- 警告:' + pp.warnings.join(';'))
6518
+ L.push('- 下一步:' + paperNextStep(pp))
6519
+ }
6520
+ }
6521
+ L.push('')
4463
6522
  L.push('## 文件位置')
4464
6523
  L.push('- 根目录:' + instRoot())
4465
6524
  L.push('- 已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
@@ -4523,9 +6582,16 @@ export function apply(ctx) {
4523
6582
  // Lean formal verification (docs/formal-verification.md)
4524
6583
  formalMode, formalOn, formalRecords, formalTodo, formalOf, rebuildLeanLibIndexes,
4525
6584
  leanArchive, leanRunTool, writeFormalIndex, writeFormalTodo, recordFidelityDefect,
6585
+ leanRead, leanJobTool, leanJobView, leanJobsView, leanSearchRootView, leanInitiative, leanDailyOn,
6586
+ // math_computation (docs/math-computation.md)
6587
+ refreshMathLine, mathLine: () => mathLineZh, mathSessionHost: () => mathSessionHost,
6588
+ runLeanQueue, disposeLeanJobs, leanRecover: recoverLeanJobs,
6589
+ leanQueueApi: async () => { await runLeanQueue(); return { jobs: leanJobsView(), notices: leanNotices.length } },
4526
6590
  leanRunToolApi: async (relPath, timeoutMs) => await leanRunFile(relPath, timeoutMs),
4527
6591
  // consensus / meetings
4528
6592
  maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
6593
+ // final paper (docs/final-paper.md; the phase runs BEFORE finishRun)
6594
+ startPaper, paperStatus: paperSummary, finalizePaperByOffice,
4529
6595
  // authorization helpers (used by tool handlers)
4530
6596
  memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
4531
6597
  }
@@ -4568,6 +6634,54 @@ export function apply(ctx) {
4568
6634
  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)' }
4569
6635
  return fn(caller)
4570
6636
  }
6637
+
6638
+ // ================= math_computation registration (docs/math-computation.md) =================
6639
+ // The SHARED module is instantiated PER CALLING SESSION, so every host callback closes over
6640
+ // exactly one session's LIVE params/root/fs — there is NO ambient "current session" slot that
6641
+ // a concurrent call from another session could overwrite (audit C: a single slot meant that
6642
+ // while session A awaited a spawn, session B could take it over, and A's later
6643
+ // params/writeText/projectRoot would silently be B's). The tool FACE is installed exactly once
6644
+ // (the module's own contract: one `host.register` call per preset); the per-session instances
6645
+ // are created lazily and their `register` callback is deliberately ignored.
6646
+ // The module's tool NAME has no `vibe_` prefix on purpose (it is the same primitive in all
6647
+ // four presets), and `projectRoot()` is the INSTITUTE root, so receipts land in
6648
+ // <institute>/Computation/<runId>/.
6649
+ let mathToolMeta = null // { name, description, parameters } — the frozen face
6650
+ const mathSessionHandlers = new WeakMap() // session API object -> that session's module handler
6651
+ // The metadata instance: its callbacks are never invoked (its handler is never called), it
6652
+ // exists only so the module can hand us the frozen tool face exactly once.
6653
+ const mathMetaHost = {
6654
+ register: (name, description, parameters) => { if (!mathToolMeta) mathToolMeta = { name, description, parameters } },
6655
+ params: () => DEFAULT_PARAMS,
6656
+ projectRoot: () => '.',
6657
+ designator: 'vibe-math-v5',
6658
+ writeText: async () => false,
6659
+ readText: async () => undefined,
6660
+ exists: async () => false,
6661
+ resolveExecutable: async () => { throw new Error('metadata host') },
6662
+ spawn: async () => null,
6663
+ log: () => {},
6664
+ }
6665
+ registerMathComputation(mathMetaHost)
6666
+ // Drift guard: the installed face must BE the shared frozen text (all four presets must ship
6667
+ // the same description — a re-typed copy here would diverge silently).
6668
+ if (!mathToolMeta || mathToolMeta.description !== MATH_TOOL_DESCRIPTION) {
6669
+ console.error('vibe-math-v5: math_computation was registered with a description that is not MATH_TOOL_DESCRIPTION')
6670
+ }
6671
+ function mathHandlerFor(s) {
6672
+ let handler = mathSessionHandlers.get(s)
6673
+ if (!handler) {
6674
+ // One instance per session: its host callbacks are the session's own seam.
6675
+ const inst = registerMathComputation(s.mathSessionHost())
6676
+ handler = inst && typeof inst.handler === 'function' ? inst.handler : async () => ({ ok: false, code: 'MATH_INVALID_ARGUMENT', message: 'math_computation: no session handler' })
6677
+ mathSessionHandlers.set(s, handler)
6678
+ }
6679
+ return handler
6680
+ }
6681
+ if (mathToolMeta) {
6682
+ registerTool(mathToolMeta.name, mathToolMeta.description, mathToolMeta.parameters,
6683
+ (s, args) => mathHandlerFor(s)(args))
6684
+ }
4571
6685
  /**
4572
6686
  * Resolve the caller of an OFFICE-ONLY control and refuse anything that is not the PROVABLE
4573
6687
  * session root. `officeCaller` answers 'office' for that root, a member id for a member child, and
@@ -4599,14 +6713,23 @@ export function apply(ctx) {
4599
6713
  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()))
4600
6714
  registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
4601
6715
  registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
4602
- 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).', objParams({
6716
+ 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). LEAN TOOLCHAIN: leanCommand names the Lean executable (e.g. "lake" with leanArgs ["env","lean"]); leanArgs are inserted before the file name (the framework appends --search-path <VibeMath root> unless leanArgs already sets one); leanTimeoutMs is the per-run budget in ms (>=1000, and the per-job budget of the async queue). 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). LEAN ASYNC: leanAsync (default true) compiles on a per-session background queue (vibe_v5_lean_run / vibe_v5_lean_archive run=true enqueue and return immediately; inspect them with vibe_v5_lean_job or vibe_v5_lean_lib.jobs and wait with vibe_v5_lean_job {jobId,waitMs}); leanAsync=false restores the previous synchronous behaviour. Only a settled job (exit 0, unchanged content hash AND the same build context) may mark an object passed; a job id is the content+build-context digest. leanInitiative "off"|"normal" (default)|"eager" separates DAILY eagerness about formalizing from formalVerify (which stays the verdict-time requirement). leanSearchPaths (string[]) adds extra --search-path roots before the automatic VibeMath root (deduped; an explicit --search-path/-R/--root in leanArgs wins). leanJobsMaxParallel (default 1) caps simultaneous background compiles. MATH COMPUTATION: mathComputation "off"|"auto" (default)|"on" gates the math_computation tool; mathMode "typed+shell" (default: the host shell may be used as a fallback, but a shell run carries no receipt and its conclusion must be marked 未经工具归档/not tool-archived) | "typed" (never mention the shell; engine=cli is refused); mathEngines lists the allowed engines (cli is on by default, SageMath is a later phase); mathTimeoutMs is the per-run budget (>=1000); mathPackages are packages a computation may require; mathInstallScope "user" (default) | "system" (per call only, never remembered). Installs are two-step (plan then confirm-token) and commercial engines are never installed. Unknown spellings of these enums fall back to the documented default.', objParams({
4603
6717
  academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
4604
6718
  quorumCap: I, quorumMode: S, verdictMaxRounds: I,
4605
6719
  maxTempPerMember: I, maxTempTotal: I,
4606
6720
  compactThreshold: I, compactAfterRounds: I, maxParallel: I,
4607
6721
  activityTimeoutMs: I, stallAutoMeetingMs: I, chatDigestMs: I, chatDigestMax: I, meetingKeepEvery: I,
4608
6722
  formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] },
4609
- leanCommand: S, leanArgs: SA, leanTimeoutMs: I,
6723
+ leanCommand: S, leanArgs: SA, leanTimeoutMs: I, leanAsync: B,
6724
+ leanInitiative: { type: 'string', enum: ['off', 'normal', 'eager'] },
6725
+ leanSearchPaths: SA, leanJobsMaxParallel: I,
6726
+ mathComputation: { type: 'string', enum: ['off', 'auto', 'on'] },
6727
+ mathMode: { type: 'string', enum: ['typed', 'typed+shell'] },
6728
+ mathEngines: SA, mathTimeoutMs: I, mathPackages: SA,
6729
+ mathInstallScope: { type: 'string', enum: ['user', 'system'] },
6730
+ finalPaper: B, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] },
6731
+ paperLanguage: { type: 'string', enum: ['zh', 'en'] },
6732
+ paperCompilePdf: B, paperEditor: { type: 'string', enum: ['office', 'academician'] }, paperLatexCommand: S,
4610
6733
  provider: S, model: S, staffPersona: S, toolAllow: SA, toolDeny: SA, tempToolAllow: SA, tempToolDeny: SA,
4611
6734
  }), (s, a, x) => withOffice(s, x, 'tune institute parameters', () => s.setParams(a)))
4612
6735
  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) => {
@@ -4651,6 +6774,16 @@ export function apply(ctx) {
4651
6774
  ? s.removeResearcher(a.id)
4652
6775
  : { ok: false, code: 'V5_NOT_OFFICE', message: 'only the office (the session root) may dismiss a permanent researcher' }
4653
6776
  )))
6777
+ // ── final paper (docs/final-paper.md) ─────────────────────────────────────
6778
+ 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', () => {
6779
+ const lang = a.lang === undefined ? undefined : String(a.lang)
6780
+ const format = a.format === undefined ? undefined : String(a.format)
6781
+ if (lang !== undefined && lang !== 'zh' && lang !== 'en') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'lang must be zh|en' }
6782
+ if (format !== undefined && ['both', 'md', 'tex'].indexOf(format) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'format must be both|md|tex' }
6783
+ if (a.editor !== undefined && ['office', 'academician'].indexOf(String(a.editor)) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'editor must be office|academician' }
6784
+ return s.startPaper(a.reason === 'run-complete' ? 'run-complete' : 'manual', { lang, format, editor: a.editor, force: a.force === true, reason: a.reason })
6785
+ }))
6786
+ 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)))
4654
6787
 
4655
6788
  // ── member-facing controls ────────────────────────────────────────────────
4656
6789
  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) => {
@@ -4730,25 +6863,33 @@ export function apply(ctx) {
4730
6863
  // dynamic registration would depend on a runtime knob and break the effect discipline),
4731
6864
  // while the MODE only decides whether the framework TELLS members about them. In 'off'
4732
6865
  // mode they still work if a human or agent calls them deliberately.
4733
- 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)))
4734
- 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)))
4735
- 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) => {
6866
+ registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. With leanAsync (default true) the compile is ENQUEUED and this returns immediately with async:{jobId,state} — nothing is compiled yet; inspect it via vibe_v5_lean_lib.jobs or wait for the next round\'s 【形式化结果】 line. With leanAsync=false it blocks and returns the compiler output (exitCode/stdout/stderr). Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT. Pass target=<object id> to also record the run against that object. The framework appends `--search-path <VibeMath root>` before the file name (unless leanArgs already sets a search root).', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
6867
+ registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (only once the queued compile settles ok) also Verified/Lean/<target>.lean, marking the object Lean-passed. Re-archiving IDENTICAL content is de-duplicated (deduped:true, no rewrite/recompile). kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
6868
+ registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md (with an import-dependency column), plus the global cross-project Formal/Lib and Formal/Proved indexes, the background-compile jobs (jobs) and the injected search path (paths.searchPath). Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
4736
6869
  const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
4737
6870
  const st = s.status()
4738
6871
  return {
4739
6872
  ok: true, mode: s.formalMode(), rebuilt: !(a && a.refresh === false),
4740
- counts: r, todo: s.formalTodo(),
4741
- objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note })),
4742
- paths: { project: 'Formal/(相对研究所根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/' },
4743
- hint: '复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'。',
6873
+ counts: r, todo: s.formalTodo(), jobs: s.leanJobsView(),
6874
+ objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note, async: (s.formalRecords()[k] || {}).async || null })),
6875
+ paths: { project: 'Formal/(相对研究所根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/', searchPath: s.leanSearchRootView() },
6876
+ hint: '复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'。复用已归档内容:import Formal.Lib.<name> / import Formal.Proved.<name>,或用 vibe_v5_lean_read {name} 取原文逐字复制。同内容重复归档会自动去重。',
4744
6877
  verify: st.verify ? st.verify.target : null,
4745
6878
  }
4746
6879
  })
6880
+ // Read-only: the EXACT text of an archived definition/lemma (docs/formal-verification.md §5.4).
6881
+ // Only <VibeMath root>/Formal/{Lib,Proved}/<name>.lean is reachable; a path-shaped name is
6882
+ // refused (never sanitised into a different file).
6883
+ registerTool('vibe_v5_lean_read', '(member) Read one archived Lean file VERBATIM so it can be reused: name is the archived file name (no extension needed), kind is "auto" (Lib then Proved, default), "lib" or "proved". Returns {ok,name,file,kind,sha256,bytes,text,truncated} (text capped at 64KB). Only the global reusable library is reachable.', objParams({ name: S, kind: { type: 'string', enum: ['auto', 'lib', 'proved'] } }, ['name']), (s, a) => s.leanRead(a))
6884
+ // Wait/observe the background compile queue (docs/formal-verification.md §7: no polling tool
6885
+ // for the member's own turn — `waitMs` is a bounded internal wait, and the state is returned
6886
+ // either way, so a member can also just check and continue).
6887
+ registerTool('vibe_v5_lean_job', '(member) Inspect the background Lean compile queue. With no jobId it lists this session\'s jobs ({jobs,count,running,maxParallel}); with jobId it returns that job\'s state/exitCode, its receipt Formal/Jobs/<jobId>.json and, when it passed, the archived proof path. waitMs>0 waits up to that many milliseconds for a queued/running job to settle (it polls the queue instead of blocking the heartbeat) and otherwise returns the CURRENT state.', objParams({ jobId: S, waitMs: I }), (s, a) => s.leanJobTool(a))
4747
6888
 
4748
6889
  // ── /v5 slash command ────────────────────────────────────────────────────
4749
6890
  ctx.effect(() => commands.register({
4750
6891
  name: 'v5', description: 'control the Vibe Math V5 research institute',
4751
- input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set]' },
6892
+ 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]]' },
4752
6893
  handler: async function (inv) {
4753
6894
  const s = getSession(inv && inv.agent)
4754
6895
  if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
@@ -4784,7 +6925,28 @@ export function apply(ctx) {
4784
6925
  else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
4785
6926
  else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
4786
6927
  else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
4787
- else if (cmd === 'set') {
6928
+ else if (cmd === 'paper') {
6929
+ // `/v5 paper [lang=en] [format=tex] [force] [editor=office|academician]`
6930
+ const o = {}
6931
+ let force = false
6932
+ let bad = ''
6933
+ for (const tok of rest) {
6934
+ if (tok === 'force') { force = true; continue }
6935
+ const eq = tok.indexOf('=')
6936
+ if (eq <= 0) { bad = 'unknown paper argument: ' + tok; break }
6937
+ const k = tok.slice(0, eq), v = tok.slice(eq + 1)
6938
+ if (k === 'lang' || k === 'format' || k === 'editor') o[k] = v
6939
+ else { bad = 'unknown paper option: ' + k + ' (use lang=zh|en format=both|md|tex editor=office|academician force)'; break }
6940
+ }
6941
+ if (!bad && o.lang !== undefined && o.lang !== 'zh' && o.lang !== 'en') bad = 'lang must be zh|en'
6942
+ if (!bad && o.format !== undefined && ['both', 'md', 'tex'].indexOf(o.format) === -1) bad = 'format must be both|md|tex'
6943
+ if (!bad && o.editor !== undefined && ['office', 'academician'].indexOf(o.editor) === -1) bad = 'editor must be office|academician'
6944
+ if (bad) return { kind: 'error', text: JSON.stringify({ ok: false, code: 'V5_INVALID_ARGUMENT', message: bad }, null, 2) }
6945
+ // `editor=office` is the manual path that uses the office as the finalising
6946
+ // representative (docs/final-paper.md §7 — never reachable from an automatic run). One-shot: it
6947
+ // does not rewrite the persisted `paperEditor`.
6948
+ r = await s.startPaper('manual', { lang: o.lang, format: o.format, editor: o.editor, force })
6949
+ } else if (cmd === 'set') {
4788
6950
  const upd = {}
4789
6951
  for (const tok of rest) {
4790
6952
  const eq = tok.indexOf('=')
@@ -4794,7 +6956,7 @@ export function apply(ctx) {
4794
6956
  upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
4795
6957
  }
4796
6958
  r = await s.setParams(upd)
4797
- } else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set' }
6959
+ } 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]' }
4798
6960
  // A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
4799
6961
  // CommandResult union distinguishes success from error, and returning 'success' made a rejected
4800
6962
  // invocation look identical to a successful one (same fix as v2/v3).
@@ -4867,6 +7029,9 @@ export const __testHelpers = {
4867
7029
  parseReply,
4868
7030
  sanitizeToolFilter,
4869
7031
  registeredToolsFromError,
7032
+ sha256Hex,
7033
+ leanHashText,
7034
+ leanHasSearchFlag,
4870
7035
  }
4871
7036
 
4872
7037
  function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
@@ -4889,6 +7054,32 @@ function slugify(s) {
4889
7054
  return t || ''
4890
7055
  }
4891
7056
 
7057
+ // Explicit parameter coercion (docs/final-paper.md §2). A boolean parameter spelled `'false'`, `0`, `'no'`
7058
+ // or `'off'` must be false — the old `=== true || === 'true'` silently treated `1`/`'yes'` as
7059
+ // false. An unrecognised spelling is REJECTED (the caller's default wins), never guessed.
7060
+ function coerceBool(v, def) {
7061
+ if (v === undefined) return def
7062
+ if (v === true || v === 1 || v === '1') return true
7063
+ if (v === false || v === 0 || v === '0') return false
7064
+ const t = String(v).trim().toLowerCase()
7065
+ if (['true', 'yes', 'on', 'y', 't'].indexOf(t) !== -1) return true
7066
+ if (['false', 'no', 'off', 'n', 'f', ''].indexOf(t) !== -1) return false
7067
+ return def
7068
+ }
7069
+
7070
+ // Enum coercion with legacy aliases (.v5 / older flat spellings) and a documented fallback.
7071
+ const ENUM_ALIASES = {
7072
+ paperFormat: { markdown: 'md', latex: 'tex', texonly: 'tex', all: 'both' },
7073
+ paperLanguage: { cn: 'zh', chinese: 'zh', 'zh-cn': 'zh', english: 'en', 'en-us': 'en' },
7074
+ paperEditor: { root: 'office', host: 'office', acad: 'academician', dean: 'academician' },
7075
+ }
7076
+ function coercePaperEnum(key, v, allowed, def) {
7077
+ const t = String(v == null ? '' : v).trim().toLowerCase()
7078
+ if (allowed.indexOf(t) !== -1) return t
7079
+ const alias = (ENUM_ALIASES[key] || {})[t]
7080
+ return alias !== undefined && allowed.indexOf(alias) !== -1 ? alias : def
7081
+ }
7082
+
4892
7083
  function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
4893
7084
 
4894
7085
  function parseReply(text) {