dsh-vibe-math 2.4.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.en.md +53 -14
  2. package/README.md +54 -14
  3. package/cordis.patch.yml +54 -17
  4. package/docs/AUDIT-CHECKLIST.md +26 -7
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/final-paper.md +171 -0
  7. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
  9. package/docs/test-timing.md +77 -40
  10. package/installer.js +225 -45
  11. package/package.json +6 -3
  12. package/preset-declaration.js +38 -7
  13. package/prompt-corpus-persona/persona-corpus.json +13 -13
  14. package/prompt-corpus-persona/persona-corpus.md +113 -39
  15. package/prompt-corpus-v2/formal-verify-v2.json +128 -92
  16. package/prompt-corpus-v2/formal-verify-v2.md +380 -142
  17. package/prompt-corpus-v3/formal-verify-v3.json +117 -81
  18. package/prompt-corpus-v3/formal-verify-v3.md +693 -1189
  19. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  20. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  21. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  22. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  23. package/tests/audit-installer-compat.test.mjs +70 -4
  24. package/tests/audit-installer-policy.test.mjs +229 -4
  25. package/tests/audit-persona-sensitivity.mjs +68 -8
  26. package/tests/audit-persona-surface.test.mjs +21 -8
  27. package/tests/audit-v5-sensitivity.mjs +47 -6
  28. package/tests/e2e-v5-round2.test.mjs +825 -1
  29. package/tests/formal-verify-v2.test.mjs +57 -2
  30. package/tests/formal-verify-v3.test.mjs +67 -2
  31. package/tests/formal-verify-v4.test.mjs +120 -4
  32. package/tests/prompt-v5-integrity.test.mjs +9 -3
  33. package/tests/run-tests.mjs +104 -23
  34. package/tests/selfdrive-v5.mjs +55 -4
  35. package/vibe-math-v2/agent.cordis.yml +20 -4
  36. package/vibe-math-v2/vibe-math-v2.js +1318 -106
  37. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  38. package/vibe-math-v3/agent.cordis.yml +36 -14
  39. package/vibe-math-v3/vibe-math-v3.js +1348 -179
  40. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  41. package/vibe-math-v4/agent.cordis.yml +36 -8
  42. package/vibe-math-v4/vibe-math-v4.js +1195 -57
  43. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  44. package/vibe-math-v5/agent.cordis.yml +16 -8
  45. package/vibe-math-v5/vibe-math-v5.js +1628 -112
  46. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +143 -41
@@ -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,10 +114,20 @@ 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)
108
125
  let lastActivityAt = now(), lastProgressAt = now(), artifactCount = 0, lastSyncMeetingAt = 0, persistedEpoch = '', heartbeatDisposer = null
126
+ // `artifactBaseline` = the artifact count observed when the auto-sync-meeting counter was last
127
+ // re-based (see `countArtifacts`/`syncArtifactCount`): the documented behaviour is "every N NEW
128
+ // artifacts", so the modulo test must run against the number of artifacts written SINCE the run
129
+ // started, not against the raw file count of a possibly pre-existing project tree.
130
+ let artifactBaseline = null
109
131
  const activityLogCap = 200
110
132
 
111
133
  // ---- utils ----
@@ -858,10 +880,21 @@ export function apply(ctx) {
858
880
  // `off` mode must not CREATE Lean state: a run that never used the feature leaves no
859
881
  // State/formal.json behind. Seeded/live records (or a mode that is on) still persist.
860
882
  if(formalOn()||formalPersisted||Object.keys(formal).length||formalTodos.length) await writeJson('State/formal.json', {records:formal,todo:formalTodos})
861
- await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount})
883
+ await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount,artifactBaseline,
884
+ // In-flight consensus must survive a restart too: a verification that had collected 3 of 4
885
+ // verdicts and a meeting that had collected half the speeches used to evaporate entirely
886
+ // (Shared/debates|meetings/<id>.md are only written by finalize*, so the partial debate was
887
+ // not even on disk) — and the queued proposals in pendingVerify were lost with it.
888
+ meetingState,verifyState,pendingVerify,pendingMeeting})
862
889
  }
