dsh-vibe-math 2.5.0 → 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.
@@ -164,6 +164,12 @@ export function apply(ctx) {
164
164
  leanCommand: 'lean', // 要执行的 Lean 可执行文件(例:'lake')
165
165
  leanArgs: [], // 插在文件名之前的附加参数(例:['env','lean'] 配合 leanCommand='lake')
166
166
  leanTimeoutMs: 120000, // 单次 Lean 运行的超时上限(毫秒)
167
+ // ---- 最终论文(规格:docs/final-paper.md;v2/v3 为单作者变体)----
168
+ finalPaper: true, // 收口时是否自动撰写最终论文(false 只关自动触发;/vibe paper 仍可用)
169
+ paperFormat: 'both', // both = md + tex | md = 只写 markdown | tex = 只写 latex
170
+ paperLanguage: 'zh', // zh = 中文(ctexart/xelatex 优先)| en = 英文(article/pdflatex 优先)
171
+ paperCompilePdf: true, // 检测到 LaTeX 时是否编译 paper.pdf
172
+ paperLatexCommand: '', // 指定 LaTeX 引擎(空 = 按语言探测 xelatex/latexmk/pdflatex/lualatex/tectonic)
167
173
  }
168
174
  let params = Object.assign({}, DEFAULT_PARAMS)
169
175
  let scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null } // activeCount 由 activeCount() 从 agentRegistry 推导,不再作为字段
@@ -284,6 +290,11 @@ export function apply(ctx) {
284
290
  { name: 'leanCommand', type: 'string', description: '要执行的 Lean 可执行文件(默认 lean;用 lake 时配合 leanArgs=["env","lean"])', suggestion: 'lean' },
285
291
  { name: 'leanArgs', type: 'string[]', description: '插在 .lean 文件名之前的附加命令行参数(默认空)', suggestion: [] },
286
292
  { name: 'leanTimeoutMs', type: 'integer', description: '单次 Lean 运行的超时上限(毫秒,默认 120000,最小 1000)', suggestion: 120000 },
293
+ { name: 'finalPaper', type: 'boolean', description: '收口(严格终止)时自动撰写最终论文:派遣一名专职「论文撰写」子代理,把已检验通过的命题/解法/成果整理成 Paper/<项目>/{paper.md,paper.tex,paper.meta.json,paper.log.md}。false = 只关自动触发,/vibe paper 手动命令仍可用', suggestion: true },
294
+ { name: 'paperFormat', type: 'enum', options: ['both', 'md', 'tex'], description: '论文产出格式:both = markdown + latex;md = 只写 paper.md;tex = 只写 paper.tex(tex 才会尝试编译 pdf)', suggestion: 'both' },
295
+ { name: 'paperLanguage', type: 'enum', options: ['zh', 'en'], description: '论文语言:zh = 中文(LaTeX 用 ctexart,引擎优先 xelatex);en = 英文(article,引擎优先 pdflatex/latexmk)', suggestion: 'zh' },
296
+ { name: 'paperCompilePdf', type: 'boolean', description: '检测到 LaTeX 时是否编译 paper.pdf(-interaction=nonstopmode 跑两遍;失败先尝试修复:换引擎/去不支持宏包/最小模板)。false 或无 LaTeX 时只保留 tex+md 并记日志', suggestion: true },
297
+ { name: 'paperLatexCommand', type: 'string', description: '指定 LaTeX 引擎可执行文件(空 = 按语言探测:中文 xelatex > latexmk > pdflatex > lualatex > tectonic;英文 pdflatex 优先)。该命令解析不到时按"未检测到"降级(只留 tex+md)', suggestion: '' },
287
298
  ]
288
299
 
289
300
  // ================= fs =================
@@ -425,12 +436,17 @@ export function apply(ctx) {
425
436
  else if (k === 'priorityAdjust') { out[k] = (v === 'none' || v === 'deadend-deprioritize' || v === 'survival-map') ? v : DEFAULT_PARAMS[k] }
426
437
  else if (k === 'proposPriorityAdjust') { out[k] = (v === 'none' || v === 'progress-graded') ? v : DEFAULT_PARAMS[k] }
427
438
  else if (k === 'solverAllowNetwork' || k === 'verifierAllowNetwork' || k === 'solverAllowScripts' || k === 'verifierAllowScripts') { out[k] = (v === true || v === false || v === '') ? v : DEFAULT_PARAMS[k] }
439
+ else if (k === 'finalPaper' || k === 'paperCompilePdf') { out[k] = (v === true || v === false) ? v : DEFAULT_PARAMS[k] }
440
+ else if (k === 'paperFormat') { out[k] = (v === 'both' || v === 'md' || v === 'tex') ? v : DEFAULT_PARAMS[k] }
441
+ else if (k === 'paperLanguage') { out[k] = (v === 'zh' || v === 'en') ? v : DEFAULT_PARAMS[k] }
428
442
  else { out[k] = v }
429
443
  }
430
444
  // 非正的 leanTimeoutMs 会让每次 Lean 运行立即"超时",而超时被记成"未通过"——静默失效。
431
445
  // 删除该键(回退默认),而不是抬到最小值,与 v5/v4 的既有处理一致。
432
446
  if (out.leanTimeoutMs !== undefined && !(out.leanTimeoutMs > 0)) delete out.leanTimeoutMs
433
447
  if (out.leanTimeoutMs !== undefined) out.leanTimeoutMs = Math.max(1000, out.leanTimeoutMs)
448
+ // 空白的 paperLatexCommand 会让 resolveExecutable('') 直接失败;回退默认(自动探测)。
449
+ if (out.paperLatexCommand !== undefined && !String(out.paperLatexCommand).trim()) out.paperLatexCommand = DEFAULT_PARAMS.paperLatexCommand
434
450
  // 未知档位一律回退 'off'(无操作),绝不回退到更强的档位:一个拼写错误若静默启用强制形式化,
435
451
  // 会让所有结论被门禁拦下。
436
452
  if (out.formalVerify !== undefined && ['off', 'encourage', 'require'].indexOf(String(out.formalVerify)) === -1) out.formalVerify = DEFAULT_PARAMS.formalVerify
