dsh-vibe-math 2.6.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 (57) hide show
  1. package/README.en.md +156 -389
  2. package/README.md +156 -390
  3. package/cordis.patch.yml +56 -14
  4. package/docs/AUDIT-CHECKLIST.md +2 -2
  5. package/docs/final-paper.md +1 -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.7.0.en.md +151 -0
  9. package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
  10. package/docs/test-timing.md +24 -12
  11. package/installer.js +7 -4
  12. package/package.json +18 -2
  13. package/prompt-corpus-persona/persona-corpus.json +12 -12
  14. package/prompt-corpus-persona/persona-corpus.md +116 -32
  15. package/prompt-corpus-v2/formal-verify-v2.json +116 -92
  16. package/prompt-corpus-v2/formal-verify-v2.md +1430 -55
  17. package/prompt-corpus-v3/formal-verify-v3.json +101 -53
  18. package/prompt-corpus-v3/formal-verify-v3.md +1001 -22
  19. package/prompt-corpus-v4/formal-verify-v4.json +14 -14
  20. package/prompt-corpus-v4/formal-verify-v4.md +247 -14
  21. package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
  22. package/prompt-corpus-v5/prompt-corpus-v5.md +570 -3
  23. package/tests/audit-math-computation-contract.mjs +191 -0
  24. package/tests/audit-math-computation-parity.mjs +169 -0
  25. package/tests/audit-math-computation-sensitivity.mjs +231 -0
  26. package/tests/audit-persona-sensitivity.mjs +4 -4
  27. package/tests/audit-persona-surface.test.mjs +38 -10
  28. package/tests/audit-v5-integrity.mjs +14 -0
  29. package/tests/e2e-v5-round2.test.mjs +452 -2
  30. package/tests/formal-verify-v2.test.mjs +292 -6
  31. package/tests/formal-verify-v3.test.mjs +271 -4
  32. package/tests/formal-verify-v4.test.mjs +295 -6
  33. package/tests/formal-verify-v5.test.mjs +9 -1
  34. package/tests/helpers/math-computation-fake-seam.mjs +135 -0
  35. package/tests/math-computation-shared.test.mjs +504 -0
  36. package/tests/prompt-v5-integrity.test.mjs +5 -5
  37. package/vibe-math-v2/agent.cordis.yml +20 -8
  38. package/vibe-math-v2/math-computation.js +936 -0
  39. package/vibe-math-v2/math-engines.js +156 -0
  40. package/vibe-math-v2/vibe-math-v2.js +837 -40
  41. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  42. package/vibe-math-v3/agent.cordis.yml +20 -8
  43. package/vibe-math-v3/math-computation.js +936 -0
  44. package/vibe-math-v3/math-engines.js +156 -0
  45. package/vibe-math-v3/vibe-math-v3.js +828 -47
  46. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  47. package/vibe-math-v4/agent.cordis.yml +34 -6
  48. package/vibe-math-v4/math-computation.js +936 -0
  49. package/vibe-math-v4/math-engines.js +156 -0
  50. package/vibe-math-v4/vibe-math-v4.js +801 -31
  51. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  52. package/vibe-math-v5/agent.cordis.yml +38 -6
  53. package/vibe-math-v5/math-computation.js +936 -0
  54. package/vibe-math-v5/math-engines.js +156 -0
  55. package/vibe-math-v5/vibe-math-v5.js +1120 -37
  56. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +83 -3
  57. 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',
@@ -96,6 +117,92 @@ const PAPER_SECTIONS = [
96
117
  '附录:证据与文件索引',
97
118
  ]
98
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
+
99
206
  // Stable error codes (ported from DSH agent-teams' typed-error discipline).
