dsh-vibe-math 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.en.md +53 -14
  2. package/README.md +54 -14
  3. package/cordis.patch.yml +54 -17
  4. package/docs/AUDIT-CHECKLIST.md +26 -7
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/final-paper.md +171 -0
  7. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
  9. package/docs/test-timing.md +77 -40
  10. package/installer.js +225 -45
  11. package/package.json +6 -3
  12. package/preset-declaration.js +38 -7
  13. package/prompt-corpus-persona/persona-corpus.json +13 -13
  14. package/prompt-corpus-persona/persona-corpus.md +113 -39
  15. package/prompt-corpus-v2/formal-verify-v2.json +128 -92
  16. package/prompt-corpus-v2/formal-verify-v2.md +380 -142
  17. package/prompt-corpus-v3/formal-verify-v3.json +117 -81
  18. package/prompt-corpus-v3/formal-verify-v3.md +693 -1189
  19. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  20. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  21. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  22. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  23. package/tests/audit-installer-compat.test.mjs +70 -4
  24. package/tests/audit-installer-policy.test.mjs +229 -4
  25. package/tests/audit-persona-sensitivity.mjs +68 -8
  26. package/tests/audit-persona-surface.test.mjs +21 -8
  27. package/tests/audit-v5-sensitivity.mjs +47 -6
  28. package/tests/e2e-v5-round2.test.mjs +825 -1
  29. package/tests/formal-verify-v2.test.mjs +57 -2
  30. package/tests/formal-verify-v3.test.mjs +67 -2
  31. package/tests/formal-verify-v4.test.mjs +120 -4
  32. package/tests/prompt-v5-integrity.test.mjs +9 -3
  33. package/tests/run-tests.mjs +104 -23
  34. package/tests/selfdrive-v5.mjs +55 -4
  35. package/vibe-math-v2/agent.cordis.yml +20 -4
  36. package/vibe-math-v2/vibe-math-v2.js +1318 -106
  37. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  38. package/vibe-math-v3/agent.cordis.yml +36 -14
  39. package/vibe-math-v3/vibe-math-v3.js +1348 -179
  40. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  41. package/vibe-math-v4/agent.cordis.yml +36 -8
  42. package/vibe-math-v4/vibe-math-v4.js +1195 -57
  43. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  44. package/vibe-math-v5/agent.cordis.yml +16 -8
  45. package/vibe-math-v5/vibe-math-v5.js +1628 -112
  46. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +143 -41