@@ -1759,11 +1775,18 @@ export function apply(ctx) {
1759
1775
  await maybeWriteReport(false)
1760
1776
  const qs = await getQs()
1761
1777
  const unsolved = qs.filter(function (q) { return !q.已解决 && q.优先级 !== 'never' })
1762
- if (unsolved.length === 0 && Object.keys(agentRegistry).length === 0 && Object.keys(tasks).length === 0) {
1763
- scheduler.running = false
1778
+ // 收口判据(spec v2 §A2/§A3):**排除**论文撰写子代理——它自己不能阻止收口,也不该被算作"在途工作"。
1779
+ const realAgents = Object.keys(agentRegistry).filter(function (cid) { const m = agentRegistry[cid]; return m && m.role !== 'paper' }).length
1780
+ if (unsolved.length === 0 && realAgents === 0 && Object.keys(tasks).length === 0) {
1764
1781
  logActivity('stop', 'all active problems solved (never-priority problems excluded) and no active agents/tasks — scheduler stopped (strict termination)')
1782
+ // ★ 最终论文(修订 §A2):**必须在 running=false 之前**派遣——停止之后 scheduleTick() 是空操作,
1783
+ // 而且 v2 没有任何激活上限处理,被拒的撰写者会静默消失;派不出去就排队,由 apply 级心跳重试。
1784
+ if (params.finalPaper !== false) {
1785
+ try { await maybeWritePaper('auto') } catch (e) { logActivity('paper', '自动撰写最终论文失败:' + String((e && e.message) || e)) }
1786
+ }
1787
+ scheduler.running = false
1765
1788
  await saveAll(); await maybeWriteReport(true); await maybePushReport(true)
1766
- } else if (Object.keys(agentRegistry).length === 0 && Object.keys(tasks).length === 0 && unsolved.length > 0) {
1789
+ } else if (realAgents === 0 && Object.keys(tasks).length === 0 && unsolved.length > 0) {
1767
1790
  let allBlocked = true
1768
1791
  for (let i = 0; i < unsolved.length; i++) {
1769
1792
  const prog = parseProgress(unsolved[i])
@@ -2663,15 +2686,507 @@ export function apply(ctx) {
2663
2686
  // 卡片写入结果 → 布尔(向后兼容既有的 if (await writeVerifiedCardIfNeeded(p)) 语义)。
2664
2687
  function cardOk(r) { return !!(r && r.ok) }
2665
2688
 
2689
+ // ==========================================================================================
2690
+ // 最终论文(规格:docs/final-paper.md(含本轮修订))—— 会话侧。
2691
+ //
2692
+ // 时序(修订 §A2):v2 的收口信号是严格终止(本文件 tick() 的 branch,见 `strict termination`)。
2693
+ // `scheduler.running=false` 之后 `scheduleTick()` 变空操作,所以**撰写子代理必须在停止翻转之前**
2694
+ // 派遣;派不出去(宿主激活上限 ACTIVATION_LIMIT_REACHED)时排队,由 apply 级心跳
2695
+ // (paperRetryDue/runPaperRetry)重试并给出可见告警。子代理的回复由 onChildEnd 的 `paper` 分支
2696
+ // 处理(未知 role 会静默丢失)。收口判据里 paper 子代理被排除(realAgents),避免它自己阻止收口。
2697
+ // 落盘(修订 §C/§D):md/tex 是文本(走宿主 fs),**paper.pdf 只能由编译器子进程生成**,插件只 stat;
2698
+ // paper.meta.json 最后写(提交点)。幂等 = run id + finalizedAt + 逐产物存在性(不引入材料稳定哈希)。
2699
+ // ==========================================================================================
2700
+ let paperInFlight = '' // 正在撰写的 childId(空 = 无)
2701
+ let paperInFlightAt = 0 // 派遣时刻(判定卡死窗口;force 与心跳都据它降级)
2702
+ let paperReaps = 0 // 本 run 内已回收卡死撰写者的次数(自动重派上限)
2703
+ const paperAbandoned = {} // childId -> true:已放弃的撰写者;它事后返回时输出被丢弃并留痕
2704
+ let paperPending = null // 激活上限排队重试:{ tries, retryAt, reason, limit, trigger, opts }
2705
+ const PAPER_RETRY_MS = 5000 // 激活上限拒绝后的重试间隔
2706
+ const PAPER_MAX_RETRIES = 12 // 连续被拒上限(超过则放弃并给可见告警)
2707
+ const PAPER_STALE_MS = 10 * 60 * 1000 // 撰写者卡死窗口:超过它,force 与心跳都会回收并重派(10 分钟)
2708
+ const PAPER_MAX_AUTO_REAPS = 2 // 心跳自动回收+重派的次数上限,之后交给 /vibe paper force
2709
+ const PAPER_LOCK_STALE_MS = 120000 // paper 作用域锁的失效时间(跨会话/跨进程尽力而为)
2710
+ /** 宿主激活上限(maxActiveSubagents)报错里的人数:v4/v5 用的同一判据。 */
2711
+ function activationLimitFrom(message) { const m = /active child limit:\s*(\d+)/.exec(String(message == null ? '' : message)); return m ? Number(m[1]) : undefined }
2712
+ function paperId(opts) { return paperDirId((opts && opts.id) || currentProject) }
2713
+ function paperDir(id) { return 'Paper/' + paperDirId(id) }
2714
+ function paperAbsDir(id) { return frameworkRoot() + '/' + paperDir(id) }
2715
+ function paperAuthorLine() { return 'Vibe Math V2(单作者:论文撰写子代理)· 项目 ' + currentProject }
2716
+ /**
2717
+ * 本次撰写的参数(spec §1/§2):命令行的 lang=/format= 覆盖参数,参数覆盖默认值。
2718
+ * 非法值已在 paperCommand 里拒绝;这里再做一次保守回退(落到纸面上的参数永远合法)。
2719
+ */
2720
+ function paperOpts(opts) {
2721
+ const o = opts || {}
2722
+ const fmt = (o.format === 'md' || o.format === 'tex' || o.format === 'both') ? o.format : ((params.paperFormat === 'md' || params.paperFormat === 'tex') ? params.paperFormat : 'both')
2723
+ const lang = (o.lang === 'en' || o.lang === 'zh') ? o.lang : (params.paperLanguage === 'en' ? 'en' : 'zh')
2724
+ const compilePdf = (o.compilePdf === true || o.compilePdf === false) ? o.compilePdf : (params.paperCompilePdf !== false)
2725
+ return { format: fmt, lang: lang, compilePdf: compilePdf, force: o.force === true, id: o.id }
2726
+ }
2727
+ async function paperReadMeta(id) { const m = await readJson(paperDir(id) + '/paper.meta.json'); return (m && typeof m === 'object' && !Array.isArray(m)) ? m : undefined }
2728
+ async function paperPathExists(rel) { try { const t = await fsTarget(rel); return (await fs.stat(t)) !== undefined } catch (e) { return false } }
2729
+ async function paperAppendLog(id, tag, text) {
2730
+ const rel = paperDir(id) + '/paper.log.md'
2731
+ const prev = await readText(rel)
2732
+ const head = (prev === undefined || !String(prev).trim()) ? (paperLogHead(id).join('\n') + '\n') : String(prev)
2733
+ await writeText(rel, head + paperLogLine(now(), tag, text) + '\n')
2734
+ }
2735
+ /**
2736
+ * paper 作用域锁(修订 §A3;v3 在触发前已释放项目锁,两个会话可能同时写同一棵 Paper/ 树)。
2737
+ * 尽力而为:同一会话总是可重入(用于续租 at);别的会话持有的锁未过期则拒绝。
2738
+ */
2739
+ async function paperLockAcquire(id) {
2740
+ const rel = paperDir(id) + '/paper.lock.json'
2741
+ const cur = await readJson(rel)
2742
+ 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) }
2743
+ await writeJson(rel, { sessionId: sessionId, at: now(), project: currentProject })
2744
+ return { ok: true }
2745
+ }
2746
+ async function paperLockRelease(id) {
2747
+ const rel = paperDir(id) + '/paper.lock.json'
2748
+ const cur = await readJson(rel)
2749
+ if (cur && cur.sessionId === sessionId) await writeJson(rel, { sessionId: '', at: now(), released: true })
2750
+ }
2751
+ /**
2752
+ * 回收一个卡死的撰写者(修订 §1:死掉的撰写者绝不能把论文永久卡住)。
2753
+ * 做法:清状态 + 尽力中断 + 从 agentRegistry 摘除(否则它一直算"活跃子代理",也会挡住后续派发)
2754
+ * + 记进 paperAbandoned(事后返回的输出被明确丢弃并留痕,不是静默消失)+ 可见告警。
2755
+ */
2756
+ async function paperReapWriter(id, why) {
2757
+ const cid = paperInFlight
2758
+ paperInFlight = ''; paperInFlightAt = 0
2759
+ if (cid) {
2760
+ try { await interruptChild(cid) } catch (e) { /* best effort */ }
2761
+ delete agentRegistry[cid]
2762
+ paperAbandoned[cid] = true
2763
+ }
2764
+ await paperAppendLog(id, 'reap', '回收卡死的撰写者 ' + (cid || '(未知)') + ':' + why + '(论文不会被永久卡住;force/心跳会继续)')
2765
+ logActivity('paper', '回收卡死的论文撰写子代理 ' + (cid || '(未知)') + ':' + why)
2766
+ console.error('vibe-math-v2: reaped a stalled paper writer (' + (cid || 'unknown') + '): ' + why)
2767
+ reportDirty = true
2768
+ return cid
2769
+ }
2770
+ function paperWriterVerdictNow(force) {
2771
+ return paperWriterVerdict({ inFlight: !!paperInFlight, force: !!force, ageMs: now() - Number(paperInFlightAt || 0), staleMs: PAPER_STALE_MS, childInRegistry: paperInFlight ? (agentRegistry[paperInFlight] !== undefined) : true })
2772
+ }
2773
+ function paperOneLine(s, cap) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, cap || 400) }
2774
+ /** 证据索引(spec §3.9):只列**确实存在**的文件;绝不含 Paper/ 自己(否则输入哈希会自我污染)。
2775
+ * off 档必须真的是无操作(formal-verify-v2 的语料守卫会断言"off 的提示词里零 Lean 文本"):
2776
+ * formal 未启用时,形式化相关的路径与字段一概不写进材料。 */
2777
+ async function paperEvidenceIndex() {
2778
+ const formalNow = formalOn()
2779
+ const out = ['qs/qs.json']
2780
+ if (formalNow && await paperPathExists('VibeMath_State/formal.json')) out.push('VibeMath_State/formal.json')
2781
+ for (const f of await listFiles('Propos')) out.push('Propos/' + f)
2782
+ for (const f of await listFiles('Verified')) out.push('Verified/' + f)
2783
+ if (formalNow) for (const f of await listFiles('Verified/Lean')) out.push('Verified/Lean/' + f)
2784
+ const dirs = ['Verification_logs', 'Progress_Logs'].concat(formalNow ? ['Formal'] : [])
2785
+ 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]) }
2786
+ return out.filter(function (x) { return x.indexOf('Paper/') !== 0 }).sort()
2787
+ }
2788
+ /** 汇总材料(spec §4):只含既有证据 + 未决/被否证的显式标注。不含时间戳 ⇒ 哈希稳定。 */
2789
+ async function buildPaperDigest() {
2790
+ const L = []
2791
+ L.push('PRESET: vibe-math-v2 (single-author)')
2792
+ L.push('PROJECT: ' + currentProject)
2793
+ const qs = await getQs()
2794
+ L.push('')
2795
+ L.push('[ORIGINAL PROBLEMS] (qs/qs.json)')
2796
+ if (qs.length === 0) L.push('- (none)')
2797
+ for (let i = 0; i < qs.length; i++) {
2798
+ const q = qs[i]
2799
+ L.push('- id=' + q.id + ' | 已解决=' + (q.已解决 === true) + ' | 优先级=' + q.优先级 + ' | 概述=' + paperOneLine(q.概述, 800))
2800
+ const sols = q.解法列表 || []
2801
+ if (sols.length === 0) L.push(' · 解法:无')
2802
+ for (let j = 0; j < sols.length; j++) {
2803
+ const s = sols[j]
2804
+ L.push(' · 解法#' + j + ' 正确概率=' + s.正确概率 + ' 已验=' + (s.已验 === true) + ' 证据=Verification_logs/ | ' + paperOneLine(s.完整解法, 1200))
2805
+ }
2806
+ }
2807
+ const props = await getPropos()
2808
+ L.push('')
2809
+ L.push('[PROPOSITIONS] (Propos/*.json)')
2810
+ if (props.length === 0) L.push('- (none)')
2811
+ for (let i = 0; i < props.length; i++) {
2812
+ const p = props[i]
2813
+ const rec = formalOn() ? formalOf(p.id) : null // off 档不把形式化字段写进材料(off 是真无操作)
2814
+ L.push('- id=' + p.id + ' | 布尔估计=' + p.布尔估计 + ' | 状态=' + (p.已验证 ? '已验证' : '未定论') + ' | 优先级=' + p.优先级 + ' | 价值/关键性=' + p['价值/关键性'] + (rec ? (' | 形式化=' + ((rec && rec.status) || 'none') + (rec && rec.proof ? ('(' + rec.proof + ')') : '')) : '') + ' | 概述=' + paperOneLine(p.概述, 800))
2815
+ const sides = [['证明', p.证明列表 || []], ['证伪', p.证伪列表 || []]]
2816
+ for (let k = 0; k < sides.length; k++) {
2817
+ const arr = sides[k][1]
2818
+ for (let j = 0; j < arr.length; j++) if (arr[j] && arr[j].正确概率 === 1) L.push(' · 已检验通过:' + sides[k][0] + '#' + j + '(正确概率=1) | ' + paperOneLine(arr[j].完整过程, 900))
2819
+ }
2820
+ }
2821
+ L.push('')
2822
+ L.push('[UNRESOLVED / REFUTED — 论文里必须显式标注,不得当成已成立的结论]')
2823
+ let any = false
2824
+ for (let i = 0; i < qs.length; i++) {
2825
+ const sols = qs[i].解法列表 || []
2826
+ for (let j = 0; j < sols.length; j++) if (!(sols[j].正确概率 === 1 || sols[j].正确概率 === 0)) { any = true; L.push('- 未定论:问题 ' + qs[i].id + ' 的解法#' + j + '(正确概率=' + sols[j].正确概率 + ')') }
2827
+ }
2828
+ for (let i = 0; i < props.length; i++) {
2829
+ if (!(props[i].布尔估计 === 1 || props[i].布尔估计 === 0)) { any = true; L.push('- 未定论:命题 ' + props[i].id + '(布尔估计=' + props[i].布尔估计 + ')') }
2830
+ else if (props[i].布尔估计 === 0) L.push('- 已被否证:命题 ' + props[i].id + '(布尔估计=0)')
2831
+ }
2832
+ if (!any) L.push('- (none — 所有对象均已定论)')
2833
+ L.push('')
2834
+ L.push('[EVIDENCE INDEX] (only files that exist)')
2835
+ const ev = await paperEvidenceIndex()
2836
+ for (let i = 0; i < ev.length; i++) L.push('- ' + ev[i])
2837
+ return L.join('\n')
2838
+ }
2839
+ /** 「论文撰写」子代理提示词(单作者;v2/v3 同一形状)。 */
2840
+ function paperWriterPrompt(digest, o) {
2841
+ const langName = o.lang === 'en' ? 'English' : '中文(Chinese)'
2842
+ return 'You are the DEDICATED PAPER WRITER (single-author mode) of a math research run that has just CONVERGED.\n' +
2843
+ 'Write its final paper in ' + langName + ', using ONLY the evidence in the MATERIAL section below.\n\n' +
2844
+ 'HARD RULES:\n' +
2845
+ '- NEVER invent content: no new proposition, no new computation, no citation that is not in the MATERIAL.\n' +
2846
+ '- Unresolved or refuted items MUST be explicitly labelled (「未定论」/「已被否证」, or "unresolved"/"refuted" in English); never present them as established.\n' +
2847
+ '- Fixed 9-section skeleton — provide bodies for these EXACT `## ` headings (the framework writes the headings, author/date and the evidence index itself):\n' +
2848
+ PAPER_SKELETON.map(function (s, i) { return ' ' + (i + 1) + '. ' + s.key + ' — ' + s.spec }).join('\n') + '\n' +
2849
+ '- A section with no evidence must be exactly 「' + PAPER_NO_EVIDENCE + '」 (do not pad it).\n' +
2850
+ '- Markdown subset only: `#`/`##`/`###`, `- ` lists, `**bold**`, `*em*`, `` `code` ``, and inline math as `$...$`. No tables, images, footnotes or raw HTML.\n\n' +
2851
+ 'OUTPUT CONTRACT — respond with ONLY one ```json code fence, no prose:\n' +
2852
+ '{"title":"<paper title>","abstract":"<original problem + main results>","sections":[{"name":"<one of the 9 headings>","body":"<markdown>"}, ...]}\n\n' +
2853
+ 'MATERIAL (evidence only — do not add anything beyond it):\n' + digest
2854
+ }
2855
+ /**
2856
+ * LaTeX 检测(spec §5 + 修订 §D):`paperLatexCommand` 非空时只用它(不猜);
2857
+ * 否则中文优先 xelatex、英文优先 pdflatex/latexmk。缺 subprocess/resolveExecutable 一律
2858
+ * 视为"未检测到"(照 LEAN_NOT_FOUND 的降级方式,永不抛错)。
2859
+ */
2860
+ async function paperDetectLatex(lang) {
2861
+ const sub = subprocessOf()
2862
+ if (sub === undefined || typeof sub.spawn !== 'function') return { available: [], reason: 'no-subprocess' }
2863
+ if (typeof sub.resolveExecutable !== 'function') return { available: [], reason: 'no-resolveExecutable' }
2864
+ const forced = String(params.paperLatexCommand || '').trim()
2865
+ if (forced) {
2866
+ 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 } }
2867
+ catch (e) { return { available: [], reason: 'paperLatexCommand not found: ' + forced, forced: true } }
2868
+ }
2869
+ const order = lang === 'en' ? PAPER_ENGINE_ORDER_EN : PAPER_ENGINE_ORDER_ZH
2870
+ const available = []
2871
+ for (let i = 0; i < order.length; i++) {
2872
+ try { const p = await sub.resolveExecutable(order[i]); if (p) available.push({ name: order[i], path: String(p) }) } catch (e) { /* 未安装 */ }
2873
+ }
2874
+ return { available: available, reason: available.length ? 'ok' : 'none' }
2875
+ }
2876
+ function paperArgvFor(engine) {
2877
+ if (engine.name === 'latexmk') return [engine.path, '-pdf', '-interaction=nonstopmode', '-halt-on-error', 'paper.tex']
2878
+ if (engine.name === 'tectonic') return [engine.path, '--keep-logs', 'paper.tex']
2879
+ return [engine.path, '-interaction=nonstopmode', '-halt-on-error', 'paper.tex']
2880
+ }
2881
+ /** 一次编译(nonstopmode),带主动超时 terminate(与 leanRunFile 同一模式)。 */
2882
+ async function paperRunOnce(engine, dirAbs, capMs) {
2883
+ const sub = subprocessOf()
2884
+ if (sub === undefined || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, code: 'NO_SUBPROCESS' }
2885
+ let handle
2886
+ try { handle = sub.spawn({ argv: paperArgvFor(engine), cwd: dirAbs, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: capMs }) }
2887
+ catch (e) { return { ok: false, exitCode: null, code: 'SPAWN_FAILED', message: String((e && e.message) || e) } }
2888
+ let timedOut = false, timer = null, outcome
2889
+ try {
2890
+ outcome = await Promise.race([
2891
+ handle.done,
2892
+ 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) }),
2893
+ ])
2894
+ } catch (e) { if (timer !== null) clearTimeout(timer); return { ok: false, exitCode: null, code: 'RUN_FAILED', message: String((e && e.message) || e) } }
2895
+ if (timer !== null) clearTimeout(timer)
2896
+ let err = ''
2897
+ try { if (handle.collected && handle.collected.stderr) err = String(handle.collected.stderr.readFrom(0).text || '') } catch (e) { /* best effort */ }
2898
+ return { ok: !timedOut && !!outcome && outcome.exitCode === 0, exitCode: outcome ? outcome.exitCode : null, timedOut: timedOut, stderr: err.slice(-1200) }
2899
+ }
2900
+ /**
2901
+ * 编译 paper.tex(spec §5 + 修订 §D/§E):检测 → 主引擎(nonstopmode,两遍)→ 换引擎 → 去不支持
2902
+ * 宏包 → 最小模板再试一次;然后报告并降级(保留 tex+md,不改写论文内容)。
2903
+ * **永不删除/覆盖已有 pdf**(§D):旧的 paper.pdf 原样保留,成功判据 = 本次运行 exit 0 **且** pdf 存在。
2904
+ */
2905
+ async function paperCompile(id, o, texPrimary, bodyTex) {
2906
+ const relDir = paperDir(id), absDir = paperAbsDir(id)
2907
+ const attempts = []
2908
+ if (!o.compilePdf) return { compile: 'skipped', reason: 'paperCompilePdf=false', engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
2909
+ // §E:tex 未产出(paperFormat=md)时**跳过**编译,且不得报"缺 tex 无法编译"的警告。
2910
+ if (o.format === 'md') return { compile: 'skipped', reason: 'paperFormat=md(不产出 tex,跳过编译)', engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
2911
+ const det = await paperDetectLatex(o.lang)
2912
+ if (det.available.length === 0) {
2913
+ await paperAppendLog(id, 'latex', '未检测到任何 LaTeX 引擎(' + det.reason + ')→ 只保留 paper.tex + paper.md,不编译 pdf(spec §5:不阻塞定稿)')
2914
+ return { compile: 'not-detected', reason: det.reason, engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
2915
+ }
2916
+ 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(' > ') + ')')))
2917
+ const plan = [{ engine: det.available[0], tex: texPrimary, mode: 'primary' }]
2918
+ if (det.available[1]) plan.push({ engine: det.available[1], tex: texPrimary, mode: 'engine-fallback' })
2919
+ plan.push({ engine: det.available[0], tex: paperSanitizeTex(texPrimary), mode: 'drop-unsupported-packages' })
2920
+ plan.push({ engine: det.available[0], tex: paperMinimalTexDoc({ lang: o.lang, bodyTex: bodyTex }), mode: 'minimal-template' })
2921
+ const cap = 120000
2922
+ for (let i = 0; i < plan.length; i++) {
2923
+ const step = plan[i]
2924
+ await writeText(relDir + '/paper.tex', step.tex)
2925
+ const runs = []
2926
+ for (let pass = 0; pass < 2; pass++) runs.push(await paperRunOnce(step.engine, absDir, cap)) // spec §5:跑两遍
2927
+ const last = runs[runs.length - 1]
2928
+ const pdf = !!last.ok && await paperPathExists(relDir + '/paper.pdf')
2929
+ attempts.push({ engine: step.engine.name, mode: step.mode, ok: pdf, exitCode: last.exitCode, timedOut: !!last.timedOut, stderr: last.stderr || '' })
2930
+ await paperAppendLog(id, 'compile', step.mode + ' engine=' + step.engine.name + ' → ' + (pdf ? 'ok(paper.pdf 已生成)' : ('failed(exit=' + String(last.exitCode) + (last.timedOut ? ', timeout' : '') + ')')))
2931
+ if (pdf) return { compile: (i === 0) ? 'ok' : 'repaired', engine: step.engine.name, mode: step.mode, attempts: attempts, pdfPreserved: false }
2932
+ }
2933
+ await writeText(relDir + '/paper.tex', texPrimary) // 失败时保留**主** tex(信息量最大;spec §5 保留 tex+md)
2934
+ const preserved = await paperPathExists(relDir + '/paper.pdf') // §D:已有的 pdf 绝不删除
2935
+ await paperAppendLog(id, 'compile', '所有尝试均失败:保留 paper.tex + paper.md' + (preserved ? '(paper.pdf 是**上一次成功编译**留下的,未被覆盖)' : ',未生成 paper.pdf') + '(不阻塞定稿)')
2936
+ logActivity('paper', '论文 pdf 编译失败(已尝试换引擎/去不支持宏包/最小模板),已保留 tex+md:' + relDir)
2937
+ reportDirty = true
2938
+ return { compile: 'failed', engine: det.available[0].name, attempts: attempts, pdfPreserved: preserved }
2939
+ }
2940
+ /** 落盘 md/tex/pdf/meta(幂等:内容相同则不重写;spec §2/§5)。 */
2941
+ async function paperEmitArtifacts(id, o, info) {
2942
+ const relDir = paperDir(id)
2943
+ const dateStr = fmtTime(now()).slice(0, 10)
2944
+ const author = paperAuthorLine()
2945
+ const evidence = info.evidence || (await paperEvidenceIndex())
2946
+ const title = String(info.title || '').trim() || ('研究报告:' + currentProject)
2947
+ const md = paperBuildMarkdown({ title: title, author: author, date: dateStr, abstract: info.abstract, sections: info.sections, evidence: evidence })
2948
+ const artifacts = { md: false, tex: false, pdf: false }
2949
+ let compile = 'skipped', engine = null, attempts = [], pdfPreserved = false
2950
+ if (o.format === 'md' || o.format === 'both') { await writeText(relDir + '/paper.md', md); artifacts.md = true }
2951
+ if (o.format === 'tex' || o.format === 'both') {
2952
+ const bodyTex = paperMdToTexBody(md)
2953
+ const tex = paperTexDoc({ title: title, author: author, date: dateStr, lang: o.lang, bodyTex: bodyTex })
2954
+ await writeText(relDir + '/paper.tex', tex)
2955
+ artifacts.tex = true
2956
+ const c = await paperCompile(id, o, tex, bodyTex)
2957
+ compile = c.compile; engine = c.engine || null; attempts = c.attempts || []
2958
+ pdfPreserved = !!c.pdfPreserved
2959
+ if (compile === 'ok' || compile === 'repaired') artifacts.pdf = await paperPathExists(relDir + '/paper.pdf')
2960
+ }
2961
+ const filled = PAPER_SKELETON.map(function (s) { return s.key }).filter(function (k) { return !!(info.sections && String(info.sections[k] || '').trim()) })
2962
+ const meta = {
2963
+ id: id, dir: relDir, preset: 'vibe-math-v2', singleAuthor: true, project: currentProject,
2964
+ title: title, author: author, date: dateStr, abstract: String(info.abstract || ''),
2965
+ sectionBodies: info.sections || {}, sections: PAPER_SKELETON.map(function (s) { return s.key }), filledSections: filled,
2966
+ raw: String(info.raw == null ? '' : info.raw), notes: Array.isArray(info.notes) ? info.notes : [],
2967
+ trigger: info.trigger || 'manual', runStartedAt: Number(scheduler.startedAt) || 0,
2968
+ params: { finalPaper: params.finalPaper !== false, paperFormat: o.format, paperLanguage: o.lang, paperCompilePdf: !!o.compilePdf, paperLatexCommand: String(params.paperLatexCommand || '') },
2969
+ finalizedAt: now(), finalizedAtText: fmtTime(now()),
2970
+ artifacts: artifacts, compile: compile, compileEngine: engine, compileAttempts: attempts, pdfPreserved: pdfPreserved,
2971
+ material: info.material || null, evidence: evidence,
2972
+ }
2973
+ // meta **最后**写:它是"这次定稿已提交"的唯一标记(修订 §C 的原子提交点)。
2974
+ await writeJson(relDir + '/paper.meta.json', meta)
2975
+ return meta
2976
+ }
2977
+ async function paperMissingArtifacts(id, o) {
2978
+ const relDir = paperDir(id)
2979
+ const need = []
2980
+ if (o.format === 'md' || o.format === 'both') { if (!(await paperPathExists(relDir + '/paper.md'))) need.push('paper.md') }
2981
+ if (o.format === 'tex' || o.format === 'both') { if (!(await paperPathExists(relDir + '/paper.tex'))) need.push('paper.tex') }
2982
+ return need
2983
+ }
2984
+ /**
2985
+ * 派遣一次(内部):材料摘要只作记录(修订 §C 不再用稳定哈希);激活上限拒绝 → 排队重试(§A2)。
2986
+ */
2987
+ async function paperDispatch(trigger, o, id, prevTries) {
2988
+ const relDir = paperDir(id)
2989
+ const digest = await buildPaperDigest()
2990
+ let childId
2991
+ try {
2992
+ childId = await spawnChild('paper-writer:' + id, paperWriterPrompt(digest, o), { role: 'paper', paperId: id, trigger: trigger, lang: o.lang, format: o.format, compilePdf: o.compilePdf })
2993
+ } catch (e) {
2994
+ const message = String((e && e.message) || e)
2995
+ const limit = activationLimitFrom(message)
2996
+ if (limit !== undefined || /ACTIVATION_LIMIT_REACHED|active child limit/i.test(message)) {
2997
+ const tries = Number(prevTries || 0) + 1
2998
+ if (tries > PAPER_MAX_RETRIES) {
2999
+ paperPending = null
3000
+ await paperAppendLog(id, 'dispatch-failed', '宿主激活上限连续拒绝 ' + PAPER_MAX_RETRIES + ' 次,已放弃自动派遣:' + message)
3001
+ logActivity('paper', '论文撰写子代理被宿主激活上限连续拒绝(' + PAPER_MAX_RETRIES + ' 次),已放弃自动派遣(稍后可 /vibe paper force 重试):' + message)
3002
+ console.error('vibe-math-v2: paper writer refused by the host activation limit ' + PAPER_MAX_RETRIES + ' times; giving up (retry later with /vibe paper force) — ' + message)
3003
+ await paperLockRelease(id)
3004
+ return { ok: false, message: 'paper writer refused by the activation limit ' + PAPER_MAX_RETRIES + ' times: ' + message, id: id, dir: relDir, limit: limit }
3005
+ }
3006
+ paperPending = { tries: tries, retryAt: now() + PAPER_RETRY_MS, reason: message, limit: limit, trigger: trigger, opts: { lang: o.lang, format: o.format, compilePdf: o.compilePdf } }
3007
+ if (tries === 1 || tries % 3 === 0) {
3008
+ await paperAppendLog(id, 'dispatch-queued', '宿主激活上限(' + (limit === undefined ? '?' : limit) + ')拒绝派遣(第 ' + tries + ' 次):' + (PAPER_RETRY_MS / 1000) + 's 后重试')
3009
+ logActivity('paper', '论文撰写子代理被宿主激活上限拒绝(第 ' + tries + ' 次),已排队重试:' + message)
3010
+ console.error('vibe-math-v2: paper writer queued after an activation-limit refusal (try ' + tries + '): ' + message)
3011
+ }
3012
+ reportDirty = true
3013
+ return { ok: true, queued: true, reason: 'activation-limit-reached', tries: tries, retryAt: paperPending.retryAt, limit: limit, id: id, dir: relDir }
3014
+ }
3015
+ await paperAppendLog(id, 'dispatch-failed', message)
3016
+ logActivity('paper', '「论文撰写」子代理派遣失败:' + message)
3017
+ await paperLockRelease(id)
3018
+ return { ok: false, message: 'paper writer dispatch failed: ' + message, id: id, dir: relDir }
3019
+ }
3020
+ paperInFlight = childId
3021
+ paperInFlightAt = now()
3022
+ // 只有"非自动重试"的派遣才重置回收计数:auto-retry 必须保留计数,否则卡死重派会无限循环。
3023
+ if (trigger !== 'auto-retry') paperReaps = 0
3024
+ paperPending = null
3025
+ const material = paperMaterialSummary(digest)
3026
+ 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')
3027
+ logActivity('paper', '已派遣「论文撰写」子代理(' + trigger + ',' + o.format + '/' + o.lang + ')→ ' + relDir)
3028
+ reportDirty = true
3029
+ return { ok: true, dispatched: true, childId: childId, id: id, dir: relDir, trigger: trigger, format: o.format, lang: o.lang, compilePdf: o.compilePdf }
3030
+ }
3031
+ /**
3032
+ * 触发入口(spec §2/§3 + 修订 §A2/§A3):自动(收口)与手动命令共用。
3033
+ * 幂等 = **同一 run(scheduler.startedAt)+ finalizedAt + 逐产物存在性**(修订 §C);force 跳过全部检查。
3034
+ * 已有定稿但缺产物 → 用已存内容补写(不重新派遣作者)。别的会话持有 paper 锁 → 跳过并留痕。
3035
+ */
3036
+ async function maybeWritePaper(trigger, opts) {
3037
+ const o = paperOpts(opts)
3038
+ const id = paperId(opts)
3039
+ const relDir = paperDir(id)
3040
+ const meta = await paperReadMeta(id)
3041
+ const sameRun = !!meta && Number(meta.runStartedAt || 0) === (Number(scheduler.startedAt) || 0)
3042
+ if (!o.force && meta && meta.finalizedAt && sameRun) {
3043
+ const missing = await paperMissingArtifacts(id, o)
3044
+ if (missing.length === 0) {
3045
+ await paperAppendLog(id, 'skip', '幂等:同一 run(' + (Number(scheduler.startedAt) || 0) + ')已有定稿且产物齐全 → 不重复撰写(/vibe paper force 可重写)')
3046
+ 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)' }
3047
+ }
3048
+ await paperAppendLog(id, 'fill', '幂等:已有定稿但缺少 ' + missing.join(', ') + ' → 用已存的定稿内容补写(不重新派遣作者)')
3049
+ 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 })
3050
+ return { ok: true, skipped: true, reason: 'filled-missing-artifacts', filled: missing, id: id, dir: relDir, finalizedAt: filled.finalizedAt, artifacts: filled.artifacts, compile: filled.compile }
3051
+ }
3052
+ if (paperInFlight) {
3053
+ const verdict = paperWriterVerdictNow(o.force)
3054
+ if (verdict === 'reap') {
3055
+ const gone = agentRegistry[paperInFlight] === undefined
3056
+ await paperReapWriter(id, gone ? '子代理已不在 agentRegistry(end 事件丢失 / 宿主丢弃)' : ('超过卡死窗口 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟未返回'))
3057
+ // 回收后继续走下面的 paper 锁 + 派遣路径(force 与收口重试都是这个语义)
3058
+ } else if (verdict === 'refuse') {
3059
+ const ageMs = now() - Number(paperInFlightAt || 0)
3060
+ await paperAppendLog(id, 'force-refused', '仍有在途撰写者 ' + paperInFlight + '(' + Math.round(ageMs / 1000) + 's):超过 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟才会被 force 放弃')
3061
+ 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 会自动回收并重派(心跳也会自动回收)。' }
3062
+ } else {
3063
+ return { ok: true, skipped: true, reason: 'writer-in-flight', childId: paperInFlight, ageMs: now() - Number(paperInFlightAt || 0), staleMs: PAPER_STALE_MS, id: id, dir: relDir }
3064
+ }
3065
+ }
3066
+ if (paperPending) return { ok: true, queued: true, reason: 'retry-queued', retryAt: paperPending.retryAt, tries: paperPending.tries, id: id, dir: relDir }
3067
+ const lock = await paperLockAcquire(id)
3068
+ if (!lock.ok) {
3069
+ await paperAppendLog(id, 'skip', 'paper 锁由会话 ' + lock.holder + ' 持有(未过期)→ 本次不派遣(修订 §A3 的 paper 作用域锁)')
3070
+ return { ok: true, skipped: true, reason: 'locked-by-another-session', holder: lock.holder, id: id, dir: relDir }
3071
+ }
3072
+ return await paperDispatch(trigger, o, id, 0)
3073
+ }
3074
+ /** apply 级心跳用:有排队的论文派遣且到点了吗(同步判断,便宜)。 */
3075
+ function paperRetryDue() { return !!(paperPending && now() >= Number(paperPending.retryAt || 0)) }
3076
+ /**
3077
+ * 心跳(修订 §1):在途 → 续租 paper 锁;**卡死则自动回收**(child 不在注册表,或超过卡死窗口),
3078
+ * 并自动重派(上限 PAPER_MAX_AUTO_REAPS,之后给出可执行提示交给 /vibe paper force);有排队则重试派遣。
3079
+ */
3080
+ async function runPaperRetry() {
3081
+ const id = paperId()
3082
+ if (paperInFlight) {
3083
+ if (paperWriterVerdictNow(false) === 'reap') {
3084
+ const gone = agentRegistry[paperInFlight] === undefined
3085
+ await paperReapWriter(id, gone ? '子代理已不在 agentRegistry(end 事件丢失 / 宿主丢弃)' : ('超过卡死窗口 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟未返回'))
3086
+ const meta = await paperReadMeta(id)
3087
+ if (params.finalPaper !== false && !(meta && meta.finalizedAt) && Number(paperReaps || 0) < PAPER_MAX_AUTO_REAPS) {
3088
+ paperReaps = Number(paperReaps || 0) + 1
3089
+ await paperAppendLog(id, 'retry-after-reap', '第 ' + paperReaps + ' 次自动重派(上限 ' + PAPER_MAX_AUTO_REAPS + ')')
3090
+ await paperDispatch('auto-retry', paperOpts({}), id, 0)
3091
+ } else {
3092
+ await paperAppendLog(id, 'reap-giveup', '自动重派已达上限 ' + PAPER_MAX_AUTO_REAPS + '(或已有定稿):不再自动派遣,请用 /vibe paper force 手动重写')
3093
+ logActivity('paper', '论文撰写子代理连续卡死/已放弃:不再自动重派,可用 /vibe paper force 手动重写')
3094
+ }
3095
+ return
3096
+ }
3097
+ try { await paperLockAcquire(id) } catch (e) { /* 续租失败不致命 */ }
3098
+ return
3099
+ }
3100
+ if (!paperRetryDue()) return
3101
+ const p = paperPending
3102
+ paperPending = null
3103
+ const o = paperOpts(p.opts)
3104
+ await paperDispatch(p.trigger || 'auto-retry', o, id, Number(p.tries || 0))
3105
+ }
3106
+ /** `/vibe paper [lang=zh|en] [format=both|md|tex] [force]`(spec §2/§6)。非法参数 → ok:false(命令层转 kind:'error')。 */
3107
+ async function paperCommand(args) {
3108
+ const rest = Array.isArray(args) ? args : []
3109
+ const opts = {}
3110
+ for (let i = 0; i < rest.length; i++) {
3111
+ const a = String(rest[i] || '')
3112
+ if (a === 'force') { opts.force = true; continue }
3113
+ const eq = a.indexOf('=')
3114
+ const k = eq > 0 ? a.slice(0, eq).toLowerCase() : ''
3115
+ const v = eq > 0 ? a.slice(eq + 1).toLowerCase() : ''
3116
+ if (k === 'lang') { if (v !== 'zh' && v !== 'en') return { ok: false, message: '/vibe paper lang= 只接受 zh|en(收到 ' + JSON.stringify(a) + ')' }; opts.lang = v; continue }
3117
+ 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 }
3118
+ return { ok: false, message: 'unknown /vibe paper option: ' + a + '(可用 lang=zh|en、format=both|md|tex、force)' }
3119
+ }
3120
+ const r = await maybeWritePaper('manual', opts)
3121
+ if (r.ok === false) return r
3122
+ const autoNote = params.finalPaper === false ? '(自动触发已关闭 finalPaper=false;手动命令仍可用)' : ''
3123
+ const msg = r.dispatched ? ('已派遣「论文撰写」子代理,产物将写入 ' + r.dir + '/')
3124
+ : r.queued ? ('宿主激活上限已满:论文撰写已排队,' + (PAPER_RETRY_MS / 1000) + 's 后自动重试(第 ' + r.tries + ' 次)')
3125
+ : (r.reason === 'already-finalized-this-run' ? '同一 run 已有定稿且产物齐全:不重复撰写(需要重写请用 /vibe paper force)'
3126
+ : (r.reason === 'filled-missing-artifacts' ? ('已用既有定稿补写缺失产物:' + (r.filled || []).join(', ')) : ('未重新派遣:' + (r.reason || ''))))
3127
+ return Object.assign({}, r, { message: msg + autoNote, autoDisabled: params.finalPaper === false })
3128
+ }
3129
+ /** 论文撰写子代理结束(spec §4):解析回复 → 组装 9 节 → 落盘 → 编译 → 记日志 → 释放 paper 锁。 */
3130
+ async function handlePaperWriter(childId, meta, output) {
3131
+ const id = meta.paperId || paperId()
3132
+ paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0
3133
+ delete paperAbandoned[childId]
3134
+ const o = paperOpts({ lang: meta.lang, format: meta.format, compilePdf: meta.compilePdf })
3135
+ const parsed = parseJson(output)
3136
+ const info = paperSkeletonFromReply(parsed, output)
3137
+ if (!info.title) info.title = paperTitleFromMd(String(output || '')) || ('研究报告:' + currentProject)
3138
+ for (let i = 0; i < info.notes.length; i++) await paperAppendLog(id, 'note', info.notes[i])
3139
+ if (!String(output || '').trim()) await paperAppendLog(id, 'reply', '作者回复为空:只写骨架 + 占位说明(绝不编造内容)')
3140
+ let written
3141
+ try {
3142
+ 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()) })
3143
+ } catch (e) {
3144
+ await paperAppendLog(id, 'finalize-failed', String((e && e.message) || e))
3145
+ logActivity('paper', '论文落盘失败:' + String((e && e.message) || e))
3146
+ await paperLockRelease(id)
3147
+ return
3148
+ }
3149
+ await paperAppendLog(id, 'finalize', '产物 ' + JSON.stringify(written.artifacts) + ' compile=' + written.compile + (written.compileEngine ? (' engine=' + written.compileEngine) : '') + ' run=' + (Number(scheduler.startedAt) || 0))
3150
+ logActivity('paper', '论文(最终稿)已落盘:' + paperDir(id) + '/(' + Object.keys(written.artifacts).filter(function (k) { return written.artifacts[k] }).join('/') + ',编译=' + written.compile + ')')
3151
+ await paperLockRelease(id)
3152
+ reportDirty = true
3153
+ await saveAll()
3154
+ }
3155
+ /** status 里的论文视图(可观测性;命令与守卫都读它)。 */
3156
+ async function paperStatusView() {
3157
+ const id = paperId()
3158
+ const meta = await paperReadMeta(id)
3159
+ return {
3160
+ id: id, dir: paperDir(id), inFlight: paperInFlight || null,
3161
+ inFlightSince: paperInFlight ? Number(paperInFlightAt || 0) : null,
3162
+ inFlightAgeMs: paperInFlight ? (now() - Number(paperInFlightAt || 0)) : null,
3163
+ staleMs: PAPER_STALE_MS, reapedThisRun: Number(paperReaps || 0),
3164
+ queued: paperPending ? { tries: paperPending.tries, retryAt: paperPending.retryAt, reason: paperPending.reason, limit: paperPending.limit === undefined ? null : paperPending.limit } : null,
3165
+ autoFinalPaper: params.finalPaper !== false, format: params.paperFormat, language: params.paperLanguage, compilePdf: params.paperCompilePdf !== false,
3166
+ finalizedAt: meta ? meta.finalizedAt : null, artifacts: meta ? (meta.artifacts || {}) : {}, compile: meta ? meta.compile : null,
3167
+ }
3168
+ }
3169
+
2666
3170
  // ================= child result dispatch =================