100
207
  function v5err(code, message) {
101
208
  const e = new Error(message || code)
@@ -138,6 +245,15 @@ export function apply(ctx) {
138
245
  const tools = ctx.tools
139
246
  const commands = ctx.commands
140
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
+
141
257
  // Optional services are resolved LAZILY at call time, never snapshotted in apply():
142
258
  // a `ctx.get()` snapshot taken here is order-sensitive, so a service provided later
143
259
  // would stay undefined for the whole session.
@@ -551,6 +667,38 @@ export function apply(ctx) {
551
667
  leanCommand: 'lean',
552
668
  leanArgs: [],
553
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,
554
702
  // ── final paper (docs/final-paper.md; the phase runs BEFORE the completion flags) ──
555
703
  // finalPaper — write the final paper when the run concludes (manual /v5 paper
556
704
  // still works when this is false, and says so).
@@ -661,7 +809,17 @@ export function apply(ctx) {
661
809
  // i.e. the fallback would silently lose the whole institute across a restart.
662
810
  async function ready() {
663
811
  if (!backend) installBackend()
664
- 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
665
823
  }
666
824
  // Load the CURRENT state path before its first read. `state()` is synchronous by design
667
825
  // (the whole fold is read through it), so it kicks the load off eagerly; the caller that
@@ -728,7 +886,14 @@ export function apply(ctx) {
728
886
  const cur = stateCache.institutes[key]
729
887
  if (cur) {
730
888
  phase = cur.phase || phase
731
- 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')
732
897
  project = cur.project || project
733
898
  instituteName = cur.institute || instituteName
734
899
  }
@@ -1151,6 +1316,9 @@ export function apply(ctx) {
1151
1316
  b.push('[形式化] ' + (formalMode() === 'require' ? '强制' : '鼓励') + ' Lean|已通过 ' + passed
1152
1317
  + '|已记录阻塞 ' + blocked + (formalTodo().length ? '|形式化待办 ' + formalTodo().length + ' 项(见 Formal/TODO.md)' : ''))
1153
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)
1154
1322
  const tasks = inst().tasks.filter((t) => t.status !== 'deleted')
1155
1323
  const mine = tasks.filter((t) => t.ownerId === member.id && t.status === 'in_progress')
1156
1324
  const ready = tasks.filter((t) => t.status === 'pending' && taskReady(t))
@@ -1242,7 +1410,7 @@ export function apply(ctx) {
1242
1410
  // directory is what actually creates the tree — inside the policy.
1243
1411
  async function mkdirs() {
1244
1412
  const base = instRoot()
1245
- 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']
1246
1414
  // The REUSABLE Lean library is global (cross-project), so it hangs off the VibeMath
1247
1415
  // root rather than the institute root — creating it under instRoot would scatter a
1248
1416
  // second, invisible copy per institute.
@@ -1740,6 +1908,7 @@ export function apply(ctx) {
1740
1908
  L.push('')
1741
1909
  if (initialTask) { L.push(resume ? '恢复说明:' : '你的初始任务/用途:'); L.push(' ' + initialTask); L.push('') }
1742
1910
  if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
1911
+ mathPushLine(L)
1743
1912
  L.push('------------')
1744
1913
  L.push(stateBlock(member))
1745
1914
  L.push('------------')
@@ -1757,7 +1926,8 @@ export function apply(ctx) {
1757
1926
  L.push('作为院士,除了做研究,你还要**统筹全所**:用 vibe_v5_overview 看清谁在做什么、')
1758
1927
  L.push('哪里是瓶颈;把工作拆成任务并用 vibe_v5_assign 分派;必要时用 vibe_v5_nudge 督办。')
1759
1928
  }
1760
- if (formalOn()) { L.push(''); L.push(formalWorkLine()) }
1929
+ if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
1930
+ mathPushLine(L)
1761
1931
  L.push('')
1762
1932
  L.push('------------')
1763
1933
  L.push(stateBlock(member))
@@ -1773,7 +1943,8 @@ export function apply(ctx) {
1773
1943
  L.push('读一读同事的库、推进你的子问题/引理/方法、尝试一条新路线;')
1774
1944
  L.push('或者向团队发消息(say)、开一个议题(propose_meeting)、给某个方向开任务(task_create)。')
1775
1945
  L.push('如果你确实已无路可走或认为原问题接近解决,请说明你的判断与理由。')
1776
- if (formalOn()) { L.push(''); L.push(formalWorkLine()) }
1946
+ if (leanDailyOn()) { L.push(''); L.push(formalWorkLine()) }
1947
+ mathPushLine(L)
1777
1948
  L.push('')
1778
1949
  L.push('------------')
1779
1950
  L.push(stateBlock(member))
@@ -1800,6 +1971,7 @@ export function apply(ctx) {
1800
1971
  L.push('(会议轮请把你的发言同时填进 JSON 的 "input" 字段,框架据此写会议纪要。)')
1801
1972
  L.push('如果你认为原问题已解决,请填 "vote_solved": true —— 只有当**全体有表决权者**都')
1802
1973
  L.push('一致认为是真时,本所才会停下来。')
1974
+ mathPushLine(L)
1803
1975
  L.push('')
1804
1976
  L.push('------------')
1805
1977
  L.push(stateBlock(member))
@@ -1844,6 +2016,7 @@ export function apply(ctx) {
1844
2016
  }
1845
2017
  L.push('**不要为了配合别人而改票,也不要为了让流程往前走而给出你不相信的 1 或 0。**')
1846
2018
  L.push('本所宁可留下未定论,也不要一个骗人的结论。')
2019
+ mathPushLine(L)
1847
2020
  L.push('')
1848
2021
  L.push('------------')
1849
2022
  L.push(stateBlock(member))
@@ -1879,6 +2052,9 @@ export function apply(ctx) {
1879
2052
  const delay = posMs(ms, posMs(params.activityTimeoutMs, 120000))
1880
2053
  heartbeatDisposer = ctx.timeout(() => {
1881
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)))
1882
2058
  scheduleNext().catch((e) => console.error('vibe-math-v5: heartbeat: ' + String((e && e.message) || e)))
1883
2059
  }, delay)
1884
2060
  }
@@ -1939,6 +2115,12 @@ export function apply(ctx) {
1939
2115
  const FORMAL_MODES = ['off', 'encourage', 'require']
1940
2116
  const formalMode = () => (FORMAL_MODES.indexOf(String(params.formalVerify)) !== -1 ? String(params.formalVerify) : 'off')
1941
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')
1942
2124
  const formalRoot = () => instRoot() + '/Formal'
1943
2125
  const formalLibRoot = () => vibeRoot() + '/Formal/Lib'
1944
2126
  const formalProvedRoot = () => vibeRoot() + '/Formal/Proved'
@@ -1993,7 +2175,10 @@ export function apply(ctx) {
1993
2175
 
1994
2176
  // Run the toolchain on one file. NEVER throws into the scheduler: every failure mode
1995
2177
  // (no service, no executable, timeout, non-zero exit) becomes a readable result.
1996
- 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) {
1997
2182
  const started = now()
1998
2183
  const rel = String(relPath || '').trim()
1999
2184
  if (!rel) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'file is required' }
@@ -2016,7 +2201,8 @@ export function apply(ctx) {
2016
2201
  } catch (e) {
2017
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 }
2018
2203
  }
2019
- 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])
2020
2206
  let handle
2021
2207
  try {
2022
2208
  handle = sub.spawn({
@@ -2025,6 +2211,11 @@ export function apply(ctx) {
2025
2211
  stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } },
2026
2212
  graceMs: cap,
2027
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)
2028
2219
  } catch (e) {
2029
2220
  return { ok: false, code: 'LEAN_SPAWN_FAILED', message: String((e && e.message) || e), file: rel, ms: now() - started }
2030
2221
  }
@@ -2152,6 +2343,608 @@ export function apply(ctx) {
2152
2343
  + ' —— 已撤回「已通过」状态' + (removed ? ',并已使 Verified/Lean/ 中的归档证明失效(删除或覆盖为撤回声明)' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
2153
2344
  return { ok: true, target: t, status: 'attempted', removed }
2154
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
+
2155
2948
  function formalPromptBlock(target) {
2156
2949
  if (!formalOn()) return ''
2157
2950
  const mode = formalMode()
@@ -2185,6 +2978,9 @@ export function apply(ctx) {
2185
2978
  L.push(' 请复核这个判断是否成立;若你认为其实可以形式化,请指出来并动手做。')
2186
2979
  } else {
2187
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 再审。')
2188
2984
  L.push(' · 工具:vibe_v5_lean_run(执行)· vibe_v5_lean_archive(归档)· vibe_v5_lean_lib(查已有可复用库)')
2189
2985
  L.push(' · 工作目录:Formal/(相对研究所根);可复用定义放 ' + formalLibRoot().replace(/\\/g, '/') + '/,')
2190
2986
  L.push(' 已证引理放 ' + formalProvedRoot().replace(/\\/g, '/') + '/;写之前先 vibe_v5_lean_lib 查重。')
@@ -2205,11 +3001,21 @@ export function apply(ctx) {
2205
3001
  return L.join('\n')
2206
3002
  }
2207
3003
  function formalWorkLine() {
2208
- if (!formalOn()) return ''
2209
- return '【顺手形式化(' + (formalMode() === 'require' ? '强制' : '鼓励') + ')】把你工作中常用或可能复用的对象、假设、'
3004
+ if (!leanDailyOn()) return ''
3005
+ const eager = leanInitiative() === 'eager'
3006
+ return '【顺手形式化(' + (formalOn() ? (formalMode() === 'require' ? '强制' : '鼓励') : '主动(leanInitiative=eager)') + ')】'
3007
+ + (eager ? '**主动**把工作中出现的有价值的小引理、命题、定义顺手形式化并存库;' : '')
3008
+ + '把你工作中常用或可能复用的对象、假设、'
2210
3009
  + '新定义用 Lean 形式化定义并归档到全局可复用库(vibe_v5_lean_archive kind=\'def\'),已成立的引理归到 Proved/'
2211
3010
  + '(kind=\'lemma\');写之前先 vibe_v5_lean_lib 查重,避免重复定义。'
2212
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
+ + '【形式化结果】行看结果。**在作业落地为“通过”之前,不得把该对象当成已通过。**'
2213
3019
  + (formalMode() === 'require'
2214
3020
  ? '本模式下,任何要定论为真/假的对象都必须先有 Lean 通过或显式阻塞记录。'
2215
3021
  : '这会让后续的验证与证明省掉大量重复工作。')
@@ -2262,7 +3068,10 @@ export function apply(ctx) {
2262
3068
  const txt = (await readTextAbs(dirAbs + '/' + e.name)) || ''
2263
3069
  const name = String(e.name).replace(/\.lean$/, '')
2264
3070
  const first = (txt.split('\n').filter((l) => l.trim() && !/^\s*(\/\/|--|import)/.test(l))[0] || '').trim().slice(0, 110)
2265
- 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, '/') + ' |')
2266
3075
  }
2267
3076
  } catch (e) { /* listing is best-effort */ }
2268
3077
  return rows
@@ -2270,13 +3079,13 @@ export function apply(ctx) {
2270
3079
  const libRows = await scan(formalLibRoot(), 'Formal/Lib', 'def')
2271
3080
  await writeTextAbs(vibeRoot() + '/Formal/Lib/Index.md', ['# 可复用 Lean 定义库(跨项目)|' + instituteName, '',
2272
3081
  '> 写新定义之前先查这里:能复用就不要重新定义。', '',
2273
- '| 名称 | 文件 | 类别 | 摘要 |', '|---|---|---|---|']
2274
- .concat(libRows.length ? libRows : ['| (暂无) | | | |']).join('\n') + '\n')
3082
+ '| 名称 | 文件 | 类别 | 摘要 | 依赖 |', '|---|---|---|---|---|']
3083
+ .concat(libRows.length ? libRows : ['| (暂无) | | | | |']).join('\n') + '\n')
2275
3084
  const provedRows = await scan(formalProvedRoot(), 'Formal/Proved', 'lemma')
2276
3085
  await writeTextAbs(vibeRoot() + '/Formal/Proved/Index.md', ['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|' + instituteName, '',
2277
3086
  '> 这些文件是通过内核检查的引理,可直接 import 复用。', '',
2278
- '| 名称 | 文件 | 类别 | 陈述 |', '|---|---|---|---|']
2279
- .concat(provedRows.length ? provedRows : ['| (暂无) | | | |']).join('\n') + '\n')
3087
+ '| 名称 | 文件 | 类别 | 陈述 | 依赖 |', '|---|---|---|---|---|']
3088
+ .concat(provedRows.length ? provedRows : ['| (暂无) | | | | |']).join('\n') + '\n')
2280
3089
  await writeFormalIndex()
2281
3090
  await writeFormalTodo()
2282
3091
  return { lib: libRows.length, proved: provedRows.length, objects: Object.keys(formalRecords()).length }
@@ -2288,9 +3097,30 @@ export function apply(ctx) {
2288
3097
  // Execute one Lean file through the toolchain, record the run (optionally against an
2289
3098
  // object), refresh the indexes, and report the outcome verbatim. Deliberately called
2290
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.
2291
3102
  async function leanRunTool(memberId, o) {
2292
3103
  const args = o || {}
2293
- 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)
2294
3124
  if (run.ok || run.file) {
2295
3125
  if (String(args.target || '').trim()) await formalSetRun(String(args.target), run)
2296
3126
  await writeFormalIndex()
@@ -2300,11 +3130,46 @@ export function apply(ctx) {
2300
3130
  + '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target + ' 记为已尝试(若此前已通过/已阻塞则保留原状态)' : ''))
2301
3131
  }
2302
3132
  return Object.assign({ ok: !!run.ok }, run, {
3133
+ async: null,
2303
3134
  hint: run.ok
2304
3135
  ? '通过。若是某个对象的证明,请用 vibe_v5_lean_archive kind=\'proof\' 归档(会写入 Verified/Lean/ 并把审查对象变成忠实性);若是可复用定义/引理,用 kind=\'def\'/\'lemma\' 归档到全局库。'
2305
3136
  : '未通过。请按上面的编译器输出修复后重跑;若判断无法完成,用 vibe_v5_lean_archive kind=\'blocked\' 记录原因。',
2306
3137
  })
2307
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
+ }
2308
3173
  async function leanArchive(memberId, o) {
2309
3174
  const args = o || {}
2310
3175
  const kind = String(args.kind || '')
@@ -2321,15 +3186,46 @@ export function apply(ctx) {
2321
3186
  }
2322
3187
  if (body === undefined) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
2323
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
+ }
2324
3196
  const okWrite = await writeTextAbs(instRootless(rel), body)
2325
3197
  if (!okWrite) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
2326
- // The global library sits beside the project tree, so it must be executed through
2327
- // 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
+ }
2328
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
+ })
2329
3225
  await rebuildLeanLibIndexes()
2330
3226
  await saveChatLine('【形式化】' + memberId + ' 归档了' + (kind === 'def' ? '可复用定义' : '已证引理') + ' `' + name + '` → ' + rel
2331
3227
  + (run ? '(运行 ' + (run.ok ? '通过' : '未通过') + ')' : ''))
2332
- 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 复用' }
2333
3229
  }
2334
3230
  if (kind === 'proof') {
2335
3231
  const target = idSafe(String(args.target || ''))
@@ -2342,9 +3238,41 @@ export function apply(ctx) {
2342
3238
  }
2343
3239
  if (body === undefined) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
2344
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
+ }
2345
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
+ }
2346
3275
  const run = await leanRunFile(workRel)