863
890
  async function loadAll(){
864
- const s=await readJson('State/session.json'); if(s){ running=!!s.running; autoDone=!!s.autoDone; phase=s.phase||'idle'; problemId=s.problemId||problemId; problemText=s.problemText||problemText; runId=s.runId||runId; meetings=s.meetings||[]; reports=s.reports||[]; lastActivityAt=s.lastActivityAt||now(); lastProgressAt=s.lastProgressAt||now(); activityLog=s.activityLog||activityLog; persistedEpoch=s.processEpoch||''; artifactCount=s.artifactCount||0 }
891
+ const s=await readJson('State/session.json'); if(s){ running=!!s.running; autoDone=!!s.autoDone; phase=s.phase||'idle'; problemId=s.problemId||problemId; problemText=s.problemText||problemText; runId=s.runId||runId; meetings=s.meetings||[]; reports=s.reports||[]; lastActivityAt=s.lastActivityAt||now(); lastProgressAt=s.lastProgressAt||now(); activityLog=s.activityLog||activityLog; persistedEpoch=s.processEpoch||''; artifactCount=s.artifactCount||0; artifactBaseline=(s.artifactBaseline===undefined?null:Number(s.artifactBaseline))
892
+ // Restore the in-flight consensus (see saveAll). `resume()` refreshes the watchdog clocks right
893
+ // after this, so a run resumed after a crash gets a fresh stall window instead of being
894
+ // abandoned by the meeting/verify watchdog on its first serviced pass.
895
+ meetingState=s.meetingState||null; verifyState=s.verifyState||null
896
+ pendingVerify=Array.isArray(s.pendingVerify)?s.pendingVerify:[]
897
+ pendingMeeting=s.pendingMeeting||null }
865
898
  const rm=await readJson('State/residents.json'); if(rm&&typeof rm==='object') residents=new Map(Object.entries(rm))
866
899
  const mb=await readJson('State/mailboxes.json'); if(mb&&typeof mb==='object') mailboxes=new Map(Object.entries(mb))
867
900
  const tb=await readJson('State/taskboard.json'); if(Array.isArray(tb)) taskboard=tb
@@ -878,7 +911,12 @@ export function apply(ctx) {
878
911
 
879
912
  // ---- resident prompts ----
880
913
  function banner(){ const o=[]; for(const [id,r] of residents) o.push('- '+id+'「'+(r.direction||'(未定)')+'」'+r.status+'·轮'+r.rounds); return o.join('\n') }
881
- async function inboxText(rId){ const mb=mailboxes.get(rId)||[]; if(mb.length===0) return ' (no new messages)\n'; return mb.map(m=>' ['+m.from+'] '+m.content).join('\n')+'\n' }
914
+ // Who sent this? `facilitator` is the framework/human messenger (NOT a resident, not on the
915
+ // roster): a resident that reads a bare `[facilitator]` line tries to reply to it and gets
916
+ // `no such resident`. The definition travels WITH the frame, so it is present on the very wake
917
+ // that shows the message — not only in the once-only onboarding brief.
918
+ function senderLabel(from){ return String(from)==='facilitator'?'facilitator(框架/人类信使,不是常驻成员,不要向它回信;要回话请用本轮回执的 "input" 或 vibe_v4_send_message {to:"all"})':String(from) }
919
+ async function inboxText(rId){ const mb=mailboxes.get(rId)||[]; if(mb.length===0) return ' (no new messages)\n'; return mb.map(m=>' ['+senderLabel(m.from)+'] '+m.content).join('\n')+'\n' }
882
920
  function residentLibraries(){
883
921
  const base=frameworkRoot()
884
922
  return '你的资料库根目录:'+base+'/\n'
@@ -934,6 +972,7 @@ export function apply(ctx) {
934
972
  s.push('### 规则')
935
973
  s.push('- 只有 Verified/(或卡片标"已验证·真/假")算已确立;其余都是你的实验性工作,请区分"猜想/已知"。')
936
974
  s.push('- 验证必须**全组一致**(全真或全假);你只信全票结果。未全票的对象留在库里带概率。')
975
+ s.push('- `facilitator` 是**框架/人类介入的信使名**(不是常驻成员,不在编制里):它转达人类或框架的话,但**不要向它回信**(会返回 no such resident);要回话请用本轮回执的 "input" 字段(会转给全组)或 `vibe_v4_send_message {to:"all"}`。')
937
976
  s.push('- 你自己决定做什么,但**优先级/分工由团队讨论决定**,不是固定模式。若你认为问题已解决或接近解决,请**发起会议**让团队表决。')
938
977
  s.push('- 退出时**只**输出一个 JSON 对象(放在 ```json 代码围栏内;围栏外不要有文字)。')
939
978
  return s.join('\n')
@@ -947,6 +986,7 @@ export function apply(ctx) {
947
986
  // dynamic: after a /compact the resident must re-anchor on the rules it is actually
948
987
  // living under right now.
949
988
  return '[核心规则重申] 只有 Verified/(及标记"已验证·真/假")算已确立;验证须全组一致(全真或全假)才作数,否则留库附平均概率;你只写自己的库('+base+'/ 的 Progress/<你>/、Propos/<你>/、Methods/<你>/、Subproblems/<你>/),可只读任何人的库;任务分工由团队讨论决定;退出只输出一个 JSON 对象。'
989
+ +'\n`facilitator` 是**框架/人类介入的信使名**,不是常驻成员,也不在编制里——**不要向它回信**(`vibe_v4_send_message` 会返回 no such resident);要回话请用本轮回执的 "input" 字段(会转给全组)或 `vibe_v4_send_message {to:"all"}`。'
950
990
  +(formalOn()?('\n'+formalWorkLine()):'')
951
991
  }
952
992
  function brainstormPrompt(r){
@@ -975,11 +1015,10 @@ export function apply(ctx) {
975
1015
  const prior=Object.entries(st.inputs).filter(([k])=>k!==r.rId).map(([k,iv])=>' ['+k+'] '+String(iv.input||iv.summary||'')).join('\n')
976
1016
  return (params.residentPersona?params.residentPersona+'\n':'')
977
1017
  +'Resident '+r.rId+' — 团队会议进行中。 A meeting is in progress (agenda: '+st.agenda+').'
978
- +(st.type==='verify'?('\n团队正在验证对象:'+st.targetId+'('+st.targetType+',提出者 '+st.targetOwner+')。请先看他人意见,再给独立判断。'):'')
979
1018
  +'\n这是一场真实讨论:下面已有人发言(转给你),请先看,然后**加入讨论/补充/反驳/表决**。'
980
1019
  +(prior?('\n\n### 已有发言(他人 input,已转发给你)\n'+prior):'\n(目前还没人发言,你先说。)')
981
1020
  +'\n\n你可以:提议任务(propose_task)、认领开放任务(claim_task)、提议验证对象(propose_verify)、或对"原问题是否已解决"表决(voteSolved)。请把**你的实际发言**写进 "input"。'
982
- +'\nReply with ONLY a JSON object:\n'
1021
+ +'\n**停止表决必须是绝对票**:`voteSolved:true` 只表示你认为原问题**已解决**;只要有一名在册常驻没投 `true`(投 false、弃权、或漏写这个字段),run 就**不会**停止。不确定就投 false。' +'\nReply with ONLY a JSON object:\n'
983
1022
  +'{"input":"<your real contribution to this discussion>","propose_task":"<task title or null>","task_desc":"...","claim_task":"<task id or null>","propose_verify":"<id or null>","voteSolved":true}'
984
1023
  }
985
1024
  function verifyPrompt(r, vs){
@@ -990,9 +1029,19 @@ export function apply(ctx) {
990
1029
  const others=Object.entries(src).map(([k,v])=>'- '+k+': 正确概率 '+String(v.prob!=null?Number(v.prob).toFixed(2):0.5)+' → '+v.reason).join('\n')
991
1030
  const L=[]
992
1031
  L.push((params.residentPersona?params.residentPersona+'\n':'')
993
- +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+vs.targetType+',提出者 '+vs.targetOwner+')。\n'
1032
+ +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+targetTypeWord(vs.targetType)+',提出者 '+vs.targetOwner+')。\n'
994
1033
  +'请给出你对「该对象为真」的**正确概率 `verdict`**,仅一个 0–1 数值:**1 = 绝对为真,0 = 绝对为假,0.5 = 完全不确定,其余为介于其间的程度**(不要给 TRUE/FALSE,就给一个数值)。\n'
995
- +'判定规则:仅当**全体常驻一致给 1(都认为是真)或一致给 0(都认为是假)**,才按「真/假」写入 Verified/;否则**只作为概率数值(一种程度)保留在库中**,附全组平均正确概率,不写成真/假。\n'
1034
+ // ── THE VOTE CONTRACT (why the example below is 1 and not 0.9) ──────────────────────────
1035
+ // Only an EXACT 1 or 0 is a vote; anything strictly between is the model's honest
1036
+ // probability of truth, i.e. an ABSTENTION. The old text demonstrated `verdict:0.9` while
1037
+ // the code required `Number(prob)===1`, so a cautious-but-convinced group could never
1038
+ // converge: every round burned and the object stayed unverified forever. The rule is now
1039
+ // stated HERE, the example shows `1`, and 0.9 is explained instead of merely tolerated.
1040
+ +'**投票契约**:只有**恰好 1**(你认为是**绝对**为真)和**恰好 0**(你认为是**绝对**为假)算表决;**严格介于 0 与 1 之间**(例如 0.9、0.95、0.5)是**弃权**——它是你对"该对象为真"的**概率估计**,不是你的一票。\n'
1041
+ +' · 有把握认为它为真就投 **1**;不要为了"留一点余地"投 0.9——那会让全组永远无法定论。\n'
1042
+ +' · 弃权会被如实统计:本轮没有人全票 → 对象**不会**停止验证,而是把全组的**平均概率**写回它在库中的卡片(`- 概率:`),带概率继续留在库里。\n'
1043
+ +' · 弃权的两种合法用途:① 你确实不确定(用 0.5 附近的值表达);② 已归档的机器检查证明与命题原文不一致、你**不能**用 0 表达"命题为假"(此时请用 `formal` 回执的 `decision:"defect"` 报告偏差,见下方形式化段)。\n'
1044
+ +'判定规则:**仅当全体在册常驻都恰好给 1(都认为是真)、或都恰好给 0(都认为是假)**,才按「真/假」写入 Verified/ 并回写来源卡的「已验证·真/假」;否则**只按概率数值(一种程度)保留在库中**,附全组平均正确概率,不写成真/假。\n'
996
1045
  +'请给出你**诚实独立的判断**'
997
1046
  +(vs.stage==='debate'?',并参考他人意见:\n':'。\n')
998
1047
  +(vs.stage==='debate'&&others?('### 他人上一轮意见(已转发给你)\n'+others+'\n'):''))
@@ -1004,7 +1053,7 @@ export function apply(ctx) {
1004
1053
  }
1005
1054
  L.push('')
1006
1055
  L.push('Reply with ONLY a JSON object:')
1007
- L.push('{"vote":{"verdict":0.9,"reason":"<your logic>"}}')
1056
+ L.push('{"vote":{"verdict":1,"reason":"<your logic>"}}')
1008
1057
  if(formalOn()){
1009
1058
  // The formal field belongs in the VOTING contract too: voters are exactly the agents who
1010
1059
  // must either formalize the object or record why they judged it infeasible.
@@ -1068,6 +1117,33 @@ export function apply(ctx) {
1068
1117
 
1069
1118
  // ---- resident lifecycle ----
1070
1119
  let residentSeq = 0
1120
+ // Spawns that are currently IN FLIGHT, plus the `subagent/start` payloads seen while they were.
1121
+ //
1122
+ // ORDERING FACT (verified against the installed host, dsh-subagent 0.2.0-rc.2): `subagent/start`
1123
+ // is emitted SYNCHRONOUSLY from inside `startContinuable`'s await — `emit('subagent/start', …)` at
1124
+ // `dsh-subagent/lib/index.js:279` (and again from the lifecycle observer at `:306` once
1125
+ // `observer.start(handle.agent)` runs at `:1116`) — and `startContinuable` resolves only
1126
+ // afterwards. `spawnResident` therefore cannot know `started.childId` when the event fires, and
1127
+ // the payload carries no label (see `SubagentRunInfo`: runId/provider/id/local only), so the ONLY
1128
+ // information available at that moment is "a child of mine just started".
1129
+ //
1130
+ // So: the start handler STASHES the live Agent by child id (no ownership guess at all), and
1131
+ // `spawnResident` CLAIMS the record for its child id as soon as the call resolves — at which point
1132
+ // ownership is known exactly. `labelHints` bridges the (mock-ish) hosts that embed the label in
1133
+ // the id for the handler's own `ownsChild` check.
1134
+ const pendingSpawnLabels = new Set()
1135
+ const pendingStartAgents = new Map() // childId -> Agent, seen but not yet owned
1136
+ function stashStartAgent(childId, agent){
1137
+ if(!childId || !agent) return
1138
+ if(pendingStartAgents.size>=64) pendingStartAgents.delete(pendingStartAgents.keys().next().value)
1139
+ pendingStartAgents.set(String(childId), agent)
1140
+ }
1141
+ function claimStartAgent(childId){
1142
+ const id=String(childId==null?'':childId)
1143
+ const agent=pendingStartAgents.get(id)
1144
+ if(agent) pendingStartAgents.delete(id)
1145
+ return agent
1146
+ }
1071
1147
  function newResident(dir){ const rId='r-'+(++residentSeq); return {rId,childId:'',direction:dir||'',status:'brainstorm',rounds:0,roundsSinceCompact:0,lastActiveAt:now(),insight:'',contextPct:0,contextSeed:'',needCompact:false} }
1072
1148
  async function spawnResident(r){
1073
1149
  // Skip BEFORE touching the host when we already KNOW the ceiling and our own live children
@@ -1076,6 +1152,8 @@ export function apply(ctx) {
1076
1152
  if(hostChildLimit!==undefined && liveChildCount()>=hostChildLimit){ noteSpawnRefused(r, hostChildLimit); return false }
1077
1153
  const ao=residentAgentOptions(); const tf=residentToolFilter()
1078
1154
  let started
1155
+ // Register the label for the whole in-flight window: `subagent/start` fires inside this await.
1156
+ pendingSpawnLabels.add(r.rId)
1079
1157
  try {
1080
1158
  started=await startWithToolFilter(tf, function(f){ return {provider:pickProvider(),label:r.rId,request:{prompt:[textBlock(brainstormPrompt(r))],parent:rootAgent,agentOptions:ao,...(f?{toolFilter:f}:{})},signal:makeSignal(params.activityTimeoutMs||60000)} })
1081
1159
  } catch(e){
@@ -1085,17 +1163,46 @@ export function apply(ctx) {
1085
1163
  // failure keeps its previous behaviour and still propagates.
1086
1164
  if(!isActivationLimitReached(e)) throw e
1087
1165
  noteSpawnRefused(r, noteChildLimit(e)); return false
1088
- }
1166
+ } finally { pendingSpawnLabels.delete(r.rId) }
1167
+ // The child just started, so its `subagent/start` payload has already been seen (the host emits
1168
+ // it inside the await above). Claim the stashed Agent for it NOW — ownership is exact here — and
1169
+ // that is what makes the REAL `/compact` path reachable (see the `subagent/start` listener).
1170
+ const claimed=claimStartAgent(started.childId)
1171
+ if(claimed) rememberAgent(started.childId, claimed)
1089
1172
  r.childId=started.childId; r.status='brainstorm'; r.lastActiveAt=now()
1090
1173
  childOwner.set(started.childId,sessionId); busy.add(r.rId); wakeKind.set(r.rId,'normal'); currentResident=r.rId
1091
1174
  residents.set(r.rId,r); await saveAll(); logActivity('spawn',r.rId+' ('+(r.direction||'brainstorm')+')')
1092
1175
  return true
1093
1176
  }
1177
+ /**
1178
+ * Is `childId` one of THIS session's residents — including a spawn that is still IN FLIGHT?
1179
+ *
1180
+ * `spawnResident` sets `r.childId` (and `childOwner`) only after `startContinuable` resolves, but
1181
+ * the host emits `subagent/start` from inside that await. So "is this mine?" must also consult the
1182
+ * pending-spawn labels registered around the call; without that, the start handler rejects every
1183
+ * child of a fresh spawn and `liveAgents` never gets populated.
1184
+ */
1185
+ function ownsChild(childId){
1186
+ const id=String(childId)
1187
+ for(const [,r] of residents){ if(r.childId===id) return true }
1188
+ for(const r of pendingSpawns){ if(r.childId===id) return true }
1189
+ // During the spawn await the child's id is not recorded anywhere yet, but the framework chose
1190
+ // the spawn's LABEL and `spawnResident` registers it for exactly this window. A host id that
1191
+ // embeds the label (`child-r-1`, the common test/mock shape) is matched by containment; an
1192
+ // opaque host id cannot be matched here, which is why the label is only a
1193
+ // best-effort bridge in addition to the roster checks above.
1194
+ for(const label of pendingSpawnLabels){ if(id===label || id.indexOf(label)>=0) return true }
1195
+ return false
1196
+ }
1094
1197
  async function wakeResident(r, promptText, kind){
1095
1198
  if(!r || !r.childId) return false // a removed resident must never be woken (else r.childId would crash)
1096
1199
  clearHeartbeat()
1097
1200
  busy.add(r.rId); wakeKind.set(r.rId,kind||'normal'); currentResident=r.rId
1098
- r.lastActiveAt=now(); r.rounds+=1; r.roundsSinceCompact+=1
1201
+ // `rounds`/`roundsSinceCompact` are advanced only AFTER the send actually succeeded (below), so
1202
+ // a wake that never reached the child cannot consume a "round" of the compaction heuristic nor
1203
+ // inflate the round number the resident is told. `lastActiveAt` still moves here on purpose: a
1204
+ // failed wake must back the A-fill off for one activityTimeoutMs instead of hammering the child.
1205
+ r.lastActiveAt=now()
1099
1206
  // Context compaction has TWO distinct needs. Confusing them is the bug that made
1100
1207
  // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the start of nearly every prompt:
1101
1208
  // (a) r.needCompact (set by a REAL /compact) => the resident's rules may be blurred, so
@@ -1135,6 +1242,8 @@ export function apply(ctx) {
1135
1242
  } else {
1136
1243
  throw new Error('no subagent continuation API (need sendMessage or followup)')
1137
1244
  }
1245
+ // The turn is really in flight now: count it (F10 — a failed send must not consume a round).
1246
+ r.rounds+=1; r.roundsSinceCompact+=1
1138
1247
  return true
1139
1248
  }
1140
1249
  catch(e){ console.error('vibe-v4 wake '+r.rId+' failed: '+String((e&&e.message)||e)); busy.delete(r.rId); return false }
@@ -1146,18 +1255,74 @@ export function apply(ctx) {
1146
1255
  async function recordProposition(rId,o){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const id=o.id?idSafe(o.id):('p-'+shortId()); const lines=['# 命题|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 类型: 命题','- 状态: 未定论','- 概率: '+cl(o.prob!=null?o.prob:0.5),'- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'- 依赖: []','','## 陈述',String(o.statement||''),'','## 证明尝试','','## 证伪尝试','']; await writeText('Propos/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 命题 '+id); bumpArtifacts(); return {ok:true,id,file:'Propos/'+rId+'/'+id+'.md'} }
1147
1256
  async function recordMethod(rId,o){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const id=o.id?idSafe(o.id):('m-'+shortId()); const lines=['# 方法|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 类型: '+(o.type||'方法'),'- 状态: 经验','- 可信断言: []','- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'','## 核心内容',String(o.content||''),'','## 定义与记号',String(o.notation||''),'','## 应用记录','## 改进历史','']; await writeText('Methods/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 方法 '+id); bumpArtifacts(); return {ok:true,id,file:'Methods/'+rId+'/'+id+'.md'} }
1148
1257
  async function recordSubproblem(rId,o){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const id=o.id?idSafe(o.id):('s-'+shortId()); const lines=['# 子问题|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 状态: 求解中','- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'- 依赖: []','','## 陈述',String(o.statement||''),'','## 进度','']; await writeText('Subproblems/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 子问题 '+id); bumpArtifacts(); return {ok:true,id,file:'Subproblems/'+rId+'/'+id+'.md'} }
1149
- // auto-sync meeting: every meetingKeepEvery artifact records, convene a general coordination meeting.
1258
+ // ── auto-sync meeting: every `meetingKeepEvery` NEW artifacts, convene a coordination meeting ──
1259
+ //
1260
+ // The counting basis is the RESIDENTS' card libraries themselves (Propos/Methods/Subproblems),
1261
+ // NOT the three `record_*` convenience tools. The prompt tells residents verbatim that those tools
1262
+ // are optional and that they are recommended to write their own files directly with `fs`, so a
1263
+ // team that follows the recommendation never called `bumpArtifacts` and
1264
+ // `artifactCount % meetingKeepEvery === 0` could never fire — the documented "every N new
1265
+ // artifacts auto-sync" was UNREACHABLE in the very workflow the prompt recommends, leaving the
1266
+ // stall watchdog as the only path that ever convenes an auto-meeting.
1150
1267
  //
1151
- // ⚠ 已知设计缺口(有意保留,未修):计数口径**只有** record_* 三个便捷工具的调用。而提示词明确
1152
- // 告诉常驻:"vibe_v4_publish_progress/record_* 只是便捷记录器(可选;推荐直接用 fs 写自己的文件)",
1153
- // 所以一个完全按推荐方式(fs 直写)工作的团队不会让 artifactCount 增长,
1154
- // `artifactCount % meetingKeepEvery === 0` 永不成立 —— 文档承诺的"每积累 N 个新产物自动同步
1155
- // 一次"在推荐工作流下不可达(此时只有"停滞看门狗"那条时间触发路径会开会)。
1156
- // 试过把"完成的常驻轮次"也计入,但那会改变开会节奏,令 e2e-v4-fixes T4 与 selfdrive-v4 的
1157
- // 时序断言失败(两套测试都按当前节奏写死了预期)。这属于**设计参数取舍**,需要维护者决定:
1158
- // 要么改计数口径并同步调整测试预期,要么把"便捷记录器可选"的措辞改为"建议使用以便触发周期同步"。
1159
- function bumpArtifacts(){ artifactCount+=1; markProgress(); if(!meetingState && !verifyState && !pendingMeeting && Number(params.meetingKeepEvery)>0 && artifactCount % Number(params.meetingKeepEvery)===0){ startMeeting('定期同步:分工/进展/是否需要验证','general',null).catch(()=>{}) } }
1160
- function listResidents(){ return Array.from(residents.values()).map(r=>({id:r.rId,direction:r.direction,status:r.status,rounds:r.rounds,contextPct:r.contextPct,insight:r.insight?r.insight.slice(0,80):''})) }
1268
+ // Setting `artifactBaseline` also means a tile count of the correct sign, since residents write
1269
+ // the paths `Propos/<r>/<id>.md` explicitly.
1270
+ async function countArtifacts(){
1271
+ let n=0
1272
+ for(const base of ['Propos','Methods','Subproblems']){
1273
+ try {
1274
+ const t=await fs.resolve(base,{cwd:frameworkRoot()})
1275
+ if(await fs.stat(t)===undefined) continue
1276
+ const ents=await fs.listDir(t)
1277
+ for(const e of ents||[]){
1278
+ if(!e||e.type!=='directory') continue
1279
+ try {
1280
+ const dt=await fs.resolve(base+'/'+e.name,{cwd:frameworkRoot()})
1281
+ if(await fs.stat(dt)===undefined) continue
1282
+ const files=await fs.listDir(dt)
1283
+ for(const f of files||[]) if(f&&f.type==='file'&&/\.md$/.test(String(f.name))) n++
1284
+ } catch(e2){ /* a single unreadable library must not stop the scan */ }
1285
+ }
1286
+ } catch(e){ /* best-effort: an absent library counts as zero */ }
1287
+ }
1288
+ return n
1289
+ }
1290
+ /**
1291
+ * Re-base the artifact counter on what is on disk. Called from `start()` (a fresh run: the count
1292
+ * of NEW artifacts starts at 0 even if the project tree already held cards) and from
1293
+ * `scheduleNext` (the single choke point of every scheduling pass, right before the auto-meeting
1294
+ * test), so a group that writes its cards with `fs` still drives the documented cadence.
1295
+ * Costs no writes and never throws into the scheduler.
1296
+ */
1297
+ async function syncArtifactCount(){
1298
+ const n=await countArtifacts()
1299
+ if(artifactBaseline===null) artifactBaseline=n
1300
+ artifactCount=Math.max(artifactCount, n-(artifactBaseline||0))
1301
+ return artifactCount
1302
+ }
1303
+ /**
1304
+ * The `meetingKeepEvery` cadence: every N newly accumulated artifacts, convene ONE coordination
1305
+ * meeting. Called from BOTH paths that can observe the count:
1306
+ * · `bumpArtifacts` (a `record_*` convenience-tool call — the counter moves immediately), and
1307
+ * · `scheduleNext` right after `syncArtifactCount()` re-bases the counter on the files on disk,
1308
+ * which is the ONLY way an `fs`-written card can be seen.
1309
+ * Without the second call site the documented cadence is unreachable in the very workflow the
1310
+ * prompt recommends (`record_*` 可选;推荐直接用 fs 写自己的文件). `lastSyncMeetingAt` is reused as
1311
+ * the count at which the last sync meeting was convened, so the same crossing cannot fire twice.
1312
+ */
1313
+ function maybeArtifactSyncMeeting(){
1314
+ const every=Number(params.meetingKeepEvery)
1315
+ if(!(every>0)) return false
1316
+ if(meetingState || verifyState || pendingMeeting) return false
1317
+ if(!(artifactCount>0) || artifactCount % every !== 0) return false
1318
+ if(artifactCount<=lastSyncMeetingAt) return false // this crossing already convened a meeting
1319
+ lastSyncMeetingAt=artifactCount
1320
+ logActivity('meeting','定期同步触发:新增产物达到 '+every+' 的倍数(累计 '+artifactCount+')')
1321
+ startMeeting('定期同步:分工/进展/是否需要验证','general',null).catch(()=>{})
1322
+ return true
1323
+ }
1324
+ function bumpArtifacts(){ artifactCount+=1; markProgress(); maybeArtifactSyncMeeting() }
1325
+ function listResidents(){ return Array.from(residents.values()).map(r=>({id:r.rId,direction:r.direction,status:r.status,rounds:r.rounds,contextPct:r.contextPct,insight:r.insight?r.insight.slice(0,80):'',roundsSinceCompact:r.roundsSinceCompact||0,needCompact:!!r.needCompact,wakeKind:wakeKind.get(r.rId)||''})) }
1161
1326
  // identify WHICH resident is calling a resident-facing tool: match the caller's
1162
1327
  // subagent id to a resident's childId. Fall back to the last-woken resident when
1163
1328
  // the caller is the host/assistant (or an unknown agent). This makes per-resident
@@ -1171,17 +1336,28 @@ export function apply(ctx) {
1171
1336
  // wake the claimer to work on it (framework moves the task, resident decides how).
1172
1337
  // NOT while paused/stopped: a paused run must not start new work — the claim is recorded on the
1173
1338
  // board and the resident (who claimed it) picks it up again after resume.
1174
- const r=residents.get(claimer); if(r && !busy.has(claimer) && running && !autoDone){ currentResident=claimer; await wakeResident(r, (await normalPrompt(r))+'\n\n[YOU CLAIMED TASK '+id+'] '+t.title+' — '+t.description,'normal'); await saveAll() }
1339
+ const r=residents.get(claimer); if(r && !busy.has(claimer) && running && !autoDone){ currentResident=claimer; const ok=await wakeResident(r, (await normalPrompt(r))+'\n\n[YOU CLAIMED TASK '+id+'] '+t.title+' — '+t.description,'normal'); await saveAll(); if(!ok) armHeartbeat() }
1175
1340
  return {ok:true} }
1176
1341
  async function taskDone(id,claimer){ const t=taskboard.find(x=>x.id===id); if(!t) return {ok:false}; t.status='done'; t.doneBy=claimer; await saveTaskboard(); await writeTaskboard(); markProgress(); logActivity('task','done '+id); return {ok:true} }
1177
1342
  async function saveTaskboard(){ await writeJson('State/taskboard.json',taskboard); await writeTaskboard() }
1178
1343
  function listTasks(){ return taskboard.filter(t=>t.status!=='done') }
1179
1344
  async function reportContext(rId,pct){ const r=residents.get(rId); if(r){ r.contextPct=clPct(pct); if(Number(pct)<30) r.needCompact=false; } return {ok:true} }
1180
- // Apply context/compact bookkeeping from a resident's reply, so the flag can clear even when
1181
- // the reply came through a meeting/verify branch (defensive) as well as the normal branch.
1182
- function postmark(r, parsed){
1345
+ /**
1346
+ * Apply context/compact bookkeeping from a resident's reply.
1347
+ *
1348
+ * `kind` is the wake kind. It matters because the OLD version treated ANY non-empty `summary`
1349
+ * on ANY turn as the acknowledgement of a requested compaction: a meeting/verify reply that
1350
+ * happened to echo a summary (their contracts do not forbid it) cleared `needCompact`, zeroed
1351
+ * `roundsSinceCompact` and clamped `contextPct` to <=25 WITHOUT any compaction happening — so the
1352
+ * soft-compact trigger stayed below threshold and the compaction the flag was asking for was
1353
+ * never issued. Only a real normal research turn can acknowledge the soft directive it received
1354
+ * (`wakeResident` injects `[CONTEXT COMPACT …]` on normal rounds only); `parsed.compacted===true`
1355
+ * stays honoured on every kind, because that is the resident explicitly saying it condensed.
1356
+ */
1357
+ function postmark(r, parsed, kind){
1183
1358
  const cp=Number(parsed.contextPct); if(Number.isFinite(cp)) r.contextPct=clPct(cp) // tolerate numeric strings ("40")
1184
- if(parsed.compacted===true || (r.needCompact && parsed.summary)){
1359
+ const isNormalTurn=(kind||'normal')==='normal'
1360
+ if(parsed.compacted===true || (isNormalTurn && r.needCompact && parsed.summary)){
1185
1361
  r.contextSeed=String(parsed.summary||r.contextSeed||'')
1186
1362
  r.contextPct=Math.min(r.contextPct||15,25)
1187
1363
  r.roundsSinceCompact=0
@@ -1195,8 +1371,22 @@ export function apply(ctx) {
1195
1371
  const r=residents.get(to); if(!r) return {ok:false,message:'no such resident'}
1196
1372
  if(!busy.has(to)){
1197
1373
  currentResident=r.rId
1198
- await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
1199
- await saveAll(); markProgress(); logActivity('message',from+'→'+to); return {ok:true}
1374
+ const ok=await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
1375
+ await saveAll(); markProgress()
1376
+ if(ok){ logActivity('message',from+'→'+to); return {ok:true} }
1377
+ // The immediate wake FAILED (and `wakeResident` already cleared the `busy` mark, so no turn
1378
+ // actually started). Round C: the message must fall back to the MAILBOX instead of being
1379
+ // discarded — the old code logged "(wake failed; heartbeat re-armed)" and then dropped the
1380
+ // text, so the re-armed heartbeat had nothing left to do and the message was lost forever
1381
+ // while the recipient stayed cold (this is what the audit's F1 probe measured: it reported
1382
+ // "queued mail already consumed: false" but the mailbox was in fact already empty, so no
1383
+ // later pass could ever deliver anything). The mailbox is the one place a pass is guaranteed
1384
+ // to look again — including during `brainstorm`, see the delivery call at the top of
1385
+ // scheduleNext — and a successful retry marks the recipient busy and clears the queue.
1386
+ const mb=mailboxes.get(to)||[]; mb.push({from,at:now(),content}); mailboxes.set(to,mb)
1387
+ await saveAll(); armHeartbeat()
1388
+ logActivity('message',from+'→'+to+' (immediate wake failed; queued for retry)')
1389
+ return {ok:true}
1200
1390
  }
1201
1391
  const mb=mailboxes.get(to)||[]; mb.push({from,at:now(),content}); mailboxes.set(to,mb); await saveAll(); logActivity('message',from+'→'+to+' (queued)'); return {ok:true}
1202
1392
  }
@@ -1287,14 +1477,36 @@ export function apply(ctx) {
1287
1477
  if(iv.claim_task) await claimTask(iv.claim_task, id)
1288
1478
  if(iv.propose_verify) maybeQueueVerify(iv.propose_verify, id)
1289
1479
  }
1290
- const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
1291
- const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
1480
+ // ── the STOP vote must be UNANIMOUS, abstentions included ────────────────────────────────
1481
+ // `onResidentEnd` stores `voteSolved` only when it is a real boolean, otherwise `null`. The
1482
+ // old code filtered those nulls OUT and then asked `votes.every(v=>v===true)`, so a resident
1483
+ // that omitted the field (the meeting contract makes every key except `input` optional) or
1484
+ // wrote the string "true" simply dropped out of the electorate — and 3-of-4 voting true read
1485
+ // as "the whole team agrees the problem is solved" and STOPPED the run. `finalizeVerify` was
1486
+ // already written correctly (every resident must have a verdict); this mirrors it: every
1487
+ // speaker must have produced an explicit `true`.
1488
+ const speakers=Object.values(st.inputs)
1489
+ const allSolved = allSpoke && speakers.length>0 && speakers.every(iv=>iv.voteSolved===true)
1292
1490
  logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
1293
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:跳过最终论文,直接收口')
1294
1509
  running=false; autoDone=true; phase='done'; clearHeartbeat()
1295
- // symmetric with initAbort: the STOP path must also release the coordination state, else
1296
- // status keeps reporting a phantom in-progress meeting (and stale wake kinds) forever.
1297
- meetingState=null; wakeKind.clear(); pendingMeeting=null; verifyState=null; pendingVerify=[]
1298
1510
  logActivity('stop','all residents agree: problem solved'); await saveAll(); return
1299
1511
  }
1300
1512
  meetingState=null; wakeKind.clear(); await saveAll()
@@ -1369,14 +1581,18 @@ export function apply(ctx) {
1369
1581
  else await closeVerify(vs,allTrue)
1370
1582
  doSchedule=true
1371
1583
  }
1372
- else if(vs.round+1<params.verdictMaxRounds){
1584
+ else if(vs.round<params.verdictMaxRounds){
1585
+ // `verdictMaxRounds` is documented (README / 实现方案 / persona) as the maximum number of
1586
+ // verification rounds: 1 independent first vote + (verdictMaxRounds-1) debate re-votes. The
1587
+ // old `vs.round+1<verdictMaxRounds` gave one round FEWER than the name and the docs promise
1588
+ // (default 3 → only 2), so the parameter read as an off-by-one from every documented surface.
1373
1589
  // Move to a REAL debate round: snapshot the current votes into history (so the next round's
1374
1590
  // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
1375
1591
  // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
1376
1592
  // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
1377
1593
  vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
1378
1594
  vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
1379
- vs.stage='debate'; vs.round+=1; vs.asked=[]; logActivity('verify',vs.targetId+' round '+vs.round+' → debate (re-vote after seeing others)'); await saveAll(); doSchedule=true
1595
+ vs.stage='debate'; vs.round+=1; vs.asked=[]; logActivity('verify',vs.targetId+' round '+vs.round+'/'+params.verdictMaxRounds+' → debate (re-vote after seeing others)'); await saveAll(); doSchedule=true
1380
1596
  }
1381
1597
  else {
1382
1598
  const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
@@ -1440,13 +1656,25 @@ export function apply(ctx) {
1440
1656
  function maybeQueueVerify(target, proposer){
1441
1657
  const t=idSafe(target) // sanitize BEFORE it becomes file names / dedup keys / status output
1442
1658
  if(!t || t==='id') return false
1659
+ // The object KIND is derived from the id prefix, and it decides BOTH the directory the Verified
1660
+ // card lands in (Verified/命题/ vs Verified/问题/) and the `- 类型:` line of the source card's
1661
+ // write-back. The old fallback mapped EVERY unrecognised id to 'proposition', so proposing a
1662
+ // method/sub-problem whose id does not start with m-/s- (e.g. `2.1`, `lemma-A`) silently wrote a
1663
+ // card into Verified/命题/ carrying `类型: 命题` — a card whose location and declaration disagree.
1664
+ // The prefix IS the coordination contract (the prompt hands residents `p-`/`m-`/`s-`), so an id
1665
+ // that does not carry one is refused HERE, at the single queueing funnel, instead of guessing.
1666
+ const tt=guessTargetType(t)
1667
+ if(!tt){
1668
+ logActivity('verify',t+' 的提议被拒绝:对象 id 必须以 p-(命题)/ m-(方法)/ s-(子问题)开头,否则框架无法确定它属于哪个库(V4_INVALID_ARGUMENT)')
1669
+ return false
1670
+ }
1443
1671
  const last=verifiedRecently.get(t)
1444
1672
  if(last!==undefined && (now()-last) < recoverStallMs()){
1445
1673
  logActivity('verify',t+' re-propose ignored (just verified at '+fmtTime(last)+')')
1446
1674
  return false
1447
1675
  }
1448
1676
  if(pendingVerify.some(p=>String(p.targetId)===t)) return true // already queued → keep ONE entry
1449
- pendingVerify.push({targetId:t,targetType:guessTargetType(t),proposer:proposer||'',at:now()})
1677
+ pendingVerify.push({targetId:t,targetType:tt,proposer:proposer||'',at:now()})
1450
1678
  return true
1451
1679
  }
1452
1680
  async function writeDebateDoc(vs,done,val){
@@ -1532,7 +1760,11 @@ export function apply(ctx) {
1532
1760
  next=rewriteCardField(next,'概率',isTrue?'1':'0')
1533
1761
  await writeText(rel,next||text)
1534
1762
  }
1535
- function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return 'proposition' }
1763
+ function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return '' }
1764
+ // Human-readable word for the target kind, used in the prompts. Never print the RAW internal
1765
+ // token: the voter is a model, and `proposition`/`method`/`subproblem` in an otherwise Chinese
1766
+ // contract is exactly the kind of untranslated placeholder these audits look for.
1767
+ function targetTypeWord(t){ return t==='method'?'方法':(t==='subproblem'?'子问题':'命题') }
1536
1768
 
1537
1769
  // ---- heartbeat / liveness helpers (boundary-A: event-driven + gated heartbeat) ----
1538
1770
  // A checkpoint wake is NOT "keep working forever": it nudges the least-recently-active
@@ -1648,11 +1880,35 @@ export function apply(ctx) {
1648
1880
 
1649
1881
  // ---- liveness / scheduling ----
1650
1882
  async function scheduleNext(){
1651
- 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
+ }
1652
1896
  // Residents the host's live-child cap refused are retried here — the single choke point every
1653
1897
  // scheduling pass goes through — so a refused spawn is deferred by a whole round (and paced by
1654
1898
  // the heartbeat) instead of being retried inside the loop that discovered the refusal.
1655
1899
  if(pendingSpawns.length) await retryPendingSpawns()
1900
+ // Queued mail MUST also be delivered while the group is still brainstorming (Round C, F1).
1901
+ // A human/team message is not "work": the brainstorm phase used to return at the next line
1902
+ // before ever reaching the delivery point below, so a wake that FAILED left the message
1903
+ // re-queued and every subsequent heartbeat firing a no-op (`maybeFinishBrainstorm` only flips
1904
+ // the phase once every resident has an insight). The scheduler then stayed cold with mail
1905
+ // still queued — the exact permanent stall F1 exists to remove, narrowed to this phase
1906
+ // (_oneoff/probe-v4-mailbox-stall.mjs used to print REPRODUCED here).
1907
+ // Deliberately conditioned on "no consensus in progress": continueMeetingRound /
1908
+ // continueVerifyRound refuse to flush mailboxes on purpose (a backlog must not starve a
1909
+ // consensus past its watchdog, see their comments), so a parked meeting/verify keeps that
1910
+ // guarantee and the mail goes out on the first pass after the consensus closes.
1911
+ if(!meetingState && !verifyState){ const delivered=await deliverNextMailbox(); if(delivered) return }
1656
1912
  if(phase==='brainstorm'){ await maybeFinishBrainstorm(); return }
1657
1913
  if(meetingState){ await continueMeetingRound(); return }
1658
1914
  if(verifyState){ await continueVerifyRound(); return }
@@ -1660,11 +1916,24 @@ export function apply(ctx) {
1660
1916
  // A meeting requested while a verify held the floor is parked in pendingMeeting; once the
1661
1917
  // verify queue has truly drained (no verifyState / pendingVerify), resume it before anything else.
1662
1918
  if(pendingMeeting){ const pm=pendingMeeting; pendingMeeting=null; await startMeeting(pm.agenda, pm.type, pm.targetId); return }
1663
- // mailbox delivery
1919
+ // mailbox delivery. `wakeResident` CLEARS the heartbeat as its first act, so a pass that delivers
1920
+ // NOTHING must not return without re-arming one: a rejected `sendMessage`
1921
+ // (subagent/delivery-unavailable, a cold-resumed child whose Activation closed, a transient
1922
+ // persistence failure) re-queues the message, refreshes the recipient's lastActiveAt (so the fill
1923
+ // loop below skips it for a whole activityTimeoutMs) and used to end the pass with no wake, no
1924
+ // heartbeat and no in-flight turn — nothing else could ever re-drive the scheduler, so the whole
1925
+ // group stopped permanently with mail still queued. Falling through instead lets the branches
1926
+ // below re-arm (and B/A still get their chance); on a SUCCESSFUL delivery the recipient's own
1927
+ // subagent/end re-drives the pump, so the pass can end here as before.
1664
1928
  const delivered=await deliverNextMailbox(); if(delivered) return
1665
1929
  // maxParallel: don't start a new wake when the in-flight cap is reached
1666
1930
  const mp=Number(params.maxParallel)||0
1667
1931
  if(mp>0 && busy.size>=mp){ armHeartbeat(); return }
1932
+ // Refresh the artifact counter from the residents' own card libraries (fs-written cards count
1933
+ // too) before the auto-sync test below, so `meetingKeepEvery` measures NEW artifacts — and
1934
+ // convene the sync meeting here, because this is the only place an `fs`-written card is seen.
1935
+ await syncArtifactCount()
1936
+ if(maybeArtifactSyncMeeting()) return
1668
1937
  // B) stall auto-sync meeting (分级保活 B): the group has been idle with NO progress for
1669
1938
  // stallAutoMeetingMs → convene a sync meeting so the residents coordinate their next move
1670
1939
  // (framework convenes & records; residents decide — never assigns work). Only when no
@@ -1763,8 +2032,12 @@ export function apply(ctx) {
1763
2032
  realCompact(r).catch(()=>{}) // best-effort real DSH /compact of this resident while idle
1764
2033
  const output=blocksToText(info&&info.lastAssistantMessage)
1765
2034
  const parsed=parseReply(output)
1766
- postmark(r, parsed) // context/compact bookkeeping, regardless of wake kind (clears any leak)
1767
2035
  const kind=wakeKind.get(r.rId)||'normal'
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 }
1768
2041
  if(kind==='meeting' && meetingState){
1769
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}
1770
2043
  meetingState.lastInputAt=now()
@@ -1822,6 +2095,714 @@ export function apply(ctx) {
1822
2095
  await saveAll(); await scheduleNext()
1823
2096
  }
1824
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
+
1825
2806
  // ---- controls ----
1826
2807
  async function start({problem,residentCount,seedDirections}){
1827
2808
  await loadSettings()
@@ -1843,6 +2824,10 @@ export function apply(ctx) {
1843
2824
  // carrying a previous run's records over would let a stale `passed` open the new gate.
1844
2825
  formal={}; formalTodos=[]
1845
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>/)
2828
+ // Re-base the artifact counter on the cards already on disk, so the auto-sync meeting counts
2829
+ // artifacts written by THIS run (the project tree may already hold cards from an earlier run).
2830
+ artifactBaseline=null; artifactCount=0; await syncArtifactCount()
1846
2831
  lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
1847
2832
  const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
1848
2833
  pendingSpawns=[] // a fresh run replaces the roster: nothing is queued from before
@@ -1883,7 +2868,15 @@ export function apply(ctx) {
1883
2868
  // If the persisted State came from a DIFFERENT process (crash/restart), the saved
1884
2869
  // childIds are stale; clear them so residents re-spawn (their libraries persist on
1885
2870
  // disk and re-seed the resumed run). Same-process pause→resume keeps continuable ids.
2871
+ //
2872
+ // `persistedEpoch` is only ever READ here, so before this fix a same-process pause→resume
2873
+ // compared this process's random epoch against the epoch saved by ITSELF and always concluded
2874
+ // "cross-process": every resident was re-spawned, `busy`/`wakeKind` were thrown away while the
2875
+ // OLD children were still running (`resume()` never interrupts them, so the late
2876
+ // `subagent/end` that the code's own comment warns about was guaranteed, not hypothetical).
2877
+ // Re-sync it on a same-process load, which is the contract `paused` already assumes.
1886
2878
  const crossProcess = persistedEpoch !== processEpoch
2879
+ if(!crossProcess) persistedEpoch = processEpoch
1887
2880
  if(crossProcess){ for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.roundsSinceCompact=0 } }
1888
2881
  // ANY re-spawn (cross-process OR a same-process abort that already cleared childIds) must get a FRESH
1889
2882
  // coordination/concurrency state and a brainstorm phase. Otherwise: re-spawned brainstorm residents run
@@ -1913,10 +2906,20 @@ export function apply(ctx) {
1913
2906
  pendingSpawns: pendingSpawns.length, hostChildLimit: (hostChildLimit===undefined?null:hostChildLimit),
1914
2907
  meetingInProgress: !!(meetingState), verifyInProgress: !!(verifyState), pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null, pendingVerifyCount: pendingVerify.length,
1915
2908
  parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
2909
+ // The coordination counters (`spoke k/N`, `voted k/N`, the verification round) used to live only
2910
+ // in `report()`: from `status()` alone an operator could not tell "the group is progressing"
2911
+ // from "the group is stuck at 2/4 votes", which is exactly how F1/F2/F7 stay invisible.
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}
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),
2914
+ artifactCount: artifactCount, artifactBaseline: artifactBaseline,
2915
+ paper: paperStatusSummary(),
1916
2916
  // The Lean knobs and the per-object formal records are part of the readable status: without
1917
2917
  // them a `require`-mode run that keeps returning 未定论 would be undiagnosable from outside.
1918
2918
  formal: formalView(),
1919
- 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(', ') } }
2919
+ // The parameter NAMES (not just the rendered string) so a caller — the `/v4 set` handler in
2920
+ // particular — can reject an unknown key instead of silently dropping it.
2921
+ paramsKeys: Object.keys(params),
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(', ') } }
1920
2923
  function formalReportText(){
1921
2924
  if(!formalOn()) return '- 未启用(`formalVerify` = off;可用 vibe_v4_set 切到 encourage / require)'
1922
2925
  const v=formalView()
@@ -1933,6 +2936,7 @@ export function apply(ctx) {
1933
2936
  pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null,
1934
2937
  parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
1935
2938
  formal: formalView(),
2939
+ paper: paperStatusSummary(),
1936
2940
  meetings:meetings.length, recentActivity: activityLog.slice(-8) } }
1937
2941
  /** Human-readable mirror of the formal state (kept OUT of the JSON report shape). */
1938
2942
  function formalReport(){ return {ok:true, formalMode:formalMode(), formalReport:'## Lean 形式化\n'+formalReportText(), formal:formalView()} }
@@ -1980,6 +2984,21 @@ export function apply(ctx) {
1980
2984
  // Degrading to a STRONGER mode would let a typo silently gate every conclusion — the exact
1981
2985
  // failure mode `require` is supposed to avoid.
1982
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 }
1983
3002
  // A blank command would make resolveExecutable('') fail confusingly; fall back to the default.
1984
3003
  if(k==='leanCommand'){ const s=String(v==null?'':v).trim(); return s||'lean' }
1985
3004
  // A non-positive timeout is meaningless (the run would be killed instantly) → default.
@@ -2003,9 +3022,30 @@ export function apply(ctx) {
2003
3022
  if(min===undefined) return n
2004
3023
  return n < min ? min : n
2005
3024
  }
2006
- function setParams(upd){ for(const k of Object.keys(upd||{})){ if(k in params){ const nv=normalizeParam(k, upd[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } saveSettings().catch(()=>{}); return {ok:true} }
3025
+ /**
3026
+ * Apply a parameter update. Keys the parameter layer does not know are NOT silently dropped: the
3027
+ * caller gets them back (`ok:false` + `ignored`), because `{ok:true}` for a call that changed
3028
+ * nothing is the "declared but not received" silent failure AUDIT-CHECKLIST §1.9 names.
3029
+ */
3030
+ function setParams(upd){
3031
+ const keys=Object.keys(upd||{})
3032
+ // `k in params` 会沿原型链判真:`toString` / `constructor` / `__proto__` 这类键会被当成"已知参数"
3033
+ // 写进 params(`params['__proto__']=…` 还会真的改掉 params 的原型),而 schema 的
3034
+ // additionalProperties:false 在 v4 上**不产生运行期拒绝**(裸 tools.register,宿主不校验实参)。
3035
+ // 已知参数一律以**自有属性**为准(AUDIT-CHECKLIST §1.9 的"声明=接收"只能在自有键上成立)。
3036
+ const has = (o,k)=>Object.prototype.hasOwnProperty.call(o,k)
3037
+ const ignored=keys.filter(k=>!has(params,k))
3038
+ for(const k of keys){ if(has(params,k)){ const nv=normalizeParam(k, upd[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } }
3039
+ saveSettings().catch(()=>{})
3040
+ if(ignored.length){
3041
+ logActivity('set','忽略未知参数:'+ignored.join(', '))
3042
+ return {ok:false,message:'unknown parameter(s): '+ignored.join(', ')+' — nothing was changed'+(ignored.length<keys.length?('(已应用:'+keys.filter(k=>!(ignored.indexOf(k)>=0)).join(', ')+')'):''),ignored,applied:keys.filter(k=>!(ignored.indexOf(k)>=0))}
3043
+ }
3044
+ return {ok:true,applied:keys}
3045
+ }
2007
3046
  // ---- create / configure (no auto-start) + settings-file persistence ----
2008
- async function loadSettings(){ const s=await readJson('State/settings.json'); if(s&&typeof s==='object'){ for(const k of Object.keys(s)){ if(k in params){ const nv=normalizeParam(k, s[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } } }
3047
+ // 同一把守护:State/settings.json 是用户可编辑的,原型链上的键名(toString/__proto__)不是参数。
3048
+ async function loadSettings(){ const s=await readJson('State/settings.json'); if(s&&typeof s==='object'){ for(const k of Object.keys(s)){ if(Object.prototype.hasOwnProperty.call(params,k)){ const nv=normalizeParam(k, s[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } } }
2009
3049
  async function saveSettings(){ await writeJson('State/settings.json', params) }
2010
3050
  // Create/configure a project and set params/problem WITHOUT starting any resident.
2011
3051
  // The intended flow: vibe_v4_configure {project?, problem?, params?} → vibe_v4_start {}.
@@ -2030,6 +3070,7 @@ export function apply(ctx) {
2030
3070
  // can ever clear those marks). resume()/start() re-initialize anyway; this keeps status truthful
2031
3071
  // between abort and the next action.
2032
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
2033
3074
  await saveAll(); return {ok:true,message:'aborted'} }
2034
3075
  function setPause(){ clearHeartbeat(); running=false; return {ok:true,message:'paused'} }
2035
3076
 
@@ -2041,10 +3082,30 @@ export function apply(ctx) {
2041
3082
  rememberAgent, forgetAgent,
2042
3083
  setPause, initAbort, postMessage, startMeeting, saveAll, broadcast, configure, loadSettings,
2043
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(),
3088
+ /** Does this session own the given child id? (used by the `subagent/start` capture) */
3089
+ ownsChild,
3090
+ /** Stash a live Agent seen at `subagent/start` time; claimed by `spawnResident` on resolve. */
3091
+ stashStartAgent,
2044
3092
  // safety kick: drive one scheduler pass (used when an end handler errored, so an exceptional
2045
3093
  // turn can never leave the group with no end-event and no heartbeat to continue it)
2046
3094
  nudge:()=>scheduleNext().catch(()=>{}),
2047
- residentIdOf:(agent)=>{ const m=residentOfAgent(agent); if(m) return m; const c=currentResident; return (c && residents.has(c)) ? c : '' },
3095
+ /**
3096
+ * Which resident is calling a resident-facing tool?
3097
+ * 1. the caller's subagent id matched against a resident's childId — the exact answer;
3098
+ * 2. the last-woken resident, but ONLY when no caller identity exists at all (the mock/harness
3099
+ * path, where `exec.agent` is undefined).
3100
+ * An agent that IS present but matches no resident is NOT guessed at: attributing a host call to
3101
+ * whichever resident happened to be woken last writes into that resident's private library with
3102
+ * no indication that the attribution was invented (AUDIT-CHECKLIST §0.2: identity is never
3103
+ * inferred from global mutable state). Such a call now fails explicitly with 'no such resident'.
3104
+ */
3105
+ residentIdOf:(agent)=>{ const m=residentOfAgent(agent); if(m) return m
3106
+ let hasId=false; try { hasId=!!(agent&&agent.id) } catch(e){ hasId=false }
3107
+ if(hasId) return ''
3108
+ const c=currentResident; return (c && residents.has(c)) ? c : '' },
2048
3109
  useResident:(id)=>{ currentResident=id },
2049
3110
  publishProgress, recordProposition, recordMethod, recordSubproblem, listResidents, reportContext,
2050
3111
  proposeTask, claimTask, taskDone, listTasks,
@@ -2071,6 +3132,12 @@ export function apply(ctx) {
2071
3132
  // `vs` mirrors the live verification state ({targetId,targetType,targetOwner,stage,history,verdicts});
2072
3133
  // pass one explicitly to ask "what WOULD the voters read for this object right now?".
2073
3134
  verify:(rId,vs)=>{ const r=residents.get(String(rId)); return r?verifyPrompt(r, vs||verifyState||{targetId:'',targetType:'proposition',targetOwner:'',stage:'independent',verdicts:{}}):'' },
3135
+ // The meeting prompt is the ONLY group-chat / task-allocation / stop-vote surface this preset
3136
+ // has, so a host (or the audit corpus) must be able to READ it. Pass `st` to ask "what would
3137
+ // the next speaker read right now?"; the live meeting state is the default.
3138
+ meeting:(rId,st)=>{ const r=residents.get(String(rId)); if(!r) return ''
3139
+ const s=st||meetingState||{agenda:'(无进行中的会议)',type:'general',inputs:{},order:[]}
3140
+ return meetingPrompt(r,s) },
2074
3141
  formalBlock:(target)=>formalPromptBlock(target),
2075
3142
  formalWorkLine:()=>formalWorkLine(),
2076
3143
  },
@@ -2081,9 +3148,15 @@ export function apply(ctx) {
2081
3148
  if(which==='brainstorm') return brainstormPrompt(r)
2082
3149
  if(which==='heartbeat') return heartbeatPrompt(r)
2083
3150
  if(which==='coreRules') return coreRulesBrief()
3151
+ if(which==='meeting') return (arg&&arg.meeting)?meetingPrompt(r,arg.meeting):(meetingState?meetingPrompt(r,meetingState):meetingPrompt(r,{agenda:'(无进行中的会议)',type:'general',inputs:{},order:[]}))
2084
3152
  if(which==='verify'){
2085
3153
  const target=idSafe(String((arg&&arg.target)||''))
2086
- return verifyPrompt(r,{targetId:target,targetType:guessTargetType(target),targetOwner:'',stage:String((arg&&arg.stage)||'independent'),history:{},verdicts:{}})
3154
+ // F14 的同一条尺子:前缀(p-/m-/s-)就是对象归属的契约。此前的兜底把**任何**认不出的 id
3155
+ // 都当成 proposition,于是这个"读真实提示词"的工具会展示一条带着错误对象类型的投票提示词
3156
+ // (而真正入队的入口 maybeQueueVerify 已经拒绝这种 id)。认不出就如实拒绝,不猜。
3157
+ const tt=guessTargetType(target)
3158
+ if(!tt) return 'V4_INVALID_ARGUMENT: target must start with p- (proposition) / m- (method) / s- (subproblem) — cannot render a verification prompt for an unknown object kind'
3159
+ return verifyPrompt(r,{targetId:target,targetType:tt,targetOwner:'',stage:String((arg&&arg.stage)||'independent'),history:{},verdicts:{}})
2087
3160
  }
2088
3161
  return normalPrompt(r)
2089
3162
  },
@@ -2117,7 +3190,7 @@ export function apply(ctx) {
2117
3190
  // The exact text a resident would receive. "成员读到的文字就是产品" (AUDIT-CHECKLIST §0.1): a host
2118
3191
  // (or an audit) must be able to READ the prompt, not just the tool return values, or a prompt
2119
3192
  // defect stays invisible. Pure builder calls — no side effects on the run.
2120
- registerTool('vibe_v4_prompts','Read the exact prompt text a resident would receive (which: brainstorm|normal|heartbeat|verify|coreRules). member = resident id; target/stage describe the object for `verify`. Prompt text is the product — this makes it auditable.',objParams({which:{type:'string',enum:['brainstorm','normal','heartbeat','verify','coreRules']},member:{type:'string'},target:{type:'string'},stage:{type:'string'}},['which']),async (s,a)=>{
3193
+ registerTool('vibe_v4_prompts','Read the exact prompt text a resident would receive (which: brainstorm|normal|heartbeat|meeting|verify|coreRules). member = resident id; target/stage describe the object for `verify`. Prompt text is the product — this makes it auditable.',objParams({which:{type:'string',enum:['brainstorm','normal','heartbeat','meeting','verify','coreRules']},member:{type:'string'},target:{type:'string'},stage:{type:'string'}},['which']),async (s,a)=>{
2121
3194
  const which=String(a.which||'normal')
2122
3195
  if(which==='coreRules') return {ok:true,which,text:await s.promptApi.coreRules()}
2123
3196
  const text=await s.promptFor(which,String(a.member||'r-1'),a)
@@ -2135,7 +3208,7 @@ export function apply(ctx) {
2135
3208
  // MODE is dynamic — switching it changes the very next prompt), leanCommand/leanArgs select the
2136
3209
  // executable, leanTimeoutMs bounds one run. Invalid values fall back to the defaults and an
2137
3210
  // unknown mode degrades to 'off' (never to a STRONGER mode).
2138
- registerTool('vibe_v4_set','Set V4 parameters. 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.',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)=>{ s.setParams(a); return s.status() })
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 })
2139
3212
  // resident-facing tools: route to the CALLING resident (exec.agent.id === childId);
2140
3213
  // fall back to the last-woken resident when called by the host/assistant.
2141
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) })
@@ -2152,8 +3225,16 @@ export function apply(ctx) {
2152
3225
  registerTool('vibe_v4_list_tasks','(resident) List open tasks.',objParams({}),(s)=>({ok:true,tasks:s.listTasks()}))
2153
3226
  // context / compact (resident reports its context usage so the framework can /compact-equivalent)
2154
3227
  registerTool('vibe_v4_report_context','(resident) Report your context usage %; the framework compacts (self-summary) when it reaches compactThreshold.',objParams({pct:{type:'number'}},['pct']),(s,a,x)=>s.reportContext(s.residentIdOf(x),a.pct))
2155
- registerTool('vibe_v4_claim_write','Reserved: shared-file write lock (framework-managed).',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
2156
- registerTool('vibe_v4_release_write','Reserved: shared-file write lock release.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
3228
+ // NOT IMPLEMENTED — and the description says so. These two tools were registered with
3229
+ // "Reserved: shared-file write lock", which reads as "the lock exists and works": a model following
3230
+ // the v3 convention called `claim_write`, got `{ok:true}` and concluded it held a lock that no code
3231
+ // anywhere implements (this preset has no shared file a resident writes — see the write-scope rule
3232
+ // in `contextBrief`, which restricts every resident to its OWN Progress/Propos/Methods/Subproblems
3233
+ // library, while the framework alone writes Shared/ and State/). They stay registered (removing them
3234
+ // would change the model-visible tool surface) and keep echoing the key, but they now tell the
3235
+ // truth: nothing is reserved, nothing is serialized, write only your own files.
3236
+ registerTool('vibe_v4_claim_write','NOT IMPLEMENTED: no lock exists. This returns {ok:true} without reserving anything. Each resident writes only its own library (Progress/<you>/, Propos/<you>/, Methods/<you>/, Subproblems/<you>/), so overlapping writers are not expected; the framework alone writes Shared/ and State/ (serially, per file). Do not rely on this to exclude another resident.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target,locked:false,note:'no lock is implemented; write only your own library files'}))
3237
+ registerTool('vibe_v4_release_write','NOT IMPLEMENTED: there is no lock to release. Returns {ok:true} as a no-op so an agent that calls it out of habit is not misled into thinking it held a reservation.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target,locked:false,note:'no lock is implemented; nothing was reserved'}))
2157
3238
 
2158
3239
  // ── Lean formal verification (docs/formal-verification.md §5) ─────────────
2159
3240
  // These three tools are registered UNCONDITIONALLY. Registration is STATIC (a mode-dependent
@@ -2174,13 +3255,16 @@ export function apply(ctx) {
2174
3255
  // registration belongs to this fiber and must be unwound with it.
2175
3256
  ctx.effect(() => commands.register({
2176
3257
  name:'v4', description:'control the Vibe Math V4 framework',
2177
- 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]'},
2178
3259
  handler: async function(inv){
2179
3260
  const s=getSession(inv&&inv.agent); if(!s) return {kind:'success',text:JSON.stringify({ok:false,error:'no session'})}
2180
3261
  const line=String(inv&&inv.rawInput?inv.rawInput:'').trim(); const parts=line.split(/\s+/); const cmd=parts[0]||''; const rest=parts.slice(1)
2181
3262
  let r
2182
3263
  if(cmd==='configure') r=await s.configure({project:rest[0]||'', problem:parts.slice(2).join(' ')})
2183
- else if(cmd==='start') r=await s.start({})
3264
+ // `/v4 start <problem>` is the natural way to try this out and the tool it mirrors accepts a
3265
+ // problem, so the arguments are honoured instead of silently dropped (a bare `/v4 start` keeps
3266
+ // using the configured problem).
3267
+ else if(cmd==='start') r=await s.start(rest.length?{problem:rest.join(' ')}:{})
2184
3268
  else if(cmd==='resume') r=await s.resume()
2185
3269
  else if(cmd==='pause') r=s.setPause()
2186
3270
  else if(cmd==='abort') r=await s.initAbort()
@@ -2191,9 +3275,40 @@ export function apply(ctx) {
2191
3275
  else if(cmd==='members') r={ok:true,residents:s.listResidents()}
2192
3276
  else if(cmd==='add') r=await s.addMember(rest.join(' '))
2193
3277
  else if(cmd==='remove') r=await s.removeMember(rest[0]||'')
2194
- else if(cmd==='set'){ const upd={}; for(const tok of rest){ const eq=tok.indexOf('='); if(eq>0){ const k=tok.slice(0,eq); const rv=tok.slice(eq+1); const n=Number(rv); upd[k]=Number.isFinite(n)?n:rv } } r=s.setParams(upd) }
2195
- else r={ok:false,usage:'configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set'}
2196
- return {kind:'success',text:JSON.stringify(r,null,2)}
3278
+ else if(cmd==='set'){
3279
+ // Keys are validated HERE and the value is handed to the same `normalizeParam` the tool path
3280
+ // uses, so `/v4 set` cannot (a) coerce `provider=123` into a number for a string-typed key,
3281
+ // or (b) report success for a typo that `setParams` would silently ignore.
3282
+ const upd={}
3283
+ const known=s.status().paramsKeys||[]
3284
+ for(const tok of rest){
3285
+ const eq=tok.indexOf('='); if(eq<=0) continue
3286
+ const k=tok.slice(0,eq)
3287
+ if(known.indexOf(k)<0){ r={ok:false,message:'unknown parameter: '+k+'(用 /v4 status 查看可调参数)'}; break }
3288
+ upd[k]=tok.slice(eq+1)
3289
+ }
3290
+ if(r===undefined){
3291
+ // Apply, then answer with the SAME shape the `vibe_v4_set` TOOL answers with (the current
3292
+ // status + the applied/ignored bookkeeping): reporting only the raw setParams result would
3293
+ // make the slash command a different, less useful surface than the tool it mirrors.
3294
+ const res=s.setParams(upd)
3295
+ const st=s.status()
3296
+ r=(res&&res.ok===false)?Object.assign({},st,{ok:false,warning:res.message,ignored:res.ignored}):Object.assign({},st,{applied:res.applied})
3297
+ }
3298
+ }
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>...'}
3308
+ // `kind:'error'` on a rejected business action, exactly as v2/v3 do: every branch above used to
3309
+ // report `success`, so the human's only non-tool control surface read as success on failure.
3310
+ const failed = !r || r.ok === false || (r.ok === undefined && !!r.error)
3311
+ return {kind: failed?'error':'success', text:JSON.stringify(r,null,2)}
2197
3312
  },
2198
3313
  }))
2199
3314
 
@@ -2202,13 +3317,36 @@ export function apply(ctx) {
2202
3317
  // registry (dsh-subagent:1231 dispose -> dsh-agent:508 store.delete ->
2203
3318
  // dsh-subagent:1241 settle/emit), so an end-time `agents.get(childId)` can never
2204
3319
  // resolve. See `liveAgents` in the session body.
3320
+ //
3321
+ // DO NOT key this handler on `childOwner`: the host emits `subagent/start` INSIDE
3322
+ // `startContinuable`'s await (dsh-subagent/lib/index.js:1116 → `observer.start` → emit at `:306`,
3323
+ // and the direct `emit('subagent/start', …)` at `:279`), i.e. BEFORE that call resolves — and
3324
+ // `spawnResident` can only learn `started.childId` after the resolve. Keying on it made this
3325
+ // handler return for EVERY child, so `liveAgents` stayed empty for the whole run and the real
3326
+ // `/compact` path (`liveAgentOf` → `if(!agent || !agent.session) return`) was dead code.
3327
+ //
3328
+ // Ownership is therefore resolved the other way round: the handler finds the (usually single)
3329
+ // session whose spawn window is open and STASHES the Agent under the child id from the payload;
3330
+ // `spawnResident` claims that stash the moment its call resolves. For a host that embeds the spawn
3331
+ // label in the id, `ownsChild` can be decided from the payload alone — that path is used when no
3332
+ // spawn window is open, so a `subagent/start` for a child we did not spawn is never recorded.
2205
3333
  ctx.on('subagent/start', function(info){
2206
3334
  if(!info || !info.id) return
2207
- const sid=childOwner.get(info.id); const s=sid!==undefined?sessions.get(sid):undefined
2208
- if(!s) return
2209
3335
  let agent
2210
3336
  try { agent = agents.get(info.id) } catch(e){ agent = undefined }
2211
- if(agent) s.rememberAgent(info.id, agent)
3337
+ if(!agent) return
3338
+ let s
3339
+ try {
3340
+ for(const [,ss] of sessions){
3341
+ const open=ss.ownsChild && ss.ownsChild(info.id)
3342
+ if(open){ s=ss; break }
3343
+ }
3344
+ if(!s){ const sid=childOwner.get(info.id); if(sid!==undefined) s=sessions.get(sid) }
3345
+ } catch(e){ s=undefined }
3346
+ if(s){ s.rememberAgent(info.id, agent); return }
3347
+ // No owner yet — the spawn this child belongs to is still inside `startContinuable`. Stash it for
3348
+ // the first session whose spawn window is open, so the claim after the resolve can find it.
3349
+ for(const [,ss] of sessions){ if(ss.stashStartAgent){ ss.stashStartAgent(info.id, agent); return } }
2212
3350
  })
2213
3351
 
2214
3352
  ctx.on('subagent/end', function(info){