2667
3171
  async function onChildEnd(info) {
2668
3172
  const meta = agentRegistry[info.id]
2669
- if (meta === undefined) return
3173
+ if (meta === undefined) {
3174
+ // 已放弃(回收)的撰写者事后才返回:输出被丢弃,但必须**留痕**,不能静默消失(修订 §1)。
3175
+ if (paperAbandoned[info.id]) {
3176
+ delete paperAbandoned[info.id]
3177
+ logActivity('paper', '已回收的论文撰写子代理 ' + info.id + ' 事后返回:其输出被丢弃(已重派新的撰写者)')
3178
+ reportDirty = true
3179
+ }
3180
+ return
3181
+ }
2670
3182
  const output = blocksToText(info.lastAssistantMessage)
2671
3183
  try {
2672
3184
  if (meta.role === 'explorer') await handleExplorer(info.id, meta, output)
2673
3185
  else if (meta.role === 'solver') await handleSolver(info.id, meta, output, info.stopReason)
2674
3186
  else if (meta.role === 'verifier') await handleVerifier(info.id, meta, output, info.stopReason)
3187
+ // 修订 §A2:论文撰写子代理必须有**自己的分支**——此前未知 role 会静默丢失(撰写成果永远落不了盘)。
3188
+ else if (meta.role === 'paper') await handlePaperWriter(info.id, meta, output)
3189
+ else console.error('vibe-math-v2: subagent ' + info.id + ' ended with an unknown role "' + String(meta.role) + '" — its output is dropped')
2675
3190
  } catch (e) { console.error('vibe-math-v2 onChildEnd error: ' + String((e && e.stack) || e)) }
2676
3191
  await saveAll()
2677
3192
  scheduleTick()
@@ -2693,6 +3208,10 @@ export function apply(ctx) {
2693
3208
  if (Object.keys(agentRegistry).length > 0 || Object.keys(tasks).length > 0) {
2694
3209
  logActivity(fresh ? 'start' : 'resume', 'cleared ' + Object.keys(agentRegistry).length + ' agent(s) and ' + Object.keys(tasks).length + ' task(s) (' + (fresh ? 'restart' : 'stale from previous process') + ')')
2695
3210
  agentRegistry = {}; tasks = {}
3211
+ // 论文的派遣态是本进程内存态:子代理已被中断/丢弃,排队与在途标记必须一起清掉,
3212
+ // 否则 paperInFlight 会永久挡住后续 /vibe paper(修订 §A2 的"静默消失"同类问题)。
3213
+ if (paperInFlight || paperPending) logActivity('paper', '重启/跨进程恢复:清空论文撰写态(inFlight=' + (paperInFlight || '-') + (paperPending ? ', queued' : '') + ')')
3214
+ paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null
2696
3215
  }
2697
3216
  // 并发计数由 agentRegistry 推导:清空 registry 后自然归零,无需显式赋值。
2698
3217
  }
@@ -2703,7 +3222,7 @@ export function apply(ctx) {
2703
3222
  async function startScheduler() { const r = await init(true); if (!r.ok) return r; abandonGatedDecision('scheduler restarted'); scheduler.running = true; scheduler.startedAt = now(); logActivity('start', 'scheduler started for project ' + currentProject); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler started', project: currentProject, frameworkRoot: frameworkRoot() } }
2704
3223
  async function resumeScheduler() { const r = await init(false); if (!r.ok) return r; abandonGatedDecision('scheduler resumed (离开那次运行,挂起的节点不再等待)'); scheduler.running = true; logActivity('resume', 'scheduler resumed'); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler resumed', project: currentProject, frameworkRoot: frameworkRoot() } }
2705
3224
  async function pauseScheduler() { scheduler.running = false; logActivity('pause', 'scheduler paused'); await saveAll(); return { ok: true, message: 'scheduler paused' } }
2706
- async function abortScheduler() { scheduler.running = false; abandonGatedDecision('scheduler aborted'); const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); agentRegistry = {}; logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
3225
+ async function abortScheduler() { scheduler.running = false; abandonGatedDecision('scheduler aborted'); const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); agentRegistry = {}; paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null; logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
2707
3226
  // auto 模式语义 = 无人值守自动通过关键节点:切回 auto 时把仍挂起的人工决策按自动策略放行
2708
3227
  async function autoResolvePending() {
2709
3228
  const pending = decisionQueue.filter(function (d) { return d.status === 'pending' })
@@ -2743,6 +3262,8 @@ export function apply(ctx) {
2743
3262
  todo: formalTodo(),
2744
3263
  paths: { project: 'Formal/', lib: vibeRoot() + '/Formal/Lib/', proved: vibeRoot() + '/Formal/Proved/', proofs: 'Verified/Lean/' },
2745
3264
  },
3265
+ // 最终论文的可观测面(spec §2/§6):自动开关、在途/排队、定稿时间、产物与编译结果。
3266
+ paper: await paperStatusView(),
2746
3267
  }
2747
3268
  }
2748
3269
 
@@ -2755,7 +3276,7 @@ export function apply(ctx) {
2755
3276
  else abandonGatedDecision('project switched')
2756
3277
  currentProject = slug; await writeCurrentProject(); await ensureDirs()
2757
3278
  if ((await readJson('qs/qs.json')) === undefined) await writeJson('qs/qs.json', [])
2758
- params = Object.assign({}, DEFAULT_PARAMS); scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null }; agentRegistry = {}; decisionQueue = []; verifierAccuracy = {}; tasks = {}; explorerRetries = {}; activityLog = []; lastReportWrite = 0; lastPushReport = 0; reportDirty = false
3279
+ params = Object.assign({}, DEFAULT_PARAMS); scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null }; agentRegistry = {}; decisionQueue = []; verifierAccuracy = {}; tasks = {}; explorerRetries = {}; activityLog = []; lastReportWrite = 0; lastPushReport = 0; reportDirty = false; paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null
2759
3280
  formalState = { records: {}, todo: [] } // 形式化记录随项目切换(loadState 会读新项目的 formal.json)
2760
3281
  await loadSettings(); await migrateLegacyParams(); await loadState(); await saveAll()
2761
3282
  return { ok: true, project: slug, frameworkRoot: frameworkRoot() }
@@ -2780,7 +3301,7 @@ export function apply(ctx) {
2780
3301
  registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), async function () { await refreshParams(); return await getStatus() })
2781
3302
  registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json.', objParams({}), async function () { await refreshParams(); await maybeWriteReport(true); return await buildReport() })
2782
3303
  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 } })