2347
- const prev = formalOf(target)
2348
3276
  const passed = !!run.ok
2349
3277
  const rec = Object.assign({}, prev, {
2350
3278
  status: passed ? 'passed' : 'attempted',
@@ -2358,6 +3286,7 @@ export function apply(ctx) {
2358
3286
  decision: 'used',
2359
3287
  note: String(args.note || prev.note || ''),
2360
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,
2361
3290
  updatedAt: now(),
2362
3291
  })
2363
3292
  if (passed) await writeTextRel('Verified/Lean/' + target + '.lean', body)
@@ -2365,7 +3294,7 @@ export function apply(ctx) {
2365
3294
  await rebuildLeanLibIndexes()
2366
3295
  await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
2367
3296
  + '(运行 ' + (passed ? '**通过**,已归档到 ' + rec.proof + ',验证转为忠实性审查' : '**未通过**:' + tail(run.stderr || run.message, 160)) + ')')
2368
- 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 }
2369
3298
  }
2370
3299
  if (kind === 'blocked') {
2371
3300
  const target = idSafe(String(args.target || ''))
@@ -4614,7 +5543,13 @@ export function apply(ctx) {
4614
5543
  // activity wait, which costs no tokens while the institute is genuinely idle.
4615
5544
  function syncParamsFromState() {
4616
5545
  const cur = inst()
4617
- 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
+ }
4618
5553
  if (cur && cur.project) project = cur.project
4619
5554
  if (cur && cur.institute) instituteName = cur.institute
4620
5555
  if (cur) phase = cur.phase || phase
@@ -5019,15 +5954,21 @@ export function apply(ctx) {
5019
5954
  }
5020
5955
 
5021
5956
  // ---- control plane -----------------------------------------------------
5022
- function normalizeParams(input) {
5957
+ function normalizeParams(input, dropped) {
5023
5958
  const out = {}
5024
- 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',
5025
5964
  'compactThreshold', 'compactAfterRounds', 'maxParallel', 'activityTimeoutMs', 'stallAutoMeetingMs',
5026
5965
  'chatDigestMs', 'chatDigestMax', 'meetingKeepEvery', 'leanTimeoutMs']
5027
- const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign', 'finalPaper', 'paperCompilePdf']
5966
+ const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign', 'finalPaper', 'paperCompilePdf', 'leanAsync']
5028
5967
  const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand',
5029
- 'paperFormat', 'paperLanguage', 'paperEditor', 'paperLatexCommand']
5030
- const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny', 'leanArgs']
5968
+ 'paperFormat', 'paperLanguage', 'paperEditor', 'paperLatexCommand', 'leanInitiative',
5969
+ 'mathComputation', 'mathMode', 'mathInstallScope']
5970
+ const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny', 'leanArgs', 'leanSearchPaths',
5971
+ 'mathEngines', 'mathPackages']
5031
5972
  for (const k of ints) if (input[k] !== undefined) { const n = Math.floor(Number(input[k])); if (Number.isFinite(n)) out[k] = n }
