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
@@ -39,6 +39,22 @@
39
39
  // VibeMath/current.<sessionId>.json = per-session current project.
40
40
  export const name = 'vibe-math-v3'
41
41
  export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands']
42
+ import { createHash } from 'node:crypto'
43
+ // 数学计算工具(共享模块,四套字节一致;由 integration owner 维护,本文件只做接线)。
44
+ // 模块内部自己 import './math-engines.js',接线方不直接引用引擎表。
45
+ import {
46
+ registerMathComputation,
47
+ MATH_PARAM_NAMES,
48
+ MATH_PARAM_DEFAULTS,
49
+ MATH_TOOL_DESCRIPTION,
50
+ MATH_TOOL_SCHEMA,
51
+ MATH_ARCHIVE_WORKFLOW_LINE,
52
+ MATH_PERSONA_TOOL_LINE,
53
+ MATH_RULE_LINES,
54
+ normalizeMathParams,
55
+ mathAvailabilityLine,
56
+ probeMathEngines,
57
+ } from './math-computation.js'
42
58
 
43
59
  // Standing mount: DSH mounts each agent preset ONCE per preset and joins every
44
60
  // session that names it to that SAME plugin instance (see @deepseek-ai/dsh-agent-presets).
@@ -179,12 +195,24 @@ export function apply(ctx) {
179
195
  leanCommand: 'lean', // 要执行的 Lean 可执行文件(例:'lake')
180
196
  leanArgs: [], // 插在文件名之前的附加参数(例:['env','lean'] 配 leanCommand='lake')
181
197
  leanTimeoutMs: 120000, // 单次 Lean 运行超时上限(毫秒,正整数)
198
+ leanAsync: true, // true = Lean 编译走后台队列(入队即返回)| false = 同步 await(旧语义)
199
+ leanJobsMaxParallel: 1, // 后台编译并发上限(默认 1 = 串行;可调大以并行编译)
200
+ leanInitiative: 'normal', // 日常流程中的形式化主动性:off | normal | eager(与 formalVerify 的"验证要求强度"是两件事)
201
+ leanSearchPaths: [], // 额外搜索路径(默认空 = 只用自动注入的 VibeMath 根);非空时先注入它们、再注入自动根
182
202
  // ---- 最终论文(规格:docs/final-paper.md;v2/v3 为单作者变体)----
183
203
  finalPaper: true, // 收口时是否自动撰写最终论文(false 只关自动触发;/vibe paper 仍可用)
184
204
  paperFormat: 'both', // both = md + tex | md = 只写 markdown | tex = 只写 latex
185
205
  paperLanguage: 'zh', // zh = 中文(ctexart/xelatex 优先)| en = 英文(article/pdflatex 优先)
186
206
  paperCompilePdf: true, // 检测到 LaTeX 时是否编译 paper.pdf
187
207
  paperLatexCommand: '', // 指定 LaTeX 引擎(空 = 按语言探测 xelatex/latexmk/pdflatex/lualatex/tectonic)
208
+ // ---- 数学计算 math_computation(规格:docs/math-computation.md;六参数已冻结,拼写不得改)----
209
+ // 值取共享模块的 MATH_PARAM_DEFAULTS(四套逐字一致);数组必须**拷贝**,否则四套会共享同一个默认数组。
210
+ mathComputation: MATH_PARAM_DEFAULTS.mathComputation, // off | auto | on(auto = 探测到任一允许引擎才工作)
211
+ mathMode: MATH_PARAM_DEFAULTS.mathMode, // typed | typed+shell(后者才允许提示词里的 shell 兜底段)
212
+ mathEngines: MATH_PARAM_DEFAULTS.mathEngines.slice(), // 允许的引擎(含 cli;cli 默认开启,受 mathMode/mathEngines 双闸)
213
+ mathTimeoutMs: MATH_PARAM_DEFAULTS.mathTimeoutMs, // 单次计算超时上限(毫秒,下界 1000)
214
+ mathPackages: MATH_PARAM_DEFAULTS.mathPackages.slice(), // 需要预检的包(缺包只报告 + 给安装计划)
215
+ mathInstallScope: MATH_PARAM_DEFAULTS.mathInstallScope, // user | system(system 只对当次显式调用生效,永不记忆)
188
216
  }
189
217
  let params = Object.assign({}, DEFAULT_PARAMS)
190
218
  let scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null } // activeCount 由 activeCount() 从 agentRegistry 推导(防漂移,同 v2)
@@ -304,12 +332,23 @@ export function apply(ctx) {
304
332
  { name: 'formalVerify', type: 'enum', options: ['off', 'encourage', 'require'], description: 'Lean 形式化验证:off = 不额外要求(默认,提示词里不出现任何 Lean 内容);encourage = 鼓励按实现难度形式化,Lean 通过后验证转为忠实性审查;require = 同上并加门禁:真/假定论必须先达到 Lean 已通过 或 已记录显式阻塞原因,否则记为未定论(formal-required)并进「形式化待办」', suggestion: 'off' },
305
333
  { name: 'leanCommand', type: 'string', description: 'Lean 可执行文件(例:lean / lake;配合 leanArgs=[env,lean] 用 lake)', suggestion: 'lean' },
306
334
  { name: 'leanArgs', type: 'string[]', description: '插在 .lean 文件名之前的附加参数', suggestion: [] },
307
- { name: 'leanTimeoutMs', type: 'integer', description: '单次 Lean 运行的超时上限(毫秒,非正数回退默认)', suggestion: 120000 },
335
+ { name: 'leanTimeoutMs', type: 'integer', description: '单次 Lean 运行的超时上限(毫秒,非正数回退默认);异步档下它同时是**每个后台作业**的预算(到时主动 terminate,作业记 timeout,对象留在 attempted)', suggestion: 120000 },
336
+ { name: 'leanAsync', type: 'boolean', description: 'Lean 编译模式:true(默认)= 后台队列,vibe_math_lean_run / vibe_math_lean_archive{run:true} 立即返回 async.jobId 入队,成员不阻塞,结果由下一轮提示的【形式化结果】行与 vibe_math_lean_job 公告(**只有作业落地为 ok 才会置 passed 并写归档证明**);false = 完全同步 await(与旧行为逐字一致)', suggestion: true },
337
+ { name: 'leanJobsMaxParallel', type: 'integer', description: '后台 Lean 编译的并发上限(默认 1 = 串行,保持可预测的资源占用;调大可并行编译多个作业)', suggestion: 1 },
338
+ { name: 'leanInitiative', type: 'enum', options: ['off', 'normal', 'eager'], description: '日常流程中的形式化主动性:off(不主动,只在验证提示词按 formalVerify 的要求做)| normal(默认:顺手把有价值且可能复用的东西形式化)| eager(更主动:日常就主动把有价值的小引理/命题/定义形式化)。注意它与 formalVerify(验证时的要求强度:off|encourage|require)是**两件事**', suggestion: 'normal' },
339
+ { name: 'leanSearchPaths', type: 'string[]', description: '额外 Lean 搜索路径(默认空数组 = 只用框架自动注入的 VibeMath 根)。非空时按顺序先注入这里给的路径、再注入自动根(去重);若 leanArgs 里已显式给了 --search-path/-R/--root,则完全尊重用户配置、不注入任何东西', suggestion: [] },
308
340
  { name: 'finalPaper', type: 'boolean', description: '收口(checkTermination 的完整收口分支:无未解决问题、无待验证对象、无任务/计划/门)时自动撰写最终论文:派遣一名专职「论文撰写」子代理,把已定论命题/解法/方法整理成 Paper/<项目>/{paper.md,paper.tex,paper.meta.json,paper.log.md}。false = 只关自动触发,/vibe paper 手动命令仍可用', suggestion: true },
309
341
  { name: 'paperFormat', type: 'enum', options: ['both', 'md', 'tex'], description: '论文产出格式:both = markdown + latex;md = 只写 paper.md(跳过编译);tex = 只写 paper.tex(tex 才会尝试编译 pdf)', suggestion: 'both' },
310
342
  { name: 'paperLanguage', type: 'enum', options: ['zh', 'en'], description: '论文语言:zh = 中文(LaTeX 用 ctexart,引擎优先 xelatex);en = 英文(article,引擎优先 pdflatex/latexmk)', suggestion: 'zh' },
311
343
  { name: 'paperCompilePdf', type: 'boolean', description: '检测到 LaTeX 时是否编译 paper.pdf(-interaction=nonstopmode 跑两遍;失败先尝试修复:换引擎/去不支持宏包/最小模板)。false 或无 LaTeX 时只保留 tex+md 并记日志(不阻塞定稿)', suggestion: true },
312
344
  { name: 'paperLatexCommand', type: 'string', description: '指定 LaTeX 引擎可执行文件(空 = 按语言探测:中文 xelatex > latexmk > pdflatex > lualatex > tectonic;英文 pdflatex 优先)。解析不到时按"未检测到"降级', suggestion: '' },
345
+ // ---- 数学计算 math_computation(六参数冻结;描述与 prompts.md §1/§6 口径一致)----
346
+ { name: 'mathComputation', type: 'enum', options: ['off', 'auto', 'on'], description: '数学计算总开关:off = 真无操作(提示词零提及);auto = 探测到 mathEngines 里任一允许引擎才工作;on = 同上(探测失败时工具仍返回可执行的安装指引,而不是假装可用)', suggestion: 'auto' },
347
+ { name: 'mathMode', type: 'enum', options: ['typed', 'typed+shell'], description: '计算策略:typed+shell(默认)= 工具不可用时允许宿主 shell 兜底,但结论必须标注"未经工具归档(shell 路径)";typed = 只用工具路径(提示词里不出现 shell 兜底段,engine:"cli" 返回 REFUSED{reason:policy})', suggestion: 'typed+shell' },
348
+ { name: 'mathEngines', type: 'string[]', description: '允许的引擎列表(默认 python|r|octave|julia|matlab|maple|wolfram|cli)。cli 默认开启且走同一套超时/输出上限/回执;从列表里移除某引擎即禁用(商业引擎只探测+许可,永不安装)', suggestion: MATH_PARAM_DEFAULTS.mathEngines.slice() },
349
+ { name: 'mathTimeoutMs', type: 'integer', description: '单次数学计算的超时上限(毫秒,默认 60000,最小 1000);到时主动 terminate 并把回执标为 MATH_TIMEOUT', suggestion: 60000 },
350
+ { name: 'mathPackages', type: 'string[]', description: '需要预检的包(默认空)。缺包只报告 + 给"用户自装指引"或"代理代装计划",不会执行脚本,也永不自动安装', suggestion: [] },
351
+ { name: 'mathInstallScope', type: 'enum', options: ['user', 'system'], description: '安装作用域:user(默认,用户级目录);system 只对当次显式调用生效、永不记忆(不会写进状态文件)', suggestion: 'user' },
313
352
  ]
314
353
 
315
354
  // ================= fs (adapted to DSH 0.1.1: resolve returns {targetKey, displayPath}) =================
@@ -371,6 +410,18 @@ export function apply(ctx) {
371
410
  if (isWindows()) return 'Remove-Item -Force -LiteralPath ' + psQuote(path) + ' -ErrorAction SilentlyContinue'
372
411
  return 'rm -f ' + shQuote(path)
373
412
  }
413
+ /**
414
+ * `subprocess.spawn` 的契约返回**句柄**(带 `done`),但桩宿主/测试替身常常写成 `async spawn(...)`
415
+ * ——返回的是 Promise,`handle.done` 是 undefined,`await handle.done` 得到 undefined,随后
416
+ * `outcome.exitCode` 抛 "Cannot read properties of undefined (reading 'exitCode')",被 catch 成一条
417
+ * 误导性的目录创建失败。这里统一兼容一次:Promise 就先 await,句柄缺 `done` 才如实报错。
418
+ */
419
+ async function spawnHandle(subprocess, spec) {
420
+ const raw = subprocess.spawn(spec)
421
+ const handle = (raw && typeof raw.then === 'function') ? await raw : raw
422
+ if (!handle || handle.done === undefined) throw new Error('subprocess.spawn returned no handle.done')
423
+ return handle
424
+ }
374
425
  async function runShell(script, cwd) {
375
426
  const subprocess = subprocessOf()
376
427
  if (subprocess === undefined) return { ok: false, error: 'no-subprocess' }
@@ -378,9 +429,9 @@ export function apply(ctx) {
378
429
  const argv = isWindows()
379
430
  ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', script]
380
431
  : ['/bin/sh', '-c', script]
381
- const handle = subprocess.spawn({ argv: argv, cwd: cwd || workspaceRoot(), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 20000 })
432
+ const handle = await spawnHandle(subprocess, { argv: argv, cwd: cwd || workspaceRoot(), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 20000 })
382
433
  const outcome = await handle.done
383
- return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode }
434
+ return { ok: !!outcome && outcome.exitCode === 0, exitCode: (outcome && outcome.exitCode !== undefined) ? outcome.exitCode : null }
384
435
  } catch (e) { return { ok: false, error: String((e && e.message) || e) } }
385
436
  }
