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.
@@ -68,6 +68,34 @@ const EV = {
68
68
  formal: 'vibe5/formal',
69
69
  }
70
70
 
71
+ // ── final paper (docs/final-paper.md) ─────────────────────────────────────────
72
+ // The run's LAST deliverable. The permanent staff each write their own part, cross-review
73
+ // each other's part, and the editor named by `paperEditor` finalises — but ONLY after the
74
+ // whole institute has agreed the parts are deliverable, and (when the office edits) only
75
+ // after the office has consulted the institute. The phase runs BEFORE the completion flags
76
+ // are flipped (docs/final-paper.md §3): after `phase='solved'` the machinery refuses to wake
77
+ // members (`wakeIfIdle`), to convene meetings (`startMeeting`) or to dispatch an end.
78
+ const PAPER_MAX_ROUNDS = 3
79
+ // Chinese first (docs/final-paper.md §8): xelatex covers CJK out of the box; English prefers pdflatex.
80
+ const PAPER_ENGINE_ORDER = {
81
+ zh: ['xelatex', 'latexmk', 'pdflatex', 'lualatex', 'tectonic'],
82
+ en: ['pdflatex', 'latexmk', 'lualatex', 'tectonic', 'xelatex'],
83
+ }
84
+ // The repair pass keeps only what the text itself needs; everything else (hyperref,
85
+ // longtable, booktabs, an unknown package) is dropped before the retry.
86
+ const PAPER_REPAIR_CORE = ['amsmath', 'amssymb', 'amsthm', 'geometry', 'ctex']
87
+ const PAPER_SECTIONS = [
88
+ '标题、作者、日期与摘要',
89
+ '引言与问题背景',
90
+ '原问题的完整解法',
91
+ '已检验通过的命题',
92
+ '已解决的子问题 / 中间成果',
93
+ '创造或发现的有价值之物:方法、理论、思想、经验与数学理解',
94
+ '规律总结',
95
+ '讨论、局限与展望',
96
+ '附录:证据与文件索引',
97
+ ]
98
+
71
99
  // Stable error codes (ported from DSH agent-teams' typed-error discipline).
72
100
  function v5err(code, message) {
73
101
  const e = new Error(message || code)
@@ -189,6 +217,9 @@ export function apply(ctx) {
189
217
  lastProgressAt: now(),
190
218
  artifactCount: 0,
191
219
  diagnostics: [],
220
+ // The final-paper flow (stage machine + its artifacts state). Durable like everything
221
+ // else, so a restart resumes the same stage instead of writing a second paper.
222
+ paper: null,
192
223
  }
193
224
  }
194
225
  function initState() { return { v: PROJECTION_VERSION, institutes: {}, order: [] } }
@@ -228,6 +259,16 @@ export function apply(ctx) {
228
259
  if (patch.runId !== undefined) n.runId = String(patch.runId)
229
260
  if (patch.lastProgressAt !== undefined) n.lastProgressAt = Number(patch.lastProgressAt) || 0
230
261
  if (patch.artifactCount !== undefined) n.artifactCount = Number(patch.artifactCount) || 0
262
+ // The final-paper state. A FUNCTION is a MUTATION applied inside the fold (the same
263
+ // trick the verify queue uses): the paper stage, its parts and its office-consultation
264
+ // counters are updated from several places (member replies, office messages, meetings),
265
+ // and a whole-object read-modify-write outside the fold would lose updates.
266
+ if (patch.paper !== undefined) {
267
+ const next = typeof patch.paper === 'function'
268
+ ? patch.paper(n.paper === undefined ? null : n.paper)
269
+ : patch.paper
270
+ if (next !== undefined) n.paper = next
271
+ }
231
272
  return n
232
273
  })
233
274
  }
