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.
@@ -90,6 +90,18 @@ export function apply(ctx) {
90
90
  leanCommand: 'lean',
91
91
  leanArgs: [], // inserted BEFORE the file name (e.g. ['env','lean'] with lake)
92
92
  leanTimeoutMs: 120000,
93
+ // ---- final paper (docs/final-paper.md §1) ----------------------------------
94
+ // The run's conclusion is a PAPER the whole team co-authors once consensus has converged.
95
+ // finalPaper=true triggers it automatically at that moment (idempotent); the other four knobs
96
+ // decide what is produced, in which language, whether PDFs are attempted, and WHO finalises.
97
+ finalPaper: true,
98
+ paperFormat: 'both', // 'both' | 'md' | 'tex'
99
+ paperLanguage: 'zh', // 'zh' | 'en'
100
+ paperCompilePdf: true, // compile a PDF when a LaTeX engine is actually detected
101
+ // v2 §A5: the editor is the SESSION ROOT / human side ("office") by default — v4 has no
102
+ // `facilitator` roster member with an LLM — or one named resident (`resident:<id>`).
103
+ paperEditor: 'office',
104
+ paperLatexCommand: '', // preferred LaTeX executable ('' = the standard detection order)
93
105
  }
94
106
  let params = Object.assign({}, DEFAULT_PARAMS)
95
107
  let running = false, autoDone = false, phase = 'idle'