386
437
  /**
@@ -391,19 +442,139 @@ export function apply(ctx) {
391
442
  // Progress_Logs/ 必须和别的骨架目录**一起**建出来(审计 L8):vibe_math_report 会写
392
443
  // `Progress_Logs/report.json`,README 也把它列为布局的一部分,但此前它只靠 writeText 的隐式
393
444
  // mkdir 兜底——在宿主不支持删除/创建的路径上(runShell 失败)报告目录就时有时无。
394
- async function ensureDirs() { const base = frameworkRoot(); const dirs = ['Problems', 'Progress', 'Progress_Logs', 'Propos', 'Methods', 'Verified/命题', 'Verified/问题', 'Verified/Lean', 'Formal', 'Reliable', 'Notes', 'Logs/Verification', 'Logs/Plans', 'State']; const paths = [vibeRoot() + '/Projects', vibeRoot() + '/Methods', vibeRoot() + '/Formal/Lib', vibeRoot() + '/Formal/Proved'].concat(dirs.map(function (d) { return base + '/' + d })); return await runShell(mkdirCmd(paths)) }
445
+ async function ensureDirs() { const base = frameworkRoot(); const dirs = ['Problems', 'Progress', 'Progress_Logs', 'Propos', 'Methods', 'Verified/命题', 'Verified/问题', 'Verified/Lean', 'Formal', 'Formal/Jobs', 'Reliable', 'Notes', 'Logs/Verification', 'Logs/Plans', 'State', 'Computation']; const paths = [vibeRoot() + '/Projects', vibeRoot() + '/Methods', vibeRoot() + '/Formal/Lib', vibeRoot() + '/Formal/Proved'].concat(dirs.map(function (d) { return base + '/' + d })); return await runShell(mkdirCmd(paths)) }
395
446
  async function removeFile(rel) { const base = frameworkRoot(); return await runShell(rmCmd(base + '/' + rel)) }
396
447
 
448
+ // ================= 数学计算 math_computation:会话侧接线(共享模块,FREEZE §4/§5) =================
449
+ // 探测量(是否装了 python/r/…)是异步的,而提示词是同步构造的 ⇒ 会话级缓存 + TTL 刷新,
450
+ // 提示词侧只读缓存现算(与 formalVerify / Lean 的动态纪律一致)。
451
+ const MATH_PROBE_TTL_MS = 15 * 60 * 1000
452
+ let mathProbe = null
453
+ let mathProbeAt = 0
454
+ /**
455
+ * `host.spawn` 适配:模块只给 `{argv, cwd, timeoutMs, stdoutCap, stderrCap}`,超时/终止由**接线方**负责
456
+ * (契约:超时必须 handle.terminate())。返回**完整** stdout/stderr——模块自己落盘完整版、只在返回体裁 64KB,
457
+ * 所以 stdio 上限要宽(默认 4MB),否则"超大输出"永远测不出完整落盘。
458
+ */
459
+ async function mathSpawn(spec) {
460
+ const sub = subprocessOf()
461
+ if (sub === undefined || typeof sub.spawn !== 'function') return null // 模块映射为 MATH_NO_SUBPROCESS
462
+ const s = spec || {}
463
+ const cap = Math.max(1000, Number(s.timeoutMs) || Number(params.mathTimeoutMs) || 60000)
464
+ const outCap = Math.max(4096, Number(s.stdoutCap) || 4 * 1024 * 1024)
465
+ const errCap = Math.max(4096, Number(s.stderrCap) || 4 * 1024 * 1024)
466
+ const started = now()
467
+ let handle
468
+ try {
469
+ handle = await spawnHandle(sub, {
470
+ argv: s.argv,
471
+ cwd: s.cwd || frameworkRoot(),
472
+ stdio: { stdin: 'ignore', stdout: { maxBytes: outCap }, stderr: { maxBytes: errCap } },
473
+ graceMs: cap,
474
+ })
475
+ } catch (e) { return null }
476
+ let timedOut = false, killed = false, timer = null, outcome
477
+ try {
478
+ outcome = await Promise.race([
479
+ handle.done,
480
+ new Promise(function (resolve) {
481
+ timer = setTimeout(function () {
482
+ timedOut = true
483
+ killed = true
484
+ try { if (handle && typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* best effort */ }
485
+ resolve({ exitCode: null })
486
+ }, cap)
487
+ }),
488
+ ])
489
+ } catch (e) { if (timer !== null) clearTimeout(timer); return null }
490
+ if (timer !== null) clearTimeout(timer)
491
+ let out = '', err = ''
492
+ try { if (handle.collected && handle.collected.stdout) out = handle.collected.stdout.readFrom(0).text } catch (e) { /* best effort */ }
493
+ try { if (handle.collected && handle.collected.stderr) err = handle.collected.stderr.readFrom(0).text } catch (e) { /* best effort */ }
494
+ return {
495
+ exit: (outcome && outcome.exitCode !== undefined) ? outcome.exitCode : null,
496
+ timedOut: timedOut, killed: killed, ms: now() - started,
497
+ stdout: String(out == null ? '' : out), stderr: String(err == null ? '' : err),
498
+ }
499
+ }
500
+ /** host.exists:项目根相对路径是否存在。 */
501
+ async function mathExists(rel) {
502
+ try { const t = await fsTarget(rel); const s = await fs.stat(t); return s !== undefined } catch (e) { return false }
503
+ }
504
+ /**
505
+ * host.listDir(**可选**,FREEZE §4):模块用它数 `Computation/` 下的 run 目录数(>200 只告警、永不删除)。
506
+ * 必须原样返回 DSH `fs.listDir` 的条目({name,type})——模块按 `type === 'directory'` 计数。
507
+ * 宿主 fs 没有该能力时整个回调都不声明(见 mathHost 的条件字段)。
508
+ */
509
+ async function mathListDir(rel) {
510
+ try { const t = await fsTarget(rel); const ents = await fs.listDir(t); return Array.isArray(ents) ? ents : [] } catch (e) { return [] }
511
+ }
512
+ /** 会话级 host:参数是**活引用**(函数),回执里写 designator='vibe-math-v3'。 */
513
+ const mathHost = {
514
+ // 模块注册时只**交回** handler/description/parameters;真正的挂载是下面两条**字面量** registerTool
515
+ // 调用(会话层 + apply 层)。原因:tests/audit-v3-registration-parity.mjs 的静态扫描器按
516
+ // `registerTool('<名字字面量>', TOOL_DESC.<同名>, …)` 收集两条路径,动态回调形态它看不见,
517
+ // 会让 `tableKeys.size === byName.size` 不成立。回调保持为空以实现"只注册一次"的显式化。
518
+ register: function (name, description, parameters, handler) { /* 挂载见 mathTool 之后的字面量两行 */ },
519
+ params: function () { return params },
520
+ projectRoot: function () { return frameworkRoot() },
521
+ designator: 'vibe-math-v3',
522
+ // 宿主明知没有 subprocess 服务时可以直接说 false ⇒ 模块立即返回 MATH_NO_SUBPROCESS(不再逐个探引擎)。
523
+ hasSubprocess: function () { const sub = subprocessOf(); return !!(sub && typeof sub.spawn === 'function') },
524
+ // **可选能力,按宿主实际情况声明**:宿主 fs 没有 listDir 就不给这个回调(模块据此跳过
525
+ // "每项目 run 目录数 >200" 的保留检查,而不是每次运行都白问一次)。
526
+ listDir: (typeof fs.listDir === 'function') ? mathListDir : undefined,
527
+ writeText: async function (rel, text) { return await writeText(rel, text) },
528
+ readText: async function (rel) { return await readText(rel) },
529
+ exists: mathExists,
530
+ resolveExecutable: async function (cmd) {
531
+ const sub = subprocessOf()
532
+ if (sub === undefined || typeof sub.resolveExecutable !== 'function') throw new Error('no subprocess service')
533
+ const p = await sub.resolveExecutable(String(cmd))
534
+ if (typeof p !== 'string' || !p) throw new Error('not found: ' + String(cmd))
535
+ return p
536
+ },
537
+ spawn: mathSpawn,
538
+ log: function (kind, msg) { logActivity('math', String(kind) + ': ' + String(msg)) },
539
+ }
540
+ // 真实注册必须等到 `const handlers = {}` 与 `function registerTool(...)` 就绪(handlers 是 const,
541
+ // 提前调用会撞 TDZ)⇒ 这里只留槽位,注册放在会话工具表旁。
542
+ let mathTool = null
543
+ /** 刷新探测缓存。`mathComputation:'off'` ⇒ 不探测、不缓存、不注入(真 no-op)。 */
544
+ async function refreshMathProbe(force) {
545
+ if (params.mathComputation === 'off') { mathProbe = null; mathProbeAt = 0; return null }
546
+ if (!mathTool) return null
547
+ try { mathProbe = await mathTool.probe(force ? { refresh: true } : undefined); mathProbeAt = now() } catch (e) { mathProbe = null }
548
+ return mathProbe
549
+ }
550
+ /** 心跳里的 TTL 刷新(每拍一次布尔判断)。 */
551
+ function mathProbeDue() { return params.mathComputation !== 'off' && (!mathProbe || (now() - mathProbeAt) >= MATH_PROBE_TTL_MS) }
552
+ /**
553
+ * 每轮可用性行(中文)。由模块的 `mathAvailabilityLine(probe,'zh',mathMode)` 按档位拼装:
554
+ * 只有 `typed+shell` 才含 shell 兜底句(`mathMode:'typed'` 天然不含,守卫据此断言)。
555
+ * `off` 档返回空串——提示词零提及(对齐 formalVerify:'off' 的真 no-op 纪律)。
556
+ */
557
+ function mathWorkLine() {
558
+ if (params.mathComputation === 'off') return ''
559
+ if (!mathProbe) return '' // 还没探测过 ⇒ 不注入(不撒谎;init 会先探一次)
560
+ return '\n' + mathAvailabilityLine(mathProbe, 'zh', params.mathMode) + '\n'
561
+ }
562
+
397
563
  // ================= settings =================
398
564
  function sanitizeParams(obj) {
399
565
  const out = {}
400
566
  const intFields = ['maxParallelThreshold', 'solverMaxRounds', 'directionsPerSolver', 'verifierCount', 'debateMaxRounds', 'solverMaxToolCalls', 'verifierMaxToolCalls', 'reportIntervalMs', 'tickIntervalMs', 'activityLogCap', 'maxExplorerRetries', 'planningHorizon', 'planMinIntervalMs', 'plannerMaxFails', 'methodKeepIntervalMs', 'methodKeepEvery', 'projectLockTimeoutMs']
401
567
  const numFields = ['promoteValueThreshold']
402
568
  const arrayFields = ['solverToolAllow', 'solverToolDeny', 'verifierToolAllow', 'verifierToolDeny', 'leanArgs']
403
- const boolFields = ['plannerEnabled', 'methodAutoPromote', 'indexAutoRebuild', 'finalPaper', 'paperCompilePdf']
569
+ const boolFields = ['plannerEnabled', 'methodAutoPromote', 'indexAutoRebuild', 'finalPaper', 'paperCompilePdf', 'leanAsync']
570
+ // 数学计算六参数的显式强制(FREEZE §5.3):交给共享模块的 normalizeMathParams 逐键判型
571
+ // (enum/array/integer)——`'false'`/错类型/错枚举一律回退默认,**绝不**落到末尾的
572
+ // `else { out[k] = v }`(那条尾巴会把任意字符串当合法值放行)。
573
+ const mathNorm = normalizeMathParams(obj)
404
574
  for (const k of Object.keys(DEFAULT_PARAMS)) {
405
575
  if (!(k in obj)) continue
406
576
  const v = obj[k]
577
+ if (MATH_PARAM_NAMES.indexOf(k) !== -1) { out[k] = Object.prototype.hasOwnProperty.call(mathNorm, k) ? mathNorm[k] : DEFAULT_PARAMS[k]; continue }
407
578
  if (intFields.indexOf(k) !== -1) { const n = Number(v); out[k] = Number.isFinite(n) ? Math.floor(n) : DEFAULT_PARAMS[k] }
408
579
  else if (numFields.indexOf(k) !== -1) { const n = Number(v); out[k] = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : DEFAULT_PARAMS[k] }
409
580
  else if (arrayFields.indexOf(k) !== -1) { out[k] = Array.isArray(v) ? v.filter(function (x) { return typeof x === 'string' }) : DEFAULT_PARAMS[k] }
@@ -418,6 +589,15 @@ export function apply(ctx) {
418
589
  else if (k === 'formalVerify') { out[k] = (v === 'off' || v === 'encourage' || v === 'require') ? v : 'off' }
419
590
  else if (k === 'leanCommand') { const s = String(v == null ? '' : v).trim(); out[k] = s || 'lean' }
420
591
  else if (k === 'leanTimeoutMs') { const n = Number(v); out[k] = (Number.isFinite(n) && n > 0) ? Math.floor(n) : DEFAULT_PARAMS[k] }
592
+ // Lean 增量/异步(spec §1.1 + 修订 §5):并行度必须有下界(0 会让队列永不推进,静默失效)。
593
+ else if (k === 'leanJobsMaxParallel') { const n = Number(v); out[k] = Number.isFinite(n) ? Math.max(1, Math.min(8, Math.floor(n))) : DEFAULT_PARAMS[k] }
594
+ else if (k === 'leanInitiative') { out[k] = (v === 'off' || v === 'normal' || v === 'eager') ? v : DEFAULT_PARAMS[k] }
595
+ else if (k === 'leanSearchPaths') {
596
+ if (!Array.isArray(v)) { out[k] = DEFAULT_PARAMS[k] } else {
597
+ const seen = {}
598
+ out[k] = v.map(function (x) { return String(x == null ? '' : x).trim() }).filter(function (x) { if (!x || seen[x]) return false; seen[x] = true; return true })
599
+ }
600
+ }
421
601
  else if (k === 'solverAllowNetwork' || k === 'verifierAllowNetwork' || k === 'solverAllowScripts' || k === 'verifierAllowScripts') { out[k] = (v === true || v === false || v === '') ? v : DEFAULT_PARAMS[k] }
422
602
  // 最终论文(spec v2 §B):布尔/枚举必须**显式归一化**,不允许未知键直通('false' 会保持真值)。
423
603
  else if (k === 'paperFormat') { out[k] = (v === 'both' || v === 'md' || v === 'tex') ? v : DEFAULT_PARAMS[k] }
@@ -1227,7 +1407,7 @@ export function apply(ctx) {
1227
1407
  'feasibility ∈ [0,1] = your estimate of the probability this direction leads to a full solution. Respond with ONLY a single JSON object in a ```json code fence (no prose outside it). Register the directions as metadata; the scheduler writes them into the research log:\n' +
1228
1408
  '{"meta":{"kind":"directions","qid":"<qid>","directions":[{"id":"d1","title":"...","method":"...","core_assumption":"...","feasibility":0.5}],"methods_used":[{"id":"m-...","效果":"<为何该方向借鉴它>","建议":"..."}],"new_inventions":[{"类型":"方法|工具|...","标题":"...","内容描述":"...","是否已入库":false}]}}' +
1229
1409
  // 顺手形式化(契约 §6.2)+ 回执字段(契约 §6.3)
1230
- (formalOn() ? '\n' + formalWorkLine() + formalReplyNote() : '')
1410
+ mathWorkLine() + formalDailySection()
1231
1411
  }
1232
1412
  function rederivePrompt(q, prog) {
1233
1413
  const prior = prog.map(function (d) {
@@ -1242,7 +1422,7 @@ export function apply(ctx) {
1242
1422
  'Then deeply DERIVE 1-3 BRAND-NEW directions never tried before, each with a one-line motivation. Return the UNION of high-potential leftover directions and the brand-new directions (drop dead ends).\n\n' +
1243
1423
  'feasibility ∈ [0,1]. Respond with ONLY a single JSON object in a ```json code fence (no prose outside it). Register the directions as metadata; the scheduler writes them into the research log:\n' +
1244
1424
  '{"meta":{"kind":"directions","qid":"<qid>","directions":[{"id":"d1","title":"...","method":"...","core_assumption":"...","feasibility":0.5}],"methods_used":[{"id":"m-...","效果":"...","建议":"..."}],"new_inventions":[{"类型":"方法|工具|...","标题":"...","内容描述":"...","是否已入库":false}]}}' +
1245
- (formalOn() ? '\n' + formalWorkLine() + formalReplyNote() : '')
1425
+ mathWorkLine() + formalDailySection()
1246
1426
  }
1247
1427
  function directionSummary(d) {
1248
1428
  return 'id ' + d.id + '「' + d.title + '」method=' + d.method + ' | round=' + d.round + ' status=' + d.status +
@@ -1290,7 +1470,7 @@ export function apply(ctx) {
1290
1470
  '区分规则:methods_used 只能填**已存在的方法卡 ID**(m-…,来自 AVAILABLE METHODS 列表)——引用你自己刚想出的新方法/新技巧不属于 methods_used,请如实填入 new_inventions(它会由 Method Keeper 蒸馏建卡);不要把方法名/标题当 id 填进 methods_used。'
1291
1471
  // 顺手形式化(契约 §6.2):把常用/可复用的对象、假设、新定义沉淀到全局 Lean 库;
1292
1472
  // 回执里同样要带上 formal 难度判断字段(契约 §6.3)。
1293
- if (formalOn()) head += '\n' + formalWorkLine() + formalReplyNote()
1473
+ head += mathWorkLine() + formalDailySection()
1294
1474
  return head
1295
1475
  }
1296
1476
  function verifierTargetText(r) {
@@ -1349,7 +1529,8 @@ export function apply(ctx) {
1349
1529
  'For each pending invention decide: create a NEW method card, or fold it into an EXISTING method (as an improvement). Only list 可信断言 for claims already verified (ids from Verified/) — everything else stays 经验 (experiential). You may propose 上级体系/子方法 links to organize methods into systems.\n' +
1350
1530
  // Method Keeper 的职责正是「沉淀可复用方法」,所以形式化的沉淀也归它:可复用的定义/假设
1351
1531
  // 进全局 Lib/,已成立的引理进 Proved/,让后续项目的证明直接 import 复用(契约 §6.2)。
1352
- (formalOn() ? formalWorkLine() + '\n【方法沉淀 × Lean 形式化】除了方法卡,你沉淀的每个可复用对象 / 定义 / 假设都应当归档到全局 Lean 库(vibe_math_lean_archive kind=\'def\'),已成立的引理归档到 Proved/(kind=\'lemma\');归档时**连同定义与陈述一起写清**,方便后续直接 import。\n' : '') +
1532
+ mathWorkLine() +
1533
+ (formalWorkLine() + (formalOn() ? '\n【方法沉淀 × Lean 形式化】除了方法卡,你沉淀的每个可复用对象 / 定义 / 假设都应当归档到全局 Lean 库(vibe_math_lean_archive kind=\'def\'),已成立的引理归档到 Proved/(kind=\'lemma\');归档时**连同定义与陈述一起写清**,方便后续直接 import。\n' : '')) +
1353
1534
  'OUTPUT CONTRACT — pick ONE channel. Write method cards into Markdown; only the created IDs, which cards were used, and improvements cross the machine reply.\n' +
1354
1535
  'CHANNEL A (recommended, you can write files): write each method card into `Methods/<m-id>.md` (`# 方法|标题` + `- 标题/ID/类型/状态/可信断言/适用场景` + `## 核心内容`/`## 应用记录`/`## 改进历史`), then reply ONLY this metadata:\n' +
1355
1536
  '{"meta":{"kind":"methods","used":[{"id":"m-...","效果":"...","建议":"..."}],"created":["m-xxx"],"improvements":[{"id":"m-...","改进内容":"...","原因":"..."}]}}\n' +
@@ -2487,21 +2668,30 @@ export function apply(ctx) {
2487
2668
  * 在一个 .lean 文件上跑工具链。**任何**失败模式(无 subprocess 服务、工具链不存在、
2488
2669
  * spawn 失败、超时、非零退出)都变成可读结果:调度循环永远不会因为 Lean 而崩。
2489
2670
  */
2671
+ /**
2672
+ * 路径/存在性守卫(同步档与异步档共用同一套错误码)。
2673
+ */
2490
2674
  async function leanRunFile(relPath, timeoutMs) {
2491
- const started = now()
2492
2675
  const rel = String(relPath == null ? '' : relPath).trim()
2493
2676
  if (!rel) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'file is required' }
2494
2677
  const abs = await leanResolveRun(rel)
2495
- if (abs === null) {
2496
- return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'Lean 文件必须位于 ' + vibeRoot().replace(/\\/g, '/') + '/ 之内(收到 ' + rel + ')' }
2497
- }
2678
+ if (abs === null) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'Lean 文件必须位于 ' + vibeRoot().replace(/\\/g, '/') + '/ 之内(收到 ' + rel + ')' }
2498
2679
  if (!/\.lean$/i.test(abs)) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'only .lean files can be executed' }
2499
2680
  if (await readTextAbs(abs) === undefined) return { ok: false, code: 'V3_NOT_FOUND', message: 'no such file: ' + rel }
2681
+ return await leanExecFile(rel, abs, timeoutMs)
2682
+ }
2683
+ /**
2684
+ * 已解析路径的**实际执行**(同步档与后台作业共用):搜索路径注入(spec §3 + 修订 §2)
2685
+ * 在**用户 leanArgs 之后、文件名之前**;作业用入队时冻结的 plan(修订 §4)。
2686
+ */
2687
+ async function leanExecFile(rel, abs, timeoutMs, plan, onHandle) {
2688
+ const started = now()
2500
2689
  const sub = subprocessOf()
2501
2690
  if (sub === undefined || typeof sub.spawn !== 'function') {
2502
2691
  return { ok: false, code: 'NO_SUBPROCESS', message: 'the host exposes no subprocess service; Lean cannot be executed here', file: rel, ms: 0 }
2503
2692
  }
2504
- const cmd = String(params.leanCommand || 'lean')
2693
+ const p = plan || leanBuildPlan()
2694
+ const cmd = String(p.engine || 'lean')
2505
2695
  const cap = Math.max(1000, Number(timeoutMs) || Number(params.leanTimeoutMs) || 120000)
2506
2696
  if (typeof sub.resolveExecutable !== 'function') {
2507
2697
  return { ok: false, code: 'LEAN_NOT_FOUND', message: 'the host subprocess service exposes no resolveExecutable(); cannot resolve "' + cmd + '" —— 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, ms: now() - started }
@@ -2510,10 +2700,13 @@ export function apply(ctx) {
2510
2700
  try { exe = await sub.resolveExecutable(cmd) } catch (e) {
2511
2701
  return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + cmd + '": ' + String((e && e.message) || e) + ' —— 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, ms: now() - started }
2512
2702
  }
2513
- const argv = [exe].concat((Array.isArray(params.leanArgs) ? params.leanArgs : []).map(String)).concat([abs])
2703
+ const inject = Array.isArray(p.inject) ? p.inject : leanSearchPathPlan(p.args, p.searchPaths, vibeRoot()).inject
2704
+ const argv = [exe].concat((Array.isArray(p.args) ? p.args : []).map(String)).concat(inject).concat([abs])
2705
+ const searchPath = (Array.isArray(p.searchPaths) && p.searchPaths.length) ? p.searchPaths.join(',') : vibeRoot()
2706
+ logActivity('formal', 'Lean 运行 ' + rel + '(search-path=' + searchPath + ')')
2514
2707
  let handle
2515
2708
  try {
2516
- handle = sub.spawn({
2709
+ handle = await spawnHandle(sub, {
2517
2710
  argv: argv,
2518
2711
  cwd: frameworkRoot(),
2519
2712
  stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } },
@@ -2522,7 +2715,8 @@ export function apply(ctx) {
2522
2715
  } catch (e) {
2523
2716
  return { ok: false, code: 'LEAN_SPAWN_FAILED', message: String((e && e.message) || e), file: rel, ms: now() - started }
2524
2717
  }
2525
- // 超时必须有**主动**兜底:graceMs 只是宿主侧的宽限,契约 §7 要求超时后调用 handle.terminate()。
2718
+ leanSyncHandle = handle
2719
+ if (typeof onHandle === 'function') { try { onHandle(handle) } catch (e) { /* best effort */ } }
2526
2720
  let timedOut = false
2527
2721
  let timer = null
2528
2722
  let outcome
@@ -2542,26 +2736,24 @@ export function apply(ctx) {
2542
2736
  return { ok: false, code: 'LEAN_RUN_FAILED', message: String((e && e.message) || e), file: rel, ms: now() - started }
2543
2737
  }
2544
2738
  if (timer) clearTimeout(timer)
2739
+ leanSyncHandle = null
2740
+ if (typeof onHandle === 'function') { try { onHandle(null) } catch (e) { /* best effort */ } }
2545
2741
  let out = '', err = ''
2546
2742
  try { if (handle.collected && handle.collected.stdout) out = handle.collected.stdout.readFrom(0).text } catch (e) { /* best effort */ }
2547
2743
  try { if (handle.collected && handle.collected.stderr) err = handle.collected.stderr.readFrom(0).text } catch (e) { /* best effort */ }
2548
2744
  const exitCode = outcome ? outcome.exitCode : null
2549
2745
  const ms = now() - started
2550
2746
  const ok = exitCode === 0
2747
+ const isTimeout = timedOut || ms >= cap
2551
2748
  return {
2552
2749
  ok: ok, exitCode: exitCode, signal: (outcome && outcome.signal) || null, ms: ms,
2553
- command: argv.join(' '), file: rel,
2750
+ command: argv.join(' '), file: rel, searchPath: searchPath,
2554
2751
  stdout: formalTail(out, 4000), stderr: formalTail(err, 4000),
2555
- timedOut: timedOut,
2556
- code: ok ? undefined : (timedOut ? 'LEAN_TIMEOUT' : 'LEAN_FAILED'),
2752
+ timedOut: isTimeout,
2753
+ code: ok ? undefined : (isTimeout ? 'LEAN_TIMEOUT' : 'LEAN_FAILED'),
2557
2754
  }
2558
2755
  }
2559
- /**
2560
- * 把一次运行记到对象上(契约 §4 状态迁移)。普通运行最多把对象推进到 `attempted`;
2561
- * `passed` 只能由 vibe_math_lean_archive{kind:'proof'} 且该文件最近一次运行 ok 产生——否则一次
2562
- * 顺手跑个无关文件就能把对象"洗白"成已形式化。passed/blocked 不会被普通运行降级。
2563
- */
2564
- async function formalSetRun(target, run) {
2756
+ async function formalSetRun(target, run, asyncInfo) {
2565
2757
  const t = formalId(target)
2566
2758
  if (!t) return
2567
2759
  const prev = formalOf(t)
@@ -2570,10 +2762,10 @@ export function apply(ctx) {
2570
2762
  status: status,
2571
2763
  file: (run && run.file) || prev.file || '',
2572
2764
  run: { at: now(), ok: !!(run && run.ok), exitCode: (run && run.exitCode !== undefined) ? run.exitCode : null, ms: (run && run.ms) || 0, stdoutTail: formalTail(run && run.stdout, 800), stderrTail: formalTail(run && run.stderr, 800) },
2765
+ async: asyncInfo || prev.async || null,
2573
2766
  updatedAt: now(),
2574
2767
  }))
2575
2768
  }
2576
-
2577
2769
  // ---- 提示词注入(都在**构造提示词的那一刻**现算,故运行中切模式立刻生效)--------
2578
2770
  function formalPromptBlock(target) {
2579
2771
  if (!formalOn()) return ''
@@ -2604,7 +2796,7 @@ export function apply(ctx) {
2604
2796
  L.push(' 请复核这个判断是否成立;若你认为其实可以形式化,请指出来并动手做。')
2605
2797
  } else {
2606
2798
  L.push(' · 请先判断该对象的**实现难度**:若能在可接受的工作量内形式化,优先写 Lean 代码并执行。')
2607
- L.push(' · 工具:vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库)')
2799
+ L.push(' · 工具:vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库/jobs)· vibe_math_lean_read(取回归档原文)')
2608
2800
  L.push(' · 工作目录:Formal/(相对项目根);可复用定义放 ' + (vibeRoot() + '/Formal/Lib/').replace(/\\/g, '/')
2609
2801
  + ',已证引理放 ' + (vibeRoot() + '/Formal/Proved/').replace(/\\/g, '/') + ';写之前先 vibe_math_lean_lib 查重。')
2610
2802
  L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义/对象/条件/假设/结论是否与命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
@@ -2616,14 +2808,27 @@ export function apply(ctx) {
2616
2808
  L.push(' · 若你判断不值得或无法形式化,可以不做,但请在回执的 formal 字段写明难度判断(decision=\'blocked\' 时必须写明 note)。')
2617
2809
  }
2618
2810
  L.push(' · 归档可复用定义/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run);跑不通不要入库。')
2811
+ // —— §5 B(逐字):三条筛选判据 + 先查再写 + 异步"落地前不得当成已通过" ——
2812
+ L.push(' · 三条筛选判据:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)。')
2813
+ L.push(' · 写新定义/证明前**先 vibe_math_lean_lib 查已有库**(vibe_math_lean_read 可取回归档原文逐字复用),查不到再写;复用已归档内容用 `import Formal.Lib.<name>` / `import Formal.Proved.<name>`。')
2814
+ L.push(' · **没把握就记 blocked** 并写清难点,别用形式化掩盖不确定。')
2815
+ L.push(' · 该对象若已有后台编译在队列中(leanAsync 默认开启),**不得**在作业落地为通过之前声称已通过或转忠实性审查;等 vibe_math_lean_lib 的 jobs 显示 settled 再审。')
2619
2816
  L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或宿主不提供 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"——这算显式阻塞原因,定论门禁可以据此放行。')
2620
2817
  }
2621
- return L.join('\n')
2818
+ // 数学计算可用性行(prompts.md §2):验证轮同样告知"先 probe 再 run / 回执即引用"。
2819
+ return L.join('\n') + leanNoticeSection() + mathWorkLine()
2622
2820
  }
2623
2821
  /** 日常提示词里的"顺手形式化"一行(off 模式返回空串 = 一个字都不多)。 */
2624
2822
  function formalWorkLine() {
2625
- if (!formalOn()) return ''
2626
- return '【顺手形式化(' + (formalMode() === 'require' ? '强制' : '鼓励') + ')】把你工作中常用或可能复用的对象、假设、'
2823
+ // v5 统一口径:**日常主动性(leanInitiative)与验证要求强度(formalVerify)是两根独立的轴**。
2824
+ // 不能用 `!formalOn()` 一票否决——那会让 `leanInitiative:'eager'` 在 `formalVerify:'off'` 下静默
2825
+ // 失效。`formalVerify:'off'` 只关掉**验证阶段**的 Lean 文本(formalPromptBlock 仍 `!formalOn() ⇒ ''`);
2826
+ // `leanInitiative:'off'` 连日线都不注入(真无操作)。
2827
+ const initiative = LEAN_INITIATIVE_MODES.indexOf(params.leanInitiative) !== -1 ? params.leanInitiative : 'normal'
2828
+ // 后台作业的一次性公告(spec §2.4):注入到本轮提示里,取走即清空。
2829
+ const notice = leanNoticeSection()
2830
+ if (initiative === 'off' || !(formalOn() || initiative === 'eager')) return ''
2831
+ return '【顺手形式化(' + (formalOn() ? (formalMode() === 'require' ? '强制' : '鼓励') : '仅主动性') + ')】把你工作中常用或可能复用的对象、假设、'
2627
2832
  + '新定义用 Lean 形式化定义并归档到全局可复用库(vibe_math_lean_archive kind=\'def\'),已成立的引理归到 '
2628
2833
  + (vibeRoot() + '/Formal/Proved/').replace(/\\/g, '/') + '(kind=\'lemma\');写之前先 vibe_math_lean_lib 查重,避免重复定义。'
2629
2834
  + (formalMode() === 'require'
@@ -2631,6 +2836,29 @@ export function apply(ctx) {
2631
2836
  : '这会让后续的验证与证明省掉大量重复工作。')
2632
2837
  // 硬要求 3(契约 §6 顶部):可复用库只收**跑通过**的代码,否则它会被不编译的定义污染。
2633
2838
  + '归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。'
2839
+ + (initiative === 'eager'
2840
+ ? '\n · **主动档(leanInitiative=eager)**:日常就主动把有价值的小引理/命题/定义形式化——每轮工作结束时审视一次"这轮有什么值得进库",值得就顺手归档。'
2841
+ : '\n · 主动性 normal:顺手把明显有价值且可能复用的东西形式化;不必刻意扩大范围。')
2842
+ // —— §5 A(逐字):三条筛选判据 + 先查再写 + 「没把握就记 blocked」 + 异步"落地前不得当成已通过" ——
2843
+ + '\n · 三条筛选判据:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)。'
2844
+ + '\n · **先 vibe_math_lean_lib 查再决定是否重写**:vibe_math_lean_lib 列出现成定义/引理,vibe_math_lean_read 可取回归档原文逐字复用;'
2845
+ + '复用已归档内容用 `import Formal.Lib.<name>` / `import Formal.Proved.<name>`(模块根 = ' + vibeRoot().replace(/\\/g, '/') + ',框架已把它加进编译搜索路径)。'
2846
+ + '\n · **没把握就记 blocked**(vibe_math_lean_archive kind=\'blocked\' note=…,或回执 formal 的 blocked):把难点写清楚,别用形式化掩盖不确定。'
2847
+ + '\n · 编译默认走后台队列(leanAsync=true):入队后你可以继续工作;用 vibe_math_lean_job(可 waitMs 等结果)或下一轮提示里的'
2848
+ + '【形式化结果】行看结果。**在作业落地为通过之前,不得把该对象当成已通过。**'
2849
+ + notice
2850
+ }
2851
+ /**
2852
+ * 平时工作提示词里的「顺手形式化」日线段 + 回执 note。**两根轴独立**(v5 统一口径):
2853
+ * 日线由 `leanInitiative` 决定(`eager` 在 `formalVerify:'off'` 下也要注入),
2854
+ * `formalReplyNote()` 只属于**验证阶段**(`formalOn()` 门控)。
2855
+ * 用一个 `formalOn()` 把两者一起关掉,正是 audit-B #2 的成因。
2856
+ */
2857
+ function formalDailySection() {
2858
+ const line = formalWorkLine()
2859
+ const note = formalOn() ? formalReplyNote() : ''
2860
+ if (!line && !note) return ''
2861
+ return (line ? '\n' + line : '') + note
2634
2862
  }
2635
2863
  /** 回执契约里的 formal 字段(契约 §6.3):非 off 模式必须出现在回执契约里,否则这条通道不可发现。 */
2636
2864
  function formalJsonField(target) {
@@ -2725,6 +2953,304 @@ export function apply(ctx) {
2725
2953
  note: formalOn() ? '' : '未启用(formalVerify = off;可用 vibe_math_set_params 切到 encourage / require)',
2726
2954
  }
2727
2955
  }
2956
+ // ================= Lean 增量 + 异步:作业队列(spec §2) =================
2957
+ // 并发上限 = `leanJobsMaxParallel`(默认 1 = 串行)。队列是**内存**态,作业记录镜像到
2958
+ // <项目>/Formal/Jobs/<jobId>.json —— 崩溃恢复、lean_lib.jobs 与 lean_job 都读它。
2959
+ const LEAN_QUEUE_CONCURRENCY_MAX = 8
2960
+ const leanQueue = [] // FIFO:待跑作业
2961
+ const leanJobs = new Map() // jobId -> job(内存视图,含 state)
2962
+ const leanRunningHandles = new Map() // jobId -> 子进程 handle(dispose 时 terminate)
2963
+ let leanSyncHandle = null // 同步档在跑的 handle(同样能被 terminate)
2964
+ const leanPendingNotices = [] // 一次性公告行(注入下一次 round prompt,spec §2.4)
2965
+ /** 当前会话的构建计划(修订 §2/§4):冻结 engine/args/搜索路径,作业与指纹都用它。 */
2966
+ function leanBuildPlan() {
2967
+ const userArgs = (Array.isArray(params.leanArgs) ? params.leanArgs : []).map(String)
2968
+ const plan = leanSearchPathPlan(userArgs, params.leanSearchPaths, vibeRoot())
2969
+ return { engine: String(params.leanCommand || 'lean'), args: userArgs, searchPaths: plan.paths, inject: plan.inject, explicit: plan.explicit }
2970
+ }
2971
+ function leanPlanContext(plan) { return leanBuildContext(plan.engine, plan.args, plan.searchPaths) }
2972
+ /** 归档作业当前应绑定的构建计划(def/lemma 与 proof 都是本项目计划)。 */
2973
+ function leanContextNow() { return leanPlanContext(leanBuildPlan()) }
2974
+ function leanJobsMaxParallel() { return Math.max(1, Math.min(LEAN_QUEUE_CONCURRENCY_MAX, Number(params.leanJobsMaxParallel) || 1)) }
2975
+ function leanActiveCount() { let n = 0; for (const j of leanJobs.values()) if (j.state === 'running') n++ ; return n }
2976
+ function leanSleep(ms) { return new Promise(function (r) { setTimeout(r, Math.max(0, Number(ms) || 0)) }) }
2977
+ /** VibeMath 根相对 → 绝对(全局库不在项目树内)。 */
2978
+ function vibeAbs(rel) { return vibeRoot() + '/' + String(rel).replace(/^\.\//, '') }
2979
+ function leanJobAbs(job) {
2980
+ const rel = String((job && job.rel) || '')
2981
+ if (!rel) return null
2982
+ return job.scope === 'root' ? normalizeAbsPath(vibeAbs(rel)) : normalizeAbsPath(frameworkRoot() + '/' + rel)
2983
+ }
2984
+ /** 作业的回执路径与归档路径(lean_job 返回它们,修订 §3)。 */
2985
+ function leanJobPaths(job) {
2986
+ const rec = { receipt: 'Formal/Jobs/' + job.jobId + '.json' }
2987
+ if (job.kind === 'archive' && job.archive && job.archive.kind === 'proof' && job.target) {
2988
+ rec.work = job.rel
2989
+ rec.archive = job.state === 'settled' ? ('Verified/Lean/' + job.target + '.lean') : ''
2990
+ } else if (job.kind === 'archive') rec.archive = job.rel
2991
+ else rec.file = job.rel
2992
+ return rec
2993
+ }
2994
+ function leanJobRecord(job) {
2995
+ return {
2996
+ jobId: job.jobId, kind: job.kind, state: job.state, attempts: Number(job.attempts || 1),
2997
+ target: job.target || '', name: job.name || '', rel: job.rel || '', scope: job.scope || 'project',
2998
+ sha256: job.sha || '', buildSha256: job.buildSha || '', buildContext: job.buildCtx || '',
2999
+ engine: (job.plan && job.plan.engine) || '', argv: (job.plan && job.plan.args) || [], searchPaths: (job.plan && job.plan.searchPaths) || [],
3000
+ archiveKind: (job.archive && job.archive.kind) || '',
3001
+ enqueuedAt: job.enqueuedAt || 0, startedAt: job.startedAt || 0, settledAt: job.settledAt || 0,
3002
+ exitCode: job.exitCode === undefined ? null : job.exitCode,
3003
+ timedOut: !!job.timedOut, interrupted: !!job.interrupted, note: job.note || '',
3004
+ project: currentProject,
3005
+ }
3006
+ }
3007
+ async function writeLeanJobFile(job) { await writeJson('Formal/Jobs/' + job.jobId + '.json', leanJobRecord(job)) }
3008
+ function hydrateLeanJob(rec) {
3009
+ return {
3010
+ jobId: String(rec.jobId), kind: rec.kind || 'run', target: rec.target || '', name: rec.name || '',
3011
+ rel: rec.rel || '', scope: rec.scope || 'project', sha: rec.sha256 || '',
3012
+ buildSha: rec.buildSha256 || '', buildCtx: rec.buildContext || '',
3013
+ plan: rec.engine !== undefined ? { engine: rec.engine || '', args: rec.argv || [], searchPaths: rec.searchPaths || [] } : null,
3014
+ archive: rec.archiveKind ? { kind: rec.archiveKind, target: rec.target || '', name: rec.name || '' } : null,
3015
+ state: rec.state || 'queued', attempts: Number(rec.attempts || 1), enqueuedAt: Number(rec.enqueuedAt || 0),
3016
+ startedAt: Number(rec.startedAt || 0), settledAt: Number(rec.settledAt || 0),
3017
+ exitCode: rec.exitCode === undefined ? null : rec.exitCode, timedOut: !!rec.timedOut,
3018
+ interrupted: !!rec.interrupted, run: rec.run || null, note: rec.note || '',
3019
+ }
3020
+ }
3021
+ /** 入队:同 jobId 已在队列/在跑 ⇒ 复用(幂等);返回作业对象。 */
3022
+ async function enqueueLeanJob(spec) {
3023
+ const existing = leanJobs.get(spec.jobId)
3024
+ if (existing && (existing.state === 'queued' || existing.state === 'running')) return existing
3025
+ const job = Object.assign({}, spec, {
3026
+ state: 'queued', attempts: Number(spec.attempts || ((existing && existing.attempts) || 0) + 1),
3027
+ enqueuedAt: spec.enqueuedAt || now(), startedAt: 0, settledAt: 0, exitCode: null,
3028
+ timedOut: false, interrupted: false, run: null, note: spec.note || '',
3029
+ })
3030
+ leanJobs.set(job.jobId, job)
3031
+ leanQueue.push(job)
3032
+ await writeLeanJobFile(job)
3033
+ formalAnnounce('Lean 作业入队 ' + job.jobId + '(' + job.kind + (job.target ? '|对象 ' + job.target : (job.name ? '|' + job.name : '')) + ',state=queued)')
3034
+ return job
3035
+ }
3036
+ function pushLeanNotice(line) { if (leanPendingNotices.length >= 20) leanPendingNotices.shift(); leanPendingNotices.push(String(line)) }
3037
+ /** 一次性公告(spec §2.4):取走即清空,保证同一条结果只注入一次。 */
3038
+ function leanNoticeSection() {
3039
+ if (leanPendingNotices.length === 0) return ''
3040
+ const lines = leanPendingNotices.splice(0, leanPendingNotices.length)
3041
+ return '\n' + lines.join('\n') + '\n'
3042
+ }
3043
+ function leanJobPublic(job) { return { jobId: job.jobId, state: job.state, rel: job.rel || '', target: job.target || '', attempts: Number(job.attempts || 1), settledAt: Number(job.settledAt || 0) } }
3044
+ /**
3045
+ * 心跳里推进队列:**不 await 编译本身**(调用方不 await 本函数)。
3046
+ * 并发上限 = `leanJobsMaxParallel`(默认 1,串行);串行时本函数等待该作业跑完再返回。
3047
+ */
3048
+ async function runLeanQueue() {
3049
+ const max = leanJobsMaxParallel()
3050
+ let started = 0
3051
+ while (leanQueue.length > 0 && leanActiveCount() + started < max) {
3052
+ const job = leanQueue.shift()
3053
+ if (!job || job.state !== 'queued') continue
3054
+ started++
3055
+ if (max <= 1) { await runLeanJob(job); return }
3056
+ runLeanJob(job).catch(function (e) { console.error('vibe-math-v3: lean job failed: ' + String((e && e.message) || e)) })
3057
+ }
3058
+ }
3059
+ async function runLeanJob(job) {
3060
+ if (job.state !== 'queued') return
3061
+ job.state = 'running'; job.startedAt = now(); job.interrupted = false
3062
+ await writeLeanJobFile(job)
3063
+ let run
3064
+ try { run = await leanExecFile(job.rel, leanJobAbs(job), job.timeoutMs, job.plan, function (h) { if (h) leanRunningHandles.set(job.jobId, h); else leanRunningHandles.delete(job.jobId) }) }
3065
+ catch (e) { run = { ok: false, exitCode: null, code: 'LEAN_RUN_FAILED', message: String((e && e.message) || e), ms: 0 } }
3066
+ try { await settleLeanJob(job, run) } catch (e) { console.error('vibe-math-v3: lean job settle failed: ' + String((e && e.stack) || e)) }
3067
+ }
3068
+ /** 结果公告行(spec §2.4 的四种结局)。 */
3069
+ function leanSettleNotice(job, settledOk, capMs) {
3070
+ const ms = (job.run && job.run.ms) || 0
3071
+ if (job.interrupted) return '【形式化结果】' + job.jobId + ':中断(会话卸载或崩溃,已标记 attempted,可重跑)'
3072
+ if (job.timedOut) return '【形式化结果】' + job.jobId + ':超时(leanTimeoutMs=' + capMs + ' 已 terminate;对象留在 attempted)'
3073
+ if (settledOk) return '【形式化结果】' + job.jobId + ':通过(exit 0,' + (ms / 1000).toFixed(1) + 's' + ((job.kind === 'archive' && job.archive && job.archive.kind === 'proof') ? (',已归档 Verified/Lean/' + job.target + '.lean') : '') + ')'
3074
+ if (job.hashMismatch) return '【形式化结果】' + job.jobId + ':失败(编译期间文件内容已变,本次结果已丢弃;请重跑)'
3075
+ if (job.contextMismatch) return '【形式化结果】' + job.jobId + ':失败(编译期间构建上下文已变:引擎/参数/搜索路径,本次结果已丢弃;请在新上下文下重跑)'
3076
+ return '【形式化结果】' + job.jobId + ':失败(exit ' + String(job.exitCode) + ',见 stderr 尾部)'
3077
+ }
3078
+ /**
3079
+ * 落地一个作业(**唯一**的 passed 入口,spec §2.1):只有 exit 0 **且** 内容哈希仍匹配才算 ok;
3080
+ * 其余一律 attempted;中断/超时都不是 passed;内容变了只标记不贴结果。
3081
+ */
3082
+ async function settleLeanJob(job, run) {
3083
+ const capMs = Math.max(1000, Number(job.timeoutMs) || Number(params.leanTimeoutMs) || 120000)
3084
+ let currentSha = null
3085
+ try { const t = await readTextAbs(leanJobAbs(job)); if (t !== undefined) currentSha = leanContentSha(t) } catch (e) { /* 读不到 ⇒ 视为不匹配 */ }
3086
+ const hashOk = currentSha !== null && currentSha === job.sha
3087
+ // 修订 §4:结果只对**产生它的那个构建上下文**有效。参数在编译途中被改(引擎/参数/搜索路径)
3088
+ // ⇒ 这次 ok 不能贴到"现在的配置"上,宁可判为未通过并明确公告。
3089
+ const ctxNow = leanContextNow()
3090
+ const ctxOk = !job.buildCtx || job.buildCtx === ctxNow
3091
+ job.hashMismatch = !hashOk
3092
+ job.contextMismatch = !ctxOk
3093
+ job.exitCode = run.exitCode === undefined ? null : run.exitCode
3094
+ job.timedOut = !!run.timedOut
3095
+ job.settledAt = now()
3096
+ const settledOk = !job.interrupted && !job.timedOut && run.exitCode === 0 && hashOk && ctxOk
3097
+ job.state = job.interrupted ? 'interrupted' : (job.timedOut ? 'timeout' : (settledOk ? 'settled' : 'failed'))
3098
+ job.run = { at: now(), ok: settledOk, exitCode: job.exitCode, ms: run.ms || 0, timedOut: !!job.timedOut, interrupted: !!job.interrupted, stdoutTail: formalTail(run.stdout, 800), stderrTail: formalTail(run.stderr, 800), searchPath: run.searchPath || vibeRoot(), buildContext: job.buildCtx || '' }
3099
+ if (job.hashMismatch && !job.interrupted && !job.timedOut) job.note = 'file changed while compiling — result discarded (hash mismatch)'
3100
+ else if (job.contextMismatch && !job.interrupted && !job.timedOut) job.note = 'build context changed while compiling (engine/args/search paths) — result discarded; re-run under the new context'
3101
+ const asyncInfo = { jobId: job.jobId, state: job.state, attempts: job.attempts, enqueuedAt: job.enqueuedAt, startedAt: job.startedAt, settledAt: job.settledAt, exitCode: job.exitCode, buildSha256: job.buildSha || '' }
3102
+ if (job.kind === 'archive') await settleLeanArchiveJob(job, settledOk, asyncInfo)
3103
+ else if (job.target) await formalSetRun(job.target, Object.assign({}, run, { ok: settledOk, file: job.rel }), asyncInfo)
3104
+ if (!job.interrupted) await writeFormalIndex()
3105
+ await writeLeanJobFile(job)
3106
+ pushLeanNotice(leanSettleNotice(job, settledOk, capMs))
3107
+ formalAnnounce('Lean 作业 ' + job.jobId + ' ' + job.state + '(' + job.kind + ')' + (job.target ? '|对象 ' + job.target : ''))
3108
+ reportDirty = true
3109
+ }
3110
+ /** 归档类作业的落地:**只有 ok 才落库/置 passed**(def/lemma 的文件在入队前已写好)。 */
3111
+ async function settleLeanArchiveJob(job, ok, asyncInfo) {
3112
+ const a = job.archive || {}
3113
+ if (a.kind !== 'proof') {
3114
+ // 与同步档同一份记账:全局库的运行结果进 formalState.libRuns(索引据此显示"跑通/未跑通")。
3115
+ const libRel = String(job.rel || '').replace(/^Formal\//, '')
3116
+ if (libRel) formalState.libRuns[libRel] = { ok: !!ok, exitCode: job.exitCode === undefined ? null : job.exitCode, ms: (job.run && job.run.ms) || 0, at: now() }
3117
+ await rebuildLeanLibIndexes()
3118
+ formalAnnounce('归档作业 ' + job.jobId + '(' + a.kind + ' ' + job.rel + ')' + (ok ? '运行通过' : '运行未通过(见 stderr 尾部;该文件不应被当作可复用定义)'))
3119
+ return
3120
+ }
3121
+ const target = job.target
3122
+ const prev = formalOf(target)
3123
+ const passed = !!ok
3124
+ const stalePrev = passed ? '' : String(prev.proof || ('Verified/Lean/' + target + '.lean'))
3125
+ const rec = Object.assign({}, prev, {
3126
+ status: passed ? 'passed' : 'attempted',
3127
+ file: job.rel,
3128
+ proof: passed ? 'Verified/Lean/' + target + '.lean' : '',
3129
+ decision: 'used',
3130
+ note: String(prev.note || ''),
3131
+ run: job.run,
3132
+ async: asyncInfo,
3133
+ updatedAt: now(),
3134
+ })
3135
+ let withdrawn = null
3136
+ if (passed) {
3137
+ const body = await readTextAbs(leanJobAbs(job))
3138
+ if (body !== undefined) await writeText('Verified/Lean/' + target + '.lean', body)
3139
+ } else if (stalePrev) { try { withdrawn = await withdrawArchivedProof(stalePrev) } catch (e) { withdrawn = { rel: stalePrev, outcome: 'failed' } } }
3140
+ await putFormal(target, rec)
3141
+ await upsertFormalAnchor(target)
3142
+ await rebuildLeanLibIndexes()
3143
+ formalAnnounce('归档作业 ' + job.jobId + ' 为 ' + target + ' ' + (passed ? ('通过,已归档 ' + rec.proof + ',验证转为忠实性审查')
3144
+ : ('未通过:' + formalTail(job.run && job.run.stderrTail, 160) + ';已撤回上一份已通过状态与归档证明' + (withdrawn && withdrawn.outcome !== 'failed' ? '(' + withdrawn.outcome + ')' : '(⚠ 撤回失败,请不要把 ' + stalePrev + ' 当作该对象的证明)'))))
3145
+ }
3146
+ /** 去重(spec §4.3):同内容且已成功过 ⇒ 跳过写盘与重编译。 */
3147
+ async function leanDedupeLookup(jobId, abs, sha, proofTarget) {
3148
+ if (proofTarget) {
3149
+ const rec = formalOf(proofTarget)
3150
+ if (rec && rec.status === 'passed') {
3151
+ const cur = await readTextAbs(vibeAbs('Verified/Lean/' + proofTarget + '.lean'))
3152
+ if (cur !== undefined && leanContentSha(cur) === sha) return { deduped: true, file: 'Verified/Lean/' + proofTarget + '.lean' }
3153
+ }
3154
+ return null
3155
+ }
3156
+ const prev = await readJson('Formal/Jobs/' + jobId + '.json')
3157
+ if (leanJobSettledOk(prev)) {
3158
+ const cur = await readTextAbs(abs)
3159
+ if (cur !== undefined && leanContentSha(cur) === sha) return { deduped: true, file: String(prev.rel || '') }
3160
+ }
3161
+ return null
3162
+ }
3163
+ /** 后台作业的公共返回形状(§1.2)。 */
3164
+ function leanAsyncReturn(job, extra) {
3165
+ return Object.assign({
3166
+ ok: true, async: { jobId: job.jobId, state: job.state }, jobId: job.jobId,
3167
+ message: '已入队后台编译(并发上限 1);你可以继续工作。结果会写入 Formal/ 与索引,并在下一轮提示里公告;也可用 vibe_math_lean_lib 的 jobs 字段随时查看。**在该作业落地为通过之前,不得把相关对象当成已通过。**',
3168
+ }, extra || {})
3169
+ }
3170
+ /** 崩溃恢复(spec §2.6):queued 重入队;running 哈希匹配 ⇒ 标记 interrupted + 重入队(attempts+1),不匹配 ⇒ 只标记;恢复**绝不**置 passed。 */
3171
+ async function recoverLeanJobs() {
3172
+ let files = []
3173
+ try { files = (await listFiles('Formal/Jobs')) || [] } catch (e) { return { recovered: 0 } }
3174
+ let recovered = 0
3175
+ for (let i = 0; i < files.length; i++) {
3176
+ const f = String(files[i])
3177
+ if (!/\.json$/.test(f)) continue
3178
+ const rec = await readJson('Formal/Jobs/' + f)
3179
+ if (!rec || !rec.jobId) continue
3180
+ // 本进程内存里已知的作业(queued/running 或已落地)不再恢复:同一进程内切换项目时,
3181
+ // 内存队列还在正常跑,按文件状态"恢复"会把一个在跑的作业误判成崩溃(重复入队/误置 interrupted)。
3182
+ if (leanJobs.has(String(rec.jobId))) continue
3183
+ const job = hydrateLeanJob(rec)
3184
+ leanJobs.set(job.jobId, job)
3185
+ const abs = leanJobAbs(job)
3186
+ const txt = abs ? await readTextAbs(abs) : undefined
3187
+ const curSha = txt === undefined ? null : leanContentSha(txt)
3188
+ const hashMatch = curSha !== null && curSha === job.sha
3189
+ if (job.state === 'queued') {
3190
+ await enqueueLeanJob(job)
3191
+ pushLeanNotice('【形式化结果】' + job.jobId + ':上次会话未执行的排队作业已重新入队')
3192
+ recovered++
3193
+ } else if (job.state === 'running') {
3194
+ if (hashMatch) {
3195
+ if (job.target) await putFormal(job.target, Object.assign({}, formalOf(job.target), { status: 'attempted', async: { jobId: job.jobId, state: 'interrupted', attempts: job.attempts }, updatedAt: now() }))
3196
+ job.interrupted = true
3197
+ // 先落到 interrupted,再重入队:否则 enqueueLeanJob 的幂等守卫会把这条 "running" 记录
3198
+ // 当成"仍在跑"而早退,attempts 也不会 +1(恢复就静默失效了)。
3199
+ job.state = 'interrupted'
3200
+ await enqueueLeanJob(Object.assign({}, job, { attempts: job.attempts + 1 }))
3201
+ pushLeanNotice('【形式化结果】' + job.jobId + ':中断(会话崩溃,已标记 attempted,已用新 attempts 重新入队)')
3202
+ } else {
3203
+ if (job.target) await putFormal(job.target, Object.assign({}, formalOf(job.target), { status: 'attempted', async: { jobId: job.jobId, state: 'interrupted', attempts: job.attempts, note: 'file changed' }, updatedAt: now() }))
3204
+ job.state = 'interrupted'; job.interrupted = true
3205
+ job.note = 'file changed while the previous session was compiling — not auto-re-driven'
3206
+ await writeLeanJobFile(job)
3207
+ pushLeanNotice('【形式化结果】' + job.jobId + ':中断(文件已变,未自动重驱;请手动重跑)')
3208
+ }
3209
+ recovered++
3210
+ } else if (job.state === 'settled') {
3211
+ // 修订 §4:只有**同一构建上下文**下的 settled(ok) 才允许恢复成 passed。
3212
+ const ctxNow = leanContextNow()
3213
+ const ctxOk = !job.buildCtx || job.buildCtx === ctxNow
3214
+ if (leanJobSettledOk(rec) && hashMatch && ctxOk) {
3215
+ job.run = { at: now(), ok: true, exitCode: 0, ms: 0, timedOut: false, interrupted: false, stdoutTail: '', stderrTail: '', searchPath: vibeRoot(), buildContext: job.buildCtx || '' }
3216
+ if (job.kind === 'archive') await settleLeanArchiveJob(job, true, { jobId: job.jobId, state: 'settled', attempts: job.attempts, settledAt: job.settledAt, exitCode: 0, buildSha256: job.buildSha || '' })
3217
+ else if (job.target) await formalSetRun(job.target, job.run, { jobId: job.jobId, state: 'settled', buildSha256: job.buildSha || '' })
3218
+ pushLeanNotice('【形式化结果】' + job.jobId + ':已按作业记录补写归档与 passed(恢复时校验内容哈希与构建上下文一致)')
3219
+ } else if (job.target) {
3220
+ await putFormal(job.target, Object.assign({}, formalOf(job.target), { status: 'attempted', async: { jobId: job.jobId, state: 'interrupted', attempts: job.attempts, note: ctxOk ? 'recovered-incomplete' : 'build context changed' }, updatedAt: now() }))
3221
+ pushLeanNotice('【形式化结果】' + job.jobId + ':按作业记录标记为未通过/已变(恢复绝不置 passed)')
3222
+ }
3223
+ recovered++
3224
+ }
3225
+ }
3226
+ if (recovered > 0) formalAnnounce('Lean 作业恢复:处理了 ' + recovered + ' 条遗留作业记录(queued 重入队 / running 标记中断 / settled 补写;绝不置 passed)')
3227
+ return { recovered: recovered }
3228
+ }
3229
+ /** 会话卸载/销毁:终止在跑的编译并标记 interrupted(不留孤儿进程,spec §2.5)。 */
3230
+ function disposeLeanJobs() {
3231
+ let n = 0
3232
+ for (const job of leanJobs.values()) {
3233
+ if (job.state === 'running') { job.interrupted = true; n++ }
3234
+ }
3235
+ for (const job of leanQueue) { job.state = 'interrupted'; job.interrupted = true; n++ }
3236
+ for (const h of leanRunningHandles.values()) {
3237
+ try { if (h && typeof h.terminate === 'function') h.terminate() } catch (e) { /* best effort */ }
3238
+ }
3239
+ leanRunningHandles.clear()
3240
+ if (leanSyncHandle && typeof leanSyncHandle.terminate === 'function') { try { leanSyncHandle.terminate() } catch (e) { /* best effort */ } }
3241
+ leanSyncHandle = null
3242
+ leanQueue.length = 0
3243
+ if (n > 0) {
3244
+ pushLeanNotice('【形式化结果】干预:会话卸载,' + n + ' 个 Lean 作业已标记 interrupted(未落地为 passed)')
3245
+ // 落盘尽力而为(disposer 不能 await):作业记录留着,供下次 init 恢复时标记。
3246
+ try {
3247
+ for (const job of leanJobs.values()) { if (job.state === 'running' || job.interrupted) writeLeanJobFile(job).catch(function () { }) }
3248
+ } catch (e) { /* best effort */ }
3249
+ }
3250
+ return n
3251
+ }
3252
+
3253
+ // ---- 三份索引(框架维护;契约 §9)----
2728
3254
  async function writeFormalIndex() {
2729
3255
  const recs = formalRecords()
2730
3256
  const keys = Object.keys(recs).sort()
@@ -2784,9 +3310,9 @@ export function apply(ctx) {
2784
3310
  }
2785
3311
  const libRows = await scan(vibeRoot() + '/Formal/Lib', 'Lib')
2786
3312
  await writeTextAbs(vibeRoot() + '/Formal/Lib/Index.md', ['# 可复用 Lean 定义库(跨项目)|' + currentProject, '',
2787
- '> 写新定义之前先查这里:能复用就不要重新定义。', '',
2788
- '| 名称 | 文件 | 类别 | 摘要 | 最近运行 |', '|---|---|---|---|---|']
2789
- .concat(libRows.length ? libRows.map(function (r) { return '| ' + r.base + ' | ' + r.rel + ' | def | ' + r.first.replace(/\|/g, '/') + ' | ' + r.run + ' |' }) : ['| (暂无) | | | | |']).join('\n') + '\n')
3313
+ '> 写新定义之前先查这里:能复用就不要重新定义。复用方式:`import Formal.Lib.<名称>`(模块根 = VibeMath 根)。', '',
3314
+ '| 名称 | 文件 | 类别 | 依赖(import) | 摘要 | 最近运行 |', '|---|---|---|---|---|---|']
3315
+ .concat(libRows.length ? libRows.map(function (r) { return '| ' + r.base + ' | ' + r.rel + ' | def | ' + String(r.depend || '—').replace(/\|/g, '/') + ' | ' + r.first.replace(/\|/g, '/') + ' | ' + r.run + ' |' }) : ['| (暂无) | | | | | |']).join('\n') + '\n')
2790
3316
  const provedRows = await scan(vibeRoot() + '/Formal/Proved', 'Proved')
2791
3317
  await writeTextAbs(vibeRoot() + '/Formal/Proved/Index.md', ['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|' + currentProject, '',
2792
3318
  '> 这些文件是通过内核检查的引理,可直接 import 复用。', '',
@@ -2844,18 +3370,27 @@ export function apply(ctx) {
2844
3370
  */
2845
3371
  async function leanRunTool(memberId, o) {
2846
3372
  const args = o || {}
2847
- const run = await leanRunFile(String(args.file || ''), args.timeout_ms)
3373
+ const rel = String(args.file || '').trim()
2848
3374
  const target = String(args.target || '').trim()
3375
+ if (params.leanAsync !== false) {
3376
+ const guard = await leanResolveRunTarget(rel)
3377
+ if (!guard.ok) return guard
3378
+ const plan = leanBuildPlan()
3379
+ const ctx = leanPlanContext(plan)
3380
+ const fp = leanJobFingerprint(guard.text, ctx)
3381
+ const jobId = leanJobId(target || guard.rel, fp)
3382
+ const job = await enqueueLeanJob({ jobId: jobId, kind: 'run', key: target || guard.rel, rel: guard.rel, scope: 'project', target: target, sha: guard.sha, buildSha: fp, buildCtx: ctx, plan: plan, memberId: memberId, timeoutMs: args.timeout_ms })
3383
+ return leanAsyncReturn(job, { file: guard.rel, state: job.state, hint: '已入队。**在它落地为通过之前,不得把该对象当成已通过**;结果会在下一轮提示的【形式化结果】行里公告,或用 vibe_math_lean_job(可 waitMs 等待)查看。' })
3384
+ }
3385
+ const run = await leanRunFile(rel, args.timeout_ms)
2849
3386
  if (run.file || run.ok) {
2850
3387
  if (target) { await formalSetRun(target, run); await upsertFormalAnchor(target) }
2851
3388
  await writeFormalIndex()
2852
3389
  }
2853
3390
  if (run.ok) await formalAnnounce('【形式化】' + (memberId || 'scheduler') + ' 运行 Lean 通过:' + run.file + '(' + ((run.ms || 0) / 1000).toFixed(1) + 's)' + (target ? '|对象 ' + target : ''))
2854
- // 失败提示必须与失败原因一致:工具链缺失 / 宿主没有 subprocess 服务时**没有任何编译器输出**
2855
- // 可以"按它修复",把它当成普通编译错误会让代理反复重试而不是走"写下代码 + 记录显式阻塞"
2856
- // 这条出路(契约 §6 硬要求 4)。
2857
3391
  const noHost = run.code === 'LEAN_NOT_FOUND' || run.code === 'NO_SUBPROCESS' || run.code === 'LEAN_SPAWN_FAILED'
2858
3392
  return Object.assign({ ok: !!run.ok }, run, {
3393
+ async: null,
2859
3394
  hint: run.ok
2860
3395
  ? '通过。若是某个对象的证明,请用 vibe_math_lean_archive kind=\'proof\' 归档(会写入 Verified/Lean/ 并把审查对象变成忠实性);若是可复用定义/引理,用 kind=\'def\'/\'lemma\' 归档到全局库。'
2861
3396
  : (noHost
@@ -2863,6 +3398,80 @@ export function apply(ctx) {
2863
3398
  : '未通过。请按上面的编译器输出修复后重跑;若判断无法完成,用 vibe_math_lean_archive kind=\'blocked\' 记录原因。'),
2864
3399
  })
2865
3400
  }
3401
+ /** 异步档的路径/存在性校验(与 leanRunFile 同一套守卫与错误码)。 */
3402
+ async function leanResolveRunTarget(relPath) {
3403
+ const rel = String(relPath || '').trim()
3404
+ if (!rel) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'file is required' }
3405
+ const abs = await leanResolveRun(rel)
3406
+ if (abs === null) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'Lean 文件必须位于 ' + vibeRoot().replace(/\\/g, '/') + '/ 之内(收到 ' + rel + ')' }
3407
+ if (!/\.lean$/i.test(abs)) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'only .lean files can be executed' }
3408
+ const txt = await readTextAbs(abs)
3409
+ if (txt === undefined) return { ok: false, code: 'V3_NOT_FOUND', message: 'no such file: ' + rel }
3410
+ return { ok: true, rel: rel, abs: abs, sha: leanContentSha(txt), text: txt }
3411
+ }
3412
+ /** `lean_read`:只读地取回归档的 Lean 原文(verbatim 复用;§1.2)。 */
3413
+ async function leanReadTool(o) {
3414
+ const args = o || {}
3415
+ const rawName = String(args.name || '').trim()
3416
+ if (!rawName) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'name is required' }
3417
+ if (rawName.indexOf('..') !== -1 || /[\\/]/.test(rawName) || /^[a-z]:/i.test(rawName)) {
3418
+ return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'name must be a bare library name (no path separators, no "..", no absolute path)' }
3419
+ }
3420
+ const name = idSafe(rawName)
3421
+ const kind = (args.kind === 'lib' || args.kind === 'proved') ? args.kind : 'auto'
3422
+ const order = kind === 'auto' ? ['lib', 'proved'] : [kind]
3423
+ for (let i = 0; i < order.length; i++) {
3424
+ const k = order[i]
3425
+ const rel = 'Formal/' + (k === 'lib' ? 'Lib' : 'Proved') + '/' + name + '.lean'
3426
+ const dir = normalizeAbsPath(vibeRoot() + '/Formal/' + (k === 'lib' ? 'Lib' : 'Proved'))
3427
+ const abs = normalizeAbsPath(vibeRoot() + '/' + rel)
3428
+ if (abs.indexOf(dir + '/') !== 0) continue
3429
+ const txt = await readTextAbs(abs)
3430
+ if (txt === undefined) continue
3431
+ const LIMIT = 64 * 1024
3432
+ const bytes = Buffer.byteLength(txt, 'utf8')
3433
+ const truncated = bytes > LIMIT
3434
+ return { ok: true, name: name, file: rel, kind: k, sha256: leanContentSha(txt), bytes: bytes, text: truncated ? Buffer.from(txt, 'utf8').slice(0, LIMIT).toString('utf8') : txt, truncated: truncated }
3435
+ }
3436
+ return { ok: false, code: 'V3_NOT_FOUND', message: 'no archived Lean file named ' + name + '(查过 Formal/Lib 与 Formal/Proved);可先用 vibe_math_lean_lib 看清单' }
3437
+ }
3438
+ /**
3439
+ * `lean_job`(修订 §3,只读):不带 jobId ⇒ 本会话作业清单;带 ⇒ 该作业 state/exitCode/回执路径/归档路径。
3440
+ * waitMs>0 最多等这么久(内部轮询;心跳是独立 timer,不会被阻塞),超时返回当前 state。
3441
+ */
3442
+ async function leanJobTool(o) {
3443
+ const args = o || {}
3444
+ const wantId = String(args.jobId || '').trim()
3445
+ const waitMs = Math.max(0, Math.min(600000, Number(args.waitMs) || 0))
3446
+ if (!wantId) {
3447
+ const jobs = []
3448
+ for (const j of leanJobs.values()) jobs.push(Object.assign(leanJobPublic(j), { exitCode: j.exitCode === undefined ? null : j.exitCode, paths: leanJobPaths(j), buildSha256: j.buildSha || '' }))
3449
+ jobs.sort(function (a, b) { return (b.settledAt || 0) - (a.settledAt || 0) || String(a.jobId).localeCompare(String(b.jobId)) })
3450
+ return { ok: true, async: params.leanAsync !== false, maxParallel: leanJobsMaxParallel(), queued: leanQueue.length, running: leanActiveCount(), jobs: jobs, paths: { receipts: 'Formal/Jobs/', proofs: 'Verified/Lean/', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/' }, hint: 'state=settled 且 exitCode=0(且内容哈希/构建上下文一致)才算通过;queued/running 一律还不算。' }
3451
+ }
3452
+ let job = leanJobs.get(wantId)
3453
+ if (!job) {
3454
+ const rec = await readJson('Formal/Jobs/' + wantId + '.json')
3455
+ if (!rec || !rec.jobId) return { ok: false, code: 'V3_NOT_FOUND', message: 'no such Lean job: ' + wantId + '(用不带 jobId 的 vibe_math_lean_job 列清单)' }
3456
+ job = hydrateLeanJob(rec)
3457
+ if (!leanJobs.has(job.jobId)) leanJobs.set(job.jobId, job)
3458
+ }
3459
+ const startedAt = now()
3460
+ while (waitMs > 0 && (job.state === 'queued' || job.state === 'running') && (now() - startedAt) < waitMs) {
3461
+ await leanSleep(50)
3462
+ const cur = leanJobs.get(wantId)
3463
+ if (cur) job = cur
3464
+ }
3465
+ return {
3466
+ ok: true, jobId: job.jobId, state: job.state, exitCode: job.exitCode === undefined ? null : job.exitCode,
3467
+ attempt: Number(job.attempts || 1), timedOut: !!job.timedOut, interrupted: !!job.interrupted, note: job.note || '',
3468
+ buildSha256: job.buildSha || '', buildContext: job.buildCtx || '',
3469
+ waitedMs: now() - startedAt, stillRunning: (job.state === 'queued' || job.state === 'running'),
3470
+ passed: leanJobSettledOk(leanJobRecord(job)),
3471
+ paths: leanJobPaths(job),
3472
+ run: job.run || null,
3473
+ }
3474
+ }
2866
3475
  async function leanArchive(memberId, o) {
2867
3476
  const args = o || {}
2868
3477
  const kind = String(args.kind || '')
@@ -2887,6 +3496,22 @@ export function apply(ctx) {
2887
3496
  const dirRel = kind === 'def' ? 'Lib' : 'Proved'
2888
3497
  const rel = 'Formal/' + dirRel + '/' + name + '.lean'
2889
3498
  const abs = vibeRoot() + '/' + rel
3499
+ const sha = leanContentSha(body)
3500
+ const plan = leanBuildPlan()
3501
+ const ctx = leanPlanContext(plan)
3502
+ const fp = leanJobFingerprint(body, ctx)
3503
+ const jobId = leanJobId(name, fp)
3504
+ // 去重(§4.3):同内容 + 同构建上下文且已成功归档过 ⇒ 跳过重写与重编译。
3505
+ const dedupe = await leanDedupeLookup(jobId, abs, sha, null)
3506
+ if (dedupe) {
3507
+ await formalAnnounce('【形式化】' + who + ' 归档 ' + kind + ' `' + name + '` 命中去重(同内容与构建上下文,跳过重写与重编译)')
3508
+ return { ok: true, kind: kind, name: name, file: rel, deduped: true, sha256: sha, buildSha256: fp, note: '内容与构建上下文都与已归档并编译通过的版本一致:跳过重写与重编译(去重)。' }
3509
+ }
3510
+ if (params.leanAsync !== false && args.run !== false) {
3511
+ if (!await writeTextAbs(abs, body)) return { ok: false, code: 'V3_WRITE_FAILED', message: 'could not write ' + rel }
3512
+ const job = await enqueueLeanJob({ jobId: jobId, kind: 'archive', key: name, rel: rel, scope: 'root', name: name, target: '', sha: sha, buildSha: fp, buildCtx: ctx, plan: plan, memberId: memberId, archive: { kind: kind } })
3513
+ return leanAsyncReturn(job, { kind: kind, name: name, file: rel, sha256: sha, buildSha256: fp, deduped: false, note: '文件已写入 ' + rel + ';编译已入队(并发上限 leanJobsMaxParallel)。**落地为通过之前,请不要把它当作可复用定义**;结果会在下一轮提示的【形式化结果】行公告,也可用 vibe_math_lean_job 查/等。' })
3514
+ }
2890
3515
  if (!await writeTextAbs(abs, body)) return { ok: false, code: 'V3_WRITE_FAILED', message: 'could not write ' + rel }
2891
3516
  // 全局库在项目树之外,必须用**绝对路径**执行(相对形式会在项目根里找)。
2892
3517
  const run = args.run === false ? null : await leanRunFile(abs)
@@ -2903,6 +3528,36 @@ export function apply(ctx) {
2903
3528
  if (body === undefined && from) { const r = await readFrom(); if (r.err) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: r.err }; body = r.body }
2904
3529
  if (body === undefined) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
2905
3530
  const workRel = 'Formal/' + target + '.lean'
3531
+ const sha = leanContentSha(body)
3532
+ const plan = leanBuildPlan()
3533
+ const ctx = leanPlanContext(plan)
3534
+ const fp = leanJobFingerprint(body, ctx)
3535
+ const jobId = leanJobId(target, fp)
3536
+ // 去重(§4.3):对象已 passed 且归档证明与新提交内容一致 ⇒ 跳过。
3537
+ const dedupe = await leanDedupeLookup(jobId, null, sha, target)
3538
+ if (dedupe) {
3539
+ await formalAnnounce('【形式化】' + who + ' 归档证明 ' + target + ' 命中去重(已 passed 且内容一致)')
3540
+ return { ok: true, kind: kind, target: target, file: workRel, proof: dedupe.file, passed: true, deduped: true, sha256: sha, buildSha256: fp, status: 'passed', note: '该对象已是 passed,且归档证明与新提交内容一致:跳过重写与重编译(去重)。' }
3541
+ }
3542
+ if (params.leanAsync !== false) {
3543
+ if (!await writeTextAbs(frameworkRoot() + '/' + workRel, body)) return { ok: false, code: 'V3_WRITE_FAILED', message: 'could not write ' + workRel }
3544
+ const prevA = formalOf(target)
3545
+ await putFormal(target, Object.assign({}, prevA, {
3546
+ status: prevA.status === 'passed' ? 'passed' : 'attempted',
3547
+ file: workRel,
3548
+ decision: 'used',
3549
+ note: String(args.note || prevA.note || ''),
3550
+ async: { jobId: jobId, state: 'queued', attempts: 0, enqueuedAt: now(), startedAt: 0, settledAt: 0, exitCode: null, buildSha256: fp },
3551
+ updatedAt: now(),
3552
+ }))
3553
+ await upsertFormalAnchor(target)
3554
+ await writeFormalIndex()
3555
+ const job = await enqueueLeanJob({ jobId: jobId, kind: 'archive', key: target, rel: workRel, scope: 'project', target: target, sha: sha, buildSha: fp, buildCtx: ctx, plan: plan, memberId: memberId, archive: { kind: 'proof', target: target } })
3556
+ return leanAsyncReturn(job, {
3557
+ kind: kind, target: target, file: workRel, status: 'attempted', sha256: sha, buildSha256: fp,
3558
+ note: '工作文件已写入 ' + workRel + ';编译已入队。**只有该作业在同一个构建上下文下落地为通过,才会写入 Verified/Lean/' + target + '.lean 并把状态变为 passed**——在它落地之前,这个对象不是"已通过形式化"。',
3559
+ })
3560
+ }
2906
3561
  // 用绝对路径写:`writeText` 写失败时只会抛(由工具包装层变成通用 error),
2907
3562
  // 这一行的 V3_WRITE_FAILED 分支就永远不可能触发;writeTextAbs 会如实返回 false。
2908
3563
  if (!await writeTextAbs(frameworkRoot() + '/' + workRel, body)) return { ok: false, code: 'V3_WRITE_FAILED', message: 'could not write ' + workRel }
@@ -3503,7 +4158,7 @@ export function apply(ctx) {
3503
4158
  const sub = subprocessOf()
3504
4159
  if (sub === undefined || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, code: 'NO_SUBPROCESS' }
3505
4160
  let handle
3506
- try { handle = sub.spawn({ argv: paperArgvFor(engine), cwd: dirAbs, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: capMs }) }
4161
+ try { handle = await spawnHandle(sub, { argv: paperArgvFor(engine), cwd: dirAbs, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: capMs }) }
3507
4162
  catch (e) { return { ok: false, exitCode: null, code: 'SPAWN_FAILED', message: String((e && e.message) || e) } }
3508
4163
  let timedOut = false, timer = null, outcome
3509
4164
  try {
@@ -3825,6 +4480,11 @@ export function apply(ctx) {
3825
4480
  }
3826
4481
  }
3827
4482
  await writeJson('State/process_epoch.json', processEpoch)
4483
+ // Lean 异步作业的崩溃恢复(spec §2.6):同一进程 continue 不重驱(内存队列还在跑),
4484
+ // 只有新进程/新会话接手(fresh 或 stale epoch)才按 Formal/Jobs/*.json 恢复;恢复绝不置 passed。
4485
+ if (fresh || stale) { try { await recoverLeanJobs() } catch (e) { console.error('vibe-math-v3: lean job recovery failed: ' + String((e && e.message) || e)) } }
4486
+ // 数学计算的引擎探测(异步)在 init 时先跑一次:提示词侧只读缓存现算(mathWorkLine)。
4487
+ try { await refreshMathProbe(true) } catch (e) { /* 探测失败不影响 init:mathWorkLine 会因此为空 */ }
3828
4488
  await saveAll()
3829
4489
  if (params.indexAutoRebuild) await rebuildIndex()
3830
4490
  return { ok: true }
@@ -3974,7 +4634,12 @@ export function apply(ctx) {
3974
4634
  if (scheduler.running) await abortScheduler()
3975
4635
  currentProject = slug; await writeCurrentProject(); await ensureDirs()
3976
4636
  params = Object.assign({}, DEFAULT_PARAMS); scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null }; agentRegistry = {}; decisionQueue = []; verifierAccuracy = {}; tasks = {}; explorerRetries = {}; activityLog = []; planQueue = []; plannerFails = 0; methodLog = { pendingInventions: [], keepCount: 0, lastKeepAt: 0 }; projectLock = { sessionId: '', at: 0 }; lastReportWrite = 0; lastPushReport = 0; reportDirty = false; lastPlanSummary = null; archivedJ = {}; lastIndexWrite = 0; formalState = { records: {}, todo: [], libRuns: {} }; paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null
3977
- await loadSettings(); await migrateLegacyParams(); await loadState(); await loadKnowledgeBase(); await saveAll()
4637
+ await loadSettings(); await migrateLegacyParams(); await loadState()
4638
+ // 切到/新建项目 = 接手这棵树的遗留作业(本进程不认识的 jobId 才恢复)。
4639
+ try { await recoverLeanJobs() } catch (e) { console.error('vibe-math-v3: lean job recovery on project switch failed: ' + String((e && e.message) || e)) }; await loadKnowledgeBase()
4640
+ // 切项目后引擎探测缓存过期(mathEngines/路径可能不同):重探一次再让提示词读它。
4641
+ try { await refreshMathProbe(true) } catch (e) { /* 探测失败不影响切换 */ }
4642
+ await saveAll()
3978
4643
  if (params.indexAutoRebuild) await rebuildIndex()
3979
4644
  return { ok: true, project: slug, frameworkRoot: frameworkRoot() }
3980
4645
  }
@@ -3985,6 +4650,13 @@ export function apply(ctx) {
3985
4650
  function objParams(props, required) { return { type: 'object', properties: props, additionalProperties: false, required: required || [] } }
3986
4651
  const handlers = {}
3987
4652
  function registerTool(name, description, parameters, executeFn) { handlers[name] = executeFn }
4653
+ // math_computation 的**会话层**注册(共享模块的 host 在上面构造,绑定本会话的 params/Paths/fs)。
4654
+ // 模块调用一次 host.register(...) ⇒ handlers['math_computation'] = 该会话的 handler;
4655
+ // apply 层(工具面)在 apply 作用域单独注册一次,按名路由回这里(FREEZE §4 要求两层都在)。
4656
+ mathTool = registerMathComputation(mathHost)
4657
+ // 会话层挂载(**字面量形态**,静态扫描器可见):handler 是模块为**本会话**构造的那一个;
4658
+ // apply 层的同名词由宿主按 handlerName 路由回这里。两层名字/描述/schema 三处完全一致。
4659
+ registerTool('math_computation', TOOL_DESC.math_computation, MATH_TOOL_SCHEMA, async function (args, agent) { return await mathTool.handler(args || {}, agent) })
3988
4660
  registerTool('vibe_math_start', TOOL_DESC.vibe_math_start, objParams({ override: { type: 'boolean' } }), async function (args) { return await startScheduler(args && args.override) })
3989
4661
  registerTool('vibe_math_resume', TOOL_DESC.vibe_math_resume, objParams({ override: { type: 'boolean' } }), async function (args) { return await resumeScheduler(args && args.override) })
3990
4662
  registerTool('vibe_math_pause', TOOL_DESC.vibe_math_pause, objParams({}), async function () { return await pauseScheduler() })
@@ -3992,7 +4664,7 @@ export function apply(ctx) {
3992
4664
  registerTool('vibe_math_status', TOOL_DESC.vibe_math_status, objParams({}), async function () { await refreshParams(); return await getStatus() })
3993
4665
  registerTool('vibe_math_report', TOOL_DESC.vibe_math_report, objParams({}), async function () { await refreshParams(); await maybeWriteReport(true); return await buildReport() })
3994
4666
  registerTool('vibe_math_set_mode', TOOL_DESC.vibe_math_set_mode, objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }, ['mode']), async function (args) { params.mode = args.mode; await saveAll(); await saveSettings(); if (params.mode === 'auto') await autoResolvePending(); return { ok: true, mode: params.mode } })
3995
- registerTool('vibe_math_set_params', TOOL_DESC.vibe_math_set_params, objParams({ mode: { type: 'string', enum: ['manual', 'auto'] }, maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' }, finalPaper: { type: 'boolean' }, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] }, paperLanguage: { type: 'string', enum: ['zh', 'en'] }, paperCompilePdf: { type: 'boolean' }, paperLatexCommand: { type: 'string' } }), async function (args) { params = Object.assign({}, params, sanitizeParams(args)); await saveAll(); await saveSettings(); return { ok: true, params: params } })
4667
+ registerTool('vibe_math_set_params', TOOL_DESC.vibe_math_set_params, objParams({ mode: { type: 'string', enum: ['manual', 'auto'] }, maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' }, leanAsync: { type: 'boolean' }, leanJobsMaxParallel: { type: 'integer' }, leanInitiative: { type: 'string', enum: ['off', 'normal', 'eager'] }, leanSearchPaths: { type: 'array', items: { type: 'string' } }, mathComputation: { type: 'string', enum: ['off', 'auto', 'on'] }, mathMode: { type: 'string', enum: ['typed', 'typed+shell'] }, mathEngines: { type: 'array', items: { type: 'string' } }, mathTimeoutMs: { type: 'integer' }, mathPackages: { type: 'array', items: { type: 'string' } }, mathInstallScope: { type: 'string', enum: ['user', 'system'] }, finalPaper: { type: 'boolean' }, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] }, paperLanguage: { type: 'string', enum: ['zh', 'en'] }, paperCompilePdf: { type: 'boolean' }, paperLatexCommand: { type: 'string' } }), async function (args) { params = Object.assign({}, params, sanitizeParams(args)); await saveAll(); await saveSettings(); if (args && MATH_PARAM_NAMES.some(function (k) { return k in args })) { try { await refreshMathProbe(true) } catch (e) { /* 探测失败不影响参数保存 */ } } return { ok: true, params: params } })
3996
4668
  registerTool('vibe_math_setup', TOOL_DESC.vibe_math_setup, objParams({}), async function () { await refreshParams(); const list = PARAM_SCHEMA.map(function (p) { const out = Object.assign({}, p); out.current = params[p.name]; out.default = DEFAULT_PARAMS[p.name]; return out }); return { ok: true, parameters: list, saveTo: frameworkRoot() + '/vibe_math_setting.json' } })
3997
4669
  registerTool('vibe_math_save_settings', TOOL_DESC.vibe_math_save_settings, objParams({}), async function () { return await saveSettings() })
3998
4670
  registerTool('vibe_math_template', TOOL_DESC.vibe_math_template, objParams({ where: { type: 'string', enum: ['global', 'project'] } }), async function (args) { return await createTemplate((args && args.where) || 'global') })
@@ -4038,13 +4710,17 @@ export function apply(ctx) {
4038
4710
  counts: r, todo: formalTodo().map(function (t) { return { id: t.id, why: t.why, at: t.at } }),
4039
4711
  objects: Object.keys(formalRecords()).map(function (k) {
4040
4712
  const rec = formalRecords()[k] || {}
4041
- return { target: k, status: rec.status, file: rec.file, proof: rec.proof, note: rec.note }
4713
+ return { target: k, status: rec.status, file: rec.file, proof: rec.proof, note: rec.note, async: rec.async || null }
4042
4714
  }),
4043
- paths: { project: 'Formal/(相对项目根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/' },
4044
- hint: "复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_math_lean_archive kind='def' 归档,已证引理用 kind='lemma';归档前先跑通(run=true 或先 vibe_math_lean_run),跑不通不要入库。",
4715
+ async: params.leanAsync !== false,
4716
+ jobs: (function () { const out = []; for (const j of leanJobs.values()) out.push(Object.assign(leanJobPublic(j), { exitCode: j.exitCode === undefined ? null : j.exitCode, paths: leanJobPaths(j), buildSha256: j.buildSha || '' })); out.sort(function (x, y) { return (y.settledAt || 0) - (x.settledAt || 0) || String(x.jobId).localeCompare(String(y.jobId)) }); return out })(),
4717
+ paths: { project: 'Formal/(相对项目根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/', searchPath: vibeRoot(), jobs: 'Formal/Jobs/' },
4718
+ hint: "复用优先:先在 Lib/ 里找现成定义(vibe_math_lean_read 可取原文);新定义用 vibe_math_lean_archive kind='def' 归档,已证引理用 kind='lemma';归档前先跑通(run=true 或先 vibe_math_lean_run),跑不通不要入库。异步档用 jobs 字段查后台编译:**只有 state=settled 才算通过**。",
4045
4719
  verify: (Object.keys(tasks).length ? String(tasks[Object.keys(tasks)[0]].rId || '') : null),
4046
4720
  }
4047
4721
  })
4722
+ registerTool('vibe_math_lean_read', TOOL_DESC.vibe_math_lean_read, objParams({ name: { type: 'string' }, kind: { type: 'string', enum: ['auto', 'lib', 'proved'] } }, ['name']), async function (args) { return await leanReadTool(args) })
4723
+ registerTool('vibe_math_lean_job', TOOL_DESC.vibe_math_lean_job, objParams({ jobId: { type: 'string' }, waitMs: { type: 'integer' } }), async function (args) { return await leanJobTool(args) })
4048
4724
 
4049
4725
  // ---- 代理直接写 md 的写锁 + 轻元数据同步(任务2:代理自组织写各自对应路径的 md,避免并发写同一文件) ----
4050
4726
  /**
@@ -4280,6 +4956,17 @@ export function apply(ctx) {
4280
4956
  // (激活上限)与 paper 锁续租必须由**独立于调度器**的心跳驱动。
4281
4957
  paperRetryDue: paperRetryDue,
4282
4958
  runPaperRetry: runPaperRetry,
4959
+ // Lean 异步队列(spec §2):心跳推进队列;dispose 由 apply 级 disposer 调用(不留孤儿进程)。
4960
+ runLeanQueue: runLeanQueue,
4961
+ disposeLeanJobs: disposeLeanJobs,
4962
+ leanQueueSize: function () { return leanQueue.length },
4963
+ leanJobsPublic: function () { const out = []; for (const j of leanJobs.values()) out.push(leanJobPublic(j)); return out },
4964
+ // 数学计算(FREEZE §4):心跳用 TTL 刷新探测缓存;off 档不探测(真 no-op)。
4965
+ mathProbeDue: mathProbeDue,
4966
+ refreshMathProbe: refreshMathProbe,
4967
+ mathProbe: function () { return mathProbe },
4968
+ // P2a:persona 两个文本块必须含的「归档→编辑→重跑」规则(文本取自共享模块常量,persona 不手抄)。
4969
+ mathArchiveWorkflowLine: MATH_ARCHIVE_WORKFLOW_LINE,
4283
4970
  // childOwner 裁剪用(审计 L1):这个会话当前仍"可能再发 subagent/end"的 child
4284
4971
  // = 在册子代理 + 任何任务正在等的那几个。
4285
4972
  referencedChildIds: function () {
@@ -4318,7 +5005,7 @@ export function apply(ctx) {
4318
5005
  registerTool('vibe_math_status', TOOL_DESC.vibe_math_status, objParams({}), 'vibe_math_status')
4319
5006
  registerTool('vibe_math_report', TOOL_DESC.vibe_math_report, objParams({}), 'vibe_math_report')
4320
5007
  registerTool('vibe_math_set_mode', TOOL_DESC.vibe_math_set_mode, objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }, ['mode']), 'vibe_math_set_mode')
4321
- registerTool('vibe_math_set_params', TOOL_DESC.vibe_math_set_params, objParams({ mode: { type: 'string', enum: ['manual', 'auto'] }, maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' }, finalPaper: { type: 'boolean' }, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] }, paperLanguage: { type: 'string', enum: ['zh', 'en'] }, paperCompilePdf: { type: 'boolean' }, paperLatexCommand: { type: 'string' } }), 'vibe_math_set_params')
5008
+ registerTool('vibe_math_set_params', TOOL_DESC.vibe_math_set_params, objParams({ mode: { type: 'string', enum: ['manual', 'auto'] }, maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' }, leanAsync: { type: 'boolean' }, leanJobsMaxParallel: { type: 'integer' }, leanInitiative: { type: 'string', enum: ['off', 'normal', 'eager'] }, leanSearchPaths: { type: 'array', items: { type: 'string' } }, mathComputation: { type: 'string', enum: ['off', 'auto', 'on'] }, mathMode: { type: 'string', enum: ['typed', 'typed+shell'] }, mathEngines: { type: 'array', items: { type: 'string' } }, mathTimeoutMs: { type: 'integer' }, mathPackages: { type: 'array', items: { type: 'string' } }, mathInstallScope: { type: 'string', enum: ['user', 'system'] }, finalPaper: { type: 'boolean' }, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] }, paperLanguage: { type: 'string', enum: ['zh', 'en'] }, paperCompilePdf: { type: 'boolean' }, paperLatexCommand: { type: 'string' } }), 'vibe_math_set_params')
4322
5009
  registerTool('vibe_math_setup', TOOL_DESC.vibe_math_setup, objParams({}), 'vibe_math_setup')
4323
5010
  registerTool('vibe_math_save_settings', TOOL_DESC.vibe_math_save_settings, objParams({}), 'vibe_math_save_settings')
4324
5011
  registerTool('vibe_math_template', TOOL_DESC.vibe_math_template, objParams({ where: { type: 'string', enum: ['global', 'project'] } }), 'vibe_math_template')
@@ -4346,6 +5033,15 @@ export function apply(ctx) {
4346
5033
  registerTool('vibe_math_lean_run', TOOL_DESC.vibe_math_lean_run, objParams({ file: { type: 'string' }, target: { type: 'string' }, timeout_ms: { type: 'integer' } }, ['file']), 'vibe_math_lean_run')
4347
5034
  registerTool('vibe_math_lean_archive', TOOL_DESC.vibe_math_lean_archive, objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: { type: 'string' }, target: { type: 'string' }, content: { type: 'string' }, from: { type: 'string' }, note: { type: 'string' }, run: { type: 'boolean' } }, ['kind']), 'vibe_math_lean_archive')
4348
5035
  registerTool('vibe_math_lean_lib', TOOL_DESC.vibe_math_lean_lib, objParams({ refresh: { type: 'boolean' } }), 'vibe_math_lean_lib')
5036
+ registerTool('vibe_math_lean_read', TOOL_DESC.vibe_math_lean_read, objParams({ name: { type: 'string' }, kind: { type: 'string', enum: ['auto', 'lib', 'proved'] } }, ['name']), 'vibe_math_lean_read')
5037
+ registerTool('vibe_math_lean_job', TOOL_DESC.vibe_math_lean_job, objParams({ jobId: { type: 'string' }, waitMs: { type: 'integer' } }), 'vibe_math_lean_job')
5038
+
5039
+ // ── math_computation:apply 层(工具面)────────────────────────────────────────────────────
5040
+ // 共享模块的 handler 在**会话层**构造(闭包住该会话的 params/Paths/fs);这一层只把工具名/描述/schema
5041
+ // 挂到宿主工具面,执行时由 apply 级包装按 handlerName 路由回 s.handlers['math_computation']。
5042
+ // **字面量形态**(与 audit-v3-registration-parity.mjs 的扫描器约定一致):名字字面量 +
5043
+ // TOOL_DESC.math_computation + MATH_TOOL_SCHEMA,两条路径的描述/schema 因此逐字相同。
5044
+ registerTool('math_computation', TOOL_DESC.math_computation, MATH_TOOL_SCHEMA, 'math_computation')
4349
5045
 
4350
5046
  // /vibe slash command (registered once; routed per session)
4351
5047
  ctx.effect(() => commands.register({
@@ -4406,7 +5102,13 @@ export function apply(ctx) {
4406
5102
  function everyMs(ms, fn) {
4407
5103
  try { if (typeof ctx.interval === 'function') return ctx.interval(fn, ms) } catch (e) { /* 回落到 setInterval */ }
4408
5104
  const t = setInterval(fn, ms)
4409
- return () => clearInterval(t)
5105
+ return () => {
5106
+ clearInterval(t)
5107
+ // 卸载/销毁:终止在跑的编译并标记 interrupted(不留孤儿进程,spec §2.5)。
5108
+ for (const s of sessions.values()) {
5109
+ try { if (typeof s.disposeLeanJobs === 'function') s.disposeLeanJobs() } catch (e) { console.error('vibe-math-v3: lean dispose failed: ' + String((e && e.message) || e)) }
5110
+ }
5111
+ }
4410
5112
  }
4411
5113
  ctx.effect(() => everyMs(LOCK_POLL_MS, function () { for (const s of sessions.values()) { s.renewLockIfDue().catch(function (e) { console.error('vibe-math-v3 lock renew error: ' + String((e && e.message) || e)) }) } }))
4412
5114
  // tick timer (registered once; ticks every running session at its own pace)
@@ -4418,6 +5120,10 @@ export function apply(ctx) {
4418
5120
  // 论文心跳(修订 §A2):**独立于 scheduler.running** —— 收口后 running=false,但被激活上限
4419
5121
  // 拒绝的撰写派遣仍要重试,在途时还要续租 paper 锁。runPaperRetry() 无事可做时立即返回。
4420
5122
  if (typeof s.runPaperRetry === 'function') s.runPaperRetry().catch(function (e) { console.error('vibe-math-v3: paper retry failed: ' + String((e && e.message) || e)) })
5123
+ // Lean 异步队列(spec §2.2):独立于 scheduler.running;runLeanQueue() 内部不 await 编译。
5124
+ if (typeof s.runLeanQueue === 'function') s.runLeanQueue().catch(function (e) { console.error('vibe-math-v3: lean queue failed: ' + String((e && e.message) || e)) })
5125
+ // 数学计算的探测缓存 TTL 刷新(15 分钟或从未探测过):mathWorkLine 只读缓存,不能自己 await。
5126
+ if (typeof s.mathProbeDue === 'function' && s.mathProbeDue()) s.refreshMathProbe(false).catch(function (e) { console.error('vibe-math-v3: math probe failed: ' + String((e && e.message) || e)) })
4421
5127
  }
4422
5128
  // childOwner 裁剪(审计 L1):每 30 拍(约 30s)一次,成本是"会话数 × 映射数"的一次扫描。
4423
5129
  if ((++beat % 30) === 0) {
@@ -4689,6 +5395,67 @@ function paperTitleFromMd(md) {
4689
5395
  return m ? m[1].trim() : ''
4690
5396
  }
4691
5397
 
5398
+ /** 搜索路径 flag:收敛成一个常量(不同 Lean 版本拼写可能不同,改这里即可,接口不变)。 */
5399
+ const LEAN_SEARCH_PATH_FLAG = '--search-path'
5400
+ /** 用户已显式给出搜索路径就不再注入(显式覆盖优先)。 */
5401
+ function hasLeanSearchFlag(args) {
5402
+ return (Array.isArray(args) ? args : []).some(function (a) {
5403
+ const s = String(a)
5404
+ return s === LEAN_SEARCH_PATH_FLAG || s.indexOf(LEAN_SEARCH_PATH_FLAG + '=') === 0 || s === '-R' || s === '--root'
5405
+ })
5406
+ }
5407
+ /**
5408
+ * 搜索路径注入计划(修订 §2):用户 leanArgs 已显式给过 ⇒ 什么都不注入(显式覆盖优先);
5409
+ * 否则**先注入用户给的 leanSearchPaths,再注入自动根**,去重且保持顺序。
5410
+ */
5411
+ function leanSearchPathPlan(userArgs, extraPaths, autoRoot) {
5412
+ const args = Array.isArray(userArgs) ? userArgs.map(String) : []
5413
+ if (hasLeanSearchFlag(args)) return { inject: [], paths: [], explicit: true }
5414
+ const out = []
5415
+ const seen = {}
5416
+ const add = function (p) {
5417
+ const s = String(p == null ? '' : p).trim()
5418
+ if (!s || seen[s]) return
5419
+ seen[s] = true
5420
+ out.push(s)
5421
+ }
5422
+ for (const p of (Array.isArray(extraPaths) ? extraPaths : [])) add(p)
5423
+ add(autoRoot)
5424
+ const inject = []
5425
+ for (const p of out) { inject.push(LEAN_SEARCH_PATH_FLAG, p) }
5426
+ return { inject: inject, paths: out, explicit: false }
5427
+ }
5428
+ /** 规范化文本:换行统一 \n、去行尾空白、去尾部空行——内容哈希只反映语义内容。 */
5429
+ function normalizeLeanText(t) {
5430
+ return String(t == null ? '' : t).replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '').replace(/\n+$/, '') + '\n'
5431
+ }
5432
+ /** 内容 sha256(归档去重与作业幂等的键)。 */
5433
+ function leanContentSha(t) { return createHash('sha256').update(normalizeLeanText(t), 'utf8').digest('hex') }
5434
+ /**
5435
+ * 构建上下文(修订 §4):同一段代码在不同引擎/参数/搜索路径下结果可能不同,
5436
+ * 所以"已通过"的判定必须绑定**构建上下文**,不能只看内容。
5437
+ */
5438
+ function leanBuildContext(engine, args, searchPaths) {
5439
+ return JSON.stringify({
5440
+ engine: String(engine == null ? '' : engine),
5441
+ args: (Array.isArray(args) ? args : []).map(String),
5442
+ searchPaths: (Array.isArray(searchPaths) ? searchPaths : []).map(String),
5443
+ })
5444
+ }
5445
+ /** 作业指纹 = sha256(内容 + 构建上下文)(修订 §4:jobId 取它的前 12 位)。 */
5446
+ function leanJobFingerprint(contentText, buildCtx) {
5447
+ return createHash('sha256').update(normalizeLeanText(contentText) + '\n' + String(buildCtx || ''), 'utf8').digest('hex')
5448
+ }
5449
+ /** jobId = `<target|name>-<指纹[0:12]>`:同一内容 + 同一构建上下文天然只有一个作业。 */
5450
+ function leanJobId(key, fingerprint) {
5451
+ const k = String(key == null ? '' : key).replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^_+|_+$/g, '').slice(0, 60) || 'job'
5452
+ return k + '-' + String(fingerprint == null ? '' : fingerprint).slice(0, 12)
5453
+ }
5454
+ /** "已落地且通过"的**唯一**判据:state=settled 且 exit 0 且未超时未中断(内容哈希由 settle 校验)。 */
5455
+ function leanJobSettledOk(rec) { return !!rec && rec.state === 'settled' && rec.exitCode === 0 && !rec.timedOut && !rec.interrupted }
5456
+ /** 主动性档位(修订 §1):与"验证时的要求强度"(formalVerify)是两件事。 */
5457
+ const LEAN_INITIATIVE_MODES = ['off', 'normal', 'eager']
5458
+
4692
5459
  export const __testHelpers = {
4693
5460
  uuid,
4694
5461
  shortId,
@@ -4716,6 +5483,16 @@ export const __testHelpers = {
4716
5483
  parseMethodMd,
4717
5484
  sanitizeToolFilter,
4718
5485
  registeredToolsFromError,
5486
+ LEAN_INITIATIVE_MODES,
5487
+ leanJobSettledOk,
5488
+ leanJobId,
5489
+ leanJobFingerprint,
5490
+ leanBuildContext,
5491
+ leanContentSha,
5492
+ normalizeLeanText,
5493
+ leanSearchPathPlan,
5494
+ hasLeanSearchFlag,
5495
+ LEAN_SEARCH_PATH_FLAG,
4719
5496
  // final paper(spec §6 的纯函数守卫面;与 v2 同构)
4720
5497
  PAPER_SKELETON,
4721
5498
  PAPER_NO_EVIDENCE,
@@ -4757,7 +5534,7 @@ const TOOL_DESC = {
4757
5534
  vibe_math_set_mode: 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.',
4758
5535
  // set_params 的 schema 现在也收 mode(M10):同一能力既有专用工具 vibe_math_set_mode,也可用这个键;
4759
5536
  // 两条注册路径共用这一份描述,`--self-probe` 会证明漂移能被抓到。
4760
- vibe_math_set_params: 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象未达到 Lean 已通过或已记录显式阻塞原因之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式。最终论文:finalPaper(默认 true;收口时自动派遣一名「论文撰写」子代理)/ paperFormat = both|md|tex / paperLanguage = zh|en / paperCompilePdf(检测到 LaTeX 时编译 paper.pdf)/ paperLatexCommand(指定引擎,空 = 自动探测 xelatex→latexmk→pdflatex→lualatex→tectonic),产物在 Paper/<项目>/。',
5537
+ vibe_math_set_params: 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象未达到 Lean 已通过或已记录显式阻塞原因之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式(框架会在用户 leanArgs 之后、文件名之前自动追加 --search-path <VibeMath 根>,用户已显式给出就不注入;leanSearchPaths 可附加额外搜索路径,先注入它们再注入自动根);leanAsync = true(默认,后台队列:入队即返回,只有作业落地 ok 才置 passed 并写归档证明)| false(同步 await 的旧语义);leanJobsMaxParallel = 后台编译并发上限(默认 1 = 串行);leanInitiative = off|normal|eager 控制**日常的**形式化主动性(与 formalVerify 的验证要求强度是两件事)。最终论文:finalPaper(默认 true;收口时自动派遣一名「论文撰写」子代理)/ paperFormat = both|md|tex / paperLanguage = zh|en / paperCompilePdf(检测到 LaTeX 时编译 paper.pdf)/ paperLatexCommand(指定引擎,空 = 自动探测 xelatex→latexmk→pdflatex→lualatex→tectonic),产物在 Paper/<项目>/。数学计算(工具 math_computation,回执落在 Computation/<id>/):mathComputation = off|auto|on(默认 auto;off 时提示词零提及)· mathMode = typed|typed+shell(默认 typed+shell;typed 时提示词不含 shell 兜底段且 cli 返回 REFUSED{reason:policy})· mathEngines = 允许的引擎列表(默认含 cli,cli 默认开启)· mathTimeoutMs(默认 60000,最小 1000;到时主动 terminate)· mathPackages(需预检的包,缺包只报告+给安装计划)· mathInstallScope = user|system(默认 user;system 只对当次显式调用生效、永不记忆)。',
4761
5538
  vibe_math_setup: 'Return the interactive parameter schema for guided configuration.',
4762
5539
  vibe_math_save_settings: 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.',
4763
5540
  vibe_math_template: 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.',
@@ -4783,6 +5560,10 @@ const TOOL_DESC = {
4783
5560
  vibe_math_lean_run: "(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Never throws: a missing toolchain returns LEAN_NOT_FOUND, a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.",
4784
5561
  vibe_math_lean_archive: '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (VibeMath/Formal/Lib). kind="lemma": a machine-checked lemma → VibeMath/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).',
4785
5562
  vibe_math_lean_lib: "(member) List (and by default rebuild) the Lean reuse library: this project'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.",
5563
+ vibe_math_lean_read: '(member) Read back the original text of one archived Lean file (verbatim reuse). Only files under Formal/Lib and Formal/Proved are readable; name is id-sanitised and path escapes are rejected. Returns {ok,name,file,kind,sha256,bytes,text,truncated} (text capped at 64KB).',
5564
+ vibe_math_lean_job: '(member) Read-only view of the background Lean compile jobs. Without jobId: the session job list (state/rel/target/attempts/paths). With jobId: that job state/exitCode/receipt + archive paths. waitMs>0 waits up to that many ms for a queued/running job to settle (polling; it does not block the heartbeat) and returns the current state on timeout. Only state=settled with exitCode=0 (same content hash AND same build context) counts as passed.',
5565
+ // 数学计算:描述逐字来自共享模块的 MATH_TOOL_DESCRIPTION(四套一致,FREEZE §1)。
5566
+ math_computation: MATH_TOOL_DESCRIPTION,
4786
5567
  }
4787
5568
 
4788
5569
  function uuid() { const h = '0123456789abcdef'; let s = ''; for (let i = 0; i < 36; i++) { if (i === 8 || i === 13 || i === 18 || i === 23) s += '-'; else s += h[Math.floor(Math.random() * 16)] } return s }