@@ -510,6 +551,22 @@ export function apply(ctx) {
510
551
  leanCommand: 'lean',
511
552
  leanArgs: [],
512
553
  leanTimeoutMs: 120000,
554
+ // ── final paper (docs/final-paper.md; the phase runs BEFORE the completion flags) ──
555
+ // finalPaper — write the final paper when the run concludes (manual /v5 paper
556
+ // still works when this is false, and says so).
557
+ // paperFormat — 'both' | 'md' | 'tex'
558
+ // paperLanguage — 'zh' | 'en'
559
+ // paperCompilePdf — compile a PDF when a LaTeX engine is detected
560
+ // paperEditor — 'academician' (default: the only editor reachable unattended) |
561
+ // 'office' (manual /v5 paper only: the office must consult the
562
+ // institute first — messages + at least one meeting)
563
+ // paperLatexCommand — force ONE engine command instead of auto-detection ('' = auto)
564
+ finalPaper: true,
565
+ paperFormat: 'both',
566
+ paperLanguage: 'zh',
567
+ paperCompilePdf: true,
568
+ paperEditor: 'academician',
569
+ paperLatexCommand: '',
513
570
  // ── model / tools ────────────────────────────────────────────────────
514
571
  provider: '',
515
572
  model: '',
@@ -1248,6 +1305,10 @@ export function apply(ctx) {
1248
1305
  }
1249
1306
  counters.message = n
1250
1307
  await putCounters(counters)
1308
+ // The office talking to the institute is HALF of the `paperEditor='office'` consultation
1309
+ // requirement; counting it here (the one place a message really is queued) means the gate
1310
+ // cannot be satisfied by merely intending to consult (docs/final-paper.md §7).
1311
+ if (from === 'office') await notePaperConsult('message')
1251
1312
  notifyActivity()
1252
1313
  // Kick one scheduling pass so an ADDRESSED message (dm/office/assign) wakes its
1253
1314
  // recipient promptly instead of waiting for the digest window. Plain chat stays
@@ -3311,6 +3372,11 @@ export function apply(ctx) {
3311
3372
  solveVotes.clear()
3312
3373
  await putMeeting({ id, agenda: meeting.agenda, kind: meeting.kind, at: now(), file: 'Shared/Meetings/' + id + '.md' })
3313
3374
  await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
3375
+ // A meeting that ACTUALLY begins and was convened by the office is the other half of the
3376
+ // `paperEditor='office'` consultation requirement (docs/final-paper.md §7). Counted here, not at the
3377
+ // request: a parked/refused request is not a consultation. A FRAMEWORK-convened stall
3378
+ // meeting (`auto:true`) is not the office's own act, so it does not count either.
3379
+ if (meeting.by === 'office' && opts.auto !== true) await notePaperConsult('meeting')
3314
3380
  await mkdirs()
3315
3381
  await writeTextRel('Shared/Meetings/' + id + '.md', [
3316
3382
  '# 会议纪要|' + id + '|' + instituteName,
@@ -3429,9 +3495,24 @@ export function apply(ctx) {
3429
3495
  // Stop ONLY on a unanimous true solve-vote from every VOTING member. There is no
3430
3496
  // forced/flat/near-consensus closure: any objection keeps the institute working.
3431
3497
  async function checkSolved() {
3498
+ if (autoDone) return true
3432
3499
  const vs = voters().map((m) => m.id)
3433
3500
  if (!vs.length) return false
3434
3501
  if (!vs.every((id) => solveVotes.get(id) === true)) return false
3502
+ // ── THE FINAL PAPER PHASE COMES FIRST (docs/final-paper.md §3) ────────────────────────────────
3503
+ // `finishRun` flips phase='solved' / autoDone / running=false, after which the machinery
3504
+ // REFUSES: `wakeIfIdle` returns false, `startMeeting` rejects a concluded institute and
3505
+ // nothing schedules — so the co-writing, the cross-review and the office consultation
3506
+ // could never run. The run therefore stays alive here until the paper is finalised
3507
+ // (or degraded with a report), and `finalizePaper` calls `finishRun` for us.
3508
+ if (params.finalPaper !== false) {
3509
+ const p = paper()
3510
+ if (!p) { await startPaper('run-complete', {}); return true }
3511
+ if (p.status !== 'finalized') {
3512
+ if (!p.completesRun) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { completesRun: true }) : cur))
3513
+ return true
3514
+ }
3515
+ }
3435
3516
  await finishRun('全体有表决权者一致认为原问题已解决')
3436
3517
  return true
3437
3518
  }
@@ -3469,6 +3550,916 @@ export function apply(ctx) {
3469
3550
  notifyActivity()
3470
3551
  }
3471
3552
 
3553
+ // ================= final paper (docs/final-paper.md) ============
3554
+ // THE PHASE RUNS BEFORE `finishRun` (docs/final-paper.md §3). After `phase='solved'` the machinery
3555
+ // refuses: `wakeIfIdle` (:3257) returns false, `startMeeting` refuses a concluded
3556
+ // institute, and a concluded run has no scheduling at all — so the co-writing, the
3557
+ // cross-review and the office consultation could never run. `checkSolved` therefore
3558
+ // enters this phase while the run is still alive and only calls `finishRun` once the
3559
+ // paper is finalised (or degraded with a report).
3560
+ //
3561
+ // Only evidence that ALREADY exists is written down: verified verdicts, the task board,
3562
+ // the Lean records, the members' libraries and the members' own text. The framework never
3563
+ // invents content — sections 3/6/7/8 are the members' own words, 4/5/9 are derived from
3564
+ // durable state, and undecided/refuted items are always marked as such.
3565
+ const PAPER_FILE = (id, file) => 'Paper/' + id + '/' + file
3566
+ function paper() { return inst().paper || null }
3567
+ function paperActive() { const p = paper(); return !!p && p.status !== 'finalized' }
3568
+ function paperParticipants() {
3569
+ return activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher').map((m) => m.id)
3570
+ }
3571
+ function paperActiveParticipants(p) {
3572
+ return ((p && p.participants) || []).filter((id) => { const m = memberById(id); return !!m && m.phase === 'active' })
3573
+ }
3574
+ function paperConfiguredEditor() { return params.paperEditor === 'office' ? 'office' : 'academician' }
3575
+ function paperIdFromState() {
3576
+ const a = idSafe(instituteName)
3577
+ if (a) return a
3578
+ const b = idSafe(String(runId || (inst() && inst().runId) || ''))
3579
+ return b || 'institute'
3580
+ }
3581
+ async function fileExistsAbs(abs) {
3582
+ try { const t = await fsTargetAbs(abs); return (await fs.stat(t)) !== undefined } catch (e) { return false }
3583
+ }
3584
+ function paperAbs(id, file) { return instRoot() + '/' + PAPER_FILE(id, file) }
3585
+ function paperDirRel(id) { return 'Paper/' + id }
3586
+ async function mutatePaper(fn) {
3587
+ await patchInstitute({ paper: (cur) => { const next = fn(cur === undefined ? null : cur); return next === undefined ? (cur === undefined ? null : cur) : next } })
3588
+ return paper()
3589
+ }
3590
+ async function paperLog(title, body) {
3591
+ const p = paper()
3592
+ const id = (p && p.id) || paperIdFromState()
3593
+ const rel = PAPER_FILE(id, 'paper.log.md')
3594
+ const prev = (await readTextRel(rel)) || ('# 最终论文流程日志|' + id + '\n\n')
3595
+ await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
3596
+ return rel
3597
+ }
3598
+ function paperAskEvery() { return posMs(params.activityTimeoutMs, 120000) }
3599
+ function paperNextStep(p) {
3600
+ if (!p) return 'no paper flow yet: finish the run with finalPaper=true, or trigger /v5 paper manually'
3601
+ if (p.status === 'finalized') return 'finalised — /v5 paper force rewrites it'
3602
+ if (p.status === 'writing') return 'waiting for the staff parts (round ' + p.round + '/' + PAPER_MAX_ROUNDS + ')'
3603
+ if (p.status === 'reviewing') return 'waiting for the cross-reviews (each reviewer judges another part)'
3604
+ if (p.status === 'awaiting-editor' && p.editor === 'office') {
3605
+ return 'awaiting the OFFICE: send >=1 office message and convene >=1 meeting, then call vibe_v5_finalize_paper (consultation so far: messages=' +
3606
+ Number((p.consult || {}).messages || 0) + ', meetings=' + Number((p.consult || {}).meetings || 0) + ')'
3607
+ }
3608
+ if (p.status === 'awaiting-editor') return 'waiting for the academician to finalise'
3609
+ return p.status
3610
+ }
3611
+ function paperAutoNote() {
3612
+ return params.finalPaper === false ? '自动已关闭(finalPaper=false);本次为手动触发,仍会生成论文。' : ''
3613
+ }
3614
+ function paperSummary() {
3615
+ const p = paper()
3616
+ if (!p) return null
3617
+ return {
3618
+ id: p.id, status: p.status, stage: p.stage, round: p.round,
3619
+ editor: p.editor, editorFallback: !!p.editorFallback, completesRun: !!p.completesRun,
3620
+ participants: p.participants || [],
3621
+ parts: Object.keys(p.parts || {}).map((id) => ({ member: id, round: (p.parts[id] || {}).round, title: (p.parts[id] || {}).title || '' })),
3622
+ reviews: Object.keys(p.reviews || {}).map((id) => ({ member: id, of: (p.reviews[id] || {}).of || '', round: (p.reviews[id] || {}).round, deliverable: (p.reviews[id] || {}).deliverable === true })),
3623
+ reviewOf: p.reviewOf || {},
3624
+ consult: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
3625
+ lang: p.lang, format: p.format, compilePdf: p.pdf === true,
3626
+ compile: p.compile || null, engine: p.engine || '', artifacts: p.artifacts || [],
3627
+ warnings: p.warnings || [], disagreement: p.disagreement || [], skipped: p.skipped || [],
3628
+ forcedAfterCap: !!p.forcedAfterCap, finalizedAt: p.finalizedAt || null,
3629
+ dir: 'Paper/' + p.id + '/', next: paperNextStep(p),
3630
+ meta: p.status === 'finalized' ? 'Paper/' + p.id + '/paper.meta.json' : null,
3631
+ }
3632
+ }
3633
+
3634
+ // ---- the three asks ----------------------------------------------------
3635
+ function paperWritePrompt(member, p) {
3636
+ const L = []
3637
+ L.push('【最终论文·撰写 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
3638
+ L.push('本所对原问题的一致结论已经达成,现在撰写**最终论文**(第 ' + p.round + '/' + PAPER_MAX_ROUNDS + ' 轮)。')
3639
+ L.push('请你**只写你自己库里已有证据支撑**的内容:')
3640
+ L.push('- 直接引用你的卡片(Members/' + member.id + '/Propos|Methods|Subproblems/)、Members/' + member.id + '/Progress/progress.md、你参与的表决记录;')
3641
+ L.push('- **不得编造**:没有证据的推测不要写成结论;未决 / 被否证的条目必须显式标注“未定论 / 已被否证”;')
3642
+ L.push('- 在 evidence 里写清证据路径,附录会逐条索引。')
3643
+ if (p.round > 1) {
3644
+ const rv = (p.reviews || {})[member.id]
3645
+ L.push('')
3646
+ L.push('上一轮互审给你(' + member.id + ')部分的意见:' + (rv ? String(rv.comments || '(无具体意见)') : '(无)'))
3647
+ L.push('请据此修订;若不同意该意见,请在 limits 里写明理由。')
3648
+ }
3649
+ L.push('')
3650
+ L.push('------------')
3651
+ L.push(stateBlock(member))
3652
+ L.push('------------')
3653
+ L.push('结束时只输出一个 JSON 对象:')
3654
+ L.push('{ "paper_part": {')
3655
+ L.push(' "title": "你这部分的标题",')
3656
+ L.push(' "solution": "你对**原问题完整解法**的贡献(推理链与结论,只写有证据的)",')
3657
+ L.push(' "methods": "你创造/发现的方法、理论、思想、有价值经验、数学理解",')
3658
+ L.push(' "rules": "你从这些工作中归纳出的可复用规律",')
3659
+ L.push(' "limits": "局限、未决、被否证之处(必须诚实、显式)",')
3660
+ L.push(' "evidence": ["Members/' + member.id + '/Propos/p-x.md"] } }')
3661
+ return L.join('\n')
3662
+ }
3663
+ function paperReviewPrompt(member, p, ofId) {
3664
+ const part = ofId ? (p.parts || {})[ofId] : null
3665
+ const L = []
3666
+ L.push('【最终论文·互审 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
3667
+ L.push('请你审阅 **' + (ofId || '(未指定)') + '** 撰写的部分,判断它是否可以交付(deliverable)。')
3668
+ L.push('要点:证据是否充分;是否与已定论的表决一致;有无编造或未标注的未决项;术语与符号是否清楚。')
3669
+ L.push('')
3670
+ L.push('------------ 待审部分(' + (ofId || '') + ')------------')
3671
+ L.push('标题:' + ((part && part.title) || '(无)'))
3672
+ L.push('【完整解法】' + String((part && part.solution) || '(空)').slice(0, 6000))
3673
+ L.push('【方法/理论/思想/经验/理解】' + String((part && part.methods) || '(空)').slice(0, 4000))
3674
+ L.push('【规律】' + String((part && part.rules) || '(空)').slice(0, 3000))
3675
+ L.push('【局限/未决】' + String((part && part.limits) || '(空)').slice(0, 3000))
3676
+ L.push('【声称的证据】' + ((((part && part.evidence) || []).join('、')) || '(无)'))
3677
+ L.push('------------')
3678
+ L.push('')
3679
+ L.push(stateBlock(member))
3680
+ L.push('')
3681
+ L.push('结束时只输出一个 JSON 对象:')
3682
+ L.push('{ "paper_review": { "of": "' + (ofId || '') + '", "deliverable": true, "comments": "具体意见" } }')
3683
+ L.push('deliverable 必须是 true(可交付)或 false(不可交付,需修改)。只有**全体参与成员**都投 true,定稿代表才能定稿。')
3684
+ return L.join('\n')
3685
+ }
3686
+ function paperFinalPrompt(member, p) {
3687
+ const L = []
3688
+ L.push('【最终论文·定稿(院士)—— ' + member.id + '】')
3689
+ L.push('全体参与成员已在互审中表示“可交付”。请你作为定稿代表做**最后一次**把关:')
3690
+ L.push('核对合并稿与互审意见,确认没有编造、没有未标注的未决项、没有与表决记录矛盾之处,然后给出决定。')
3691
+ L.push('')
3692
+ L.push('------------ 合并稿(各成员部分)------------')
3693
+ for (const id of paperActiveParticipants(p)) {
3694
+ const part = (p.parts || {})[id] || {}
3695
+ L.push('### ' + id + '|' + String(part.title || ''))
3696
+ L.push('【完整解法】' + String(part.solution || '').slice(0, 3000))
3697
+ L.push('【方法/规律】' + (String(part.methods || '') + ' ' + String(part.rules || '')).slice(0, 2000))
3698
+ L.push('【局限】' + String(part.limits || '').slice(0, 1500))
3699
+ L.push('')
3700
+ }
3701
+ L.push('------------ 互审结论 ------------')
3702
+ for (const id of Object.keys(p.reviews || {})) {
3703
+ const rv = p.reviews[id] || {}
3704
+ L.push('- ' + id + ' 审 ' + (rv.of || '?') + ':deliverable=' + (rv.deliverable === true) + '|' + String(rv.comments || '').slice(0, 500))
3705
+ }
3706
+ if ((p.disagreement || []).length) L.push('- ⚠ 已达轮次上限仍有分歧:' + JSON.stringify(p.disagreement[p.disagreement.length - 1]).slice(0, 500))
3707
+ L.push('------------')
3708
+ L.push('')
3709
+ L.push(stateBlock(member))
3710
+ L.push('')
3711
+ L.push('结束时只输出一个 JSON 对象:')
3712
+ L.push('{ "paper_final": { "decision": "deliverable" | "revise",')
3713
+ L.push(' "note": "定稿说明:你如何审阅、统一术语与符号、是否发现并纠正了问题",')
3714
+ L.push(' "conclusion": "(可选)定稿代表对原问题的最终结论(只写有证据的)" } }')
3715
+ L.push('decision="revise" 会退回继续修订(有轮次上限)。')
3716
+ return L.join('\n')
3717
+ }
3718
+
3719
+ // ---- the stage driver --------------------------------------------------
3720
+ let paperLock = false
3721
+ async function paperStep() {
3722
+ const p = paper()
3723
+ if (!p || p.status === 'finalized' || p.status === 'awaiting-editor') return false
3724
+ const active = paperActiveParticipants(p)
3725
+ if (!active.length) return await paperAdvance(p, active)
3726
+ const parts = p.parts || {}
3727
+ const reviews = p.reviews || {}
3728
+ const missing = p.status === 'writing'
3729
+ ? active.filter((id) => !(parts[id] && parts[id].round === p.round))
3730
+ : active.filter((id) => !(reviews[id] && reviews[id].round === p.round))
3731
+ if (!missing.length) return await paperAdvance(p, active)
3732
+ const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
3733
+ let asked = 0
3734
+ for (const id of missing) {
3735
+ if (asked >= budget) break
3736
+ if (busy.has(id)) continue
3737
+ const m = memberById(id)
3738
+ if (!m || m.phase !== 'active') continue
3739
+ const last = (p.lastAskAt || {})[id] || 0
3740
+ if (last && (now() - last) < paperAskEvery()) continue
3741
+ const prompt = p.status === 'writing'
3742
+ ? paperWritePrompt(m, p)
3743
+ : paperReviewPrompt(m, p, (p.reviewOf || {})[id])
3744
+ // `wakeMember`, not `wakeIfIdle`: the paper phase deliberately drives members with a
3745
+ // dedicated ask, and a research round must never be mistaken for a paper answer.
3746
+ const ok = await wakeMember(m, prompt, 'paper')
3747
+ if (ok) {
3748
+ asked += 1
3749
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3750
+ lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now(),
3751
+ }) : cur))
3752
+ }
3753
+ }
3754
+ // Nothing to advance yet. ALWAYS re-arm: the pass that got here may have skipped an ask
3755
+ // because its member is busy or still inside the ask window, and `schedulePass` returns
3756
+ // from the paper branch BEFORE its own heartbeat arming — without this a paper whose ask
3757
+ // was paced out would stall until some unrelated member turn ended.
3758
+ if (asked) { armHeartbeat(); return false }
3759
+ // Stall watchdog (same clock as meetings/verifications): a member whose turn never ends
3760
+ // must not wedge the paper forever. The stuck members are recorded as non-participants.
3761
+ const stale = now() - Number(p.updatedAt || p.createdAt || now())
3762
+ if (stale >= recoverStallMs()) {
3763
+ const stuck = missing.filter((id) => busy.has(id))
3764
+ const name = stuck.length ? stuck : missing
3765
+ const warn = '看门狗:' + name.join('、') + ' 在 ' + Math.round(stale / 1000) + 's 内没有交稿,已按“未参与”处理并记入附录。'
3766
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3767
+ warnings: (cur.warnings || []).concat([warn]),
3768
+ skipped: Array.from(new Set((cur.skipped || []).concat(name))), updatedAt: now(),
3769
+ }) : cur))
3770
+ await paperLog('看门狗', warn)
3771
+ return await paperAdvance(paper(), paperActiveParticipants(paper()))
3772
+ }
3773
+ armHeartbeat()
3774
+ return false
3775
+ }
3776
+ async function paperAdvance(p, active) {
3777
+ if (p.status === 'writing') {
3778
+ const reviewOf = {}
3779
+ for (let i = 0; i < active.length; i++) reviewOf[active[i]] = active[(i + 1) % active.length]
3780
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3781
+ status: 'reviewing', stage: 'reviewing', reviewOf, reviews: {}, lastAskAt: {}, updatedAt: now(),
3782
+ }) : cur))
3783
+ await paperLog('进入互审(第 ' + p.round + ' 轮)', active.map((a) => a + ' 审 ' + reviewOf[a]).join(';') || '(无参与成员)')
3784
+ return true
3785
+ }
3786
+ if (p.status === 'reviewing') {
3787
+ const reviews = p.reviews || {}
3788
+ const refusers = active.filter((id) => !(reviews[id] && reviews[id].deliverable === true))
3789
+ if (!refusers.length) {
3790
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now() }) : cur))
3791
+ await paperLog('互审一致:可交付', '全体参与成员(' + active.join('、') + ')均表示可交付。定稿代表:' + p.editor)
3792
+ return true
3793
+ }
3794
+ const detail = refusers.map((id) => id + ':' + String((reviews[id] || {}).comments || '未说明')).join(';')
3795
+ const entry = { round: p.round, members: refusers, detail, at: now() }
3796
+ if (p.round < PAPER_MAX_ROUNDS) {
3797
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3798
+ status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
3799
+ disagreement: (cur.disagreement || []).concat([entry]),
3800
+ }) : cur))
3801
+ await paperLog('互审未达成一致 → 进入第 ' + (p.round + 1) + ' 轮', detail)
3802
+ return true
3803
+ }
3804
+ const warn = '互审在 ' + PAPER_MAX_ROUNDS + ' 轮上限仍未达成全体一致(' + refusers.join('、') + ');分歧已写入论文附录。'
3805
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3806
+ status: 'awaiting-editor', stage: 'awaiting-editor', lastAskAt: {}, updatedAt: now(),
3807
+ warnings: (cur.warnings || []).concat([warn]), disagreement: (cur.disagreement || []).concat([entry]), forcedAfterCap: true,
3808
+ }) : cur))
3809
+ await paperLog('达到轮次上限:转交定稿代表', warn)
3810
+ return true
3811
+ }
3812
+ return false
3813
+ }
3814
+ async function paperAwaitEditor(p) {
3815
+ if (p.editor !== 'academician') return false // office: waits for vibe_v5_finalize_paper
3816
+ const id = academicianId()
3817
+ if (!id) {
3818
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3819
+ editor: 'office', editorFallback: true, updatedAt: now(),
3820
+ warnings: (cur.warnings || []).concat(['定稿代表 academician 不在册:已回退为 office(需人工定稿)。']),
3821
+ }) : cur))
3822
+ await paperLog('定稿代表回退', 'academician 不在册,回退为 office:用 /v5 paper 或 vibe_v5_finalize_paper 定稿(仍需先与全所交流并开会)。')
3823
+ return false
3824
+ }
3825
+ const m = memberById(id)
3826
+ if (!m || m.phase !== 'active') return false
3827
+ if (busy.has(id)) { armHeartbeat(); return false }
3828
+ const last = (p.lastAskAt || {})[id] || 0
3829
+ if (last && (now() - last) < paperAskEvery()) { armHeartbeat(); return false }
3830
+ const ok = await wakeMember(m, paperFinalPrompt(m, p), 'paper')
3831
+ if (ok) await mutatePaper((cur) => (cur ? Object.assign({}, cur, { lastAskAt: Object.assign({}, cur.lastAskAt || {}, { [id]: now() }), updatedAt: now() }) : cur))
3832
+ armHeartbeat()
3833
+ return false
3834
+ }
3835
+ // The ONE driver. Called from `onMemberEnd` (after the reply is folded), from the manual
3836
+ // tools/command, from the resume path and from `schedulePass`; `paperLock` keeps it single.
3837
+ async function paperPass() {
3838
+ if (paperLock) return false
3839
+ paperLock = true
3840
+ try {
3841
+ for (let i = 0; i < 6; i++) {
3842
+ const p = paper()
3843
+ if (!p || p.status === 'finalized') return false
3844
+ if (p.status === 'awaiting-editor') { await paperAwaitEditor(p); return false }
3845
+ const advanced = await paperStep()
3846
+ if (!advanced) return false
3847
+ }
3848
+ return false
3849
+ } catch (e) {
3850
+ console.error('vibe-math-v5: paper pass: ' + String((e && e.stack) || e))
3851
+ return false
3852
+ } finally { paperLock = false }
3853
+ }
3854
+
3855
+ // ---- start / record / consult / finalize -------------------------------
3856
+ async function startPaper(trigger, opts) {
3857
+ const o = opts || {}
3858
+ const trg = String(trigger || 'manual')
3859
+ const existing = paper()
3860
+ if (existing && existing.status === 'finalized' && !o.force) {
3861
+ const refill = await refillPaperArtifacts(existing)
3862
+ return { ok: true, alreadyFinalized: true, id: existing.id, dir: 'Paper/' + existing.id + '/', refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
3863
+ }
3864
+ if (existing && existing.status !== 'finalized' && !o.force) {
3865
+ return { ok: true, alreadyRunning: true, id: existing.id, status: existing.status, stage: existing.stage, round: existing.round, next: paperNextStep(existing), autoDisabled: params.finalPaper === false, note: paperAutoNote() }
3866
+ }
3867
+ const participants = paperParticipants()
3868
+ // A per-call editor override (`/v5 paper editor=office`) is ONE-SHOT: it is not written
3869
+ // back into the persisted params. An AUTOMATIC (run-complete) trigger always uses the
3870
+ // academician — the office is the root session and has no wake path (docs/final-paper.md §7).
3871
+ const configured = (o.editor === 'office' || o.editor === 'academician') ? o.editor : paperConfiguredEditor()
3872
+ const editor = trg === 'run-complete' ? 'academician' : configured
3873
+ const configWantsOffice = configured === 'office'
3874
+ const editorNote = (trg === 'run-complete' && configWantsOffice)
3875
+ ? 'paperEditor=office 已配置,但自动(收口)触发必须由**可唤醒**的院士定稿(所办是根会话,没有唤醒路径);本次自动流程用 academician。要所办定稿请在收尾后用 /v5 paper 手动触发。'
3876
+ : (editor === 'office' ? '所办定稿:必须先与全所交流(>=1 条所办消息)并至少召开一次会议,结论写进定稿说明,然后调用 vibe_v5_finalize_paper。' : '')
3877
+ const lang = (o.lang === 'en' || o.lang === 'zh') ? o.lang : (params.paperLanguage === 'en' ? 'en' : 'zh')
3878
+ const format = ['both', 'md', 'tex'].indexOf(o.format) !== -1 ? o.format : (['both', 'md', 'tex'].indexOf(params.paperFormat) !== -1 ? params.paperFormat : 'both')
3879
+ const p = {
3880
+ id: paperIdFromState(), status: 'writing', stage: 'writing', round: 1,
3881
+ trigger: trg, createdAt: now(), updatedAt: now(),
3882
+ participants, parts: {}, reviews: {}, reviewOf: {}, final: null,
3883
+ editor, editorNote, completesRun: trg === 'run-complete',
3884
+ lang, format, pdf: params.paperCompilePdf !== false,
3885
+ consult: { messages: 0, meetings: 0 },
3886
+ lastAskAt: {}, warnings: editorNote ? [editorNote] : [], disagreement: [], skipped: [], artifacts: [],
3887
+ forced: !!o.force, forceReason: o.force ? String(o.reason || 'manual force') : '',
3888
+ }
3889
+ await patchInstitute({ paper: p })
3890
+ await paperLog('流程开始(' + (trg === 'run-complete' ? '收口自动触发' : '手动触发') + ')', [
3891
+ '- 目录:Paper/' + p.id + '/',
3892
+ '- 语言:' + p.lang + '|格式:' + p.format + '|编译 PDF:' + p.pdf,
3893
+ '- 参与成员:' + (participants.join('、') || '(无常驻成员)'),
3894
+ '- 定稿代表:' + editor + (editorNote ? '(' + editorNote + ')' : ''),
3895
+ ].join('\n'))
3896
+ await paperPass()
3897
+ return {
3898
+ ok: true, started: true, id: p.id, dir: 'Paper/' + p.id + '/', status: 'writing',
3899
+ participants, editor, next: paperNextStep(paper()),
3900
+ autoDisabled: params.finalPaper === false, note: paperAutoNote(),
3901
+ }
3902
+ }
3903
+ async function paperRecordPart(memberId, raw) {
3904
+ const p = paper()
3905
+ if (!p || p.status !== 'writing') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is not in its writing stage (now: ' + (p ? p.status : 'none') + ')' }
3906
+ if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a writing participant of this paper' }
3907
+ const part = {
3908
+ member: memberId, round: p.round,
3909
+ title: String(raw.title || '').slice(0, 300),
3910
+ solution: String(raw.solution || '').slice(0, 20000),
3911
+ methods: String(raw.methods || '').slice(0, 20000),
3912
+ rules: String(raw.rules || '').slice(0, 12000),
3913
+ limits: String(raw.limits || '').slice(0, 12000),
3914
+ evidence: Array.isArray(raw.evidence) ? raw.evidence.map(String).slice(0, 50) : [],
3915
+ at: now(),
3916
+ }
3917
+ if (!part.title && !part.solution && !part.methods && !part.rules && !part.limits) {
3918
+ await notice(memberId, 'paper_part 是空的:请至少给出 title 与你的贡献正文(solution/methods/rules/limits),只写库里已有证据支撑的内容。')
3919
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_part carries no content' }
3920
+ }
3921
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { parts: Object.assign({}, cur.parts || {}, { [memberId]: part }), updatedAt: now() }) : cur))
3922
+ await paperLog('收到 ' + memberId + ' 的部分(第 ' + p.round + ' 轮)', '- 标题:' + (part.title || '(无)') + '\n- 声称证据:' + (part.evidence.length || 0) + ' 条')
3923
+ return { ok: true, recorded: memberId, round: p.round }
3924
+ }
3925
+ async function paperRecordReview(memberId, raw) {
3926
+ const p = paper()
3927
+ if (!p || p.status !== 'reviewing') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is not in its cross-review stage (now: ' + (p ? p.status : 'none') + ')' }
3928
+ if ((p.participants || []).indexOf(memberId) === -1) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: memberId + ' is not a review participant of this paper' }
3929
+ if (raw.deliverable !== true && raw.deliverable !== false) {
3930
+ await notice(memberId, 'paper_review.deliverable 必须是 true 或 false(你敢不敢交付?未表态不算可交付)。')
3931
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'paper_review.deliverable must be a boolean' }
3932
+ }
3933
+ const assigned = (p.reviewOf || {})[memberId]
3934
+ const rev = {
3935
+ member: memberId, of: String(raw.of || assigned || ''), round: p.round,
3936
+ deliverable: raw.deliverable === true,
3937
+ comments: String(raw.comments || '').slice(0, 8000), at: now(),
3938
+ }
3939
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { reviews: Object.assign({}, cur.reviews || {}, { [memberId]: rev }), updatedAt: now() }) : cur))
3940
+ await paperLog('互审意见|' + memberId + ' → ' + rev.of, '- deliverable:' + rev.deliverable + '\n- 意见:' + (rev.comments || '(无)').slice(0, 2000))
3941
+ return { ok: true, recorded: memberId, of: rev.of, deliverable: rev.deliverable }
3942
+ }
3943
+ async function paperRecordFinal(memberId, raw) {
3944
+ const p = paper()
3945
+ if (!p || p.status !== 'awaiting-editor') return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper is not awaiting its editor (now: ' + (p ? p.status : 'none') + ')' }
3946
+ if (p.editor !== 'academician' || memberId !== academicianId()) {
3947
+ return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'paperEditor is "' + p.editor + '": the academician does not finalise this paper' }
3948
+ }
3949
+ const decision = String(raw.decision || '')
3950
+ if (decision !== 'deliverable' && decision !== 'revise') {
3951
+ await notice(memberId, "paper_final.decision 必须是 'deliverable' 或 'revise'(收到 " + decision + ')。')
3952
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'decision must be deliverable|revise' }
3953
+ }
3954
+ const fin = { by: memberId, decision, note: String(raw.note || '').slice(0, 4000), conclusion: String(raw.conclusion || '').slice(0, 20000), at: now() }
3955
+ if (decision === 'deliverable') {
3956
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
3957
+ return await finalizePaper('academician-deliverable')
3958
+ }
3959
+ if (p.round < PAPER_MAX_ROUNDS) {
3960
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3961
+ final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
3962
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
3963
+ }) : cur))
3964
+ await paperLog('定稿代表要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note || '(未说明)')
3965
+ return { ok: true, revised: true, round: p.round + 1 }
3966
+ }
3967
+ const warn = '定稿代表在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
3968
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
3969
+ final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
3970
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: [memberId], detail: '定稿代表要求修订:' + fin.note, at: now() }]),
3971
+ }) : cur))
3972
+ await paperLog('达到轮次上限:定稿代表仍要求修订', warn)
3973
+ return await finalizePaper('academician-revise-at-cap')
3974
+ }
3975
+ // The office consultation gate (v2 §A6 / the user's requirement): the office may finalise
3976
+ // ONLY after it has actually talked to the institute (>=1 office message) AND convened at
3977
+ // least one meeting; both counts are recorded in the finalisation note and the meta.
3978
+ async function finalizePaperByOffice(o) {
3979
+ const args = o || {}
3980
+ const p = paper()
3981
+ if (!p) return { ok: false, code: 'V5_PAPER_STATE', message: 'no paper flow has been started (finish the run with finalPaper=true, or use /v5 paper)' }
3982
+ if (p.status === 'finalized' && !args.force) {
3983
+ const refill = await refillPaperArtifacts(p)
3984
+ return { ok: true, alreadyFinalized: true, id: p.id, refill, autoDisabled: params.finalPaper === false, note: paperAutoNote() }
3985
+ }
3986
+ if (p.status !== 'awaiting-editor') {
3987
+ return { ok: false, code: 'V5_PAPER_STATE', message: 'the paper flow is in stage "' + p.status + '"; the editor finalises only after every part is written and cross-reviewed' }
3988
+ }
3989
+ if (p.editor !== 'office') {
3990
+ return { ok: false, code: 'V5_NOT_OFFICE', message: 'paperEditor="academician": the academician finalises from its own round; the office does not' }
3991
+ }
3992
+ const decision = String(args.decision || '')
3993
+ if (decision !== 'deliverable' && decision !== 'revise') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "decision must be 'deliverable' or 'revise'" }
3994
+ const note = String(args.note || '').trim()
3995
+ if (decision === 'deliverable' && !note) {
3996
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '定稿说明(note)是必填的:写明你如何与全所交流、商讨、优化、审查,以及结论' }
3997
+ }
3998
+ const consult = Object.assign({ messages: 0, meetings: 0 }, p.consult || {})
3999
+ if (!(Number(consult.messages) > 0) || !(Number(consult.meetings) > 0)) {
4000
+ return {
4001
+ ok: false, code: 'V5_PAPER_CONSULT_REQUIRED',
4002
+ message: 'paperEditor="office" 要求所办先与全所交流、商讨、优化、审查:至少 1 条所办消息(vibe_v5_message)+ 至少 1 次会议(vibe_v5_meeting),然后才能定稿。当前:messages=' +
4003
+ Number(consult.messages || 0) + ', meetings=' + Number(consult.meetings || 0),
4004
+ consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
4005
+ }
4006
+ }
4007
+ const fin = {
4008
+ by: 'office', decision, note: String(note).slice(0, 4000),
4009
+ conclusion: String(args.conclusion || '').slice(0, 20000),
4010
+ consult: { messages: Number(consult.messages || 0), meetings: Number(consult.meetings || 0) },
4011
+ at: now(),
4012
+ }
4013
+ if (decision === 'deliverable') {
4014
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, { final: fin, updatedAt: now() }) : cur))
4015
+ return await finalizePaper('office-deliverable')
4016
+ }
4017
+ if (p.round < PAPER_MAX_ROUNDS) {
4018
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4019
+ final: fin, status: 'writing', stage: 'writing', round: cur.round + 1, lastAskAt: {}, updatedAt: now(),
4020
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
4021
+ }) : cur))
4022
+ await paperLog('所办要求修订 → 第 ' + (p.round + 1) + ' 轮', fin.note)
4023
+ return { ok: true, revised: true, round: p.round + 1 }
4024
+ }
4025
+ const warn = '所办在第 ' + PAPER_MAX_ROUNDS + ' 轮上限仍要求修订;按规格记警告并定稿(分歧写入附录)。'
4026
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4027
+ final: fin, warnings: (cur.warnings || []).concat([warn]), forcedAfterCap: true, updatedAt: now(),
4028
+ disagreement: (cur.disagreement || []).concat([{ round: cur.round, members: ['office'], detail: '所办要求修订:' + fin.note, at: now() }]),
4029
+ }) : cur))
4030
+ await paperLog('达到轮次上限:所办仍要求修订', warn)
4031
+ return await finalizePaper('office-revise-at-cap')
4032
+ }
4033
+ // Count the office's consultation evidence. Called from the ONE place each act really
4034
+ // happens (`say` for a message, `beginMeeting` for a meeting that actually starts), so the
4035
+ // gate cannot be satisfied by merely intending to consult.
4036
+ async function notePaperConsult(kind) {
4037
+ const p = paper()
4038
+ if (!p || p.status === 'finalized') return
4039
+ const key = kind === 'meeting' ? 'meetings' : 'messages'
4040
+ await mutatePaper((cur) => {
4041
+ if (!cur || cur.status === 'finalized') return cur
4042
+ const consult = Object.assign({ messages: 0, meetings: 0 }, cur.consult || {})
4043
+ consult[key] = Number(consult[key] || 0) + 1
4044
+ return Object.assign({}, cur, { consult, updatedAt: now() })
4045
+ })
4046
+ }
4047
+
4048
+ // ---- artifacts ---------------------------------------------------------
4049
+ function paperEvidence() {
4050
+ const s = inst()
4051
+ const V = s.verdicts || {}
4052
+ const keys = Object.keys(V)
4053
+ const closed = (o) => keys.filter((k) => V[k] && V[k].closed && V[k].outcome === o)
4054
+ const tasks = (s.tasks || []).filter((t) => t && t.status !== 'deleted')
4055
+ const formal = formalRecords()
4056
+ return {
4057
+ s, V,
4058
+ trueV: closed('true'), falseV: closed('false'), undecidedV: closed('undecided'),
4059
+ openV: keys.filter((k) => V[k] && !V[k].closed),
4060
+ tasks, done: tasks.filter((t) => t.status === 'completed'), openT: tasks.filter((t) => t.status !== 'completed'),
4061
+ formal, formalKeys: Object.keys(formal),
4062
+ members: s.members || [],
4063
+ }
4064
+ }
4065
+ function verdictCardRel(kind, id) { return 'Verified/' + (kind === 'subproblem' ? '问题' : kind === 'method' ? '方法' : '命题') + '/' + id + '.md' }
4066
+ function cardDirOf(kind) { return kind === 'method' ? 'Methods' : kind === 'subproblem' ? 'Subproblems' : 'Propos' }
4067
+ function paperTitle(p) {
4068
+ const st = String((inst().problem || {}).statement || '').trim()
4069
+ const head = st ? st.split(/\n/)[0].slice(0, 100) : ''
4070
+ return head || ('研究所成果论文|' + p.id)
4071
+ }
4072
+ function paperSources(p, active, ev) {
4073
+ const out = []
4074
+ out.push('Problems/conclusion.md(结题记录)')
4075
+ if ((inst().problem || {}).id) out.push('Problems/' + inst().problem.id + '.md(原问题卡)')
4076
+ out.push('State/' + instituteName + '.v5state.json(权威状态)')
4077
+ for (const k of ev.trueV.concat(ev.falseV)) out.push(verdictCardRel((ev.V[k] || {}).kind, k) + '(Verified 卡片)')
4078
+ for (const k of ev.trueV.concat(ev.falseV)) out.push('Shared/Debates/' + k + '.md(辩论录)')
4079
+ for (const k of ev.formalKeys) {
4080
+ const r = ev.formal[k] || {}
4081
+ if (r.file) out.push(r.file + '(Lean 工作文件)')
4082
+ if (r.proof) out.push(r.proof + '(归档证明)')
4083
+ }
4084
+ if (ev.formalKeys.length) out.push('Formal/Index.md', 'Formal/TODO.md')
4085
+ for (const m of ev.members) {
4086
+ out.push('Members/' + m.id + '/Progress/progress.md(' + m.id + ' 的研究日志)')
4087
+ out.push('Members/' + m.id + '/{Propos,Methods,Subproblems}/(' + m.id + ' 的卡片)')
4088
+ }
4089
+ out.push('Institutes.md(编制镜像)', 'Shared/TaskBoard.md(任务板镜像)')
4090
+ out.push('Paper/' + p.id + '/paper.log.md(本论文流程往来)')
4091
+ for (const id of active) {
4092
+ const part = (p.parts || {})[id] || {}
4093
+ for (const e of (part.evidence || [])) out.push(String(e) + '(' + id + ' 声称的证据)')
4094
+ }
4095
+ return Array.from(new Set(out))
4096
+ }
4097
+ function paperSections(p, active, skipped, ev, extra) {
4098
+ const o = extra || {}
4099
+ const fin = o.final || p.final || null
4100
+ const prob = ev.s.problem || {}
4101
+ const S = []
4102
+ // 1
4103
+ const b1 = []
4104
+ b1.push({ p: '标题:' + paperTitle(p) })
4105
+ b1.push({ ul: [
4106
+ '作者:' + ((p.participants || []).map((id) => id + '(' + ((memberById(id) || {}).kind === 'academician' ? '院士' : '常驻研究员') + ')').join('、') || '(无在册作者)'),
4107
+ '研究所:' + instituteName + '|项目:' + project + '|运行:' + (ev.s.runId || runId || '(无)'),
4108
+ '日期:' + fmtTime(p.finalizedAt || now()).slice(0, 10) + '|语言:' + p.lang + '|定稿代表:' + p.editor,
4109
+ ] })
4110
+ const mainConclusions = ev.trueV.length
4111
+ ? ('已由表决确立的结论:' + ev.trueV.join('、'))
4112
+ : '尚无经表决确立为真的结论(见第 4、8 节)。'
4113
+ b1.push({ p: '摘要:' + (prob.statement ? String(prob.statement).replace(/\s+/g, ' ').slice(0, 400) : '(未设定原问题)') + '。' + mainConclusions })
4114
+ if (fin && fin.note) b1.push({ p: '定稿说明:' + fin.note + (fin.consult ? '(定稿前与全所交流:消息 ' + fin.consult.messages + ' 条、会议 ' + fin.consult.meetings + ' 次)' : '') })
4115
+ if (fin && fin.conclusion) b1.push({ p: '定稿结论:' + fin.conclusion })
4116
+ S.push({ title: PAPER_SECTIONS[0], blocks: b1 })
4117
+ // 2
4118
+ S.push({ title: PAPER_SECTIONS[1], blocks: [
4119
+ { p: prob.statement ? String(prob.statement) : '(本所未记录原问题的完整陈述——请勿在论文中补写。)' },
4120
+ { p: '问题 ID:' + (prob.id || '(无)') + '|参与成员:' + (p.participants || []).join('、') },
4121
+ ] })
4122
+ // 3
4123
+ const b3 = []
4124
+ const solvers = active.filter((id) => String(((p.parts || {})[id] || {}).solution || '').trim())
4125
+ if (solvers.length) {
4126
+ for (const id of solvers) {
4127
+ const part = (p.parts || {})[id] || {}
4128
+ b3.push({ h: id + '|' + String(part.title || '贡献') })
4129
+ b3.push({ p: String(part.solution) })
4130
+ }
4131
+ } else {
4132
+ b3.push({ p: '(参与成员未提交可引用的完整解法叙述;请以第 4 节的表决结论与证据索引为准。)' })
4133
+ }
4134
+ if (o.conclusionText) b3.push({ h: '结题记录(Problems/conclusion.md)' }, { p: String(o.conclusionText).slice(0, 4000) })
4135
+ S.push({ title: PAPER_SECTIONS[2], blocks: b3 })
4136
+ // 4
4137
+ const b4 = []
4138
+ if (ev.trueV.length) {
4139
+ b4.push({ p: '以下对象经 m 票布尔一致判定为**真**(证据:Verified 卡片 + 辩论录):' })
4140
+ b4.push({ ul: ev.trueV.map((k) => {
4141
+ const v = ev.V[k] || {}
4142
+ return '**' + k + '**(判为真;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';表决者 ' + ((v.voters || []).join('、') || '?') +
4143
+ ';证据:' + verdictCardRel(v.kind, k) + ';辩论:Shared/Debates/' + k + '.md;提出者:' + (v.proposer || '(office)') + ')'
4144
+ }) })
4145
+ } else b4.push({ p: '(没有经表决判定为真的命题。)' })
4146
+ if (ev.falseV.length) {
4147
+ b4.push({ p: '**已被否证(显式标注,不得当作结论使用)**:' })
4148
+ b4.push({ ul: ev.falseV.map((k) => {
4149
+ const v = ev.V[k] || {}
4150
+ return k + '(判为假;平均概率 ' + Number(v.mean || 0).toFixed(2) + ';m=' + (v.m || '?') + ';证据:' + verdictCardRel(v.kind, k) + ')'
4151
+ }) })
4152
+ }
4153
+ S.push({ title: PAPER_SECTIONS[3], blocks: b4 })
4154
+ // 5
4155
+ const b5 = []
4156
+ const subV = ev.trueV.filter((k) => (ev.V[k] || {}).kind === 'subproblem')
4157
+ if (subV.length) b5.push({ ul: subV.map((k) => '子问题 ' + k + '(已判定为真;证据:' + verdictCardRel('subproblem', k) + ')') })
4158
+ if (ev.done.length) b5.push({ ul: ev.done.map((t) => '任务 ' + t.id + ':' + String(t.subject || '') + '(owner=' + (t.ownerId || '?') + ';镜像:Shared/TaskBoard.md)') })
4159
+ if (!subV.length && !ev.done.length) b5.push({ p: '(没有已完成的子问题或任务被记录。)' })
4160
+ S.push({ title: PAPER_SECTIONS[4], blocks: b5 })
4161
+ // 6
4162
+ const b6 = []
4163
+ const methodParts = active.filter((id) => String(((p.parts || {})[id] || {}).methods || '').trim())
4164
+ for (const id of methodParts) b6.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).methods) })
4165
+ if (!methodParts.length) b6.push({ p: '(参与成员未提交方法/理论/思想/经验/数学理解的叙述。)' })
4166
+ S.push({ title: PAPER_SECTIONS[5], blocks: b6 })
4167
+ // 7
4168
+ const b7 = []
4169
+ const ruleParts = active.filter((id) => String(((p.parts || {})[id] || {}).rules || '').trim())
4170
+ for (const id of ruleParts) b7.push({ h: id + '|' + String(((p.parts || {})[id] || {}).title || '') }, { p: String(((p.parts || {})[id] || {}).rules) })
4171
+ if (!ruleParts.length) b7.push({ p: '(参与成员未归纳出可复用规律。)' })
4172
+ S.push({ title: PAPER_SECTIONS[6], blocks: b7 })
4173
+ // 8
4174
+ const b8 = []
4175
+ const limitParts = active.filter((id) => String(((p.parts || {})[id] || {}).limits || '').trim())
4176
+ for (const id of limitParts) b8.push({ h: id }, { p: String(((p.parts || {})[id] || {}).limits) })
4177
+ const unresolved = []
4178
+ for (const k of ev.undecidedV) unresolved.push('未定论:' + k + '(平均概率 ' + Number((ev.V[k] || {}).mean || 0).toFixed(2) + ',m=' + ((ev.V[k] || {}).m || '?') + ')')
4179
+ for (const k of ev.openV) unresolved.push('尚未裁决:' + k)
4180
+ for (const t of ev.openT) unresolved.push('未完成任务:' + t.id + '|' + String(t.subject || '') + '(' + t.status + ')')
4181
+ for (const m of ev.members.filter((x) => x.phase === 'failed' || x.phase === 'dismissed')) unresolved.push('成员 ' + m.id + ':' + m.phase + (m.error ? '(' + String(m.error).slice(0, 120) + ')' : ''))
4182
+ for (const id of skipped) unresolved.push('未交稿/未参与:' + id)
4183
+ if (unresolved.length) b8.push({ p: '**未决与未参与(显式标注)**:' }, { ul: unresolved })
4184
+ if ((p.warnings || []).length) b8.push({ p: '流程警告:' }, { ul: (p.warnings || []).map(String) })
4185
+ if (!limitParts.length && !unresolved.length && !(p.warnings || []).length) b8.push({ p: '(无特别记录的局限。)' })
4186
+ S.push({ title: PAPER_SECTIONS[7], blocks: b8 })
4187
+ // 9
4188
+ const b9 = [{ p: '以下路径均相对于研究所根目录(与 State/<institute>.v5state.json 同级)。' }]
4189
+ b9.push({ ul: paperSources(p, active, ev) })
4190
+ if ((p.disagreement || []).length) {
4191
+ b9.push({ h: '分歧记录(未达成全体一致 / 定稿代表要求修订)' })
4192
+ b9.push({ ul: (p.disagreement || []).map((d) => '第 ' + d.round + ' 轮|' + (d.members || []).join('、') + ':' + String(d.detail || '')) })
4193
+ }
4194
+ if (skipped.length) b9.push({ p: '按“未参与”处理的成员:' + skipped.join('、') })
4195
+ S.push({ title: PAPER_SECTIONS[8], blocks: b9 })
4196
+ return S
4197
+ }
4198
+ function renderPaperMd(p, S) {
4199
+ const L = ['# ' + paperTitle(p), '']
4200
+ S.forEach((sec, i) => {
4201
+ L.push('## ' + (i + 1) + '. ' + sec.title)
4202
+ L.push('')
4203
+ for (const b of sec.blocks) {
4204
+ if (b.h) { L.push('### ' + b.h); L.push('') }
4205
+ else if (b.p !== undefined) { L.push(String(b.p)); L.push('') }
4206
+ else if (b.ul) { for (const it of b.ul) L.push('- ' + String(it)); L.push('') }
4207
+ }
4208
+ })
4209
+ return L.join('\n')
4210
+ }
4211
+ const TEX_ESCAPES = { '\\': '\\textbackslash{}', '&': '\\&', '%': '\\%', '$': '\\$', '#': '\\#', '_': '\\_', '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}' }
4212
+ function texEscape(s) { return String(s == null ? '' : s).replace(/[\\&%$#_{}~^]/g, (c) => TEX_ESCAPES[c]) }
4213
+ function renderPaperTex(p, S) {
4214
+ const zh = p.lang !== 'en'
4215
+ const L = [
4216
+ '\\documentclass[11pt]{' + (zh ? 'ctexart' : 'article') + '}',
4217
+ '\\usepackage{amsmath,amssymb,amsthm}',
4218
+ '\\usepackage[margin=2.5cm]{geometry}',
4219
+ '\\usepackage[hidelinks]{hyperref}',
4220
+ '\\usepackage{longtable,booktabs}',
4221
+ '\\title{' + texEscape(paperTitle(p)) + '}',
4222
+ '\\author{' + texEscape(((p.participants || []).join('、')) || (zh ? '研究所' : 'Institute')) + '}',
4223
+ '\\date{' + texEscape(fmtTime(p.finalizedAt || now()).slice(0, 10)) + '}',
4224
+ '\\begin{document}',
4225
+ '\\maketitle',
4226
+ ]
4227
+ S.forEach((sec, i) => {
4228
+ L.push('\\section{' + texEscape((i + 1) + '. ' + sec.title) + '}')
4229
+ for (const b of sec.blocks) {
4230
+ if (b.h) L.push('\\subsection{' + texEscape(b.h) + '}')
4231
+ else if (b.p !== undefined) L.push(texEscape(b.p) + '\n')
4232
+ else if (b.ul) { L.push('\\begin{itemize}'); for (const it of b.ul) L.push('\\item ' + texEscape(it)); L.push('\\end{itemize}') }
4233
+ }
4234
+ })
4235
+ L.push('\\end{document}')
4236
+ return L.join('\n') + '\n'
4237
+ }
4238
+ // The repair pass keeps only the packages the text itself needs (docs/final-paper.md §8):
4239
+ // every optional or unknown package is dropped before the engine swap retry.
4240
+ function texStripOptionalPackages(tex) {
4241
+ return String(tex).split('\n').filter((line) => {
4242
+ if (/^\s*\\(?:input|include)\b/.test(line)) return false
4243
+ const m = /^\s*\\usepackage(?:\[[^\]]*\])?\{([^}]+)\}/.exec(line)
4244
+ if (!m) return true
4245
+ return m[1].split(',').map((x) => x.trim()).some((pk) => PAPER_REPAIR_CORE.indexOf(pk) !== -1)
4246
+ }).join('\n')
4247
+ }
4248
+ function texMinimal(tex, lang) {
4249
+ const body = String(tex).replace(/[\s\S]*?\\begin\{document\}/, '').replace(/\\end\{document\}[\s\S]*$/, '')
4250
+ return '\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}\n\\begin{document}\n' + body.trim() + '\n\\end{document}\n'
4251
+ }
4252
+ function paperEngineCandidates(lang) {
4253
+ const order = PAPER_ENGINE_ORDER[lang === 'en' ? 'en' : 'zh'].slice()
4254
+ const forced = String(params.paperLatexCommand || '').trim()
4255
+ if (!forced) return order
4256
+ return [forced].concat(order.filter((n) => n !== forced))
4257
+ }
4258
+ async function latexEngines(lang) {
4259
+ const sub = subprocessOf()
4260
+ if (!sub || typeof sub.resolveExecutable !== 'function') {
4261
+ return { engines: [], reason: '宿主没有 subprocess 服务:无法检测或调用 LaTeX' }
4262
+ }
4263
+ const engines = []
4264
+ for (const name of paperEngineCandidates(lang)) {
4265
+ try { const exe = await sub.resolveExecutable(name); if (exe) engines.push({ name: String(name), exe: String(exe) }) } catch (e) { /* not installed */ }
4266
+ }
4267
+ return { engines, reason: engines.length ? '' : ('未检测到 LaTeX 引擎(' + paperEngineCandidates(lang).join('/') + ')') }
4268
+ }
4269
+ // ONE engine invocation. NEVER throws: every failure becomes a readable result (the Lean
4270
+ // seam's discipline) and a timeout really terminates the process.
4271
+ async function runPaperProcess(argv, cwd) {
4272
+ const sub = subprocessOf()
4273
+ const started = now()
4274
+ if (!sub || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, ms: 0, message: 'no subprocess service' }
4275
+ let handle
4276
+ try {
4277
+ handle = sub.spawn({ argv, cwd, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: 120000 })
4278
+ } catch (e) { return { ok: false, exitCode: null, ms: now() - started, message: String((e && e.message) || e) } }
4279
+ let outcome = null
4280
+ let timer = null
4281
+ try {
4282
+ const ran = Promise.resolve(handle.done).then((v) => ({ settled: true, value: v }), (e) => ({ settled: false, error: e }))
4283
+ const r = await Promise.race([
4284
+ ran,
4285
+ new Promise((resolve) => {
4286
+ timer = ctx.timeout(() => {
4287
+ try { if (typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* the race result is the report */ }
4288
+ resolve({ settled: true, value: { exitCode: null, signal: 'SIGTERM' } })
4289
+ }, 120000)
4290
+ }),
4291
+ ])
4292
+ if (!r.settled) return { ok: false, exitCode: null, ms: now() - started, message: String((r.error && r.error.message) || r.error) }
4293
+ outcome = r.value
4294
+ } finally { if (timer) { try { timer() } catch (e) { /* already settled */ } } }
4295
+ const exitCode = outcome ? outcome.exitCode : null
4296
+ return { ok: exitCode === 0, exitCode, ms: now() - started, message: '' }
4297
+ }
4298
+ // Compile attempts are CAPPED (docs/final-paper.md §8): full → nonstopmode rerun → engine swap with
4299
+ // the optional packages stripped → one minimal-template retry → report and degrade.
4300
+ async function compilePaperTex(id, tex, lang) {
4301
+ if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
4302
+ return { status: 'kept-existing', engine: '', reason: 'Paper/' + id + '/paper.pdf 已存在:不覆盖(docs/final-paper.md §5)', attempts: [] }
4303
+ }
4304
+ const det = await latexEngines(lang)
4305
+ if (!det.engines.length) return { status: 'not-detected', engine: '', reason: det.reason, attempts: [] }
4306
+ const e0 = det.engines[0], e1 = det.engines[1] || e0
4307
+ const plan = [
4308
+ { label: 'full', engine: e0, text: tex, nonstopOnly: false },
4309
+ { label: 'nonstopmode-rerun', engine: e0, text: tex, nonstopOnly: true },
4310
+ { label: 'engine-swap+stripped', engine: e1, text: texStripOptionalPackages(tex), nonstopOnly: true },
4311
+ { label: 'minimal-template', engine: e0, text: texMinimal(tex, lang), nonstopOnly: true },
4312
+ ]
4313
+ const attempts = []
4314
+ for (const a of plan) {
4315
+ const r = await runLatexAttempt(id, a)
4316
+ attempts.push(r)
4317
+ if (r.ok) return { status: 'compiled', engine: a.engine.name, attempts }
4318
+ }
4319
+ return { status: 'failed', engine: '', reason: '编译在 ' + attempts.length + ' 次尝试后仍失败(已保留 paper.tex 与 paper.md)', attempts }
4320
+ }
4321
+ async function runLatexAttempt(id, a) {
4322
+ const dirRel = paperDirRel(id)
4323
+ const dirAbs = instRoot() + '/' + dirRel
4324
+ if (!await writeTextRel(dirRel + '/paper.tex', a.text)) {
4325
+ return { label: a.label, engine: a.engine.name, ok: false, exitCode: null, ms: 0, message: 'tex 写入失败' }
4326
+ }
4327
+ const started = now()
4328
+ const args = a.engine.name === 'tectonic'
4329
+ ? ['-X', 'paper.tex']
4330
+ : (a.nonstopOnly ? ['-interaction=nonstopmode', 'paper.tex'] : ['-interaction=nonstopmode', '-halt-on-error', 'paper.tex'])
4331
+ const argv = [a.engine.exe].concat(args)
4332
+ // TWO passes (docs/final-paper.md §8): the second resolves references/TOC. A failed first pass is not
4333
+ // rerun — the retry PLAN is what varies the command, not a blind repeat.
4334
+ const first = await runPaperProcess(argv, dirAbs)
4335
+ if (!first.ok) return { label: a.label, engine: a.engine.name, ok: false, exitCode: first.exitCode, ms: now() - started, message: first.message || 'first pass failed' }
4336
+ const second = await runPaperProcess(argv, dirAbs)
4337
+ const pdf = await fileExistsAbs(paperAbs(id, 'paper.pdf'))
4338
+ return { label: a.label, engine: a.engine.name, ok: second.ok && pdf, exitCode: second.exitCode, ms: now() - started, message: pdf ? (second.message || '') : '编译器没有产出 paper.pdf' }
4339
+ }
4340
+ function paperMeta(p, extra) {
4341
+ const o = extra || {}
4342
+ const active = paperActiveParticipants(p)
4343
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
4344
+ return {
4345
+ id: p.id,
4346
+ dir: 'Paper/' + p.id + '/',
4347
+ institute: instituteName, project, runId: inst().runId || runId || '',
4348
+ problemId: (inst().problem || {}).id || '',
4349
+ trigger: p.trigger,
4350
+ finalizedAt: o.finalizedAt || (p.finalizedAt || now()),
4351
+ language: p.lang, format: p.format, compilePdf: p.pdf === true,
4352
+ editor: p.editor, editorNote: p.editorNote || '', editorFallback: !!p.editorFallback,
4353
+ finalisation: p.final || null,
4354
+ consultation: Object.assign({ messages: 0, meetings: 0 }, p.consult || {}),
4355
+ participants: p.participants || [], skipped,
4356
+ rounds: p.round, maxRounds: PAPER_MAX_ROUNDS,
4357
+ parts: Object.keys(p.parts || {}).map((id) => ({ member: id, round: (p.parts[id] || {}).round, title: (p.parts[id] || {}).title || '', evidence: ((p.parts[id] || {}).evidence || []).length })),
4358
+ reviews: Object.keys(p.reviews || {}).map((id) => ({ member: id, of: (p.reviews[id] || {}).of, round: (p.reviews[id] || {}).round, deliverable: (p.reviews[id] || {}).deliverable === true })),
4359
+ disagreement: p.disagreement || [], warnings: o.warnings || p.warnings || [],
4360
+ forcedAfterCap: !!p.forcedAfterCap,
4361
+ sections: PAPER_SECTIONS.length,
4362
+ compile: o.compile || { status: p.compile || 'unknown', engine: p.engine || '', attempts: [] },
4363
+ params: {
4364
+ finalPaper: params.finalPaper, paperFormat: params.paperFormat, paperLanguage: params.paperLanguage,
4365
+ paperCompilePdf: params.paperCompilePdf, paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
4366
+ },
4367
+ files: o.files || [],
4368
+ }
4369
+ }
4370
+ async function refillPaperArtifacts(p) {
4371
+ const id = p.id
4372
+ const active = paperActiveParticipants(p)
4373
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
4374
+ const conclusionText = await readTextRel('Problems/conclusion.md')
4375
+ const S = paperSections(p, active, skipped, paperEvidence(), { final: p.final, conclusionText })
4376
+ const filled = []
4377
+ if (p.format !== 'tex' && (await readTextAbs(paperAbs(id, 'paper.md'))) === undefined) {
4378
+ if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) filled.push('paper.md')
4379
+ }
4380
+ let tex = await readTextAbs(paperAbs(id, 'paper.tex'))
4381
+ if (p.format !== 'md' && tex === undefined) {
4382
+ tex = renderPaperTex(p, S)
4383
+ if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) filled.push('paper.tex')
4384
+ }
4385
+ if (p.format !== 'md' && p.pdf === true && !await fileExistsAbs(paperAbs(id, 'paper.pdf'))) {
4386
+ const c = await compilePaperTex(id, tex || renderPaperTex(p, S), p.lang)
4387
+ if (c.status === 'compiled') filled.push('paper.pdf')
4388
+ // Same rule as finalizePaper: the delivered tex is always the canonical generated one.
4389
+ if (tex !== undefined && tex !== null) await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
4390
+ }
4391
+ if ((await readTextAbs(paperAbs(id, 'paper.meta.json'))) === undefined) {
4392
+ if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(paperMeta(p, { refilled: true }), null, 2) + '\n')) filled.push('paper.meta.json')
4393
+ }
4394
+ if ((await readTextAbs(paperAbs(id, 'paper.log.md'))) === undefined) {
4395
+ await paperLog('重复触发:补写缺失产物', '- 本次补写:' + (filled.join('、') || '(无缺失)'))
4396
+ filled.push('paper.log.md')
4397
+ } else if (filled.length) {
4398
+ await paperLog('重复触发:补写缺失产物', '- 本次补写:' + filled.join('、'))
4399
+ }
4400
+ return { filled }
4401
+ }
4402
+ async function finalizePaper(reason) {
4403
+ const p = paper()
4404
+ if (!p) return { ok: false, code: 'V5_PAPER_STATE', message: 'no paper flow' }
4405
+ const id = p.id
4406
+ const active = paperActiveParticipants(p)
4407
+ const skipped = (p.participants || []).filter((x) => active.indexOf(x) === -1)
4408
+ const ev = paperEvidence()
4409
+ const conclusionText = await readTextRel('Problems/conclusion.md')
4410
+ const finalizedAt = now()
4411
+ const S = paperSections(p, active, skipped, ev, { final: p.final, conclusionText })
4412
+ const files = []
4413
+ if (p.format !== 'tex') {
4414
+ if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) files.push('paper.md')
4415
+ }
4416
+ let tex = ''
4417
+ if (p.format !== 'md') {
4418
+ tex = renderPaperTex(p, S)
4419
+ if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) files.push('paper.tex')
4420
+ }
4421
+ // `paperFormat=md` with `paperCompilePdf=true` must NOT warn about a missing tex: no tex
4422
+ // was produced, so compilation is skipped silently (docs/final-paper.md §8).
4423
+ let compile
4424
+ if (p.format === 'md') compile = { status: 'skipped', engine: '', reason: 'paperFormat=md:未产出 tex,跳过编译', attempts: [] }
4425
+ else if (p.pdf !== true) compile = { status: 'skipped', engine: '', reason: 'paperCompilePdf=false', attempts: [] }
4426
+ else compile = await compilePaperTex(id, tex, p.lang)
4427
+ // The repair attempts overwrite `paper.tex` with stripped/minimal variants to get past a
4428
+ // broken engine. Whatever the compile outcome, the DELIVERED tex must be the real,
4429
+ // fully-generated one (the attempts are recorded in the meta either way).
4430
+ if (p.format !== 'md') await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)
4431
+ if (await fileExistsAbs(paperAbs(id, 'paper.pdf'))) files.push('paper.pdf')
4432
+ const warnings = (p.warnings || []).slice()
4433
+ if (compile.status === 'failed') warnings.push('LaTeX 编译失败(已保留 paper.tex 与 paper.md,不阻塞定稿):' + (compile.attempts || []).map((a) => a.engine + '/' + a.label + ' exit=' + a.exitCode + (a.message ? '(' + a.message + ')' : '')).join(';'))
4434
+ if (compile.status === 'not-detected') warnings.push('未检测到 LaTeX 引擎(' + (compile.reason || '') + '):只交付 paper.tex 与 paper.md。')
4435
+ const meta = paperMeta(Object.assign({}, p, { finalizedAt, compile: compile.status, engine: compile.engine || '' }), {
4436
+ finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', attempts: compile.attempts || [] }, files,
4437
+ })
4438
+ if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')) files.push('paper.meta.json')
4439
+ await paperLog('定稿(' + reason + ')', [
4440
+ '- 目录:Paper/' + id + '/',
4441
+ '- 产物:' + files.join('、'),
4442
+ '- 编译:' + compile.status + (compile.engine ? '(' + compile.engine + ')' : '') + '|尝试 ' + (compile.attempts || []).length + ' 次',
4443
+ '- 定稿代表:' + p.editor + '|轮次:' + p.round + '/' + PAPER_MAX_ROUNDS,
4444
+ '- 所办交流:消息 ' + Number((p.consult || {}).messages || 0) + ' 条、会议 ' + Number((p.consult || {}).meetings || 0) + ' 次',
4445
+ (p.final && p.final.note ? '- 定稿说明:' + p.final.note : ''),
4446
+ (warnings.length ? '- 警告:\n - ' + warnings.join('\n - ') : ''),
4447
+ ].filter(Boolean).join('\n'))
4448
+ await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
4449
+ status: 'finalized', stage: 'finalized', finalizedAt, compile: compile.status,
4450
+ engine: compile.engine || '', warnings, artifacts: files, updatedAt: now(),
4451
+ }) : cur))
4452
+ await saveChatLine('【论文】最终论文已定稿:Paper/' + id + '/(' + (files.join('、') || '(无产物)') + ';编译 ' + compile.status + ')。')
4453
+ notifyActivity()
4454
+ // The run is only now complete (docs/final-paper.md §3): the paper phase ran BEFORE the flags.
4455
+ if (p.completesRun && !autoDone) await finishRun('全体有表决权者一致认为原问题已解决')
4456
+ return {
4457
+ ok: true, finalized: true, id, dir: 'Paper/' + id + '/', files,
4458
+ compile: compile.status, engine: compile.engine || '', attempts: (compile.attempts || []).length,
4459
+ warnings, agreement: !(p.disagreement || []).length, rounds: p.round,
4460
+ }
4461
+ }
4462
+
3472
4463
  // ---- hire / fire -------------------------------------------------------