@@ -102,6 +114,11 @@ export function apply(ctx) {
102
114
  // whether its verdict may take effect.
103
115
  let formal = {}, formalTodos = [], formalPersisted = false
104
116
  let meetingState = null, verifyState = null, pendingVerify = [], pendingMeeting = null // pendingVerify: FIFO queue (several residents may independently propose different objects before any verify runs — a single slot silently DROPPED all but the last proposal)
117
+ // The final-paper flow (docs/final-paper.md, v1 + v2 amendments). In-memory only: the DURABLE
118
+ // idempotency record is `Paper/<run id>/paper.meta.json` on disk (finalizedAt + per-artifact
119
+ // existence, v2 §C), so a re-trigger after a restart is still recognised even though this object
120
+ // did not survive. While a paper is in flight this holds the whole team-authoring state machine.
121
+ let paperState = null
105
122
  let busy = new Set(), wakeKind = new Map(), currentResident = ''
106
123
  let finalizeLock = null // 'meeting'|'verify' while a consensus finalize is running (reentry guard)
107
124
  const verifiedRecently = new Map() // targetId -> timestamp when it was closed as Verified (dedup re-propose)
@@ -1472,10 +1489,24 @@ export function apply(ctx) {
1472
1489
  const allSolved = allSpoke && speakers.length>0 && speakers.every(iv=>iv.voteSolved===true)
1473
1490
  logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
1474
1491
  if(allSolved){
1492
+ // ── FINAL PAPER ENTRY (docs/final-paper.md §A1) ─────────────────────────────────────────────────
1493
+ // The paper phase MUST start HERE, BEFORE `running=false; autoDone=true` and before the
1494
+ // coordination state is released: afterwards `startMeeting` refuses (autoDone), this
1495
+ // handler's meeting/verify branches return early on `!running||autoDone`, and
1496
+ // `scheduleNext` does nothing — co-writing/merge/cross-review could never run. The run
1497
+ // therefore stays `running` while the team writes; `paperFinalize()` performs the
1498
+ // completion flip once the paper exists (or has degraded safely).
1499
+ meetingState=null; pendingMeeting=null; verifyState=null; pendingVerify=[]; wakeKind.clear()
1500
+ await saveAll()
1501
+ if(params.finalPaper){
1502
+ const paper=await paperStart('auto',{deferCompletion:true})
1503
+ if(paper&&paper.ok){
1504
+ logActivity('paper','收口:一致性停止票通过('+PAPER_CLOSURE_SIGNAL+')→ 进入最终论文阶段,论文完成后才标记完成')
1505
+ return
1506
+ }
1507
+ logActivity('paper','最终论文未能启动('+((paper&&paper.message)||'unknown')+')→ 按原路径收口')
1508
+ } else logActivity('paper','finalPaper=false:跳过最终论文,直接收口')
1475
1509
  running=false; autoDone=true; phase='done'; clearHeartbeat()
1476
- // symmetric with initAbort: the STOP path must also release the coordination state, else
1477
- // status keeps reporting a phantom in-progress meeting (and stale wake kinds) forever.
1478
- meetingState=null; wakeKind.clear(); pendingMeeting=null; verifyState=null; pendingVerify=[]
1479
1510
  logActivity('stop','all residents agree: problem solved'); await saveAll(); return
1480
1511
  }
1481
1512
  meetingState=null; wakeKind.clear(); await saveAll()
@@ -1849,7 +1880,19 @@ export function apply(ctx) {
1849
1880
 
1850
1881
  // ---- liveness / scheduling ----
1851
1882
  async function scheduleNext(){
1852
- if(!running||autoDone){ clearHeartbeat(); return }
1883
+ if(!running||autoDone){
1884
+ // A MANUALLY triggered final paper may run on an already-concluded run; it is the one thing
1885
+ // that still needs the timer, so it keeps its own pacing here (paperAdvance re-arms the
1886
+ // heartbeat while it waits and clears it once the paper is done).
1887
+ if(paperState&&paperState.status!=='done'&&paperState.status!=='failed'){ try{ await paperAdvance('scheduler-concluded') }catch(e){} return }
1888
+ clearHeartbeat(); return
1889
+ }
1890
+ // The run is CONCLUDING (its closing meeting voted unanimously to stop): the ONLY remaining
1891
+ // work is the final paper. No new research round may be dispatched behind it (docs/final-paper.md §A1).
1892
+ if(paperState&&paperState.status!=='done'&&paperState.status!=='failed'){
1893
+ try{ await paperAdvance('scheduler-paper') }catch(e){ console.error('vibe-math-v4: paper pass failed: '+String((e&&e.message)||e)) }
1894
+ return
1895
+ }
1853
1896
  // Residents the host's live-child cap refused are retried here — the single choke point every
1854
1897
  // scheduling pass goes through — so a refused spawn is deferred by a whole round (and paced by
1855
1898
  // the heartbeat) instead of being retried inside the loop that discovered the refusal.
@@ -1991,6 +2034,10 @@ export function apply(ctx) {
1991
2034
  const parsed=parseReply(output)
1992
2035
  const kind=wakeKind.get(r.rId)||'normal'
1993
2036
  postmark(r, parsed, kind) // context/compact bookkeeping — a mid-consensus turn may NOT ack a compaction it never received
2037
+ // The final-paper flow (docs/final-paper.md §A1) has its own wake kinds and its own state machine. It is
2038
+ // handled BEFORE the meeting/verify branches and before their `!running||autoDone` guards,
2039
+ // because a manually triggered paper legitimately runs on an already-concluded run.
2040
+ if(kind.indexOf('paper:')===0){ await onPaperWakeEnd(r,parsed); return }
1994
2041
  if(kind==='meeting' && meetingState){
1995
2042
  meetingState.inputs[r.rId]={input:parsed.input||parsed.summary||'',voteSolved:typeof parsed.voteSolved==='boolean'?parsed.voteSolved:null,propose_verify:parsed.propose_verify||null,propose_task:parsed.propose_task||null,task_desc:parsed.task_desc||'',claim_task:parsed.claim_task||null}
1996
2043
  meetingState.lastInputAt=now()
@@ -2048,6 +2095,714 @@ export function apply(ctx) {
2048
2095
  await saveAll(); await scheduleNext()
2049
2096
  }
2050
2097
 
2098
+ // ================= final paper (docs/final-paper.md §3-§5) =================
2099
+ // When v4's consensus has converged (§2: the unanimous "solved" meeting closed, no ballot in
2100
+ // flight, nothing queued) the run must produce a PAPER, not stop at a chat log. The team
2101
+ // co-authors it: every resident writes the part it owns, the facilitator (the framework)
2102
+ // MERGES the parts, every participant CROSS-REVIEWS at least one other part, the `paperEditor`
2103
+ // finalises the merged draft, and the paper is finalised only when every participant has
2104
+ // explicitly stated it is deliverable. Dissent iterates a bounded number of rounds and, at the
2105
+ // cap, the disagreement is recorded in the appendix and the run is warned about.
2106
+ //
2107
+ // Everything the paper states comes from EVIDENCE ALREADY ON DISK (Verified/ cards, Propos/
2108
+ // cards, Methods/ cards, Shared/meetings|debates/, the Lean records) or from the team's own
2109
+ // written parts. Nothing is invented; unresolved/refuted items are flagged as such.
2110
+ const PAPER_FORMATS=['both','md','tex'], PAPER_LANGS=['zh','en']
2111
+ // v2 §A5: v4's `facilitator` is NOT a roster member and has no LLM (it is the framework / the
2112
+ // human-side messenger), so "let the facilitator unify the notation" has no author. The editor is
2113
+ // therefore the SESSION ROOT (the office = the human side) by default, or one named resident.
2114
+ const PAPER_EDITOR_DEFAULT='office'
2115
+ function paperEditorOk(v){ const s=String(v==null?'':v).trim(); return s==='office'||/^resident:[^\s]+$/.test(s) }
2116
+ // v2 §A4: v4's REAL closure signal is the closing meeting's UNANIMOUS stop vote — the branch
2117
+ // below in `finalizeMeeting` (allSolved). The v1 wording ("every verify target decided / mailbox
2118
+ // empty") is stricter than the code and would never fire, so the paper phase is entered from
2119
+ // inside that exact branch and any still-未决 item is flagged in the paper instead.
2120
+ const PAPER_CLOSURE_SIGNAL='finalizeMeeting/allSolved(一致性会议的一致停止票)'
2121
+ const PAPER_MAX_ROUNDS=2 // bounded iterations after dissent (docs/final-paper.md §4: "有上限")
2122
+ const PAPER_NUDGE_TRIES=2 // paced re-asks before the step deadline declares a timeout
2123
+ const PAPER_WAIT_FLOOR_MS=2000 // a step's deadline is at least this (tests run with tiny timeouts)
2124
+ const PAPER_KINDS={part:'paper:part',review:'paper:review',final:'paper:final',vote:'paper:vote',revise:'paper:revise'}
2125
+ const PAPER_MARK={part:'[PAPER PART]',review:'[PAPER REVIEW]',final:'[PAPER FINAL]',vote:'[PAPER DELIVERABLE]',revise:'[PAPER REVISE]'}
2126
+ const PAPER_HEAD_ZH=['标题、作者、日期与摘要','引言与问题背景','原问题的完整解法','已检验通过的命题','已解决的子问题与中间成果','创造或发现的有价值之物','规律总结','讨论、局限与展望','附录:证据与文件索引']
2127
+ const PAPER_HEAD_EN=['Title, Authors, Date and Abstract','Introduction and Problem Background','Complete Solution of the Original Problem','Verified Propositions','Solved Sub-problems and Intermediate Results','Valuable Creations and Discoveries','Patterns and Reusable Principles','Discussion, Limitations and Outlook','Appendix: Evidence and File Index']
2128
+
2129
+ function paperTitles(lang){ return lang==='en'?PAPER_HEAD_EN:PAPER_HEAD_ZH }
2130
+ /** The run id doubles as the v4 "institute id": Paper/<run id>/ (docs/final-paper.md §5). Sanitised through
2131
+ * `idSafe`+`slugify`, so a hostile problem text/title can never produce `../`. */
2132
+ function paperIdFor(){ return idSafe(slugify('v4-'+problemId))+'-'+idSafe(runId) }
2133
+ function paperDirFor(){ return 'Paper/'+paperIdFor() }
2134
+ /** Containment (docs/final-paper.md §6): every paper write must stay inside `Paper/<id>/`. */
2135
+ async function paperWriteRel(rel,content){
2136
+ const dir=paperState?paperState.dir:paperDirFor()
2137
+ const norm=String(rel).replace(/\\/g,'/')
2138
+ if(norm.indexOf(dir+'/')!==0 || norm.indexOf('..')!==-1) return false
2139
+ return await writeText(norm,content)
2140
+ }
2141
+ function paperLog(line){ const ps=paperState; if(ps) ps.log.push(fmtTime()+'|'+String(line)) }
2142
+ function paperStepLimit(){ return Math.max(PAPER_WAIT_FLOOR_MS, recoverStallMs()*4) }
2143
+ function paperCfg(ov){
2144
+ const o=ov||{}
2145
+ const ed=String(o.editor==null?'':o.editor).trim()
2146
+ return {
2147
+ language:PAPER_LANGS.indexOf(String(o.lang))!==-1?String(o.lang):params.paperLanguage,
2148
+ format:PAPER_FORMATS.indexOf(String(o.format))!==-1?String(o.format):params.paperFormat,
2149
+ editor:paperEditorOk(ed)?ed:(paperEditorOk(params.paperEditor)?String(params.paperEditor):PAPER_EDITOR_DEFAULT),
2150
+ compilePdf:params.paperCompilePdf!==false,
2151
+ }
2152
+ }
2153
+ /** `resident:<id>` -> the id, `office`/absent -> ''. */
2154
+ function paperEditorResidentId(editor){ const m=/^resident:([^\s]+)$/.exec(String(editor||'')); return m?m[1]:'' }
2155
+ // ---- evidence (every claim in §4-§9 of the skeleton must be traceable to one of these) ----
2156
+ async function paperListDirFiles(base){
2157
+ const out=[]
2158
+ try {
2159
+ const t=await fs.resolve(base,{cwd:frameworkRoot()}); if(await fs.stat(t)===undefined) return out
2160
+ for(const e of (await fs.listDir(t))||[]){ if(!e||e.type!=='file'||!/\.md$/.test(String(e.name))) continue
2161
+ const rel=base+'/'+e.name; out.push({rel,text:(await readText(rel))||''}) }
2162
+ } catch(e){ /* best effort: an absent directory is an empty evidence set */ }
2163
+ out.sort((a,b)=>a.rel<b.rel?-1:a.rel>b.rel?1:0); return out
2164
+ }
2165
+ async function paperListCards(base){
2166
+ const out=[]
2167
+ try {
2168
+ const root=await fs.resolve(base,{cwd:frameworkRoot()}); if(await fs.stat(root)===undefined) return out
2169
+ for(const d of (await fs.listDir(root))||[]){ if(!d||d.type!=='directory') continue
2170
+ const dir=base+'/'+d.name
2171
+ try {
2172
+ const t=await fs.resolve(dir,{cwd:frameworkRoot()}); if(await fs.stat(t)===undefined) continue
2173
+ for(const e of (await fs.listDir(t))||[]){ if(!e||e.type!=='file'||!/\.md$/.test(String(e.name))) continue
2174
+ const rel=dir+'/'+e.name; out.push({rel,text:(await readText(rel))||''}) }
2175
+ } catch(e2){ /* one unreadable library must not stop the scan */ }
2176
+ }
2177
+ } catch(e){ /* best effort */ }
2178
+ out.sort((a,b)=>a.rel<b.rel?-1:a.rel>b.rel?1:0); return out
2179
+ }
2180
+ function paperField(text,name){
2181
+ const esc=String(name).replace(/[.*+?^${}()|[\]\\]/g,'\\$&')
2182
+ const re=new RegExp('(?:^|\\n|;\\s*)-\\s*'+esc+':\\s*([^;\\n]*)')
2183
+ const m=re.exec(String(text||'')); return m?m[1].trim():''
2184
+ }
2185
+ async function paperEvidence(){
2186
+ const [verifiedFiles,problemFiles,propos,methods,subproblems,meetings,debates]=await Promise.all([
2187
+ paperListDirFiles('Verified/命题'), paperListDirFiles('Verified/问题'), paperListCards('Propos'),
2188
+ paperListCards('Methods'), paperListCards('Subproblems'), paperListDirFiles('Shared/meetings'), paperListDirFiles('Shared/debates'),
2189
+ ])
2190
+ const parse=f=>({rel:f.rel,id:paperField(f.text,'ID'),结论:paperField(f.text,'结论'),概率:paperField(f.text,'概率'),
2191
+ 类型:paperField(f.text,'类型'),状态:paperField(f.text,'状态')||paperField(f.text,'状态'),标题:paperField(f.text,'标题'),
2192
+ 可信断言:paperField(f.text,'可信断言'),text:f.text})
2193
+ return {
2194
+ problem:problemText, problemId:problemId, runId:runId, project:currentProject, converged:!!autoDone,
2195
+ verified:verifiedFiles.map(parse), verifiedProblems:problemFiles.map(parse),
2196
+ propos:propos.map(parse), methods:methods.map(parse), subproblems:subproblems.map(parse),
2197
+ meetings:meetings.map(f=>f.rel), debates:debates.map(f=>f.rel),
2198
+ formal:formalView().objects.map(o=>({target:o.target,status:o.status,file:o.file,proof:o.proof})),
2199
+ index:[...verifiedFiles,...problemFiles,...propos,...methods,...subproblems,...meetings,...debates].map(f=>f.rel).sort(),
2200
+ }
2201
+ }
2202
+ function paperHash(s){ let h=5381; const t=String(s==null?'':s); for(let i=0;i<t.length;i++) h=((h*33)^t.charCodeAt(i))>>>0; return h.toString(16).padStart(8,'0') }
2203
+ /** Idempotency is per (run id, finalizedAt) plus per-artifact existence — docs/final-paper.md §C explicitly
2204
+ * drops v1's multi-file inputsHash. This only fingerprints one string for the log, never a
2205
+ * decision. */
2206
+ function paperFingerprint(ev){ return paperHash([ev.problemId,ev.runId,ev.problem,ev.index.join('|')].join('\n')) }
2207
+ // ---- prompts (stable markers so the flow is observable/driveable) ----
2208
+ function paperPrompt(kind,rid,again){
2209
+ const ps=paperState, T=paperTitles(ps.cfg.language), zh=ps.cfg.language!=='en'
2210
+ const head=zh
2211
+ ? '【最终论文·团队合写】你('+rid+')是本次运行的参与常驻。'
2212
+ : '[FINAL PAPER — TEAM CO-AUTHORING] You ('+rid+') are a participating resident of this run.'
2213
+ const common=(zh?'\n论文目录:`'+ps.dir+'/`(一切写入必须在这里面)。只整理**已有证据**:你自己的库、Verified/、Shared/meetings|debates/ 与既有卡片;**不得编造**;未决/被否证的条目必须显式标注。'
2214
+ :'\nPaper directory: `'+ps.dir+'/` (everything you write stays inside it). Only organise EXISTING evidence (your own library, Verified/, Shared/meetings|debates/, the recorded cards); never invent; flag unresolved/refuted items explicitly.')
2215
+ if(kind==='part') return PAPER_MARK.part+'\n'+head+'\n'+(again?(zh?'(上一次没有收到你的部分,请重发。)\n':'(no part was received from you last time — please resend.)\n'):'')
2216
+ +(zh?'请撰写**你负责的那一部分**(你库里的命题/方法/子问题/进展,以及你亲自参与的会议与争论中确立的内容),成文即可,不必追求篇幅。\n'
2217
+ :'Write the part YOU own (the propositions/methods/sub-problems/progress in your library and what you personally established in meetings/debates).\n')
2218
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperPart":{"title":"<部分标题>","markdown":"<正文 markdown>"}}`(也可以直接用 fs 写到 `'+ps.dir+'/parts/'+rid+'.md`)。'
2219
+ :'Reply with one JSON object: `{"paperPart":{"title":"<title>","markdown":"<body markdown>"}}` (you may also write `'+ps.dir+'/parts/'+rid+'.md` with fs).')
2220
+ +'\n'+(zh?'九节骨架:':"The paper's nine sections: ")+T.join(' / ')
2221
+ if(kind==='review') return PAPER_MARK.review+'\n'+head+'\n'+(again?(zh?'(上一次的互审没有指名**其他**参与者,请重发。)\n':'(your last review did not name ANOTHER participant — please resend.)\n'):'')
2222
+ +(zh?'合并稿见 `'+ps.dir+'/draft.md`。请**审阅至少一位其他参与者的部分**(不能审自己),指出具体问题并给出可落实的改进。\n'
2223
+ :'The merged draft is at `'+ps.dir+'/draft.md`. Review AT LEAST ONE OTHER participant\'s part (never your own), name concrete problems and give actionable improvements.\n')
2224
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperReview":{"reviewed":"<被审的参与者 id>","points":["<问题>","..."],"improvements":"<你建议的改进>"}}`'
2225
+ :'Reply with one JSON object: `{"paperReview":{"reviewed":"<other participant id>","points":["..."],"improvements":"..."}}`')
2226
+ if(kind==='final') return PAPER_MARK.final+'\n'+head+'\n'
2227
+ +(zh?'你是本次的**定稿代表**(paperEditor)。请把合并稿 `'+ps.dir+'/draft.md` 与互审意见梳理成**最终稿**:去重、统一术语与记号,保留全部证据与未决标注。\n'
2228
+ :'You are the run\'s EDITOR (paperEditor). Turn the merged draft `'+ps.dir+'/draft.md` plus the review points into the FINAL draft: deduplicate, unify terms and notation, keep every evidence pointer and unresolved flag.\n')
2229
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperFinal":{"markdown":"<最终稿正文 markdown>"}}`(或直接写 `'+ps.dir+'/final.md`)。'
2230
+ :'Reply with one JSON object: `{"paperFinal":{"markdown":"<final body markdown>"}}` (or write `'+ps.dir+'/final.md`).')
2231
+ // vote / revise
2232
+ return PAPER_MARK[kind]+'\n'+head+'\n'
2233
+ +(zh?'请阅读最终稿 `'+ps.dir+'/final.md`,然后**明确表态它是否可交付**。有反对必须说明理由(框架会迭代,超限会把分歧写进附录)。\n'
2234
+ :'Read the final draft at `'+ps.dir+'/final.md` and state EXPLICITLY whether it is deliverable. Any objection must state its reason (the framework iterates; at the cap the disagreement goes into the appendix).\n')
2235
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperDeliverable":true|false,"reason":"<理由>"}`'
2236
+ :'Reply with one JSON object: `{"paperDeliverable":true|false,"reason":"<why>"}`')
2237
+ }
2238
+ // ---- state machine ----
2239
+ function paperHas(step,rid){
2240
+ const ps=paperState; if(!ps) return false
2241
+ if(step==='parts') return !!ps.parts[rid]
2242
+ if(step==='review') return !!ps.reviews[rid]
2243
+ if(step==='final') return !!ps.finalDraft
2244
+ if(step==='vote') return typeof ps.deliverable[rid]==='boolean'
2245
+ return false
2246
+ }
2247
+ function paperMarkMissing(step,rid){
2248
+ const ps=paperState
2249
+ if(step==='parts') ps.parts[rid]={text:'',missing:true,source:'missing'}
2250
+ else if(step==='review') ps.reviews[rid]={missing:true}
2251
+ else if(step==='final'){ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal() }
2252
+ else if(step==='vote') ps.deliverable[rid]=false
2253
+ paperLog('超时:'+rid+' 在 '+step+' 阶段未回应,已按"缺少该部分/未表态"记录')
2254
+ }
2255
+ async function paperWakeSome(ids,kind){
2256
+ const ps=paperState
2257
+ for(const rid of ids){
2258
+ const r=residents.get(rid)
2259
+ if(!r||!r.childId){ paperLog('警告:'+rid+' 已不在场,跳过 '+kind); paperMarkMissing(kind==='part'?'parts':kind==='review'?'review':kind==='final'?'final':'vote',rid); continue }
2260
+ if(busy.has(rid)) continue
2261
+ const ok=await wakeResident(r,paperPrompt(kind,rid),PAPER_KINDS[kind])
2262
+ if(!ok) paperLog('警告:无法唤醒 '+rid+' 执行 '+kind)
2263
+ }
2264
+ await paperWriteLog()
2265
+ }
2266
+ async function paperWait(step){
2267
+ const ps=paperState; if(!ps) return
2268
+ const miss=ps.participants.filter(x=>!paperHas(step,x))
2269
+ if(!miss.length) return await paperAdvance('wait-recheck')
2270
+ const nowTs=now()
2271
+ if(nowTs-ps.stepAt>=paperStepLimit()){
2272
+ for(const rid of miss) paperMarkMissing(step,rid)
2273
+ ps.warning=(ps.warning?ps.warning+';':'')+'有参与者在 '+step+' 阶段超时未回应('+miss.join('、')+'),已按"缺少该部分"记录并继续'
2274
+ paperLog('警告:'+ps.warning); ps.stepAt=nowTs
2275
+ await paperWriteLog(); return await paperAdvance('timeout')
2276
+ }
2277
+ // Paced re-ask: at most one nudge per stall window, and only PAPER_NUDGE_TRIES in total, so an
2278
+ // unresponsive host cannot produce a wake storm while the deadline is still open.
2279
+ if(nowTs-(ps.lastNudgeAt||0)>=posMs(params.activityTimeoutMs,120000)){
2280
+ ps.lastNudgeAt=nowTs
2281
+ for(const rid of miss){
2282
+ const key=rid+':'+step
2283
+ ps.attempts[key]=(ps.attempts[key]||0)+1
2284
+ if(ps.attempts[key]>PAPER_NUDGE_TRIES) continue
2285
+ const r=residents.get(rid)
2286
+ if(!r||!r.childId||busy.has(rid)) continue
2287
+ const ok=await wakeResident(r,paperPrompt(step,rid,true),PAPER_KINDS[step])
2288
+ if(!ok) paperLog('警告:无法唤醒 '+rid+'('+step+' 重试)')
2289
+ }
2290
+ await paperWriteLog()
2291
+ }
2292
+ armHeartbeat() // keep the clock running so the step deadline is actually reachable
2293
+ }
2294
+ async function paperAdvance(why){
2295
+ const ps=paperState
2296
+ if(!ps||ps.status==='done'||ps.status==='failed') return
2297
+ try {
2298
+ if(ps.status==='parts'){
2299
+ if(ps.participants.every(x=>paperHas('parts',x))) return await paperMerge()
2300
+ return await paperWait('parts')
2301
+ }
2302
+ if(ps.status==='review'){
2303
+ if(ps.participants.every(x=>paperHas('review',x))) return await paperFinalStep()
2304
+ return await paperWait('review')
2305
+ }
2306
+ if(ps.status==='final'){
2307
+ if(ps.finalDraft) return await paperVoteStep()
2308
+ return await paperWait('final')
2309
+ }
2310
+ if(ps.status==='vote'){
2311
+ if(!ps.participants.every(x=>paperHas('vote',x))) return await paperWait('vote')
2312
+ if(ps.participants.every(x=>ps.deliverable[x]===true)) return await paperFinalize()
2313
+ return await paperIterate()
2314
+ }
2315
+ } catch(e){ console.error('vibe-math-v4: paper step ('+(why||'')+') failed: '+String((e&&e.stack)||e)) }
2316
+ }
2317
+ async function paperMerge(){
2318
+ const ps=paperState
2319
+ const parts=ps.participants.map(rid=>({rid,text:String((ps.parts[rid]&&ps.parts[rid].text)||'')})).filter(p=>p.text.trim())
2320
+ const seen=new Map(), dups=[], terms=new Map(), conflicts=[], body=[]
2321
+ for(const p of parts){
2322
+ const paras=p.text.replace(/\r\n/g,'\n').split(/\n{2,}/)
2323
+ for(const raw of paras){
2324
+ const para=raw.trim(); if(!para) continue
2325
+ const norm=para.replace(/\s+/g,' ').toLowerCase()
2326
+ if(norm.length>=40 && seen.has(norm)){ dups.push({owner:seen.get(norm),dup:p.rid,head:para.slice(0,60)}); continue }
2327
+ if(norm.length>=40) seen.set(norm,p.rid)
2328
+ body.push(para)
2329
+ // Notation/term unification: the FIRST definition of a `- 术语: 定义` / `**术语**:…` line
2330
+ // wins; a different definition of the same term in another part is recorded as a conflict.
2331
+ const m=/^(?:[-*]\s*)?(?:\*\*)?([\u4e00-\u9fa5A-Za-z][^::*]{0,24})(?:\*\*)?\s*[::]\s*(\S.*)$/.exec(para)
2332
+ if(m){ const term=m[1].trim()
2333
+ if(!terms.has(term)) terms.set(term,{owner:p.rid,def:para})
2334
+ else if(terms.get(term).owner!==p.rid && terms.get(term).def!==para) conflicts.push({term,kept:terms.get(term).owner,conflict:p.rid}) }
2335
+ }
2336
+ }
2337
+ ps.draft=body.join('\n\n')
2338
+ const L=['# 合并稿(facilitator 合并)','','- 论文 id:'+ps.id,'- 参与成员:'+(ps.participants.join('、')||'(无常驻)'),
2339
+ '- 合并规则:跨部分**完全重复**的段落只保留首次出现;同一术语的**首个定义**为准,冲突记入下方。','']
2340
+ for(const p of parts) L.push('## 部分|'+p.rid,'',p.text.trim(),'')
2341
+ L.push('## 合并说明','')
2342
+ L.push('- 去重:'+(dups.length?dups.map(d=>'「'+d.head+'…」('+d.dup+' 重复了 '+d.owner+')').join(';'):'无跨部分重复段落'))
2343
+ L.push('- 术语/记号统一:'+(terms.size?[...terms.entries()].map(([k,v])=>k+'(取 '+v.owner+' 的定义)').join(';'):'未识别到形如「术语:定义」的行'))
2344
+ L.push('- 术语冲突:'+(conflicts.length?conflicts.map(c=>c.term+'(保留 '+c.kept+','+c.conflict+' 的定义不同)').join(';'):'无'))
2345
+ ps.mergeNotes={dups:dups.length,terms:terms.size,conflicts:conflicts.length}
2346
+ paperLog('facilitator 合并完成:'+parts.length+' 个部分,去重 '+dups.length+' 段,统一术语 '+terms.size+' 个,冲突 '+conflicts.length+' 处')
2347
+ await paperWriteRel(ps.dir+'/draft.md',L.join('\n')+'\n')
2348
+ ps.status='review'; ps.stepAt=now(); ps.lastNudgeAt=0
2349
+ await paperWriteMeta(); await paperWriteLog()
2350
+ if(!ps.participants.length){ return await paperFinalStep() }
2351
+ await paperWakeSome(ps.participants.slice(),'review')
2352
+ await paperAdvance('review-started')
2353
+ }
2354
+ /** The framework's own finalisation (paperEditor='facilitator'): the merged body plus the review
2355
+ * round APPLIED as an attributed revision block. No new claims are introduced. */
2356
+ function paperFacilitatorFinal(){
2357
+ const ps=paperState
2358
+ const L=[String(ps.draft||'').trim()]
2359
+ const rows=ps.participants.map(rid=>ps.reviews[rid]).filter(Boolean)
2360
+ if(rows.length){
2361
+ L.push('','## 互审改进(已采纳)')
2362
+ for(const rid of ps.participants){
2363
+ const rv=ps.reviews[rid]; if(!rv) continue
2364
+ if(rv.missing){ L.push('- '+rid+':互审超时未提交(已记入附录)'); continue }
2365
+ const pts=(rv.points||[]).map(x=>'「'+x+'」').join('、')
2366
+ L.push('- '+rid+' → 审阅 '+rv.reviewed+':'+(pts||'(未列出具体问题)')+(rv.improvements?(';改进:'+rv.improvements):''))
2367
+ }
2368
+ }
2369
+ return L.join('\n').trim()
2370
+ }
2371
+ async function paperFinalStep(){
2372
+ const ps=paperState
2373
+ ps.status='final'; ps.stepAt=now(); ps.lastNudgeAt=0
2374
+ const wantId=paperEditorResidentId(ps.cfg.editor)
2375
+ const r=wantId?residents.get(wantId):undefined
2376
+ if(wantId && r && r.childId){
2377
+ ps.editorId=wantId
2378
+ paperLog('定稿代表:resident:'+wantId+'(paperEditor 指定)')
2379
+ const ok=await wakeResident(r,paperPrompt('final',wantId),PAPER_KINDS.final)
2380
+ if(ok){ await paperWriteMeta(); await paperWriteLog(); armHeartbeat(); return }
2381
+ ps.warning='无法唤醒定稿代表 resident:'+wantId+',降级到 office'
2382
+ ps.editorDowngraded=true; paperLog('警告:'+ps.warning)
2383
+ } else if(wantId){
2384
+ // v2 §A5: the named editor left the roster (e.g. removeMember) -> downgrade to office and
2385
+ // RECORD the downgrade in meta + paper.log.md (never silently substitute an author).
2386
+ ps.warning='定稿代表 resident:'+wantId+' 已不在编制(可能已被解雇),降级到 office'
2387
+ ps.editorDowngraded=true; paperLog('警告:'+ps.warning)
2388
+ }
2389
+ ps.editorId='office'
2390
+ paperLog('定稿代表:office(会话根/人类侧)—— 框架依 office 之责把合并稿与互审记录整理成最终稿')
2391
+ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal()
2392
+ await paperWriteRel(ps.dir+'/final.md',ps.finalDraft)
2393
+ await paperWriteMeta(); await paperWriteLog()
2394
+ return await paperVoteStep()
2395
+ }
2396
+ async function paperVoteStep(){
2397
+ const ps=paperState
2398
+ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal()
2399
+ await paperWriteRel(ps.dir+'/final.md',ps.finalDraft)
2400
+ ps.status='vote'; ps.stepAt=now(); ps.lastNudgeAt=0
2401
+ paperLog('交付表决:请全体参与成员明确表态"可交付"')
2402
+ await paperWriteMeta(); await paperWriteLog()
2403
+ if(!ps.participants.length) return await paperFinalize()
2404
+ await paperWakeSome(ps.participants.slice(),'vote')
2405
+ await paperAdvance('vote-started')
2406
+ }
2407
+ async function paperIterate(){
2408
+ const ps=paperState
2409
+ const against=ps.participants.filter(x=>ps.deliverable[x]!==true)
2410
+ ps.dissent=against.slice()
2411
+ ps.round+=1
2412
+ paperLog('第 '+ps.round+' 轮:'+against.join('、')+' 未表示可交付('+(against.map(x=>ps.deliverable[x]===false?'明确反对':'未表态').join(';'))+')')
2413
+ if(ps.round>ps.maxRounds){
2414
+ ps.warning=(ps.warning?ps.warning+';':'')+'互审/交付迭代达到上限('+ps.maxRounds+'):'+against.join('、')+' 的分歧已写入附录'
2415
+ paperLog('警告:'+ps.warning)
2416
+ return await paperFinalize()
2417
+ }
2418
+ for(const rid of against) ps.deliverable[rid]=undefined
2419
+ ps.status='vote'; ps.stepAt=now(); ps.lastNudgeAt=0
2420
+ await paperWriteMeta(); await paperWriteLog()
2421
+ await paperWakeSome(against,'revise')
2422
+ await paperAdvance('revise-started')
2423
+ }
2424
+ // ---- content: the nine-section skeleton, built ONLY from evidence + the team's own text ----
2425
+ function paperExtractSection(draft,words){
2426
+ const lines=String(draft||'').split(/\r?\n/)
2427
+ let start=-1,level=0
2428
+ for(let i=0;i<lines.length;i++){
2429
+ const m=/^(#{1,6})\s*(.+)$/.exec(lines[i])
2430
+ if(m&&new RegExp(words,'i').test(m[2])){ start=i+1; level=m[1].length; break }
2431
+ }
2432
+ if(start<0) return ''
2433
+ const out=[]
2434
+ for(let i=start;i<lines.length;i++){
2435
+ const m=/^(#{1,6})\s*/.exec(lines[i])
2436
+ if(m&&m[1].length<=level) break
2437
+ out.push(lines[i])
2438
+ }
2439
+ return out.join('\n').trim()
2440
+ }
2441
+ function paperBullets(items,empty){ return items.length?items.map(x=>'- '+x).join('\n'):('- '+empty) }
2442
+ function paperCompose(ps,ev,cfg){
2443
+ const zh=cfg.language!=='en', T=paperTitles(cfg.language)
2444
+ const r=(a,b)=>zh?a:b
2445
+ const body=String(ps.finalDraft||ps.draft||'').trim()
2446
+ const truth=ev.verified.filter(v=>v.结论!=='假'), refuted=ev.verified.filter(v=>v.结论==='假')
2447
+ const pending=ev.propos.filter(p=>!['已验证·真','已验证·假'].includes(p.状态))
2448
+ const firstLine=String(ev.problem||'').split(/\r?\n/)[0].trim()
2449
+ const conclusion=r(
2450
+ (truth.length?('全体一致检验通过的命题 '+truth.length+' 条'+(refuted.length?(',被否证 '+refuted.length+' 条'):'')):'本轮没有取得全体一致的检验结论')+';未决条目 '+pending.length+' 条。',
2451
+ (truth.length?('Verified propositions: '+truth.length+(refuted.length?('; refuted: '+refuted.length):'')):'No unanimously verified proposition this run')+'; unresolved items: '+pending.length+'.')
2452
+ const sec=[];
2453
+ sec.push({title:T[0],body:[r('**摘要**:原问题——',' **Abstract** — problem: ')+firstLine,
2454
+ r('主要结论:','Main conclusion: ')+conclusion].join('\n')});
2455
+ sec.push({title:T[1],body:[r('原问题(完整陈述):','Original problem (full statement):'),'',String(ev.problem||''),
2456
+ '','- '+r('项目','Project')+':'+ev.project,'- '+r('运行','Run')+':'+ev.runId,'- '+r('状态','Status')+':'+(ev.converged?r('已收敛','converged'):r('未收敛','not converged'))].join('\n')});
2457
+ sec.push({title:T[2],body:[body||r('(团队本轮没有产出正文;以下仅为已有证据的索引。)','(The team authored no body this run; only the evidence index below.)'),
2458
+ '','### '+r('最终答案与结论','Final answer and conclusions'),paperBullets(
2459
+ truth.map(v=>v.id+(v.概率?('('+r('判定为真,估计值','true, estimate')+' '+v.概率+')'):'')+' ← `'+v.rel+'`'),
2460
+ r('本轮没有被全体一致判定为真的命题','No unanimously true proposition this run'))].join('\n')});
2461
+ sec.push({title:T[3],body:[paperBullets(
2462
+ ev.verified.map(v=>{ const verdict=v.结论==='假'?r('已否证','refuted'):r('真','true')
2463
+ return '['+verdict+'] '+v.id+(v.概率?('|'+r('概率','probability')+' '+v.概率):'')+'|'+r('证据','evidence')+' `'+v.rel+'`'+(v.类型?('|'+v.类型):'') }),
2464
+ r('本轮没有被检验通过的命题','No verified proposition this run')),
2465
+ '','### '+r('未决 / 未通过','Unresolved / not passed'),
2466
+ paperBullets(pending.map(p=>'[未决] '+p.id+'('+r('状态','status')+':'+(p.状态||'未定论')+')|`'+p.rel+'`')
2467
+ .concat(ev.formal.filter(f=>f.status!=='passed').map(f=>'[未决·形式化] '+f.target+'('+f.status+')'+(f.file?('|`'+f.file+'`'):''))),
2468
+ r('无','none'))].join('\n')});
2469
+ sec.push({title:T[4],body:[paperBullets(
2470
+ ev.verifiedProblems.map(v=>'[已解决] '+v.id+'|`'+v.rel+'`').concat(ev.subproblems.map(s=>'['+(['已验证·真','已验证·假'].includes(s.状态)?r('已解决','solved'):r('未决','unresolved'))+'] '+s.id+(s.标题?('「'+s.标题+'」'):'')+'|`'+s.rel+'`')),
2471
+ r('本轮没有记录子问题','No sub-problem was recorded this run'))].join('\n')});
2472
+ sec.push({title:T[5],body:[paperBullets(
2473
+ ev.methods.map(m=>m.id+(m.标题?('「'+m.标题+'」'):'')+'('+(m.类型||r('方法','method'))+','+(m.状态||r('经验','experiential'))+')'+(m.可信断言?('|'+r('可信断言','trusted claims')+':'+m.可信断言):'')+'|`'+m.rel+'`'),
2474
+ r('本轮没有记录方法/理论/工具','No method/theory/tool was recorded this run')),
2475
+ '',r('已归档的形式化产物:','Archived formal artifacts: ')+paperBullets(ev.formal.filter(f=>f.status==='passed').map(f=>f.target+(f.proof?('|`'+f.proof+'`'):'')+(f.file?('|`'+f.file+'`'):'')),r('无','none'))].join('\n')});
2476
+ const pattern=paperExtractSection(body,zh?'规律|总结|原理':'pattern|principle')
2477
+ sec.push({title:T[6],body:[pattern||[r('团队本轮没有单独撰写"规律总结";以下为**有证据支撑**的可复用条目(取自方法卡与已验证命题),未作外推:',
2478
+ 'The team did not author a separate "patterns" section; below are the EVIDENCE-BACKED reusable items (method cards and verified propositions), with no extrapolation:'),
2479
+ paperBullets(ev.methods.filter(m=>m.可信断言).map(m=>m.可信断言+'(`'+m.rel+'`)')
2480
+ .concat(truth.map(v=>v.id+'(`'+v.rel+'`)')),r('无','none'))].join('\n')].join('\n')});
2481
+ const disc=paperExtractSection(body,zh?'讨论|局限|展望':'discussion|limitation|outlook')
2482
+ sec.push({title:T[7],body:[disc||r('(团队未单独撰写讨论;以下为框架按证据整理的局限。)','(No separate discussion was authored; the framework lists the evidenced limitations.)'),
2483
+ '','### '+r('局限与未决','Limitations and open items'),
2484
+ paperBullets([
2485
+ r('未决/未通过条目 '+pending.length+' 条(见第 4 节,均已标注)','Unresolved items: '+pending.length+' (flagged in §4)'),
2486
+ r('被否证的命题 '+refuted.length+' 条(见第 4 节)','Refuted propositions: '+refuted.length+' (see §4)'),
2487
+ (ps.compile&&ps.compile.result==='failed')?r('PDF 编译失败:已保留 paper.tex 与 paper.md 并上报','PDF compilation failed: paper.tex and paper.md were kept and reported'):'',
2488
+ (ps.compile&&ps.compile.result==='not-detected')?r('本机未检测到 LaTeX 引擎:只产出 tex+md','No LaTeX engine detected: tex+md only'):'',
2489
+ ps.dissent.length?r('交付分歧('+ps.dissent.join('、')+')已写入附录','Deliverability dissent ('+ps.dissent.join(', ')+') is recorded in the appendix'):'',
2490
+ ps.warning?r('过程警告:'+ps.warning,'Process warning: '+ps.warning):'',
2491
+ ].filter(Boolean),r('无','none'))].join('\n')});
2492
+ sec.push({title:T[8],body:[r('证据与文件索引(全部相对于项目根):','Evidence and file index (all relative to the project root):'),
2493
+ '',paperBullets(ev.index.map(p=>'`'+p+'`'),r('无','none')),
2494
+ '','- '+r('过程记录','Process log')+':`'+ps.dir+'/paper.log.md`',
2495
+ '- '+r('元数据','Metadata')+':`'+ps.dir+'/paper.meta.json`',
2496
+ '- '+r('各成员撰写部分','Per-member parts')+':`'+ps.dir+'/parts/`',
2497
+ '- '+r('互审记录','Cross-review records')+':`'+ps.dir+'/reviews/`',
2498
+ '- '+r('合并稿/最终稿','Merged / final draft')+':`'+ps.dir+'/draft.md`、`'+ps.dir+'/final.md`',
2499
+ (ps.dissent.length||ps.round>ps.maxRounds)?['','### '+r('附录:交付分歧','Appendix: deliverability dissent'),paperBullets(ps.participants.filter(x=>ps.deliverable[x]!==true).map(x=>x+':'+(ps.deliverable[x]===false?r('反对交付','objected'):r('未表态','no statement'))+'|'+r('迭代轮次','rounds')+' '+ps.round+'/'+ps.maxRounds),r('无','none'))].join('\n'):'']
2500
+ .filter(Boolean).join('\n')});
2501
+ const title=r('Vibe-Mathematics v4 研究报告','Vibe-Mathematics v4 Research Report')+'|'+firstLine.slice(0,60)
2502
+ const md=L=>L
2503
+ const mdText=['# '+title,'','- '+r('作者','Authors')+':Vibe-Mathematics v4('+r('运行','run')+' '+ps.runId+')',
2504
+ '- '+r('日期','Date')+':'+fmtTime(ps.finalizedAt||now()).slice(0,10),
2505
+ '- '+r('参与成员','Participants')+':'+(ps.participants.join('、')||r('(无常驻)','(no residents)')),
2506
+ '- '+r('定稿代表','Editor')+':'+(ps.editorId||cfg.editor),'',
2507
+ ...sec.map((s,i)=>['## '+(i+1)+'. '+s.title,'',s.body,''].join('\n'))].join('\n')
2508
+ const texBody=sec.map((s,i)=>'\\section{'+texEsc(s.title)+'}\n'+texEsc(s.body)).join('\n\n')
2509
+ const tex=paperTex(cfg,title,texBody)
2510
+ return {title, sections:sec, md:mdText, tex, texBody}
2511
+ }
2512
+ function paperTex(cfg,title,texBody){
2513
+ const zh=cfg.language!=='en'
2514
+ const cls=zh?'ctexart':'article'
2515
+ const pkgs=['amsmath','amssymb','amsthm','geometry','hyperref','longtable','booktabs']
2516
+ return ['% generated by vibe-math-v4 (final paper)','\\documentclass[11pt]{'+cls+'}',
2517
+ ...pkgs.map(p=>'\\usepackage{'+p+'}'),
2518
+ '\\title{'+texEsc(title)+'}','\\author{Vibe-Mathematics v4}','\\date{'+texEsc(fmtTime(now()).slice(0,10))+'}',
2519
+ '\\begin{document}','\\maketitle','',texBody,'','\\end{document}',''].join('\n')
2520
+ }
2521
+ /** tex escaping (docs/final-paper.md §5): every `_ % & # $ { } ~ ^ \\` becomes a safe command/escape. */
2522
+ function texEsc(s){
2523
+ const M={'\\':'\\textbackslash{}','{':'\\{','}':'\\}','$':'\\$','&':'\\&','#':'\\#','^':'\\textasciicircum{}','_':'\\_','%':'\\%','~':'\\textasciitilde{}'}
2524
+ let out=''; for(const ch of String(s==null?'':s)) out+=(M[ch]||ch); return out
2525
+ }
2526
+ // ---- compilation (detect → compile twice → capped repair → degrade; docs/final-paper.md §D/§E) ----
2527
+ // The host fs is TEXT-ONLY (dsh-fs has no writeBytes), so `paper.pdf` can only ever be produced
2528
+ // by the compiler subprocess inside `Paper/<id>/` — the plugin just `stat`s it. The Lean seam
2529
+ // (`sub.resolveExecutable` + `sub.spawn`) is reused verbatim so a fake compiler is injectable.
2530
+ async function paperDetectEngine(lang){
2531
+ const sub=subprocessOf(); if(sub===undefined) return {ok:false,tried:[],reason:'NO_SUBPROCESS'}
2532
+ const prefer=String(params.paperLatexCommand||'').trim()
2533
+ const base=lang==='en'?['pdflatex','latexmk','xelatex','lualatex','tectonic']:['xelatex','latexmk','pdflatex','lualatex','tectonic']
2534
+ const order=(prefer?[prefer]:[]).concat(base.filter(x=>x!==prefer))
2535
+ const tried=[]
2536
+ for(let i=0;i<order.length;i++){
2537
+ const exe=order[i]
2538
+ try { const r=await sub.resolveExecutable(exe); if(r) return {ok:true,exe:String(r),tried,alts:order.slice(i+1)} }
2539
+ catch(e){ tried.push(exe) }
2540
+ }
2541
+ return {ok:false,tried,reason:'LATEX_NOT_FOUND'}
2542
+ }
2543
+ function paperEngineArgv(exe,lang){
2544
+ if(/latexmk$/.test(exe)) return [exe,'-interaction=nonstopmode',lang==='en'?'-pdf':'-xelatex','paper.tex']
2545
+ return [exe,'-interaction=nonstopmode','paper.tex']
2546
+ }
2547
+ async function paperRunEngine(exe,argv,dir,cap){
2548
+ const sub=subprocessOf(); if(sub===undefined) return {ok:false,code:'NO_SUBPROCESS'}
2549
+ let handle
2550
+ try { handle=sub.spawn({argv,cwd:dir,stdio:{stdin:'ignore',stdout:{maxBytes:64*1024},stderr:{maxBytes:64*1024}},graceMs:cap}) }
2551
+ catch(e){ return {ok:false,code:'LATEX_SPAWN_FAILED',message:String((e&&e.message)||e)} }
2552
+ let killed=false,timerDispose=null,outcome
2553
+ try {
2554
+ outcome=await Promise.race([handle.done,new Promise(function(resolve){
2555
+ if(typeof ctx.timeout!=='function') return
2556
+ timerDispose=ctx.timeout(function(){ killed=true; try{ if(typeof handle.terminate==='function') handle.terminate() }catch(e){} resolve({exitCode:null,signal:'SIGTERM'}) },cap)
2557
+ })])
2558
+ } catch(e){ if(timerDispose){ try{ timerDispose() }catch(_e){} } return {ok:false,code:'LATEX_RUN_FAILED',message:String((e&&e.message)||e)} }
2559
+ if(timerDispose){ try{ timerDispose() }catch(e){} }
2560
+ let out='',err=''
2561
+ try { if(handle.collected&&handle.collected.stdout) out=handle.collected.stdout.readFrom(0).text } catch(e){}
2562
+ try { if(handle.collected&&handle.collected.stderr) err=handle.collected.stderr.readFrom(0).text } catch(e){}
2563
+ const exitCode=outcome?outcome.exitCode:null
2564
+ return {ok:exitCode===0,exitCode,timedOut:killed,stdout:tail(out,2000),stderr:tail(err,2000)}
2565
+ }
2566
+ async function paperPdfExists(dir){
2567
+ try { const t=await fs.resolve(dir+'/paper.pdf',{cwd:frameworkRoot()}); return !!(await fs.stat(t)) } catch(e){ return false }
2568
+ }
2569
+ /** v2 §E: at most ONE engine swap, then ONE minimal-template retry, then report + degrade. */
2570
+ async function paperCompile(ps,composed,ev){
2571
+ const dir=ps.dir
2572
+ // v2 §E: `paperFormat=md` produces no tex, so "cannot compile without tex" must NOT be warned.
2573
+ if(ps.cfg.format==='md') return {result:'skipped',reason:'paperFormat=md(未产出 tex,跳过编译)',attempts:[]}
2574
+ if(!ps.cfg.compilePdf) return {result:'skipped',reason:'paperCompilePdf=false',attempts:[]}
2575
+ // Never clobber an existing pdf (v2 §D): a previously compiled artifact is kept as-is.
2576
+ if(await paperPdfExists(dir)) return {result:'ok',engine:'existing',note:'已存在 paper.pdf,未覆盖',attempts:[]}
2577
+ const det=await paperDetectEngine(ps.cfg.language)
2578
+ if(!det.ok) return {result:'not-detected',reason:'未检测到 LaTeX 引擎('+(det.tried.join('/')||'subprocess 不可用')+'):只产出 tex+md,不编译',tried:det.tried,attempts:[]}
2579
+ const cap=posMs(params.activityTimeoutMs,120000)
2580
+ const attempts=[]
2581
+ const runTwice=async(exe,label,passes)=>{
2582
+ const argv=paperEngineArgv(exe,ps.cfg.language)
2583
+ let ok=true,first=null
2584
+ for(let i=0;i<(passes||2);i++){ const a=await paperRunEngine(exe,argv,dir,cap); if(i===0) first=a; if(!a.ok){ ok=false; break } }
2585
+ const pdf=await paperPdfExists(dir)
2586
+ attempts.push({stage:label,engine:exe,ok:!!(ok&&pdf),exitCode:first?first.exitCode:null,pdf,stderr:String((first&&first.stderr)||'').slice(0,300)})
2587
+ return {ok:!!(ok&&pdf),last:first}
2588
+ }
2589
+ let r=await runTwice(det.exe,'initial',2)
2590
+ if(r.ok) return {result:'ok',engine:det.exe,attempts}
2591
+ // repair 1: swap to another RESOLVABLE engine (same tex, nonstopmode, twice) — capped at one swap
2592
+ const sub=subprocessOf()
2593
+ for(const alt of det.alts||[]){
2594
+ let altExe=null
2595
+ try { if(sub) altExe=String(await sub.resolveExecutable(alt)) } catch(e){ continue }
2596
+ if(!altExe||altExe===det.exe) continue
2597
+ const rr=await runTwice(altExe,'engine-switch',2)
2598
+ if(rr.ok) return {result:'ok',engine:altExe,repaired:'engine-switch',attempts}
2599
+ break
2600
+ }
2601
+ // repair 2: ONE minimal-template retry (drops every optional package / custom preamble)
2602
+ await paperWriteRel(dir+'/paper.tex',paperMinimalTemplate(ps.cfg,composed.title,composed.texBody))
2603
+ const rr=await runTwice(det.exe,'minimal-template',1)
2604
+ if(rr.ok) return {result:'ok',engine:det.exe,repaired:'minimal-template',attempts}
2605
+ const last=attempts[attempts.length-1]||{}
2606
+ return {result:'failed',engine:det.exe,attempts,error:'编译失败(已按上限尝试换引擎/最小模板):保留 paper.tex 与 paper.md,不阻塞定稿',tail:String(last.stderr||'')}
2607
+ }
2608
+ function paperMinimalTemplate(cfg,title,texBody){
2609
+ const cls=cfg.language==='en'?'article':'ctexart'
2610
+ return ['% generated by vibe-math-v4 (minimal fallback template)','\\documentclass[11pt]{'+cls+'}',
2611
+ '\\begin{document}','\\section*{'+texEsc(title)+'}','',String(texBody||''),'','\\end{document}',''].join('\n')
2612
+ }
2613
+ async function paperEnsureArtifacts(meta,cfg){
2614
+ const filled=[]
2615
+ const dir=String(meta.dir||('Paper/'+meta.id))
2616
+ const saved=paperState
2617
+ try {
2618
+ if(!paperState||paperState.id!==meta.id){
2619
+ paperState={ id:meta.id, dir, trigger:'recheck', deferCompletion:false, startedAt:meta.startedAt||now(), finalizedAt:meta.finalizedAt||0,
2620
+ status:'done', round:meta.rounds||0, maxRounds:PAPER_MAX_ROUNDS, cfg, fingerprint:meta.fingerprint||'',
2621
+ participants:Array.isArray(meta.participants)?meta.participants:[], parts:{}, reviews:{}, deliverable:{}, dissent:meta.dissent||[],
2622
+ log:[], warning:meta.warning||'', compile:meta.compile||null, draft:'', finalDraft:'', files:meta.files||[], stepAt:now(), lastNudgeAt:0, attempts:{}, editorId:meta.editorId||'' }
2623
+ }
2624
+ const ev=await paperEvidence()
2625
+ const body=await readText(dir+'/final.md')
2626
+ if(body!==undefined&&body!=='') paperState.finalDraft=body
2627
+ const composed=paperCompose(paperState,ev,cfg)
2628
+ const wantMd=cfg.format!=='tex', wantTex=cfg.format!=='md'
2629
+ const mdRaw=await readText(dir+'/paper.md'), texRaw=await readText(dir+'/paper.tex')
2630
+ if(wantMd&&mdRaw===undefined){ if(await paperWriteRel(dir+'/paper.md',composed.md)) filled.push('paper.md') }
2631
+ if(wantTex&&texRaw===undefined){ if(await paperWriteRel(dir+'/paper.tex',composed.tex)) filled.push('paper.tex') }
2632
+ if(cfg.compilePdf&&wantTex&&!(await paperPdfExists(dir))){
2633
+ const c=await paperCompile(paperState,composed,ev)
2634
+ paperState.compile=c
2635
+ if(c.result==='ok') filled.push('paper.pdf')
2636
+ }
2637
+ if(filled.length) await paperWriteMeta()
2638
+ return filled
2639
+ } finally { paperState=saved||null }
2640
+ }
2641
+ async function paperWriteMeta(){
2642
+ const ps=paperState; if(!ps) return false
2643
+ const meta={ id:ps.id, dir:ps.dir, generatedBy:'vibe-math-v4', preset:'v4', trigger:ps.trigger,
2644
+ runId, closureSignal:PAPER_CLOSURE_SIGNAL, startedAt:ps.startedAt, finalizedAt:ps.finalizedAt||0,
2645
+ params:{finalPaper:params.finalPaper,paperFormat:ps.cfg.format,paperLanguage:ps.cfg.language,paperCompilePdf:ps.cfg.compilePdf,paperEditor:ps.cfg.editor,paperLatexCommand:params.paperLatexCommand},
2646
+ editor:ps.cfg.editor, editorId:ps.editorId||'', editorDowngraded:!!ps.editorDowngraded, participants:ps.participants, rounds:ps.round, maxRounds:ps.maxRounds,
2647
+ fingerprint:ps.fingerprint||'', warning:ps.warning||'', files:ps.files||[], compile:ps.compile||null, mergeNotes:ps.mergeNotes||null,
2648
+ parts:Object.keys(ps.parts).sort().map(k=>({id:k,chars:String((ps.parts[k]||{}).text||'').length,missing:!!(ps.parts[k]||{}).missing,source:(ps.parts[k]||{}).source||''})),
2649
+ reviews:Object.keys(ps.reviews).sort().map(k=>({id:k,reviewed:(ps.reviews[k]||{}).reviewed||'',points:((ps.reviews[k]||{}).points||[]).length,improvements:(ps.reviews[k]||{}).improvements||'',missing:!!(ps.reviews[k]||{}).missing})),
2650
+ deliverable:Object.keys(ps.deliverable).sort().map(k=>({id:k,deliverable:ps.deliverable[k]})),
2651
+ dissent:ps.dissent||[] }
2652
+ return await paperWriteRel(ps.dir+'/paper.meta.json',JSON.stringify(meta,null,2))
2653
+ }
2654
+ async function paperWriteLog(){
2655
+ const ps=paperState; if(!ps) return false
2656
+ const L=['# 最终论文过程记录(paper.log.md)','','- 论文 id:'+ps.id,'- 触发:'+ps.trigger,
2657
+ '- 语言 / 格式 / 定稿代表:'+ps.cfg.language+' / '+ps.cfg.format+' / '+ps.cfg.editor,'- 参与成员:'+(ps.participants.join('、')||'(无常驻)'),
2658
+ '- 状态:'+ps.status,'']
2659
+ L.push('## 过程(谁写了什么)'); for(const l of ps.log) L.push('- '+l); L.push('')
2660
+ L.push('## 归属与处理(谁提了什么意见、如何采纳)')
2661
+ for(const rid of ps.participants){ const p=ps.parts[rid]
2662
+ L.push('- 撰写:'+rid+(p?(p.missing?'(超时未提交)':'('+String(p.text||'').length+' 字,来源 '+(p.source||'reply')+')'):'(未提交)')) }
2663
+ for(const rid of ps.participants){ const rv=ps.reviews[rid]
2664
+ if(!rv||rv.missing){ L.push('- 互审:'+rid+'('+(rv?'超时未提交,已记入附录':'未完成')+')'); continue }
2665
+ L.push('- 互审:'+rid+' → '+rv.reviewed+',意见 '+((rv.points||[]).length)+' 条,处理:已采纳并记入合并稿审阅记录'+(rv.improvements?('(改进:'+String(rv.improvements).slice(0,200)+')'):'')) }
2666
+ L.push('- 定稿:'+(ps.editorId||ps.cfg.editor))
2667
+ for(const rid of ps.participants) if(typeof ps.deliverable[rid]==='boolean') L.push('- 交付表态:'+rid+' = '+(ps.deliverable[rid]?'可交付':'不可交付'))
2668
+ if(ps.compile) L.push('- 编译:'+JSON.stringify({result:ps.compile.result,engine:ps.compile.engine||'',repaired:ps.compile.repaired||'',attempts:(ps.compile.attempts||[]).length,error:ps.compile.error||''}))
2669
+ if(ps.warning) L.push('- 警告:'+ps.warning)
2670
+ return await paperWriteRel(ps.dir+'/paper.log.md',L.join('\n')+'\n')
2671
+ }
2672
+ async function paperFinalize(){
2673
+ const ps=paperState
2674
+ const ev=await paperEvidence()
2675
+ ps.fingerprint=paperFingerprint(ev)
2676
+ // final.md / draft.md must exist before composing so a re-run is reproducible
2677
+ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal()
2678
+ await paperWriteRel(ps.dir+'/final.md',ps.finalDraft)
2679
+ const composed=paperCompose(ps,ev,ps.cfg)
2680
+ ps.files=[]
2681
+ if(ps.cfg.format!=='tex'){ if(await paperWriteRel(ps.dir+'/paper.md',composed.md)) ps.files.push('paper.md') }
2682
+ if(ps.cfg.format!=='md'){ if(await paperWriteRel(ps.dir+'/paper.tex',composed.tex)) ps.files.push('paper.tex') }
2683
+ ps.compile=await paperCompile(ps,composed,ev)
2684
+ if(ps.compile&&ps.compile.result==='ok') ps.files.push('paper.pdf')
2685
+ ps.finalizedAt=now(); ps.status='done'
2686
+ await paperWriteMeta(); await paperWriteLog()
2687
+ // v2 §A1: the paper phase was entered from INSIDE the closing branch, BEFORE the completion
2688
+ // flags — so the run only becomes "done" here, once the paper exists (or has degraded safely).
2689
+ if(ps.deferCompletion && autoDone!==true){
2690
+ running=false; autoDone=true; phase='done'; clearHeartbeat()
2691
+ wakeKind.clear(); meetingState=null; verifyState=null; pendingMeeting=null; pendingVerify=[]
2692
+ logActivity('stop','all residents agree: problem solved(一致性停止票;最终论文已产出)')
2693
+ await saveAll()
2694
+ }
2695
+ const warn=[ps.warning,ps.compile&&ps.compile.result==='failed'?ps.compile.error:''].filter(Boolean).join(';')
2696
+ if(warn) logActivity('paper','最终论文完成(有警告):'+ps.dir+'|'+warn)
2697
+ else logActivity('paper','最终论文完成:'+ps.dir+'('+ps.files.join('、')+(ps.compile?('|编译 '+(ps.compile.result||'')):'')+')')
2698
+ return {ok:true,id:ps.id,dir:ps.dir,files:ps.files,compile:ps.compile,rounds:ps.round,dissent:ps.dissent,warning:ps.warning}
2699
+ }
2700
+ function paperStatusView(){
2701
+ const ps=paperState
2702
+ return {
2703
+ params:{finalPaper:params.finalPaper,paperFormat:params.paperFormat,paperLanguage:params.paperLanguage,paperCompilePdf:params.paperCompilePdf,paperEditor:params.paperEditor,paperLatexCommand:params.paperLatexCommand},
2704
+ id:ps?ps.id:paperIdFor(), dir:ps?ps.dir:paperDirFor(), status:ps?ps.status:'idle',
2705
+ running:!!(ps&&ps.status!=='done'&&ps.status!=='failed'), trigger:ps?ps.trigger:'',
2706
+ rounds:ps?ps.round:0, maxRounds:ps?ps.maxRounds:PAPER_MAX_ROUNDS, participants:ps?ps.participants:[],
2707
+ editor:ps?(ps.editorId||ps.cfg.editor):params.paperEditor, editorDowngraded:!!(ps&&ps.editorDowngraded),
2708
+ parts:ps?Object.keys(ps.parts).length:0, reviews:ps?Object.keys(ps.reviews).length:0,
2709
+ finalizedAt:ps?ps.finalizedAt:0, warning:ps?ps.warning:'', files:ps?(ps.files||[]):[], compile:ps?ps.compile:null,
2710
+ closureSignal:PAPER_CLOSURE_SIGNAL,
2711
+ }
2712
+ }
2713
+ function paperStatusSummary(){
2714
+ const v=paperStatusView()
2715
+ return {ok:true, id:v.id, dir:v.dir, status:v.status, running:v.running, trigger:v.trigger, lang:v.params.paperLanguage,
2716
+ format:v.params.paperFormat, editor:v.editor, editorDowngraded:v.editorDowngraded, participants:v.participants,
2717
+ parts:v.parts, reviews:v.reviews, rounds:v.rounds+'/'+v.maxRounds, finalizedAt:v.finalizedAt, files:v.files,
2718
+ params:v.params,
2719
+ compile:v.compile?{result:v.compile.result,engine:v.compile.engine||'',repaired:v.compile.repaired||''}:null,
2720
+ warning:v.warning, closureSignal:v.closureSignal}
2721
+ }
2722
+ async function paperStart(trigger,opts){
2723
+ const o=opts||{}
2724
+ const force=!!o.force
2725
+ const cfg=paperCfg(o)
2726
+ const id=paperIdFor(), dir='Paper/'+id
2727
+ if(paperState && paperState.id===id && paperState.status!=='done' && paperState.status!=='failed' && !force)
2728
+ return {ok:true,id,dir,status:paperState.status,alreadyRunning:true,message:'最终论文正在进行('+paperState.status+')'}
2729
+ if(paperState && paperState.id!==id && paperState.status!=='done' && paperState.status!=='failed' && !force)
2730
+ return {ok:false,code:'V4_PAPER_BUSY',message:'另一篇论文正在进行('+paperState.id+'),完成或 force 后再试'}
2731
+ // Idempotency (docs/final-paper.md §C): the run id IS the directory, so a finalizedAt in THAT directory
2732
+ // means this run already has its paper; only per-artifact existence is re-checked.
2733
+ const meta=await readJson(dir+'/paper.meta.json')
2734
+ if(!force && meta && meta.finalizedAt){
2735
+ const filled=await paperEnsureArtifacts(meta,cfg)
2736
+ return {ok:true,id,dir,idempotent:true,finalizedAt:meta.finalizedAt,filled,
2737
+ message:'论文已定稿(幂等:只补写缺失产物'+(filled.length?(':'+filled.join('、')):':无缺失')+')'+(params.finalPaper===false?'(finalPaper=false:自动触发已关闭,本次为手动命令)':'')}
2738
+ }
2739
+ const prevDeliver={}
2740
+ paperState={ id, dir, trigger, deferCompletion:!!o.deferCompletion, startedAt:now(), finalizedAt:0, status:'parts', round:0, maxRounds:PAPER_MAX_ROUNDS,
2741
+ cfg, fingerprint:'', participants:Array.from(residents.keys()).sort(), parts:{}, reviews:{}, deliverable:prevDeliver,
2742
+ dissent:[], log:[], warning:'', compile:null, draft:'', finalDraft:'', files:[], stepAt:now(), lastNudgeAt:0, attempts:{}, editorId:'', editorDowngraded:false }
2743
+ paperLog('触发('+trigger+'):'+id+'|收敛信号='+PAPER_CLOSURE_SIGNAL+'|语言='+cfg.language+'|格式='+cfg.format+'|定稿='+cfg.editor+'|参与='+(paperState.participants.join('、')||'(无常驻)'))
2744
+ if(params.finalPaper===false) paperLog('注意:finalPaper=false —— 自动触发已关闭;本次为手动命令')
2745
+ await paperWriteMeta(); await paperWriteLog()
2746
+ if(!paperState.participants.length){
2747
+ paperState.warning='本轮没有在场常驻:正文由框架(office 侧)从既有证据整理'
2748
+ paperLog('警告:'+paperState.warning)
2749
+ await paperMerge() // -> paperFinalStep -> vote(no participants) -> finalize
2750
+ return {ok:true,id,dir,status:paperState.status,message:'最终论文已启动(无常驻成员,由框架从既有证据整理)'+(params.finalPaper===false?'(finalPaper=false:自动触发已关闭)':'')}
2751
+ }
2752
+ await paperWakeSome(paperState.participants.slice(),'part')
2753
+ await paperAdvance('parts-started')
2754
+ return {ok:true,id,dir,status:paperState.status,participants:paperState.participants,files:paperState.files,
2755
+ message:'最终论文已启动('+trigger+')'+(params.finalPaper===false?'(finalPaper=false:自动触发已关闭)':'')}
2756
+ }
2757
+ async function runPaperCommand(ov,force){
2758
+ const known=['lang','format','editor']
2759
+ for(const k of Object.keys(ov||{})) if(known.indexOf(k)===-1) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'未知选项 '+k+'(支持 lang=zh|en / format=md|tex|both / editor=office|resident:<id> / force)'}
2760
+ if(ov&&ov.lang&&PAPER_LANGS.indexOf(String(ov.lang))===-1) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'paperLanguage 只能是 zh|en'}
2761
+ if(ov&&ov.format&&PAPER_FORMATS.indexOf(String(ov.format))===-1) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'paperFormat 只能是 both|md|tex'}
2762
+ if(ov&&ov.editor&&!paperEditorOk(ov.editor)) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'paperEditor 只能是 office 或 resident:<id>'}
2763
+ return await paperStart('manual',{force:!!force,lang:ov&&ov.lang,format:ov&&ov.format,editor:ov&&ov.editor})
2764
+ }
2765
+ /** A paper wake's reply. `kind` is read from wakeKind (set by wakeResident, not cleared here). */
2766
+ async function onPaperWakeEnd(r,parsed){
2767
+ const ps=paperState; if(!ps) return
2768
+ const kind=String(wakeKind.get(r.rId)||'')
2769
+ // A paper contribution may arrive as a plain string or as `{markdown:"…"}` — anything else is
2770
+ // NOT accepted (a stray object must never become "[object Object]" in the final draft).
2771
+ const pick=v=>{ if(typeof v==='string') return v; if(v&&typeof v==='object'&&typeof v.markdown==='string') return v.markdown; return '' }
2772
+ if(kind===PAPER_KINDS.part){
2773
+ let text=pick(parsed&&parsed.paperPart).trim()
2774
+ if(!text) text=String((await readText(ps.dir+'/parts/'+r.rId+'.md'))||'').trim()
2775
+ if(text){ ps.parts[r.rId]={text,at:now(),source:'reply'}; await paperWriteRel(ps.dir+'/parts/'+r.rId+'.md',text)
2776
+ paperLog(r.rId+' 提交了自己负责的部分('+text.length+' 字)') }
2777
+ else paperLog('警告:'+r.rId+' 的部分为空(将按超时/重试处理)')
2778
+ } else if(kind===PAPER_KINDS.review){
2779
+ const rv=(parsed&&parsed.paperReview&&typeof parsed.paperReview==='object')?parsed.paperReview:{}
2780
+ const reviewed=String(rv.reviewed||'')
2781
+ const others=ps.participants.filter(x=>x!==r.rId)
2782
+ if(others.indexOf(reviewed)===-1){
2783
+ paperLog('拒绝 '+r.rId+' 的互审:未指名其他参与者(reviewed='+JSON.stringify(rv.reviewed===undefined?null:rv.reviewed)+')——必须审阅至少一位他人')
2784
+ } else {
2785
+ const points=Array.isArray(rv.points)?rv.points.map(String).filter(Boolean):[]
2786
+ ps.reviews[r.rId]={reviewed,points,improvements:String(rv.improvements||''),at:now(),accepted:true}
2787
+ paperLog(r.rId+' 审阅了 '+reviewed+' 的部分:'+points.length+' 条意见'+(rv.improvements?',并给出改进建议':'') )
2788
+ await paperWriteRel(ps.dir+'/reviews/'+r.rId+'.md',['# 互审记录|'+r.rId,'','- 审阅对象:'+reviewed,'- 意见:','',
2789
+ ...points.map(p=>'- '+p),'','## 改进建议','',String(rv.improvements||'(无)')].join('\n')+'\n')
2790
+ }
2791
+ } else if(kind===PAPER_KINDS.final){
2792
+ let text=pick(parsed&&parsed.paperFinal).trim()
2793
+ if(!text) text=String((await readText(ps.dir+'/final.md'))||'').trim()
2794
+ if(text&&!/^#\s*互审记录/.test(text)){ ps.finalDraft=text; await paperWriteRel(ps.dir+'/final.md',text); paperLog('定稿代表 '+(ps.editorId||ps.cfg.editor)+' 提交了最终稿('+text.length+' 字)') }
2795
+ else paperLog('警告:定稿代表返回空稿,改由框架(office)定稿')
2796
+ } else if(kind===PAPER_KINDS.vote||kind===PAPER_KINDS.revise){
2797
+ const d=parsed?parsed.paperDeliverable:undefined
2798
+ if(typeof d!=='boolean'){ paperLog('警告:'+r.rId+' 未明确表态"可交付/不可交付"(将按未表态处理)'); }
2799
+ else { ps.deliverable[r.rId]=d
2800
+ paperLog(r.rId+(d?' 表示:可交付':' 表示:不可交付')+(parsed&&parsed.reason?('|理由:'+String(parsed.reason).slice(0,160)):'')) }
2801
+ }
2802
+ await paperWriteLog()
2803
+ await paperAdvance('resident-end:'+kind)
2804
+ }
2805
+
2051
2806
  // ---- controls ----
2052
2807
  async function start({problem,residentCount,seedDirections}){
2053
2808
  await loadSettings()
@@ -2069,6 +2824,7 @@ export function apply(ctx) {
2069
2824
  // carrying a previous run's records over would let a stale `passed` open the new gate.
2070
2825
  formal={}; formalTodos=[]
2071
2826
  busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; lastSyncMeetingAt=0; finalizeLock=null; verifiedRecently.clear() // fresh run must NOT inherit stale concurrency/coordination state (busy/wakeKind/currentResident/pendingMeeting) from a previous run on the same reused session
2827
+ paperState=null // a fresh run gets a fresh paper flow (its durable record lives in Paper/<run id>/)
2072
2828
  // Re-base the artifact counter on the cards already on disk, so the auto-sync meeting counts
2073
2829
  // artifacts written by THIS run (the project tree may already hold cards from an earlier run).
2074
2830
  artifactBaseline=null; artifactCount=0; await syncArtifactCount()
@@ -2156,13 +2912,14 @@ export function apply(ctx) {
2156
2912
  consensus: meetingState?{kind:'meeting',id:meetingState.id,agenda:meetingState.agenda,round:meetingState.round,spoke:Object.keys(meetingState.inputs).length,expected:residents.size,solvedVotes:Object.values(meetingState.inputs).filter(iv=>iv.voteSolved===true).length}
2157
2913
  :(verifyState?{kind:'verify',target:verifyState.targetId,targetType:verifyState.targetType,stage:verifyState.stage,round:verifyState.round,voted:Object.keys(verifyState.verdicts).length,expected:residents.size}:null),
2158
2914
  artifactCount: artifactCount, artifactBaseline: artifactBaseline,
2915
+ paper: paperStatusSummary(),
2159
2916
  // The Lean knobs and the per-object formal records are part of the readable status: without
2160
2917
  // them a `require`-mode run that keeps returning 未定论 would be undiagnosable from outside.
2161
2918
  formal: formalView(),
2162
2919
  // The parameter NAMES (not just the rendered string) so a caller — the `/v4 set` handler in
2163
2920
  // particular — can reject an unknown key instead of silently dropping it.
2164
2921
  paramsKeys: Object.keys(params),
2165
- params:['residentCount','compactAfterRounds','compactThreshold','maxParallel','activityTimeoutMs','meetingKeepEvery','verdictMaxRounds','stallAutoMeetingMs','provider','model','residentPersona','toolAllow','toolDeny','formalVerify','leanCommand','leanArgs','leanTimeoutMs'].map(k=>k+'='+(Array.isArray(params[k])?params[k].join(','):params[k])).join(', ') } }
2922
+ params:['residentCount','compactAfterRounds','compactThreshold','maxParallel','activityTimeoutMs','meetingKeepEvery','verdictMaxRounds','stallAutoMeetingMs','provider','model','residentPersona','toolAllow','toolDeny','formalVerify','leanCommand','leanArgs','leanTimeoutMs','finalPaper','paperFormat','paperLanguage','paperCompilePdf','paperEditor','paperLatexCommand'].map(k=>k+'='+(Array.isArray(params[k])?params[k].join(','):params[k])).join(', ') } }
2166
2923
  function formalReportText(){
2167
2924
  if(!formalOn()) return '- 未启用(`formalVerify` = off;可用 vibe_v4_set 切到 encourage / require)'
2168
2925
  const v=formalView()
@@ -2179,6 +2936,7 @@ export function apply(ctx) {
2179
2936
  pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null,
2180
2937
  parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
2181
2938
  formal: formalView(),
2939
+ paper: paperStatusSummary(),
2182
2940
  meetings:meetings.length, recentActivity: activityLog.slice(-8) } }