2783
- registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 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' }, 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: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { 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 } })
3304
+ registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式。最终论文:finalPaper(默认 true;收口时自动派遣一名「论文撰写」子代理)/ paperFormat = both|md|tex / paperLanguage = zh|en / paperCompilePdf(检测到 LaTeX 时编译 paper.pdf)/ paperLatexCommand(指定引擎,空 = 自动探测),产物在 Paper/<项目>/。', 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' }, 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: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { 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 } })
2784
3305
  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' } })
2785
3306
  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() })
2786
3307
  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') })
@@ -2835,7 +3356,8 @@ export function apply(ctx) {
2835
3356
  }
2836
3357
  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 } }) }
2837
3358
  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 } }
2838
- 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 | project [list|new <name>|<name>] | decisions | agents', message: 'unknown /vibe subcommand: ' + (cmd || '(empty)') }
3359
+ if (cmd === 'paper') return await paperCommand(args)
3360
+ 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 | project [list|new <name>|<name>] | decisions | agents | paper [lang=zh|en] [format=both|md|tex] [force]', message: 'unknown /vibe subcommand: ' + (cmd || '(empty)') }
2839
3361
  }
2840
3362
 
2841
3363
  // ================= session surface =================
@@ -2850,6 +3372,10 @@ export function apply(ctx) {
2850
3372
  // 每次工具调用前同步当前项目(按会话读 current.json;多会话互不干扰)
2851
3373
  refreshProject: async function () { if (rootAgent) currentProject = await readCurrentProject() },
2852
3374
  getRunning: function () { return scheduler.running },
3375
+ // apply 级心跳用(修订 §A2):running=false 之后 scheduleTick() 是空操作,论文的排队重试
3376
+ // (激活上限)与 paper 锁续租必须由**独立于调度器**的心跳驱动。
3377
+ paperRetryDue: paperRetryDue,
3378
+ runPaperRetry: runPaperRetry,
2853
3379
  // childOwner 裁剪用:这个会话当前仍"可能再发 subagent/end"的 child(在册的 + 任务正在等的)。
2854
3380
  referencedChildIds: function () {
2855
3381
  const out = Object.keys(agentRegistry)
@@ -2895,7 +3421,7 @@ export function apply(ctx) {
2895
3421
  registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), 'vibe_math_status')
2896
3422
  registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json.', objParams({}), 'vibe_math_report')
2897
3423
  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')
2898
- registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 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' }, 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: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { 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')
3424
+ registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式。最终论文:finalPaper(默认 true;收口时自动派遣一名「论文撰写」子代理)/ paperFormat = both|md|tex / paperLanguage = zh|en / paperCompilePdf(检测到 LaTeX 时编译 paper.pdf)/ paperLatexCommand(指定引擎,空 = 自动探测),产物在 Paper/<项目>/。', 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' }, 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: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { 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')
2899
3425
  registerTool('vibe_math_setup', 'Return the interactive parameter schema for guided configuration.', objParams({}), 'vibe_math_setup')
2900
3426
  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')
2901
3427
  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')
@@ -2923,7 +3449,7 @@ export function apply(ctx) {
2923
3449
  ctx.effect(() => commands.register({
2924
3450
  name: 'vibe',
2925
3451
  description: 'control the Vibe Math V2 solver (start/pause/projects/setup/save/decisions/agents/propositions)',
2926
- input: { hint: '[start|resume|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|project [list|new <name>|<name>]|decisions|agents]' },
3452
+ input: { hint: '[start|resume|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|project [list|new <name>|<name>]|decisions|agents|paper [lang=zh|en] [format=both|md|tex] [force]]' },
2927
3453
  handler: async function (invocation) {
2928
3454
  const s = getSession(invocation && invocation.agent)
2929
3455
  if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no vibe-math session for this agent' }) }
@@ -2956,7 +3482,13 @@ export function apply(ctx) {
2956
3482
  ctx.effect(() => {
2957
3483
  let beat = 0
2958
3484
  const t = setInterval(function () {
2959
- for (const s of sessions.values()) { if (s.getRunning() && !s.tickInFlight && s.tickDue() && s.scheduler.gate === null) s.scheduleTick() }
3485
+ for (const s of sessions.values()) {
3486
+ if (s.getRunning() && !s.tickInFlight && s.tickDue() && s.scheduler.gate === null) s.scheduleTick()
3487
+ // 论文心跳(修订 §A2):**独立于 scheduler.running** —— 收口后 running=false,但被激活上限
3488
+ // 拒绝的撰写派遣仍要重试,在途时还要续租 paper 锁(否则别的会话 2 分钟后可接管同一棵树)。
3489
+ // runPaperRetry() 在两种情形都不成立时立即返回(一次布尔判断)。
3490
+ if (typeof s.runPaperRetry === 'function') s.runPaperRetry().catch(function (e) { console.error('vibe-math-v2: paper retry failed: ' + String((e && e.message) || e)) })
3491
+ }
2960
3492
  // childOwner / sessions 裁剪:每 30 拍(约 30s)一次,成本是"会话数 × 映射数"的一次扫描。
2961
3493
  if ((++beat % 30) === 0) {
2962
3494
  try { pruneChildOwner() } catch (e) { console.error('vibe-math-v2: pruneChildOwner failed: ' + String((e && e.message) || e)) }
@@ -2980,6 +3512,261 @@ export function apply(ctx) {
2980
3512
  // to the clock) and `parseProgress` normalises the object it is handed in place (pre-existing).
2981
3513
  // Nothing here is used by the plugin at runtime except through `apply()`, and behaviour is
2982
3514
  // byte-identical to the previous in-`apply` declarations.
3515
+ // ============================================================================================
3516
+ // 最终论文(规格:docs/final-paper.md)—— **纯函数部分**(module scope,v2/v3 逐字同构;
3517
+ // 只有数据来源不同)。会话相关的落盘/派遣/编译在 makeSession 里(见 paperCommand/maybeWritePaper)。
3518
+ // ============================================================================================
3519
+ /** 固定 9 节骨架(spec §3)。key 同时是 md 的 `## ` 标题与 tex 的 `\section{}`。 */
3520
+ const PAPER_SKELETON = [
3521
+ { key: '摘要', spec: '1 标题、作者、日期、摘要(原问题 + 主要结论)' },
3522
+ { key: '引言与问题背景', spec: '2 原问题的完整陈述' },
3523
+ { key: '原问题的完整解法', spec: '3 最终答案 + 完整推理链' },
3524
+ { key: '已检验通过的命题', spec: '4 逐条列出,含判定为真的估计值与证据来源' },
3525
+ { key: '已解决的子问题与中间成果', spec: '5' },
3526
+ { key: '创造或发现的有价值之物', spec: '6 方法、理论、思想、有价值经验、数学理解' },
3527
+ { key: '规律总结', spec: '7 从上述条目归纳出的可复用规律' },
3528
+ { key: '讨论、局限与展望', spec: '8' },
3529
+ { key: '附录:证据与文件索引', spec: '9 Verified/、Logs/、关键卡片路径' },
3530
+ ]
3531
+ const PAPER_NO_EVIDENCE = '(本节暂无证据支持的内容——不编造。)'
3532
+ const PAPER_EVIDENCE_HEADING = '证据与文件索引'
3533
+ /** LaTeX 引擎检测顺序(spec §5):中文优先 xelatex,英文优先 pdflatex/latexmk。 */
3534
+ const PAPER_ENGINE_ORDER_ZH = ['xelatex', 'latexmk', 'pdflatex', 'lualatex', 'tectonic']
3535
+ const PAPER_ENGINE_ORDER_EN = ['pdflatex', 'latexmk', 'xelatex', 'lualatex', 'tectonic']
3536
+ /** 只用常见宏包(spec §5);修复阶段会丢弃不在这个名单里的 \usepackage。 */
3537
+ const PAPER_ALLOWED_PACKAGES = ['amsmath', 'amssymb', 'amsthm', 'geometry', 'hyperref', 'longtable', 'booktabs']
3538
+ /** tex 必须转义的 10 个字符(spec §5):\ _ % & # $ { } ~ ^ ——一次替换,避免二次转义。 */
3539
+ const PAPER_TEX_MAP = { '\\': '\\textbackslash{}', '_': '\\_', '%': '\\%', '&': '\\&', '#': '\\#', '$': '\\$', '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}' }
3540
+ function texEscape(s) { return String(s == null ? '' : s).replace(/[\\_%&#${}~^]/g, function (c) { return PAPER_TEX_MAP[c] }) }
3541
+ /** 输入材料摘要(仅供 paper.log.md/meta 记录规模,**不**用于幂等判定:spec v2 §C 用 run id + finalizedAt + 逐产物存在性)。 */
3542
+ function paperMaterialSummary(digest) {
3543
+ const t = String(digest == null ? '' : digest)
3544
+ return { chars: t.length, lines: t.split('\n').length }
3545
+ }
3546
+ /**
3547
+ * 撰写者卡死判定(纯函数,便于守卫覆盖两条分支):
3548
+ * 'none' 正常在途(还没到卡死窗口,且 force 未要求放弃)
3549
+ * 'reap' 必须回收:child 已不在 agentRegistry(end 事件丢失 / 宿主丢弃,它永远不会再回来),
3550
+ * 或已超过卡死窗口——force 与心跳都据此自动降级,论文不能被一个死掉的撰写者永久卡住
3551
+ * 'refuse' force 但还没到窗口:调用方必须给出**可执行**的报错(告诉调用者还要等多久)
3552
+ */
3553
+ function paperWriterVerdict(o) {
3554
+ if (!(o && o.inFlight)) return 'none'
3555
+ if (o.childInRegistry === false) return 'reap'
3556
+ const age = Number((o && o.ageMs) || 0)
3557
+ const stale = Math.max(1, Number((o && o.staleMs) || 0))
3558
+ if (age >= stale) return 'reap'
3559
+ return (o && o.force) ? 'refuse' : 'none'
3560
+ }
3561
+ /** 行内 md → tex:``code`` / **bold** / *em* 转成命令,其余文本按 spec §5 转义;`$...$` 原样保留(数学模式由作者书写)。 */
3562
+ function paperInlineToTex(s) {
3563
+ const t = String(s == null ? '' : s)
3564
+ let out = ''
3565
+ let i = 0
3566
+ while (i < t.length) {
3567
+ const c = t[i]
3568
+ if (c === '$') { const j = t.indexOf('$', i + 1); if (j > i) { out += t.slice(i, j + 1); i = j + 1; continue } }
3569
+ if (c === '`') { const j = t.indexOf('`', i + 1); if (j > i) { out += '\\texttt{' + texEscape(t.slice(i + 1, j)) + '}'; i = j + 1; continue } }
3570
+ 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 } }
3571
+ if (c === '*') { const j = t.indexOf('*', i + 1); if (j > i) { out += '\\emph{' + paperInlineToTex(t.slice(i + 1, j)) + '}'; i = j + 1; continue } }
3572
+ // 普通字符:按 10 个字符的映射逐字转义(一次一个字符,天然不会二次转义)
3573
+ out += PAPER_TEX_MAP[c] !== undefined ? PAPER_TEX_MAP[c] : c
3574
+ i++
3575
+ }
3576
+ return out
3577
+ }
3578
+ /** md 正文 → tex 正文(标题/列表/段落;只认 compose 侧约定的最小语法)。 */
3579
+ function paperMdToTexBody(md) {
3580
+ const out = []
3581
+ let inList = false
3582
+ let buf = []
3583
+ const flush = function () { if (buf.length) { out.push(paperInlineToTex(buf.join(' '))); buf = [] } }
3584
+ const closeList = function () { if (inList) { out.push('\\end{itemize}'); inList = false } }
3585
+ const lines = String(md == null ? '' : md).split(/\r?\n/)
3586
+ for (let i = 0; i < lines.length; i++) {
3587
+ const line = lines[i].replace(/\s+$/, '')
3588
+ const h = /^(#{1,4})\s+(.*)$/.exec(line)
3589
+ if (h) {
3590
+ flush(); closeList()
3591
+ const txt = paperInlineToTex(h[2])
3592
+ const lv = h[1].length
3593
+ out.push(lv <= 1 ? ('\\section*{' + txt + '}') : lv === 2 ? ('\\section{' + txt + '}') : lv === 3 ? ('\\subsection{' + txt + '}') : ('\\subsubsection{' + txt + '}'))
3594
+ continue
3595
+ }
3596
+ const li = /^\s*[-*]\s+(.*)$/.exec(line)
3597
+ if (li) { flush(); if (!inList) { out.push('\\begin{itemize}'); inList = true } out.push(' \\item ' + paperInlineToTex(li[1])); continue }
3598
+ if (!line.trim()) { flush(); closeList(); out.push(''); continue }
3599
+ buf.push(line.trim())
3600
+ }
3601
+ flush(); closeList()
3602
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
3603
+ }
3604
+ /** 主模板(spec §5):中文 ctexart / 英文 article,只加常见宏包。 */
3605
+ function paperTexDoc(o) {
3606
+ const lang = (o && o.lang === 'en') ? 'en' : 'zh'
3607
+ return [
3608
+ '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}',
3609
+ '\\usepackage[margin=2.5cm]{geometry}',
3610
+ '\\usepackage{amsmath,amssymb,amsthm}',
3611
+ '\\usepackage{hyperref}',
3612
+ '\\usepackage{longtable,booktabs}',
3613
+ '\\title{' + texEscape(o && o.title) + '}',
3614
+ '\\author{' + texEscape(o && o.author) + '}',
3615
+ '\\date{' + texEscape(o && o.date) + '}',
3616
+ '\\begin{document}',
3617
+ '\\maketitle',
3618
+ String((o && o.bodyTex) || ''),
3619
+ '\\end{document}',
3620
+ ].join('\n') + '\n'
3621
+ }
3622
+ /** 修复阶段的**最小模板**(spec §5):只留 documentclass + 正文(section/itemize/texttt 都在 LaTeX 核心)。 */
3623
+ function paperMinimalTexDoc(o) {
3624
+ const lang = (o && o.lang === 'en') ? 'en' : 'zh'
3625
+ return [
3626
+ '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}',
3627
+ '\\begin{document}',
3628
+ String((o && o.bodyTex) || ''),
3629
+ '\\end{document}',
3630
+ ].join('\n') + '\n'
3631
+ }
3632
+ /** 修复阶段:丢弃不在常见宏包名单里的 \usepackage(其余原样保留)。 */
3633
+ function paperSanitizeTex(tex) {
3634
+ const keep = []
3635
+ const lines = String(tex == null ? '' : tex).split('\n')
3636
+ for (let i = 0; i < lines.length; i++) {
3637
+ const m = /^\s*\\usepackage(\[[^\]]*\])?\{([^}]*)\}\s*$/.exec(lines[i])
3638
+ if (m) {
3639
+ const pkgs = String(m[2]).split(',').map(function (x) { return x.trim() }).filter(Boolean)
3640
+ const ok = pkgs.filter(function (p) { return PAPER_ALLOWED_PACKAGES.indexOf(p) >= 0 })
3641
+ if (ok.length === 0) continue
3642
+ keep.push('\\usepackage' + (m[1] || '') + '{' + ok.join(',') + '}')
3643
+ continue
3644
+ }
3645
+ keep.push(lines[i])
3646
+ }
3647
+ return keep.join('\n')
3648
+ }
3649
+ /** 骨架名匹配:允许带序号/前缀,或只写节名的一部分(例如「附录」)。 */
3650
+ function paperSkeletonKey(name) {
3651
+ const n = String(name == null ? '' : name).replace(/^#+\s*/, '').trim()
3652
+ if (!n) return undefined
3653
+ for (const s of PAPER_SKELETON) if (n === s.key) return s.key
3654
+ for (const s of PAPER_SKELETON) if (n.indexOf(s.key) !== -1 || s.key.indexOf(n) !== -1) return s.key
3655
+ return undefined
3656
+ }
3657
+ /** 按 `## ` 把任意 markdown 切成 [{name,text}](无标题时 name='')。 */
3658
+ function paperSplitByHeadings(md) {
3659
+ const out = []
3660
+ let cur = { name: '', text: [] }
3661
+ const lines = String(md == null ? '' : md).split(/\r?\n/)
3662
+ for (let i = 0; i < lines.length; i++) {
3663
+ const h = /^##\s+(.*?)\s*$/.exec(lines[i])
3664
+ 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 }
3665
+ cur.text.push(lines[i])
3666
+ }
3667
+ if (cur.name || cur.text.length) out.push({ name: cur.name, text: cur.text.join('\n').trim() })
3668
+ return out
3669
+ }
3670
+ /**
3671
+ * 把「论文撰写」子代理的回复映射到 9 节骨架上(spec §3/§4)。
3672
+ * 接受契约 JSON(title/abstract/sections[])或一整段 md(按 `## ` 切);**绝不编造**:
3673
+ * 没写到的节由调用方填 PAPER_NO_EVIDENCE,不属于骨架的节并入第 8 节并记一条说明。
3674
+ */
3675
+ function paperSkeletonFromReply(reply, fallbackText) {
3676
+ const sections = {}
3677
+ const notes = []
3678
+ let title = ''
3679
+ let abstract = ''
3680
+ const put = function (key, body) {
3681
+ const b = String(body == null ? '' : body).trim()
3682
+ if (!b) return
3683
+ sections[key] = sections[key] ? (sections[key] + '\n\n' + b) : b
3684
+ }
3685
+ const absorbUnknown = function (name, text) {
3686
+ if (!name && !String(text || '').trim()) return
3687
+ notes.push('作者写了一节「' + (name || '(无名)') + '」,不属于固定 9 节骨架,已并入「讨论、局限与展望」')
3688
+ put('讨论、局限与展望', (name ? ('**' + name + '**\n\n') : '') + text)
3689
+ }
3690
+ const obj = (reply && typeof reply === 'object' && !Array.isArray(reply)) ? reply : null
3691
+ if (obj) {
3692
+ title = String(obj.title || obj.标题 || '').trim()
3693
+ abstract = String(obj.abstract || obj.摘要 || '').trim()
3694
+ const list = Array.isArray(obj.sections) ? obj.sections : (Array.isArray(obj.章节) ? obj.章节 : [])
3695
+ for (let i = 0; i < list.length; i++) {
3696
+ const s = list[i]
3697
+ if (!s) continue
3698
+ const name = String(s.name || s.key || s.heading || s.标题 || '')
3699
+ const body = String(s.body != null ? s.body : (s.content != null ? s.content : (s.内容 != null ? s.内容 : '')))
3700
+ const key = paperSkeletonKey(name)
3701
+ if (key === undefined) absorbUnknown(name.trim(), body)
3702
+ else put(key, body)
3703
+ }
3704
+ const bodyField = String(obj.body || obj.content || obj.md || obj.正文 || '')
3705
+ if (bodyField.trim()) {
3706
+ const parts = paperSplitByHeadings(bodyField)
3707
+ for (let i = 0; i < parts.length; i++) {
3708
+ const key = paperSkeletonKey(parts[i].name)
3709
+ 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) }
3710
+ else put(key, parts[i].text)
3711
+ }
3712
+ }
3713
+ } else {
3714
+ // 非契约 JSON:把模型**原样**写出的 md 当正文(不编造任何内容;识别不了就整体并入第 8 节)
3715
+ const parts = paperSplitByHeadings(String(fallbackText == null ? '' : fallbackText))
3716
+ let matched = 0
3717
+ for (let i = 0; i < parts.length; i++) {
3718
+ const key = paperSkeletonKey(parts[i].name)
3719
+ if (key === undefined) { if (parts[i].name) absorbUnknown(parts[i].name, parts[i].text) }
3720
+ else { put(key, parts[i].text); matched++ }
3721
+ }
3722
+ if (matched === 0) {
3723
+ const raw = String(fallbackText == null ? '' : fallbackText).trim()
3724
+ if (raw) { notes.push('作者回复不是契约 JSON,也没有可识别的 `## ` 节标题;其原样正文已记入「讨论、局限与展望」'); put('讨论、局限与展望', raw) }
3725
+ else notes.push('作者回复为空:没有任何内容可写(不得编造)')
3726
+ }
3727
+ }
3728
+ return { title: title, abstract: abstract, sections: sections, notes: notes }
3729
+ }
3730
+ /** 组装 md(spec §3):9 节固定骨架 + 确定性证据索引;缺证据的节写占位说明。 */
3731
+ function paperBuildMarkdown(o) {
3732
+ const sec = (o && o.sections) || {}
3733
+ const ev = ((o && o.evidence) || []).slice()
3734
+ const L = []
3735
+ const ttl = String((o && o.title) || '').trim() || '研究报告'
3736
+ L.push('# ' + ttl)
3737
+ L.push('')
3738
+ L.push('- 作者:' + String((o && o.author) || ''))
3739
+ L.push('- 日期:' + String((o && o.date) || ''))
3740
+ L.push('')
3741
+ for (let i = 0; i < PAPER_SKELETON.length; i++) {
3742
+ const key = PAPER_SKELETON[i].key
3743
+ L.push('## ' + key)
3744
+ L.push('')
3745
+ let body = String(sec[key] || '').trim()
3746
+ if (key === '摘要') { const ab = String((o && o.abstract) || '').trim(); if (ab) body = body ? (ab + '\n\n' + body) : ab }
3747
+ if (key === '附录:证据与文件索引' && ev.length) {
3748
+ const idx = '### ' + PAPER_EVIDENCE_HEADING + '\n\n' + ev.map(function (x) { return '- `' + String(x) + '`' }).join('\n')
3749
+ body = body ? (body + '\n\n' + idx) : idx
3750
+ }
3751
+ L.push(body || PAPER_NO_EVIDENCE)
3752
+ L.push('')
3753
+ }
3754
+ return L.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
3755
+ }
3756
+ /** paper.log.md 的**唯一格式**(v2/v3 逐字一致,spec §4)。 */
3757
+ function paperLogHead(id) {
3758
+ return ['# 论文写作日志|' + String(id), '',
3759
+ '> 单作者流程(v2/v3 同一格式):收口触发 → 派遣「论文撰写」子代理 → 落盘 md/tex → 编译 pdf。', '']
3760
+ }
3761
+ function paperLogLine(at, tag, text) { return '- ' + fmtTime(at) + ' [' + String(tag) + '] ' + String(text == null ? '' : text) }
3762
+ /** 论文目录 id:只允许安全字符(同一把归一化 ⇒ 不可能写出 Paper/ 之外,spec §6 越权用例)。 */
3763
+ function paperDirId(raw) { return (safeId(String(raw == null ? '' : raw).trim()) || 'run') }
3764
+ /** 从回复里抽出可能存在的标题行(md 非契约路径的兜底)。 */
3765
+ function paperTitleFromMd(md) {
3766
+ const m = /^#\s+(.*?)\s*$/m.exec(String(md == null ? '' : md))
3767
+ return m ? m[1].trim() : ''
3768
+ }
3769
+
2983
3770
  export const __testHelpers = {
2984
3771
  uuid,
2985
3772
  shortId,
@@ -2994,6 +3781,27 @@ export const __testHelpers = {
2994
3781
  parseProgress,
2995
3782
  sanitizeToolFilter,
2996
3783
  registeredToolsFromError,
3784
+ // final paper(spec §6 的纯函数守卫面)
3785
+ PAPER_SKELETON,
3786
+ PAPER_NO_EVIDENCE,
3787
+ PAPER_ALLOWED_PACKAGES,
3788
+ PAPER_ENGINE_ORDER_ZH,
3789
+ PAPER_ENGINE_ORDER_EN,
3790
+ texEscape,
3791
+ paperMaterialSummary,
3792
+ paperWriterVerdict,
3793
+ paperInlineToTex,
3794
+ paperMdToTexBody,
3795
+ paperTexDoc,
3796
+ paperMinimalTexDoc,
3797
+ paperSanitizeTex,
3798
+ paperSkeletonKey,
3799
+ paperSkeletonFromReply,
3800
+ paperBuildMarkdown,
3801
+ paperLogHead,
3802
+ paperLogLine,
3803
+ paperDirId,
3804
+ paperTitleFromMd,
2997
3805
  }
2998
3806
 
2999
3807
  function now() { return Date.now() }