3473
4464
  function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
3474
4465
  // ANY academician or permanently-employed researcher may hire its own temp
@@ -3673,6 +4664,16 @@ export function apply(ctx) {
3673
4664
  await beginMeeting({ agenda: p.agenda, kind: p.kind, target: p.target, by: p.by })
3674
4665
  return
3675
4666
  }
4667
+ // The final-paper phase (docs/final-paper.md §3) owns the room once it is active: a paper ask is its
4668
+ // own kind of turn, and a research round must not be mistaken for a paper answer. While
4669
+ // the flow waits for the OFFICE the institute keeps working normally — the required
4670
+ // consultation needs office messages and a real meeting to be deliverable.
4671
+ if (paperActive()) {
4672
+ await paperPass()
4673
+ const cur = paper()
4674
+ const waitingForOffice = !!cur && cur.status === 'awaiting-editor' && cur.editor === 'office'
4675
+ if (!waitingForOffice) return
4676
+ }
3676
4677
  const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
3677
4678
  const idleMs = posMs(params.activityTimeoutMs, 120000)
3678
4679
  let filled = 0
@@ -3723,7 +4724,7 @@ export function apply(ctx) {
3723
4724
  const stallMs = posMs(params.stallAutoMeetingMs, 360000)
3724
4725
  if (!meeting && !pendingMeeting && !hasVerifyInFlight() && phase === 'active' &&
3725
4726
  busy.size === 0 && (now() - lastProgressAt) >= stallMs) {
3726
- await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync' })
4727
+ await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync', auto: true })
3727
4728
  return
3728
4729
  }
3729
4730
  // (e) heartbeat: push the longest-idle member to make progress rather than just
@@ -3897,6 +4898,12 @@ export function apply(ctx) {
3897
4898
  }
3898
4899
  // (10) solve votes / personal judgement
3899
4900
  if (p.vote_solved !== undefined) await recordSolveVote(member.id, p.vote_solved === true)
4901
+ // (10b) final-paper channels: the member's own part, a cross-review, or the editor's
4902
+ // decision. Each is only honoured in the stage that asked for it (the record functions
4903
+ // refuse otherwise), so a stray/stale field can never advance the paper.
4904
+ if (p.paper_part && typeof p.paper_part === 'object') await paperRecordPart(member.id, p.paper_part)
4905
+ if (p.paper_review && typeof p.paper_review === 'object') await paperRecordReview(member.id, p.paper_review)
4906
+ if (p.paper_final && typeof p.paper_final === 'object') await paperRecordFinal(member.id, p.paper_final)
3900
4907
  // (11) meeting input collection (keyed on the LIVE member set, so a member who
3901
4908
  // joined mid-meeting still has to speak and a dismissed one stops blocking it)
3902
4909
  if (kind === 'meeting' && meeting) {
@@ -3952,6 +4959,9 @@ export function apply(ctx) {
3952
4959
  try { await maybeRealCompact(childId, member) } catch (e) { /* compaction is best-effort */ }
3953
4960
  wakeKind.delete(member.id)
3954
4961
  if (member.activeMeetingId) delete member.activeMeetingId
4962
+ // The final-paper phase is driven by member turns, exactly like a meeting: the reply was
4963
+ // just folded, so advance the paper BEFORE asking the (possibly inert) scheduler.
4964
+ try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after end: ' + String((e && e.message) || e)) }
3955
4965
  await scheduleNext()
3956
4966
  }
3957
4967
  // ---- toolFilter names the host may not register -------------------------
@@ -4014,11 +5024,15 @@ export function apply(ctx) {
4014
5024
  const ints = ['researcherCount', 'quorumCap', 'verdictMaxRounds', 'maxTempPerMember', 'maxTempTotal',
4015
5025
  'compactThreshold', 'compactAfterRounds', 'maxParallel', 'activityTimeoutMs', 'stallAutoMeetingMs',
4016
5026
  'chatDigestMs', 'chatDigestMax', 'meetingKeepEvery', 'leanTimeoutMs']
4017
- const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign']
4018
- const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand']
5027
+ const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign', 'finalPaper', 'paperCompilePdf']
5028
+ const strs = ['quorumMode', 'provider', 'model', 'staffPersona', 'formalVerify', 'leanCommand',
5029
+ 'paperFormat', 'paperLanguage', 'paperEditor', 'paperLatexCommand']
4019
5030
  const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny', 'leanArgs']
4020
5031
  for (const k of ints) if (input[k] !== undefined) { const n = Math.floor(Number(input[k])); if (Number.isFinite(n)) out[k] = n }
4021
- for (const k of bools) if (input[k] !== undefined) out[k] = (input[k] === true || input[k] === 'true')
5032
+ // Explicit boolean coercion (docs/final-paper.md §2): the old `=== true || === 'true'` turned a
5033
+ // legitimate `1` / `'yes'` into FALSE and left junk values truthy-looking. An
5034
+ // unrecognised spelling is REJECTED (the default wins) rather than guessed.
5035
+ for (const k of bools) if (input[k] !== undefined) out[k] = coerceBool(input[k], false)
4022
5036
  for (const k of strs) if (input[k] !== undefined) out[k] = String(input[k])
4023
5037
  for (const k of arrs) {
4024
5038
  if (input[k] === undefined) continue
@@ -4032,6 +5046,12 @@ export function apply(ctx) {
4032
5046
  out.formalVerify = ['off', 'encourage', 'require'].indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'
4033
5047
  }
4034
5048
  if (out.leanCommand !== undefined && !String(out.leanCommand).trim()) out.leanCommand = 'lean'
5049
+ // ── final-paper enums (closed sets; an unknown value degrades to the documented
5050
+ // default instead of silently becoming an unreachable fourth mode) ────────────────
5051
+ if (out.paperFormat !== undefined) out.paperFormat = coercePaperEnum('paperFormat', out.paperFormat, ['both', 'md', 'tex'], 'both')
5052
+ if (out.paperLanguage !== undefined) out.paperLanguage = coercePaperEnum('paperLanguage', out.paperLanguage, ['zh', 'en'], 'zh')
5053
+ if (out.paperEditor !== undefined) out.paperEditor = coercePaperEnum('paperEditor', out.paperEditor, ['office', 'academician'], 'academician')
5054
+ if (out.paperLatexCommand !== undefined) out.paperLatexCommand = String(out.paperLatexCommand).trim()
4035
5055
  // Guard every duration against a negative/NaN value: such a value would make a
4036
5056
  // watchdog fire instantly and abandon all consensus (v4 §30-T41).
4037
5057
  for (const k of ['activityTimeoutMs', 'stallAutoMeetingMs', 'chatDigestMs', 'leanTimeoutMs']) {
@@ -4076,6 +5096,10 @@ export function apply(ctx) {
4076
5096
  chatDigestMax: params.chatDigestMax, meetingKeepEvery: params.meetingKeepEvery,
4077
5097
  formalVerify: params.formalVerify, leanCommand: params.leanCommand,
4078
5098
  leanArgs: params.leanArgs, leanTimeoutMs: params.leanTimeoutMs,
5099
+ // ── final paper ──────────────────────────────────────────────────────
5100
+ finalPaper: params.finalPaper, paperFormat: params.paperFormat,
5101
+ paperLanguage: params.paperLanguage, paperCompilePdf: params.paperCompilePdf,
5102
+ paperEditor: params.paperEditor, paperLatexCommand: params.paperLatexCommand,
4079
5103
  provider: params.provider, model: params.model,
4080
5104
  toolAllow: params.toolAllow, toolDeny: params.toolDeny,
4081
5105
  }
@@ -4318,6 +5342,9 @@ export function apply(ctx) {
4318
5342
  await saveChatLine('【恢复】研究所继续推进(重建成员 ' + respawned + ' 名)。')
4319
5343
  notifyActivity()
4320
5344
  await scheduleNext()
5345
+ // An interrupted FINAL PAPER resumes its own stage (the paper phase runs before the
5346
+ // completion flags, so a crash there leaves a live, resumable institute).
5347
+ if (paperActive()) { try { await paperPass() } catch (e) { console.error('vibe-math-v5: paper after resume: ' + String((e && e.message) || e)) } }
4321
5348
  return { ok: true, resumed: true, members: members.map((m) => m.id), respawned, running: true }
4322
5349
  }
4323
5350
  function setPause() {
@@ -4394,6 +5421,10 @@ export function apply(ctx) {
4394
5421
  blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
4395
5422
  todo: formalTodo(),
4396
5423
  },
5424
+ // The final-paper flow: stage, who wrote/reviewed what, the office-consultation
5425
+ // counters and the compile result. Without this an operator cannot tell whether the
5426
+ // run is waiting for the office, for a member, or for nobody.
5427
+ paper: paperSummary(),
4397
5428
  lastProgressAt, params: visibleParams(),
4398
5429
  }
4399
5430
  }
@@ -4460,6 +5491,21 @@ export function apply(ctx) {
4460
5491
  L.push('- 可复用库:VibeMath/Formal/{Lib,Proved}/(跨项目)|本所形式化:Formal/|归档证明:Verified/Lean/')
4461
5492
  }
4462
5493
  L.push('')
5494
+ L.push('## 最终论文')
5495
+ {
5496
+ const pp = paper()
5497
+ if (!pp) L.push('- 尚未开始(' + (params.finalPaper === false ? '自动已关闭(finalPaper=false),可用 /v5 paper 手动生成' : '收口时会自动开始;也可用 /v5 paper 手动开始') + ')')
5498
+ else {
5499
+ L.push('- 目录:Paper/' + pp.id + '/|状态:' + pp.status + '|阶段:' + pp.stage + '|轮次:' + pp.round + '/' + PAPER_MAX_ROUNDS)
5500
+ L.push('- 定稿代表:' + pp.editor + (pp.editorFallback ? '(原定代表不在册,已回退)' : '') + '|语言:' + pp.lang + '|格式:' + pp.format)
5501
+ L.push('- 交稿:' + (Object.keys(pp.parts || {}).join('、') || '(无)') + '|互审:' + (Object.keys(pp.reviews || {}).map((k) => k + '→' + ((pp.reviews[k] || {}).of || '?') + (pp.reviews[k] && pp.reviews[k].deliverable === true ? '(可交付)' : '(需修改)')).join('、') || '(无)'))
5502
+ L.push('- 所办交流:消息 ' + Number((pp.consult || {}).messages || 0) + ' 条|会议 ' + Number((pp.consult || {}).meetings || 0) + ' 次')
5503
+ if (pp.compile) L.push('- 编译:' + pp.compile + (pp.engine ? '(' + pp.engine + ')' : ''))
5504
+ if ((pp.warnings || []).length) L.push('- 警告:' + pp.warnings.join(';'))
5505
+ L.push('- 下一步:' + paperNextStep(pp))
5506
+ }
5507
+ }
5508
+ L.push('')
4463
5509
  L.push('## 文件位置')
4464
5510
  L.push('- 根目录:' + instRoot())
4465
5511
  L.push('- 已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
@@ -4526,6 +5572,8 @@ export function apply(ctx) {
4526
5572
  leanRunToolApi: async (relPath, timeoutMs) => await leanRunFile(relPath, timeoutMs),
4527
5573
  // consensus / meetings
4528
5574
  maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
5575
+ // final paper (docs/final-paper.md; the phase runs BEFORE finishRun)
5576
+ startPaper, paperStatus: paperSummary, finalizePaperByOffice,
4529
5577
  // authorization helpers (used by tool handlers)
4530
5578
  memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
4531
5579
  }
@@ -4599,7 +5647,7 @@ export function apply(ctx) {
4599
5647
  registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s, a, x) => withOffice(s, x, 'stop the institute', () => s.initStop()))
4600
5648
  registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
4601
5649
  registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
4602
- registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); an m-vote passes only when at least m Boolean votes (exactly 1 or exactly 0) exist AND no voter returns an opposing Boolean, so with the default roster it degenerates to unanimity among the current voters; only current voters count (a dismissed member\'s earlier ballot is dropped). quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record).', objParams({
5650
+ registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); an m-vote passes only when at least m Boolean votes (exactly 1 or exactly 0) exist AND no voter returns an opposing Boolean, so with the default roster it degenerates to unanimity among the current voters; only current voters count (a dismissed member\'s earlier ballot is dropped). quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record). FINAL PAPER: finalPaper (default true) writes the final paper when the run concludes — the paper phase runs BEFORE the run is marked complete, the permanent staff write their own part, cross-review each other, and the editor named by paperEditor finalises; paperFormat "both"|"md"|"tex"; paperLanguage "zh"|"en"; paperCompilePdf compiles a PDF when a LaTeX engine is detected; paperEditor "academician" (default, the only editor an unattended run can reach) | "office" (manual /v5 paper only — the office must first consult the whole institute: >=1 office message AND >=1 meeting, recorded in the finalisation note); paperLatexCommand forces one engine command instead of auto-detection (empty = auto: xelatex -> latexmk -> pdflatex -> lualatex -> tectonic, English prefers pdflatex). Unknown spellings of these enums fall back to the documented default.', objParams({
4603
5651
  academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
4604
5652
  quorumCap: I, quorumMode: S, verdictMaxRounds: I,
4605
5653
  maxTempPerMember: I, maxTempTotal: I,
@@ -4607,6 +5655,9 @@ export function apply(ctx) {
4607
5655
  activityTimeoutMs: I, stallAutoMeetingMs: I, chatDigestMs: I, chatDigestMax: I, meetingKeepEvery: I,
4608
5656
  formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] },
4609
5657
  leanCommand: S, leanArgs: SA, leanTimeoutMs: I,
5658
+ finalPaper: B, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] },
5659
+ paperLanguage: { type: 'string', enum: ['zh', 'en'] },
5660
+ paperCompilePdf: B, paperEditor: { type: 'string', enum: ['office', 'academician'] }, paperLatexCommand: S,
4610
5661
  provider: S, model: S, staffPersona: S, toolAllow: SA, toolDeny: SA, tempToolAllow: SA, tempToolDeny: SA,
4611
5662
  }), (s, a, x) => withOffice(s, x, 'tune institute parameters', () => s.setParams(a)))
4612
5663
  registerTool('vibe_v5_message', 'Relay a message from the office/human into the institute (to a member id, to "all", or to "voters").', objParams({ to: S, content: S }, ['to', 'content']), (s, a, x) => {
@@ -4651,6 +5702,16 @@ export function apply(ctx) {
4651
5702
  ? s.removeResearcher(a.id)
4652
5703
  : { ok: false, code: 'V5_NOT_OFFICE', message: 'only the office (the session root) may dismiss a permanent researcher' }
4653
5704
  )))
5705
+ // ── final paper (docs/final-paper.md) ─────────────────────────────────────
5706
+ registerTool('vibe_v5_paper', 'Final paper: start (or re-run) the team-authored paper for this run. The permanent staff write their own part, cross-review another member\'s part, and the editor named by paperEditor finalises. lang/format override paperLanguage/paperFormat for THIS paper; editor overrides paperEditor for THIS paper only (office = the manual path, which parks the flow until the office consults the institute and calls vibe_v5_finalize_paper); force rewrites an already-finalised paper (idempotent otherwise: it only fills artifacts that are missing).', objParams({ lang: S, format: S, editor: { type: 'string', enum: ['office', 'academician'] }, force: B, reason: S }), (s, a, x) => withOffice(s, x, 'write the final paper', () => {
5707
+ const lang = a.lang === undefined ? undefined : String(a.lang)
5708
+ const format = a.format === undefined ? undefined : String(a.format)
5709
+ if (lang !== undefined && lang !== 'zh' && lang !== 'en') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'lang must be zh|en' }
5710
+ if (format !== undefined && ['both', 'md', 'tex'].indexOf(format) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'format must be both|md|tex' }
5711
+ if (a.editor !== undefined && ['office', 'academician'].indexOf(String(a.editor)) === -1) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'editor must be office|academician' }
5712
+ return s.startPaper(a.reason === 'run-complete' ? 'run-complete' : 'manual', { lang, format, editor: a.editor, force: a.force === true, reason: a.reason })
5713
+ }))
5714
+ registerTool('vibe_v5_finalize_paper', 'Office: finalise the final paper after consulting the whole institute. Required when paperEditor="office": at least one office message (vibe_v5_message) AND at least one meeting convened by the office must happen first (paper.meta.json records them, and the note must state the conclusion). decision="revise" asks for another writing round (bounded).', objParams({ decision: { type: 'string', enum: ['deliverable', 'revise'] }, note: S, conclusion: S, force: B }, ['decision']), (s, a, x) => withOffice(s, x, 'finalise the final paper', () => s.finalizePaperByOffice(a)))
4654
5715
 