2183
2941
  /** Human-readable mirror of the formal state (kept OUT of the JSON report shape). */
2184
2942
  function formalReport(){ return {ok:true, formalMode:formalMode(), formalReport:'## Lean 形式化\n'+formalReportText(), formal:formalView()} }
@@ -2226,6 +2984,21 @@ export function apply(ctx) {
2226
2984
  // Degrading to a STRONGER mode would let a typo silently gate every conclusion — the exact
2227
2985
  // failure mode `require` is supposed to avoid.
2228
2986
  if(k==='formalVerify') return FORMAL_MODES.indexOf(String(v))!==-1?String(v):'off'
2987
+ // ---- final paper (docs/final-paper.md §B): booleans and enums are coerced HERE, because `/v4 set` hands
2988
+ // the parameter layer RAW STRINGS ('false' must become false, not stay truthy) and an unknown
2989
+ // value must fall back to the default instead of silently keeping a bogus strong setting. ----
2990
+ if(k==='finalPaper'||k==='paperCompilePdf'){
2991
+ if(typeof v==='boolean') return v
2992
+ const s=String(v==null?'':v).trim().toLowerCase()
2993
+ if(s==='true'||s==='1'||s==='yes'||s==='on') return true
2994
+ if(s==='false'||s==='0'||s==='no'||s==='off') return false
2995
+ return DEFAULT_PARAMS[k]
2996
+ }
2997
+ if(k==='paperFormat') return PAPER_FORMATS.indexOf(String(v))!==-1?String(v):DEFAULT_PARAMS.paperFormat
2998
+ if(k==='paperLanguage') return PAPER_LANGS.indexOf(String(v))!==-1?String(v):DEFAULT_PARAMS.paperLanguage
2999
+ // v2 §A5: `office` (the session root / human side) by default, or `resident:<id>`.
3000
+ if(k==='paperEditor'){ const s=String(v==null?'':v).trim(); return paperEditorOk(s)?s:DEFAULT_PARAMS.paperEditor }
3001
+ if(k==='paperLatexCommand'){ const s=String(v==null?'':v).trim(); return s }
2229
3002
  // A blank command would make resolveExecutable('') fail confusingly; fall back to the default.
2230
3003
  if(k==='leanCommand'){ const s=String(v==null?'':v).trim(); return s||'lean' }
2231
3004
  // A non-positive timeout is meaningless (the run would be killed instantly) → default.
@@ -2297,6 +3070,7 @@ export function apply(ctx) {
2297
3070
  // can ever clear those marks). resume()/start() re-initialize anyway; this keeps status truthful
2298
3071
  // between abort and the next action.
2299
3072
  meetingState=null; verifyState=null; pendingMeeting=null; pendingVerify=[]; busy=new Set(); wakeKind=new Map(); currentResident=''; finalizeLock=null
3073
+ paperState=null // an aborted run is over: its paper flow (if any) must not keep driving wakes
2300
3074
  await saveAll(); return {ok:true,message:'aborted'} }
2301
3075
  function setPause(){ clearHeartbeat(); running=false; return {ok:true,message:'paused'} }
2302
3076
 
@@ -2308,6 +3082,9 @@ export function apply(ctx) {
2308
3082
  rememberAgent, forgetAgent,
2309
3083
  setPause, initAbort, postMessage, startMeeting, saveAll, broadcast, configure, loadSettings,
2310
3084
  currentResident:()=>currentResident,
3085
+ // Final paper (docs/final-paper.md): the session API used by the `/v4 paper` command and by
3086
+ // the suites. `paperStatus` is the same view `status()`/`report()` expose.
3087
+ runPaperCommand, paperStatus:()=>paperStatusSummary(),
2311
3088
  /** Does this session own the given child id? (used by the `subagent/start` capture) */
2312
3089
  ownsChild,
2313
3090
  /** Stash a live Agent seen at `subagent/start` time; claimed by `spawnResident` on resolve. */
@@ -2431,7 +3208,7 @@ export function apply(ctx) {
2431
3208
  // MODE is dynamic — switching it changes the very next prompt), leanCommand/leanArgs select the
2432
3209
  // executable, leanTimeoutMs bounds one run. Invalid values fall back to the defaults and an
2433
3210
  // unknown mode degrades to 'off' (never to a STRONGER mode).
2434
- registerTool('vibe_v4_set','Set V4 parameters: residentCount (how many residents a start spawns), compactThreshold (resident context % that triggers a compaction) and compactAfterRounds (rounds between soft compactions), meetingKeepEvery (every N newly accumulated artifacts an automatic sync meeting is convened), maxParallel (how many residents may be woken concurrently), activityTimeoutMs (idle window before a resident is nudged; also the heartbeat/watchdog period), stallAutoMeetingMs (how long the group may make NO progress before an auto sync meeting is convened), verdictMaxRounds (how many verification rounds one object gets: 1 independent round + re-vote debate rounds), model/provider override resident LLM route (empty=inherit main); toolAllow/toolDeny restrict resident tools (arrays of tool names); residentPersona adds a persona line; formalVerify: "off" (default, a true no-op) | "encourage" (residents 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 unanimous true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record); leanCommand/leanArgs/leanTimeoutMs configure the toolchain. A key outside this list is refused (ok:false, ignored:[...]) instead of being silently dropped.',objParams({residentCount:{type:'integer'},compactAfterRounds:{type:'integer'},compactThreshold:{type:'integer'},meetingKeepEvery:{type:'integer'},maxParallel:{type:'integer'},activityTimeoutMs:{type:'integer'},verdictMaxRounds:{type:'integer'},stallAutoMeetingMs:{type:'integer'},provider:{type:'string'},model:{type:'string'},residentPersona:{type:'string'},toolAllow:{type:'array',items:{type:'string'}},toolDeny:{type:'array',items:{type:'string'}},formalVerify:{type:'string',enum:['off','encourage','require']},leanCommand:{type:'string'},leanArgs:{type:'array',items:{type:'string'}},leanTimeoutMs:{type:'integer'}}),(s,a)=>{ const r=s.setParams(a); const st=s.status(); if(r&&r.ok===false) return Object.assign({},st,{ok:false,warning:r.message,ignored:r.ignored}); return st })
3211
+ registerTool('vibe_v4_set','Set V4 parameters: residentCount (how many residents a start spawns), compactThreshold (resident context % that triggers a compaction) and compactAfterRounds (rounds between soft compactions), meetingKeepEvery (every N newly accumulated artifacts an automatic sync meeting is convened), maxParallel (how many residents may be woken concurrently), activityTimeoutMs (idle window before a resident is nudged; also the heartbeat/watchdog period), stallAutoMeetingMs (how long the group may make NO progress before an auto sync meeting is convened), verdictMaxRounds (how many verification rounds one object gets: 1 independent round + re-vote debate rounds), model/provider override resident LLM route (empty=inherit main); toolAllow/toolDeny restrict resident tools (arrays of tool names); residentPersona adds a persona line; formalVerify: "off" (default, a true no-op) | "encourage" (residents 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 unanimous true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record); leanCommand/leanArgs/leanTimeoutMs configure the toolchain; finalPaper (default true) writes the run\'s final PAPER automatically once the closing meeting votes unanimously to stop, paperFormat: "both" (default) | "md" | "tex" decides which text versions are produced, paperLanguage: "zh" (default) | "en", paperCompilePdf (default true) compiles a PDF when a LaTeX engine is actually detected (xelatex|latexmk|pdflatex|lualatex|tectonic, overridable via paperLatexCommand), paperEditor: "office" (default — the session root/human side) | "resident:<id>" names who finalises the merged draft after the cross-review round. A key outside this list is refused (ok:false, ignored:[...]) instead of being silently dropped.',objParams({residentCount:{type:'integer'},compactAfterRounds:{type:'integer'},compactThreshold:{type:'integer'},meetingKeepEvery:{type:'integer'},maxParallel:{type:'integer'},activityTimeoutMs:{type:'integer'},verdictMaxRounds:{type:'integer'},stallAutoMeetingMs:{type:'integer'},provider:{type:'string'},model:{type:'string'},residentPersona:{type:'string'},toolAllow:{type:'array',items:{type:'string'}},toolDeny:{type:'array',items:{type:'string'}},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'},paperEditor:{type:'string'},paperLatexCommand:{type:'string'}}),(s,a)=>{ const r=s.setParams(a); const st=s.status(); if(r&&r.ok===false) return Object.assign({},st,{ok:false,warning:r.message,ignored:r.ignored}); return st })
2435
3212
  // resident-facing tools: route to the CALLING resident (exec.agent.id === childId);
2436
3213
  // fall back to the last-woken resident when called by the host/assistant.
2437
3214
  registerTool('vibe_v4_send_message','(resident) Send a message to another resident (to=all broadcasts to the whole team).',objParams({to:{type:'string'},content:{type:'string'}},['to','content']),(s,a,x)=>{ const from=s.residentIdOf(x); if(!from) return {ok:false,message:'no such resident'}; if(String(a.to)==='all') return s.broadcast(a.content, from); return s.postMessage(from,a.to,a.content) })
@@ -2478,7 +3255,7 @@ export function apply(ctx) {
2478
3255
  // registration belongs to this fiber and must be unwound with it.
2479
3256
  ctx.effect(() => commands.register({
2480
3257
  name:'v4', description:'control the Vibe Math V4 framework',
2481
- input:{hint:'[configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set]'},
3258
+ input:{hint:'[configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|paper [lang=zh|en] [format=md|tex|both] [editor=office|resident:<id>] [force]|members|add|remove|set]'},
2482
3259
  handler: async function(inv){
2483
3260
  const s=getSession(inv&&inv.agent); if(!s) return {kind:'success',text:JSON.stringify({ok:false,error:'no session'})}
2484
3261
  const line=String(inv&&inv.rawInput?inv.rawInput:'').trim(); const parts=line.split(/\s+/); const cmd=parts[0]||''; const rest=parts.slice(1)
@@ -2519,7 +3296,15 @@ export function apply(ctx) {
2519
3296
  r=(res&&res.ok===false)?Object.assign({},st,{ok:false,warning:res.message,ignored:res.ignored}):Object.assign({},st,{applied:res.applied})
2520
3297
  }
2521
3298
  }
2522
- else r={ok:false,usage:'configure|start [problem]|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set <k=v>...'}
3299
+ else if(cmd==='paper'){
3300
+ // Manual trigger (docs/final-paper.md §2). `lang=` / `format=` / `editor=` override THIS invocation only;
3301
+ // a bare `force` rewrites an already-finalized paper. An unknown token becomes an unknown
3302
+ // OPTION and is rejected by runPaperCommand → kind:'error' (never a silent success).
3303
+ const o={}; let force=false
3304
+ for(const tok of rest){ const eq=tok.indexOf('='); if(eq>0) o[tok.slice(0,eq)]=tok.slice(eq+1); else if(tok==='force') force=true; else if(tok) o[tok]=true }
3305
+ r=await s.runPaperCommand(o,force)
3306
+ }
3307
+ else r={ok:false,usage:'configure|start [problem]|resume|pause|abort|status|report|message <to|all> <content>|meeting|paper [lang=zh|en] [format=md|tex|both] [editor=office|resident:<id>] [force]|members|add|remove|set <k=v>...'}
2523
3308
  // `kind:'error'` on a rejected business action, exactly as v2/v3 do: every branch above used to
2524
3309
  // report `success`, so the human's only non-tool control surface read as success on failure.
2525
3310
  const failed = !r || r.ok === false || (r.ok === undefined && !!r.error)