5032
5973
  // Explicit boolean coercion (docs/final-paper.md §2): the old `=== true || === 'true'` turned a
5033
5974
  // legitimate `1` / `'yes'` into FALSE and left junk values truthy-looking. An
@@ -5046,6 +5987,38 @@ export function apply(ctx) {
5046
5987
  out.formalVerify = ['off', 'encourage', 'require'].indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'
5047
5988
  }
5048
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
+ }
5049
6022
  // ── final-paper enums (closed sets; an unknown value degrades to the documented
5050
6023
  // default instead of silently becoming an unreachable fourth mode) ────────────────
5051
6024
  if (out.paperFormat !== undefined) out.paperFormat = coercePaperEnum('paperFormat', out.paperFormat, ['both', 'md', 'tex'], 'both')
@@ -5068,13 +6041,44 @@ export function apply(ctx) {
5068
6041
  if (out.compactThreshold !== undefined && !(out.compactThreshold > 0)) delete out.compactThreshold
5069
6042
  if (out.chatDigestMax !== undefined && out.chatDigestMax < 1) out.chatDigestMax = 1
5070
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
+ }
5071
6051
  return out
5072
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
+ }
5073
6072
  async function setParams(input) {
5074
- const patch = normalizeParams(input || {})
6073
+ const droppedSet = []
6074
+ const patch = normalizeParams(input || {}, droppedSet)
6075
+ reportDroppedStateKeys(inst(), droppedSet, 'set')
5075
6076
  const merged = Object.assign({}, params, patch)
5076
6077
  await patchInstitute({ params: merged })
5077
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()
5078
6082
  // An already-armed heartbeat keeps the delay it was armed with, so a lowered
5079
6083
  // activityTimeoutMs (or a raised maxParallel) would not take effect until some
5080
6084
  // unrelated event drove a pass. Tuning must apply immediately.
@@ -5096,6 +6100,13 @@ export function apply(ctx) {
5096
6100
  chatDigestMax: params.chatDigestMax, meetingKeepEvery: params.meetingKeepEvery,
5097
6101
  formalVerify: params.formalVerify, leanCommand: params.leanCommand,
5098
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,
5099
6110
  // ── final paper ──────────────────────────────────────────────────────
5100
6111
  finalPaper: params.finalPaper, paperFormat: params.paperFormat,
5101
6112
  paperLanguage: params.paperLanguage, paperCompilePdf: params.paperCompilePdf,
@@ -5345,6 +6356,8 @@ export function apply(ctx) {
5345
6356
  // An interrupted FINAL PAPER resumes its own stage (the paper phase runs before the
5346
6357
  // completion flags, so a crash there leaves a live, resumable institute).
5347
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 */ }
5348
6361
  return { ok: true, resumed: true, members: members.map((m) => m.id), respawned, running: true }
5349
6362
  }
5350
6363
  function setPause() {
@@ -5415,7 +6428,7 @@ export function apply(ctx) {
5415
6428
  mode: formalMode(),
5416
6429
  objects: Object.keys(formalRecords()).map((k) => {
5417
6430
  const r = formalRecords()[k] || {}
5418
- 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 }
5419
6432
  }),
5420
6433
  passed: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'passed'),
5421
6434
  blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
@@ -5569,6 +6582,11 @@ export function apply(ctx) {
5569
6582
  // Lean formal verification (docs/formal-verification.md)
5570
6583
  formalMode, formalOn, formalRecords, formalTodo, formalOf, rebuildLeanLibIndexes,
5571
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 } },
5572
6590
  leanRunToolApi: async (relPath, timeoutMs) => await leanRunFile(relPath, timeoutMs),