4655
5716
  // ── member-facing controls ────────────────────────────────────────────────
4656
5717
  registerTool('vibe_v5_say', '(member) Speak in the group chat (omit "to"), send a private message ("to":"r-2"), or address only the voters ("to":"voters").', objParams({ text: S, to: S }, ['text']), (s, a, x) => {
@@ -4748,7 +5809,7 @@ export function apply(ctx) {
4748
5809
  // ── /v5 slash command ────────────────────────────────────────────────────
4749
5810
  ctx.effect(() => commands.register({
4750
5811
  name: 'v5', description: 'control the Vibe Math V5 research institute',
4751
- input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set]' },
5812
+ input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=en] [format=tex] [editor=office] [force]]' },
4752
5813
  handler: async function (inv) {
4753
5814
  const s = getSession(inv && inv.agent)
4754
5815
  if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
@@ -4784,7 +5845,28 @@ export function apply(ctx) {
4784
5845
  else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
4785
5846
  else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
4786
5847
  else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
4787
- else if (cmd === 'set') {
5848
+ else if (cmd === 'paper') {
5849
+ // `/v5 paper [lang=en] [format=tex] [force] [editor=office|academician]`
5850
+ const o = {}
5851
+ let force = false
5852
+ let bad = ''
5853
+ for (const tok of rest) {
5854
+ if (tok === 'force') { force = true; continue }
5855
+ const eq = tok.indexOf('=')
5856
+ if (eq <= 0) { bad = 'unknown paper argument: ' + tok; break }
5857
+ const k = tok.slice(0, eq), v = tok.slice(eq + 1)
5858
+ if (k === 'lang' || k === 'format' || k === 'editor') o[k] = v
5859
+ else { bad = 'unknown paper option: ' + k + ' (use lang=zh|en format=both|md|tex editor=office|academician force)'; break }
5860
+ }
5861
+ if (!bad && o.lang !== undefined && o.lang !== 'zh' && o.lang !== 'en') bad = 'lang must be zh|en'
5862
+ if (!bad && o.format !== undefined && ['both', 'md', 'tex'].indexOf(o.format) === -1) bad = 'format must be both|md|tex'
5863
+ if (!bad && o.editor !== undefined && ['office', 'academician'].indexOf(o.editor) === -1) bad = 'editor must be office|academician'
5864
+ if (bad) return { kind: 'error', text: JSON.stringify({ ok: false, code: 'V5_INVALID_ARGUMENT', message: bad }, null, 2) }
5865
+ // `editor=office` is the manual path that uses the office as the finalising
5866
+ // representative (docs/final-paper.md §7 — never reachable from an automatic run). One-shot: it
5867
+ // does not rewrite the persisted `paperEditor`.
5868
+ r = await s.startPaper('manual', { lang: o.lang, format: o.format, editor: o.editor, force })
5869
+ } else if (cmd === 'set') {
4788
5870
  const upd = {}
4789
5871
  for (const tok of rest) {
4790
5872
  const eq = tok.indexOf('=')
@@ -4794,7 +5876,7 @@ export function apply(ctx) {
4794
5876
  upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
4795
5877
  }
4796
5878
  r = await s.setParams(upd)
4797
- } else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set' }
5879
+ } else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]' }
4798
5880
  // A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
4799
5881
  // CommandResult union distinguishes success from error, and returning 'success' made a rejected
4800
5882
  // invocation look identical to a successful one (same fix as v2/v3).
@@ -4889,6 +5971,32 @@ function slugify(s) {
4889
5971
  return t || ''
4890
5972
  }
4891
5973
 
5974
+ // Explicit parameter coercion (docs/final-paper.md §2). A boolean parameter spelled `'false'`, `0`, `'no'`
5975
+ // or `'off'` must be false — the old `=== true || === 'true'` silently treated `1`/`'yes'` as
5976
+ // false. An unrecognised spelling is REJECTED (the caller's default wins), never guessed.
5977
+ function coerceBool(v, def) {
5978
+ if (v === undefined) return def
5979
+ if (v === true || v === 1 || v === '1') return true
5980
+ if (v === false || v === 0 || v === '0') return false
5981
+ const t = String(v).trim().toLowerCase()
5982
+ if (['true', 'yes', 'on', 'y', 't'].indexOf(t) !== -1) return true
5983
+ if (['false', 'no', 'off', 'n', 'f', ''].indexOf(t) !== -1) return false
5984
+ return def
5985
+ }
5986
+
5987
+ // Enum coercion with legacy aliases (.v5 / older flat spellings) and a documented fallback.
5988
+ const ENUM_ALIASES = {
5989
+ paperFormat: { markdown: 'md', latex: 'tex', texonly: 'tex', all: 'both' },
5990
+ paperLanguage: { cn: 'zh', chinese: 'zh', 'zh-cn': 'zh', english: 'en', 'en-us': 'en' },
5991
+ paperEditor: { root: 'office', host: 'office', acad: 'academician', dean: 'academician' },
5992
+ }
5993
+ function coercePaperEnum(key, v, allowed, def) {
5994
+ const t = String(v == null ? '' : v).trim().toLowerCase()
5995
+ if (allowed.indexOf(t) !== -1) return t
5996
+ const alias = (ENUM_ALIASES[key] || {})[t]
5997
+ return alias !== undefined && allowed.indexOf(alias) !== -1 ? alias : def
5998
+ }
5999
+
4892
6000
  function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
4893
6001
 
4894
6002
  function parseReply(text) {