@@ -64,6 +64,25 @@ export function apply(ctx) {
64
64
  // ================= per-session registry =================
65
65
  const sessions = new Map() // rootAgentId -> Session
66
66
  const childOwner = new Map() // childId -> rootAgentId (route subagent/end back to its session)
67
+ // childOwner 的**有界化**(审计 L1)。这条映射只在"这个 child 还可能再发 subagent/end"时有价值,
68
+ // 但(与 v2 同一结论)不能在事件回调里立即回收:辩论/续轮会在同一个 child 上再次 end,丢了映射这次
69
+ // 事件就没人路由,verdict 收口随之失效。所以改为**引用 + 宽限期**:仍被任务或 agentRegistry 引用的
70
+ // 一律保留,没有引用且距最近一次 end 已超过宽限期才回收。
71
+ const CHILD_OWNER_GRACE_MS = 5 * 60 * 1000
72
+ const lastChildEndAt = new Map() // childId -> 最近一次 subagent/end 的时间
73
+ function pruneChildOwner() {
74
+ const keep = new Set()
75
+ for (const s of sessions.values()) {
76
+ try { for (const id of s.referencedChildIds()) keep.add(id) } catch (e) { /* 尽力而为 */ }
77
+ }
78
+ const cutoff = now() - CHILD_OWNER_GRACE_MS
79
+ for (const cid of Array.from(childOwner.keys())) {
80
+ if (keep.has(cid)) continue
81
+ const ended = lastChildEndAt.get(cid)
82
+ if (ended === undefined || ended > cutoff) continue
83
+ childOwner.delete(cid); lastChildEndAt.delete(cid)
84
+ }
85
+ }
67
86
  const fileOwner = {} // 进程级写锁:fileKey -> { childId, sessionId, at } —— 防任何代理(跨会话)并发写同一 md 文件
68
87
  // Process epoch: PROCESS-level (one per apply, shared by every session), written to
69
88
  // State/process_epoch.json at init; a DIFFERENT persisted epoch means a previous DSH
@@ -71,6 +90,11 @@ export function apply(ctx) {
71
90
  // same-process pause→resume (children may still be alive). Kept at apply level so two
72
91
  // sessions in one process never treat each other as a stale previous process.
73
92
  const processEpoch = String(Date.now()) + '-' + Math.random().toString(36).slice(2, 8)
93
+ // 项目锁 LEASE 的续租轮询间隔(apply 级定时器用,见 apply() 末尾):它必须**明显快于**续租判据
94
+ // (projectLockTimeoutMs/4),否则"多久检查一次"会成为租约年龄的下界——实测 2500ms 轮询配 625ms
95
+ // 判据时最坏年龄仍有 2467ms,几乎顶到 2500ms 的租约上限。250ms 轮询下最坏租约年龄 ≈
96
+ // max(250ms, projectLockTimeoutMs/4),即上限的 1/4 再加一次轮询的抖动。
97
+ const LOCK_POLL_MS = 250
74
98
 
75
99
  function sessionIdOf(agent) { try { return (agent && agent.id) ? String(agent.id) : undefined } catch (e) { return undefined } }
76
100
  // Walk up the durable session lineage to the top-level (root) agent of this session,
@@ -111,7 +135,7 @@ export function apply(ctx) {
111
135
  directionsPerSolver: 1, // directions shown per solver prompt (1 = own only)
112
136
  verifierCount: 3, // independent reviewers per verification
113
137
  debateMaxRounds: 5, // debate round cap
114
- verdictMode: 'forced', // flat = 均衡(0.5) | forced = 按历史准确率+严谨性加权;两者都先做近共识判定(同侧且均值≥0.85/≤0.15取均值)
138
+ verdictMode: 'forced', // flat = 均衡(0.5) | forced = 对每票**等权**取均值(每票按自己报出的概率贡献;严格 1/0 是绝对投票,其影响通过数值本身拉向端点)。**不再**按"历史准确率"加权:那个统计量统计的是"与本批裁决的一致度",没有后续真值可纠正它。两者都先做近共识判定(同侧且均值≥0.85/≤0.15取均值)
115
139
  provider: '',
116
140
  model: '',
117
141
  solverPersona: '',
@@ -143,7 +167,7 @@ export function apply(ctx) {
143
167
  plannerEnabled: true, // false = 完全走内置启发式(规划代理禁用)
144
168
  plannerProvider: '', // 规划代理模型 provider(空 = 继承)
145
169
  plannerModel: '', // 规划代理模型 id(空 = 继承)
146
- planMinIntervalMs: 30000, // 两次规划调用的最小间隔(系统空闲且有工作时忽略)
170
+ planMinIntervalMs: 30000, // 每一次规划调用的最小间隔(含空计划与「空闲但有工作」)
147
171
  plannerMaxFails: 3, // 规划代理连续失败达此值 → 自动降级启发式
148
172
  methodKeepIntervalMs: 0, // Method Keeper 定时整理间隔(0 = 事件驱动)
149
173
  methodKeepEvery: 5, // 每积累 N 个待沉淀发明/新命题触发一次整理
@@ -155,12 +179,26 @@ export function apply(ctx) {
155
179
  leanCommand: 'lean', // 要执行的 Lean 可执行文件(例:'lake')
156
180
  leanArgs: [], // 插在文件名之前的附加参数(例:['env','lean'] 配 leanCommand='lake')
157
181
  leanTimeoutMs: 120000, // 单次 Lean 运行超时上限(毫秒,正整数)
182
+ // ---- 最终论文(规格:docs/final-paper.md;v2/v3 为单作者变体)----
183
+ finalPaper: true, // 收口时是否自动撰写最终论文(false 只关自动触发;/vibe paper 仍可用)
184
+ paperFormat: 'both', // both = md + tex | md = 只写 markdown | tex = 只写 latex
185
+ paperLanguage: 'zh', // zh = 中文(ctexart/xelatex 优先)| en = 英文(article/pdflatex 优先)
186
+ paperCompilePdf: true, // 检测到 LaTeX 时是否编译 paper.pdf
187
+ paperLatexCommand: '', // 指定 LaTeX 引擎(空 = 按语言探测 xelatex/latexmk/pdflatex/lualatex/tectonic)
158
188
  }
159
189
  let params = Object.assign({}, DEFAULT_PARAMS)
160
190
  let scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null } // activeCount 由 activeCount() 从 agentRegistry 推导(防漂移,同 v2)
161
191
  let agentRegistry = {}
162
192
  let decisionQueue = []
163
193
  let verifierAccuracy = {}
194
+ // 验证者票数统计的保留上限(审计 L1):它按**一次性的 childId** 记,每次验证都新增键,
195
+ // 长跑进程里无界增长并整表落盘。它已经不再参与任何加权(见 finalVerdict 的 M8 说明),
196
+ // 只是审计轨迹,所以按插入顺序保留最近 N 条即可。
197
+ const VERIFIER_ACC_KEEP = 200
198
+ function pruneVerifierAccuracy() {
199
+ const keys = Object.keys(verifierAccuracy)
200
+ for (let i = 0; i < keys.length - VERIFIER_ACC_KEEP; i++) delete verifierAccuracy[keys[i]]
201
+ }
164
202
  let tasks = {} // verify tasks keyed by 'verify:<rId>'
165
203
  let activityLog = []
166
204
  let lastReportWrite = 0
@@ -223,9 +261,9 @@ export function apply(ctx) {
223
261
  { name: 'maxParallelThreshold', type: 'integer', description: '全局最大并发子代理轮数(新派发前须满足 active < 阈值)', suggestion: 4 },
224
262
  { name: 'solverMaxRounds', type: 'integer', description: '每个求解方向的最大迭代轮数', suggestion: 3 },
225
263
  { name: 'directionsPerSolver', type: 'integer', description: '每个 solver 提示词附带的其他活跃方向摘要数量:1 = 只看自己方向', suggestion: 1 },
226
- { name: 'verifierCount', type: 'integer', description: '每个验证对象的独立验证器数量', suggestion: 3 },
264
+ { name: 'verifierCount', type: 'integer', description: '每个验证对象的独立验证器数量(实际下界为 2:少于 2 份独立评审一律不定论,见 minVotes)', suggestion: 3 },
227
265
  { name: 'debateMaxRounds', type: 'integer', description: '验证辩论(交流群)最大轮数', suggestion: 5 },
228
- { name: 'verdictMode', type: 'enum', options: ['flat', 'forced'], description: '裁决模式:flat = 均衡机制;forced = 强制裁决(按历史准确率+严谨性加权);两者都先做近共识判定(同侧且均值≥0.85/≤0.15取均值,修复 v2 flat 误判)', suggestion: 'forced' },
266
+ { name: 'verdictMode', type: 'enum', options: ['flat', 'forced'], description: '裁决模式:flat = 均衡机制(分歧时 0.5);forced = 强制裁决(对每票等权取均值;严格 1/0 是绝对投票,其影响通过数值本身拉向端点。**不按"历史准确率"加权**——该统计量统计的是与本批裁决的一致度,没有后续真值可纠正);两者都先做近共识判定(同侧且均值≥0.85/≤0.15取均值,修复 v2 flat 误判)', suggestion: 'forced' },
229
267
  { name: 'provider', type: 'string', description: '子代理模型 provider(空 = 继承根代理)', suggestion: '' },
230
268
  { name: 'model', type: 'string', description: '子代理模型 id(空 = 继承根代理)', suggestion: '' },
231
269
  { name: 'solverPersona', type: 'string', description: '注入每个求解器提示词开头的人格/要求', suggestion: '' },
@@ -256,7 +294,7 @@ export function apply(ctx) {
256
294
  { name: 'plannerEnabled', type: 'boolean', description: 'false = 完全走内置启发式调度(规划代理禁用)', suggestion: true },
257
295
  { name: 'plannerProvider', type: 'string', description: '规划代理模型 provider(空 = 继承根代理)', suggestion: '' },
258
296
  { name: 'plannerModel', type: 'string', description: '规划代理模型 id(空 = 继承根代理)', suggestion: '' },
259
- { name: 'planMinIntervalMs', type: 'integer', description: '两次规划调用的最小间隔(毫秒);系统空闲且有工作时忽略', suggestion: 30000 },
297
+ { name: 'planMinIntervalMs', type: 'integer', description: '每一次规划调用的最小间隔(毫秒);对**每一次**规划调用都生效(含空计划、含「空闲但仍有工作」,不再有空闲绕过)', suggestion: 30000 },
260
298
  { name: 'plannerMaxFails', type: 'integer', description: '规划代理连续失败达此值 → 自动降级启发式', suggestion: 3 },
261
299
  { name: 'methodKeepIntervalMs', type: 'integer', description: 'Method Keeper 定时整理间隔(0 = 事件驱动)', suggestion: 0 },
262
300
  { name: 'methodKeepEvery', type: 'integer', description: '每积累 N 个待沉淀发明/新命题触发一次整理', suggestion: 5 },
@@ -267,6 +305,11 @@ export function apply(ctx) {
267
305
  { name: 'leanCommand', type: 'string', description: 'Lean 可执行文件(例:lean / lake;配合 leanArgs=[env,lean] 用 lake)', suggestion: 'lean' },
268
306
  { name: 'leanArgs', type: 'string[]', description: '插在 .lean 文件名之前的附加参数', suggestion: [] },
269
307
  { name: 'leanTimeoutMs', type: 'integer', description: '单次 Lean 运行的超时上限(毫秒,非正数回退默认)', suggestion: 120000 },
308
+ { name: 'finalPaper', type: 'boolean', description: '收口(checkTermination 的完整收口分支:无未解决问题、无待验证对象、无任务/计划/门)时自动撰写最终论文:派遣一名专职「论文撰写」子代理,把已定论命题/解法/方法整理成 Paper/<项目>/{paper.md,paper.tex,paper.meta.json,paper.log.md}。false = 只关自动触发,/vibe paper 手动命令仍可用', suggestion: true },
309
+ { name: 'paperFormat', type: 'enum', options: ['both', 'md', 'tex'], description: '论文产出格式:both = markdown + latex;md = 只写 paper.md(跳过编译);tex = 只写 paper.tex(tex 才会尝试编译 pdf)', suggestion: 'both' },
310
+ { name: 'paperLanguage', type: 'enum', options: ['zh', 'en'], description: '论文语言:zh = 中文(LaTeX 用 ctexart,引擎优先 xelatex);en = 英文(article,引擎优先 pdflatex/latexmk)', suggestion: 'zh' },
311
+ { name: 'paperCompilePdf', type: 'boolean', description: '检测到 LaTeX 时是否编译 paper.pdf(-interaction=nonstopmode 跑两遍;失败先尝试修复:换引擎/去不支持宏包/最小模板)。false 或无 LaTeX 时只保留 tex+md 并记日志(不阻塞定稿)', suggestion: true },
312
+ { name: 'paperLatexCommand', type: 'string', description: '指定 LaTeX 引擎可执行文件(空 = 按语言探测:中文 xelatex > latexmk > pdflatex > lualatex > tectonic;英文 pdflatex 优先)。解析不到时按"未检测到"降级', suggestion: '' },
270
313
  ]
271
314
 
272
315
  // ================= fs (adapted to DSH 0.1.1: resolve returns {targetKey, displayPath}) =================
@@ -345,7 +388,10 @@ export function apply(ctx) {
345
388
  * 与 `Verified/Lean/`(归档证明,与定论卡片同处 Verified/,一眼可见"这条结论的证明在哪"),
346
389
  * 以及**项目树之外**的全局可复用库 `<VibeMath 根>/Formal/{Lib,Proved}`(跨项目复用是核心收益)。
347
390
  */
348
- async function ensureDirs() { const base = frameworkRoot(); const dirs = ['Problems', 'Progress', '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)) }
391
+ // Progress_Logs/ 必须和别的骨架目录**一起**建出来(审计 L8):vibe_math_report 会写
392
+ // `Progress_Logs/report.json`,README 也把它列为布局的一部分,但此前它只靠 writeText 的隐式
393
+ // 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)) }
349
395
  async function removeFile(rel) { const base = frameworkRoot(); return await runShell(rmCmd(base + '/' + rel)) }
350
396
 
351
397
  // ================= settings =================
@@ -354,7 +400,7 @@ export function apply(ctx) {
354
400
  const intFields = ['maxParallelThreshold', 'solverMaxRounds', 'directionsPerSolver', 'verifierCount', 'debateMaxRounds', 'solverMaxToolCalls', 'verifierMaxToolCalls', 'reportIntervalMs', 'tickIntervalMs', 'activityLogCap', 'maxExplorerRetries', 'planningHorizon', 'planMinIntervalMs', 'plannerMaxFails', 'methodKeepIntervalMs', 'methodKeepEvery', 'projectLockTimeoutMs']
355
401
  const numFields = ['promoteValueThreshold']
356
402
  const arrayFields = ['solverToolAllow', 'solverToolDeny', 'verifierToolAllow', 'verifierToolDeny', 'leanArgs']
357
- const boolFields = ['plannerEnabled', 'methodAutoPromote', 'indexAutoRebuild']
403
+ const boolFields = ['plannerEnabled', 'methodAutoPromote', 'indexAutoRebuild', 'finalPaper', 'paperCompilePdf']
358
404
  for (const k of Object.keys(DEFAULT_PARAMS)) {
359
405
  if (!(k in obj)) continue
360
406
  const v = obj[k]
@@ -373,6 +419,10 @@ export function apply(ctx) {
373
419
  else if (k === 'leanCommand') { const s = String(v == null ? '' : v).trim(); out[k] = s || 'lean' }
374
420
  else if (k === 'leanTimeoutMs') { const n = Number(v); out[k] = (Number.isFinite(n) && n > 0) ? Math.floor(n) : DEFAULT_PARAMS[k] }
375
421
  else if (k === 'solverAllowNetwork' || k === 'verifierAllowNetwork' || k === 'solverAllowScripts' || k === 'verifierAllowScripts') { out[k] = (v === true || v === false || v === '') ? v : DEFAULT_PARAMS[k] }
422
+ // 最终论文(spec v2 §B):布尔/枚举必须**显式归一化**,不允许未知键直通('false' 会保持真值)。
423
+ else if (k === 'paperFormat') { out[k] = (v === 'both' || v === 'md' || v === 'tex') ? v : DEFAULT_PARAMS[k] }
424
+ else if (k === 'paperLanguage') { out[k] = (v === 'zh' || v === 'en') ? v : DEFAULT_PARAMS[k] }
425
+ else if (k === 'paperLatexCommand') { const s = String(v == null ? '' : v).trim(); out[k] = s }
376
426
  else { out[k] = v }
377
427
  }
378
428
  return out
@@ -424,7 +474,11 @@ export function apply(ctx) {
424
474
  // ================= md soft-spec helpers =================
425
475
  // 软规范:对象 md 头部锚点行(唯一强制部分)+ 正文自由叙述。调度器只解析
426
476
  // 头部锚点与条目标题行(### 解法/证明/证伪 N|标题|概率X|状态Y),从不解析正文散文。
427
- function anchorLine(k, v) { return '- ' + k + ': ' + String(v == null ? '' : v) }
477
+ function anchorLine(k, v) {
478
+ // 锚点行是**逐行**格式,parseAnchors 的 `.*$` 不跨行(无 `s` 标志):值里含换行会让这一行被
479
+ // 劈成两行,第二行既不是锚点也会污染下一个锚点/段落的解析。compose 侧统一折成空格(审计 L6)。
480
+ return '- ' + k + ': ' + String(v == null ? '' : v).replace(/[\r\n]+/g, ' ')
481
+ }
428
482
  // 解析条目标题行 + 其后正文,直到下一个 ### / ## 标题。返回 [{heading, text}]
429
483
  /**
430
484
  * 按顺序切出正文里的所有 `## ` 顶层段。用于"无损往返":compose 只重新生成自己管理的
@@ -460,6 +514,12 @@ export function apply(ctx) {
460
514
  lines.push(anchorLine('依赖', JSON.stringify(p.依赖 || [])))
461
515
  lines.push(anchorLine('被依赖', JSON.stringify(p.被依赖 || [])))
462
516
  lines.push(anchorLine('来源', p.来源 || '原始'))
517
+ // 关联锚点(H4):这两行此前**只读不写** ⇒ 任何一次重载(resume / vibe_math_index /
518
+ // set_project / 进程重启都会按文件名重新解析卡片)之后,判断问题与它的临时假设命题
519
+ // 全部失联:processStatusUpdates 的"收口僵尸判断问题"分支、settleVerdict 的"回写源命题"
520
+ // 分支、Verified 卡的来源字段都只在同进程内有效。写出来才是真正的持久关联。
521
+ if (p.判断命题) lines.push(anchorLine('判断命题', p.判断命题))
522
+ if (p.来源命题) lines.push(anchorLine('来源命题', p.来源命题))
463
523
  lines.push(anchorLine('计划', p.计划 || ''))
464
524
  lines.push('')
465
525
  lines.push('## 陈述')
@@ -471,7 +531,7 @@ export function apply(ctx) {
471
531
  if (sols.length === 0) lines.push('(暂无解法候选)')
472
532
  else for (let i = 0; i < sols.length; i++) {
473
533
  const s = sols[i]
474
- lines.push('### 解法 ' + (i + 1) + '|' + (s.title || '解法' + (i + 1)) + '|概率' + (s.prob != null ? s.prob : 0.5) + '|状态' + (s.status || '未定论'))
534
+ lines.push('### 解法 ' + (i + 1) + '|' + escField(s.title || '解法' + (i + 1)) + '|概率' + probText(s.prob != null ? s.prob : 0.5) + '|状态' + escField(s.status || '未定论'))
475
535
  lines.push(s.text || '')
476
536
  lines.push('')
477
537
  }
@@ -480,7 +540,7 @@ export function apply(ctx) {
480
540
  function parseProblemMd(id, text) {
481
541
  const { head, body } = splitHeader(text)
482
542
  const a = parseAnchors(head)
483
- const sols = parseEntries(body, /^###\s*解法\s*\d+|(.*?)|概率([0-9.]+)|状态(.+)$/).map(function (e) { return { title: e.title, prob: clamp01(e.prob), status: e.status, text: e.text } })
543
+ const sols = parseEntries(body, entryRe('解法')).map(function (e) { return { title: unescField(e.title), prob: clamp01(e.prob), status: unescField(e.status), text: e.text } })
484
544
  return {
485
545
  id: id, 标题: a['标题'] || id, 状态: a['状态'] || '求解中',
486
546
  优先级: (a['优先级'] === 'never' ? 'never' : (Number(a['优先级']) || 1)),
@@ -505,6 +565,10 @@ export function apply(ctx) {
505
565
  if (formalAnchorLine(p.id)) lines.push(anchorLine('形式化', formalAnchorLine(p.id)))
506
566
  lines.push(anchorLine('优先级', p.优先级 == null ? 1 : p.优先级))
507
567
  lines.push(anchorLine('依赖', JSON.stringify(p.依赖 || [])))
568
+ // 来源锚点(H4):与问题卡的 判断命题/来源命题 对称——不写盘的话,重载后
569
+ // 「引理来自哪个问题/方向」丢失,Verified 命题卡的 `- 来源:` 也会永久为空。
570
+ if (p.来源问题) lines.push(anchorLine('来源问题', p.来源问题))
571
+ if (p.来源方向) lines.push(anchorLine('来源方向', p.来源方向))
508
572
  if (p.价值关键性 != null) lines.push(anchorLine('价值/关键性', p.价值关键性))
509
573
  lines.push('')
510
574
  lines.push('## 陈述')
@@ -515,7 +579,7 @@ export function apply(ctx) {
515
579
  if (proofs.length === 0) lines.push('(暂无证明尝试)')
516
580
  else for (let i = 0; i < proofs.length; i++) {
517
581
  const s = proofs[i]
518
- lines.push('### 证明 ' + (i + 1) + '|' + (s.title || '证明' + (i + 1)) + '|概率' + (s.prob != null ? s.prob : 0.5) + '|状态' + (s.status || '未定论'))
582
+ lines.push('### 证明 ' + (i + 1) + '|' + escField(s.title || '证明' + (i + 1)) + '|概率' + probText(s.prob != null ? s.prob : 0.5) + '|状态' + escField(s.status || '未定论'))
519
583
  lines.push(s.text || '')
520
584
  lines.push('')
521
585
  }
@@ -524,7 +588,7 @@ export function apply(ctx) {
524
588
  if (refutes.length === 0) lines.push('(暂无证伪尝试)')
525
589
  else for (let i = 0; i < refutes.length; i++) {
526
590
  const s = refutes[i]
527
- lines.push('### 证伪 ' + (i + 1) + '|' + (s.title || '证伪' + (i + 1)) + '|概率' + (s.prob != null ? s.prob : 0.5) + '|状态' + (s.status || '未定论'))
591
+ lines.push('### 证伪 ' + (i + 1) + '|' + escField(s.title || '证伪' + (i + 1)) + '|概率' + probText(s.prob != null ? s.prob : 0.5) + '|状态' + escField(s.status || '未定论'))
528
592
  lines.push(s.text || '')
529
593
  lines.push('')
530
594
  }
@@ -533,8 +597,8 @@ export function apply(ctx) {
533
597
  function parsePropositionMd(id, text) {
534
598
  const { head, body } = splitHeader(text)
535
599
  const a = parseAnchors(head)
536
- const proofs = parseEntries(body, /^###\s*证明\s*\d+|(.*?)|概率([0-9.]+)|状态(.+)$/).map(function (e) { return { title: e.title, prob: clamp01(e.prob), status: e.status, text: e.text } })
537
- const refutes = parseEntries(body, /^###\s*证伪\s*\d+|(.*?)|概率([0-9.]+)|状态(.+)$/).map(function (e) { return { title: e.title, prob: clamp01(e.prob), status: e.status, text: e.text } })
600
+ const proofs = parseEntries(body, entryRe('证明')).map(function (e) { return { title: unescField(e.title), prob: clamp01(e.prob), status: unescField(e.status), text: e.text } })
601
+ const refutes = parseEntries(body, entryRe('证伪')).map(function (e) { return { title: unescField(e.title), prob: clamp01(e.prob), status: unescField(e.status), text: e.text } })
538
602
  return {
539
603
  id: id, 标题: a['标题'] || id, 状态: a['状态'] || '未定论',
540
604
  概率: clamp01(a['概率'] != null ? Number(a['概率']) : 0.5),
@@ -768,6 +832,8 @@ export function apply(ctx) {
768
832
  function allProblems() { return Array.from(problems.values()) }
769
833
  function allPropos() { return Array.from(propos.values()) }
770
834
  function depResolved(id) {
835
+ // M9:对象键一律是 idSafe(id)(见各写入点),依赖是模型写的原始 id ⇒ 查表前必须走同一把归一化。
836
+ id = idSafe(id)
771
837
  if (problems.has(id)) { const q = problems.get(id); return q.状态 === '已解决' || q.优先级 === 'never' }
772
838
  if (propos.has(id)) { const p = propos.get(id); return p.概率 === 1 || p.概率 === 0 || p.优先级 === 'never' } // never = 主动弃权,视为依赖已满足,避免等待依赖死锁
773
839
  return true // unknown dependency: treat as resolved (conservative)
@@ -804,7 +870,14 @@ export function apply(ctx) {
804
870
  const va = await readJson('State/verifier_accuracy.json'); if (va) verifierAccuracy = va
805
871
  const tk = await readJson('State/tasks.json'); if (tk) tasks = tk
806
872
  const er = await readJson('State/explorer_retries.json'); if (er) explorerRetries = er
807
- const pq = await readJson('State/plans.json'); if (pq && Array.isArray(pq.queued)) planQueue = pq.queued
873
+ const pq = await readJson('State/plans.json')
874
+ // 计划队列的**跨进程**语义(审计 L9):planQueue 是"本进程内跨 tick"的队列,里面的动作指的是
875
+ // 当时那个进程/那次运行的对象与状态。上一进程留下的队列在恢复时重放,轻则对已不存在的 target
876
+ // 空转,重则把过期计划的动作施加到新状态上。进程 epoch 是权威判据:只有本进程写下的队列才恢复。
877
+ if (pq && Array.isArray(pq.queued)) {
878
+ if (pq.epoch === processEpoch) planQueue = pq.queued
879
+ else if (pq.queued.length > 0) { planQueue = []; logActivity('plan', '丢弃上一进程留下的 ' + pq.queued.length + ' 条计划动作(跨进程重放会施加过期计划)') }
880
+ }
808
881
  const ml = await readJson('State/method_log.json'); if (ml) methodLog = Object.assign({ pendingInventions: [], keepCount: 0, lastKeepAt: 0 }, ml)
809
882
  const pl = await readJson('State/project_lock.json'); if (pl) projectLock = Object.assign({ sessionId: '', at: 0 }, pl)
810
883
  const lp = await readJson('State/last_plan.json'); if (lp) lastPlanSummary = lp
@@ -825,7 +898,7 @@ export function apply(ctx) {
825
898
  await writeJson('State/verifier_accuracy.json', verifierAccuracy)
826
899
  await writeJson('State/tasks.json', tasks)
827
900
  await writeJson('State/explorer_retries.json', explorerRetries)
828
- await writeJson('State/plans.json', { queued: planQueue })
901
+ await writeJson('State/plans.json', { queued: planQueue, epoch: processEpoch })
829
902
  await writeJson('State/method_log.json', methodLog)
830
903
  await writeJson('State/project_lock.json', projectLock)
831
904
  await writeJson('State/archived_journals.json', archivedJ)
@@ -927,7 +1000,10 @@ export function apply(ctx) {
927
1000
  ',问题 ' + report.problems.solved + '/' + report.problems.total + ' 已解决,命题 ' + report.propositions.resolved + '/' + report.propositions.total + ' 已定论,' +
928
1001
  '活跃代理轮数=' + report.activeCount + ',待人工决策=' + report.pendingDecisions.length + ',待执行计划=' + report.queuedPlanActions + '。' +
929
1002
  '请调用 vibe_math_report 汇总当前进展及各代理状态,并用人话简要汇报(不打断用户,简短即可)。'
930
- rootAgent.followup({ id: uuid(), role: 'user', content: [textBlock(text)], source: { kind: 'plugin', plugin: 'vibe-math-v3' } })
1003
+ // 来源 kind 必须是**已声明**的:`MessageSourceMap` 是 merge-extensible 联合,但没有共享的
1004
+ // catch-all `plugin` kind(审计 L5/dsh-llm message.d.ts),{kind:'plugin'} 是契约外形状。
1005
+ // role 本来就是 'user',正文自带 "[Vibe Math V3] 进度更新" 的真署名,故用核心声明的 {kind:'user'}。
1006
+ rootAgent.followup({ id: uuid(), role: 'user', content: [textBlock(text)], source: { kind: 'user' } })
931
1007
  lastPushReport = now()
932
1008
  } catch (e) {
933
1009
  console.error('vibe-math-v3: push report failed: ' + String((e && e.message) || e))
@@ -1053,7 +1129,8 @@ export function apply(ctx) {
1053
1129
  function kcObjectModels() {
1054
1130
  return '\n2) OBJECT MODELS(md 卡片,软规范:头部锚点行 + 正文自由叙述):\n' +
1055
1131
  '- 问题卡 Problems/<id>.md:{ 标题, ID, 类型:问题, 状态:原始|求解中|等待依赖|已解决|死路, 优先级, 依赖:[], 被依赖:[], 来源:原始|后生, 计划(由调度器按规划代理的计划自动更新:一句话说明下一轮安排), ## 陈述(完整问题陈述,每个记号/对象都要完整定义), ## 来源与动机(后生问题:产生流程/动机/如何回填主线), ## 解法候选(### 解法 N|标题|概率X|状态Y + 叙述式完整解法)}。\n' +
1056
- '- 命题卡 Propos/<分类>/<id>.md:{ 标题, ID, 类型:命题, 状态:未定论|已验证·真|已验证·假, 概率, 优先级, 依赖:[], ## 陈述(完整), ## 证明尝试(### 证明 N|…|概率X|状态Y), ## 证伪尝试(### 证伪 N|…|概率X|状态Y)}。\n' +
1132
+ '- 命题卡 Propos/<分类>/<id>.md:{ 标题, ID, 类型:命题, 状态:未定论|已验证·真|已验证·假, 概率, 优先级, 依赖:[], 价值/关键性, 来源问题, 来源方向, ## 陈述(完整), ## 证明尝试(### 证明 N|…|概率X|状态Y), ## 证伪尝试(### 证伪 N|…|概率X|状态Y)}。\n' +
1133
+ '- `价值/关键性 ∈ [0,1]`(M1):这条命题对**整个项目主线**有多关键——若它成立/被证伪,能改变多少后续方向。≥ promoteValueThreshold(默认 0.7)且仍未定论(概率∈(0,1))的命题会被自动晋升成「判断下述命题是否成立:…」问题,由求解器专门证明/证伪并把结果回写源命题。你不填就默认 0.5(永不晋升)。**只有你(写卡的代理)能设这个值**——请在你认为"这条引理/猜想值得单独立项"时显式写上。\n' +
1057
1134
  '- 证明/证伪尝试语义:`## 证明尝试`=为证实而写的论证;`## 证伪尝试`=专门反驳/反例的论证。**失败的"找反例未果"/sanity check 是支持性证据,不属于证伪尝试**;不要写入 `## 证伪尝试`(否则系统会当作待验证的反驳去验证)。对仍未完成的证明/证伪,明确标注缺口而非伪装完成。\n' +
1058
1135
  '- 方法卡 Methods/<id>.md:{ 标题, ID, 类型:方法, 状态:经验|应用验证|含已验证断言, 可信断言:[](只允许已进 Verified/ 的 ID), 上级体系/子方法/相关, 适用场景, ## 核心内容, ## 定义与记号, ## 应用记录, ## 改进历史 }。\n' +
1059
1136
  '- 收口规则:某个解法/证明/证伪 概率=1 → 问题已解决 / 命题已验证(状态/概率锚点由调度器改写)。\n'
@@ -1079,7 +1156,7 @@ export function apply(ctx) {
1079
1156
  function kcSolverFiles() {
1080
1157
  return '你的归属文件:\n' +
1081
1158
  '- 求解器:把该方向的完整叙述(本轮进展/子路线/可行性信号/教训/完整解法文本)写进 `Progress/<问题id>/<方向id>.md`;聚合索引 `Progress/<问题id>.md` 由调度器维护,不要动它。\n' +
1082
- '- 新引理:写一张完整命题卡到 `Propos/<分类>/<p-id>.md`,含锚点 `- 标题:`、`- ID/类型/状态/概率/优先级` 与 `## 陈述`;证明写进 `### 证明 1|标题|概率X|状态Y` 段落(完整证明文本是验证必需,否则验证器只能验裸命题)。\n'
1159
+ '- 新引理:写一张完整命题卡到 `Propos/<分类>/<p-id>.md`,含锚点 `- 标题:`、`- ID/类型/状态/概率/优先级/价值关键性` 与 `## 陈述`;证明写进 `### 证明 1|标题|概率X|状态Y` 段落(完整证明文本是验证必需,否则验证器只能验裸命题)。`- 价值/关键性: <0..1>` 表示这条引理对项目主线的关键程度(≥ 阈值会被自动晋升为独立问题),别省。\n'
1083
1160
  }
1084
1161
  // 方法整理代理专属:方法卡
1085
1162
  function kcKeeperFiles() {
@@ -1207,7 +1284,7 @@ export function apply(ctx) {
1207
1284
  head += '\nLEMMA RULES: every lemma you register MUST carry a complete proof in `lemmas[].proof` (and in the card\'s `## 证明尝试`). If a claim is only partly argued, do NOT register it as a finished lemma — either prove it fully or record it as an explicit gap/conjecture stating the missing step, so the verifier knows exactly what is (and is not) being claimed. Incomplete "lemmas" waste verification and can mislead.\n'
1208
1285
  head += '\nOUTPUT CONTRACT — pick ONE channel. Write content into Markdown; only lightweight scheduling metadata (and verification-required proofs) cross the machine reply.\n' +
1209
1286
  'CHANNEL A (recommended, you can write files): write the full round narrative into `Progress/' + q.id + '/' + dir.id + '.md` and each new lemma card into `Propos/<分类>/<id>.md`, then reply ONLY this metadata object:\n' +
1210
- '{"meta":{"kind":"solver","qid":"' + q.id + '","dirId":"' + dir.id + '","round":' + round + ',"survival":0.5,"status":"continue|success|dead-end","dead_end_reason":"... or null","lemmas":[{"id":"p-...","title":"...","statement":"...","proof":"<完整证明文本,供验证器核验>","prob":0.6,"分类":"<引理卡目录名,必须与你要写入的 Propos/<分类>/ 目录严格一致>","优先级":1}],"methods_used":[{"id":"m-...","效果":"...","建议":"..."}],"new_inventions":[{"类型":"...","标题":"...","内容描述":"...","是否已入库":false}],"solution_prob":0.85,"solution_text":"<完整解法文本,或 null>","sub_questions":[{"q_sub_title":"...","q_sub_statement":"完整问题陈述(含所有对象/定义)","assumption_title":"p_{q-tmp} 标题","assumption_statement":"完整假设陈述(含所有定义)"}]}}\n' +
1287
+ '{"meta":{"kind":"solver","qid":"' + q.id + '","dirId":"' + dir.id + '","round":' + round + ',"survival":0.5,"status":"continue|success|dead-end","dead_end_reason":"... or null","lemmas":[{"id":"p-...","title":"...","statement":"...","proof":"<完整证明文本,供验证器核验>","prob":0.6,"价值/关键性":0.5,"分类":"<引理卡目录名,必须与你要写入的 Propos/<分类>/ 目录严格一致>","优先级":1}],"methods_used":[{"id":"m-...","效果":"...","建议":"..."}],"new_inventions":[{"类型":"...","标题":"...","内容描述":"...","是否已入库":false}],"solution_prob":0.85,"solution_text":"<完整解法文本,或 null>","sub_questions":[{"q_sub_title":"...","q_sub_statement":"完整问题陈述(含所有对象/定义)","assumption_title":"p_{q-tmp} 标题","assumption_statement":"完整假设陈述(含所有定义)"}]}}\n' +
1211
1288
  'CHANNEL B (your file tools are unavailable): put the content you would have written into __writes and carry the same meta:\n' +
1212
1289
  '{"__writes":[{"path":"Progress/' + q.id + '/' + dir.id + '.md","content":"<完整本轮叙述>"}],"meta":{"kind":"solver","qid":"' + q.id + '","dirId":"' + dir.id + '",...同上 meta 字段...}}\n' +
1213
1290
  '区分规则:methods_used 只能填**已存在的方法卡 ID**(m-…,来自 AVAILABLE METHODS 列表)——引用你自己刚想出的新方法/新技巧不属于 methods_used,请如实填入 new_inventions(它会由 Method Keeper 蒸馏建卡);不要把方法名/标题当 id 填进 methods_used。'
@@ -1281,7 +1358,24 @@ export function apply(ctx) {
1281
1358
  }
1282
1359
 
1283
1360
  // ================= decisions (manual/auto) =================
1284
- function enqueueDecision(node, contextText, data) { const d = { id: uuid(), node: node, context: contextText, data: data, status: 'pending', resolution: null, createdAt: now() }; decisionQueue.push(d); return d }
1361
+ // 已结清决策的保留条数(审计 L1):decisionQueue 以前只把 status 置 resolved、永不裁剪,
1362
+ // 且整表落盘到 State/decision_queue.json,长跑进程里它是第二处无界增长。保留最近 N 条已结清记录
1363
+ // 作为审计轨迹(list_decisions 只列 pending,所以裁剪不影响任何读取路径),pending 一律不裁。
1364
+ const RESOLVED_DECISION_KEEP = 50
1365
+ function pruneDecisionQueue() {
1366
+ let resolved = 0
1367
+ for (let i = 0; i < decisionQueue.length; i++) if (decisionQueue[i] && decisionQueue[i].status !== 'pending') resolved++
1368
+ let drop = resolved - RESOLVED_DECISION_KEEP
1369
+ if (drop <= 0) return
1370
+ const kept = []
1371
+ for (let i = 0; i < decisionQueue.length; i++) {
1372
+ const d = decisionQueue[i]
1373
+ if (d && d.status !== 'pending' && drop > 0) { drop--; continue }
1374
+ kept.push(d)
1375
+ }
1376
+ decisionQueue = kept
1377
+ }
1378
+ function enqueueDecision(node, contextText, data) { const d = { id: uuid(), node: node, context: contextText, data: data, status: 'pending', resolution: null, createdAt: now() }; decisionQueue.push(d); pruneDecisionQueue(); return d }
1285
1379
  async function maybeGate(node, contextText, data, autoFn) { if (params.mode === 'auto') return await autoFn(data); const d = enqueueDecision(node, contextText, data); setGate(d, node); logActivity('gate', node + ': ' + contextText); await saveAll(); return { gated: true, decisionId: d.id } }
1286
1380
  /**
1287
1381
  * 设置唯一闸门,并把被它取代的旧决策**立即结清**。
@@ -1372,6 +1466,8 @@ export function apply(ctx) {
1372
1466
  await reconcileVerify()
1373
1467
  await executePlanQueue()
1374
1468
  await maybePlan()
1469
+ // 方法整理排在终止判定之前:到期的方法整理是**真实工作**,先把它派出去,
1470
+ // 再让 checkTermination 看到"有在途的 method-keeper"从而不误判停机(H1 修复的配套)。
1375
1471
  await maybeMethodKeepFallback()
1376
1472
  await maybePushReport(false)
1377
1473
  await maybeWriteReport(false)
@@ -1570,7 +1666,9 @@ export function apply(ctx) {
1570
1666
  }
1571
1667
  function verifyTaskBusy(rId) { if (tasks['verify:' + rId]) return true; return Object.keys(agentRegistry).some(function (cid) { const m = agentRegistry[cid]; return m && m.role === 'verifier' && m.rId === rId }) }
1572
1668
  async function createVerifyTask(c) {
1573
- const rId = c.rId
1669
+ // M9:验证 id 由对象 id 派生(`r-<对象id>[-sN]`),对象键已归一化 ⇒ rId 也归一化,
1670
+ // 否则原始 id 里的路径分隔符会被拼进 Logs/Verification/<rId>_<ts>.json 造出子目录。
1671
+ const rId = idSafe(c.rId)
1574
1672
  if (verifyTaskBusy(rId)) return false
1575
1673
  // require 门禁的**防空转**(契约 §2「对象留在原库,可形式化后再次提议」):已经被记为
1576
1674
  // 「形式化待办」、而形式化又还没补齐的对象不再重复表决——重复表决只会一次又一次被同一道
@@ -1657,15 +1755,18 @@ export function apply(ctx) {
1657
1755
  // 规划代理在途时不再重复调用
1658
1756
  const plannerInFlight = Object.keys(agentRegistry).some(function (cid) { const m = agentRegistry[cid]; return m && m.role === 'planner' })
1659
1757
  if (plannerInFlight) return
1660
- // 有"可调度工作"= 有待解问题方向可推进 或 有验证候选 或 有待沉淀发明 或 有待执行计划。
1661
- // 修复:仅剩验证候选(如所有问题已解决但 Propos 里仍有未验证命题/解法)时也必须触发规划,
1662
- // 否则 planner 永远不会被调用、验证永不进行。
1758
+ // 有"可调度工作"= 有待解问题方向可推进 或 有验证候选 或 有**到期**的方法整理 或 有待执行计划。
1759
+ // H1 修复:这里必须用真正的到期判定 methodKeepDue(),而不是"待沉淀发明非空"。
1760
+ // 旧条件在 pendingInventions ∈ [1, methodKeepEvery-1] 时恒真,于是**没有任何工作**的每一
1761
+ // 个 tick 都派生一个 planner:空计划不留痕(lastPlanSummary 不写)、冷却又被"无在途工作"
1762
+ // 绕过、终止判定又被刚派出的 planner 挡掉 ⇒ 按 tick 计费的 planner 热循环永不终止。
1763
+ // 待沉淀发明到期时由 maybeMethodKeepFallback() 处理,不需要(也不应该)叫 planner。
1663
1764
  const verifyWork = (await buildVerifyCandidates()).length > 0
1664
- if (!hasSchedulableWork() && !verifyWork && methodLog.pendingInventions.length === 0 && planQueue.length === 0) { await maybeMethodKeepFallback(); return }
1665
- // 冷却:系统空闲且有工作时忽略冷却(避免 30s 空转)
1765
+ if (!hasSchedulableWork() && !verifyWork && !methodKeepDue() && planQueue.length === 0) return
1766
+ // 冷却对**每一次**规划调用生效(含空计划):空计划同样是一次真实的模型调用,必须退避。
1767
+ // 旧代码用 hasInflight 跳过冷却,本意是"空闲且有工作时不空转",但那恰好是 H1 的放大器。
1666
1768
  const cooldown = Number(params.planMinIntervalMs) || 0
1667
- const hasInflight = Object.keys(agentRegistry).length > 0 || planQueue.length > 0
1668
- if (cooldown > 0 && hasInflight && (now() - lastPlanAt) < cooldown) return
1769
+ if (cooldown > 0 && (now() - lastPlanAt) < cooldown) return
1669
1770
  await callPlanner()
1670
1771
  }
1671
1772
  async function callPlanner() {
@@ -1742,7 +1843,7 @@ export function apply(ctx) {
1742
1843
  if (act === 'spawn') {
1743
1844
  const role = String(a.role || '')
1744
1845
  if (role === 'explorer') {
1745
- const q = problems.get(String(a.target || ''))
1846
+ const q = problems.get(idSafe(String(a.target || ''))) // M9:问题键已归一化
1746
1847
  if (!q || q.状态 === '已解决' || q.优先级 === 'never') continue
1747
1848
  const dirs = getDirState(q.id)
1748
1849
  const allExhausted = dirs.length > 0 && dirs.every(function (d) { return d.status === 'dead-end' || d.status === 'success' })
@@ -1751,7 +1852,7 @@ export function apply(ctx) {
1751
1852
  if (busy) continue
1752
1853
  if ((explorerRetries[q.id] || 0) >= (Number(params.maxExplorerRetries) || 3)) continue
1753
1854
  } else if (role === 'solver') {
1754
- const q = problems.get(String(a.target || ''))
1855
+ const q = problems.get(idSafe(String(a.target || ''))) // M9:问题键已归一化
1755
1856
  if (!q || q.状态 === '已解决' || q.优先级 === 'never' || q.状态 === '等待依赖') continue
1756
1857
  const dir = (getDirState(q.id) || []).find(function (d) { return d.id === String(a.direction || '') })
1757
1858
  if (!dir || dir.status !== 'active') continue
@@ -1759,11 +1860,13 @@ export function apply(ctx) {
1759
1860
  const running = Object.keys(agentRegistry).some(function (cid) { const m = agentRegistry[cid]; return m && m.qid === q.id && m.direction === dir.id && m.role === 'solver' })
1760
1861
  if (running) continue
1761
1862
  } else if (role === 'verifier') {
1762
- const rId = String(a.target || '')
1863
+ const rId = idSafe(String(a.target || '')) // M9:与 createVerifyTask 同一把归一化
1763
1864
  if (!rId) continue
1764
1865
  if (verifyTaskBusy(rId)) continue
1765
1866
  const cands = await buildVerifyCandidates()
1766
- if (!cands.some(function (c) { return c.rId === rId })) continue
1867
+ if (!cands.some(function (c) { return idSafe(c.rId) === rId })) continue
1868
+ out.push({ action: 'spawn', role: role, target: rId, direction: a.direction || '', reason: String(a.reason || '') })
1869
+ continue
1767
1870
  } else if (role === 'method-keeper') {
1768
1871
  // 无目标;允许(由代码兜底去重:一次只允许一个 method-keeper)
1769
1872
  const running = Object.keys(agentRegistry).some(function (cid) { const m = agentRegistry[cid]; return m && m.role === 'method-keeper' })
@@ -1775,10 +1878,10 @@ export function apply(ctx) {
1775
1878
  if (!agentRegistry[cid]) continue
1776
1879
  out.push({ action: 'interrupt', childId: cid, reason: String(a.reason || '') })
1777
1880
  } else if (act === 'promote') {
1778
- const p = propos.get(String(a.target || ''))
1881
+ const p = propos.get(idSafe(String(a.target || ''))) // M9:命题键已归一化
1779
1882
  if (!p || p.概率 === 1 || p.概率 === 0 || p.优先级 === 'never' || p.在问题清单) continue
1780
1883
  if (Number(p.价值关键性) < Number(params.promoteValueThreshold)) continue
1781
- out.push({ action: 'promote', target: String(a.target || ''), reason: String(a.reason || '') })
1884
+ out.push({ action: 'promote', target: p.id, reason: String(a.reason || '') })
1782
1885
  } else if (act === 'wait' || act === 'continue' || act === 'stop') {
1783
1886
  out.push(Object.assign({ action: act }, a.childId ? { childId: String(a.childId) } : {}, a.target ? { target: String(a.target) } : {}, { reason: String(a.reason || '') }))
1784
1887
  }
@@ -1974,7 +2077,9 @@ export function apply(ctx) {
1974
2077
  if (parsed.lessons) dir.lessons = (dir.lessons || []).concat(parsed.lessons)
1975
2078
  if (parsed.blockers) dir.blockers = (dir.blockers || []).concat(parsed.blockers)
1976
2079
  if (parsed.dead_end_reason) dir.dead_end_reason = parsed.dead_end_reason
2080
+ // M11:`survival_probability`(遗留扁平名)与 `survival`(提示词名)都接受。
1977
2081
  if (typeof parsed.survival_probability === 'number') dir.survival = clamp01(parsed.survival_probability)
2082
+ else if (typeof parsed.survival === 'number') dir.survival = clamp01(parsed.survival)
1978
2083
  if (parsed.lemmas && parsed.lemmas.length) { for (let i = 0; i < parsed.lemmas.length; i++) { const lid = await addLemmaAsProposition(qid, parsed.lemmas[i]); if (lid) { dir.lemmas = dir.lemmas || []; dir.lemmas.push({ id: lid, title: parsed.lemmas[i].title || '' }) } } }
1979
2084
  if (parsed.sub_questions && parsed.sub_questions.length) { for (let i = 0; i < parsed.sub_questions.length; i++) { const sq = parsed.sub_questions[i]; if (sq && sq.q_sub_statement && dir.sub_questions && dir.sub_questions.some(function (x) { return x.statement === sq.q_sub_statement })) continue; const rec = await addSubQuestion(qid, dirId, sq); if (rec) { dir.sub_questions = dir.sub_questions || []; dir.sub_questions.push(rec) } } }
1980
2085
  // journal narrative (论文式续写:把本轮叙述追加进研究日志)
@@ -1988,11 +2093,16 @@ export function apply(ctx) {
1988
2093
  await consumeMethodFeedback(parsed, { qid: qid, dirId: dirId })
1989
2094
  }
1990
2095
  if (status === 'success') {
1991
- if (parsed && parsed.solution) {
2096
+ // M11:遗留扁平通道的字段名(solution / solution_probability)与提示词教的名字
2097
+ // (solution_text / solution_prob)不一致 ⇒ 严格按提示词回写的 solver 会掉进 else 分支,
2098
+ // 在轮次上限处被判成"claimed success without solution"并把方向写死。两套名字都接受。
2099
+ const flatSolution = (parsed && parsed.solution != null) ? parsed.solution : (parsed ? parsed.solution_text : undefined)
2100
+ const flatSolutionProb = (parsed && parsed.solution_probability != null) ? parsed.solution_probability : (parsed ? parsed.solution_prob : undefined)
2101
+ if (parsed && flatSolution) {
1992
2102
  dir.status = 'success'
1993
2103
  delete agentRegistry[childId]
1994
2104
  logActivity('solver', qid + '/' + dirId + ' success at round ' + meta.round)
1995
- await addSolution(qid, parsed.solution, parsed.solution_probability)
2105
+ await addSolution(qid, flatSolution, flatSolutionProb)
1996
2106
  } else {
1997
2107
  if (meta.round >= params.solverMaxRounds) {
1998
2108
  dir.status = 'dead-end'; dir.dead_end_reason = dir.dead_end_reason || 'claimed success without solution at iteration cap'
@@ -2031,12 +2141,14 @@ export function apply(ctx) {
2031
2141
  function statusFromStop(stopReason) { return (stopReason === 'completed' || stopReason === 'max-tokens') ? 'continue' : 'dead-end' }
2032
2142
  async function addLemmaAsProposition(qid, lemma) {
2033
2143
  if (!lemma || !lemma.title) return
2034
- let be = clamp01(lemma.布尔估计 != null ? lemma.布尔估计 : 0.6)
2144
+ // M11:`prob`(提示词名)与 `布尔估计`(遗留扁平名)都接受;`分类` 与 `细类型` 同理。
2145
+ const rawProb = lemmaProb(lemma)
2146
+ let be = clamp01(rawProb != null ? rawProb : 0.6)
2035
2147
  if (be >= 1) be = 0.99; else if (be <= 0) be = 0.01
2036
2148
  const p = {
2037
2149
  id: 'p-' + shortId(), 标题: lemma.title, 状态: '未定论', 概率: be,
2038
2150
  优先级: (lemma.优先级 != null) ? lemma.优先级 : 1, 依赖: [], 价值关键性: clamp01(lemma['价值/关键性'] != null ? lemma['价值/关键性'] : 0.5),
2039
- 分类: categoryOf({ 分类: Object.keys(lemma.细类型 || { 未分类: {} })[0] || '未分类' }),
2151
+ 分类: categoryOf({ 分类: lemmaCategory(lemma) || '未分类' }),
2040
2152
  陈述: lemma.statement || lemma.title,
2041
2153
  proofs: [{ title: lemma.title + '(证明)', prob: clamp01(0.7), status: '未定论', text: lemma.proof || '' }],
2042
2154
  refutes: [], 来源问题: qid, 在问题清单: false,
@@ -2101,21 +2213,23 @@ export function apply(ctx) {
2101
2213
  if (Array.isArray(parsed.methods_used)) {
2102
2214
  for (const mu of parsed.methods_used) {
2103
2215
  if (!mu || !mu.id) continue
2104
- const m = methods.get(mu.id) || globalMethods.get(mu.id)
2216
+ // M9:方法键一律是 idSafe(id),代理给的是原始 id ⇒ 查表前归一化。
2217
+ const muId = idSafe(mu.id)
2218
+ const m = methods.get(muId) || globalMethods.get(muId)
2105
2219
  if (!m) {
2106
2220
  // 未知 id:solver 引用了一个尚未入卡的方法/技巧 → 作为待沉淀发明记录,防引用丢失(Method Keeper 将据此建卡)
2107
- methodLog.pendingInventions.push({ at: now(), 来源: ctx.qid ? ('问题 ' + ctx.qid + (ctx.dirId ? ' 方向 ' + ctx.dirId : '')) : '', 类型: '方法', 标题: String(mu.id), 内容描述: (mu.效果 || '') + (mu.建议 ? ';建议:' + mu.建议 : '') })
2108
- logActivity('method', 'methods_used referenced unknown method ' + mu.id + ' → queued as pending invention')
2221
+ methodLog.pendingInventions.push({ at: now(), 来源: ctx.qid ? ('问题 ' + ctx.qid + (ctx.dirId ? ' 方向 ' + ctx.dirId : '')) : '', 类型: '方法', 标题: String(muId), 内容描述: (mu.效果 || '') + (mu.建议 ? ';建议:' + mu.建议 : '') })
2222
+ logActivity('method', 'methods_used referenced unknown method ' + muId + ' → queued as pending invention')
2109
2223
  continue
2110
2224
  }
2111
- if (methods.has(mu.id)) {
2225
+ if (methods.has(muId)) {
2112
2226
  // 只记录"有实际问题/方向上下文"的引用;method-keeper 纯整理时的引用(qid/dirId 皆空)不当作应用,
2113
2227
  // 避免把"整理时引用该方法"误记为"实际应用",从而污染应用计数并触发错误的全局晋升。
2114
2228
  if (ctx.dirId || ctx.qid) {
2115
2229
  m.applications = m.applications || []
2116
2230
  m.applications.push({ at: fmtTime(), 问题: ctx.qid || '', 方向: ctx.dirId || '', text: (mu.效果 || '') + (mu.建议 ? ';建议:' + mu.建议 : '') })
2117
2231
  await saveMethod(m, false)
2118
- logActivity('method', 'application record appended to ' + mu.id + ' (问题 ' + ctx.qid + ' 方向 ' + ctx.dirId + ')')
2232
+ logActivity('method', 'application record appended to ' + muId + ' (问题 ' + ctx.qid + ' 方向 ' + ctx.dirId + ')')
2119
2233
  }
2120
2234
  }
2121
2235
  }
@@ -2871,7 +2985,15 @@ export function apply(ctx) {
2871
2985
  const id = formalId(target)
2872
2986
  if (!id) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'defect 记录必须写明 target' }
2873
2987
  const prev = formalOf(id)
2874
- const archived = String(prev.proof || '').trim() || ('Verified/Lean/' + id + '.lean')
2988
+ // 撤回目标(审计 L2):只在**确实有过归档证明**时才撤回。此前 prev.proof 为空时也会拿
2989
+ // 'Verified/Lean/<id>.lean' 去撤,而 withdrawArchivedProof 在文件删不掉/不存在时会**就地覆盖写**
2990
+ // ——于是一个从未有过证明的对象会在"所有人都来这里找证明"的目录里凭空多出一份撤回声明文件。
2991
+ let archived = String(prev.proof || '').trim()
2992
+ if (!archived) {
2993
+ const fallback = 'Verified/Lean/' + id + '.lean'
2994
+ const abs = leanAbsPath(fallback)
2995
+ if (abs !== null && (await readTextAbs(abs)) !== undefined) archived = fallback
2996
+ }
2875
2997
  await putFormal(id, Object.assign({}, prev, {
2876
2998
  status: 'attempted',
2877
2999
  decision: 'defect',
@@ -2880,7 +3002,7 @@ export function apply(ctx) {
2880
3002
  updatedAt: now(),
2881
3003
  }))
2882
3004
  // 归档证明必须消失,否则 Verified/Lean/ 里会留下一份"看起来已通过"的不忠实代码。
2883
- const withdrawn = await withdrawArchivedProof(archived)
3005
+ const withdrawn = archived ? await withdrawArchivedProof(archived) : 'none'
2884
3006
  // 待办条目按 id 去重、就地刷新:note 进入 TODO.md 的 why 列(require 档据此搁置定论)。
2885
3007
  const list = formalTodo()
2886
3008
  const i = list.findIndex(function (x) { return x && x.id === id })
@@ -2892,12 +3014,13 @@ export function apply(ctx) {
2892
3014
  await rebuildLeanLibIndexes()
2893
3015
  await saveAll()
2894
3016
  // 如实说出**哪一种**撤回发生了:删除成功 / 就地覆盖 / 两者都失败(后者必须让人手动处理,
2895
- // 否则一份不忠实的代码会静静留在"证明"的路径上而无人知晓)。
3017
+ // 否则一份不忠实的代码会静静留在"证明"的路径上而无人知晓)。'none' = 本来就没有归档证明。
2896
3018
  await formalAnnounce('【形式化】' + who + ' 认定 ' + id + ' 的 Lean 形式化存在**忠实性缺陷**:' + note
2897
3019
  + '。这不是"命题为假",而是**形式化不合格**:已撤回其「已通过」状态(降级为 attempted)、'
2898
- + (withdrawn === 'deleted' ? '删除归档证明 ' + archived
2899
- : withdrawn === 'overwritten' ? '归档证明 ' + archived + ' 无法删除(宿主不支持删除),已**就地覆盖为撤回声明**'
2900
- : '归档证明 ' + archived + ' **未能撤回**(宿主删除与覆盖均失败,请手动删除,不要把它当作该对象的证明)')
3020
+ + (withdrawn === 'none' ? '该对象此前没有任何归档证明,无需撤回'
3021
+ : withdrawn === 'deleted' ? '删除归档证明 ' + archived
3022
+ : withdrawn === 'overwritten' ? '归档证明 ' + archived + ' 无法删除(宿主不支持删除),已**就地覆盖为撤回声明**'
3023
+ : '归档证明 ' + archived + ' **未能撤回**(宿主删除与覆盖均失败,请手动删除,不要把它当作该对象的证明)')
2901
3024
  + '、写入 Formal/TODO.md;'
2902
3025
  + (formalMode() === 'require'
2903
3026
  ? '本次裁定**不定论**,修正形式化并重新跑通(vibe_math_lean_archive kind=\'proof\')后再投票。'
@@ -2952,6 +3075,26 @@ export function apply(ctx) {
2952
3075
  }
2953
3076
 
2954
3077
  // ================= verification (验证器) =================
3078
+ /**
3079
+ * 一个判决对象**至少**需要几票。
3080
+ *
3081
+ * H5:此前没有任何下限——`maxParallelThreshold=1` 时 backfillVerifiers 只派出 1 个验证器,
3082
+ * 它一结束 `allReported`(只看"已存在的孩子是否都报了")即为真,`consensus` 对单元素数组
3083
+ * 恒真,于是跳过全部辩论直接 `finalVerdict → 1 → settleVerdict`,写出 `概率:1/已验证·真`
3084
+ * 并落 `Verified/`。`Verified/` 是所有提示词的"绝对可信"层,一票定论 = 一个验证器的偏见
3085
+ * 直接变成下游全部代理的"已知事实"。
3086
+ *
3087
+ * 下限取 max(2, expectedCount):verifierCount=1 不是"允许单票",只是"至少 2 个独立验证器"
3088
+ * (与 createVerifyTask 的 `Math.max(2, ...)` 一致)。
3089
+ */
3090
+ function minVotes(t) { return Math.max(2, Number(t && t.expectedCount) || 0, Number(params.verifierCount) || 0) }
3091
+ function haveEnoughVotes(t) { return Object.keys(t.childResults).length >= minVotes(t) }
3092
+ /** 所有已派出的验证器都报了本轮的票,**且**票数达到下限——否则不得推进/裁决。 */
3093
+ function allReportedWithQuorum(t, round) {
3094
+ if (!t || !Array.isArray(t.children) || t.children.length === 0) return false
3095
+ if (t.children.length < Math.max(2, Number(t.expectedCount) || 0)) return false
3096
+ return t.children.every(function (cid) { const r = t.childResults[cid]; return r && r.round === round })
3097
+ }
2955
3098
  function consensus(t) { const vs = Object.keys(t.childResults).map(function (cid) { return t.childResults[cid].Result }); if (vs.length === 0) return false; return vs.every(function (v) { return v === 1 }) || vs.every(function (v) { return v === 0 }) }
2956
3099
  function buildTranscript(t) { const parts = []; const cids = Object.keys(t.childResults); for (let i = 0; i < cids.length; i++) { const r = t.childResults[cids[i]]; parts.push('Reviewer ' + i + ': Result=' + r.Result + ' Reason=' + r.Reason) } return parts.join('\n') }
2957
3100
  async function handleVerifier(childId, meta, output, stopReason) {
@@ -2974,12 +3117,16 @@ export function apply(ctx) {
2974
3117
  if (t.children.indexOf(childId) === -1) t.children.push(childId)
2975
3118
  t.childResults[childId] = { Result: Result, Reason: Reason, round: meta.round }
2976
3119
  delete agentRegistry[childId]
2977
- const allReported = t.children.length > 0 && t.children.every(function (cid) { const r = t.childResults[cid]; return r && r.round === meta.round })
2978
- if (!allReported) { await saveAll(); return }
3120
+ // H5:推进/裁决的前置条件是"已凑齐下限票数且本轮都已回报",不是"目前存在的那几个孩子都报了"。
3121
+ // 未凑齐时保持 spawning,由 reconcileVerify 在后续 tick 里补派验证器(不裁决、不定论)。
3122
+ if (!allReportedWithQuorum(t, meta.round)) { await saveAll(); return }
2979
3123
  await advanceVerification(t, meta.round)
2980
3124
  await saveAll()
2981
3125
  }
2982
3126
  async function advanceVerification(t, round) {
3127
+ // H5 第二道闸门:票数不足下限时**不辩论、不裁决**,回到 spawning 等 reconcileVerify 补派。
3128
+ // 放在最前面:即便 handleVerifier 因为"孩子被删除"等边缘情形推进到这里,也绝不定论。
3129
+ if (!haveEnoughVotes(t)) { t.status = 'spawning'; return }
2983
3130
  if (round < params.debateMaxRounds && !consensus(t) && t.children.length > 0) {
2984
3131
  if (!scheduler.running) { t.status = 'paused'; return }
2985
3132
  if (activeCount() >= params.maxParallelThreshold) { t.status = 'paused'; return }
@@ -3008,6 +3155,8 @@ export function apply(ctx) {
3008
3155
  }
3009
3156
  }
3010
3157
  async function finalizeVerification(t) {
3158
+ // H5 最后一道闸门:票数不足下限时**决不**给出裁决(单票绝不结论)。
3159
+ if (!haveEnoughVotes(t)) { t.status = 'spawning'; return }
3011
3160
  const verdict = finalVerdict(t)
3012
3161
  if (params.mode === 'manual') {
3013
3162
  const d = enqueueDecision('verdict', 'verdict for ' + t.rId + ' (debate finished) = ' + verdict, { rId: t.rId, verdict: verdict, task: JSON.parse(JSON.stringify(t)) })
@@ -3033,16 +3182,14 @@ export function apply(ctx) {
3033
3182
  if (mean >= 0.85 || mean <= 0.15) return Math.max(0.01, Math.min(0.99, mean))
3034
3183
  }
3035
3184
  if (params.verdictMode === 'forced') {
3036
- let num = 0; let den = 0
3037
- const cids = Object.keys(t.childResults)
3038
- for (let i = 0; i < rs.length; i++) {
3039
- const acc = verifierAccuracy[cids[i]] || { correct: 0, total: 0 }
3040
- const accRate = acc.total > 0 ? (acc.correct / acc.total) : 0.5
3041
- const confident = (rs[i].Result === 1 || rs[i].Result === 0) ? 0.1 : 0
3042
- const w = Math.max(0.05, Math.min(0.95, accRate + confident))
3043
- num += w * rs[i].Result; den += w
3044
- }
3045
- return den > 0 ? Math.max(0.01, Math.min(0.99, num / den)) : 0.5
3185
+ // M8:这里**不再**按 verifierAccuracy 加权。那份统计量统计的是"这一票与该次裁决是否一致",
3186
+ // 而裁决本身就是同一批票的聚合——即"与自己一致"(注:v 为加权均值时 `Result === v` 几乎恒假,
3187
+ // 于是它其实把"从众度"记成 0),且没有任何后续真值(Lean 通过 / 被 defect 撤回 / 命题被反证)
3188
+ // 会纠正它。用这样一个量去加权,放大而非抑制系统性偏见,与"按历史准确率加权"的宣称相反。
3189
+ // 改为**等权**取均值:每票按自己报出的概率贡献,本身就是该验证器的贝叶斯式估计;
3190
+ // 严格的 1/0 是绝对投票,其影响已通过数值本身(拉向端点)自然体现,无需人为加权。
3191
+ let sum = 0; for (let i = 0; i < rs.length; i++) sum += rs[i].Result
3192
+ return Math.max(0.01, Math.min(0.99, sum / rs.length))
3046
3193
  }
3047
3194
  return 0.5
3048
3195
  }
@@ -3050,12 +3197,17 @@ export function apply(ctx) {
3050
3197
  const v = clamp01(verdict)
3051
3198
  const r = t.r
3052
3199
  const cids = Object.keys(t.childResults)
3200
+ // 票数记录保留(Logs/Verification 与 State/verifier_accuracy.json 是审计轨迹),但该统计量
3201
+ // **不再**参与任何加权(见 finalVerdict 的 M8 说明)。这里记的是"该票的实际概率"而非布尔值:
3202
+ // 原来的 `Result === v` 记法在 v 为加权均值时几乎不可能相等,等于把"从众度"记成 0,
3203
+ // 与字段名 correct/total 的含义相去更远。
3053
3204
  for (let i = 0; i < cids.length; i++) {
3054
3205
  const acc = verifierAccuracy[cids[i]] || { correct: 0, total: 0 }
3055
3206
  acc.total += 1
3056
- if (t.childResults[cids[i]].Result === v) acc.correct += 1
3207
+ if (Number(t.childResults[cids[i]].Result) === v) acc.correct += 1
3057
3208
  verifierAccuracy[cids[i]] = acc
3058
3209
  }
3210
+ pruneVerifierAccuracy()
3059
3211
  await writeJson('Logs/Verification/' + t.rId + '_' + Date.now() + '.json', { r: r, verdict: v, results: t.childResults, transcript: buildTranscript(t), history: t.history || [], at: now() })
3060
3212
  // ── require 门禁(契约 §8)────────────────────────────────────────────────
3061
3213
  // 判定为真(严格证明)或假(严格反驳)之前必须先有 `passed` 或 `blocked`。门禁放在**改
@@ -3152,13 +3304,493 @@ export function apply(ctx) {
3152
3304
  return best
3153
3305
  }
3154
3306
 
3307
+ // ==========================================================================================
3308
+ // 最终论文(规格:docs/final-paper.md)—— 会话侧(v3 数据来源:md 知识库)。
3309
+ //
3310
+ // 时序(修订 §A2/§A3):v3 的收口信号是 checkTermination() 的完整收口分支(无未解决问题、
3311
+ // `leftoverVerify` 为空——**这才是真实完整性判据**(§A4)、无真实在途代理、无任务/计划/挂起门)。
3312
+ // 撰写子代理必须在 `scheduler.running=false` 与 `releaseProjectLock()` **之前**派遣
3313
+ // (停止后 scheduleTick() 是空操作;提前释放项目锁后另一会话可能写同一棵树)。派不出去
3314
+ // (宿主激活上限 ACTIVATION_LIMIT_REACHED)→ 排队 + apply 级心跳重试 + 可见告警。
3315
+ // 落盘(§C/§D):md/tex 是文本;**paper.pdf 只能由编译器子进程生成**、插件只 stat 且**永不删除/覆盖**;
3316
+ // paper.meta.json 最后写(原子提交点);幂等 = run id + finalizedAt + 逐产物存在性(不用稳定哈希)。
3317
+ // ==========================================================================================
3318
+ let paperInFlight = '' // 正在撰写的 childId
3319
+ let paperInFlightAt = 0 // 派遣时刻(判定卡死窗口;force 与心跳都据它降级)
3320
+ let paperReaps = 0 // 本 run 内已回收卡死撰写者的次数(自动重派上限)
3321
+ const paperAbandoned = {} // childId -> true:已放弃的撰写者;它事后返回时输出被丢弃并留痕
3322
+ let paperPending = null // 激活上限排队重试:{ tries, retryAt, reason, limit, trigger, opts }
3323
+ const PAPER_RETRY_MS = 5000
3324
+ const PAPER_MAX_RETRIES = 12
3325
+ const PAPER_STALE_MS = 10 * 60 * 1000 // 撰写者卡死窗口:超过它,force 与心跳都会回收并重派(10 分钟)
3326
+ const PAPER_MAX_AUTO_REAPS = 2 // 心跳自动回收+重派的次数上限,之后交给 /vibe paper force
3327
+ const PAPER_LOCK_STALE_MS = 120000
3328
+ function activationLimitFrom(message) { const m = /active child limit:\s*(\d+)/.exec(String(message == null ? '' : message)); return m ? Number(m[1]) : undefined }
3329
+ function paperId(opts) { return paperDirId((opts && opts.id) || currentProject) }
3330
+ function paperDir(id) { return 'Paper/' + paperDirId(id) }
3331
+ function paperAbsDir(id) { return frameworkRoot() + '/' + paperDir(id) }
3332
+ function paperAuthorLine() { return 'Vibe Math V3(单作者:论文撰写子代理)· 项目 ' + currentProject }
3333
+ function paperOpts(opts) {
3334
+ const o = opts || {}
3335
+ const fmt = (o.format === 'md' || o.format === 'tex' || o.format === 'both') ? o.format : ((params.paperFormat === 'md' || params.paperFormat === 'tex') ? params.paperFormat : 'both')
3336
+ const lang = (o.lang === 'en' || o.lang === 'zh') ? o.lang : (params.paperLanguage === 'en' ? 'en' : 'zh')
3337
+ const compilePdf = (o.compilePdf === true || o.compilePdf === false) ? o.compilePdf : (params.paperCompilePdf !== false)
3338
+ return { format: fmt, lang: lang, compilePdf: compilePdf, force: o.force === true, id: o.id }
3339
+ }
3340
+ async function paperReadMeta(id) { const m = await readJson(paperDir(id) + '/paper.meta.json'); return (m && typeof m === 'object' && !Array.isArray(m)) ? m : undefined }
3341
+ async function paperPathExists(rel) { try { const t = await fsTarget(rel); return (await fs.stat(t)) !== undefined } catch (e) { return false } }
3342
+ async function paperAppendLog(id, tag, text) {
3343
+ const rel = paperDir(id) + '/paper.log.md'
3344
+ const prev = await readText(rel)
3345
+ const head = (prev === undefined || !String(prev).trim()) ? (paperLogHead(id).join('\n') + '\n') : String(prev)
3346
+ await writeText(rel, head + paperLogLine(now(), tag, text) + '\n')
3347
+ }
3348
+ /** paper 作用域锁(修订 §A3):v3 在收口时已释放项目锁,用它防止两个会话同时写同一棵 Paper/ 树。 */
3349
+ async function paperLockAcquire(id) {
3350
+ const rel = paperDir(id) + '/paper.lock.json'
3351
+ const cur = await readJson(rel)
3352
+ if (cur && cur.sessionId && cur.sessionId !== sessionId && (now() - Number(cur.at || 0)) < PAPER_LOCK_STALE_MS) return { ok: false, holder: String(cur.sessionId), at: Number(cur.at || 0) }
3353
+ await writeJson(rel, { sessionId: sessionId, at: now(), project: currentProject })
3354
+ return { ok: true }
3355
+ }
3356
+ async function paperLockRelease(id) {
3357
+ const rel = paperDir(id) + '/paper.lock.json'
3358
+ const cur = await readJson(rel)
3359
+ if (cur && cur.sessionId === sessionId) await writeJson(rel, { sessionId: '', at: now(), released: true })
3360
+ }
3361
+ /**
3362
+ * 回收卡死的撰写者(修订 §1:死掉的撰写者绝不能把论文永久卡住)。清状态 + 尽力中断 +
3363
+ * 从 agentRegistry 摘除(否则一直被算作活跃子代理)+ 记进 paperAbandoned(事后返回的输出被明确
3364
+ * 丢弃并留痕)+ 可见告警。
3365
+ */
3366
+ async function paperReapWriter(id, why) {
3367
+ const cid = paperInFlight
3368
+ paperInFlight = ''; paperInFlightAt = 0
3369
+ if (cid) {
3370
+ try { await interruptChild(cid) } catch (e) { /* best effort */ }
3371
+ delete agentRegistry[cid]
3372
+ paperAbandoned[cid] = true
3373
+ }
3374
+ await paperAppendLog(id, 'reap', '回收卡死的撰写者 ' + (cid || '(未知)') + ':' + why + '(论文不会被永久卡住;force/心跳会继续)')
3375
+ logActivity('paper', '回收卡死的论文撰写子代理 ' + (cid || '(未知)') + ':' + why)
3376
+ console.error('vibe-math-v3: reaped a stalled paper writer (' + (cid || 'unknown') + '): ' + why)
3377
+ reportDirty = true
3378
+ return cid
3379
+ }
3380
+ function paperWriterVerdictNow(force) {
3381
+ return paperWriterVerdict({ inFlight: !!paperInFlight, force: !!force, ageMs: now() - Number(paperInFlightAt || 0), staleMs: PAPER_STALE_MS, childInRegistry: paperInFlight ? (agentRegistry[paperInFlight] !== undefined) : true })
3382
+ }
3383
+ function paperOneLine(s, cap) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, cap || 400) }
3384
+ /** 证据索引(spec §3.9):只列**确实存在**的文件;排除 Paper/ 自己(避免自我污染)。
3385
+ * formalVerify=off 必须真的是无操作(formal-verify-v3 的语料守卫断言"off 的提示词里零 Lean 文本"),
3386
+ * 所以 off 档不把形式化路径写进材料。 */
3387
+ async function paperEvidenceIndex() {
3388
+ const formalNow = formalOn()
3389
+ const out = []
3390
+ if (formalNow && await paperPathExists('State/formal.json')) out.push('State/formal.json')
3391
+ if (await paperPathExists('State/index.json')) out.push('State/index.json')
3392
+ for (const p of allProblems()) out.push(problemRel(p))
3393
+ for (const p of allPropos()) out.push(propositionRel(p))
3394
+ for (const m of methods.values()) out.push(methodRel(m, false))
3395
+ const dirs = ['Verified/命题', 'Verified/问题', 'Logs/Verification', 'Logs/Plans', 'Progress'].concat(formalNow ? ['Verified/Lean', 'Formal'] : [])
3396
+ for (let i = 0; i < dirs.length; i++) { const files = await listFiles(dirs[i]); for (let j = 0; j < files.length; j++) out.push(dirs[i] + '/' + files[j]) }
3397
+ const seen = {}, uniq = []
3398
+ for (let i = 0; i < out.length; i++) { const x = String(out[i]); if (seen[x] || x.indexOf('Paper/') === 0) continue; seen[x] = true; uniq.push(x) }
3399
+ return uniq.sort()
3400
+ }
3401
+ /** 汇总材料(spec §4 + 修订 §A4):只含既有证据;未决项(含 leftoverVerify 的待验证对象)显式标注。 */
3402
+ async function buildPaperDigest() {
3403
+ const L = []
3404
+ L.push('PRESET: vibe-math-v3 (single-author)')
3405
+ L.push('PROJECT: ' + currentProject)
3406
+ const qs = allProblems()
3407
+ L.push('')
3408
+ L.push('[ORIGINAL PROBLEMS] (Problems/*.md)')
3409
+ if (qs.length === 0) L.push('- (none)')
3410
+ for (let i = 0; i < qs.length; i++) {
3411
+ const q = qs[i]
3412
+ L.push('- id=' + q.id + ' | 状态=' + q.状态 + ' | 优先级=' + q.优先级 + ' | 依赖=' + JSON.stringify(q.依赖 || []) + ' | 来源=' + (q.来源 || '原始') + ' | 陈述=' + paperOneLine(q.陈述, 800))
3413
+ const dirs = getDirState(q.id)
3414
+ if (dirs.length) L.push(' · 方向:' + dirs.map(function (d) { return d.id + ':' + d.status }).join(', '))
3415
+ const sols = q.solutions || []
3416
+ if (sols.length === 0) L.push(' · 解法:无')
3417
+ for (let j = 0; j < sols.length; j++) L.push(' · 解法#' + j + ' 概率=' + sols[j].prob + ' 状态=' + (sols[j].status || '') + ' 证据=Verified/问题/ 与 Logs/Verification/ | ' + paperOneLine(sols[j].text, 1200))
3418
+ }
3419
+ const ps = allPropos()
3420
+ L.push('')
3421
+ L.push('[PROPOSITIONS] (Propos/<分类>/<id>.md)')
3422
+ if (ps.length === 0) L.push('- (none)')
3423
+ for (let i = 0; i < ps.length; i++) {
3424
+ const p = ps[i]
3425
+ const rec = formalOn() ? formalOf(p.id) : null // off 档不把形式化字段写进材料(off 是真无操作)
3426
+ L.push('- id=' + p.id + ' | 概率=' + p.概率 + ' | 状态=' + p.状态 + ' | 优先级=' + p.优先级 + ' | 价值/关键性=' + p.价值关键性 + ' | 来源问题=' + (p.来源问题 || '') + (rec ? (' | 形式化=' + ((rec && rec.status) || 'none') + (rec && rec.proof ? ('(' + rec.proof + ')') : '')) : '') + ' | 陈述=' + paperOneLine(p.陈述, 800))
3427
+ const sides = [['证明', p.proofs || []], ['证伪', p.refutes || []]]
3428
+ for (let k = 0; k < sides.length; k++) { const arr = sides[k][1]; for (let j = 0; j < arr.length; j++) if (arr[j] && arr[j].prob === 1) L.push(' · 已检验通过:' + sides[k][0] + '#' + j + ' | ' + paperOneLine(arr[j].text, 900)) }
3429
+ }
3430
+ L.push('')
3431
+ L.push('[METHODS / ARTEFACTS] (Methods/ + 全局 VibeMath/Methods/)')
3432
+ const ms = Array.from(methods.values()).concat(Array.from(globalMethods.values()))
3433
+ if (ms.length === 0) L.push('- (none)')
3434
+ for (let i = 0; i < ms.length; i++) {
3435
+ const m = ms[i]
3436
+ L.push('- id=' + m.id + ' | 标题=' + m.标题 + ' | 类型=' + m.类型 + ' | 状态=' + m.状态 + ' | 可信断言=' + JSON.stringify(m.可信断言 || []) + ' | 应用次数=' + ((m.applications || []).length) + ' | 核心内容=' + paperOneLine(m.核心内容, 900))
3437
+ }
3438
+ // §A4:v3 的真实完整性判据是 leftoverVerify(buildVerifyCandidates)——把仍未验证的对象列出来。
3439
+ let cands = []
3440
+ try { cands = await buildVerifyCandidates() } catch (e) { /* 尽力而为 */ }
3441
+ L.push('')
3442
+ L.push('[STILL UNVERIFIED — v3 的完整性判据 leftoverVerify(buildVerifyCandidates);必须标注为未决]')
3443
+ if (cands.length === 0) L.push('- (none — 没有待验证对象)')
3444
+ for (let i = 0; i < cands.length; i++) L.push('- 未决:' + cands[i].rId + '(' + cands[i].kind + ',' + paperOneLine(cands[i].概述, 200) + (cands[i].prob === undefined ? '' : (',prob=' + cands[i].prob)) + ')')
3445
+ L.push('')
3446
+ L.push('[UNRESOLVED / REFUTED — 论文里必须显式标注,不得当成已成立的结论]')
3447
+ let any = false
3448
+ for (let i = 0; i < ps.length; i++) {
3449
+ if (!(ps[i].概率 === 1 || ps[i].概率 === 0)) { any = true; L.push('- 未定论:命题 ' + ps[i].id + '(概率=' + ps[i].概率 + ')') }
3450
+ else if (ps[i].概率 === 0) L.push('- 已被否证:命题 ' + ps[i].id)
3451
+ }
3452
+ for (let i = 0; i < qs.length; i++) {
3453
+ const sols = qs[i].solutions || []
3454
+ for (let j = 0; j < sols.length; j++) if (!(sols[j].prob === 1 || sols[j].prob === 0)) { any = true; L.push('- 未定论:问题 ' + qs[i].id + ' 的解法#' + j + '(概率=' + sols[j].prob + ')') }
3455
+ }
3456
+ if (!any && cands.length === 0) L.push('- (none — 所有对象均已定论)')
3457
+ L.push('')
3458
+ L.push('[EVIDENCE INDEX] (only files that exist)')
3459
+ const ev = await paperEvidenceIndex()
3460
+ for (let i = 0; i < ev.length; i++) L.push('- ' + ev[i])
3461
+ return L.join('\n')
3462
+ }
3463
+ /** 「论文撰写」子代理提示词(单作者;与 v2 同一形状/同一骨架)。 */
3464
+ function paperWriterPrompt(digest, o) {
3465
+ const langName = o.lang === 'en' ? 'English' : '中文(Chinese)'
3466
+ return 'You are the DEDICATED PAPER WRITER (single-author mode) of a math research run that has just CONVERGED.\n' +
3467
+ 'Write its final paper in ' + langName + ', using ONLY the evidence in the MATERIAL section below.\n\n' +
3468
+ 'HARD RULES:\n' +
3469
+ '- NEVER invent content: no new proposition, no new computation, no citation that is not in the MATERIAL.\n' +
3470
+ '- Unresolved or refuted items MUST be explicitly labelled (「未定论」/「已被否证」, or "unresolved"/"refuted" in English); never present them as established.\n' +
3471
+ '- Fixed 9-section skeleton — provide bodies for these EXACT `## ` headings (the framework writes the headings, author/date and the evidence index itself):\n' +
3472
+ PAPER_SKELETON.map(function (s, i) { return ' ' + (i + 1) + '. ' + s.key + ' — ' + s.spec }).join('\n') + '\n' +
3473
+ '- A section with no evidence must be exactly 「' + PAPER_NO_EVIDENCE + '」 (do not pad it).\n' +
3474
+ '- Markdown subset only: `#`/`##`/`###`, `- ` lists, `**bold**`, `*em*`, `` `code` ``, and inline math as `$...$`. No tables, images, footnotes or raw HTML.\n\n' +
3475
+ 'OUTPUT CONTRACT — respond with ONLY one ```json code fence, no prose:\n' +
3476
+ '{"title":"<paper title>","abstract":"<original problem + main results>","sections":[{"name":"<one of the 9 headings>","body":"<markdown>"}, ...]}\n\n' +
3477
+ 'MATERIAL (evidence only — do not add anything beyond it):\n' + digest
3478
+ }
3479
+ /** LaTeX 检测(spec §5 + 修订 §D):paperLatexCommand 非空时只用它;否则按语言顺序探测。 */
3480
+ async function paperDetectLatex(lang) {
3481
+ const sub = subprocessOf()
3482
+ if (sub === undefined || typeof sub.spawn !== 'function') return { available: [], reason: 'no-subprocess' }
3483
+ if (typeof sub.resolveExecutable !== 'function') return { available: [], reason: 'no-resolveExecutable' }
3484
+ const forced = String(params.paperLatexCommand || '').trim()
3485
+ if (forced) {
3486
+ try { const p = await sub.resolveExecutable(forced); return p ? { available: [{ name: forced, path: String(p) }], reason: 'ok', forced: true } : { available: [], reason: 'paperLatexCommand not found: ' + forced, forced: true } }
3487
+ catch (e) { return { available: [], reason: 'paperLatexCommand not found: ' + forced, forced: true } }
3488
+ }
3489
+ const order = lang === 'en' ? PAPER_ENGINE_ORDER_EN : PAPER_ENGINE_ORDER_ZH
3490
+ const available = []
3491
+ for (let i = 0; i < order.length; i++) {
3492
+ try { const p = await sub.resolveExecutable(order[i]); if (p) available.push({ name: order[i], path: String(p) }) } catch (e) { /* 未安装 */ }
3493
+ }
3494
+ return { available: available, reason: available.length ? 'ok' : 'none' }
3495
+ }
3496
+ function paperArgvFor(engine) {
3497
+ if (engine.name === 'latexmk') return [engine.path, '-pdf', '-interaction=nonstopmode', '-halt-on-error', 'paper.tex']
3498
+ if (engine.name === 'tectonic') return [engine.path, '--keep-logs', 'paper.tex']
3499
+ return [engine.path, '-interaction=nonstopmode', '-halt-on-error', 'paper.tex']
3500
+ }
3501
+ /** 一次编译(nonstopmode),带主动超时 terminate。 */
3502
+ async function paperRunOnce(engine, dirAbs, capMs) {
3503
+ const sub = subprocessOf()
3504
+ if (sub === undefined || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, code: 'NO_SUBPROCESS' }
3505
+ 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 }) }
3507
+ catch (e) { return { ok: false, exitCode: null, code: 'SPAWN_FAILED', message: String((e && e.message) || e) } }
3508
+ let timedOut = false, timer = null, outcome
3509
+ try {
3510
+ outcome = await Promise.race([
3511
+ handle.done,
3512
+ new Promise(function (resolve) { timer = setTimeout(function () { timedOut = true; try { if (handle && typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* best effort */ } resolve({ exitCode: null }) }, capMs) }),
3513
+ ])
3514
+ } catch (e) { if (timer !== null) clearTimeout(timer); return { ok: false, exitCode: null, code: 'RUN_FAILED', message: String((e && e.message) || e) } }
3515
+ if (timer !== null) clearTimeout(timer)
3516
+ let err = ''
3517
+ try { if (handle.collected && handle.collected.stderr) err = String(handle.collected.stderr.readFrom(0).text || '') } catch (e) { /* best effort */ }
3518
+ return { ok: !timedOut && !!outcome && outcome.exitCode === 0, exitCode: outcome ? outcome.exitCode : null, timedOut: timedOut, stderr: err.slice(-1200) }
3519
+ }
3520
+ /**
3521
+ * 编译(spec §5 + 修订 §D/§E):主引擎(nonstopmode,两遍)→ 换引擎 → 去不支持宏包 → 最小模板再试一次;
3522
+ * 然后报告并降级。**永不删除/覆盖已有 pdf**:成功判据 = 本次运行 exit 0 **且** pdf 存在。
3523
+ */
3524
+ async function paperCompile(id, o, texPrimary, bodyTex) {
3525
+ const relDir = paperDir(id), absDir = paperAbsDir(id)
3526
+ const attempts = []
3527
+ if (!o.compilePdf) return { compile: 'skipped', reason: 'paperCompilePdf=false', engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
3528
+ if (o.format === 'md') return { compile: 'skipped', reason: 'paperFormat=md(不产出 tex,跳过编译)', engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
3529
+ const det = await paperDetectLatex(o.lang)
3530
+ if (det.available.length === 0) {
3531
+ await paperAppendLog(id, 'latex', '未检测到任何 LaTeX 引擎(' + det.reason + ')→ 只保留 paper.tex + paper.md,不编译 pdf(spec §5:不阻塞定稿)')
3532
+ return { compile: 'not-detected', reason: det.reason, engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
3533
+ }
3534
+ await paperAppendLog(id, 'latex', '检测到引擎 ' + det.available.map(function (x) { return x.name }).join(', ') + (det.forced ? '(paperLatexCommand 指定)' : ('(顺序 ' + (o.lang === 'en' ? PAPER_ENGINE_ORDER_EN : PAPER_ENGINE_ORDER_ZH).join(' > ') + ')')))
3535
+ const plan = [{ engine: det.available[0], tex: texPrimary, mode: 'primary' }]
3536
+ if (det.available[1]) plan.push({ engine: det.available[1], tex: texPrimary, mode: 'engine-fallback' })
3537
+ plan.push({ engine: det.available[0], tex: paperSanitizeTex(texPrimary), mode: 'drop-unsupported-packages' })
3538
+ plan.push({ engine: det.available[0], tex: paperMinimalTexDoc({ lang: o.lang, bodyTex: bodyTex }), mode: 'minimal-template' })
3539
+ const cap = 120000
3540
+ for (let i = 0; i < plan.length; i++) {
3541
+ const step = plan[i]
3542
+ await writeText(relDir + '/paper.tex', step.tex)
3543
+ const runs = []
3544
+ for (let pass = 0; pass < 2; pass++) runs.push(await paperRunOnce(step.engine, absDir, cap))
3545
+ const last = runs[runs.length - 1]
3546
+ const pdf = !!last.ok && await paperPathExists(relDir + '/paper.pdf')
3547
+ attempts.push({ engine: step.engine.name, mode: step.mode, ok: pdf, exitCode: last.exitCode, timedOut: !!last.timedOut, stderr: last.stderr || '' })
3548
+ await paperAppendLog(id, 'compile', step.mode + ' engine=' + step.engine.name + ' → ' + (pdf ? 'ok(paper.pdf 已生成)' : ('failed(exit=' + String(last.exitCode) + (last.timedOut ? ', timeout' : '') + ')')))
3549
+ if (pdf) return { compile: (i === 0) ? 'ok' : 'repaired', engine: step.engine.name, mode: step.mode, attempts: attempts, pdfPreserved: false }
3550
+ }
3551
+ await writeText(relDir + '/paper.tex', texPrimary)
3552
+ const preserved = await paperPathExists(relDir + '/paper.pdf')
3553
+ await paperAppendLog(id, 'compile', '所有尝试均失败:保留 paper.tex + paper.md' + (preserved ? '(paper.pdf 是**上一次成功编译**留下的,未被覆盖)' : ',未生成 paper.pdf') + '(不阻塞定稿)')
3554
+ logActivity('paper', '论文 pdf 编译失败(已尝试换引擎/去不支持宏包/最小模板),已保留 tex+md:' + relDir)
3555
+ reportDirty = true
3556
+ return { compile: 'failed', engine: det.available[0].name, attempts: attempts, pdfPreserved: preserved }
3557
+ }
3558
+ /** 落盘 md/tex/pdf/meta(meta **最后**写 = 原子提交点;spec §2/§5)。 */
3559
+ async function paperEmitArtifacts(id, o, info) {
3560
+ const relDir = paperDir(id)
3561
+ const dateStr = fmtTime(now()).slice(0, 10)
3562
+ const author = paperAuthorLine()
3563
+ const evidence = info.evidence || (await paperEvidenceIndex())
3564
+ const title = String(info.title || '').trim() || ('研究报告:' + currentProject)
3565
+ const md = paperBuildMarkdown({ title: title, author: author, date: dateStr, abstract: info.abstract, sections: info.sections, evidence: evidence })
3566
+ const artifacts = { md: false, tex: false, pdf: false }
3567
+ let compile = 'skipped', engine = null, attempts = [], pdfPreserved = false
3568
+ if (o.format === 'md' || o.format === 'both') { await writeText(relDir + '/paper.md', md); artifacts.md = true }
3569
+ if (o.format === 'tex' || o.format === 'both') {
3570
+ const bodyTex = paperMdToTexBody(md)
3571
+ const tex = paperTexDoc({ title: title, author: author, date: dateStr, lang: o.lang, bodyTex: bodyTex })
3572
+ await writeText(relDir + '/paper.tex', tex)
3573
+ artifacts.tex = true
3574
+ const c = await paperCompile(id, o, tex, bodyTex)
3575
+ compile = c.compile; engine = c.engine || null; attempts = c.attempts || []
3576
+ pdfPreserved = !!c.pdfPreserved
3577
+ if (compile === 'ok' || compile === 'repaired') artifacts.pdf = await paperPathExists(relDir + '/paper.pdf')
3578
+ }
3579
+ const filled = PAPER_SKELETON.map(function (s) { return s.key }).filter(function (k) { return !!(info.sections && String(info.sections[k] || '').trim()) })
3580
+ const meta = {
3581
+ id: id, dir: relDir, preset: 'vibe-math-v3', singleAuthor: true, project: currentProject,
3582
+ title: title, author: author, date: dateStr, abstract: String(info.abstract || ''),
3583
+ sectionBodies: info.sections || {}, sections: PAPER_SKELETON.map(function (s) { return s.key }), filledSections: filled,
3584
+ raw: String(info.raw == null ? '' : info.raw), notes: Array.isArray(info.notes) ? info.notes : [],
3585
+ trigger: info.trigger || 'manual', runStartedAt: Number(scheduler.startedAt) || 0,
3586
+ params: { finalPaper: params.finalPaper !== false, paperFormat: o.format, paperLanguage: o.lang, paperCompilePdf: !!o.compilePdf, paperLatexCommand: String(params.paperLatexCommand || '') },
3587
+ finalizedAt: now(), finalizedAtText: fmtTime(now()),
3588
+ artifacts: artifacts, compile: compile, compileEngine: engine, compileAttempts: attempts, pdfPreserved: pdfPreserved,
3589
+ material: info.material || null, evidence: evidence,
3590
+ }
3591
+ await writeJson(relDir + '/paper.meta.json', meta)
3592
+ return meta
3593
+ }
3594
+ async function paperMissingArtifacts(id, o) {
3595
+ const relDir = paperDir(id)
3596
+ const need = []
3597
+ if (o.format === 'md' || o.format === 'both') { if (!(await paperPathExists(relDir + '/paper.md'))) need.push('paper.md') }
3598
+ if (o.format === 'tex' || o.format === 'both') { if (!(await paperPathExists(relDir + '/paper.tex'))) need.push('paper.tex') }
3599
+ return need
3600
+ }
3601
+ /** 派遣(内部):激活上限拒绝 → 排队重试(修订 §A2)。 */
3602
+ async function paperDispatch(trigger, o, id, prevTries) {
3603
+ const relDir = paperDir(id)
3604
+ const digest = await buildPaperDigest()
3605
+ let childId
3606
+ try {
3607
+ childId = await spawnChild('paper-writer:' + id, paperWriterPrompt(digest, o), { role: 'paper', paperId: id, trigger: trigger, lang: o.lang, format: o.format, compilePdf: o.compilePdf })
3608
+ } catch (e) {
3609
+ const message = String((e && e.message) || e)
3610
+ const limit = activationLimitFrom(message)
3611
+ if (limit !== undefined || /ACTIVATION_LIMIT_REACHED|active child limit/i.test(message)) {
3612
+ const tries = Number(prevTries || 0) + 1
3613
+ if (tries > PAPER_MAX_RETRIES) {
3614
+ paperPending = null
3615
+ await paperAppendLog(id, 'dispatch-failed', '宿主激活上限连续拒绝 ' + PAPER_MAX_RETRIES + ' 次,已放弃自动派遣:' + message)
3616
+ logActivity('paper', '论文撰写子代理被宿主激活上限连续拒绝(' + PAPER_MAX_RETRIES + ' 次),已放弃自动派遣(稍后可 /vibe paper force 重试):' + message)
3617
+ console.error('vibe-math-v3: paper writer refused by the host activation limit ' + PAPER_MAX_RETRIES + ' times; giving up (retry later with /vibe paper force) — ' + message)
3618
+ await paperLockRelease(id)
3619
+ return { ok: false, message: 'paper writer refused by the activation limit ' + PAPER_MAX_RETRIES + ' times: ' + message, id: id, dir: relDir, limit: limit }
3620
+ }
3621
+ paperPending = { tries: tries, retryAt: now() + PAPER_RETRY_MS, reason: message, limit: limit, trigger: trigger, opts: { lang: o.lang, format: o.format, compilePdf: o.compilePdf } }
3622
+ if (tries === 1 || tries % 3 === 0) {
3623
+ await paperAppendLog(id, 'dispatch-queued', '宿主激活上限(' + (limit === undefined ? '?' : limit) + ')拒绝派遣(第 ' + tries + ' 次):' + (PAPER_RETRY_MS / 1000) + 's 后重试')
3624
+ logActivity('paper', '论文撰写子代理被宿主激活上限拒绝(第 ' + tries + ' 次),已排队重试:' + message)
3625
+ console.error('vibe-math-v3: paper writer queued after an activation-limit refusal (try ' + tries + '): ' + message)
3626
+ }
3627
+ reportDirty = true
3628
+ return { ok: true, queued: true, reason: 'activation-limit-reached', tries: tries, retryAt: paperPending.retryAt, limit: limit, id: id, dir: relDir }
3629
+ }
3630
+ await paperAppendLog(id, 'dispatch-failed', message)
3631
+ logActivity('paper', '「论文撰写」子代理派遣失败:' + message)
3632
+ await paperLockRelease(id)
3633
+ return { ok: false, message: 'paper writer dispatch failed: ' + message, id: id, dir: relDir }
3634
+ }
3635
+ paperInFlight = childId
3636
+ paperInFlightAt = now()
3637
+ // 只有"非自动重试"的派遣才重置回收计数:auto-retry 必须保留计数,否则卡死重派会无限循环。
3638
+ if (trigger !== 'auto-retry') paperReaps = 0
3639
+ paperPending = null
3640
+ const material = paperMaterialSummary(digest)
3641
+ await paperAppendLog(id, 'dispatch', 'trigger=' + trigger + ' child=' + childId + ' format=' + o.format + ' lang=' + o.lang + ' compilePdf=' + o.compilePdf + (o.force ? ' force=true' : '') + ' run=' + (Number(scheduler.startedAt) || 0) + ' material=' + material.chars + ' chars/' + material.lines + ' lines')
3642
+ logActivity('paper', '已派遣「论文撰写」子代理(' + trigger + ',' + o.format + '/' + o.lang + ')→ ' + relDir)
3643
+ reportDirty = true
3644
+ return { ok: true, dispatched: true, childId: childId, id: id, dir: relDir, trigger: trigger, format: o.format, lang: o.lang, compilePdf: o.compilePdf }
3645
+ }
3646
+ /** 触发入口(spec §2/§3 + 修订 §A2/§A3):自动(收口)与手动命令共用。 */
3647
+ async function maybeWritePaper(trigger, opts) {
3648
+ const o = paperOpts(opts)
3649
+ const id = paperId(opts)
3650
+ const relDir = paperDir(id)
3651
+ const meta = await paperReadMeta(id)
3652
+ const sameRun = !!meta && Number(meta.runStartedAt || 0) === (Number(scheduler.startedAt) || 0)
3653
+ if (!o.force && meta && meta.finalizedAt && sameRun) {
3654
+ const missing = await paperMissingArtifacts(id, o)
3655
+ if (missing.length === 0) {
3656
+ await paperAppendLog(id, 'skip', '幂等:同一 run(' + (Number(scheduler.startedAt) || 0) + ')已有定稿且产物齐全 → 不重复撰写(/vibe paper force 可重写)')
3657
+ return { ok: true, skipped: true, reason: 'already-finalized-this-run', id: id, dir: relDir, finalizedAt: meta.finalizedAt, artifacts: meta.artifacts || {}, compile: meta.compile, note: '同一 run 只写一次;重复触发只补写缺失产物(spec §2)' }
3658
+ }
3659
+ await paperAppendLog(id, 'fill', '幂等:已有定稿但缺少 ' + missing.join(', ') + ' → 用已存的定稿内容补写(不重新派遣作者)')
3660
+ const filled = await paperEmitArtifacts(id, o, { title: meta.title, abstract: meta.abstract, sections: meta.sectionBodies || {}, evidence: meta.evidence, raw: meta.raw, notes: (meta.notes || []).concat(['补写缺失产物:' + missing.join(', ')]), trigger: meta.trigger || trigger, material: meta.material })
3661
+ return { ok: true, skipped: true, reason: 'filled-missing-artifacts', filled: missing, id: id, dir: relDir, finalizedAt: filled.finalizedAt, artifacts: filled.artifacts, compile: filled.compile }
3662
+ }
3663
+ if (paperInFlight) {
3664
+ const verdict = paperWriterVerdictNow(o.force)
3665
+ if (verdict === 'reap') {
3666
+ const gone = agentRegistry[paperInFlight] === undefined
3667
+ await paperReapWriter(id, gone ? '子代理已不在 agentRegistry(end 事件丢失 / 宿主丢弃)' : ('超过卡死窗口 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟未返回'))
3668
+ } else if (verdict === 'refuse') {
3669
+ const ageMs = now() - Number(paperInFlightAt || 0)
3670
+ await paperAppendLog(id, 'force-refused', '仍有在途撰写者 ' + paperInFlight + '(' + Math.round(ageMs / 1000) + 's):超过 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟才会被 force 放弃')
3671
+ return { ok: false, reason: 'writer-in-flight', childId: paperInFlight, ageMs: ageMs, staleMs: PAPER_STALE_MS, id: id, dir: relDir, message: '已有在途的论文撰写子代理 ' + paperInFlight + '(已 ' + Math.round(ageMs / 1000) + 's 未返回)。等它返回,或等超过 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟后 force 会自动回收并重派(心跳也会自动回收)。' }
3672
+ } else {
3673
+ return { ok: true, skipped: true, reason: 'writer-in-flight', childId: paperInFlight, ageMs: now() - Number(paperInFlightAt || 0), staleMs: PAPER_STALE_MS, id: id, dir: relDir }
3674
+ }
3675
+ }
3676
+ if (paperPending) return { ok: true, queued: true, reason: 'retry-queued', retryAt: paperPending.retryAt, tries: paperPending.tries, id: id, dir: relDir }
3677
+ const lock = await paperLockAcquire(id)
3678
+ if (!lock.ok) {
3679
+ await paperAppendLog(id, 'skip', 'paper 锁由会话 ' + lock.holder + ' 持有(未过期)→ 本次不派遣(修订 §A3 的 paper 作用域锁)')
3680
+ return { ok: true, skipped: true, reason: 'locked-by-another-session', holder: lock.holder, id: id, dir: relDir }
3681
+ }
3682
+ return await paperDispatch(trigger, o, id, 0)
3683
+ }
3684
+ function paperRetryDue() { return !!(paperPending && now() >= Number(paperPending.retryAt || 0)) }
3685
+ /**
3686
+ * 心跳(修订 §1):在途 → 续租 paper 锁;**卡死则自动回收**(child 不在注册表,或超过卡死窗口),
3687
+ * 并自动重派(上限 PAPER_MAX_AUTO_REAPS,之后给出可执行提示交给 /vibe paper force);有排队则重试派遣。
3688
+ */
3689
+ async function runPaperRetry() {
3690
+ const id = paperId()
3691
+ if (paperInFlight) {
3692
+ if (paperWriterVerdictNow(false) === 'reap') {
3693
+ const gone = agentRegistry[paperInFlight] === undefined
3694
+ await paperReapWriter(id, gone ? '子代理已不在 agentRegistry(end 事件丢失 / 宿主丢弃)' : ('超过卡死窗口 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟未返回'))
3695
+ const meta = await paperReadMeta(id)
3696
+ if (params.finalPaper !== false && !(meta && meta.finalizedAt) && Number(paperReaps || 0) < PAPER_MAX_AUTO_REAPS) {
3697
+ paperReaps = Number(paperReaps || 0) + 1
3698
+ await paperAppendLog(id, 'retry-after-reap', '第 ' + paperReaps + ' 次自动重派(上限 ' + PAPER_MAX_AUTO_REAPS + ')')
3699
+ await paperDispatch('auto-retry', paperOpts({}), id, 0)
3700
+ } else {
3701
+ await paperAppendLog(id, 'reap-giveup', '自动重派已达上限 ' + PAPER_MAX_AUTO_REAPS + '(或已有定稿):不再自动派遣,请用 /vibe paper force 手动重写')
3702
+ logActivity('paper', '论文撰写子代理连续卡死/已放弃:不再自动重派,可用 /vibe paper force 手动重写')
3703
+ }
3704
+ return
3705
+ }
3706
+ try { await paperLockAcquire(id) } catch (e) { /* 续租失败不致命 */ }
3707
+ return
3708
+ }
3709
+ if (!paperRetryDue()) return
3710
+ const p = paperPending
3711
+ paperPending = null
3712
+ const o = paperOpts(p.opts)
3713
+ await paperDispatch(p.trigger || 'auto-retry', o, id, Number(p.tries || 0))
3714
+ }
3715
+ /** `/vibe paper [lang=zh|en] [format=both|md|tex] [force]`(spec §2/§6)。 */
3716
+ async function paperCommand(args) {
3717
+ const rest = Array.isArray(args) ? args : []
3718
+ const opts = {}
3719
+ for (let i = 0; i < rest.length; i++) {
3720
+ const a = String(rest[i] || '')
3721
+ if (a === 'force') { opts.force = true; continue }
3722
+ const eq = a.indexOf('=')
3723
+ const k = eq > 0 ? a.slice(0, eq).toLowerCase() : ''
3724
+ const v = eq > 0 ? a.slice(eq + 1).toLowerCase() : ''
3725
+ if (k === 'lang') { if (v !== 'zh' && v !== 'en') return { ok: false, message: '/vibe paper lang= 只接受 zh|en(收到 ' + JSON.stringify(a) + ')' }; opts.lang = v; continue }
3726
+ if (k === 'format') { if (v !== 'both' && v !== 'md' && v !== 'tex') return { ok: false, message: '/vibe paper format= 只接受 both|md|tex(收到 ' + JSON.stringify(a) + ')' }; opts.format = v; continue }
3727
+ return { ok: false, message: 'unknown /vibe paper option: ' + a + '(可用 lang=zh|en、format=both|md|tex、force)' }
3728
+ }
3729
+ const r = await maybeWritePaper('manual', opts)
3730
+ if (r.ok === false) return r
3731
+ const autoNote = params.finalPaper === false ? '(自动触发已关闭 finalPaper=false;手动命令仍可用)' : ''
3732
+ const msg = r.dispatched ? ('已派遣「论文撰写」子代理,产物将写入 ' + r.dir + '/')
3733
+ : r.queued ? ('宿主激活上限已满:论文撰写已排队,' + (PAPER_RETRY_MS / 1000) + 's 后自动重试(第 ' + r.tries + ' 次)')
3734
+ : (r.reason === 'already-finalized-this-run' ? '同一 run 已有定稿且产物齐全:不重复撰写(需要重写请用 /vibe paper force)'
3735
+ : (r.reason === 'filled-missing-artifacts' ? ('已用既有定稿补写缺失产物:' + (r.filled || []).join(', ')) : ('未重新派遣:' + (r.reason || ''))))
3736
+ return Object.assign({}, r, { message: msg + autoNote, autoDisabled: params.finalPaper === false })
3737
+ }
3738
+ /** 论文撰写子代理结束(spec §4):解析回复 → 组装 9 节 → 落盘 → 编译 → 记日志 → 释放 paper 锁。 */
3739
+ async function handlePaperWriter(childId, meta, output) {
3740
+ const id = meta.paperId || paperId()
3741
+ paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0
3742
+ delete paperAbandoned[childId]
3743
+ const o = paperOpts({ lang: meta.lang, format: meta.format, compilePdf: meta.compilePdf })
3744
+ const parsed = parseJson(output)
3745
+ const info = paperSkeletonFromReply(parsed, output)
3746
+ if (!info.title) info.title = paperTitleFromMd(String(output || '')) || ('研究报告:' + currentProject)
3747
+ for (let i = 0; i < info.notes.length; i++) await paperAppendLog(id, 'note', info.notes[i])
3748
+ if (!String(output || '').trim()) await paperAppendLog(id, 'reply', '作者回复为空:只写骨架 + 占位说明(绝不编造内容)')
3749
+ let written
3750
+ try {
3751
+ written = await paperEmitArtifacts(id, o, { title: info.title, abstract: info.abstract, sections: info.sections, raw: String(output || ''), notes: info.notes, trigger: meta.trigger || 'auto', material: paperMaterialSummary(await buildPaperDigest()) })
3752
+ } catch (e) {
3753
+ await paperAppendLog(id, 'finalize-failed', String((e && e.message) || e))
3754
+ logActivity('paper', '论文落盘失败:' + String((e && e.message) || e))
3755
+ await paperLockRelease(id)
3756
+ return
3757
+ }
3758
+ await paperAppendLog(id, 'finalize', '产物 ' + JSON.stringify(written.artifacts) + ' compile=' + written.compile + (written.compileEngine ? (' engine=' + written.compileEngine) : '') + ' run=' + (Number(scheduler.startedAt) || 0))
3759
+ logActivity('paper', '论文(最终稿)已落盘:' + paperDir(id) + '/(' + Object.keys(written.artifacts).filter(function (k) { return written.artifacts[k] }).join('/') + ',编译=' + written.compile + ')')
3760
+ await paperLockRelease(id)
3761
+ reportDirty = true
3762
+ await saveAll()
3763
+ }
3764
+ /** status 里的论文视图(spec §2/§6)。 */
3765
+ async function paperStatusView() {
3766
+ const id = paperId()
3767
+ const meta = await paperReadMeta(id)
3768
+ return {
3769
+ id: id, dir: paperDir(id), inFlight: paperInFlight || null,
3770
+ inFlightSince: paperInFlight ? Number(paperInFlightAt || 0) : null,
3771
+ inFlightAgeMs: paperInFlight ? (now() - Number(paperInFlightAt || 0)) : null,
3772
+ staleMs: PAPER_STALE_MS, reapedThisRun: Number(paperReaps || 0),
3773
+ queued: paperPending ? { tries: paperPending.tries, retryAt: paperPending.retryAt, reason: paperPending.reason, limit: paperPending.limit === undefined ? null : paperPending.limit } : null,
3774
+ autoFinalPaper: params.finalPaper !== false, format: params.paperFormat, language: params.paperLanguage, compilePdf: params.paperCompilePdf !== false,
3775
+ finalizedAt: meta ? meta.finalizedAt : null, artifacts: meta ? (meta.artifacts || {}) : {}, compile: meta ? meta.compile : null,
3776
+ }
3777
+ }
3778
+
3155
3779
  // ================= child result dispatch =================
3156
3780
  async function onChildEnd(info) {
3157
3781
  // 子代理结束/中断:自动释放它持有的所有写锁(防锁残留导致文件被永久锁住)
3158
3782
  const endedId = String(info.id)
3159
3783
  for (const k of Object.keys(fileOwner)) { if (String(fileOwner[k].childId) === endedId) delete fileOwner[k] }
3160
3784
  const meta = agentRegistry[info.id]
3161
- if (meta === undefined) return
3785
+ if (meta === undefined) {
3786
+ // 已回收的撰写者事后才返回:输出被丢弃,但必须**留痕**,不能静默消失(修订 §1)。
3787
+ if (paperAbandoned[info.id]) {
3788
+ delete paperAbandoned[info.id]
3789
+ logActivity('paper', '已回收的论文撰写子代理 ' + info.id + ' 事后返回:其输出被丢弃(已重派新的撰写者)')
3790
+ reportDirty = true
3791
+ }
3792
+ return
3793
+ }
3162
3794
  const output = blocksToText(info.lastAssistantMessage)
3163
3795
  try {
3164
3796
  if (meta.role === 'explorer') await handleExplorer(info.id, meta, output)
@@ -3166,6 +3798,9 @@ export function apply(ctx) {
3166
3798
  else if (meta.role === 'verifier') await handleVerifier(info.id, meta, output, info.stopReason)
3167
3799
  else if (meta.role === 'planner') await handlePlanner(info.id, meta, output)
3168
3800
  else if (meta.role === 'method-keeper') await handleMethodKeeper(info.id, meta, output)
3801
+ // 修订 §A2:论文撰写子代理必须有**自己的分支**——未知 role 的产出会被静默丢弃。
3802
+ else if (meta.role === 'paper') await handlePaperWriter(info.id, meta, output)
3803
+ else console.error('vibe-math-v3: subagent ' + info.id + ' ended with an unknown role "' + String(meta.role) + '" — its output is dropped')
3169
3804
  } catch (e) { console.error('vibe-math-v3 onChildEnd error: ' + String((e && e.stack) || e)) }
3170
3805
  await saveAll()
3171
3806
  await maybePromoteMethods()
@@ -3184,6 +3819,9 @@ export function apply(ctx) {
3184
3819
  if (Object.keys(agentRegistry).length > 0 || Object.keys(tasks).length > 0) {
3185
3820
  logActivity(fresh ? 'start' : 'resume', 'cleared ' + Object.keys(agentRegistry).length + ' agent(s) and ' + Object.keys(tasks).length + ' task(s) (' + (fresh ? 'restart' : 'stale from previous process') + ')')
3186
3821
  agentRegistry = {}; tasks = {}
3822
+ // 论文派遣态是本进程内存态:子代理已被中断/丢弃,排队与在途标记必须一起清掉(修订 §A2)。
3823
+ if (paperInFlight || paperPending) logActivity('paper', '重启/跨进程恢复:清空论文撰写态(inFlight=' + (paperInFlight || '-') + (paperPending ? ', queued' : '') + ')')
3824
+ paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null
3187
3825
  }
3188
3826
  }
3189
3827
  await writeJson('State/process_epoch.json', processEpoch)
@@ -3191,10 +3829,43 @@ export function apply(ctx) {
3191
3829
  if (params.indexAutoRebuild) await rebuildIndex()
3192
3830
  return { ok: true }
3193
3831
  }
3194
- async function acquireProjectLock() {
3832
+ /**
3833
+ * 项目锁 = 租约(lease),不是一次性写入的时间戳。
3834
+ *
3835
+ * 旧实现只在获取时写一次 `at`,全仓没有任何续租点:运行满 projectLockTimeoutMs(默认 60s)后
3836
+ * 第二个会话就会把锁判为"过期"并直接夺锁,两个进程于是并发写同一棵 md 树——而写锁 fileOwner
3837
+ * 是**进程级**的,跨进程不共享,挡不住这一路。
3838
+ *
3839
+ * 现在:(1) 独立的续租定时器按 LOCK_POLL_MS 轮询、在租约用掉 1/4 时写盘——最坏租约年龄因此远低于
3840
+ * projectLockTimeoutMs,且与 tick 时长完全无关(Round C 修正:此前 renewProjectLock 放在 tick()
3841
+ * 顶部,单个 tick 超过 projectLockTimeoutMs 就会让租约过期);
3842
+ * (2) 夺取**其他会话**持有的锁需要显式 override(`{override:true}` / `/vibe resume override`)。
3843
+ * 唯一保留的隐式接管是"锁租约已明确过期"(持有者进程崩溃、不再续租),以免崩溃后项目永久锁死;
3844
+ * 正常运行中的会话不会被接管。
3845
+ */
3846
+ // 节流判据取**落盘的那份租约自己的年龄**(`projectLock.at`),不再维护影子时间戳
3847
+ // `projectLockRenewedAt`:实测它连续两次轮询都读到"距上次续租约 5s"(因而每次都判定"不必写"),
3848
+ // 而文件里的 `at` 始终停在会话启动那一刻——影子时间戳与真正的租约可以分叉。
3849
+ // `projectLock.at` 正是 acquireProjectLock() 判过期、也是崩溃恢复所依据的唯一状态。
3850
+ // 因此"多久写一次"由租约年龄自己决定,二者不可能再分叉。
3851
+ async function renewProjectLock() {
3852
+ if (!scheduler.running) return
3853
+ if (projectLock.sessionId !== sessionId) return
3854
+ const timeout = Number(params.projectLockTimeoutMs) || 60000
3855
+ if ((now() - (Number(projectLock.at) || 0)) < Math.max(1000, timeout / 4)) return
3856
+ projectLock.at = now()
3857
+ await saveAll()
3858
+ }
3859
+ async function acquireProjectLock(override) {
3195
3860
  const timeout = Number(params.projectLockTimeoutMs) || 60000
3196
- if (projectLock.sessionId && projectLock.sessionId !== sessionId && (now() - projectLock.at) < timeout) {
3197
- return { ok: false, message: '项目 "' + currentProject + '" 正被会话 ' + projectLock.sessionId + ' 占用(锁超时 ' + timeout + 'ms)' }
3861
+ const held = projectLock.sessionId && projectLock.sessionId !== sessionId
3862
+ if (held) {
3863
+ const age = now() - (Number(projectLock.at) || 0)
3864
+ // 过期租约 = 持有者进程已不在续租(崩溃) → 允许接管,否则崩溃一次项目就永久锁死。
3865
+ if (age < timeout && !override) {
3866
+ return { ok: false, code: 'PROJECT_LOCKED', message: '项目 "' + currentProject + '" 正被会话 ' + projectLock.sessionId + ' 占用(租约剩余 ' + Math.max(0, timeout - age) + 'ms;确认对方已停止后可显式接管:override=true)' }
3867
+ }
3868
+ logActivity('lock', 'project lock taken over from session ' + projectLock.sessionId + (override ? ' (explicit override)' : ' (lease expired after ' + age + 'ms)'))
3198
3869
  }
3199
3870
  projectLock = { sessionId: sessionId, at: now() }
3200
3871
  await saveAll()
@@ -3203,10 +3874,10 @@ export function apply(ctx) {
3203
3874
  async function releaseProjectLock() {
3204
3875
  if (projectLock.sessionId === sessionId) { projectLock = { sessionId: '', at: 0 }; await saveAll() }
3205
3876
  }
3206
- async function startScheduler() { const r = await init(true); if (!r.ok) return r; const lock = await acquireProjectLock(); if (!lock.ok) return lock; scheduler.running = true; scheduler.startedAt = now(); scheduler.gate = null; logActivity('start', 'scheduler started for project ' + currentProject + '(v3:md 知识库 + 规划代理调度 + 方法库)'); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler started', project: currentProject, frameworkRoot: frameworkRoot() } }
3207
- async function resumeScheduler() { const r = await init(false); if (!r.ok) return r; const lock = await acquireProjectLock(); if (!lock.ok) return lock; scheduler.running = true; scheduler.gate = null; logActivity('resume', 'scheduler resumed'); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler resumed', project: currentProject, frameworkRoot: frameworkRoot() } }
3877
+ async function startScheduler(override) { const r = await init(true); if (!r.ok) return r; const lock = await acquireProjectLock(override === true); if (!lock.ok) return lock; scheduler.running = true; scheduler.startedAt = now(); scheduler.gate = null; logActivity('start', 'scheduler started for project ' + currentProject + '(v3:md 知识库 + 规划代理调度 + 方法库)'); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler started', project: currentProject, frameworkRoot: frameworkRoot() } }
3878
+ async function resumeScheduler(override) { const r = await init(false); if (!r.ok) return r; const lock = await acquireProjectLock(override === true); if (!lock.ok) return lock; scheduler.running = true; scheduler.gate = null; logActivity('resume', 'scheduler resumed'); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler resumed', project: currentProject, frameworkRoot: frameworkRoot() } }
3208
3879
  async function pauseScheduler() { scheduler.running = false; await releaseProjectLock(); logActivity('pause', 'scheduler paused'); await saveAll(); return { ok: true, message: 'scheduler paused' } }
3209
- async function abortScheduler() { scheduler.running = false; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); agentRegistry = {}; planQueue = []; await releaseProjectLock(); logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
3880
+ async function abortScheduler() { scheduler.running = false; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); agentRegistry = {}; planQueue = []; paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null; await releaseProjectLock(); logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
3210
3881
  async function autoResolvePending() {
3211
3882
  const pending = decisionQueue.filter(function (d) { return d.status === 'pending' })
3212
3883
  for (let i = 0; i < pending.length; i++) {
@@ -3243,6 +3914,8 @@ export function apply(ctx) {
3243
3914
  plannerEnabled: params.plannerEnabled, plannerFails: plannerFails,
3244
3915
  formal: formalSummary(),
3245
3916
  recentActivity: activityLog.slice(-Math.min(10, Number(params.activityLogCap) || 100)), params: params,
3917
+ // 最终论文的可观测面(spec §2/§6)。
3918
+ paper: await paperStatusView(),
3246
3919
  }
3247
3920
  }
3248
3921
  async function checkTermination() {
@@ -3258,12 +3931,22 @@ export function apply(ctx) {
3258
3931
  // 注意:待沉淀发明(pendingInventions)不应阻塞终止——它是"批量蒸馏"(积够 methodKeepEvery 才触发),
3259
3932
  // 少量残留不会再有 method-keeper 触发,若纳入会令 scheduler 永不终止(闲置空转)。
3260
3933
  const leftoverVerify = (await buildVerifyCandidates()).length > 0
3261
- if (unsolved.length === 0 && !leftoverVerify && Object.keys(agentRegistry).length === 0 && Object.keys(tasks).length === 0 && planQueue.length === 0) {
3934
+ // H1:在途的 **planner 不算"活跃工作"**。planner 不产出知识、不持有验证任务,只是"问下一步做什么";
3935
+ // 旧判定把刚派出的 planner 也算成活跃代理,于是本 tick 的停机判定永远被自己派出的 planner 挡掉,
3936
+ // 空闲时形成"每 tick 一个 planner"的活锁。排除它之后,空闲的那一 tick 就能正常收口停机。
3937
+ const realAgents = Object.keys(agentRegistry).filter(function (cid) { const m = agentRegistry[cid]; return m && m.role !== 'planner' && m.role !== 'paper' }).length
3938
+ if (unsolved.length === 0 && !leftoverVerify && realAgents === 0 && Object.keys(tasks).length === 0 && planQueue.length === 0) {
3939
+ // ★ 最终论文(修订 §A2/§A3):必须在 running=false 与 releaseProjectLock() **之前**派遣——
3940
+ // 停止之后 scheduleTick() 是空操作;提前放锁后另一会话可能开始写同一棵树(paper 锁兜底)。
3941
+ // 触发点就是这个分支本身:`leftoverVerify` 为空才是 v3 的真实完整性判据(§A4)。
3942
+ if (params.finalPaper !== false) {
3943
+ try { await maybeWritePaper('auto') } catch (e) { logActivity('paper', '自动撰写最终论文失败:' + String((e && e.message) || e)) }
3944
+ }
3262
3945
  scheduler.running = false
3263
3946
  await releaseProjectLock()
3264
3947
  logActivity('stop', 'all active problems solved (never-priority excluded) and no active agents/tasks/plans — scheduler stopped (strict termination)')
3265
3948
  await saveAll(); await maybeWriteReport(true); await maybePushReport(true)
3266
- } else if (!hasPendingGate && Object.keys(agentRegistry).length === 0 && Object.keys(tasks).length === 0 && planQueue.length === 0 && unsolved.length > 0) {
3949
+ } else if (!hasPendingGate && realAgents === 0 && Object.keys(tasks).length === 0 && planQueue.length === 0 && unsolved.length > 0) {
3267
3950
  let allBlocked = true
3268
3951
  for (let i = 0; i < unsolved.length; i++) {
3269
3952
  const q = unsolved[i]
@@ -3290,7 +3973,7 @@ export function apply(ctx) {
3290
3973
  if (!create && !exists) return { ok: false, message: 'project not found: ' + slug }
3291
3974
  if (scheduler.running) await abortScheduler()
3292
3975
  currentProject = slug; await writeCurrentProject(); await ensureDirs()
3293
- 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: {} }
3976
+ 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
3294
3977
  await loadSettings(); await migrateLegacyParams(); await loadState(); await loadKnowledgeBase(); await saveAll()
3295
3978
  if (params.indexAutoRebuild) await rebuildIndex()
3296
3979
  return { ok: true, project: slug, frameworkRoot: frameworkRoot() }
@@ -3302,47 +3985,50 @@ export function apply(ctx) {
3302
3985
  function objParams(props, required) { return { type: 'object', properties: props, additionalProperties: false, required: required || [] } }
3303
3986
  const handlers = {}
3304
3987
  function registerTool(name, description, parameters, executeFn) { handlers[name] = executeFn }
3305
- registerTool('vibe_math_start', 'Start (or restart) the Vibe Math V3 scheduler for the current project.', objParams({}), async function () { return await startScheduler() })
3306
- registerTool('vibe_math_resume', 'Resume the Vibe Math V3 scheduler after a checkpoint/restart.', objParams({}), async function () { return await resumeScheduler() })
3307
- registerTool('vibe_math_pause', 'Pause the scheduler (in-flight children finish their current turn).', objParams({}), async function () { return await pauseScheduler() })
3308
- registerTool('vibe_math_abort', 'Abort the scheduler and interrupt all active children.', objParams({}), async function () { return await abortScheduler() })
3309
- registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), async function () { await refreshParams(); return await getStatus() })
3310
- registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json + Logs/报告.md.', objParams({}), async function () { await refreshParams(); await maybeWriteReport(true); return await buildReport() })
3311
- registerTool('vibe_math_set_mode', 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.', 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 } })
3312
- registerTool('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 工具链的调用方式。', objParams({ 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' } }), async function (args) { params = Object.assign({}, params, sanitizeParams(args)); await saveAll(); await saveSettings(); return { ok: true, params: params } })
3313
- registerTool('vibe_math_setup', 'Return the interactive parameter schema for guided configuration.', 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' } })
3314
- registerTool('vibe_math_save_settings', 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.', objParams({}), async function () { return await saveSettings() })
3315
- registerTool('vibe_math_template', 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.', objParams({ where: { type: 'string', enum: ['global', 'project'] } }), async function (args) { return await createTemplate((args && args.where) || 'global') })
3316
- registerTool('vibe_math_add_problem', 'Add a problem to the current project (creates Problems/<id>.md).', objParams({ id: { type: 'string' }, description: { type: 'string' }, priority: { type: 'integer' }, dependencies: { type: 'array', items: { type: 'string' } } }, ['id', 'description']), async function (args) { if (problems.has(args.id)) return { ok: false, message: 'problem id already exists' }; problems.set(args.id, { id: args.id, 标题: args.id, 状态: '求解中', 优先级: args.priority || 0, 依赖: Array.isArray(args.dependencies) ? args.dependencies : [], 被依赖: [], 来源: '原始', 计划: '待调度', 陈述: args.description, 来源与动机: '', solutions: [], 判断命题: '', 来源命题: '' }); await saveProblem(problems.get(args.id)); await syncDependencies(); await rebuildIndex(); scheduleTick(); return { ok: true, message: 'problem added', file: problemRel(problems.get(args.id)) } })
3317
- registerTool('vibe_math_add_proposition', 'Add a proposition to Propos/ (creates Propos/<分类>/<id>.md).', objParams({ id: { type: 'string' }, 概述: { type: 'string' }, 概率: { type: 'number' }, 优先级: { type: 'integer' }, '价值/关键性': { type: 'number' }, 分类: { type: 'string' } }, ['id', '概述']), async function (args) {
3318
- if (propos.has(args.id)) return { ok: false, message: 'proposition id already exists' }
3319
- const p = { id: args.id, 标题: args.id, 状态: '未定论', 概率: clamp01(args.概率 != null ? args.概率 : 0.5), 优先级: (args.优先级 != null) ? args.优先级 : 1, 依赖: [], 价值关键性: clamp01(args['价值/关键性'] != null ? args['价值/关键性'] : 0.5), 分类: args.分类 || '未分类', 陈述: args.概述, proofs: [], refutes: [], 来源问题: '', 在问题清单: false }
3988
+ registerTool('vibe_math_start', TOOL_DESC.vibe_math_start, objParams({ override: { type: 'boolean' } }), async function (args) { return await startScheduler(args && args.override) })
3989
+ registerTool('vibe_math_resume', TOOL_DESC.vibe_math_resume, objParams({ override: { type: 'boolean' } }), async function (args) { return await resumeScheduler(args && args.override) })
3990
+ registerTool('vibe_math_pause', TOOL_DESC.vibe_math_pause, objParams({}), async function () { return await pauseScheduler() })
3991
+ registerTool('vibe_math_abort', TOOL_DESC.vibe_math_abort, objParams({}), async function () { return await abortScheduler() })
3992
+ registerTool('vibe_math_status', TOOL_DESC.vibe_math_status, objParams({}), async function () { await refreshParams(); return await getStatus() })
3993
+ registerTool('vibe_math_report', TOOL_DESC.vibe_math_report, objParams({}), async function () { await refreshParams(); await maybeWriteReport(true); return await buildReport() })
3994
+ 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 } })
3996
+ 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
+ registerTool('vibe_math_save_settings', TOOL_DESC.vibe_math_save_settings, objParams({}), async function () { return await saveSettings() })
3998
+ 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') })
3999
+ registerTool('vibe_math_add_problem', TOOL_DESC.vibe_math_add_problem, objParams({ id: { type: 'string' }, description: { type: 'string' }, priority: { type: 'integer' }, dependencies: { type: 'array', items: { type: 'string' } } }, ['id', 'description']), async function (args) { const id = idSafe(args.id); if (problems.has(id)) return { ok: false, message: 'problem id already exists' }; problems.set(id, { id: id, 标题: id, 状态: '求解中', 优先级: args.priority || 0, 依赖: Array.isArray(args.dependencies) ? args.dependencies : [], 被依赖: [], 来源: '原始', 计划: '待调度', 陈述: String(args.description == null ? '' : args.description), 来源与动机: '', solutions: [], 判断命题: '', 来源命题: '' }); await saveProblem(problems.get(id)); await syncDependencies(); await rebuildIndex(); scheduleTick(); return { ok: true, message: 'problem added', file: problemRel(problems.get(id)) } })
4000
+ registerTool('vibe_math_add_proposition', TOOL_DESC.vibe_math_add_proposition, objParams({ id: { type: 'string' }, 概述: { type: 'string' }, 概率: { type: 'number' }, 优先级: { type: 'integer' }, '价值/关键性': { type: 'number' }, 分类: { type: 'string' }, 来源问题: { type: 'string' }, 来源方向: { type: 'string' } }, ['id', '概述']), async function (args) {
4001
+ // M9:内存键、对象 id、文件名必须是**同一个**归一化 id。此前内存键用原始 id、文件名用
4002
+ // idSafe(id)、重载键又来自文件名 ⇒ 同一个对象分裂成两个身份(`p-1/2` 与 `p-1-2`)。
4003
+ const id = idSafe(args.id)
4004
+ if (propos.has(id)) return { ok: false, message: 'proposition id already exists' }
4005
+ const p = { id: id, 标题: id, 状态: '未定论', 概率: clamp01(args.概率 != null ? args.概率 : 0.5), 优先级: (args.优先级 != null) ? args.优先级 : 1, 依赖: [], 价值关键性: clamp01(args['价值/关键性'] != null ? args['价值/关键性'] : 0.5), 分类: args.分类 || '未分类', 陈述: String(args.概述 == null ? '' : args.概述), proofs: [], refutes: [], 来源问题: String(args.来源问题 || ''), 来源方向: String(args.来源方向 || ''), 在问题清单: false }
3320
4006
  propos.set(p.id, p); await saveProposition(p); await rebuildIndex(); scheduleTick(); return { ok: true, proposition: p, file: propositionRel(p) }
3321
4007
  })
3322
- registerTool('vibe_math_list_propositions', 'List propositions from Propos/ (summary index: id, 标题, 概率, 状态, 优先级, 分类).', objParams({}), async function () { const all = allPropos(); return { ok: true, count: all.length, propositions: all.map(function (p) { return { id: p.id, 标题: p.标题, 概率: p.概率, 状态: p.状态, 优先级: p.优先级, 分类: categoryOf(p) } }) } })
3323
- registerTool('vibe_math_new_project', 'Create a new math project folder and switch to it.', objParams({ name: { type: 'string' } }, ['name']), async function (args) { const slug = slugify(args.name); return await setProject(slug, true) })
3324
- registerTool('vibe_math_set_project', 'Switch the current math project.', objParams({ name: { type: 'string' } }, ['name']), async function (args) { const slug = slugify(args.name); return await setProject(slug, false) })
3325
- registerTool('vibe_math_list_projects', 'List math projects.', objParams({}), async function () { return { ok: true, current: currentProject, projects: await listDirsAt(vibeRoot(), 'Projects') } })
3326
- registerTool('vibe_math_list_decisions', 'List pending manual decisions.', objParams({}), async function () { return { ok: true, decisions: decisionQueue.filter(function (d) { return d.status === 'pending' }).map(function (d) { return { id: d.id, node: d.node, context: d.context } }) } })
3327
- registerTool('vibe_math_decide', 'Resolve a pending manual decision (plan: approve|reject; verdict: override with verdict 1|0; spawn: approve|reject; method-promote: approve|reject).', objParams({ id: { type: 'string' }, action: { type: 'string', enum: ['approve', 'reject', 'override'] }, verdict: { type: 'number' } }, ['id', 'action']), async function (args) { const d = decisionQueue.find(function (x) { return x.id === args.id }); if (!d) return { ok: false, message: 'decision not found' }; if (d.status !== 'pending') return { ok: false, message: 'decision already resolved' }; const resolution = { action: args.action, verdict: args.verdict }; const applied = await applyDecision(d.node, d.data, resolution); const r = await resolveDecision(args.id, resolution); return Object.assign({ ok: true, applied: applied }, r) })
3328
- registerTool('vibe_math_list_agents', 'List tracked sub-agents (child sessions).', objParams({}), async function () { const out = []; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) { const m = agentRegistry[ids[i]]; out.push({ childId: ids[i], role: m.role, qid: m.qid, direction: m.direction, round: m.round, rId: m.rId }) } return { ok: true, agents: out, count: out.length } })
3329
- registerTool('vibe_math_message_agent', 'Send a message to a tracked child agent (next turn).', objParams({ childId: { type: 'string' }, message: { type: 'string' } }, ['childId', 'message']), async function (args) { if (!agentRegistry[args.childId]) return { ok: false, message: 'unknown childId' }; await followupChild(args.childId, args.message); return { ok: true, message: 'message delivered' } })
3330
- registerTool('vibe_math_interrupt_agent', 'Interrupt a tracked child agent.', objParams({ childId: { type: 'string' } }, ['childId']), async function (args) { await interruptChild(args.childId); return { ok: true, message: 'interrupt requested' } })
3331
- registerTool('vibe_math_plan', 'Show the queued plan / last plan result, or force a planning round.', objParams({ force: { type: 'boolean' } }), async function (args) { if (args && args.force && scheduler.running && !scheduler.gate) { await callPlanner(); return { ok: true, message: 'planning triggered', queued: planQueue.length } } return { ok: true, queued: planQueue, lastPlan: lastPlanSummary } })
3332
- registerTool('vibe_math_index', 'Rebuild the machine index (State/index.json) from the Markdown knowledge base.', objParams({}), async function () { await loadKnowledgeBase(); const r = await rebuildIndex(); return { ok: true, index: r, project: currentProject } })
3333
- registerTool('vibe_math_method_add', 'Manually add a method card to Methods/ (creates Methods/<id>.md).', objParams({ id: { type: 'string' }, 标题: { type: 'string' }, 类型: { type: 'string' }, 核心内容: { type: 'string' }, 适用场景: { type: 'string' } }, ['id', '标题']), async function (args) { if (methods.has(args.id)) return { ok: false, message: 'method id already exists' }; const m = { id: args.id, 标题: args.标题, 类型: args.类型 || '方法', 状态: '经验', 可信断言: [], 上级体系: [], 子方法: [], 相关: [], 适用场景: args.适用场景 || '', 核心内容: args.核心内容 || '', 定义与记号: '', applications: [], improvements: [], 来源: 'user' }; methods.set(m.id, m); await saveMethod(m, false); await rebuildIndex(); return { ok: true, method: m, file: methodRel(m, false) } })
3334
- registerTool('vibe_math_method_list', 'List methods from Methods/ (+ global VibeMath/Methods/): id, 标题, 类型, 状态, 可信断言, applications count.', objParams({}), async function () { const all = Array.from(methods.values()); const g = Array.from(globalMethods.values()); return { ok: true, count: all.length, globalCount: g.length, methods: all.map(function (m) { return { id: m.id, 标题: m.标题, 类型: m.类型, 状态: m.状态, 可信断言: m.可信断言 || [], applications: (m.applications || []).length, global: false } }).concat(g.map(function (m) { return { id: m.id, 标题: m.标题, 类型: m.类型, 状态: m.状态, 可信断言: m.可信断言 || [], applications: (m.applications || []).length, global: true } })) } })
3335
- registerTool('vibe_math_lock_status', 'Show the project lock occupancy.', objParams({}), async function () { return { ok: true, project: currentProject, lock: projectLock } })
3336
- registerTool('vibe_math_claim_write', 'Acquire the write lock for one target file (relative to the project root). Call before writing a Markdown file directly; a file may only be written by ONE agent at a time. Returns the display path you may write (VibeMath/Projects/<project>/<target>) and a hint.', objParams({ target: { type: 'string' } }, ['target']), async function (args, agent) { return await claimWrite(String(args.target || ''), agent) })
3337
- registerTool('vibe_math_release_write', 'Release the write lock for one target file (relative to the project root). Call after you finished writing it.', objParams({ target: { type: 'string' } }, ['target']), async function (args, agent) { return await releaseWrite(String(args.target || ''), agent) })
3338
- registerTool('vibe_math_sync_meta', 'Report lightweight scheduling metadata after you wrote content to Markdown files (direction status/survival, registered lemma ids, methods_used/new_inventions, new method cards). Content itself stays in the md files; this only keeps the scheduler index/state in sync.', objParams({ meta: { type: 'object' } }, ['meta']), async function (args, agent) { return await syncMeta(args.meta || {}, agent) })
4008
+ registerTool('vibe_math_list_propositions', TOOL_DESC.vibe_math_list_propositions, objParams({}), async function () { const all = allPropos(); return { ok: true, count: all.length, propositions: all.map(function (p) { return { id: p.id, 标题: p.标题, 概率: p.概率, 状态: p.状态, 优先级: p.优先级, 分类: categoryOf(p) } }) } })
4009
+ registerTool('vibe_math_new_project', TOOL_DESC.vibe_math_new_project, objParams({ name: { type: 'string' } }, ['name']), async function (args) { const slug = slugify(args.name); return await setProject(slug, true) })
4010
+ registerTool('vibe_math_set_project', TOOL_DESC.vibe_math_set_project, objParams({ name: { type: 'string' } }, ['name']), async function (args) { const slug = slugify(args.name); return await setProject(slug, false) })
4011
+ registerTool('vibe_math_list_projects', TOOL_DESC.vibe_math_list_projects, objParams({}), async function () { return { ok: true, current: currentProject, projects: await listDirsAt(vibeRoot(), 'Projects') } })
4012
+ registerTool('vibe_math_list_decisions', TOOL_DESC.vibe_math_list_decisions, objParams({}), async function () { return { ok: true, decisions: decisionQueue.filter(function (d) { return d.status === 'pending' }).map(function (d) { return { id: d.id, node: d.node, context: d.context } }) } })
4013
+ registerTool('vibe_math_decide', TOOL_DESC.vibe_math_decide, objParams({ id: { type: 'string' }, action: { type: 'string', enum: ['approve', 'reject', 'override'] }, verdict: { type: 'number' } }, ['id', 'action']), async function (args) { const d = decisionQueue.find(function (x) { return x.id === args.id }); if (!d) return { ok: false, message: 'decision not found' }; if (d.status !== 'pending') return { ok: false, message: 'decision already resolved' }; const resolution = { action: args.action, verdict: args.verdict }; const applied = await applyDecision(d.node, d.data, resolution); const r = await resolveDecision(args.id, resolution); return Object.assign({ ok: true, applied: applied }, r) })
4014
+ registerTool('vibe_math_list_agents', TOOL_DESC.vibe_math_list_agents, objParams({}), async function () { const out = []; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) { const m = agentRegistry[ids[i]]; out.push({ childId: ids[i], role: m.role, qid: m.qid, direction: m.direction, round: m.round, rId: m.rId }) } return { ok: true, agents: out, count: out.length } })
4015
+ registerTool('vibe_math_message_agent', TOOL_DESC.vibe_math_message_agent, objParams({ childId: { type: 'string' }, message: { type: 'string' } }, ['childId', 'message']), async function (args) { if (!agentRegistry[args.childId]) return { ok: false, message: 'unknown childId' }; await followupChild(args.childId, args.message); return { ok: true, message: 'message delivered' } })
4016
+ registerTool('vibe_math_interrupt_agent', TOOL_DESC.vibe_math_interrupt_agent, objParams({ childId: { type: 'string' } }, ['childId']), async function (args) { await interruptChild(args.childId); return { ok: true, message: 'interrupt requested' } })
4017
+ registerTool('vibe_math_plan', TOOL_DESC.vibe_math_plan, objParams({ force: { type: 'boolean' } }), async function (args) { if (args && args.force && scheduler.running && !scheduler.gate) { await callPlanner(); return { ok: true, message: 'planning triggered', queued: planQueue.length } } return { ok: true, queued: planQueue, lastPlan: lastPlanSummary } })
4018
+ registerTool('vibe_math_index', TOOL_DESC.vibe_math_index, objParams({}), async function () { await loadKnowledgeBase(); const r = await rebuildIndex(); return { ok: true, index: r, project: currentProject } })
4019
+ registerTool('vibe_math_method_add', TOOL_DESC.vibe_math_method_add, objParams({ id: { type: 'string' }, 标题: { type: 'string' }, 类型: { type: 'string' }, 核心内容: { type: 'string' }, 适用场景: { type: 'string' } }, ['id', '标题']), async function (args) { const id = idSafe(args.id); if (methods.has(id)) return { ok: false, message: 'method id already exists' }; const m = { id: id, 标题: args.标题, 类型: args.类型 || '方法', 状态: '经验', 可信断言: [], 上级体系: [], 子方法: [], 相关: [], 适用场景: args.适用场景 || '', 核心内容: args.核心内容 || '', 定义与记号: '', applications: [], improvements: [], 来源: 'user' }; methods.set(m.id, m); await saveMethod(m, false); await rebuildIndex(); return { ok: true, method: m, file: methodRel(m, false) } })
4020
+ registerTool('vibe_math_method_list', TOOL_DESC.vibe_math_method_list, objParams({}), async function () { const all = Array.from(methods.values()); const g = Array.from(globalMethods.values()); return { ok: true, count: all.length, globalCount: g.length, methods: all.map(function (m) { return { id: m.id, 标题: m.标题, 类型: m.类型, 状态: m.状态, 可信断言: m.可信断言 || [], applications: (m.applications || []).length, global: false } }).concat(g.map(function (m) { return { id: m.id, 标题: m.标题, 类型: m.类型, 状态: m.状态, 可信断言: m.可信断言 || [], applications: (m.applications || []).length, global: true } })) } })
4021
+ registerTool('vibe_math_lock_status', TOOL_DESC.vibe_math_lock_status, objParams({}), async function () { return { ok: true, project: currentProject, lock: projectLock } })
4022
+ registerTool('vibe_math_claim_write', TOOL_DESC.vibe_math_claim_write, objParams({ target: { type: 'string' } }, ['target']), async function (args, agent) { return await claimWrite(String(args.target || ''), agent) })
4023
+ registerTool('vibe_math_release_write', TOOL_DESC.vibe_math_release_write, objParams({ target: { type: 'string' } }, ['target']), async function (args, agent) { return await releaseWrite(String(args.target || ''), agent) })
4024
+ registerTool('vibe_math_sync_meta', TOOL_DESC.vibe_math_sync_meta, objParams({ meta: { type: 'object' } }, ['meta']), async function (args, agent) { return await syncMeta(args.meta || {}, agent) })
3339
4025
 
3340
4026
  // ---- Lean 形式化验证工具(契约 docs/formal-verification.md §5)----
3341
4027
  // **无条件注册**:注册是静态的(与既有 ctx.effect 纪律一致),模式只决定框架是否主动告诉
3342
4028
  // 代理它们存在;off 模式下人/代理主动调用时照常工作。
3343
- registerTool('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.', objParams({ file: { type: 'string' }, target: { type: 'string' }, timeout_ms: { type: 'integer' } }, ['file']), async function (args, agent) { return await leanRunTool(agent && agent.id, args) })
3344
- registerTool('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).', 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']), async function (args, agent) { return await leanArchive(agent && agent.id, args) })
3345
- registerTool('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.", objParams({ refresh: { type: 'boolean' } }), async function (args) {
4029
+ registerTool('vibe_math_lean_run', TOOL_DESC.vibe_math_lean_run, objParams({ file: { type: 'string' }, target: { type: 'string' }, timeout_ms: { type: 'integer' } }, ['file']), async function (args, agent) { return await leanRunTool(agent && agent.id, args) })
4030
+ 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']), async function (args, agent) { return await leanArchive(agent && agent.id, args) })
4031
+ registerTool('vibe_math_lean_lib', TOOL_DESC.vibe_math_lean_lib, objParams({ refresh: { type: 'boolean' } }), async function (args) {
3346
4032
  const a = args || {}
3347
4033
  const r = a.refresh === false
3348
4034
  ? { lib: null, proved: null, objects: Object.keys(formalRecords()).length }
@@ -3455,11 +4141,13 @@ export function apply(ctx) {
3455
4141
  if (Array.isArray(meta.lemmas)) {
3456
4142
  for (const l of meta.lemmas) {
3457
4143
  if (!l || (!l.id && !l.title)) continue
3458
- const pid = l.id || ('p-' + shortId())
4144
+ // M9:代理给的 id 可能与文件名归一化结果不同(`p-1/2` → `p-1-2`)⇒ 键与 id 一并归一化。
4145
+ const pid = l.id ? idSafe(l.id) : ('p-' + shortId())
3459
4146
  if (!propos.has(pid)) {
3460
- const pn = { id: pid, 标题: l.title || pid, 状态: '未定论', 概率: clamp01(l.prob != null ? l.prob : 0.6), 优先级: l.优先级 != null ? l.优先级 : 1, 依赖: [], 价值关键性: clamp01(l['价值/关键性'] != null ? l['价值/关键性'] : 0.5), 分类: l.分类 || '未分类', 陈述: l.statement || l.title || '', proofs: [], refutes: [], 来源问题: qid, 在问题清单: false }
4147
+ const lProb = lemmaProb(l)
4148
+ const pn = { id: pid, 标题: l.title || pid, 状态: '未定论', 概率: clamp01(lProb != null ? lProb : 0.6), 优先级: l.优先级 != null ? l.优先级 : 1, 依赖: [], 价值关键性: clamp01(l['价值/关键性'] != null ? l['价值/关键性'] : 0.5), 分类: lemmaCategory(l) || '未分类', 陈述: l.statement || l.title || '', proofs: [], refutes: [], 来源问题: qid, 在问题清单: false }
3461
4149
  // 引理证明文本(验证必需)由代理在 sync_meta 的 l.proof 上报(结构化,非长叙述);无则验证器只能验裸命题
3462
- if (l.proof) { pn.proofs = [{ title: (l.title || pid) + '(证明)', prob: clamp01(l.prob != null ? l.prob : 0.7), status: '未定论', text: String(l.proof) }] }
4150
+ if (l.proof) { pn.proofs = [{ title: (l.title || pid) + '(证明)', prob: clamp01(lProb != null ? lProb : 0.7), status: '未定论', text: String(l.proof) }] }
3463
4151
  propos.set(pid, pn)
3464
4152
  // 若代理已直接写了该命题卡,保留其内容(不覆盖);否则写一张标准卡兜底(保证可被索引/验证)
3465
4153
  const rel = propositionRel(pn) // 与 saveProposition 同一路径
@@ -3468,11 +4156,16 @@ export function apply(ctx) {
3468
4156
  if (!(dir.lemmas || []).some(function (x) { return x.id === pid })) { dir.lemmas = dir.lemmas || []; dir.lemmas.push({ id: pid, title: l.title || pid }) }
3469
4157
  }
3470
4158
  }
3471
- // 解法上报(prob 由代理写进 Problems/<qid>.md;这里只登记)
3472
- if (meta.solution_prob != null && meta.solution_text) {
4159
+ // 解法上报(prob 由代理写进 Problems/<qid>.md;这里只登记)。
4160
+ // M11:提示词教的是 `solution_text` / `solution_prob`,但遗留扁平通道用的是
4161
+ // `parsed.solution` / `parsed.solution_probability`。**两套拼写都接受**,否则一个
4162
+ // 严格按提示词回 `solution_text` 的 solver 会在这里被判成"声称成功却没有解法",方向被写死。
4163
+ const solText = (meta.solution_text != null) ? meta.solution_text : meta.solution
4164
+ const solProb = (meta.solution_prob != null) ? meta.solution_prob : meta.solution_probability
4165
+ if (solProb != null && solText) {
3473
4166
  q.solutions = q.solutions || []
3474
- const p = clamp01(meta.solution_prob)
3475
- q.solutions.push({ title: '解法 ' + (q.solutions.length + 1), prob: p >= 1 ? 0.99 : (p <= 0 ? 0.01 : p), status: '未定论', text: String(meta.solution_text).slice(0, 2000) })
4167
+ const p = clamp01(solProb)
4168
+ q.solutions.push({ title: '解法 ' + (q.solutions.length + 1), prob: p >= 1 ? 0.99 : (p <= 0 ? 0.01 : p), status: '未定论', text: String(solText).slice(0, 2000) })
3476
4169
  }
3477
4170
  // 子问题/临时假设(与旧 JSON 路径一致):注册 q_sub 问题 + 判断问题 + p-tmp 假设
3478
4171
  if (Array.isArray(meta.sub_questions)) {
@@ -3491,7 +4184,8 @@ export function apply(ctx) {
3491
4184
  }
3492
4185
  if (kind === 'methods') {
3493
4186
  if (Array.isArray(meta.used)) for (const mu of meta.used) await consumeMethodFeedback({ methods_used: mu ? [mu] : [] }, { qid: '', dirId: '' })
3494
- if (Array.isArray(meta.created)) for (const mid of meta.created) {
4187
+ if (Array.isArray(meta.created)) for (const rawMid of meta.created) {
4188
+ const mid = idSafe(rawMid) // M9:键、id 与文件名同一把归一化
3495
4189
  if (!methods.has(mid)) {
3496
4190
  const mm = { id: mid, 标题: mid, 类型: '方法', 状态: '经验', 可信断言: [], 上级体系: [], 子方法: [], 相关: [], 适用场景: '', 核心内容: '', 定义与记号: '', applications: [], improvements: [], 来源: 'agent-written' }
3497
4191
  methods.set(mid, mm)
@@ -3503,12 +4197,13 @@ export function apply(ctx) {
3503
4197
  if (Array.isArray(meta.improvements)) {
3504
4198
  for (const imp of meta.improvements) {
3505
4199
  if (!imp || !imp.id) continue
3506
- const m = methods.get(imp.id)
3507
- if (!m) { logActivity('method', 'improvement referenced unknown method ' + imp.id); continue }
4200
+ const impId = idSafe(imp.id) // M9:方法键已归一化,改进目标必须查同一把键
4201
+ const m = methods.get(impId)
4202
+ if (!m) { logActivity('method', 'improvement referenced unknown method ' + impId); continue }
3508
4203
  m.improvements = m.improvements || []
3509
4204
  m.improvements.push({ v: m.improvements.length + 1, 原因: imp.原因 || '', text: imp.改进内容 || '' })
3510
4205
  await saveMethod(m, false)
3511
- logActivity('method', 'method ' + imp.id + ' improved (v' + m.improvements.length + ')')
4206
+ logActivity('method', 'method ' + impId + ' improved (v' + m.improvements.length + ')')
3512
4207
  }
3513
4208
  }
3514
4209
  await saveAll()
@@ -3517,10 +4212,26 @@ export function apply(ctx) {
3517
4212
  return { ok: false, message: 'unknown meta kind: ' + kind }
3518
4213
  }
3519
4214
 
4215
+ // ---- 会话级 handler 表的工具描述(唯一来源)----
4216
+ //
4217
+ // 提示词审计 M3(v3):同一批工具在**两条注册路径**上各写了一份 description——会话 handler 表
4218
+ // (registerTool(name, description, …) 只存 handler)与文件末尾真正的 `tools.register`
4219
+ // (模型实际看到的那一份)。两边的**参数 schema 一致**(I13/I14 守),但描述会漂移:
4220
+ // `vibe_math_list_propositions` / `vibe_math_method_list` / `claim_write` / `release_write` /
4221
+ // `sync_meta` 五处曾对不上,模型看到哪一版取决于哪条路径生效。
4222
+ //
4223
+ // 现在这两条路径共用**模块级**常量 TOOL_DESC(定义在文件末尾的 shared helpers 区),
4224
+ // 任何一侧被改都会同时生效,不可能再漂移。`tests/audit-v3-registration-parity.mjs` 守这条不变式。
4225
+
3520
4226
  // ================= slash command /vibe =================
4227
+ // 注意:签名**必须**保持 `dispatchVibeCommand(cmd, args)`——`audit-persona-surface` 的
4228
+ // `commandRegion()` 靠这个字面量把 /vibe 的分支表接进提示词面一致性检查(hint/usage/handler
4229
+ // 三处必须一致)。多一个形参会让那段源码不再被扫到,等于把这条守卫静默关掉。
4230
+ // `override` 通过 args 本身传递(见下面 commands.register 的处理)。
3521
4231
  async function dispatchVibeCommand(cmd, args) {
3522
- if (cmd === 'start') return await startScheduler()
3523
- if (cmd === 'resume') return await resumeScheduler()
4232
+ const override = args.indexOf('override') !== -1
4233
+ if (cmd === 'start') return await startScheduler(override)
4234
+ if (cmd === 'resume') return await resumeScheduler(override)
3524
4235
  if (cmd === 'pause') return await pauseScheduler()
3525
4236
  if (cmd === 'abort') return await abortScheduler()
3526
4237
  if (cmd === 'status') { await refreshParams(); return await getStatus() }
@@ -3529,8 +4240,8 @@ export function apply(ctx) {
3529
4240
  if (cmd === 'setup') { 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' } }
3530
4241
  if (cmd === 'save') return await saveSettings()
3531
4242
  if (cmd === 'template') return await createTemplate(args[0] === 'project' ? 'project' : 'global')
3532
- if (cmd === 'add') { const id = args[0]; const desc = args.slice(1).join(' '); if (!id || !desc) return { ok: false, message: 'usage: /vibe add <id> <description>' }; if (problems.has(id)) return { ok: false, message: 'problem id already exists' }; problems.set(id, { id: id, 标题: id, 状态: '求解中', 优先级: 0, 依赖: [], 被依赖: [], 来源: '原始', 计划: '待调度', 陈述: desc, 来源与动机: '', solutions: [], 判断命题: '', 来源命题: '' }); await saveProblem(problems.get(id)); await rebuildIndex(); scheduleTick(); return { ok: true, message: 'problem added', file: problemRel(problems.get(id)) } }
3533
- if (cmd === 'add-proposition') { const id = args[0]; const desc = args.slice(1).join(' '); if (!id || !desc) return { ok: false, message: 'usage: /vibe add-proposition <id> <概述>' }; const p = { id: id, 标题: id, 状态: '未定论', 概率: 0.5, 优先级: 1, 依赖: [], 价值关键性: 0.5, 分类: '未分类', 陈述: desc, proofs: [], refutes: [], 来源问题: '', 在问题清单: false }; propos.set(p.id, p); await saveProposition(p); await rebuildIndex(); scheduleTick(); return { ok: true, proposition: p, file: propositionRel(p) } }
4243
+ if (cmd === 'add') { const raw = args[0]; const desc = args.slice(1).join(' '); if (!raw || !desc) return { ok: false, message: 'usage: /vibe add <id> <description>' }; const id = idSafe(raw); if (problems.has(id)) return { ok: false, message: 'problem id already exists' }; problems.set(id, { id: id, 标题: id, 状态: '求解中', 优先级: 0, 依赖: [], 被依赖: [], 来源: '原始', 计划: '待调度', 陈述: desc, 来源与动机: '', solutions: [], 判断命题: '', 来源命题: '' }); await saveProblem(problems.get(id)); await rebuildIndex(); scheduleTick(); return { ok: true, message: 'problem added', file: problemRel(problems.get(id)) } }
4244
+ if (cmd === 'add-proposition') { const raw = args[0]; const desc = args.slice(1).join(' '); if (!raw || !desc) return { ok: false, message: 'usage: /vibe add-proposition <id> <概述>' }; const id = idSafe(raw); const p = { id: id, 标题: id, 状态: '未定论', 概率: 0.5, 优先级: 1, 依赖: [], 价值关键性: 0.5, 分类: '未分类', 陈述: desc, proofs: [], refutes: [], 来源问题: '', 在问题清单: false }; propos.set(p.id, p); await saveProposition(p); await rebuildIndex(); scheduleTick(); return { ok: true, proposition: p, file: propositionRel(p) } }
3534
4245
  if (cmd === 'list-propositions') { const all = allPropos(); return { ok: true, count: all.length, propositions: all.map(function (p) { return { id: p.id, 标题: p.标题, 概率: p.概率, 状态: p.状态, 优先级: p.优先级, 分类: categoryOf(p) } }) } }
3535
4246
  if (cmd === 'methods') { const all = Array.from(methods.values()); return { ok: true, count: all.length, methods: all.map(function (m) { return { id: m.id, 标题: m.标题, 类型: m.类型, 状态: m.状态 } }) } }
3536
4247
  if (cmd === 'index') { await loadKnowledgeBase(); return await rebuildIndex() }
@@ -3543,7 +4254,8 @@ export function apply(ctx) {
3543
4254
  }
3544
4255
  if (cmd === 'decisions') return { ok: true, decisions: decisionQueue.filter(function (d) { return d.status === 'pending' }).map(function (d) { return { id: d.id, node: d.node, context: d.context } }) }
3545
4256
  if (cmd === 'agents') { const out = []; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) { const m = agentRegistry[ids[i]]; out.push({ childId: ids[i], role: m.role, qid: m.qid, direction: m.direction, round: m.round }) } return { ok: true, agents: out } }
3546
- return { ok: false, usage: 'start | resume | pause | abort | status | report | mode <auto|manual> | setup | save | template [global|project] | add <id> <desc> | add-proposition <id> <概述> | list-propositions | methods | index | plan | lock | project [list|new <name>|<name>] | decisions | agents', message: 'unknown /vibe subcommand: ' + (cmd || '(empty)') }
4257
+ if (cmd === 'paper') return await paperCommand(args)
4258
+ return { ok: false, usage: 'start [override] | resume [override] | pause | abort | status | report | mode <auto|manual> | setup | save | template [global|project] | add <id> <desc> | add-proposition <id> <概述> | list-propositions | methods | index | plan | lock | project [list|new <name>|<name>] | decisions | agents | paper [lang=zh|en] [format=both|md|tex] [force]', message: 'unknown /vibe subcommand: ' + (cmd || '(empty)') }
3547
4259
  }
3548
4260
 
3549
4261
  // ================= session surface =================
@@ -3556,11 +4268,29 @@ export function apply(ctx) {
3556
4268
  get scheduler() { return scheduler },
3557
4269
  get tickInFlight() { return tickInFlight },
3558
4270
  scheduleTick: scheduleTick,
4271
+ // Lease renewal for the apply-scope interval (see the ctx.effect below). Kept on the session so
4272
+ // the timer needs no per-session context, and so a mock host WITHOUT ctx.timeout still renews.
4273
+ renewLockIfDue: renewProjectLock,
3559
4274
  onChildEnd: onChildEnd,
3560
4275
  dispatchVibeCommand: dispatchVibeCommand,
3561
4276
  handlers: handlers,
3562
4277
  refreshProject: async function () { if (rootAgent) currentProject = await readCurrentProject() },
3563
4278
  getRunning: function () { return scheduler.running },
4279
+ // apply 级心跳用(修订 §A2):running=false 之后 scheduleTick() 是空操作,论文的排队重试
4280
+ // (激活上限)与 paper 锁续租必须由**独立于调度器**的心跳驱动。
4281
+ paperRetryDue: paperRetryDue,
4282
+ runPaperRetry: runPaperRetry,
4283
+ // childOwner 裁剪用(审计 L1):这个会话当前仍"可能再发 subagent/end"的 child
4284
+ // = 在册子代理 + 任何任务正在等的那几个。
4285
+ referencedChildIds: function () {
4286
+ const out = Object.keys(agentRegistry)
4287
+ const ids = Object.keys(tasks)
4288
+ for (let i = 0; i < ids.length; i++) {
4289
+ const t = tasks[ids[i]]
4290
+ if (t && Array.isArray(t.children)) for (let j = 0; j < t.children.length; j++) out.push(t.children[j])
4291
+ }
4292
+ return out
4293
+ },
3564
4294
  tickDue: function () { const iv = Math.max(200, Number(params.tickIntervalMs) || 2000); return (now() - lastTickAt) >= iv },
3565
4295
  }
3566
4296
  }
@@ -3581,47 +4311,47 @@ export function apply(ctx) {
3581
4311
  },
3582
4312
  }))
3583
4313
  }
3584
- registerTool('vibe_math_start', 'Start (or restart) the Vibe Math V3 scheduler for the current project.', objParams({}), 'vibe_math_start')
3585
- registerTool('vibe_math_resume', 'Resume the Vibe Math V3 scheduler after a checkpoint/restart.', objParams({}), 'vibe_math_resume')
3586
- registerTool('vibe_math_pause', 'Pause the scheduler (in-flight children finish their current turn).', objParams({}), 'vibe_math_pause')
3587
- registerTool('vibe_math_abort', 'Abort the scheduler and interrupt all active children.', objParams({}), 'vibe_math_abort')
3588
- registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), 'vibe_math_status')
3589
- registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json + Logs/报告.md.', objParams({}), 'vibe_math_report')
3590
- registerTool('vibe_math_set_mode', 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.', objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }, ['mode']), 'vibe_math_set_mode')
3591
- registerTool('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 工具链的调用方式。', objParams({ 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' } }), 'vibe_math_set_params')
3592
- registerTool('vibe_math_setup', 'Return the interactive parameter schema for guided configuration.', objParams({}), 'vibe_math_setup')
3593
- registerTool('vibe_math_save_settings', 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.', objParams({}), 'vibe_math_save_settings')
3594
- registerTool('vibe_math_template', 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.', objParams({ where: { type: 'string', enum: ['global', 'project'] } }), 'vibe_math_template')
3595
- registerTool('vibe_math_add_problem', 'Add a problem to the current project (creates Problems/<id>.md).', objParams({ id: { type: 'string' }, description: { type: 'string' }, priority: { type: 'integer' }, dependencies: { type: 'array', items: { type: 'string' } } }, ['id', 'description']), 'vibe_math_add_problem')
3596
- registerTool('vibe_math_add_proposition', 'Add a proposition to Propos/ (creates Propos/<分类>/<id>.md).', objParams({ id: { type: 'string' }, 概述: { type: 'string' }, 概率: { type: 'number' }, 优先级: { type: 'integer' }, '价值/关键性': { type: 'number' }, 分类: { type: 'string' } }, ['id', '概述']), 'vibe_math_add_proposition')
3597
- registerTool('vibe_math_list_propositions', 'List propositions from Propos/ (summary index).', objParams({}), 'vibe_math_list_propositions')
3598
- registerTool('vibe_math_new_project', 'Create a new math project folder and switch to it.', objParams({ name: { type: 'string' } }, ['name']), 'vibe_math_new_project')
3599
- registerTool('vibe_math_set_project', 'Switch the current math project.', objParams({ name: { type: 'string' } }, ['name']), 'vibe_math_set_project')
3600
- registerTool('vibe_math_list_projects', 'List math projects.', objParams({}), 'vibe_math_list_projects')
3601
- registerTool('vibe_math_list_decisions', 'List pending manual decisions.', objParams({}), 'vibe_math_list_decisions')
3602
- registerTool('vibe_math_decide', 'Resolve a pending manual decision (plan: approve|reject; verdict: override with verdict 1|0; spawn: approve|reject; method-promote: approve|reject).', objParams({ id: { type: 'string' }, action: { type: 'string', enum: ['approve', 'reject', 'override'] }, verdict: { type: 'number' } }, ['id', 'action']), 'vibe_math_decide')
3603
- registerTool('vibe_math_list_agents', 'List tracked sub-agents (child sessions).', objParams({}), 'vibe_math_list_agents')
3604
- registerTool('vibe_math_message_agent', 'Send a message to a tracked child agent (next turn).', objParams({ childId: { type: 'string' }, message: { type: 'string' } }, ['childId', 'message']), 'vibe_math_message_agent')
3605
- registerTool('vibe_math_interrupt_agent', 'Interrupt a tracked child agent.', objParams({ childId: { type: 'string' } }, ['childId']), 'vibe_math_interrupt_agent')
3606
- registerTool('vibe_math_plan', 'Show the queued plan / last plan result, or force a planning round.', objParams({ force: { type: 'boolean' } }), 'vibe_math_plan')
3607
- registerTool('vibe_math_index', 'Rebuild the machine index (State/index.json) from the Markdown knowledge base.', objParams({}), 'vibe_math_index')
3608
- registerTool('vibe_math_method_add', 'Manually add a method card to Methods/ (creates Methods/<id>.md).', objParams({ id: { type: 'string' }, 标题: { type: 'string' }, 类型: { type: 'string' }, 核心内容: { type: 'string' }, 适用场景: { type: 'string' } }, ['id', '标题']), 'vibe_math_method_add')
3609
- registerTool('vibe_math_method_list', 'List methods from Methods/ (+ global VibeMath/Methods/).', objParams({}), 'vibe_math_method_list')
3610
- registerTool('vibe_math_lock_status', 'Show the project lock occupancy.', objParams({}), 'vibe_math_lock_status')
3611
- registerTool('vibe_math_claim_write', 'Acquire the write lock for one target file (relative to the project root). Call before writing a Markdown file directly.', objParams({ target: { type: 'string' } }, ['target']), 'vibe_math_claim_write')
3612
- registerTool('vibe_math_release_write', 'Release the write lock for one target file (relative to the project root).', objParams({ target: { type: 'string' } }, ['target']), 'vibe_math_release_write')
3613
- registerTool('vibe_math_sync_meta', 'After you write content into Markdown files, report ONLY lightweight scheduling metadata to keep the scheduler state in sync (content stays in the md files). meta.kind must be one of:\n- "directions": {qid, directions:[{id,title,method,core_assumption,feasibility}], methods_used:[{id,效果,建议}], new_inventions:[{类型,标题,内容描述,是否已入库}]}\n- "solver": {qid, dirId, round, survival, status:"continue|success|dead-end", dead_end_reason, lemmas:[{id,title,statement,proof,prob,分类,优先级}], methods_used, new_inventions, solution_prob, solution_text, sub_questions:[{q_sub_title,q_sub_statement,assumption_title,assumption_statement}]}\n- "methods": {used:[{id,效果,建议}], created:[ids], improvements:[{id,改进内容,原因}]}', objParams({ meta: { type: 'object' } }, ['meta']), 'vibe_math_sync_meta')
4314
+ registerTool('vibe_math_start', TOOL_DESC.vibe_math_start, objParams({ override: { type: 'boolean' } }), 'vibe_math_start')
4315
+ registerTool('vibe_math_resume', TOOL_DESC.vibe_math_resume, objParams({ override: { type: 'boolean' } }), 'vibe_math_resume')
4316
+ registerTool('vibe_math_pause', TOOL_DESC.vibe_math_pause, objParams({}), 'vibe_math_pause')
4317
+ registerTool('vibe_math_abort', TOOL_DESC.vibe_math_abort, objParams({}), 'vibe_math_abort')
4318
+ registerTool('vibe_math_status', TOOL_DESC.vibe_math_status, objParams({}), 'vibe_math_status')
4319
+ registerTool('vibe_math_report', TOOL_DESC.vibe_math_report, objParams({}), 'vibe_math_report')
4320
+ 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')
4322
+ registerTool('vibe_math_setup', TOOL_DESC.vibe_math_setup, objParams({}), 'vibe_math_setup')
4323
+ registerTool('vibe_math_save_settings', TOOL_DESC.vibe_math_save_settings, objParams({}), 'vibe_math_save_settings')
4324
+ registerTool('vibe_math_template', TOOL_DESC.vibe_math_template, objParams({ where: { type: 'string', enum: ['global', 'project'] } }), 'vibe_math_template')
4325
+ registerTool('vibe_math_add_problem', TOOL_DESC.vibe_math_add_problem, objParams({ id: { type: 'string' }, description: { type: 'string' }, priority: { type: 'integer' }, dependencies: { type: 'array', items: { type: 'string' } } }, ['id', 'description']), 'vibe_math_add_problem')
4326
+ registerTool('vibe_math_add_proposition', TOOL_DESC.vibe_math_add_proposition, objParams({ id: { type: 'string' }, 概述: { type: 'string' }, 概率: { type: 'number' }, 优先级: { type: 'integer' }, '价值/关键性': { type: 'number' }, 分类: { type: 'string' }, 来源问题: { type: 'string' }, 来源方向: { type: 'string' } }, ['id', '概述']), 'vibe_math_add_proposition')
4327
+ registerTool('vibe_math_list_propositions', TOOL_DESC.vibe_math_list_propositions, objParams({}), 'vibe_math_list_propositions')
4328
+ registerTool('vibe_math_new_project', TOOL_DESC.vibe_math_new_project, objParams({ name: { type: 'string' } }, ['name']), 'vibe_math_new_project')
4329
+ registerTool('vibe_math_set_project', TOOL_DESC.vibe_math_set_project, objParams({ name: { type: 'string' } }, ['name']), 'vibe_math_set_project')
4330
+ registerTool('vibe_math_list_projects', TOOL_DESC.vibe_math_list_projects, objParams({}), 'vibe_math_list_projects')
4331
+ registerTool('vibe_math_list_decisions', TOOL_DESC.vibe_math_list_decisions, objParams({}), 'vibe_math_list_decisions')
4332
+ registerTool('vibe_math_decide', TOOL_DESC.vibe_math_decide, objParams({ id: { type: 'string' }, action: { type: 'string', enum: ['approve', 'reject', 'override'] }, verdict: { type: 'number' } }, ['id', 'action']), 'vibe_math_decide')
4333
+ registerTool('vibe_math_list_agents', TOOL_DESC.vibe_math_list_agents, objParams({}), 'vibe_math_list_agents')
4334
+ registerTool('vibe_math_message_agent', TOOL_DESC.vibe_math_message_agent, objParams({ childId: { type: 'string' }, message: { type: 'string' } }, ['childId', 'message']), 'vibe_math_message_agent')
4335
+ registerTool('vibe_math_interrupt_agent', TOOL_DESC.vibe_math_interrupt_agent, objParams({ childId: { type: 'string' } }, ['childId']), 'vibe_math_interrupt_agent')
4336
+ registerTool('vibe_math_plan', TOOL_DESC.vibe_math_plan, objParams({ force: { type: 'boolean' } }), 'vibe_math_plan')
4337
+ registerTool('vibe_math_index', TOOL_DESC.vibe_math_index, objParams({}), 'vibe_math_index')
4338
+ registerTool('vibe_math_method_add', TOOL_DESC.vibe_math_method_add, objParams({ id: { type: 'string' }, 标题: { type: 'string' }, 类型: { type: 'string' }, 核心内容: { type: 'string' }, 适用场景: { type: 'string' } }, ['id', '标题']), 'vibe_math_method_add')
4339
+ registerTool('vibe_math_method_list', TOOL_DESC.vibe_math_method_list, objParams({}), 'vibe_math_method_list')
4340
+ registerTool('vibe_math_lock_status', TOOL_DESC.vibe_math_lock_status, objParams({}), 'vibe_math_lock_status')
4341
+ registerTool('vibe_math_claim_write', TOOL_DESC.vibe_math_claim_write, objParams({ target: { type: 'string' } }, ['target']), 'vibe_math_claim_write')
4342
+ registerTool('vibe_math_release_write', TOOL_DESC.vibe_math_release_write, objParams({ target: { type: 'string' } }, ['target']), 'vibe_math_release_write')
4343
+ registerTool('vibe_math_sync_meta', TOOL_DESC.vibe_math_sync_meta, objParams({ meta: { type: 'object' } }, ['meta']), 'vibe_math_sync_meta')
3614
4344
  // Lean 形式化验证(docs/formal-verification.md):三个工具**无条件注册**——注册是静态的,
3615
4345
  // 模式只决定框架是否主动告诉代理它们存在。off 模式下人/代理主动调用时照常工作。
3616
- registerTool('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.', objParams({ file: { type: 'string' }, target: { type: 'string' }, timeout_ms: { type: 'integer' } }, ['file']), 'vibe_math_lean_run')
3617
- registerTool('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).', 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')
3618
- registerTool('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.", objParams({ refresh: { type: 'boolean' } }), 'vibe_math_lean_lib')
4346
+ 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
+ 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
+ registerTool('vibe_math_lean_lib', TOOL_DESC.vibe_math_lean_lib, objParams({ refresh: { type: 'boolean' } }), 'vibe_math_lean_lib')
3619
4349
 
3620
4350
  // /vibe slash command (registered once; routed per session)
3621
4351
  ctx.effect(() => commands.register({
3622
4352
  name: 'vibe',
3623
4353
  description: 'control the Vibe Math V3 solver (start/pause/projects/setup/save/decisions/agents/methods/index/plan/lock)',
3624
- input: { hint: '[start|resume|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|methods|index|plan|lock|project [list|new <name>|<name>]|decisions|agents]' },
4354
+ input: { hint: '[start [override]|resume [override]|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|methods|index|plan|lock|project [list|new <name>|<name>]|decisions|agents|paper [lang=zh|en] [format=both|md|tex] [force]]' },
3625
4355
  handler: async function (invocation) {
3626
4356
  const s = getSession(invocation && invocation.agent)
3627
4357
  if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no vibe-math session for this agent' }) }
@@ -3640,16 +4370,61 @@ export function apply(ctx) {
3640
4370
 
3641
4371
  // subagent/end (registered once; routed to the owning session via childOwner)
3642
4372
  ctx.on('subagent/end', function (info) {
4373
+ // 记下最近一次 end 的时间:childOwner 的宽限期从这里算(见 pruneChildOwner)。
4374
+ lastChildEndAt.set(info.id, now())
3643
4375
  const sid = childOwner.get(info.id)
3644
4376
  const s = sid !== undefined ? sessions.get(sid) : undefined
3645
4377
  if (s) s.onChildEnd(info).catch(function (e) { console.error('vibe-math-v3 onChildEnd reject: ' + String((e && e.stack) || e)) })
3646
- // 注意:这里**不要**回收 childOwner 条目(与 v2 同一结论)。该映射在子代理 end 之后仍会
4378
+ // 注意:这里**不要**立即回收 childOwner 条目(与 v2 同一结论)。该映射在子代理 end 之后仍会
3647
4379
  // 被后续事件路由用到;在 v2 上实测过"事件回调里回收"与"onChildEnd 末尾回收"两种写法,
3648
- // 都会让 verdict 收口失效。代价只是每个历史子代理一条小记录(有界、不影响功能)。
4380
+ // 都会让 verdict 收口失效。回收交给引用 + 宽限期的 pruneChildOwner(审计 L1)。
3649
4381
  })
3650
4382
 
4383
+ // Project-lock LEASE renewal timer (Round C, HIGH): renewal must NOT live in tick().
4384
+ //
4385
+ // Round A put `await renewProjectLock()` at the top of tick(), which left the lease starvable: a
4386
+ // single tick whose awaited work outlives projectLockTimeoutMs (one slow shell/Lean call is enough)
4387
+ // keeps the on-disk `at` stale, and the `if (tickInFlight) return` early-return (:1374) renews
4388
+ // nothing either, so a second session's acquireProjectLock() saw `age >= timeout` and took the
4389
+ // project lock WITHOUT `override` while this session was alive and mid-tick. Measured: 5803 ms of
4390
+ // stale lease against a 2500 ms timeout (probe _oneoff/rb-probe-v3-locklease.mjs).
4391
+ //
4392
+ // An independent interval cannot be delayed by tick duration, tick overlap or a held gate. It is
4393
+ // registered once per preset via ctx.effect (disposed with the plugin) — NOT per startScheduler —
4394
+ // so an `await` inside its callback cannot race plugin disposal, and the scheduler stays mockable
4395
+ // (tests/selfdrive-v3.mjs has no ctx.timeout). The poll runs many times inside one lease window,
4396
+ // so the lease can never approach expiry however long a single tick runs; renewProjectLock() itself
4397
+ // is still the throttled writer and keeps its `scheduler.running` / ownership guards, which preserves
4398
+ // the crash-recovery path exactly: a dead process stops renewing, its lease ages out, and only then
4399
+ // may a new session take it over.
4400
+ // 心跳定时器(每个宿主一次,随插件 fiber 释放)。
4401
+ //
4402
+ // L7:优先用**宿主的 timer 服务**(`ctx.interval`,随宿主暂停/记账、随 fiber 释放),
4403
+ // 只有在宿主没有该服务时(测试 mock / 旧线)才回落到裸 setInterval——回落是必需的:
4404
+ // `inject` 里没有 'timer'(加进去会让缺该服务的主机整个不激活),而 mock 宿主没有 ctx.interval。
4405
+ // 两种实现都返回 disposer,ctx.effect 的清理语义不变。
4406
+ function everyMs(ms, fn) {
4407
+ try { if (typeof ctx.interval === 'function') return ctx.interval(fn, ms) } catch (e) { /* 回落到 setInterval */ }
4408
+ const t = setInterval(fn, ms)
4409
+ return () => clearInterval(t)
4410
+ }
4411
+ 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)) }) } }))
3651
4412
  // tick timer (registered once; ticks every running session at its own pace)
3652
- ctx.effect(() => { const t = setInterval(function () { for (const s of sessions.values()) { if (s.getRunning() && !s.tickInFlight && s.tickDue() && s.scheduler.gate === null) s.scheduleTick() } }, 1000); return () => clearInterval(t) })
4413
+ ctx.effect(() => {
4414
+ let beat = 0
4415
+ return everyMs(1000, function () {
4416
+ for (const s of sessions.values()) {
4417
+ if (s.getRunning() && !s.tickInFlight && s.tickDue() && s.scheduler.gate === null) s.scheduleTick()
4418
+ // 论文心跳(修订 §A2):**独立于 scheduler.running** —— 收口后 running=false,但被激活上限
4419
+ // 拒绝的撰写派遣仍要重试,在途时还要续租 paper 锁。runPaperRetry() 无事可做时立即返回。
4420
+ if (typeof s.runPaperRetry === 'function') s.runPaperRetry().catch(function (e) { console.error('vibe-math-v3: paper retry failed: ' + String((e && e.message) || e)) })
4421
+ }
4422
+ // childOwner 裁剪(审计 L1):每 30 拍(约 30s)一次,成本是"会话数 × 映射数"的一次扫描。
4423
+ if ((++beat % 30) === 0) {
4424
+ try { pruneChildOwner() } catch (e) { console.error('vibe-math-v3: pruneChildOwner failed: ' + String((e && e.message) || e)) }
4425
+ }
4426
+ })
4427
+ })
3653
4428
  }
3654
4429
 
3655
4430
  // ---- test seam: pure, stateless helpers --------------------------------
@@ -3665,6 +4440,255 @@ export function apply(ctx) {
3665
4440
  // to the clock) and `parseProgress` normalises the object it is handed in place (pre-existing).
3666
4441
  // Nothing here is used by the plugin at runtime except through `apply()`, and behaviour is
3667
4442
  // byte-identical to the previous in-`apply` declarations.
4443
+ // ============================================================================================
4444
+ // 最终论文(规格:docs/final-paper.md)—— **纯函数部分**(module scope;
4445
+ // 与 v2 逐字同构,只有数据来源不同)。会话相关的落盘/派遣/编译在 makeSession 里。
4446
+ // ============================================================================================
4447
+ /** 固定 9 节骨架(spec §3)。key 同时是 md 的 `## ` 标题与 tex 的 `\section{}`。 */
4448
+ const PAPER_SKELETON = [
4449
+ { key: '摘要', spec: '1 标题、作者、日期、摘要(原问题 + 主要结论)' },
4450
+ { key: '引言与问题背景', spec: '2 原问题的完整陈述' },
4451
+ { key: '原问题的完整解法', spec: '3 最终答案 + 完整推理链' },
4452
+ { key: '已检验通过的命题', spec: '4 逐条列出,含判定为真的估计值与证据来源' },
4453
+ { key: '已解决的子问题与中间成果', spec: '5' },
4454
+ { key: '创造或发现的有价值之物', spec: '6 方法、理论、思想、有价值经验、数学理解' },
4455
+ { key: '规律总结', spec: '7 从上述条目归纳出的可复用规律' },
4456
+ { key: '讨论、局限与展望', spec: '8' },
4457
+ { key: '附录:证据与文件索引', spec: '9 Verified/、Logs/、关键卡片路径' },
4458
+ ]
4459
+ const PAPER_NO_EVIDENCE = '(本节暂无证据支持的内容——不编造。)'
4460
+ const PAPER_EVIDENCE_HEADING = '证据与文件索引'
4461
+ /** LaTeX 引擎检测顺序(spec §5):中文优先 xelatex,英文优先 pdflatex/latexmk。 */
4462
+ const PAPER_ENGINE_ORDER_ZH = ['xelatex', 'latexmk', 'pdflatex', 'lualatex', 'tectonic']
4463
+ const PAPER_ENGINE_ORDER_EN = ['pdflatex', 'latexmk', 'xelatex', 'lualatex', 'tectonic']
4464
+ /** 只用常见宏包(spec §5);修复阶段会丢弃不在这个名单里的 \usepackage。 */
4465
+ const PAPER_ALLOWED_PACKAGES = ['amsmath', 'amssymb', 'amsthm', 'geometry', 'hyperref', 'longtable', 'booktabs']
4466
+ /** tex 必须转义的 10 个字符(spec §5):\ _ % & # $ { } ~ ^ ——一次替换,避免二次转义。 */
4467
+ const PAPER_TEX_MAP = { '\\': '\\textbackslash{}', '_': '\\_', '%': '\\%', '&': '\\&', '#': '\\#', '$': '\\$', '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}' }
4468
+ function texEscape(s) { return String(s == null ? '' : s).replace(/[\\_%&#${}~^]/g, function (c) { return PAPER_TEX_MAP[c] }) }
4469
+ /** 材料摘要(只作记录;幂等判定用 run id + finalizedAt + 逐产物存在性,修订 §C)。 */
4470
+ function paperMaterialSummary(digest) {
4471
+ const t = String(digest == null ? '' : digest)
4472
+ return { chars: t.length, lines: t.split('\n').length }
4473
+ }
4474
+ /**
4475
+ * 撰写者卡死判定(纯函数,便于守卫覆盖两条分支):
4476
+ * 'none' 正常在途(还没到卡死窗口,且 force 未要求放弃)
4477
+ * 'reap' 必须回收:child 已不在 agentRegistry(end 事件丢失 / 宿主丢弃,它永远不会再回来),
4478
+ * 或已超过卡死窗口——force 与心跳都据此自动降级,论文不能被一个死掉的撰写者永久卡住
4479
+ * 'refuse' force 但还没到窗口:调用方必须给出**可执行**的报错(告诉调用者还要等多久)
4480
+ */
4481
+ function paperWriterVerdict(o) {
4482
+ if (!(o && o.inFlight)) return 'none'
4483
+ if (o.childInRegistry === false) return 'reap'
4484
+ const age = Number((o && o.ageMs) || 0)
4485
+ const stale = Math.max(1, Number((o && o.staleMs) || 0))
4486
+ if (age >= stale) return 'reap'
4487
+ return (o && o.force) ? 'refuse' : 'none'
4488
+ }
4489
+ /** 行内 md → tex:``code`` / **bold** / *em* 转成命令,其余按 spec §5 转义;`$...$` 原样保留。 */
4490
+ function paperInlineToTex(s) {
4491
+ const t = String(s == null ? '' : s)
4492
+ let out = ''
4493
+ let i = 0
4494
+ while (i < t.length) {
4495
+ const c = t[i]
4496
+ if (c === '$') { const j = t.indexOf('$', i + 1); if (j > i) { out += t.slice(i, j + 1); i = j + 1; continue } }
4497
+ if (c === '`') { const j = t.indexOf('`', i + 1); if (j > i) { out += '\\texttt{' + texEscape(t.slice(i + 1, j)) + '}'; i = j + 1; continue } }
4498
+ if (c === '*' && t[i + 1] === '*') { const j = t.indexOf('**', i + 2); if (j > i) { out += '\\textbf{' + paperInlineToTex(t.slice(i + 2, j)) + '}'; i = j + 2; continue } }
4499
+ if (c === '*') { const j = t.indexOf('*', i + 1); if (j > i) { out += '\\emph{' + paperInlineToTex(t.slice(i + 1, j)) + '}'; i = j + 1; continue } }
4500
+ out += PAPER_TEX_MAP[c] !== undefined ? PAPER_TEX_MAP[c] : c
4501
+ i++
4502
+ }
4503
+ return out
4504
+ }
4505
+ /** md 正文 → tex 正文(标题/列表/段落;只认 compose 侧约定的最小语法)。 */
4506
+ function paperMdToTexBody(md) {
4507
+ const out = []
4508
+ let inList = false
4509
+ let buf = []
4510
+ const flush = function () { if (buf.length) { out.push(paperInlineToTex(buf.join(' '))); buf = [] } }
4511
+ const closeList = function () { if (inList) { out.push('\\end{itemize}'); inList = false } }
4512
+ const lines = String(md == null ? '' : md).split(/\r?\n/)
4513
+ for (let i = 0; i < lines.length; i++) {
4514
+ const line = lines[i].replace(/\s+$/, '')
4515
+ const h = /^(#{1,4})\s+(.*)$/.exec(line)
4516
+ if (h) {
4517
+ flush(); closeList()
4518
+ const txt = paperInlineToTex(h[2])
4519
+ const lv = h[1].length
4520
+ out.push(lv <= 1 ? ('\\section*{' + txt + '}') : lv === 2 ? ('\\section{' + txt + '}') : lv === 3 ? ('\\subsection{' + txt + '}') : ('\\subsubsection{' + txt + '}'))
4521
+ continue
4522
+ }
4523
+ const li = /^\s*[-*]\s+(.*)$/.exec(line)
4524
+ if (li) { flush(); if (!inList) { out.push('\\begin{itemize}'); inList = true } out.push(' \\item ' + paperInlineToTex(li[1])); continue }
4525
+ if (!line.trim()) { flush(); closeList(); out.push(''); continue }
4526
+ buf.push(line.trim())
4527
+ }
4528
+ flush(); closeList()
4529
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
4530
+ }
4531
+ /** 主模板(spec §5):中文 ctexart / 英文 article,只加常见宏包。 */
4532
+ function paperTexDoc(o) {
4533
+ const lang = (o && o.lang === 'en') ? 'en' : 'zh'
4534
+ return [
4535
+ '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}',
4536
+ '\\usepackage[margin=2.5cm]{geometry}',
4537
+ '\\usepackage{amsmath,amssymb,amsthm}',
4538
+ '\\usepackage{hyperref}',
4539
+ '\\usepackage{longtable,booktabs}',
4540
+ '\\title{' + texEscape(o && o.title) + '}',
4541
+ '\\author{' + texEscape(o && o.author) + '}',
4542
+ '\\date{' + texEscape(o && o.date) + '}',
4543
+ '\\begin{document}',
4544
+ '\\maketitle',
4545
+ String((o && o.bodyTex) || ''),
4546
+ '\\end{document}',
4547
+ ].join('\n') + '\n'
4548
+ }
4549
+ /** 修复阶段的**最小模板**(spec §5):只留 documentclass + 正文。 */
4550
+ function paperMinimalTexDoc(o) {
4551
+ const lang = (o && o.lang === 'en') ? 'en' : 'zh'
4552
+ return [
4553
+ '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}',
4554
+ '\\begin{document}',
4555
+ String((o && o.bodyTex) || ''),
4556
+ '\\end{document}',
4557
+ ].join('\n') + '\n'
4558
+ }
4559
+ /** 修复阶段:丢弃不在常见宏包名单里的 \usepackage(其余原样保留)。 */
4560
+ function paperSanitizeTex(tex) {
4561
+ const keep = []
4562
+ const lines = String(tex == null ? '' : tex).split('\n')
4563
+ for (let i = 0; i < lines.length; i++) {
4564
+ const m = /^\s*\\usepackage(\[[^\]]*\])?\{([^}]*)\}\s*$/.exec(lines[i])
4565
+ if (m) {
4566
+ const pkgs = String(m[2]).split(',').map(function (x) { return x.trim() }).filter(Boolean)
4567
+ const ok = pkgs.filter(function (p) { return PAPER_ALLOWED_PACKAGES.indexOf(p) >= 0 })
4568
+ if (ok.length === 0) continue
4569
+ keep.push('\\usepackage' + (m[1] || '') + '{' + ok.join(',') + '}')
4570
+ continue
4571
+ }
4572
+ keep.push(lines[i])
4573
+ }
4574
+ return keep.join('\n')
4575
+ }
4576
+ /** 骨架名匹配:允许带序号/前缀,或只写节名的一部分(例如「附录」)。 */
4577
+ function paperSkeletonKey(name) {
4578
+ const n = String(name == null ? '' : name).replace(/^#+\s*/, '').trim()
4579
+ if (!n) return undefined
4580
+ for (const s of PAPER_SKELETON) if (n === s.key) return s.key
4581
+ for (const s of PAPER_SKELETON) if (n.indexOf(s.key) !== -1 || s.key.indexOf(n) !== -1) return s.key
4582
+ return undefined
4583
+ }
4584
+ /** 按 `## ` 把任意 markdown 切成 [{name,text}](无标题时 name='')。 */
4585
+ function paperSplitByHeadings(md) {
4586
+ const out = []
4587
+ let cur = { name: '', text: [] }
4588
+ const lines = String(md == null ? '' : md).split(/\r?\n/)
4589
+ for (let i = 0; i < lines.length; i++) {
4590
+ const h = /^##\s+(.*?)\s*$/.exec(lines[i])
4591
+ if (h) { if (cur.name || cur.text.length) out.push({ name: cur.name, text: cur.text.join('\n').trim() }); cur = { name: h[1], text: [] }; continue }
4592
+ cur.text.push(lines[i])
4593
+ }
4594
+ if (cur.name || cur.text.length) out.push({ name: cur.name, text: cur.text.join('\n').trim() })
4595
+ return out
4596
+ }
4597
+ /** 把「论文撰写」子代理的回复映射到 9 节骨架上(**绝不编造**:缺的节由调用方填占位说明)。 */
4598
+ function paperSkeletonFromReply(reply, fallbackText) {
4599
+ const sections = {}
4600
+ const notes = []
4601
+ let title = ''
4602
+ let abstract = ''
4603
+ const put = function (key, body) {
4604
+ const b = String(body == null ? '' : body).trim()
4605
+ if (!b) return
4606
+ sections[key] = sections[key] ? (sections[key] + '\n\n' + b) : b
4607
+ }
4608
+ const absorbUnknown = function (name, text) {
4609
+ if (!name && !String(text || '').trim()) return
4610
+ notes.push('作者写了一节「' + (name || '(无名)') + '」,不属于固定 9 节骨架,已并入「讨论、局限与展望」')
4611
+ put('讨论、局限与展望', (name ? ('**' + name + '**\n\n') : '') + text)
4612
+ }
4613
+ const obj = (reply && typeof reply === 'object' && !Array.isArray(reply)) ? reply : null
4614
+ if (obj) {
4615
+ title = String(obj.title || obj.标题 || '').trim()
4616
+ abstract = String(obj.abstract || obj.摘要 || '').trim()
4617
+ const list = Array.isArray(obj.sections) ? obj.sections : (Array.isArray(obj.章节) ? obj.章节 : [])
4618
+ for (let i = 0; i < list.length; i++) {
4619
+ const s = list[i]
4620
+ if (!s) continue
4621
+ const name = String(s.name || s.key || s.heading || s.标题 || '')
4622
+ const body = String(s.body != null ? s.body : (s.content != null ? s.content : (s.内容 != null ? s.内容 : '')))
4623
+ const key = paperSkeletonKey(name)
4624
+ if (key === undefined) absorbUnknown(name.trim(), body)
4625
+ else put(key, body)
4626
+ }
4627
+ const bodyField = String(obj.body || obj.content || obj.md || obj.正文 || '')
4628
+ if (bodyField.trim()) {
4629
+ const parts = paperSplitByHeadings(bodyField)
4630
+ for (let i = 0; i < parts.length; i++) {
4631
+ const key = paperSkeletonKey(parts[i].name)
4632
+ if (key === undefined) { if (!parts[i].name && !abstract && !sections['摘要']) abstract = parts[i].text; else if (parts[i].name) absorbUnknown(parts[i].name, parts[i].text) }
4633
+ else put(key, parts[i].text)
4634
+ }
4635
+ }
4636
+ } else {
4637
+ const parts = paperSplitByHeadings(String(fallbackText == null ? '' : fallbackText))
4638
+ let matched = 0
4639
+ for (let i = 0; i < parts.length; i++) {
4640
+ const key = paperSkeletonKey(parts[i].name)
4641
+ if (key === undefined) { if (parts[i].name) absorbUnknown(parts[i].name, parts[i].text) }
4642
+ else { put(key, parts[i].text); matched++ }
4643
+ }
4644
+ if (matched === 0) {
4645
+ const raw = String(fallbackText == null ? '' : fallbackText).trim()
4646
+ if (raw) { notes.push('作者回复不是契约 JSON,也没有可识别的 `## ` 节标题;其原样正文已记入「讨论、局限与展望」'); put('讨论、局限与展望', raw) }
4647
+ else notes.push('作者回复为空:没有任何内容可写(不得编造)')
4648
+ }
4649
+ }
4650
+ return { title: title, abstract: abstract, sections: sections, notes: notes }
4651
+ }
4652
+ /** 组装 md(spec §3):9 节固定骨架 + 确定性证据索引;缺证据的节写占位说明。 */
4653
+ function paperBuildMarkdown(o) {
4654
+ const sec = (o && o.sections) || {}
4655
+ const ev = ((o && o.evidence) || []).slice()
4656
+ const L = []
4657
+ const ttl = String((o && o.title) || '').trim() || '研究报告'
4658
+ L.push('# ' + ttl)
4659
+ L.push('')
4660
+ L.push('- 作者:' + String((o && o.author) || ''))
4661
+ L.push('- 日期:' + String((o && o.date) || ''))
4662
+ L.push('')
4663
+ for (let i = 0; i < PAPER_SKELETON.length; i++) {
4664
+ const key = PAPER_SKELETON[i].key
4665
+ L.push('## ' + key)
4666
+ L.push('')
4667
+ let body = String(sec[key] || '').trim()
4668
+ if (key === '摘要') { const ab = String((o && o.abstract) || '').trim(); if (ab) body = body ? (ab + '\n\n' + body) : ab }
4669
+ if (key === '附录:证据与文件索引' && ev.length) {
4670
+ const idx = '### ' + PAPER_EVIDENCE_HEADING + '\n\n' + ev.map(function (x) { return '- `' + String(x) + '`' }).join('\n')
4671
+ body = body ? (body + '\n\n' + idx) : idx
4672
+ }
4673
+ L.push(body || PAPER_NO_EVIDENCE)
4674
+ L.push('')
4675
+ }
4676
+ return L.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
4677
+ }
4678
+ /** paper.log.md 的**唯一格式**(v2/v3 逐字一致,spec §4)。 */
4679
+ function paperLogHead(id) {
4680
+ return ['# 论文写作日志|' + String(id), '',
4681
+ '> 单作者流程(v2/v3 同一格式):收口触发 → 派遣「论文撰写」子代理 → 落盘 md/tex → 编译 pdf。', '']
4682
+ }
4683
+ function paperLogLine(at, tag, text) { return '- ' + fmtTime(at) + ' [' + String(tag) + '] ' + String(text == null ? '' : text) }
4684
+ /** 论文目录 id:只允许安全字符(同一把归一化 ⇒ 不可能写出 Paper/ 之外,spec §6 越权用例)。 */
4685
+ function paperDirId(raw) { return (idSafe(String(raw == null ? '' : raw).trim()) || 'run') }
4686
+ /** 从回复里抽出可能存在的标题行(md 非契约路径的兜底)。 */
4687
+ function paperTitleFromMd(md) {
4688
+ const m = /^#\s+(.*?)\s*$/m.exec(String(md == null ? '' : md))
4689
+ return m ? m[1].trim() : ''
4690
+ }
4691
+
3668
4692
  export const __testHelpers = {
3669
4693
  uuid,
3670
4694
  shortId,
@@ -3682,21 +4706,109 @@ export const __testHelpers = {
3682
4706
  parseEntries,
3683
4707
  section,
3684
4708
  parseBodySections,
4709
+ findSectionHeads,
4710
+ escField,
4711
+ unescField,
4712
+ entryRe,
4713
+ probText,
3685
4714
  extraBodySections,
3686
4715
  parseAppTitle,
3687
4716
  parseMethodMd,
3688
4717
  sanitizeToolFilter,
3689
4718
  registeredToolsFromError,
4719
+ // final paper(spec §6 的纯函数守卫面;与 v2 同构)
4720
+ PAPER_SKELETON,
4721
+ PAPER_NO_EVIDENCE,
4722
+ PAPER_ALLOWED_PACKAGES,
4723
+ PAPER_ENGINE_ORDER_ZH,
4724
+ PAPER_ENGINE_ORDER_EN,
4725
+ texEscape,
4726
+ paperMaterialSummary,
4727
+ paperWriterVerdict,
4728
+ paperInlineToTex,
4729
+ paperMdToTexBody,
4730
+ paperTexDoc,
4731
+ paperMinimalTexDoc,
4732
+ paperSanitizeTex,
4733
+ paperSkeletonKey,
4734
+ paperSkeletonFromReply,
4735
+ paperBuildMarkdown,
4736
+ paperLogHead,
4737
+ paperLogLine,
4738
+ paperDirId,
4739
+ paperTitleFromMd,
3690
4740
  }
3691
4741
 
3692
4742
  function now() { return Date.now() }
3693
4743
 
4744
+ /**
4745
+ * 工具描述的唯一来源(提示词审计 M3):v3 的每个工具都在**两条路径**上注册——会话 handler 表
4746
+ * (makeSession 内只存 handler)与文件末尾真正生效的 `tools.register`。两条路径必须逐字一致,
4747
+ * 否则模型看到的描述取决于哪条路径生效,任何一侧被改都会静默改变提示词面。
4748
+ * 全部 33 个工具都从此表取描述;`tests/audit-v3-registration-parity.mjs` 逐字比对两条路径。
4749
+ */
4750
+ const TOOL_DESC = {
4751
+ vibe_math_start: 'Start (or restart) the Vibe Math V3 scheduler for the current project. override=true forcibly takes over the project lock held by another session (only after you verified that session is gone).',
4752
+ vibe_math_resume: 'Resume the Vibe Math V3 scheduler after a checkpoint/restart. override=true forcibly takes over the project lock held by another session (only after you verified that session is gone).',
4753
+ vibe_math_pause: 'Pause the scheduler (in-flight children finish their current turn).',
4754
+ vibe_math_abort: 'Abort the scheduler and interrupt all active children.',
4755
+ vibe_math_status: 'Show scheduler status, params, active agents, projects, and recent activity.',
4756
+ vibe_math_report: 'Return the full progress report and write it to Progress_Logs/report.json + Logs/报告.md.',
4757
+ vibe_math_set_mode: 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.',
4758
+ // set_params 的 schema 现在也收 mode(M10):同一能力既有专用工具 vibe_math_set_mode,也可用这个键;
4759
+ // 两条注册路径共用这一份描述,`--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/<项目>/。',
4761
+ vibe_math_setup: 'Return the interactive parameter schema for guided configuration.',
4762
+ vibe_math_save_settings: 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.',
4763
+ vibe_math_template: 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.',
4764
+ vibe_math_add_problem: 'Add a problem to the current project (creates Problems/<id>.md).',
4765
+ vibe_math_add_proposition: 'Add a proposition to Propos/ (creates Propos/<分类>/<id>.md).',
4766
+ vibe_math_list_propositions: 'List propositions from Propos/ (summary index: id, 标题, 概率, 状态, 优先级, 分类).',
4767
+ vibe_math_new_project: 'Create a new math project folder and switch to it.',
4768
+ vibe_math_set_project: 'Switch the current math project.',
4769
+ vibe_math_list_projects: 'List math projects.',
4770
+ vibe_math_list_decisions: 'List pending manual decisions.',
4771
+ vibe_math_decide: 'Resolve a pending manual decision (plan: approve|reject; verdict: override with verdict 1|0; spawn: approve|reject; method-promote: approve|reject).',
4772
+ vibe_math_list_agents: 'List tracked sub-agents (child sessions).',
4773
+ vibe_math_message_agent: 'Send a message to a tracked child agent (next turn).',
4774
+ vibe_math_interrupt_agent: 'Interrupt a tracked child agent.',
4775
+ vibe_math_plan: 'Show the queued plan / last plan result, or force a planning round.',
4776
+ vibe_math_index: 'Rebuild the machine index (State/index.json) from the Markdown knowledge base.',
4777
+ vibe_math_method_add: 'Manually add a method card to Methods/ (creates Methods/<id>.md).',
4778
+ vibe_math_method_list: 'List methods from Methods/ (+ global VibeMath/Methods/): id, 标题, 类型, 状态, 可信断言, applications count.',
4779
+ vibe_math_lock_status: 'Show the project lock occupancy.',
4780
+ vibe_math_claim_write: '(member) Acquire the write lock for one target file (relative to the project root). Call before writing a Markdown file directly; a file may only be written by ONE agent at a time. Returns the display path you may write (VibeMath/Projects/<project>/<target>) and a hint.',
4781
+ vibe_math_release_write: '(member) Release the write lock for one target file (relative to the project root). Call after you finished writing it.',
4782
+ vibe_math_sync_meta: '(member) After you write content into Markdown files, report ONLY lightweight scheduling metadata to keep the scheduler state in sync (content stays in the md files). meta.kind must be one of:\n- "directions": {qid, directions:[{id,title,method,core_assumption,feasibility}], methods_used:[{id,效果,建议}], new_inventions:[{类型,标题,内容描述,是否已入库}]}\n- "solver": {qid, dirId, round, survival, status:"continue|success|dead-end", dead_end_reason, lemmas:[{id,title,statement,proof,prob,价值/关键性,分类,优先级}], methods_used, new_inventions, solution_prob, solution_text, sub_questions:[{q_sub_title,q_sub_statement,assumption_title,assumption_statement}]}\n- "methods": {used:[{id,效果,建议}], created:[ids], improvements:[{id,改进内容,原因}]}',
4783
+ 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
+ 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
+ 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.",
4786
+ }
4787
+
3694
4788
  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 }
3695
4789
 
3696
4790
  function shortId() { const h = '0123456789abcdef'; let s = ''; for (let i = 0; i < 8; i++) s += h[Math.floor(Math.random() * 16)]; return s }
3697
4791
 
3698
4792
  function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
3699
4793
 
4794
+ /**
4795
+ * M11:同一个语义两套字段名。提示词教的是 `prob`(`lemmas[].prob`),而遗留扁平 JSON 通道
4796
+ * 用的是 `布尔估计`(`细类型` 同理)。这里统一取值顺序,两套拼写都接受,避免"按提示词回写的
4797
+ * 引理"被当成没有估计值。
4798
+ */
4799
+ function lemmaProb(l) {
4800
+ if (l == null) return undefined
4801
+ if (l.prob != null) return l.prob
4802
+ if (l['布尔估计'] != null) return l['布尔估计']
4803
+ return undefined
4804
+ }
4805
+ function lemmaCategory(l) {
4806
+ if (l == null) return ''
4807
+ if (l['分类'] != null) return String(l['分类'])
4808
+ if (l['细类型'] != null) return String(Object.keys(l['细类型'] || { 未分类: {} })[0] || '未分类')
4809
+ return ''
4810
+ }
4811
+
3700
4812
  function fmtTime(ts) { try { return new Date(ts || now()).toISOString().replace('T', ' ').slice(0, 19) } catch (e) { return String(ts || '') } }
3701
4813
 
3702
4814
  function slugify(s) { const t = String(s == null ? '' : s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, ''); return t || 'project' }
@@ -3744,12 +4856,37 @@ function safeJson(v, fb) { if (v == null || v === '') return fb; try { return JS
3744
4856
  function stripJsonComments(text) { let out = ''; let inStr = false; let inLine = false; let inBlock = false; let esc = false; for (let i = 0; i < text.length; i++) { const c = text[i]; const n = text[i + 1]; if (inLine) { if (c === '\n') { inLine = false; out += c } continue } if (inBlock) { if (c === '*' && n === '/') { inBlock = false; i++ } continue } if (inStr) { out += c; if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue } if (c === '"') { inStr = true; out += c; continue } if (c === '/' && n === '/') { inLine = true; i++; continue } if (c === '/' && n === '*') { inBlock = true; i++; continue } out += c } return out }
3745
4857
 
3746
4858
  function splitHeader(text) {
3747
- const idx = String(text).search(/\n## /)
3748
- const head = idx === -1 ? String(text) : String(text).slice(0, idx)
3749
- const body = idx === -1 ? '' : String(text).slice(idx + 1)
4859
+ const heads = findSectionHeads(text)
4860
+ const first = (heads.length > 0 && heads[0].from > 0) ? heads[0].from : -1
4861
+ const head = first === -1 ? String(text) : String(text).slice(0, first - 1)
4862
+ const body = first === -1 ? '' : String(text).slice(first)
3750
4863
  return { head: head, body: body }
3751
4864
  }
3752
4865
 
4866
+ /**
4867
+ * 顶层 `## ` 段落边界,**跳过围栏代码块**(审计 L4)。
4868
+ *
4869
+ * 此前 splitHeader / section / parseBodySections 都把正文里**任意** `## ` 行当段落边界。代理把
4870
+ * 引用或代码粘进「陈述」段时,围栏里的 `## ` 行会被当成新段落:那半段代码变成"额外的段",
4871
+ * 下一次 compose 还会给它补一个 `## ` 标题(正文被改写、代码块被劈开)。围栏内的 `## ` 不是边界。
4872
+ */
4873
+ function findSectionHeads(text) {
4874
+ const out = []
4875
+ const src = String(text == null ? '' : text)
4876
+ const lines = src.split('\n')
4877
+ let pos = 0, fence = false
4878
+ for (let i = 0; i < lines.length; i++) {
4879
+ const line = lines[i]
4880
+ if (/^\s*(```|~~~)/.test(line)) { fence = !fence; pos += line.length + 1; continue }
4881
+ if (!fence) {
4882
+ const m = /^##\s+(.+?)\s*$/.exec(line)
4883
+ if (m) out.push({ name: m[1].trim(), from: pos, len: line.length })
4884
+ }
4885
+ pos += line.length + 1
4886
+ }
4887
+ return out
4888
+ }
4889
+
3753
4890
  function parseAnchors(head) {
3754
4891
  const anchors = {}
3755
4892
  const re = /^-\s*([A-Za-z\u4e00-\u9fa5/]+)\s*:\s*(.*)$/gm
@@ -3758,6 +4895,28 @@ function parseAnchors(head) {
3758
4895
  return anchors
3759
4896
  }
3760
4897
 
4898
+ /**
4899
+ * md 卡片是**逐行**格式:字段里出现换行会把标题行劈成两行(`parseAnchors` 的 `.*$` 与
4900
+ * `parseEntries` 的逐行扫描都不跨行),出现全角竖线 `|` 则会让
4901
+ * `### 解法 N|标题|概率X|状态Y` 的惰性匹配在**标题内部**提前切段(审计 L6)。
4902
+ * 约定:compose 侧转义 `\`、`|` 与换行(`\n`),parse 侧还原 ⇒ 往返无损,且惰性匹配不可能切进字段。
4903
+ */
4904
+ function escField(v) { return String(v == null ? '' : v).replace(/\\/g, '\\\\').replace(/|/g, '\\|').replace(/\r?\n/g, '\\n') }
4905
+ function unescField(v) {
4906
+ const s = String(v == null ? '' : v)
4907
+ if (s.indexOf('\\') === -1) return s
4908
+ return s.replace(/\\(.)/g, function (_m, c) { return c === 'n' ? '\n' : c })
4909
+ }
4910
+ // 字段体:接受 `\\.` 转义对,但不接受裸 `|`/裸换行 ⇒ 惰性匹配只在真正的分隔符处停。
4911
+ const ENTRY_FIELD = '(?:[^|\\r\\n\\\\]|\\\\.)*?'
4912
+ /**
4913
+ * 条目标题行正则。概率段用 `[-+0-9.eE]+` 而非 `[0-9.]+`(审计 L3):指数记法(如 `概率5e-7`,
4914
+ * 来自 meta.solution_prob / l.prob 这类 ∈(0,1) 的数值)此前整条匹配失败,条目在重载后被静默丢弃。
4915
+ */
4916
+ function entryRe(kind) { return new RegExp('^###\\s*' + kind + '\\s*\\d+|(' + ENTRY_FIELD + ')|概率([-+0-9.eE]+)|状态(' + ENTRY_FIELD + ')$') }
4917
+ /** 写出条目标题时的概率文本:非有限值(NaN/Infinity)绝不能写出去,否则那一行永远解析不回来。 */
4918
+ function probText(v) { const n = Number(v); return Number.isFinite(n) ? String(n) : '0.5' }
4919
+
3761
4920
  function parseEntries(body, kindRe) {
3762
4921
  const out = []
3763
4922
  const lines = String(body).split('\n')
@@ -3783,23 +4942,24 @@ function parseEntries(body, kindRe) {
3783
4942
  }
3784
4943
 
3785
4944
  function section(body, name) {
3786
- const re = new RegExp('^##\\s+' + name + '\\s*$', 'm')
3787
- const m = re.exec(String(body))
3788
- if (!m) return ''
3789
- const rest = String(body).slice(m.index + m[0].length)
3790
- const end = rest.search(/\n##\s/)
3791
- return (end === -1 ? rest : rest.slice(0, end)).trim()
4945
+ const text = String(body)
4946
+ const heads = findSectionHeads(text)
4947
+ let start = -1, end = -1
4948
+ for (let i = 0; i < heads.length; i++) {
4949
+ if (start === -1) { if (heads[i].name === name) start = heads[i].from + heads[i].len }
4950
+ else { end = heads[i].from; break }
4951
+ }
4952
+ if (start === -1) return ''
4953
+ return (end === -1 ? text.slice(start) : text.slice(start, end)).trim()
3792
4954
  }
3793
4955
 
3794
4956
  function parseBodySections(body) {
3795
4957
  const text = String(body || '')
3796
- const re = /^##\s+(.+?)\s*$/gm
4958
+ const heads = findSectionHeads(text)
3797
4959
  const found = []
3798
- let m
3799
- while ((m = re.exec(text)) !== null) found.push({ name: m[1].trim(), from: m.index, bodyFrom: m.index + m[0].length })
3800
- for (let i = 0; i < found.length; i++) {
3801
- const to = i + 1 < found.length ? found[i + 1].from : text.length
3802
- found[i].text = text.slice(found[i].bodyFrom, to).trim()
4960
+ for (let i = 0; i < heads.length; i++) {
4961
+ const to = i + 1 < heads.length ? heads[i + 1].from : text.length
4962
+ found.push({ name: heads[i].name, from: heads[i].from, bodyFrom: heads[i].from + heads[i].len, text: text.slice(heads[i].from + heads[i].len, to).trim() })
3803
4963
  }
3804
4964
  return found
3805
4965
  }
@@ -3812,8 +4972,12 @@ function extraBodySections(body, managedNames) {
3812
4972
 
3813
4973
  function parseAppTitle(title) {
3814
4974
  const t = String(title || '')
3815
- const mQ = /问题\s*(\S+)/.exec(t)
3816
- const mD = /方向\s*(\S+)/.exec(t)
4975
+ // M7:字段名后**必须**有空白才取值为字段,且值里不许含分隔符 `|`。
4976
+ // 旧写法 `/问题\s*(\S+)/` 在"问题为空、方向非空"时把分隔符也吃进去:
4977
+ // `应用 1|<时间>|问题 |方向 d1` → 问题='|方向'(composeMethodMd 恰好会写出这种标题),
4978
+ // 下一次回写就把这个脏值固化进方法卡。
4979
+ const mQ = /(?:^|[\s|])问题[ \t]+([^|\s]+)/.exec(t)
4980
+ const mD = /(?:^|[\s|])方向[ \t]+([^|\s]+)/.exec(t)
3817
4981
  // `at` = 第一段(时间戳)。取第一个 '|' 之前的部分。
3818
4982
  const at = t.split('|')[0].trim()
3819
4983
  return { at: at, 问题: mQ ? mQ[1] : '', 方向: mD ? mD[1] : '' }
@@ -3828,8 +4992,13 @@ function parseMethodMd(id, text) {
3828
4992
  })
3829
4993
  // 改进历史必须真正解析回来:此前硬编码 [] 导致每次 compose 都写成占位符,
3830
4994
  // 于是"下一次用到该方法"就把代理沉淀的改进历史静默销毁(违反「正文只追加,不覆盖」)。
4995
+ //
4996
+ // H3:原因必须从**标题行独立解析**,不能借道 parseEntries 的槽位。约定里 m[1]=标题段、
4997
+ // m[2]=概率段、m[3]=状态段,而改进标题 `### v3(<原因>)` 只有两个捕获组:m[2] 是原因、
4998
+ // m[3] 是 undefined ⇒ `e.status` 恒为 '',每次回写都写成 `### v1()`(原因永久丢失)。
3831
4999
  const improvements = parseEntries(body, /^###\s*v(\d+)\s*((.*?))\s*$/).map(function (e) {
3832
- return { v: Number(e.title) || 0, 原因: e.status || '', text: e.text }
5000
+ const m = /^###\s*v(\d+)\s*((.*?))\s*$/.exec(e.heading)
5001
+ return { v: m ? Number(m[1]) : 0, 原因: m ? m[2] : '', text: e.text }
3833
5002
  })
3834
5003
  return {
3835
5004
  id: id, 标题: a['标题'] || id, 类型: a['类型'] || '方法', 状态: a['状态'] || '经验',