5573
6591
  // consensus / meetings
5574
6592
  maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
@@ -5616,6 +6634,54 @@ export function apply(ctx) {
5616
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)' }
5617
6635
  return fn(caller)
5618
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
+ }
5619
6685
  /**
5620
6686
  * Resolve the caller of an OFFICE-ONLY control and refuse anything that is not the PROVABLE
5621
6687
  * session root. `officeCaller` answers 'office' for that root, a member id for a member child, and
@@ -5647,14 +6713,20 @@ export function apply(ctx) {
5647
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()))
5648
6714
  registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
5649
6715
  registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
5650
- registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); an m-vote passes only when at least m Boolean votes (exactly 1 or exactly 0) exist AND no voter returns an opposing Boolean, so with the default roster it degenerates to unanimity among the current voters; only current voters count (a dismissed member\'s earlier ballot is dropped). quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record). FINAL PAPER: finalPaper (default true) writes the final paper when the run concludes — the paper phase runs BEFORE the run is marked complete, the permanent staff write their own part, cross-review each other, and the editor named by paperEditor finalises; paperFormat "both"|"md"|"tex"; paperLanguage "zh"|"en"; paperCompilePdf compiles a PDF when a LaTeX engine is detected; paperEditor "academician" (default, the only editor an unattended run can reach) | "office" (manual /v5 paper only — the office must first consult the whole institute: >=1 office message AND >=1 meeting, recorded in the finalisation note); paperLatexCommand forces one engine command instead of auto-detection (empty = auto: xelatex -> latexmk -> pdflatex -> lualatex -> tectonic, English prefers pdflatex). Unknown spellings of these enums fall back to the documented default.', objParams({
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({
5651
6717
  academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
5652
6718
  quorumCap: I, quorumMode: S, verdictMaxRounds: I,
5653
6719
  maxTempPerMember: I, maxTempTotal: I,
5654
6720
  compactThreshold: I, compactAfterRounds: I, maxParallel: I,
5655
6721
  activityTimeoutMs: I, stallAutoMeetingMs: I, chatDigestMs: I, chatDigestMax: I, meetingKeepEvery: I,
5656
6722
  formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] },
5657
- 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'] },
5658
6730
  finalPaper: B, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] },
5659
6731
  paperLanguage: { type: 'string', enum: ['zh', 'en'] },
5660
6732
  paperCompilePdf: B, paperEditor: { type: 'string', enum: ['office', 'academician'] }, paperLatexCommand: S,
@@ -5791,20 +6863,28 @@ export function apply(ctx) {
5791
6863
  // dynamic registration would depend on a runtime knob and break the effect discipline),
5792
6864
  // while the MODE only decides whether the framework TELLS members about them. In 'off'
5793
6865
  // mode they still work if a human or agent calls them deliberately.
5794
- registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT, and a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
5795
- registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
5796
- registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
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) => {
5797
6869
  const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
5798
6870
  const st = s.status()
5799
6871
  return {
5800
6872
  ok: true, mode: s.formalMode(), rebuilt: !(a && a.refresh === false),
5801
- counts: r, todo: s.formalTodo(),
5802
- 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 })),
5803
- paths: { project: 'Formal/(相对研究所根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/' },
5804
- 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} 取原文逐字复制。同内容重复归档会自动去重。',
5805
6877
  verify: st.verify ? st.verify.target : null,
5806
6878
  }
5807
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))
5808
6888
 
5809
6889
  // ── /v5 slash command ────────────────────────────────────────────────────
5810
6890
  ctx.effect(() => commands.register({
@@ -5949,6 +7029,9 @@ export const __testHelpers = {
5949
7029
  parseReply,
5950
7030
  sanitizeToolFilter,
5951
7031
  registeredToolsFromError,
7032
+ sha256Hex,
7033
+ leanHashText,
7034
+ leanHasSearchFlag,
5952
7035
  }
5953
7036
 
5954
7037
  function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }