dsh-vibe-math 2.5.0 → 2.7.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 (59) hide show
  1. package/README.en.md +175 -369
  2. package/README.md +175 -370
  3. package/cordis.patch.yml +78 -22
  4. package/docs/AUDIT-CHECKLIST.md +3 -3
  5. package/docs/final-paper.md +172 -0
  6. package/docs/formal-verification.md +184 -29
  7. package/docs/math-computation.md +121 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
  9. package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
  10. package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
  11. package/docs/test-timing.md +25 -9
  12. package/installer.js +7 -4
  13. package/package.json +21 -3
  14. package/prompt-corpus-persona/persona-corpus.json +16 -16
  15. package/prompt-corpus-persona/persona-corpus.md +164 -52
  16. package/prompt-corpus-v2/formal-verify-v2.json +128 -92
  17. package/prompt-corpus-v2/formal-verify-v2.md +1574 -108
  18. package/prompt-corpus-v3/formal-verify-v3.json +137 -53
  19. package/prompt-corpus-v3/formal-verify-v3.md +1406 -100
  20. package/prompt-corpus-v4/formal-verify-v4.json +14 -14
  21. package/prompt-corpus-v4/formal-verify-v4.md +247 -14
  22. package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
  23. package/prompt-corpus-v5/prompt-corpus-v5.md +570 -3
  24. package/tests/audit-math-computation-contract.mjs +191 -0
  25. package/tests/audit-math-computation-parity.mjs +169 -0
  26. package/tests/audit-math-computation-sensitivity.mjs +231 -0
  27. package/tests/audit-persona-sensitivity.mjs +64 -11
  28. package/tests/audit-persona-surface.test.mjs +53 -16
  29. package/tests/audit-v5-integrity.mjs +14 -0
  30. package/tests/e2e-v5-round2.test.mjs +923 -3
  31. package/tests/formal-verify-v2.test.mjs +295 -6
  32. package/tests/formal-verify-v3.test.mjs +275 -4
  33. package/tests/formal-verify-v4.test.mjs +295 -6
  34. package/tests/formal-verify-v5.test.mjs +9 -1
  35. package/tests/helpers/math-computation-fake-seam.mjs +135 -0
  36. package/tests/math-computation-shared.test.mjs +504 -0
  37. package/tests/prompt-v5-integrity.test.mjs +14 -8
  38. package/tests/selfdrive-v5.mjs +55 -4
  39. package/vibe-math-v2/agent.cordis.yml +36 -14
  40. package/vibe-math-v2/math-computation.js +936 -0
  41. package/vibe-math-v2/math-engines.js +156 -0
  42. package/vibe-math-v2/vibe-math-v2.js +1653 -48
  43. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  44. package/vibe-math-v3/agent.cordis.yml +30 -12
  45. package/vibe-math-v3/math-computation.js +936 -0
  46. package/vibe-math-v3/math-engines.js +156 -0
  47. package/vibe-math-v3/vibe-math-v3.js +1623 -53
  48. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  49. package/vibe-math-v4/agent.cordis.yml +44 -8
  50. package/vibe-math-v4/math-computation.js +936 -0
  51. package/vibe-math-v4/math-engines.js +156 -0
  52. package/vibe-math-v4/vibe-math-v4.js +1592 -37
  53. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  54. package/vibe-math-v5/agent.cordis.yml +46 -10
  55. package/vibe-math-v5/math-computation.js +936 -0
  56. package/vibe-math-v5/math-engines.js +156 -0
  57. package/vibe-math-v5/vibe-math-v5.js +2233 -42
  58. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +153 -4
  59. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
@@ -12,6 +12,32 @@
12
12
  // only from the dynamic-package start path); v2/v3 use those globals and work. An earlier
13
13
  // version of this note claimed the globals do not exist — false for file rows, and it would
14
14
  // only become true if this preset were ever converted to a dynamic package.
15
+ // The Lean job id and the archive de-duplication are CONTENT-hash based (`lean-incremental-async`
16
+ // spec §4.3), so the plugin needs a real SHA-256. `node:crypto` is a plain Node builtin here: a
17
+ // preset is a FILE row loaded by a host-realm import(), and the plugin already relies on Node
18
+ // builtins (`process.platform`) — see the header note about the vm sandbox applying only to a
19
+ // DYNAMIC package's host half.
20
+ import { createHash } from 'node:crypto'
21
+ // math_computation (docs/math-computation.md, `_oneoff/mc-P1-ready/INTERFACE-FREEZE.md` §5): the tool
22
+ // CORE lives in the shared module — installed byte-identically into all four presets by the
23
+ // integration owner and checked by `tests/audit-math-computation-parity.mjs`. This preset only WIRES
24
+ // it: params, one registration, and the prompt text. Names/shapes come from the module, never
25
+ // re-spelled here (the six parameter names are a frozen cross-preset contract).
26
+ import {
27
+ registerMathComputation,
28
+ probeMathEngines,
29
+ MATH_TOOL_DESCRIPTION,
30
+ MATH_PERSONA_TOOL_LINE,
31
+ MATH_RULE_LINES,
32
+ MATH_RULE_LINES_EN,
33
+ MATH_ARCHIVE_WORKFLOW_LINE,
34
+ MATH_ARCHIVE_WORKFLOW_LINE_EN,
35
+ MATH_PARAM_DEFAULTS,
36
+ MATH_PARAM_NAMES,
37
+ normalizeMathParams,
38
+ mathAvailabilityLine,
39
+ } from './math-computation.js'
40
+
15
41
  export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
16
42
 
17
43
  // ---- host live-child cap (DSH ≥ 0.2) ----------------------------------------
@@ -43,6 +69,15 @@ function hostChildLimitHint(limit){
43
69
  }
44
70
 
45
71
  export function apply(ctx) {
72
+ // math_computation (v5-style structure, per-session module instances):
73
+ // 1. a THROWAWAY capture-only host yields the frozen tool face (name/description/schema) once;
74
+ // 2. the tool is registered ONCE with a router that resolves the CALLING session;
75
+ // 3. each session lazily gets its OWN `registerMathComputation(...)` instance, cached by that
76
+ // session's host object, so there is NO "current session" slot at all and two sessions can run
77
+ // math calls CONCURRENTLY without ever seeing each other's params/root/fs/probe cache.
78
+ // (An earlier revision used a single holder plus a plugin-level serialisation chain; a shared slot
79
+ // was only safe while calls could not overlap, and serialising cost cross-session parallelism.)
80
+ let mathToolFace = null
46
81
  const subagents = ctx.subagents
47
82
  const agents = ctx.agents
48
83
  const fs = ctx.fs
@@ -90,8 +125,127 @@ export function apply(ctx) {
90
125
  leanCommand: 'lean',
91
126
  leanArgs: [], // inserted BEFORE the file name (e.g. ['env','lean'] with lake)
92
127
  leanTimeoutMs: 120000,
128
+ // ---- incremental + async Lean compilation (lean-incremental-async spec §1.1) ----------
129
+ // true (default): `lean_run` / `lean_archive{run:true}` ENQUEUE a background job and return
130
+ // immediately (`async:{jobId,state:'queued'}`); the queue is drained from the existing
131
+ // heartbeat. false: the historical synchronous `await` path, unchanged. The compile budget
132
+ // is `leanTimeoutMs`; the executable/args are `leanCommand`/`leanArgs` (no new parameter).
133
+ leanAsync: true,
134
+ // AMENDMENT §1: PROACTIVITY is a separate knob from the verification requirement.
135
+ // `formalVerify` says how strong the check must be at VOTING time; `leanInitiative` says how
136
+ // eager the group should be while WORKING. 'off' = no proactive nagging (verify-time only),
137
+ // 'normal' = today's "顺手形式化" line, 'eager' = actively formalize valuable small
138
+ // lemmas/propositions/definitions as they appear.
139
+ leanInitiative: 'normal',
140
+ // AMENDMENT §2: extra search roots, injected in ORDER before the automatic VibeMath root,
141
+ // de-duplicated; an explicit --search-path/-R/--root in `leanArgs` still wins outright.
142
+ leanSearchPaths: [],
143
+ // AMENDMENT §5: how many background compiles may run at once (default 1 = serial).
144
+ leanJobsMaxParallel: 1,
145
+ // ---- final paper (docs/final-paper.md §1) ----------------------------------
146
+ // The run's conclusion is a PAPER the whole team co-authors once consensus has converged.
147
+ // finalPaper=true triggers it automatically at that moment (idempotent); the other four knobs
148
+ // decide what is produced, in which language, whether PDFs are attempted, and WHO finalises.
149
+ finalPaper: true,
150
+ paperFormat: 'both', // 'both' | 'md' | 'tex'
151
+ paperLanguage: 'zh', // 'zh' | 'en'
152
+ paperCompilePdf: true, // compile a PDF when a LaTeX engine is actually detected
153
+ // v2 §A5: the editor is the SESSION ROOT / human side ("office") by default — v4 has no
154
+ // `facilitator` roster member with an LLM — or one named resident (`resident:<id>`).
155
+ paperEditor: 'office',
156
+ paperLatexCommand: '', // preferred LaTeX executable ('' = the standard detection order)
157
+ // ---- math_computation (docs/math-computation.md) ---------------------------
158
+ // The six MATH_* parameter names are a FROZEN cross-preset contract; the VALUES come from the
159
+ // shared module so a drift in one preset cannot diverge from the others (the integration
160
+ // owner's parity guard compares the four presets). `mathEngines`/`mathPackages` are copied,
161
+ // never aliased: `MATH_PARAM_DEFAULTS` is frozen and shared.
162
+ mathComputation: MATH_PARAM_DEFAULTS.mathComputation, // 'auto' | 'on' | 'off' (off = true no-op)
163
+ mathMode: MATH_PARAM_DEFAULTS.mathMode, // 'typed+shell' | 'typed'
164
+ mathEngines: MATH_PARAM_DEFAULTS.mathEngines.slice(),
165
+ mathTimeoutMs: MATH_PARAM_DEFAULTS.mathTimeoutMs,
166
+ mathPackages: MATH_PARAM_DEFAULTS.mathPackages.slice(),
167
+ mathInstallScope: MATH_PARAM_DEFAULTS.mathInstallScope,
93
168
  }
94
169
  let params = Object.assign({}, DEFAULT_PARAMS)
170
+ // ── math_computation availability line (P1) ────────────────────────────────────────────────
171
+ // The tool itself is registered ONCE at apply level (the host adapter there resolves the calling
172
+ // session dynamically). The PROMPT line is per-session state, so it is derived here from a
173
+ // session-local probe host: `probeMathEngines` caches per host object, so the expensive probe
174
+ // runs once per session and every later prompt re-uses it. `mathComputation:'off'` is a true
175
+ // no-op (no probe, no line).
176
+ const mathProbeHost = {
177
+ register: ()=>{}, // probeMathEngines never registers; adaptHost requires it
178
+ params: ()=>params,
179
+ projectRoot: ()=>frameworkRoot(),
180
+ writeText: (rel,text)=>writeText(rel,text),
181
+ readText: (rel)=>readText(rel),
182
+ exists: async (rel)=>{ try { const t=await fsTarget(rel); return (await fs.stat(t))!==undefined } catch(e){ return false } },
183
+ // P2a/audit-R2: the module's per-project retention check (MATH_ARCHIVE_MAX_RUNS) only runs
184
+ // when the host exposes listDir. Root ('', '.') means the project root. The field is attached
185
+ // ONLY when the fs surface actually has the method, so "no listDir" stays expressible (a
186
+ // never-absent function would make the module's `typeof H.listDir === 'function'` vacuous).
187
+ ...(typeof fs.listDir === 'function' ? { listDir: async (rel)=>{ try { const t=await fsTarget(rel===''||rel==='.'?'':rel); return (await fs.listDir(t))||[] } catch(e){ return [] } } } : {}),
188
+ resolveExecutable: async (cmd)=>{
189
+ const sub=subprocessOf()
190
+ if(sub===undefined||typeof sub.resolveExecutable!=='function') throw new Error('NO_SUBPROCESS')
191
+ return await sub.resolveExecutable(String(cmd))
192
+ },
193
+ spawn: async (o)=>{ const r=await mathSpawnAdapter(o); return r },
194
+ // Same capability flag as the registered host: the prompt-side probe should not advertise
195
+ // engines on a host that cannot execute at all.
196
+ hasSubprocess: ()=>!!subprocessOf(),
197
+ log: (kind,msg)=>logActivity(String(kind||'math'),String(msg||'')),
198
+ refreshAvailability: ()=>refreshMathAvailability(),
199
+ }
200
+ /**
201
+ * The shared module's spawn contract is flat — `{argv,cwd,timeoutMs,stdoutCap,stderrCap}` →
202
+ * `{exit,timedOut,killed,ms,stdout,stderr}` — while the host service hands back a live handle.
203
+ * A TIMEOUT MUST ACTIVELY KILL: the same race-and-terminate discipline `leanRunFile` uses, so the
204
+ * module is never told "timed out" while the process is still running. Output is collected up to
205
+ * the caller's cap; `run` passes 4MB on purpose (the module archives the COMPLETE output itself
206
+ * and only truncates its REPLY to 64KB).
207
+ */
208
+ async function mathSpawnAdapter(o){
209
+ const sub=subprocessOf()
210
+ // `null` is the module's documented "this host cannot execute" signal (it maps it to
211
+ // MATH_NO_SUBPROCESS), so it must NOT be papered over with an empty success-looking result.
212
+ if(sub===undefined||typeof sub.spawn!=='function') return null
213
+ const spec=o||{}
214
+ const cap=Math.max(1000,Math.floor(Number(spec.timeoutMs))||MATH_PARAM_DEFAULTS.mathTimeoutMs)
215
+ const outCap=Math.max(1024,Math.floor(Number(spec.stdoutCap))||64*1024)
216
+ const errCap=Math.max(1024,Math.floor(Number(spec.stderrCap))||64*1024)
217
+ const started=now()
218
+ let handle
219
+ try { handle=sub.spawn({ argv:Array.isArray(spec.argv)?spec.argv:[], cwd:spec.cwd||frameworkRoot(), stdio:{stdin:'ignore',stdout:{maxBytes:outCap},stderr:{maxBytes:errCap}}, graceMs:cap }) }
220
+ catch(e){ return {exit:null,timedOut:false,killed:false,ms:now()-started,stdout:'',stderr:String((e&&e.message)||e)} }
221
+ let killedByUs=false, timerDispose=null, outcome
222
+ try {
223
+ outcome=await Promise.race([
224
+ handle.done,
225
+ new Promise(function(resolve){
226
+ if(typeof ctx.timeout!=='function') return // no timer service: the host grace window is all we have
227
+ timerDispose=ctx.timeout(function(){
228
+ killedByUs=true
229
+ try { if(typeof handle.terminate==='function') handle.terminate() } catch(e){ /* best effort */ }
230
+ resolve({exitCode:null,signal:'SIGTERM'})
231
+ }, cap)
232
+ }),
233
+ ])
234
+ } catch(e){
235
+ if(timerDispose){ try { timerDispose() } catch(_e){} }
236
+ return {exit:null,timedOut:false,killed:killedByUs,ms:now()-started,stdout:'',stderr:String((e&&e.message)||e)}
237
+ }
238
+ if(timerDispose){ try { timerDispose() } catch(e){ /* already fired */ } }
239
+ let out='', err=''
240
+ try { if(handle.collected&&handle.collected.stdout) out=handle.collected.stdout.readFrom(0).text } catch(e){ /* best effort */ }
241
+ try { if(handle.collected&&handle.collected.stderr) err=handle.collected.stderr.readFrom(0).text } catch(e){ /* best effort */ }
242
+ const ms=now()-started
243
+ const exit=(outcome&&outcome.exitCode!==undefined)?outcome.exitCode:null
244
+ // Two ways a timeout is observed (identical to `leanRunFile`): OUR timer won and killed the
245
+ // handle, or the host's own grace window killed it first and its `done` beat our timer.
246
+ const timedOut=killedByUs||(exit!==0&&ms>=cap)
247
+ return {exit, timedOut, killed:killedByUs, ms, stdout:out, stderr:err}
248
+ }
95
249
  let running = false, autoDone = false, phase = 'idle'
96
250
  let residents = new Map(), mailboxes = new Map(), taskboard = [], decisions = []
97
251
  let meetings = [], reports = [], activityLog = []
@@ -102,6 +256,22 @@ export function apply(ctx) {
102
256
  // whether its verdict may take effect.
103
257
  let formal = {}, formalTodos = [], formalPersisted = false
104
258
  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)
259
+ // The final-paper flow (docs/final-paper.md, v1 + v2 amendments). In-memory only: the DURABLE
260
+ // idempotency record is `Paper/<run id>/paper.meta.json` on disk (finalizedAt + per-artifact
261
+ // existence, v2 §C), so a re-trigger after a restart is still recognised even though this object
262
+ // did not survive. While a paper is in flight this holds the whole team-authoring state machine.
263
+ let paperState = null
264
+ // ---- Lean compile queue (lean-incremental-async spec §2) --------------------------------
265
+ // Per-SESSION in-memory FIFO (created/destroyed with the session), mirrored to
266
+ // `<project>/Formal/Jobs/<jobId>.json` for crash recovery and `lean_lib.jobs`. Concurrency is a
267
+ // CONSTANT (1): compiling competes with the model for CPU and Lean parallelises internally, so
268
+ // the queue serialises instead of fanning out; the cap is not a parameter (D5: reuse, don't
269
+ // grow the parameter surface).
270
+ const LEAN_QUEUE_CONCURRENCY = 1 // the DEFAULT cap; `leanJobsMaxParallel` overrides it (AMENDMENT §5)
271
+ let leanQueue = [] // job ids waiting (queued, not started)
272
+ let leanInflight = [] // jobs currently compiling (length ≤ the concurrency cap)
273
+ let leanJobs = {} // jobId -> job record (mirrors Formal/Jobs/*.json)
274
+ let leanNotices = [] // settled-job announcements, injected once into the next prompt
105
275
  let busy = new Set(), wakeKind = new Map(), currentResident = ''
106
276
  let finalizeLock = null // 'meeting'|'verify' while a consensus finalize is running (reentry guard)
107
277
  const verifiedRecently = new Map() // targetId -> timestamp when it was closed as Verified (dedup re-propose)
@@ -253,7 +423,7 @@ export function apply(ctx) {
253
423
  }
254
424
  async function ensureDirs(){
255
425
  const base=frameworkRoot()
256
- const dirs=['Problems','Progress','Propos','Methods','Subproblems','Shared/meetings','Shared/debates','Verified/命题','Verified/问题','Verified/Lean','Formal','Reliable','Notes','State']
426
+ const dirs=['Problems','Progress','Propos','Methods','Subproblems','Shared/meetings','Shared/debates','Verified/命题','Verified/问题','Verified/Lean','Formal','Formal/Jobs','Computation','Reliable','Notes','State']
257
427
  // The GLOBAL reuse library (Formal/Lib + Formal/Proved) deliberately lives beside the
258
428
  // project tree, NOT inside it: cross-project reuse is the whole point (spec §3). It is
259
429
  // created here so the first `lean_archive kind='def'` never has to invent its parent.
@@ -394,7 +564,10 @@ export function apply(ctx) {
394
564
  catch(e){
395
565
  return {ok:false,code:'LEAN_NOT_FOUND',message:'cannot resolve "'+String(params.leanCommand||'lean')+'": '+String((e&&e.message)||e)+' — 仍可把形式化代码写下来归档,但无法在此宿主上执行',file:rel,ms:now()-started}
396
566
  }
397
- const argv=[exe].concat((Array.isArray(params.leanArgs)?params.leanArgs:[]).map(String)).concat([abs])
567
+ // §3.1: the search path is computed HERE (never persisted into `leanArgs`): user args first,
568
+ // then `--search-path <VibeMath root>` unless the user already stated a search root, then the
569
+ // file. `lake env lean` therefore becomes `lake env lean --search-path <root> <file>` for free.
570
+ const argv=leanArgv(exe, abs)
398
571
  let handle
399
572
  try {
400
573
  handle=sub.spawn({ argv, cwd:frameworkRoot(), stdio:{stdin:'ignore',stdout:{maxBytes:64*1024},stderr:{maxBytes:64*1024}}, graceMs:cap })
@@ -465,6 +638,338 @@ export function apply(ctx) {
465
638
  }))
466
639
  }
467
640
 
641
+ // ================= Lean compile queue + incremental reuse (§2-§4) =================
642
+ // · `leanAsync=true` (default): the tools ENQUEUE and return; the queue drains from the existing
643
+ // heartbeat (`armHeartbeat`), concurrency 1, budget = `leanTimeoutMs`.
644
+ // · `leanAsync=false`: the synchronous `await` path, verbatim.
645
+ // · ONLY a settled, green job whose file STILL MATCHES the enqueued content hash may set
646
+ // `passed` / write `Verified/Lean/<id>.lean`; every other outcome stays `attempted`.
647
+ const LEAN_ASYNC_ON = () => params.leanAsync !== false
648
+ // §3.6: the flag spelling is one constant on purpose — if a toolchain wants `-R <root>`, only
649
+ // this line changes and the interface stays put.
650
+ const LEAN_SEARCH_PATH_FLAG = '--search-path'
651
+ const LEAN_READ_MAX_BYTES = 64 * 1024
652
+ /** §3.1: never inject when the user already stated a search root (explicit config wins). */
653
+ function leanHasSearchFlag(args){ return (Array.isArray(args)?args:[]).some(a=>{ const s=String(a); return s===LEAN_SEARCH_PATH_FLAG||s.indexOf(LEAN_SEARCH_PATH_FLAG+'=')===0||s==='-R'||s==='--root' }) }
654
+ /** AMENDMENT §2: the roots to inject — USER paths first, then the automatic VibeMath root,
655
+ * de-duplicated, order preserved. */
656
+ function leanSearchPathList(){
657
+ const out=[]
658
+ for(const p of (Array.isArray(params.leanSearchPaths)?params.leanSearchPaths:[])){ const s=String(p==null?'':p).trim(); if(s&&out.indexOf(s)<0) out.push(s) }
659
+ const root=vibeRoot().replace(/\\/g,'/')
660
+ if(out.indexOf(root)<0) out.push(root)
661
+ return out
662
+ }
663
+ /** The injected flag pairs, or [] when the user's own `leanArgs` already state a search root. */
664
+ function leanSearchPathArgs(){
665
+ const user=(Array.isArray(params.leanArgs)?params.leanArgs:[]).map(String)
666
+ if(leanHasSearchFlag(user)) return []
667
+ const out=[]
668
+ for(const p of leanSearchPathList()) out.push(LEAN_SEARCH_PATH_FLAG,p)
669
+ return out
670
+ }
671
+ /** `[exe, ...userArgs, ...(search roots unless the user stated one), ...tail]` (§3.1 + AMENDMENT §2). */
672
+ function leanArgv(exe,tail){
673
+ const user=(Array.isArray(params.leanArgs)?params.leanArgs:[]).map(String)
674
+ return [exe].concat(user).concat(leanSearchPathArgs()).concat(tail?[tail]:[])
675
+ }
676
+ /**
677
+ * AMENDMENT §4: the job id pins the WHOLE BUILD CONTEXT — content + engine + normalized argv +
678
+ * search paths — so a result compiled under a different toolchain/flag set can never be mistaken
679
+ * for a verification of this one, and only THAT context's `settled(ok)` may set `passed`.
680
+ */
681
+ async function leanBuildContext(){
682
+ const searchPaths=leanSearchPathList()
683
+ let exe=String(params.leanCommand||'lean')
684
+ const sub=subprocessOf()
685
+ try { if(sub&&typeof sub.resolveExecutable==='function') exe=String(await sub.resolveExecutable(exe)) } catch(e){ /* keep the configured name: the run itself will report LEAN_NOT_FOUND */ }
686
+ const argv=leanArgv(exe,null)
687
+ return {engine:exe,argv,argvLine:argv.join(' '),searchPaths,searchArgs:leanSearchPathArgs()}
688
+ }
689
+ /** §4.3: CRLF/CR → LF, strip trailing whitespace per line and trailing blank lines. */
690
+ function normalizeLeanContent(s){ return String(s==null?'':s).replace(/\r\n?/g,'\n').replace(/[ \t]+$/gm,'').replace(/\n+$/,'')+'\n' }
691
+ function sha256Text(s){ return createHash('sha256').update(normalizeLeanContent(s),'utf8').digest('hex') }
692
+ function leanJobsRel(){ return 'Formal/Jobs' }
693
+ function leanJobRel(jobId){ return leanJobsRel()+'/'+jobId+'.json' }
694
+ function leanJobId(key,buildHash){ return idSafe(String(key||'job')).replace(/\.lean$/,'')+'-'+String(buildHash).slice(0,12) }
695
+ /** AMENDMENT §4: the id hash covers content + engine + normalized argv + search paths. */
696
+ function leanBuildHashOf(content,ctx){ return sha256Text(JSON.stringify([sha256Text(content),String(ctx.engine||''),ctx.argv||[],ctx.searchPaths||[]])) }
697
+ async function leanJobIdFor(key,content){ const ctx=await leanBuildContext(); return {jobId:leanJobId(key,leanBuildHashOf(content,ctx)),ctx} }
698
+ /** Sleep through the host timer service (fiber-owned); the global fallback is what the header
699
+ * note documents for file rows. */
700
+ function leanSleep(ms){ return new Promise(res=>{ if(typeof ctx.timeout==='function'){ ctx.timeout(()=>res(),ms); return } setTimeout(res,ms) }) }
701
+ function leanJobView(j){ return j?{jobId:j.jobId,state:j.state,rel:j.rel,target:j.target||j.name||'',attempts:j.attempts||1,settledAt:j.settledAt||0,exitCode:(j.exitCode===undefined?null:j.exitCode)}:null }
702
+ function listLeanJobs(){ return Object.keys(leanJobs).sort().map(k=>leanJobView(leanJobs[k])) }
703
+ async function writeLeanJob(job){
704
+ leanJobs[job.jobId]=job
705
+ try { await writeJson(leanJobRel(job.jobId),job) } catch(e){ logActivity('formal','job 记录写入失败 '+job.jobId+': '+String((e&&e.message)||e)) }
706
+ return job
707
+ }
708
+ async function readLeanJob(jobId){ try { return await readJson(leanJobRel(jobId))||undefined } catch(e){ return undefined } }
709
+ /** §2.4: ONE line per settled job, injected into the next prompt that is constructed. */
710
+ function pushLeanNotice(job,run){
711
+ const sec=run&&run.ms?((run.ms/1000).toFixed(1)+'s'):'—'
712
+ let line
713
+ if(job.state==='settled'&&run&&run.ok) line='通过(exit 0,'+sec+(job.kind==='proof'?(',已归档 '+job.verifiedRel):',已重建索引')+')'
714
+ else if(job.state==='timeout') line='超时(leanTimeoutMs='+posMs(params.leanTimeoutMs,120000)+' 已 terminate,对象保持 attempted,可重跑)'
715
+ else if(job.state==='interrupted') line='中断(会话卸载或崩溃,已标记 attempted,可重跑)'
716
+ else if(job.state==='failed'&&run&&!run.ok&&!run.hashMatch) line='失败(文件在编译期间被改写,结果不可用——请对新内容重跑)'
717
+ else line='失败(exit '+String(run&&run.exitCode)+',见 stderr 尾部:'+tail((run&&run.stderr)||'',160)+')'
718
+ leanNotices.push('【形式化结果】'+job.jobId+':'+line)
719
+ logActivity('formal','形式化结果 '+job.jobId+' → '+job.state+(job.target?('(对象 '+job.target+')'):''))
720
+ }
721
+ /** Consume-and-clear the pending announcements (injected into the next constructed prompt). */
722
+ function pendingLeanNoticeText(){ if(!leanNotices.length) return ''; const t=leanNotices.join('\n'); leanNotices=[]; return t }
723
+ /**
724
+ * Enqueue one compile. `job` = {kind:'run'|'def'|'lemma'|'proof', key, target, rel, content}.
725
+ * Deduplicated by CONTENT hash (§4.3): the job id embeds `sha[0:12]`, so re-archiving identical
726
+ * content cannot produce a second job.
727
+ */
728
+ async function enqueueLeanJob(job){
729
+ const content=String(job.content==null?'':job.content)
730
+ const sha=job.sha256||sha256Text(content)
731
+ const ctx=job.ctx||await leanBuildContext()
732
+ const buildHash=leanBuildHashOf(content,ctx)
733
+ const jobId=leanJobId(job.key,buildHash)
734
+ const existing=leanJobs[jobId]
735
+ if(existing&&(existing.state==='queued'||existing.state==='running')) return {ok:true,duplicate:true,job:existing}
736
+ const rec={ jobId, kind:job.kind, key:job.key, target:job.target||'', name:job.name||'', rel:job.rel, abs:job.abs||'',
737
+ sha256:sha, buildHash, build:{engine:ctx.engine,argv:ctx.argv,searchPaths:ctx.searchPaths}, contextChanged:false,
738
+ content, via:job.via||'', state:'queued', attempts:((existing&&existing.attempts)||0)+1,
739
+ enqueuedAt:now(), startedAt:0, settledAt:0, exitCode:null, timedOut:false, interrupted:false, run:null, verifiedRel:'' }
740
+ await writeLeanJob(rec)
741
+ leanQueue.push(jobId)
742
+ logActivity('formal','入队后台编译 '+jobId+'('+(job.kind||'run')+' '+job.rel+',engine='+ctx.engine+',search-path='+(ctx.searchPaths.join(',')||'(用户自带)')+')')
743
+ armHeartbeat() // §2.2: the queue is drained from the EXISTING heartbeat
744
+ return {ok:true,job:rec}
745
+ }
746
+ /** §2.1/§2.4: the ONE place an object may become `passed` (and the proof be archived). */
747
+ async function settleLeanJob(job,run){
748
+ const nowText=await readTextAbs(job.abs)
749
+ const hashMatch=nowText!==undefined&&sha256Text(nowText)===job.sha256
750
+ // AMENDMENT §4: the id already pins the build context; if the user changed the toolchain while
751
+ // the job was in flight, RECORD that the current context differs (the compile itself was done
752
+ // with the recorded context, which is what the job id attests to).
753
+ let contextChanged=false
754
+ try { const nowCtx=await leanBuildContext()
755
+ contextChanged=JSON.stringify([nowCtx.engine,nowCtx.argv,nowCtx.searchPaths])!==JSON.stringify([(job.build||{}).engine,(job.build||{}).argv,(job.build||{}).searchPaths])
756
+ if(contextChanged) logActivity('formal','作业 '+job.jobId+' 的结果属于记录中的构建上下文(engine/argv/search-paths 已被改动,未影响该次编译的结论)')
757
+ } catch(e){ /* best effort */ }
758
+ const ok=!!(run&&run.ok)&&hashMatch
759
+ const timedOut=!!(run&&run.timedOut)
760
+ const state=ok?'settled':(timedOut?'timeout':'failed')
761
+ const settled=Object.assign({},job,{
762
+ state, settledAt:now(), exitCode:(run&&run.exitCode===undefined?null:run.exitCode),
763
+ timedOut, interrupted:false, hashMatch, contextChanged,
764
+ run:{at:now(),ok,exitCode:(run&&run.exitCode===undefined?null:run.exitCode),ms:(run&&run.ms)||0,timedOut,interrupted:false,
765
+ hashMatch,stdoutTail:tail((run&&run.stdout)||'',800),stderrTail:tail((run&&run.stderr)||'',800)},
766
+ })
767
+ if(ok&&job.kind==='proof'){
768
+ // ★ ONLY here: a green run on UNCHANGED content mints the proof and flips the record.
769
+ settled.verifiedRel='Verified/Lean/'+job.key+'.lean'
770
+ await writeText(settled.verifiedRel,job.content)
771
+ const prev=formalOf(job.key)
772
+ await putFormal(job.key,Object.assign({},prev,{ status:'passed', file:job.rel, proof:settled.verifiedRel, decision:'used',
773
+ note:prev.note||'', async:{jobId:job.jobId,state,attempts:settled.attempts,enqueuedAt:job.enqueuedAt,startedAt:job.startedAt,settledAt:settled.settledAt,exitCode:settled.exitCode},
774
+ run:settled.run, updatedAt:now() }))
775
+ } else {
776
+ const prev=formalOf(job.key)
777
+ if(job.kind==='proof'){
778
+ await putFormal(job.key,Object.assign({},prev,{ status:prev.status==='passed'?'attempted':(prev.status==='blocked'?'blocked':'attempted'),
779
+ file:job.rel, proof:prev.proof||'', decision:prev.decision||'used', note:prev.note||'',
780
+ async:{jobId:job.jobId,state,attempts:settled.attempts,enqueuedAt:job.enqueuedAt,startedAt:job.startedAt,settledAt:settled.settledAt,exitCode:settled.exitCode},
781
+ run:settled.run, updatedAt:now() }))
782
+ } else if(job.kind==='run'&&job.target){
783
+ // A plain run only touches the formal records when it was aimed at an OBJECT (exactly what
784
+ // the synchronous path does: no target, no record). `passed`/`blocked` are preserved.
785
+ await formalSetRun(job.target,Object.assign({},run,{ok,file:job.rel,timedOut,interrupted:false}))
786
+ const cur=formalOf(job.target)
787
+ await putFormal(job.target,Object.assign({},cur,{status:cur.status==='passed'?'passed':(cur.status==='blocked'?'blocked':'attempted'),
788
+ async:{jobId:job.jobId,state,attempts:settled.attempts,enqueuedAt:job.enqueuedAt,startedAt:job.startedAt,settledAt:settled.settledAt,exitCode:settled.exitCode}}))
789
+ }
790
+ }
791
+ await writeLeanJob(settled)
792
+ try { await rebuildLeanLibIndexes() } catch(e){ /* best-effort */ }
793
+ pushLeanNotice(settled,Object.assign({},run,{ok,hashMatch}))
794
+ return settled
795
+ }
796
+ /** §2.2 + AMENDMENT §5: the drain. One pass starts up to `leanJobsMaxParallel` workers and
797
+ * returns IMMEDIATELY (a long compile must not block the heartbeat); each worker chains the
798
+ * next pass when it settles. A no-op pass costs one comparison + one array check. */
799
+ async function runLeanQueue(){
800
+ const cap=Math.max(1,Math.floor(Number(params.leanJobsMaxParallel))||LEAN_QUEUE_CONCURRENCY)
801
+ let started=false
802
+ while(leanInflight.length<cap&&leanQueue.length){
803
+ const jobId=leanQueue.shift()
804
+ const job=leanJobs[jobId]
805
+ if(!job) continue
806
+ const running=Object.assign({},job,{state:'running',startedAt:now()})
807
+ leanInflight.push(running); await writeLeanJob(running)
808
+ started=true
809
+ ;(async()=>{
810
+ let run
811
+ // Compile the ABSOLUTE path recorded at enqueue time: a `def`/`lemma` lives under the
812
+ // global `VibeMath/Formal/{Lib,Proved}` (beside the project tree), so the project-relative
813
+ // form would resolve to a non-existent file and report V4_NOT_FOUND.
814
+ try { run=await leanRunFile(String(running.abs||running.rel)) }
815
+ catch(e){ run={ok:false,code:'LEAN_RUN_FAILED',message:String((e&&e.message)||e),file:running.rel} }
816
+ leanInflight=leanInflight.filter(j=>j.jobId!==jobId)
817
+ if(leanJobs[jobId]&&leanJobs[jobId].state==='interrupted') return // dispose() already settled it
818
+ await settleLeanJob(leanJobs[jobId]||running,run)
819
+ runLeanQueue().catch(()=>{}) // keep the pipeline moving at the cap
820
+ })()
821
+ }
822
+ return started
823
+ }
824
+ /** §2.5: dispose/abort terminates every in-flight compile and marks it interrupted — never `passed`. */
825
+ async function disposeLean(){
826
+ leanQueue=[]
827
+ const jobs=leanInflight.slice()
828
+ leanInflight=[]
829
+ if(!jobs.length) return false
830
+ for(const job of jobs){
831
+ try { if(job.handle&&typeof job.handle.terminate==='function') job.handle.terminate() } catch(e){ /* best effort */ }
832
+ const cur=leanJobs[job.jobId]||job
833
+ const rec=Object.assign({},cur,{state:'interrupted',interrupted:true,settledAt:now(),
834
+ run:Object.assign({},cur.run||{},{ok:false,interrupted:true,hashMatch:false})})
835
+ await writeLeanJob(rec)
836
+ if(rec.kind==='proof'&&rec.key){ const prev=formalOf(rec.key)
837
+ await putFormal(rec.key,Object.assign({},prev,{status:prev.status==='passed'?'attempted':(prev.status||'attempted'),
838
+ async:{jobId:rec.jobId,state:'interrupted',attempts:rec.attempts,enqueuedAt:rec.enqueuedAt,startedAt:rec.startedAt,settledAt:rec.settledAt,exitCode:null}})) }
839
+ pushLeanNotice(rec,{ok:false,exitCode:null,ms:0})
840
+ }
841
+ return true
842
+ }
843
+ /**
844
+ * §2.6 crash/restart recovery: scan `<project>/Formal/Jobs/*.json` AFTER the project dirs exist.
845
+ * It NEVER mints a `passed` anything unless the job record itself says exitCode===0 AND the file
846
+ * on disk still hashes to the enqueued content.
847
+ */
848
+ async function recoverLeanJobs(opts){
849
+ const o=opts||{}
850
+ const applySettled=o.applySettled!==false
851
+ let files=[]
852
+ try {
853
+ const t=await fs.resolve(leanJobsRel(),{cwd:frameworkRoot()})
854
+ if(await fs.stat(t)===undefined) return {scanned:0}
855
+ files=await fs.listDir(t)
856
+ } catch(e){ return {scanned:0} }
857
+ let queued=0,redriven=0,stale=0,completed=0
858
+ for(const f of files||[]){
859
+ if(!f||f.type!=='file'||!/\.json$/.test(String(f.name))) continue
860
+ const job=await readJson(leanJobsRel()+'/'+f.name)
861
+ if(!job||!job.jobId||!job.rel) continue
862
+ leanJobs[job.jobId]=job
863
+ const sha=String(job.sha256||'')
864
+ const text=await readTextAbs(job.abs||vibeRelAbs(job.rel))
865
+ const hashMatch=text!==undefined&&sha256Text(text)===sha
866
+ if(job.state==='queued'){ if(leanQueue.indexOf(job.jobId)<0) leanQueue.push(job.jobId); queued++; continue }
867
+ if(job.state==='running'){
868
+ if(hashMatch){
869
+ await writeLeanJob(Object.assign({},job,{state:'queued',interrupted:true,attempts:(job.attempts||1)+1,run:Object.assign({},job.run||{},{ok:false,interrupted:true,hashMatch:true})}))
870
+ if(leanQueue.indexOf(job.jobId)<0) leanQueue.push(job.jobId)
871
+ if(job.kind==='proof'&&job.key){ const prev=formalOf(job.key)
872
+ await putFormal(job.key,Object.assign({},prev,{status:prev.status==='passed'?'attempted':(prev.status||'attempted'),
873
+ async:{jobId:job.jobId,state:'queued',attempts:(job.attempts||1)+1,enqueuedAt:job.enqueuedAt,startedAt:job.startedAt,settledAt:0,exitCode:null}})) }
874
+ leanNotices.push('【形式化结果】'+job.jobId+':崩溃/重启中断,已按新尝试次数重新入队(旧进程结果不可知,对象保持 attempted)')
875
+ redriven++
876
+ } else {
877
+ await writeLeanJob(Object.assign({},job,{state:'interrupted',interrupted:true,settledAt:now(),run:Object.assign({},job.run||{},{ok:false,interrupted:true,hashMatch:false})}))
878
+ if(job.kind==='proof'&&job.key){ const prev=formalOf(job.key)
879
+ await putFormal(job.key,Object.assign({},prev,{status:'attempted',
880
+ async:{jobId:job.jobId,state:'interrupted',attempts:job.attempts||1,enqueuedAt:job.enqueuedAt,startedAt:job.startedAt,settledAt:now(),exitCode:null}})) }
881
+ leanNotices.push('【形式化结果】'+job.jobId+':文件已变,请手动重跑(不自动重驱,避免把旧代码的结果贴到新代码上)')
882
+ stale++
883
+ }
884
+ continue
885
+ }
886
+ if(job.state==='settled'&&job.kind==='proof'&&job.key&&applySettled){
887
+ const rec=formalOf(job.key)
888
+ const applied=rec.async&&rec.async.jobId===job.jobId&&rec.async.state==='settled'
889
+ if(!applied&&job.exitCode===0&&hashMatch&&job.verifiedRel){
890
+ await writeText(job.verifiedRel,job.content)
891
+ await putFormal(job.key,Object.assign({},rec,{status:'passed',file:job.rel,proof:job.verifiedRel,decision:rec.decision||'used',
892
+ async:{jobId:job.jobId,state:'settled',attempts:job.attempts||1,enqueuedAt:job.enqueuedAt,startedAt:job.startedAt,settledAt:job.settledAt,exitCode:0},
893
+ run:job.run||rec.run,updatedAt:now()}))
894
+ leanNotices.push('【形式化结果】'+job.jobId+':按作业记录补齐(通过,已归档 '+job.verifiedRel+')')
895
+ completed++
896
+ }
897
+ }
898
+ }
899
+ if(queued+redriven+stale+completed){ await writeLeanLibIndexesSafe() ; if(leanQueue.length) armHeartbeat() }
900
+ return {scanned:(files||[]).length,queued,redriven,stale,completed}
901
+ }
902
+ async function writeLeanLibIndexesSafe(){ try { await rebuildLeanLibIndexes() } catch(e){ /* best-effort */ } }
903
+ /**
904
+ * §1.2 `lean_read`: read ONE archived library file VERBATIM (for import-or-copy reuse).
905
+ * Read-only; the path is confined to `<VibeMath>/Formal/{Lib,Proved}` — the sanitised `name`
906
+ * cannot contain a separator, and the resolved path is re-checked against the root so a crafted
907
+ * name can never escape (a `.lean` suffix is added, never accepted as a path).
908
+ */
909
+ async function leanRead(o){
910
+ const args=o||{}
911
+ const raw=String(args.name==null?'':args.name).trim()
912
+ const kind=String(args.kind||'auto')
913
+ if(!raw) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'name is required'}
914
+ if(raw.indexOf('/')>=0||raw.indexOf('\\')>=0||raw.indexOf('..')>=0) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'name must be a bare library name (no path separators)'}
915
+ if(['auto','lib','proved'].indexOf(kind)<0) return {ok:false,code:'V4_INVALID_ARGUMENT',message:"kind must be 'auto' | 'lib' | 'proved'"}
916
+ const name=idSafe(raw).replace(/\.lean$/,'')
917
+ if(!name||name==='id') return {ok:false,code:'V4_INVALID_ARGUMENT',message:'invalid name'}
918
+ const cands=[]
919
+ if(kind==='auto'||kind==='lib') cands.push({kind:'lib',rel:'Formal/Lib/'+name+'.lean',abs:formalLibRoot()+'/'+name+'.lean'})
920
+ if(kind==='auto'||kind==='proved') cands.push({kind:'proved',rel:'Formal/Proved/'+name+'.lean',abs:formalProvedRoot()+'/'+name+'.lean'})
921
+ const checked=[]
922
+ for(const c of cands){
923
+ const norm=normalizeAbsPath(c.abs)
924
+ const root=normalizeAbsPath(vibeRoot())
925
+ if(norm.indexOf(root+'/Formal/')!==0) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'refused: resolves outside the VibeMath/Formal library'}
926
+ checked.push(c)
927
+ }
928
+ const found=await (async()=>{
929
+ for(const c of checked){ const t=(await readTextAbs(c.abs)); if(t!==undefined) return {c,t} }
930
+ return null
931
+ })()
932
+ if(!found) return {ok:false,code:'V4_NOT_FOUND',message:'no archived library file named '+name+(kind==='auto'?' (checked Formal/Lib and Formal/Proved)':' in Formal/'+(kind==='lib'?'Lib':'Proved')),name,kind}
933
+ const full=String(found.t)
934
+ const bytes=(typeof TextEncoder==='function')?new TextEncoder().encode(full).length:full.length
935
+ const truncated=bytes>LEAN_READ_MAX_BYTES
936
+ const text=truncated?full.slice(0,LEAN_READ_MAX_BYTES):full
937
+ return {ok:true,name,file:found.c.rel,kind:found.c.kind,sha256:sha256Text(full),bytes,text,truncated,
938
+ hint:'复用:新文件里写 '+(found.c.kind==='lib'?'import Formal.Lib.':'import Formal.Proved.')+name+'(模块根=VibeMath 根,框架已把它加入编译搜索路径),或按上面的原文逐字复制。'}
939
+ }
940
+
941
+ /**
942
+ * AMENDMENT §3: the read-only result/wait surface. Without `jobId` it lists THIS session's jobs;
943
+ * with it, returns the job's state / exitCode / receipt path (`Formal/Jobs/<id>.json`) / archive
944
+ * path. `waitMs>0` waits up to that many ms and then reports the CURRENT state (never blocks
945
+ * forever, never throws).
946
+ */
947
+ const LEAN_JOB_TERMINAL=['settled','failed','timeout','interrupted']
948
+ async function leanJobTool(o){
949
+ const args=o||{}
950
+ const jobId=String(args.jobId||'').trim()
951
+ const waitMs=Math.max(0,Math.min(600000,Math.floor(Number(args.waitMs))||0))
952
+ if(!jobId){
953
+ const ids=Object.keys(leanJobs).sort()
954
+ return {ok:true,count:ids.length,
955
+ jobs:ids.map(k=>Object.assign(leanJobView(leanJobs[k]),{receipt:leanJobRel(k),archive:leanJobs[k].verifiedRel||'',timedOut:!!leanJobs[k].timedOut,interrupted:!!leanJobs[k].interrupted})),
956
+ queued:leanQueue.slice(),running:leanInflight.map(j=>j.jobId),
957
+ hint:'有 jobId 时用 vibe_v4_lean_job {jobId, waitMs} 取结果/等待(waitMs 超时只返回当前 state)。'}
958
+ }
959
+ let job=leanJobs[jobId]||await readLeanJob(jobId)
960
+ if(job&&waitMs>0){
961
+ const t0=now()
962
+ while(LEAN_JOB_TERMINAL.indexOf(job.state)<0&&now()-t0<waitMs){ await leanSleep(25); job=leanJobs[jobId]||job }
963
+ }
964
+ if(!job) return {ok:false,code:'V4_NOT_FOUND',message:'no such job: '+jobId}
965
+ return {ok:true,jobId,job:leanJobView(job),state:job.state,exitCode:(job.exitCode===undefined?null:job.exitCode),
966
+ rel:job.rel,receipt:leanJobRel(jobId),archive:job.verifiedRel||'',target:job.target||'',key:job.key||'',
967
+ timedOut:!!job.timedOut,interrupted:!!job.interrupted,attempts:job.attempts||1,settledAt:job.settledAt||0,
968
+ build:job.build||null,buildHash:job.buildHash||'',sha256:job.sha256||'',
969
+ run:job.run||null,
970
+ hint:LEAN_JOB_TERMINAL.indexOf(job.state)>=0?'(已终态)':'(仍在队列/编译中;可用 waitMs 再等,或等下一轮【形式化结果】公告)'}
971
+ }
972
+
468
973
  function formalModeWord(){ return formalMode()==='require'?'强制':'鼓励' }
469
974
  /**
470
975
  * The verification-prompt block. Every branch is computed from the CURRENT mode and the
@@ -500,8 +1005,11 @@ export function apply(ctx) {
500
1005
  L.push(' ▸ 因此请把 verdict 用在"这个阻塞判断是否成立 / 是否仍有别的形式化路线"上,并给出理由。')
501
1006
  } else {
502
1007
  L.push(' · 请先判断该对象的**实现难度**:若能在可接受的工作量内形式化,优先写 Lean 代码并执行。')
503
- L.push(' · 工具:vibe_v4_lean_run(执行)· vibe_v4_lean_archive(归档)· vibe_v4_lean_lib(查已有可复用库)')
504
- L.push(' · 工作目录:Formal/(相对项目根);可复用定义放 '+formalLibRoot().replace(/\\/g,'/')+'/,已证引理放 '+formalProvedRoot().replace(/\\/g,'/')+'/;写之前先 vibe_v4_lean_lib 查重。')
1008
+ // lean-incremental-async spec §5 B (verbatim, after the implementation-difficulty line).
1009
+ L.push(' · 形式化只写你有把握的版本;没把握就记 blocked 并写清难点——不要用形式化掩盖不确定。')
1010
+ L.push(' · 该对象若已有后台编译在队列中,**不得**在它落地前声称已通过或走忠实性审查;等 vibe_v4_lean_lib 显示 passed 再审。')
1011
+ L.push(' · 工具:vibe_v4_lean_run(执行/入队)· vibe_v4_lean_archive(归档)· vibe_v4_lean_lib(查已有可复用库与 jobs)· vibe_v4_lean_read(读归档原文逐字复用)')
1012
+ L.push(' · 工作目录:Formal/(相对项目根);可复用定义放 '+formalLibRoot().replace(/\\/g,'/')+'/,已证引理放 '+formalProvedRoot().replace(/\\/g,'/')+'/;写之前先 vibe_v4_lean_lib 查重。复用已归档内容:`import Formal.Lib.<name>` / `import Formal.Proved.<name>`(模块根 = VibeMath 根,框架已加入编译搜索路径)。项目工作文件 Formal/<对象 id>.lean 只是归档用,**不是**模块路径,不要 import 它。')
505
1013
  L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义/对象/条件/假设/结论是否与命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
506
1014
  if(formalMode()==='require'){
507
1015
  L.push(" · **本模式要求**:必须产出 Lean 形式化,或**必须**给出显式的阻塞原因(vibe_v4_lean_archive kind='blocked' note=… 或回执 formal.note)。若两者都没有,本次裁定不会生效,会被记为未定论(原因 formal-required)并进入「形式化待办」。")
@@ -520,17 +1028,85 @@ export function apply(ctx) {
520
1028
  }
521
1029
  return L.join('\n')
522
1030
  }
523
- /** The ordinary-work-round line (spec §6.2): formalize reusable objects as you go. */
1031
+ /** `leanInitiative` (v5-aligned): how EAGER the group is about formalizing while working. It is a
1032
+ * SEPARATE axis from `formalVerify` — see `leanDailyOn` below. */
1033
+ const leanInitiative=()=>(['off','normal','eager'].indexOf(String(params.leanInitiative))!==-1?String(params.leanInitiative):'normal')
1034
+ /** The DAILY line is injected when the group should formalize while working: any non-'off'
1035
+ * initiative with `formalVerify` on, OR explicitly `eager` — which asks for it even when
1036
+ * `formalVerify:'off'`, because the two knobs are independent (v5 uses the exact same rule).
1037
+ * 'off' never injects the daily line; the VERIFICATION prompt still follows `formalVerify`. */
1038
+ const leanDailyOn=()=>leanInitiative()!=='off'&&(formalOn()||leanInitiative()==='eager')
1039
+ /** The ordinary-work-round line (spec §6.2): formalize reusable objects as you go. Carries the
1040
+ * three selection criteria, the blocked-instead-of-guessing rule, the look-up-first reuse path
1041
+ * and the background-compile rule (lean-incremental-async spec §5 A, verbatim). */
524
1042
  function formalWorkLine(){
525
- if(!formalOn()) return ''
526
- return '【顺手形式化('+formalModeWord()+')】把你工作中常用或可能复用的对象、假设、新定义,'
1043
+ if(!leanDailyOn()) return ''
1044
+ // v5-aligned header: with `formalVerify:'off'` the mode word would be misleading, so the eager
1045
+ // 档 names itself instead.
1046
+ return '【顺手形式化('+(formalOn()?formalModeWord():'主动(leanInitiative=eager)')+')】把你工作中常用或可能复用的对象、假设、新定义,'
527
1047
  +"用 Lean 形式化定义并归档到全局可复用库(vibe_v4_lean_archive kind='def'),已成立的引理归到 Formal/Proved/"
528
1048
  +"(kind='lemma');写之前先 vibe_v4_lean_lib 查重,避免重复定义。"
1049
+ +'\n · 判断标准:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)。**没把握的先别入库**——'
1050
+ +'\n 进了 Formal/Proved 的东西会被当成已核对引理;没把握就记 blocked 并写清难点,别用形式化掩盖不确定。'
1051
+ +'\n · 复用优先:写新定义/证明前**先 vibe_v4_lean_lib 查已有库**;复用已归档内容用'
1052
+ +'\n `import Formal.Lib.<name>` / `import Formal.Proved.<name>`(模块根 = VibeMath 根,框架已把它加进编译搜索路径),'
1053
+ +'\n 或 `vibe_v4_lean_read {name}` 取原文逐字复制。**查不到再新写**;同内容重复归档会自动去重。'
1054
+ +'\n · 编译默认走后台队列(leanAsync=true):入队后你可以继续工作;用 vibe_v4_lean_lib 的 jobs 字段或下一轮提示里的'
1055
+ +'\n 【形式化结果】行看结果。**在作业落地为"通过"之前,不得把该对象当成已通过。**'
529
1056
  +(formalMode()==='require'
530
1057
  ? '本模式下,任何要定论为真/假的对象都必须先有 Lean 通过或显式阻塞记录。'
531
1058
  : '这会让后续的验证与证明省掉大量重复工作。')
1059
+ // AMENDMENT §1: only `leanInitiative='eager'` asks for MORE proactivity than the normal line.
1060
+ // `formalVerify` is deliberately NOT consulted here: it expresses the verification-time
1061
+ // requirement, not how eagerly the group should formalize while working.
1062
+ +(leanInitiative()==='eager'
1063
+ ? '\n · 本档(leanInitiative=eager):工作中出现**有价值且你有把握**的小引理 / 小命题 / 小定义时,**主动**顺手形式化并归档,不必等到验证轮;把握不足就照上面的判据记 blocked。'
1064
+ : '')
532
1065
  +'归档前先跑通(vibe_v4_lean_run 或 run=true);跑不通的定义不要进可复用库。'
533
1066
  }
1067
+ // ================= math_computation availability line (P1) =================
1068
+ // The line is DYNAMIC (it comes from the module's probe cache) while the resident prompts are
1069
+ // built by BOTH sync and async builders, so the two language variants are cached here and
1070
+ // refreshed from the async paths (start / resume / set / heartbeat) — a sync builder re-uses the
1071
+ // last known line and kicks off a refresh when the cache is still cold. `mathComputation:'off'`
1072
+ // is a true no-op: no probe, no line, nothing injected.
1073
+ let mathAvailZh='', mathAvailEn='', mathAvailBusy=false
1074
+ async function refreshMathAvailability(){
1075
+ if(params.mathComputation==='off'){ mathAvailZh=''; mathAvailEn=''; return }
1076
+ if(mathAvailBusy) return
1077
+ mathAvailBusy=true
1078
+ try {
1079
+ // Use THIS session's cached module instance (keyed by `mathProbeHost`): the probe cache lives
1080
+ // in the instance, so repeated refreshes cost nothing, and two sessions never share it.
1081
+ const inst=mathInstanceFor(mathProbeHost)
1082
+ const probe=inst?await inst.probe():await probeMathEngines(mathProbeHost)
1083
+ // The third argument is the MODE: the module drops the shell-fallback sentence in 'typed'
1084
+ // (prompts.md §4 consistency check 5) — the preset must not re-add it.
1085
+ mathAvailZh=mathAvailabilityLine(probe,'zh',params.mathMode)
1086
+ mathAvailEn=mathAvailabilityLine(probe,'en',params.mathMode)
1087
+ // The availability line is REQUIRED to carry the rule lines (prompts.md §2/§4). Referencing
1088
+ // both rule constants here is the drift check: if the module ever stops appending them, this
1089
+ // preset notices instead of shipping an availability line without the rules.
1090
+ if(mathAvailZh.indexOf(MATH_RULE_LINES[0])===-1||mathAvailEn.indexOf(MATH_RULE_LINES_EN[0])===-1){
1091
+ logActivity('math','警告:mathAvailabilityLine 未附带规则段(MATH_RULE_LINES/_EN),提示词可能缺少用法规则')
1092
+ }
1093
+ // P2a: the archive→edit→re-run workflow rule is part of the same contract (an old receipt is
1094
+ // NOT evidence for edited code). It is appended to the availability line by the module and to
1095
+ // both persona blocks by this preset — this is the drift check for the former.
1096
+ if(mathAvailZh.indexOf(MATH_ARCHIVE_WORKFLOW_LINE.trim())===-1||mathAvailEn.indexOf(MATH_ARCHIVE_WORKFLOW_LINE_EN.trim())===-1){
1097
+ logActivity('math','警告:mathAvailabilityLine 未附带归档工作流规则(MATH_ARCHIVE_WORKFLOW_LINE/_EN)')
1098
+ }
1099
+ } catch(e){ /* a probe failure must never break a prompt; keep the last known line */ }
1100
+ finally { mathAvailBusy=false }
1101
+ }
1102
+ /** The availability + rules block: the frozen persona tool line (tools/params shape) followed by
1103
+ * the dynamic zh + en availability lines. v4's prompts are bilingual (English frame, Chinese
1104
+ * rules), so both variants are injected; `mathComputation:'off'` yields '' (a true no-op). */
1105
+ function mathAvailabilityBlock(){
1106
+ if(params.mathComputation==='off') return ''
1107
+ if(!mathAvailZh&&!mathAvailEn){ refreshMathAvailability().catch(()=>{}); return '' }
1108
+ return [MATH_PERSONA_TOOL_LINE, mathAvailZh, mathAvailEn].filter(Boolean).join('\n')
1109
+ }
534
1110
  /** The `formal` object every non-off prompt documents in its JSON reply contract. */
535
1111
  function formalReplyField(target){
536
1112
  return '{"formal":{"target":"'+String(target||'p-x')+'","decision":"used|blocked|defect","file":"Formal/'+String(target||'p-x')+'.lean","note":"难度判断/阻塞原因/具体偏差"}}'
@@ -585,21 +1161,24 @@ export function apply(ctx) {
585
1161
  const txt=(await readTextAbs(dirAbs+'/'+e.name))||''
586
1162
  const name=String(e.name).replace(/\.lean$/,'')
587
1163
  const first=(txt.split('\n').filter(l=>l.trim()&&!/^\s*(\/\/|--|import)/.test(l))[0]||'').trim().slice(0,110)
588
- rows.push('| '+name+' | '+rel+' | '+kindLabel+' | '+first.replace(/\|/g,'/')+' |')
1164
+ // §4.4: the dependency column v3 already has — one entry per `import` line, so a reader
1165
+ // can see what a reusable file pulls in (and therefore what must travel with it).
1166
+ const deps=(txt.match(/^\s*import\s+[^\s]+/gm)||[]).map(l=>l.replace(/^\s*import\s+/,'').trim()).filter(Boolean)
1167
+ rows.push('| '+name+' | '+rel+' | '+kindLabel+' | '+(deps.length?deps.join(', ').replace(/\|/g,'/'):'—')+' | '+first.replace(/\|/g,'/')+' |')
589
1168
  }
590
1169
  } catch(e){ /* listing is best-effort */ }
591
1170
  return rows
592
1171
  }
593
1172
  const libRows=await scan(formalLibRoot(),'Formal/Lib','def')
594
1173
  await writeTextAbs(formalLibRoot()+'/Index.md',['# 可复用 Lean 定义库(跨项目)|'+currentProject,'',
595
- '> 写新定义之前先查这里:能复用就不要重新定义。','',
596
- '| 名称 | 文件 | 类别 | 摘要 |','|---|---|---|---|']
597
- .concat(libRows.length?libRows:['| (暂无) | | | |']).join('\n')+'\n')
1174
+ '> 写新定义之前先查这里:能复用就不要重新定义。复用方式:`import Formal.Lib.<名称>`(模块根 = VibeMath 根,框架已加入编译搜索路径),或用 vibe_v4_lean_read 取原文逐字复制。','',
1175
+ '| 名称 | 文件 | 类别 | 依赖 | 摘要 |','|---|---|---|---|---|']
1176
+ .concat(libRows.length?libRows:['| (暂无) | | | | |']).join('\n')+'\n')
598
1177
  const provedRows=await scan(formalProvedRoot(),'Formal/Proved','lemma')
599
1178
  await writeTextAbs(formalProvedRoot()+'/Index.md',['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|'+currentProject,'',
600
- '> 这些文件是通过内核检查的引理,可直接 import 复用。','',
601
- '| 名称 | 文件 | 类别 | 陈述 |','|---|---|---|---|']
602
- .concat(provedRows.length?provedRows:['| (暂无) | | | |']).join('\n')+'\n')
1179
+ '> 这些文件是通过内核检查的引理,可直接 `import Formal.Proved.<名称>` 复用(模块根 = VibeMath 根)。','',
1180
+ '| 名称 | 文件 | 类别 | 依赖 | 陈述 |','|---|---|---|---|---|']
1181
+ .concat(provedRows.length?provedRows:['| (暂无) | | | | |']).join('\n')+'\n')
603
1182
  await writeFormalIndex()
604
1183
  await writeFormalTodo()
605
1184
  return {lib:libRows.length,proved:provedRows.length,objects:Object.keys(formalRecords()).length}
@@ -613,7 +1192,24 @@ export function apply(ctx) {
613
1192
  */
614
1193
  async function leanRunTool(memberId,o){
615
1194
  const args=o||{}
616
- const run=await leanRunFile(String(args.file||''),args.timeout_ms)
1195
+ const rel=String(args.file||'')
1196
+ // §2.3 (leanAsync=true, the default): ENQUEUE and return immediately — the member keeps
1197
+ // working; the result lands in Formal/ + the indexes and is announced in the next prompt.
1198
+ if(LEAN_ASYNC_ON()){
1199
+ const abs=leanAbsPath(rel)
1200
+ if(abs===null||!/\.lean$/.test(abs)) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'Lean file must live under '+vibeRoot().replace(/\\/g,'/')+'/ and end in .lean (got '+rel+')',file:rel}
1201
+ const content=await readTextAbs(abs)
1202
+ if(content===undefined) return {ok:false,code:'V4_NOT_FOUND',message:'no such file: '+rel,file:rel}
1203
+ const target=String(args.target||'').trim()
1204
+ const key=formalKey(target||rel.replace(/\.lean$/,''))
1205
+ const q=await enqueueLeanJob({kind:'run',key,target,rel,abs,content,via:memberId||'host'})
1206
+ if(q.duplicate) return {ok:true,async:leanJobView(q.job),jobId:q.job.jobId,file:rel,mode:formalMode(),
1207
+ message:'同一内容已在队列/已归档('+q.job.state+'),未重复入队;用 vibe_v4_lean_lib 的 jobs 查看,或用 lean_read 取原文。'}
1208
+ logActivity('formal',(memberId||'host')+' vibe_v4_lean_run '+rel+' → 已入队 '+q.job.jobId)
1209
+ return {ok:true,async:{jobId:q.job.jobId,state:'queued'},jobId:q.job.jobId,file:rel,mode:formalMode(),
1210
+ message:'已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在下一轮提示里公告;也可用 vibe_v4_lean_lib 的 jobs 字段随时查看。'}
1211
+ }
1212
+ const run=await leanRunFile(rel,args.timeout_ms)
617
1213
  try {
618
1214
  if(run.ok||run.file){
619
1215
  if(String(args.target||'').trim()) formalSetRun(String(args.target),run)
@@ -625,6 +1221,9 @@ export function apply(ctx) {
625
1221
  // bare `lean_run` abbreviation (docs §6 hard requirement 1).
626
1222
  logActivity('formal',(memberId||'host')+' vibe_v4_lean_run '+(run.file||String(args.file||''))+' → '+(run.ok?'通过':(run.code||'未通过')))
627
1223
  return Object.assign({ok:!!run.ok},run,{
1224
+ // `leanAsync=false`: the historical shape, plus an explicit `async:null` so a caller can
1225
+ // tell "synchronous result" from "queued job" without guessing.
1226
+ async:null, jobId:null,
628
1227
  mode:formalMode(),
629
1228
  hint: run.ok
630
1229
  ? "通过。若是某个对象的证明,请用 vibe_v4_lean_archive kind='proof' 归档(会写入 Verified/Lean/ 并把审查对象变成忠实性);若是可复用定义/引理,用 kind='def'/'lemma' 归档到全局库——归档前先跑通(run=true 或先 vibe_v4_lean_run):跑不通的定义不要进可复用库。"
@@ -659,20 +1258,57 @@ export function apply(ctx) {
659
1258
  if(!name||name==='id') return {ok:false,code:'V4_INVALID_ARGUMENT',message:'name is required for a reusable definition/lemma'}
660
1259
  const got=await bodyFrom(); if(got.error) return got.error
661
1260
  const rel='Formal/'+(kind==='def'?'Lib':'Proved')+'/'+name+'.lean'
662
- if(!await writeTextAbs(vibeRelAbs(rel),got.body)) return {ok:false,code:'V4_WRITE_FAILED',message:'could not write '+rel}
1261
+ const abs=vibeRelAbs(rel)
1262
+ const sha=sha256Text(got.body)
1263
+ // AMENDMENT §4: de-duplication keys on the WHOLE build context (content + engine + argv +
1264
+ // search paths), so identical content compiled by a different toolchain is a NEW job.
1265
+ const jidFor=await leanJobIdFor(name,got.body)
1266
+ const jobId=jidFor.jobId
1267
+ // §4.3: the SAME content that already compiled green is a no-op — no rewrite, no recompile.
1268
+ const prior=leanJobs[jobId]
1269
+ if(prior&&prior.state==='settled'&&prior.run&&prior.run.ok&&sha256Text((await readTextAbs(abs))||'')===sha){
1270
+ logActivity('formal',String(memberId||'host')+' 归档'+kind+' '+name+' → 去重(内容与已归档版本一致)')
1271
+ return {ok:true,kind,name,file:rel,deduped:true,sha256:sha,jobId,async:leanJobView(prior),run:prior.run,
1272
+ note:'内容与已归档版本一致,跳过重写与重编译'}
1273
+ }
1274
+ if(!await writeTextAbs(abs,got.body)) return {ok:false,code:'V4_WRITE_FAILED',message:'could not write '+rel}
1275
+ if(LEAN_ASYNC_ON()){
1276
+ const q=await enqueueLeanJob({kind,key:name,name,rel,abs,content:got.body,sha256:sha,ctx:jidFor.ctx,via:memberId||'host'})
1277
+ logActivity('formal',String(memberId||'host')+' 归档'+(kind==='def'?'可复用定义':'已证引理')+' '+name+' → '+rel+'(已入队 '+(q.job&&q.job.jobId||jobId)+')')
1278
+ return {ok:true,kind,name,file:rel,sha256:sha,jobId:(q.job&&q.job.jobId)||jobId,
1279
+ async:{jobId:(q.job&&q.job.jobId)||jobId,state:q.duplicate?'queued':'queued'},run:undefined,
1280
+ note:'已并入全局可复用库并**入队后台编译**:通过前不要在别处把它当作已核对引理;结果见 vibe_v4_lean_lib.jobs 与下一轮【形式化结果】行'}
1281
+ }
663
1282
  // The global library sits BESIDE the project tree, so it must be executed through its
664
1283
  // ABSOLUTE path — the project-relative form would resolve inside frameworkRoot.
665
- const run=args.run===false?null:await leanRunFile(vibeRelAbs(rel))
1284
+ const run=args.run===false?null:await leanRunFile(abs)
666
1285
  try { await rebuildLeanLibIndexes() } catch(e){ /* index rebuild is best-effort */ }
667
1286
  logActivity('formal',String(memberId||'host')+' 归档'+(kind==='def'?'可复用定义':'已证引理')+' '+name+' → '+rel+(run?('(运行 '+(run.ok?'通过':'未通过')+')'):''))
668
- return {ok:true,kind,name,file:rel,run:run||undefined,note:'已并入全局可复用库,后续项目可直接 import 复用'}
1287
+ return {ok:true,kind,name,file:rel,deduped:false,sha256:sha,async:null,run:run||undefined,note:'已并入全局可复用库,后续项目可直接 import 复用'}
669
1288
  }
670
1289
  if(kind==='proof'){
671
1290
  const key=formalKey(String(args.target||''))
672
1291
  if(!key) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'target is required for kind=proof'}
673
1292
  const got=await bodyFrom(); if(got.error) return got.error
674
1293
  const workRel='Formal/'+key+'.lean'
1294
+ const sha=sha256Text(got.body)
1295
+ const jidFor=await leanJobIdFor(key,got.body)
1296
+ // §4.3 (proof variant): identical content + the object is ALREADY `passed` and the archived
1297
+ // proof still hashes the same ⇒ nothing to do.
1298
+ const prevRec=formalOf(key)
1299
+ if(prevRec.status==='passed'&&sha256Text((await readText('Verified/Lean/'+key+'.lean'))||'')===sha){
1300
+ logActivity('formal',String(memberId||'host')+' 归档证明 '+key+' → 去重(对象已 passed 且归档内容一致)')
1301
+ return {ok:true,kind,target:key,file:workRel,proof:prevRec.proof,passed:true,deduped:true,sha256:sha,async:leanJobView(leanJobs[jidFor.jobId])||null,status:prevRec.status}
1302
+ }
675
1303
  if(!await writeText(workRel,got.body)) return {ok:false,code:'V4_WRITE_FAILED',message:'could not write '+workRel}
1304
+ if(LEAN_ASYNC_ON()){
1305
+ const q=await enqueueLeanJob({kind:'proof',key,target:key,rel:workRel,abs:leanAbsPath(workRel),content:got.body,sha256:sha,ctx:jidFor.ctx,via:memberId||'host'})
1306
+ const jid=(q.job&&q.job.jobId)||jidFor.jobId
1307
+ logActivity('formal',String(memberId||'host')+' 为 '+key+' 归档形式化证明 '+workRel+'(已入队 '+jid+';通过前不改 passed)')
1308
+ return {ok:true,kind,target:key,file:workRel,sha256:sha,jobId:jid,
1309
+ async:{jobId:jid,state:'queued'},run:undefined,
1310
+ note:'证明已入队后台编译:**只有作业落地为通过**才会写入 Verified/Lean/'+key+'.lean 并把对象置 passed;其余情况保持 attempted,见 vibe_v4_lean_lib.jobs'}
1311
+ }
676
1312
  const run=await leanRunFile(workRel)
677
1313
  const prev=formalOf(key)
678
1314
  const passed=!!run.ok
@@ -692,7 +1328,7 @@ export function apply(ctx) {
692
1328
  await putFormal(key,rec)
693
1329
  try { await rebuildLeanLibIndexes() } catch(e){ /* best-effort */ }
694
1330
  logActivity('formal',String(memberId||'host')+' 为 '+key+' 归档形式化证明 '+workRel+(passed?'(**通过**,已归档到 '+rec.proof+',验证转为忠实性审查)':'(**未通过**:'+tail(run.stderr||run.message,160)+')'))
695
- return {ok:true,kind,target:key,file:workRel,proof:rec.proof,passed,run,status:rec.status}
1331
+ return {ok:true,kind,target:key,file:workRel,proof:rec.proof,passed,run,status:rec.status,deduped:false,sha256:sha,async:null}
696
1332
  }
697
1333
  if(kind==='blocked'){
698
1334
  const key=formalKey(String(args.target||''))
@@ -970,7 +1606,9 @@ export function apply(ctx) {
970
1606
  // living under right now.
971
1607
  return '[核心规则重申] 只有 Verified/(及标记"已验证·真/假")算已确立;验证须全组一致(全真或全假)才作数,否则留库附平均概率;你只写自己的库('+base+'/ 的 Progress/<你>/、Propos/<你>/、Methods/<你>/、Subproblems/<你>/),可只读任何人的库;任务分工由团队讨论决定;退出只输出一个 JSON 对象。'
972
1608
  +'\n`facilitator` 是**框架/人类介入的信使名**,不是常驻成员,也不在编制里——**不要向它回信**(`vibe_v4_send_message` 会返回 no such resident);要回话请用本轮回执的 "input" 字段(会转给全组)或 `vibe_v4_send_message {to:"all"}`。'
973
- +(formalOn()?('\n'+formalWorkLine()):'')
1609
+ +(leanDailyOn()?('\n'+formalWorkLine()):'')
1610
+ // math_computation is INDEPENDENT of formalVerify (its own `mathComputation` switch).
1611
+ +(mathAvailabilityBlock()?('\n'+mathAvailabilityBlock()):'')
974
1612
  }
975
1613
  function brainstormPrompt(r){
976
1614
  return (params.residentPersona?params.residentPersona+'\n':'')
@@ -983,12 +1621,23 @@ export function apply(ctx) {
983
1621
  +'{"summary":"<your insight / direction / rough plan, one tight paragraph>","solved":false}'
984
1622
  }
985
1623
  async function normalPrompt(r){
1624
+ // Consume-and-clear ONCE: calling the accessor twice would return '' the second time (bug).
1625
+ const leanNotice=pendingLeanNoticeText()
1626
+ // The math availability line is built from the probe cache; awaiting the refresh HERE means a
1627
+ // cold cache still yields a complete line on the first wake (later wakes hit the cache).
1628
+ await refreshMathAvailability()
1629
+ const mathAvail=mathAvailabilityBlock()
986
1630
  return (params.residentPersona?params.residentPersona+'\n':'')
987
1631
  +'Resident researcher '+r.rId+' — 第 '+r.rounds+' 轮。一切由你和团队讨论决定。动手前先**读别人的库**对齐事实、避免重复;把新进展/结论**直接用 fs 写进你自己的文件**;想对团队说的话放 "input"(会转给其他常驻)。\n'
988
1632
  +'\n团队成员:\n'+banner()+'\n'
989
1633
  +'New items:\n'+ (await inboxText(r.rId)) +'\n'
1634
+ // Settled background compiles are announced ONCE, in the next prompt constructed (spec §2.4):
1635
+ // the same dynamic discipline as the mode — an announcement is never frozen into a brief.
1636
+ +(leanNotice?('\n'+leanNotice+'\n'):'')
990
1637
  // 顺手形式化: computed from the CURRENT mode on every wake (docs §1: the mode is dynamic).
991
- +(formalOn()?('\n'+formalWorkLine()+'\n'):'')
1638
+ +(leanDailyOn()?('\n'+formalWorkLine()+'\n'):'')
1639
+ // math_computation availability (dynamic probe cache; independent of formalVerify).
1640
+ +(mathAvail?('\n'+mathAvail+'\n'):'')
992
1641
  +'Reply with ONLY a JSON object:\n'
993
1642
  +'{"summary":"<what you did / decided this round, 1-3 sentences>","input":"<optional: a message to the whole team, or \\"\\">","solved":false,"propose_verify":"<id|null>","propose_meeting":"<agenda|null>","propose_task":"<task title|null>","task_desc":"<optional: why this task matters / what it covers|null>","claim_task":"<task id|null>","task_done":"<task id|null>","contextPct":40'
994
1643
  +(formalOn()?(',"formal":{"target":"<对象 id>","decision":"used|blocked|defect","file":"Formal/<对象 id>.lean","note":"难度判断/阻塞原因/具体偏差"}'):'')
@@ -1034,6 +1683,9 @@ export function apply(ctx) {
1034
1683
  L.push('')
1035
1684
  L.push(formalPromptBlock(vs.targetId))
1036
1685
  }
1686
+ // A settled background compile is announced once, here too: a voter must not conclude on a
1687
+ // "passed" that only landed in the queue a moment ago (spec §2.4/§5 B).
1688
+ { const notice=pendingLeanNoticeText(); if(notice){ L.push(''); L.push(notice) } }
1037
1689
  L.push('')
1038
1690
  L.push('Reply with ONLY a JSON object:')
1039
1691
  L.push('{"vote":{"verdict":1,"reason":"<your logic>"}}')
@@ -1472,10 +2124,24 @@ export function apply(ctx) {
1472
2124
  const allSolved = allSpoke && speakers.length>0 && speakers.every(iv=>iv.voteSolved===true)
1473
2125
  logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
1474
2126
  if(allSolved){
2127
+ // ── FINAL PAPER ENTRY (docs/final-paper.md §A1) ─────────────────────────────────────────────────
2128
+ // The paper phase MUST start HERE, BEFORE `running=false; autoDone=true` and before the
2129
+ // coordination state is released: afterwards `startMeeting` refuses (autoDone), this
2130
+ // handler's meeting/verify branches return early on `!running||autoDone`, and
2131
+ // `scheduleNext` does nothing — co-writing/merge/cross-review could never run. The run
2132
+ // therefore stays `running` while the team writes; `paperFinalize()` performs the
2133
+ // completion flip once the paper exists (or has degraded safely).
2134
+ meetingState=null; pendingMeeting=null; verifyState=null; pendingVerify=[]; wakeKind.clear()
2135
+ await saveAll()
2136
+ if(params.finalPaper){
2137
+ const paper=await paperStart('auto',{deferCompletion:true})
2138
+ if(paper&&paper.ok){
2139
+ logActivity('paper','收口:一致性停止票通过('+PAPER_CLOSURE_SIGNAL+')→ 进入最终论文阶段,论文完成后才标记完成')
2140
+ return
2141
+ }
2142
+ logActivity('paper','最终论文未能启动('+((paper&&paper.message)||'unknown')+')→ 按原路径收口')
2143
+ } else logActivity('paper','finalPaper=false:跳过最终论文,直接收口')
1475
2144
  running=false; autoDone=true; phase='done'; clearHeartbeat()
1476
- // symmetric with initAbort: the STOP path must also release the coordination state, else
1477
- // status keeps reporting a phantom in-progress meeting (and stale wake kinds) forever.
1478
- meetingState=null; wakeKind.clear(); pendingMeeting=null; verifyState=null; pendingVerify=[]
1479
2145
  logActivity('stop','all residents agree: problem solved'); await saveAll(); return
1480
2146
  }
1481
2147
  meetingState=null; wakeKind.clear(); await saveAll()
@@ -1744,7 +2410,10 @@ export function apply(ctx) {
1744
2410
  function heartbeatPrompt(r){
1745
2411
  return (params.residentPersona?params.residentPersona+'\n':'')
1746
2412
  +'Resident researcher '+r.rId+' — CHECKPOINT(团队空闲,请由你们继续自主推进)。当前项目尚未解决(除非你已确认)。团队在等待有人继续:请**继续解决这个问题**——读他人的库对齐、推进某个子问题/引理/方法、尝试一条路线;或向团队发消息(input)、提议任务(propose_task)让大家分工。若你确实认为问题已解决、或已彻底无路可走,才提议开会(propose_meeting)让团队表决/商量、或声明 solved=true。默认立场是:**请推进,而不是停在原地。**\n'
1747
- +(formalOn()?(formalWorkLine()+'\n'):'')
2413
+ +(leanDailyOn()?(formalWorkLine()+'\n'):'')
2414
+ // math_computation availability (cached; `normalPrompt` awaits the refresh, so a heartbeat
2415
+ // that follows a wake is never cold).
2416
+ +(mathAvailabilityBlock()?(mathAvailabilityBlock()+'\n'):'')
1748
2417
  +'Reply with ONLY a JSON object:\n'
1749
2418
  +'{"summary":"<what you will do / what you advanced this round>","input":"<optional: a message to the whole team, or \\"\\">","solved":false,"propose_verify":"<id|null>","propose_meeting":"<agenda|null>","propose_task":"<task title|null>","task_desc":"<optional: why this task matters / what it covers|null>","claim_task":"<id|null>","contextPct":40'
1750
2419
  +(formalOn()?(',"formal":{"target":"<对象 id>","decision":"used|blocked|defect","file":"Formal/<对象 id>.lean","note":"难度判断/阻塞原因/具体偏差"}'):'')
@@ -1755,7 +2424,12 @@ export function apply(ctx) {
1755
2424
  clearHeartbeat()
1756
2425
  const ms=posMs(params.activityTimeoutMs,120000)
1757
2426
  if(typeof ctx.timeout!=='function') return
1758
- heartbeatDisposer=ctx.timeout(()=>{ heartbeatDisposer=null; scheduleNext().catch(()=>{}) }, ms)
2427
+ // §2.2: the EXISTING heartbeat is the queue driver (same place, same discipline as the paper
2428
+ // retry: independent of `scheduler.running`). `runLeanQueue()` is one boolean when idle.
2429
+ heartbeatDisposer=ctx.timeout(()=>{ heartbeatDisposer=null; runLeanQueue().catch(()=>{}).then(()=>scheduleNext().catch(()=>{})) }, ms)
2430
+ // Keep the math availability line current (engines can be installed while a run is live). The
2431
+ // module's probe cache makes this a no-op after the first probe.
2432
+ refreshMathAvailability().catch(()=>{})
1759
2433
  }
1760
2434
  // Real DSH /compact of a resident's OWN session via ctx.compaction (if the host provides it);
1761
2435
  // falling back silently to the resident self-summary directive when the service is absent.
@@ -1849,7 +2523,19 @@ export function apply(ctx) {
1849
2523
 
1850
2524
  // ---- liveness / scheduling ----
1851
2525
  async function scheduleNext(){
1852
- if(!running||autoDone){ clearHeartbeat(); return }
2526
+ if(!running||autoDone){
2527
+ // A MANUALLY triggered final paper may run on an already-concluded run; it is the one thing
2528
+ // that still needs the timer, so it keeps its own pacing here (paperAdvance re-arms the
2529
+ // heartbeat while it waits and clears it once the paper is done).
2530
+ if(paperState&&paperState.status!=='done'&&paperState.status!=='failed'){ try{ await paperAdvance('scheduler-concluded') }catch(e){} return }
2531
+ clearHeartbeat(); return
2532
+ }
2533
+ // The run is CONCLUDING (its closing meeting voted unanimously to stop): the ONLY remaining
2534
+ // work is the final paper. No new research round may be dispatched behind it (docs/final-paper.md §A1).
2535
+ if(paperState&&paperState.status!=='done'&&paperState.status!=='failed'){
2536
+ try{ await paperAdvance('scheduler-paper') }catch(e){ console.error('vibe-math-v4: paper pass failed: '+String((e&&e.message)||e)) }
2537
+ return
2538
+ }
1853
2539
  // Residents the host's live-child cap refused are retried here — the single choke point every
1854
2540
  // scheduling pass goes through — so a refused spawn is deferred by a whole round (and paced by
1855
2541
  // the heartbeat) instead of being retried inside the loop that discovered the refusal.
@@ -1991,6 +2677,10 @@ export function apply(ctx) {
1991
2677
  const parsed=parseReply(output)
1992
2678
  const kind=wakeKind.get(r.rId)||'normal'
1993
2679
  postmark(r, parsed, kind) // context/compact bookkeeping — a mid-consensus turn may NOT ack a compaction it never received
2680
+ // The final-paper flow (docs/final-paper.md §A1) has its own wake kinds and its own state machine. It is
2681
+ // handled BEFORE the meeting/verify branches and before their `!running||autoDone` guards,
2682
+ // because a manually triggered paper legitimately runs on an already-concluded run.
2683
+ if(kind.indexOf('paper:')===0){ await onPaperWakeEnd(r,parsed); return }
1994
2684
  if(kind==='meeting' && meetingState){
1995
2685
  meetingState.inputs[r.rId]={input:parsed.input||parsed.summary||'',voteSolved:typeof parsed.voteSolved==='boolean'?parsed.voteSolved:null,propose_verify:parsed.propose_verify||null,propose_task:parsed.propose_task||null,task_desc:parsed.task_desc||'',claim_task:parsed.claim_task||null}
1996
2686
  meetingState.lastInputAt=now()
@@ -2048,6 +2738,714 @@ export function apply(ctx) {
2048
2738
  await saveAll(); await scheduleNext()
2049
2739
  }
2050
2740
 
2741
+ // ================= final paper (docs/final-paper.md §3-§5) =================
2742
+ // When v4's consensus has converged (§2: the unanimous "solved" meeting closed, no ballot in
2743
+ // flight, nothing queued) the run must produce a PAPER, not stop at a chat log. The team
2744
+ // co-authors it: every resident writes the part it owns, the facilitator (the framework)
2745
+ // MERGES the parts, every participant CROSS-REVIEWS at least one other part, the `paperEditor`
2746
+ // finalises the merged draft, and the paper is finalised only when every participant has
2747
+ // explicitly stated it is deliverable. Dissent iterates a bounded number of rounds and, at the
2748
+ // cap, the disagreement is recorded in the appendix and the run is warned about.
2749
+ //
2750
+ // Everything the paper states comes from EVIDENCE ALREADY ON DISK (Verified/ cards, Propos/
2751
+ // cards, Methods/ cards, Shared/meetings|debates/, the Lean records) or from the team's own
2752
+ // written parts. Nothing is invented; unresolved/refuted items are flagged as such.
2753
+ const PAPER_FORMATS=['both','md','tex'], PAPER_LANGS=['zh','en']
2754
+ // v2 §A5: v4's `facilitator` is NOT a roster member and has no LLM (it is the framework / the
2755
+ // human-side messenger), so "let the facilitator unify the notation" has no author. The editor is
2756
+ // therefore the SESSION ROOT (the office = the human side) by default, or one named resident.
2757
+ const PAPER_EDITOR_DEFAULT='office'
2758
+ function paperEditorOk(v){ const s=String(v==null?'':v).trim(); return s==='office'||/^resident:[^\s]+$/.test(s) }
2759
+ // v2 §A4: v4's REAL closure signal is the closing meeting's UNANIMOUS stop vote — the branch
2760
+ // below in `finalizeMeeting` (allSolved). The v1 wording ("every verify target decided / mailbox
2761
+ // empty") is stricter than the code and would never fire, so the paper phase is entered from
2762
+ // inside that exact branch and any still-未决 item is flagged in the paper instead.
2763
+ const PAPER_CLOSURE_SIGNAL='finalizeMeeting/allSolved(一致性会议的一致停止票)'
2764
+ const PAPER_MAX_ROUNDS=2 // bounded iterations after dissent (docs/final-paper.md §4: "有上限")
2765
+ const PAPER_NUDGE_TRIES=2 // paced re-asks before the step deadline declares a timeout
2766
+ const PAPER_WAIT_FLOOR_MS=2000 // a step's deadline is at least this (tests run with tiny timeouts)
2767
+ const PAPER_KINDS={part:'paper:part',review:'paper:review',final:'paper:final',vote:'paper:vote',revise:'paper:revise'}
2768
+ const PAPER_MARK={part:'[PAPER PART]',review:'[PAPER REVIEW]',final:'[PAPER FINAL]',vote:'[PAPER DELIVERABLE]',revise:'[PAPER REVISE]'}
2769
+ const PAPER_HEAD_ZH=['标题、作者、日期与摘要','引言与问题背景','原问题的完整解法','已检验通过的命题','已解决的子问题与中间成果','创造或发现的有价值之物','规律总结','讨论、局限与展望','附录:证据与文件索引']
2770
+ 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']
2771
+
2772
+ function paperTitles(lang){ return lang==='en'?PAPER_HEAD_EN:PAPER_HEAD_ZH }
2773
+ /** The run id doubles as the v4 "institute id": Paper/<run id>/ (docs/final-paper.md §5). Sanitised through
2774
+ * `idSafe`+`slugify`, so a hostile problem text/title can never produce `../`. */
2775
+ function paperIdFor(){ return idSafe(slugify('v4-'+problemId))+'-'+idSafe(runId) }
2776
+ function paperDirFor(){ return 'Paper/'+paperIdFor() }
2777
+ /** Containment (docs/final-paper.md §6): every paper write must stay inside `Paper/<id>/`. */
2778
+ async function paperWriteRel(rel,content){
2779
+ const dir=paperState?paperState.dir:paperDirFor()
2780
+ const norm=String(rel).replace(/\\/g,'/')
2781
+ if(norm.indexOf(dir+'/')!==0 || norm.indexOf('..')!==-1) return false
2782
+ return await writeText(norm,content)
2783
+ }
2784
+ function paperLog(line){ const ps=paperState; if(ps) ps.log.push(fmtTime()+'|'+String(line)) }
2785
+ function paperStepLimit(){ return Math.max(PAPER_WAIT_FLOOR_MS, recoverStallMs()*4) }
2786
+ function paperCfg(ov){
2787
+ const o=ov||{}
2788
+ const ed=String(o.editor==null?'':o.editor).trim()
2789
+ return {
2790
+ language:PAPER_LANGS.indexOf(String(o.lang))!==-1?String(o.lang):params.paperLanguage,
2791
+ format:PAPER_FORMATS.indexOf(String(o.format))!==-1?String(o.format):params.paperFormat,
2792
+ editor:paperEditorOk(ed)?ed:(paperEditorOk(params.paperEditor)?String(params.paperEditor):PAPER_EDITOR_DEFAULT),
2793
+ compilePdf:params.paperCompilePdf!==false,
2794
+ }
2795
+ }
2796
+ /** `resident:<id>` -> the id, `office`/absent -> ''. */
2797
+ function paperEditorResidentId(editor){ const m=/^resident:([^\s]+)$/.exec(String(editor||'')); return m?m[1]:'' }
2798
+ // ---- evidence (every claim in §4-§9 of the skeleton must be traceable to one of these) ----
2799
+ async function paperListDirFiles(base){
2800
+ const out=[]
2801
+ try {
2802
+ const t=await fs.resolve(base,{cwd:frameworkRoot()}); if(await fs.stat(t)===undefined) return out
2803
+ for(const e of (await fs.listDir(t))||[]){ if(!e||e.type!=='file'||!/\.md$/.test(String(e.name))) continue
2804
+ const rel=base+'/'+e.name; out.push({rel,text:(await readText(rel))||''}) }
2805
+ } catch(e){ /* best effort: an absent directory is an empty evidence set */ }
2806
+ out.sort((a,b)=>a.rel<b.rel?-1:a.rel>b.rel?1:0); return out
2807
+ }
2808
+ async function paperListCards(base){
2809
+ const out=[]
2810
+ try {
2811
+ const root=await fs.resolve(base,{cwd:frameworkRoot()}); if(await fs.stat(root)===undefined) return out
2812
+ for(const d of (await fs.listDir(root))||[]){ if(!d||d.type!=='directory') continue
2813
+ const dir=base+'/'+d.name
2814
+ try {
2815
+ const t=await fs.resolve(dir,{cwd:frameworkRoot()}); if(await fs.stat(t)===undefined) continue
2816
+ for(const e of (await fs.listDir(t))||[]){ if(!e||e.type!=='file'||!/\.md$/.test(String(e.name))) continue
2817
+ const rel=dir+'/'+e.name; out.push({rel,text:(await readText(rel))||''}) }
2818
+ } catch(e2){ /* one unreadable library must not stop the scan */ }
2819
+ }
2820
+ } catch(e){ /* best effort */ }
2821
+ out.sort((a,b)=>a.rel<b.rel?-1:a.rel>b.rel?1:0); return out
2822
+ }
2823
+ function paperField(text,name){
2824
+ const esc=String(name).replace(/[.*+?^${}()|[\]\\]/g,'\\$&')
2825
+ const re=new RegExp('(?:^|\\n|;\\s*)-\\s*'+esc+':\\s*([^;\\n]*)')
2826
+ const m=re.exec(String(text||'')); return m?m[1].trim():''
2827
+ }
2828
+ async function paperEvidence(){
2829
+ const [verifiedFiles,problemFiles,propos,methods,subproblems,meetings,debates]=await Promise.all([
2830
+ paperListDirFiles('Verified/命题'), paperListDirFiles('Verified/问题'), paperListCards('Propos'),
2831
+ paperListCards('Methods'), paperListCards('Subproblems'), paperListDirFiles('Shared/meetings'), paperListDirFiles('Shared/debates'),
2832
+ ])
2833
+ const parse=f=>({rel:f.rel,id:paperField(f.text,'ID'),结论:paperField(f.text,'结论'),概率:paperField(f.text,'概率'),
2834
+ 类型:paperField(f.text,'类型'),状态:paperField(f.text,'状态')||paperField(f.text,'状态'),标题:paperField(f.text,'标题'),
2835
+ 可信断言:paperField(f.text,'可信断言'),text:f.text})
2836
+ return {
2837
+ problem:problemText, problemId:problemId, runId:runId, project:currentProject, converged:!!autoDone,
2838
+ verified:verifiedFiles.map(parse), verifiedProblems:problemFiles.map(parse),
2839
+ propos:propos.map(parse), methods:methods.map(parse), subproblems:subproblems.map(parse),
2840
+ meetings:meetings.map(f=>f.rel), debates:debates.map(f=>f.rel),
2841
+ formal:formalView().objects.map(o=>({target:o.target,status:o.status,file:o.file,proof:o.proof})),
2842
+ index:[...verifiedFiles,...problemFiles,...propos,...methods,...subproblems,...meetings,...debates].map(f=>f.rel).sort(),
2843
+ }
2844
+ }
2845
+ 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') }
2846
+ /** Idempotency is per (run id, finalizedAt) plus per-artifact existence — docs/final-paper.md §C explicitly
2847
+ * drops v1's multi-file inputsHash. This only fingerprints one string for the log, never a
2848
+ * decision. */
2849
+ function paperFingerprint(ev){ return paperHash([ev.problemId,ev.runId,ev.problem,ev.index.join('|')].join('\n')) }
2850
+ // ---- prompts (stable markers so the flow is observable/driveable) ----
2851
+ function paperPrompt(kind,rid,again){
2852
+ const ps=paperState, T=paperTitles(ps.cfg.language), zh=ps.cfg.language!=='en'
2853
+ const head=zh
2854
+ ? '【最终论文·团队合写】你('+rid+')是本次运行的参与常驻。'
2855
+ : '[FINAL PAPER — TEAM CO-AUTHORING] You ('+rid+') are a participating resident of this run.'
2856
+ const common=(zh?'\n论文目录:`'+ps.dir+'/`(一切写入必须在这里面)。只整理**已有证据**:你自己的库、Verified/、Shared/meetings|debates/ 与既有卡片;**不得编造**;未决/被否证的条目必须显式标注。'
2857
+ :'\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.')
2858
+ if(kind==='part') return PAPER_MARK.part+'\n'+head+'\n'+(again?(zh?'(上一次没有收到你的部分,请重发。)\n':'(no part was received from you last time — please resend.)\n'):'')
2859
+ +(zh?'请撰写**你负责的那一部分**(你库里的命题/方法/子问题/进展,以及你亲自参与的会议与争论中确立的内容),成文即可,不必追求篇幅。\n'
2860
+ :'Write the part YOU own (the propositions/methods/sub-problems/progress in your library and what you personally established in meetings/debates).\n')
2861
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperPart":{"title":"<部分标题>","markdown":"<正文 markdown>"}}`(也可以直接用 fs 写到 `'+ps.dir+'/parts/'+rid+'.md`)。'
2862
+ :'Reply with one JSON object: `{"paperPart":{"title":"<title>","markdown":"<body markdown>"}}` (you may also write `'+ps.dir+'/parts/'+rid+'.md` with fs).')
2863
+ +'\n'+(zh?'九节骨架:':"The paper's nine sections: ")+T.join(' / ')
2864
+ if(kind==='review') return PAPER_MARK.review+'\n'+head+'\n'+(again?(zh?'(上一次的互审没有指名**其他**参与者,请重发。)\n':'(your last review did not name ANOTHER participant — please resend.)\n'):'')
2865
+ +(zh?'合并稿见 `'+ps.dir+'/draft.md`。请**审阅至少一位其他参与者的部分**(不能审自己),指出具体问题并给出可落实的改进。\n'
2866
+ :'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')
2867
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperReview":{"reviewed":"<被审的参与者 id>","points":["<问题>","..."],"improvements":"<你建议的改进>"}}`'
2868
+ :'Reply with one JSON object: `{"paperReview":{"reviewed":"<other participant id>","points":["..."],"improvements":"..."}}`')
2869
+ if(kind==='final') return PAPER_MARK.final+'\n'+head+'\n'
2870
+ +(zh?'你是本次的**定稿代表**(paperEditor)。请把合并稿 `'+ps.dir+'/draft.md` 与互审意见梳理成**最终稿**:去重、统一术语与记号,保留全部证据与未决标注。\n'
2871
+ :'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')
2872
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperFinal":{"markdown":"<最终稿正文 markdown>"}}`(或直接写 `'+ps.dir+'/final.md`)。'
2873
+ :'Reply with one JSON object: `{"paperFinal":{"markdown":"<final body markdown>"}}` (or write `'+ps.dir+'/final.md`).')
2874
+ // vote / revise
2875
+ return PAPER_MARK[kind]+'\n'+head+'\n'
2876
+ +(zh?'请阅读最终稿 `'+ps.dir+'/final.md`,然后**明确表态它是否可交付**。有反对必须说明理由(框架会迭代,超限会把分歧写进附录)。\n'
2877
+ :'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')
2878
+ +common+'\n'+(zh?'回复一个 JSON:`{"paperDeliverable":true|false,"reason":"<理由>"}`'
2879
+ :'Reply with one JSON object: `{"paperDeliverable":true|false,"reason":"<why>"}`')
2880
+ }
2881
+ // ---- state machine ----
2882
+ function paperHas(step,rid){
2883
+ const ps=paperState; if(!ps) return false
2884
+ if(step==='parts') return !!ps.parts[rid]
2885
+ if(step==='review') return !!ps.reviews[rid]
2886
+ if(step==='final') return !!ps.finalDraft
2887
+ if(step==='vote') return typeof ps.deliverable[rid]==='boolean'
2888
+ return false
2889
+ }
2890
+ function paperMarkMissing(step,rid){
2891
+ const ps=paperState
2892
+ if(step==='parts') ps.parts[rid]={text:'',missing:true,source:'missing'}
2893
+ else if(step==='review') ps.reviews[rid]={missing:true}
2894
+ else if(step==='final'){ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal() }
2895
+ else if(step==='vote') ps.deliverable[rid]=false
2896
+ paperLog('超时:'+rid+' 在 '+step+' 阶段未回应,已按"缺少该部分/未表态"记录')
2897
+ }
2898
+ async function paperWakeSome(ids,kind){
2899
+ const ps=paperState
2900
+ for(const rid of ids){
2901
+ const r=residents.get(rid)
2902
+ if(!r||!r.childId){ paperLog('警告:'+rid+' 已不在场,跳过 '+kind); paperMarkMissing(kind==='part'?'parts':kind==='review'?'review':kind==='final'?'final':'vote',rid); continue }
2903
+ if(busy.has(rid)) continue
2904
+ const ok=await wakeResident(r,paperPrompt(kind,rid),PAPER_KINDS[kind])
2905
+ if(!ok) paperLog('警告:无法唤醒 '+rid+' 执行 '+kind)
2906
+ }
2907
+ await paperWriteLog()
2908
+ }
2909
+ async function paperWait(step){
2910
+ const ps=paperState; if(!ps) return
2911
+ const miss=ps.participants.filter(x=>!paperHas(step,x))
2912
+ if(!miss.length) return await paperAdvance('wait-recheck')
2913
+ const nowTs=now()
2914
+ if(nowTs-ps.stepAt>=paperStepLimit()){
2915
+ for(const rid of miss) paperMarkMissing(step,rid)
2916
+ ps.warning=(ps.warning?ps.warning+';':'')+'有参与者在 '+step+' 阶段超时未回应('+miss.join('、')+'),已按"缺少该部分"记录并继续'
2917
+ paperLog('警告:'+ps.warning); ps.stepAt=nowTs
2918
+ await paperWriteLog(); return await paperAdvance('timeout')
2919
+ }
2920
+ // Paced re-ask: at most one nudge per stall window, and only PAPER_NUDGE_TRIES in total, so an
2921
+ // unresponsive host cannot produce a wake storm while the deadline is still open.
2922
+ if(nowTs-(ps.lastNudgeAt||0)>=posMs(params.activityTimeoutMs,120000)){
2923
+ ps.lastNudgeAt=nowTs
2924
+ for(const rid of miss){
2925
+ const key=rid+':'+step
2926
+ ps.attempts[key]=(ps.attempts[key]||0)+1
2927
+ if(ps.attempts[key]>PAPER_NUDGE_TRIES) continue
2928
+ const r=residents.get(rid)
2929
+ if(!r||!r.childId||busy.has(rid)) continue
2930
+ const ok=await wakeResident(r,paperPrompt(step,rid,true),PAPER_KINDS[step])
2931
+ if(!ok) paperLog('警告:无法唤醒 '+rid+'('+step+' 重试)')
2932
+ }
2933
+ await paperWriteLog()
2934
+ }
2935
+ armHeartbeat() // keep the clock running so the step deadline is actually reachable
2936
+ }
2937
+ async function paperAdvance(why){
2938
+ const ps=paperState
2939
+ if(!ps||ps.status==='done'||ps.status==='failed') return
2940
+ try {
2941
+ if(ps.status==='parts'){
2942
+ if(ps.participants.every(x=>paperHas('parts',x))) return await paperMerge()
2943
+ return await paperWait('parts')
2944
+ }
2945
+ if(ps.status==='review'){
2946
+ if(ps.participants.every(x=>paperHas('review',x))) return await paperFinalStep()
2947
+ return await paperWait('review')
2948
+ }
2949
+ if(ps.status==='final'){
2950
+ if(ps.finalDraft) return await paperVoteStep()
2951
+ return await paperWait('final')
2952
+ }
2953
+ if(ps.status==='vote'){
2954
+ if(!ps.participants.every(x=>paperHas('vote',x))) return await paperWait('vote')
2955
+ if(ps.participants.every(x=>ps.deliverable[x]===true)) return await paperFinalize()
2956
+ return await paperIterate()
2957
+ }
2958
+ } catch(e){ console.error('vibe-math-v4: paper step ('+(why||'')+') failed: '+String((e&&e.stack)||e)) }
2959
+ }
2960
+ async function paperMerge(){
2961
+ const ps=paperState
2962
+ const parts=ps.participants.map(rid=>({rid,text:String((ps.parts[rid]&&ps.parts[rid].text)||'')})).filter(p=>p.text.trim())
2963
+ const seen=new Map(), dups=[], terms=new Map(), conflicts=[], body=[]
2964
+ for(const p of parts){
2965
+ const paras=p.text.replace(/\r\n/g,'\n').split(/\n{2,}/)
2966
+ for(const raw of paras){
2967
+ const para=raw.trim(); if(!para) continue
2968
+ const norm=para.replace(/\s+/g,' ').toLowerCase()
2969
+ if(norm.length>=40 && seen.has(norm)){ dups.push({owner:seen.get(norm),dup:p.rid,head:para.slice(0,60)}); continue }
2970
+ if(norm.length>=40) seen.set(norm,p.rid)
2971
+ body.push(para)
2972
+ // Notation/term unification: the FIRST definition of a `- 术语: 定义` / `**术语**:…` line
2973
+ // wins; a different definition of the same term in another part is recorded as a conflict.
2974
+ const m=/^(?:[-*]\s*)?(?:\*\*)?([\u4e00-\u9fa5A-Za-z][^::*]{0,24})(?:\*\*)?\s*[::]\s*(\S.*)$/.exec(para)
2975
+ if(m){ const term=m[1].trim()
2976
+ if(!terms.has(term)) terms.set(term,{owner:p.rid,def:para})
2977
+ else if(terms.get(term).owner!==p.rid && terms.get(term).def!==para) conflicts.push({term,kept:terms.get(term).owner,conflict:p.rid}) }
2978
+ }
2979
+ }
2980
+ ps.draft=body.join('\n\n')
2981
+ const L=['# 合并稿(facilitator 合并)','','- 论文 id:'+ps.id,'- 参与成员:'+(ps.participants.join('、')||'(无常驻)'),
2982
+ '- 合并规则:跨部分**完全重复**的段落只保留首次出现;同一术语的**首个定义**为准,冲突记入下方。','']
2983
+ for(const p of parts) L.push('## 部分|'+p.rid,'',p.text.trim(),'')
2984
+ L.push('## 合并说明','')
2985
+ L.push('- 去重:'+(dups.length?dups.map(d=>'「'+d.head+'…」('+d.dup+' 重复了 '+d.owner+')').join(';'):'无跨部分重复段落'))
2986
+ L.push('- 术语/记号统一:'+(terms.size?[...terms.entries()].map(([k,v])=>k+'(取 '+v.owner+' 的定义)').join(';'):'未识别到形如「术语:定义」的行'))
2987
+ L.push('- 术语冲突:'+(conflicts.length?conflicts.map(c=>c.term+'(保留 '+c.kept+','+c.conflict+' 的定义不同)').join(';'):'无'))
2988
+ ps.mergeNotes={dups:dups.length,terms:terms.size,conflicts:conflicts.length}
2989
+ paperLog('facilitator 合并完成:'+parts.length+' 个部分,去重 '+dups.length+' 段,统一术语 '+terms.size+' 个,冲突 '+conflicts.length+' 处')
2990
+ await paperWriteRel(ps.dir+'/draft.md',L.join('\n')+'\n')
2991
+ ps.status='review'; ps.stepAt=now(); ps.lastNudgeAt=0
2992
+ await paperWriteMeta(); await paperWriteLog()
2993
+ if(!ps.participants.length){ return await paperFinalStep() }
2994
+ await paperWakeSome(ps.participants.slice(),'review')
2995
+ await paperAdvance('review-started')
2996
+ }
2997
+ /** The framework's own finalisation (paperEditor='facilitator'): the merged body plus the review
2998
+ * round APPLIED as an attributed revision block. No new claims are introduced. */
2999
+ function paperFacilitatorFinal(){
3000
+ const ps=paperState
3001
+ const L=[String(ps.draft||'').trim()]
3002
+ const rows=ps.participants.map(rid=>ps.reviews[rid]).filter(Boolean)
3003
+ if(rows.length){
3004
+ L.push('','## 互审改进(已采纳)')
3005
+ for(const rid of ps.participants){
3006
+ const rv=ps.reviews[rid]; if(!rv) continue
3007
+ if(rv.missing){ L.push('- '+rid+':互审超时未提交(已记入附录)'); continue }
3008
+ const pts=(rv.points||[]).map(x=>'「'+x+'」').join('、')
3009
+ L.push('- '+rid+' → 审阅 '+rv.reviewed+':'+(pts||'(未列出具体问题)')+(rv.improvements?(';改进:'+rv.improvements):''))
3010
+ }
3011
+ }
3012
+ return L.join('\n').trim()
3013
+ }
3014
+ async function paperFinalStep(){
3015
+ const ps=paperState
3016
+ ps.status='final'; ps.stepAt=now(); ps.lastNudgeAt=0
3017
+ const wantId=paperEditorResidentId(ps.cfg.editor)
3018
+ const r=wantId?residents.get(wantId):undefined
3019
+ if(wantId && r && r.childId){
3020
+ ps.editorId=wantId
3021
+ paperLog('定稿代表:resident:'+wantId+'(paperEditor 指定)')
3022
+ const ok=await wakeResident(r,paperPrompt('final',wantId),PAPER_KINDS.final)
3023
+ if(ok){ await paperWriteMeta(); await paperWriteLog(); armHeartbeat(); return }
3024
+ ps.warning='无法唤醒定稿代表 resident:'+wantId+',降级到 office'
3025
+ ps.editorDowngraded=true; paperLog('警告:'+ps.warning)
3026
+ } else if(wantId){
3027
+ // v2 §A5: the named editor left the roster (e.g. removeMember) -> downgrade to office and
3028
+ // RECORD the downgrade in meta + paper.log.md (never silently substitute an author).
3029
+ ps.warning='定稿代表 resident:'+wantId+' 已不在编制(可能已被解雇),降级到 office'
3030
+ ps.editorDowngraded=true; paperLog('警告:'+ps.warning)
3031
+ }
3032
+ ps.editorId='office'
3033
+ paperLog('定稿代表:office(会话根/人类侧)—— 框架依 office 之责把合并稿与互审记录整理成最终稿')
3034
+ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal()
3035
+ await paperWriteRel(ps.dir+'/final.md',ps.finalDraft)
3036
+ await paperWriteMeta(); await paperWriteLog()
3037
+ return await paperVoteStep()
3038
+ }
3039
+ async function paperVoteStep(){
3040
+ const ps=paperState
3041
+ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal()
3042
+ await paperWriteRel(ps.dir+'/final.md',ps.finalDraft)
3043
+ ps.status='vote'; ps.stepAt=now(); ps.lastNudgeAt=0
3044
+ paperLog('交付表决:请全体参与成员明确表态"可交付"')
3045
+ await paperWriteMeta(); await paperWriteLog()
3046
+ if(!ps.participants.length) return await paperFinalize()
3047
+ await paperWakeSome(ps.participants.slice(),'vote')
3048
+ await paperAdvance('vote-started')
3049
+ }
3050
+ async function paperIterate(){
3051
+ const ps=paperState
3052
+ const against=ps.participants.filter(x=>ps.deliverable[x]!==true)
3053
+ ps.dissent=against.slice()
3054
+ ps.round+=1
3055
+ paperLog('第 '+ps.round+' 轮:'+against.join('、')+' 未表示可交付('+(against.map(x=>ps.deliverable[x]===false?'明确反对':'未表态').join(';'))+')')
3056
+ if(ps.round>ps.maxRounds){
3057
+ ps.warning=(ps.warning?ps.warning+';':'')+'互审/交付迭代达到上限('+ps.maxRounds+'):'+against.join('、')+' 的分歧已写入附录'
3058
+ paperLog('警告:'+ps.warning)
3059
+ return await paperFinalize()
3060
+ }
3061
+ for(const rid of against) ps.deliverable[rid]=undefined
3062
+ ps.status='vote'; ps.stepAt=now(); ps.lastNudgeAt=0
3063
+ await paperWriteMeta(); await paperWriteLog()
3064
+ await paperWakeSome(against,'revise')
3065
+ await paperAdvance('revise-started')
3066
+ }
3067
+ // ---- content: the nine-section skeleton, built ONLY from evidence + the team's own text ----
3068
+ function paperExtractSection(draft,words){
3069
+ const lines=String(draft||'').split(/\r?\n/)
3070
+ let start=-1,level=0
3071
+ for(let i=0;i<lines.length;i++){
3072
+ const m=/^(#{1,6})\s*(.+)$/.exec(lines[i])
3073
+ if(m&&new RegExp(words,'i').test(m[2])){ start=i+1; level=m[1].length; break }
3074
+ }
3075
+ if(start<0) return ''
3076
+ const out=[]
3077
+ for(let i=start;i<lines.length;i++){
3078
+ const m=/^(#{1,6})\s*/.exec(lines[i])
3079
+ if(m&&m[1].length<=level) break
3080
+ out.push(lines[i])
3081
+ }
3082
+ return out.join('\n').trim()
3083
+ }
3084
+ function paperBullets(items,empty){ return items.length?items.map(x=>'- '+x).join('\n'):('- '+empty) }
3085
+ function paperCompose(ps,ev,cfg){
3086
+ const zh=cfg.language!=='en', T=paperTitles(cfg.language)
3087
+ const r=(a,b)=>zh?a:b
3088
+ const body=String(ps.finalDraft||ps.draft||'').trim()
3089
+ const truth=ev.verified.filter(v=>v.结论!=='假'), refuted=ev.verified.filter(v=>v.结论==='假')
3090
+ const pending=ev.propos.filter(p=>!['已验证·真','已验证·假'].includes(p.状态))
3091
+ const firstLine=String(ev.problem||'').split(/\r?\n/)[0].trim()
3092
+ const conclusion=r(
3093
+ (truth.length?('全体一致检验通过的命题 '+truth.length+' 条'+(refuted.length?(',被否证 '+refuted.length+' 条'):'')):'本轮没有取得全体一致的检验结论')+';未决条目 '+pending.length+' 条。',
3094
+ (truth.length?('Verified propositions: '+truth.length+(refuted.length?('; refuted: '+refuted.length):'')):'No unanimously verified proposition this run')+'; unresolved items: '+pending.length+'.')
3095
+ const sec=[];
3096
+ sec.push({title:T[0],body:[r('**摘要**:原问题——',' **Abstract** — problem: ')+firstLine,
3097
+ r('主要结论:','Main conclusion: ')+conclusion].join('\n')});
3098
+ sec.push({title:T[1],body:[r('原问题(完整陈述):','Original problem (full statement):'),'',String(ev.problem||''),
3099
+ '','- '+r('项目','Project')+':'+ev.project,'- '+r('运行','Run')+':'+ev.runId,'- '+r('状态','Status')+':'+(ev.converged?r('已收敛','converged'):r('未收敛','not converged'))].join('\n')});
3100
+ sec.push({title:T[2],body:[body||r('(团队本轮没有产出正文;以下仅为已有证据的索引。)','(The team authored no body this run; only the evidence index below.)'),
3101
+ '','### '+r('最终答案与结论','Final answer and conclusions'),paperBullets(
3102
+ truth.map(v=>v.id+(v.概率?('('+r('判定为真,估计值','true, estimate')+' '+v.概率+')'):'')+' ← `'+v.rel+'`'),
3103
+ r('本轮没有被全体一致判定为真的命题','No unanimously true proposition this run'))].join('\n')});
3104
+ sec.push({title:T[3],body:[paperBullets(
3105
+ ev.verified.map(v=>{ const verdict=v.结论==='假'?r('已否证','refuted'):r('真','true')
3106
+ return '['+verdict+'] '+v.id+(v.概率?('|'+r('概率','probability')+' '+v.概率):'')+'|'+r('证据','evidence')+' `'+v.rel+'`'+(v.类型?('|'+v.类型):'') }),
3107
+ r('本轮没有被检验通过的命题','No verified proposition this run')),
3108
+ '','### '+r('未决 / 未通过','Unresolved / not passed'),
3109
+ paperBullets(pending.map(p=>'[未决] '+p.id+'('+r('状态','status')+':'+(p.状态||'未定论')+')|`'+p.rel+'`')
3110
+ .concat(ev.formal.filter(f=>f.status!=='passed').map(f=>'[未决·形式化] '+f.target+'('+f.status+')'+(f.file?('|`'+f.file+'`'):''))),
3111
+ r('无','none'))].join('\n')});
3112
+ sec.push({title:T[4],body:[paperBullets(
3113
+ 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+'`')),
3114
+ r('本轮没有记录子问题','No sub-problem was recorded this run'))].join('\n')});
3115
+ sec.push({title:T[5],body:[paperBullets(
3116
+ ev.methods.map(m=>m.id+(m.标题?('「'+m.标题+'」'):'')+'('+(m.类型||r('方法','method'))+','+(m.状态||r('经验','experiential'))+')'+(m.可信断言?('|'+r('可信断言','trusted claims')+':'+m.可信断言):'')+'|`'+m.rel+'`'),
3117
+ r('本轮没有记录方法/理论/工具','No method/theory/tool was recorded this run')),
3118
+ '',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')});
3119
+ const pattern=paperExtractSection(body,zh?'规律|总结|原理':'pattern|principle')
3120
+ sec.push({title:T[6],body:[pattern||[r('团队本轮没有单独撰写"规律总结";以下为**有证据支撑**的可复用条目(取自方法卡与已验证命题),未作外推:',
3121
+ 'The team did not author a separate "patterns" section; below are the EVIDENCE-BACKED reusable items (method cards and verified propositions), with no extrapolation:'),
3122
+ paperBullets(ev.methods.filter(m=>m.可信断言).map(m=>m.可信断言+'(`'+m.rel+'`)')
3123
+ .concat(truth.map(v=>v.id+'(`'+v.rel+'`)')),r('无','none'))].join('\n')].join('\n')});
3124
+ const disc=paperExtractSection(body,zh?'讨论|局限|展望':'discussion|limitation|outlook')
3125
+ sec.push({title:T[7],body:[disc||r('(团队未单独撰写讨论;以下为框架按证据整理的局限。)','(No separate discussion was authored; the framework lists the evidenced limitations.)'),
3126
+ '','### '+r('局限与未决','Limitations and open items'),
3127
+ paperBullets([
3128
+ r('未决/未通过条目 '+pending.length+' 条(见第 4 节,均已标注)','Unresolved items: '+pending.length+' (flagged in §4)'),
3129
+ r('被否证的命题 '+refuted.length+' 条(见第 4 节)','Refuted propositions: '+refuted.length+' (see §4)'),
3130
+ (ps.compile&&ps.compile.result==='failed')?r('PDF 编译失败:已保留 paper.tex 与 paper.md 并上报','PDF compilation failed: paper.tex and paper.md were kept and reported'):'',
3131
+ (ps.compile&&ps.compile.result==='not-detected')?r('本机未检测到 LaTeX 引擎:只产出 tex+md','No LaTeX engine detected: tex+md only'):'',
3132
+ ps.dissent.length?r('交付分歧('+ps.dissent.join('、')+')已写入附录','Deliverability dissent ('+ps.dissent.join(', ')+') is recorded in the appendix'):'',
3133
+ ps.warning?r('过程警告:'+ps.warning,'Process warning: '+ps.warning):'',
3134
+ ].filter(Boolean),r('无','none'))].join('\n')});
3135
+ sec.push({title:T[8],body:[r('证据与文件索引(全部相对于项目根):','Evidence and file index (all relative to the project root):'),
3136
+ '',paperBullets(ev.index.map(p=>'`'+p+'`'),r('无','none')),
3137
+ '','- '+r('过程记录','Process log')+':`'+ps.dir+'/paper.log.md`',
3138
+ '- '+r('元数据','Metadata')+':`'+ps.dir+'/paper.meta.json`',
3139
+ '- '+r('各成员撰写部分','Per-member parts')+':`'+ps.dir+'/parts/`',
3140
+ '- '+r('互审记录','Cross-review records')+':`'+ps.dir+'/reviews/`',
3141
+ '- '+r('合并稿/最终稿','Merged / final draft')+':`'+ps.dir+'/draft.md`、`'+ps.dir+'/final.md`',
3142
+ (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'):'']
3143
+ .filter(Boolean).join('\n')});
3144
+ const title=r('Vibe-Mathematics v4 研究报告','Vibe-Mathematics v4 Research Report')+'|'+firstLine.slice(0,60)
3145
+ const md=L=>L
3146
+ const mdText=['# '+title,'','- '+r('作者','Authors')+':Vibe-Mathematics v4('+r('运行','run')+' '+ps.runId+')',
3147
+ '- '+r('日期','Date')+':'+fmtTime(ps.finalizedAt||now()).slice(0,10),
3148
+ '- '+r('参与成员','Participants')+':'+(ps.participants.join('、')||r('(无常驻)','(no residents)')),
3149
+ '- '+r('定稿代表','Editor')+':'+(ps.editorId||cfg.editor),'',
3150
+ ...sec.map((s,i)=>['## '+(i+1)+'. '+s.title,'',s.body,''].join('\n'))].join('\n')
3151
+ const texBody=sec.map((s,i)=>'\\section{'+texEsc(s.title)+'}\n'+texEsc(s.body)).join('\n\n')
3152
+ const tex=paperTex(cfg,title,texBody)
3153
+ return {title, sections:sec, md:mdText, tex, texBody}
3154
+ }
3155
+ function paperTex(cfg,title,texBody){
3156
+ const zh=cfg.language!=='en'
3157
+ const cls=zh?'ctexart':'article'
3158
+ const pkgs=['amsmath','amssymb','amsthm','geometry','hyperref','longtable','booktabs']
3159
+ return ['% generated by vibe-math-v4 (final paper)','\\documentclass[11pt]{'+cls+'}',
3160
+ ...pkgs.map(p=>'\\usepackage{'+p+'}'),
3161
+ '\\title{'+texEsc(title)+'}','\\author{Vibe-Mathematics v4}','\\date{'+texEsc(fmtTime(now()).slice(0,10))+'}',
3162
+ '\\begin{document}','\\maketitle','',texBody,'','\\end{document}',''].join('\n')
3163
+ }
3164
+ /** tex escaping (docs/final-paper.md §5): every `_ % & # $ { } ~ ^ \\` becomes a safe command/escape. */
3165
+ function texEsc(s){
3166
+ const M={'\\':'\\textbackslash{}','{':'\\{','}':'\\}','$':'\\$','&':'\\&','#':'\\#','^':'\\textasciicircum{}','_':'\\_','%':'\\%','~':'\\textasciitilde{}'}
3167
+ let out=''; for(const ch of String(s==null?'':s)) out+=(M[ch]||ch); return out
3168
+ }
3169
+ // ---- compilation (detect → compile twice → capped repair → degrade; docs/final-paper.md §D/§E) ----
3170
+ // The host fs is TEXT-ONLY (dsh-fs has no writeBytes), so `paper.pdf` can only ever be produced
3171
+ // by the compiler subprocess inside `Paper/<id>/` — the plugin just `stat`s it. The Lean seam
3172
+ // (`sub.resolveExecutable` + `sub.spawn`) is reused verbatim so a fake compiler is injectable.
3173
+ async function paperDetectEngine(lang){
3174
+ const sub=subprocessOf(); if(sub===undefined) return {ok:false,tried:[],reason:'NO_SUBPROCESS'}
3175
+ const prefer=String(params.paperLatexCommand||'').trim()
3176
+ const base=lang==='en'?['pdflatex','latexmk','xelatex','lualatex','tectonic']:['xelatex','latexmk','pdflatex','lualatex','tectonic']
3177
+ const order=(prefer?[prefer]:[]).concat(base.filter(x=>x!==prefer))
3178
+ const tried=[]
3179
+ for(let i=0;i<order.length;i++){
3180
+ const exe=order[i]
3181
+ try { const r=await sub.resolveExecutable(exe); if(r) return {ok:true,exe:String(r),tried,alts:order.slice(i+1)} }
3182
+ catch(e){ tried.push(exe) }
3183
+ }
3184
+ return {ok:false,tried,reason:'LATEX_NOT_FOUND'}
3185
+ }
3186
+ function paperEngineArgv(exe,lang){
3187
+ if(/latexmk$/.test(exe)) return [exe,'-interaction=nonstopmode',lang==='en'?'-pdf':'-xelatex','paper.tex']
3188
+ return [exe,'-interaction=nonstopmode','paper.tex']
3189
+ }
3190
+ async function paperRunEngine(exe,argv,dir,cap){
3191
+ const sub=subprocessOf(); if(sub===undefined) return {ok:false,code:'NO_SUBPROCESS'}
3192
+ let handle
3193
+ try { handle=sub.spawn({argv,cwd:dir,stdio:{stdin:'ignore',stdout:{maxBytes:64*1024},stderr:{maxBytes:64*1024}},graceMs:cap}) }
3194
+ catch(e){ return {ok:false,code:'LATEX_SPAWN_FAILED',message:String((e&&e.message)||e)} }
3195
+ let killed=false,timerDispose=null,outcome
3196
+ try {
3197
+ outcome=await Promise.race([handle.done,new Promise(function(resolve){
3198
+ if(typeof ctx.timeout!=='function') return
3199
+ timerDispose=ctx.timeout(function(){ killed=true; try{ if(typeof handle.terminate==='function') handle.terminate() }catch(e){} resolve({exitCode:null,signal:'SIGTERM'}) },cap)
3200
+ })])
3201
+ } catch(e){ if(timerDispose){ try{ timerDispose() }catch(_e){} } return {ok:false,code:'LATEX_RUN_FAILED',message:String((e&&e.message)||e)} }
3202
+ if(timerDispose){ try{ timerDispose() }catch(e){} }
3203
+ let out='',err=''
3204
+ try { if(handle.collected&&handle.collected.stdout) out=handle.collected.stdout.readFrom(0).text } catch(e){}
3205
+ try { if(handle.collected&&handle.collected.stderr) err=handle.collected.stderr.readFrom(0).text } catch(e){}
3206
+ const exitCode=outcome?outcome.exitCode:null
3207
+ return {ok:exitCode===0,exitCode,timedOut:killed,stdout:tail(out,2000),stderr:tail(err,2000)}
3208
+ }
3209
+ async function paperPdfExists(dir){
3210
+ try { const t=await fs.resolve(dir+'/paper.pdf',{cwd:frameworkRoot()}); return !!(await fs.stat(t)) } catch(e){ return false }
3211
+ }
3212
+ /** v2 §E: at most ONE engine swap, then ONE minimal-template retry, then report + degrade. */
3213
+ async function paperCompile(ps,composed,ev){
3214
+ const dir=ps.dir
3215
+ // v2 §E: `paperFormat=md` produces no tex, so "cannot compile without tex" must NOT be warned.
3216
+ if(ps.cfg.format==='md') return {result:'skipped',reason:'paperFormat=md(未产出 tex,跳过编译)',attempts:[]}
3217
+ if(!ps.cfg.compilePdf) return {result:'skipped',reason:'paperCompilePdf=false',attempts:[]}
3218
+ // Never clobber an existing pdf (v2 §D): a previously compiled artifact is kept as-is.
3219
+ if(await paperPdfExists(dir)) return {result:'ok',engine:'existing',note:'已存在 paper.pdf,未覆盖',attempts:[]}
3220
+ const det=await paperDetectEngine(ps.cfg.language)
3221
+ if(!det.ok) return {result:'not-detected',reason:'未检测到 LaTeX 引擎('+(det.tried.join('/')||'subprocess 不可用')+'):只产出 tex+md,不编译',tried:det.tried,attempts:[]}
3222
+ const cap=posMs(params.activityTimeoutMs,120000)
3223
+ const attempts=[]
3224
+ const runTwice=async(exe,label,passes)=>{
3225
+ const argv=paperEngineArgv(exe,ps.cfg.language)
3226
+ let ok=true,first=null
3227
+ 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 } }
3228
+ const pdf=await paperPdfExists(dir)
3229
+ attempts.push({stage:label,engine:exe,ok:!!(ok&&pdf),exitCode:first?first.exitCode:null,pdf,stderr:String((first&&first.stderr)||'').slice(0,300)})
3230
+ return {ok:!!(ok&&pdf),last:first}
3231
+ }
3232
+ let r=await runTwice(det.exe,'initial',2)
3233
+ if(r.ok) return {result:'ok',engine:det.exe,attempts}
3234
+ // repair 1: swap to another RESOLVABLE engine (same tex, nonstopmode, twice) — capped at one swap
3235
+ const sub=subprocessOf()
3236
+ for(const alt of det.alts||[]){
3237
+ let altExe=null
3238
+ try { if(sub) altExe=String(await sub.resolveExecutable(alt)) } catch(e){ continue }
3239
+ if(!altExe||altExe===det.exe) continue
3240
+ const rr=await runTwice(altExe,'engine-switch',2)
3241
+ if(rr.ok) return {result:'ok',engine:altExe,repaired:'engine-switch',attempts}
3242
+ break
3243
+ }
3244
+ // repair 2: ONE minimal-template retry (drops every optional package / custom preamble)
3245
+ await paperWriteRel(dir+'/paper.tex',paperMinimalTemplate(ps.cfg,composed.title,composed.texBody))
3246
+ const rr=await runTwice(det.exe,'minimal-template',1)
3247
+ if(rr.ok) return {result:'ok',engine:det.exe,repaired:'minimal-template',attempts}
3248
+ const last=attempts[attempts.length-1]||{}
3249
+ return {result:'failed',engine:det.exe,attempts,error:'编译失败(已按上限尝试换引擎/最小模板):保留 paper.tex 与 paper.md,不阻塞定稿',tail:String(last.stderr||'')}
3250
+ }
3251
+ function paperMinimalTemplate(cfg,title,texBody){
3252
+ const cls=cfg.language==='en'?'article':'ctexart'
3253
+ return ['% generated by vibe-math-v4 (minimal fallback template)','\\documentclass[11pt]{'+cls+'}',
3254
+ '\\begin{document}','\\section*{'+texEsc(title)+'}','',String(texBody||''),'','\\end{document}',''].join('\n')
3255
+ }
3256
+ async function paperEnsureArtifacts(meta,cfg){
3257
+ const filled=[]
3258
+ const dir=String(meta.dir||('Paper/'+meta.id))
3259
+ const saved=paperState
3260
+ try {
3261
+ if(!paperState||paperState.id!==meta.id){
3262
+ paperState={ id:meta.id, dir, trigger:'recheck', deferCompletion:false, startedAt:meta.startedAt||now(), finalizedAt:meta.finalizedAt||0,
3263
+ status:'done', round:meta.rounds||0, maxRounds:PAPER_MAX_ROUNDS, cfg, fingerprint:meta.fingerprint||'',
3264
+ participants:Array.isArray(meta.participants)?meta.participants:[], parts:{}, reviews:{}, deliverable:{}, dissent:meta.dissent||[],
3265
+ log:[], warning:meta.warning||'', compile:meta.compile||null, draft:'', finalDraft:'', files:meta.files||[], stepAt:now(), lastNudgeAt:0, attempts:{}, editorId:meta.editorId||'' }
3266
+ }
3267
+ const ev=await paperEvidence()
3268
+ const body=await readText(dir+'/final.md')
3269
+ if(body!==undefined&&body!=='') paperState.finalDraft=body
3270
+ const composed=paperCompose(paperState,ev,cfg)
3271
+ const wantMd=cfg.format!=='tex', wantTex=cfg.format!=='md'
3272
+ const mdRaw=await readText(dir+'/paper.md'), texRaw=await readText(dir+'/paper.tex')
3273
+ if(wantMd&&mdRaw===undefined){ if(await paperWriteRel(dir+'/paper.md',composed.md)) filled.push('paper.md') }
3274
+ if(wantTex&&texRaw===undefined){ if(await paperWriteRel(dir+'/paper.tex',composed.tex)) filled.push('paper.tex') }
3275
+ if(cfg.compilePdf&&wantTex&&!(await paperPdfExists(dir))){
3276
+ const c=await paperCompile(paperState,composed,ev)
3277
+ paperState.compile=c
3278
+ if(c.result==='ok') filled.push('paper.pdf')
3279
+ }
3280
+ if(filled.length) await paperWriteMeta()
3281
+ return filled
3282
+ } finally { paperState=saved||null }
3283
+ }
3284
+ async function paperWriteMeta(){
3285
+ const ps=paperState; if(!ps) return false
3286
+ const meta={ id:ps.id, dir:ps.dir, generatedBy:'vibe-math-v4', preset:'v4', trigger:ps.trigger,
3287
+ runId, closureSignal:PAPER_CLOSURE_SIGNAL, startedAt:ps.startedAt, finalizedAt:ps.finalizedAt||0,
3288
+ params:{finalPaper:params.finalPaper,paperFormat:ps.cfg.format,paperLanguage:ps.cfg.language,paperCompilePdf:ps.cfg.compilePdf,paperEditor:ps.cfg.editor,paperLatexCommand:params.paperLatexCommand},
3289
+ editor:ps.cfg.editor, editorId:ps.editorId||'', editorDowngraded:!!ps.editorDowngraded, participants:ps.participants, rounds:ps.round, maxRounds:ps.maxRounds,
3290
+ fingerprint:ps.fingerprint||'', warning:ps.warning||'', files:ps.files||[], compile:ps.compile||null, mergeNotes:ps.mergeNotes||null,
3291
+ 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||''})),
3292
+ 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})),
3293
+ deliverable:Object.keys(ps.deliverable).sort().map(k=>({id:k,deliverable:ps.deliverable[k]})),
3294
+ dissent:ps.dissent||[] }
3295
+ return await paperWriteRel(ps.dir+'/paper.meta.json',JSON.stringify(meta,null,2))
3296
+ }
3297
+ async function paperWriteLog(){
3298
+ const ps=paperState; if(!ps) return false
3299
+ const L=['# 最终论文过程记录(paper.log.md)','','- 论文 id:'+ps.id,'- 触发:'+ps.trigger,
3300
+ '- 语言 / 格式 / 定稿代表:'+ps.cfg.language+' / '+ps.cfg.format+' / '+ps.cfg.editor,'- 参与成员:'+(ps.participants.join('、')||'(无常驻)'),
3301
+ '- 状态:'+ps.status,'']
3302
+ L.push('## 过程(谁写了什么)'); for(const l of ps.log) L.push('- '+l); L.push('')
3303
+ L.push('## 归属与处理(谁提了什么意见、如何采纳)')
3304
+ for(const rid of ps.participants){ const p=ps.parts[rid]
3305
+ L.push('- 撰写:'+rid+(p?(p.missing?'(超时未提交)':'('+String(p.text||'').length+' 字,来源 '+(p.source||'reply')+')'):'(未提交)')) }
3306
+ for(const rid of ps.participants){ const rv=ps.reviews[rid]
3307
+ if(!rv||rv.missing){ L.push('- 互审:'+rid+'('+(rv?'超时未提交,已记入附录':'未完成')+')'); continue }
3308
+ L.push('- 互审:'+rid+' → '+rv.reviewed+',意见 '+((rv.points||[]).length)+' 条,处理:已采纳并记入合并稿审阅记录'+(rv.improvements?('(改进:'+String(rv.improvements).slice(0,200)+')'):'')) }
3309
+ L.push('- 定稿:'+(ps.editorId||ps.cfg.editor))
3310
+ for(const rid of ps.participants) if(typeof ps.deliverable[rid]==='boolean') L.push('- 交付表态:'+rid+' = '+(ps.deliverable[rid]?'可交付':'不可交付'))
3311
+ 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||''}))
3312
+ if(ps.warning) L.push('- 警告:'+ps.warning)
3313
+ return await paperWriteRel(ps.dir+'/paper.log.md',L.join('\n')+'\n')
3314
+ }
3315
+ async function paperFinalize(){
3316
+ const ps=paperState
3317
+ const ev=await paperEvidence()
3318
+ ps.fingerprint=paperFingerprint(ev)
3319
+ // final.md / draft.md must exist before composing so a re-run is reproducible
3320
+ if(!ps.finalDraft) ps.finalDraft=paperFacilitatorFinal()
3321
+ await paperWriteRel(ps.dir+'/final.md',ps.finalDraft)
3322
+ const composed=paperCompose(ps,ev,ps.cfg)
3323
+ ps.files=[]
3324
+ if(ps.cfg.format!=='tex'){ if(await paperWriteRel(ps.dir+'/paper.md',composed.md)) ps.files.push('paper.md') }
3325
+ if(ps.cfg.format!=='md'){ if(await paperWriteRel(ps.dir+'/paper.tex',composed.tex)) ps.files.push('paper.tex') }
3326
+ ps.compile=await paperCompile(ps,composed,ev)
3327
+ if(ps.compile&&ps.compile.result==='ok') ps.files.push('paper.pdf')
3328
+ ps.finalizedAt=now(); ps.status='done'
3329
+ await paperWriteMeta(); await paperWriteLog()
3330
+ // v2 §A1: the paper phase was entered from INSIDE the closing branch, BEFORE the completion
3331
+ // flags — so the run only becomes "done" here, once the paper exists (or has degraded safely).
3332
+ if(ps.deferCompletion && autoDone!==true){
3333
+ running=false; autoDone=true; phase='done'; clearHeartbeat()
3334
+ wakeKind.clear(); meetingState=null; verifyState=null; pendingMeeting=null; pendingVerify=[]
3335
+ logActivity('stop','all residents agree: problem solved(一致性停止票;最终论文已产出)')
3336
+ await saveAll()
3337
+ }
3338
+ const warn=[ps.warning,ps.compile&&ps.compile.result==='failed'?ps.compile.error:''].filter(Boolean).join(';')
3339
+ if(warn) logActivity('paper','最终论文完成(有警告):'+ps.dir+'|'+warn)
3340
+ else logActivity('paper','最终论文完成:'+ps.dir+'('+ps.files.join('、')+(ps.compile?('|编译 '+(ps.compile.result||'')):'')+')')
3341
+ return {ok:true,id:ps.id,dir:ps.dir,files:ps.files,compile:ps.compile,rounds:ps.round,dissent:ps.dissent,warning:ps.warning}
3342
+ }
3343
+ function paperStatusView(){
3344
+ const ps=paperState
3345
+ return {
3346
+ params:{finalPaper:params.finalPaper,paperFormat:params.paperFormat,paperLanguage:params.paperLanguage,paperCompilePdf:params.paperCompilePdf,paperEditor:params.paperEditor,paperLatexCommand:params.paperLatexCommand},
3347
+ id:ps?ps.id:paperIdFor(), dir:ps?ps.dir:paperDirFor(), status:ps?ps.status:'idle',
3348
+ running:!!(ps&&ps.status!=='done'&&ps.status!=='failed'), trigger:ps?ps.trigger:'',
3349
+ rounds:ps?ps.round:0, maxRounds:ps?ps.maxRounds:PAPER_MAX_ROUNDS, participants:ps?ps.participants:[],
3350
+ editor:ps?(ps.editorId||ps.cfg.editor):params.paperEditor, editorDowngraded:!!(ps&&ps.editorDowngraded),
3351
+ parts:ps?Object.keys(ps.parts).length:0, reviews:ps?Object.keys(ps.reviews).length:0,
3352
+ finalizedAt:ps?ps.finalizedAt:0, warning:ps?ps.warning:'', files:ps?(ps.files||[]):[], compile:ps?ps.compile:null,
3353
+ closureSignal:PAPER_CLOSURE_SIGNAL,
3354
+ }
3355
+ }
3356
+ function paperStatusSummary(){
3357
+ const v=paperStatusView()
3358
+ return {ok:true, id:v.id, dir:v.dir, status:v.status, running:v.running, trigger:v.trigger, lang:v.params.paperLanguage,
3359
+ format:v.params.paperFormat, editor:v.editor, editorDowngraded:v.editorDowngraded, participants:v.participants,
3360
+ parts:v.parts, reviews:v.reviews, rounds:v.rounds+'/'+v.maxRounds, finalizedAt:v.finalizedAt, files:v.files,
3361
+ params:v.params,
3362
+ compile:v.compile?{result:v.compile.result,engine:v.compile.engine||'',repaired:v.compile.repaired||''}:null,
3363
+ warning:v.warning, closureSignal:v.closureSignal}
3364
+ }
3365
+ async function paperStart(trigger,opts){
3366
+ const o=opts||{}
3367
+ const force=!!o.force
3368
+ const cfg=paperCfg(o)
3369
+ const id=paperIdFor(), dir='Paper/'+id
3370
+ if(paperState && paperState.id===id && paperState.status!=='done' && paperState.status!=='failed' && !force)
3371
+ return {ok:true,id,dir,status:paperState.status,alreadyRunning:true,message:'最终论文正在进行('+paperState.status+')'}
3372
+ if(paperState && paperState.id!==id && paperState.status!=='done' && paperState.status!=='failed' && !force)
3373
+ return {ok:false,code:'V4_PAPER_BUSY',message:'另一篇论文正在进行('+paperState.id+'),完成或 force 后再试'}
3374
+ // Idempotency (docs/final-paper.md §C): the run id IS the directory, so a finalizedAt in THAT directory
3375
+ // means this run already has its paper; only per-artifact existence is re-checked.
3376
+ const meta=await readJson(dir+'/paper.meta.json')
3377
+ if(!force && meta && meta.finalizedAt){
3378
+ const filled=await paperEnsureArtifacts(meta,cfg)
3379
+ return {ok:true,id,dir,idempotent:true,finalizedAt:meta.finalizedAt,filled,
3380
+ message:'论文已定稿(幂等:只补写缺失产物'+(filled.length?(':'+filled.join('、')):':无缺失')+')'+(params.finalPaper===false?'(finalPaper=false:自动触发已关闭,本次为手动命令)':'')}
3381
+ }
3382
+ const prevDeliver={}
3383
+ paperState={ id, dir, trigger, deferCompletion:!!o.deferCompletion, startedAt:now(), finalizedAt:0, status:'parts', round:0, maxRounds:PAPER_MAX_ROUNDS,
3384
+ cfg, fingerprint:'', participants:Array.from(residents.keys()).sort(), parts:{}, reviews:{}, deliverable:prevDeliver,
3385
+ dissent:[], log:[], warning:'', compile:null, draft:'', finalDraft:'', files:[], stepAt:now(), lastNudgeAt:0, attempts:{}, editorId:'', editorDowngraded:false }
3386
+ paperLog('触发('+trigger+'):'+id+'|收敛信号='+PAPER_CLOSURE_SIGNAL+'|语言='+cfg.language+'|格式='+cfg.format+'|定稿='+cfg.editor+'|参与='+(paperState.participants.join('、')||'(无常驻)'))
3387
+ if(params.finalPaper===false) paperLog('注意:finalPaper=false —— 自动触发已关闭;本次为手动命令')
3388
+ await paperWriteMeta(); await paperWriteLog()
3389
+ if(!paperState.participants.length){
3390
+ paperState.warning='本轮没有在场常驻:正文由框架(office 侧)从既有证据整理'
3391
+ paperLog('警告:'+paperState.warning)
3392
+ await paperMerge() // -> paperFinalStep -> vote(no participants) -> finalize
3393
+ return {ok:true,id,dir,status:paperState.status,message:'最终论文已启动(无常驻成员,由框架从既有证据整理)'+(params.finalPaper===false?'(finalPaper=false:自动触发已关闭)':'')}
3394
+ }
3395
+ await paperWakeSome(paperState.participants.slice(),'part')
3396
+ await paperAdvance('parts-started')
3397
+ return {ok:true,id,dir,status:paperState.status,participants:paperState.participants,files:paperState.files,
3398
+ message:'最终论文已启动('+trigger+')'+(params.finalPaper===false?'(finalPaper=false:自动触发已关闭)':'')}
3399
+ }
3400
+ async function runPaperCommand(ov,force){
3401
+ const known=['lang','format','editor']
3402
+ 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)'}
3403
+ if(ov&&ov.lang&&PAPER_LANGS.indexOf(String(ov.lang))===-1) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'paperLanguage 只能是 zh|en'}
3404
+ if(ov&&ov.format&&PAPER_FORMATS.indexOf(String(ov.format))===-1) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'paperFormat 只能是 both|md|tex'}
3405
+ if(ov&&ov.editor&&!paperEditorOk(ov.editor)) return {ok:false,code:'V4_INVALID_ARGUMENT',message:'paperEditor 只能是 office 或 resident:<id>'}
3406
+ return await paperStart('manual',{force:!!force,lang:ov&&ov.lang,format:ov&&ov.format,editor:ov&&ov.editor})
3407
+ }
3408
+ /** A paper wake's reply. `kind` is read from wakeKind (set by wakeResident, not cleared here). */
3409
+ async function onPaperWakeEnd(r,parsed){
3410
+ const ps=paperState; if(!ps) return
3411
+ const kind=String(wakeKind.get(r.rId)||'')
3412
+ // A paper contribution may arrive as a plain string or as `{markdown:"…"}` — anything else is
3413
+ // NOT accepted (a stray object must never become "[object Object]" in the final draft).
3414
+ const pick=v=>{ if(typeof v==='string') return v; if(v&&typeof v==='object'&&typeof v.markdown==='string') return v.markdown; return '' }
3415
+ if(kind===PAPER_KINDS.part){
3416
+ let text=pick(parsed&&parsed.paperPart).trim()
3417
+ if(!text) text=String((await readText(ps.dir+'/parts/'+r.rId+'.md'))||'').trim()
3418
+ if(text){ ps.parts[r.rId]={text,at:now(),source:'reply'}; await paperWriteRel(ps.dir+'/parts/'+r.rId+'.md',text)
3419
+ paperLog(r.rId+' 提交了自己负责的部分('+text.length+' 字)') }
3420
+ else paperLog('警告:'+r.rId+' 的部分为空(将按超时/重试处理)')
3421
+ } else if(kind===PAPER_KINDS.review){
3422
+ const rv=(parsed&&parsed.paperReview&&typeof parsed.paperReview==='object')?parsed.paperReview:{}
3423
+ const reviewed=String(rv.reviewed||'')
3424
+ const others=ps.participants.filter(x=>x!==r.rId)
3425
+ if(others.indexOf(reviewed)===-1){
3426
+ paperLog('拒绝 '+r.rId+' 的互审:未指名其他参与者(reviewed='+JSON.stringify(rv.reviewed===undefined?null:rv.reviewed)+')——必须审阅至少一位他人')
3427
+ } else {
3428
+ const points=Array.isArray(rv.points)?rv.points.map(String).filter(Boolean):[]
3429
+ ps.reviews[r.rId]={reviewed,points,improvements:String(rv.improvements||''),at:now(),accepted:true}
3430
+ paperLog(r.rId+' 审阅了 '+reviewed+' 的部分:'+points.length+' 条意见'+(rv.improvements?',并给出改进建议':'') )
3431
+ await paperWriteRel(ps.dir+'/reviews/'+r.rId+'.md',['# 互审记录|'+r.rId,'','- 审阅对象:'+reviewed,'- 意见:','',
3432
+ ...points.map(p=>'- '+p),'','## 改进建议','',String(rv.improvements||'(无)')].join('\n')+'\n')
3433
+ }
3434
+ } else if(kind===PAPER_KINDS.final){
3435
+ let text=pick(parsed&&parsed.paperFinal).trim()
3436
+ if(!text) text=String((await readText(ps.dir+'/final.md'))||'').trim()
3437
+ if(text&&!/^#\s*互审记录/.test(text)){ ps.finalDraft=text; await paperWriteRel(ps.dir+'/final.md',text); paperLog('定稿代表 '+(ps.editorId||ps.cfg.editor)+' 提交了最终稿('+text.length+' 字)') }
3438
+ else paperLog('警告:定稿代表返回空稿,改由框架(office)定稿')
3439
+ } else if(kind===PAPER_KINDS.vote||kind===PAPER_KINDS.revise){
3440
+ const d=parsed?parsed.paperDeliverable:undefined
3441
+ if(typeof d!=='boolean'){ paperLog('警告:'+r.rId+' 未明确表态"可交付/不可交付"(将按未表态处理)'); }
3442
+ else { ps.deliverable[r.rId]=d
3443
+ paperLog(r.rId+(d?' 表示:可交付':' 表示:不可交付')+(parsed&&parsed.reason?('|理由:'+String(parsed.reason).slice(0,160)):'')) }
3444
+ }
3445
+ await paperWriteLog()
3446
+ await paperAdvance('resident-end:'+kind)
3447
+ }
3448
+
2051
3449
  // ---- controls ----
2052
3450
  async function start({problem,residentCount,seedDirections}){
2053
3451
  await loadSettings()
@@ -2068,7 +3466,16 @@ export function apply(ctx) {
2068
3466
  // A fresh run starts with a clean formal slate: the ids r-1.. and p-* are reused, so
2069
3467
  // carrying a previous run's records over would let a stale `passed` open the new gate.
2070
3468
  formal={}; formalTodos=[]
3469
+ // §2.6 recovery (fresh-run variant): a dead process may have left `Formal/Jobs/*.json` behind.
3470
+ // A FRESH run must not adopt the previous run's SETTLED outcomes (that is what the clean slate
3471
+ // above exists to prevent), so only the safe half of the table runs: `queued` jobs are
3472
+ // re-driven and `running` jobs are marked/re-queued — never a `passed` from a stale record.
3473
+ await recoverLeanJobs({mode:'start',applySettled:false})
3474
+ // Warm the math_computation availability cache so the FIRST resident prompt already carries
3475
+ // the line (the probe is cached per registered instance, so later refreshes cost nothing).
3476
+ await refreshMathAvailability()
2071
3477
  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
3478
+ paperState=null // a fresh run gets a fresh paper flow (its durable record lives in Paper/<run id>/)
2072
3479
  // Re-base the artifact counter on the cards already on disk, so the auto-sync meeting counts
2073
3480
  // artifacts written by THIS run (the project tree may already hold cards from an earlier run).
2074
3481
  artifactBaseline=null; artifactCount=0; await syncArtifactCount()
@@ -2099,6 +3506,10 @@ export function apply(ctx) {
2099
3506
  const pre=await readJson('State/session.json')
2100
3507
  if(running && !autoDone && pre && pre.processEpoch===processEpoch) return {ok:true,message:'already running (no-op)'}
2101
3508
  await loadAll(); await loadSettings()
3509
+ // §2.6 recovery runs AFTER loadAll (the formal records it may complete/annotate are restored
3510
+ // there — recovering earlier would write onto an unloaded `formal` and clobber them).
3511
+ await recoverLeanJobs({mode:'resume',applySettled:true})
3512
+ await refreshMathAvailability() // same reason as start(): the next wake must carry the line
2102
3513
  // A run the group CONCLUDED (unanimous voteSolved → autoDone) must not be silently revived into
2103
3514
  // a zombie that keeps waking residents with no consensus that it should still run. The group
2104
3515
  // decided it is done; continuing means a NEW run (vibe_v4_start / vibe_v4_configure).
@@ -2156,13 +3567,14 @@ export function apply(ctx) {
2156
3567
  consensus: meetingState?{kind:'meeting',id:meetingState.id,agenda:meetingState.agenda,round:meetingState.round,spoke:Object.keys(meetingState.inputs).length,expected:residents.size,solvedVotes:Object.values(meetingState.inputs).filter(iv=>iv.voteSolved===true).length}
2157
3568
  :(verifyState?{kind:'verify',target:verifyState.targetId,targetType:verifyState.targetType,stage:verifyState.stage,round:verifyState.round,voted:Object.keys(verifyState.verdicts).length,expected:residents.size}:null),
2158
3569
  artifactCount: artifactCount, artifactBaseline: artifactBaseline,
3570
+ paper: paperStatusSummary(),
2159
3571
  // The Lean knobs and the per-object formal records are part of the readable status: without
2160
3572
  // them a `require`-mode run that keeps returning 未定论 would be undiagnosable from outside.
2161
3573
  formal: formalView(),
2162
3574
  // The parameter NAMES (not just the rendered string) so a caller — the `/v4 set` handler in
2163
3575
  // particular — can reject an unknown key instead of silently dropping it.
2164
3576
  paramsKeys: Object.keys(params),
2165
- params:['residentCount','compactAfterRounds','compactThreshold','maxParallel','activityTimeoutMs','meetingKeepEvery','verdictMaxRounds','stallAutoMeetingMs','provider','model','residentPersona','toolAllow','toolDeny','formalVerify','leanCommand','leanArgs','leanTimeoutMs'].map(k=>k+'='+(Array.isArray(params[k])?params[k].join(','):params[k])).join(', ') } }
3577
+ params:['residentCount','compactAfterRounds','compactThreshold','maxParallel','activityTimeoutMs','meetingKeepEvery','verdictMaxRounds','stallAutoMeetingMs','provider','model','residentPersona','toolAllow','toolDeny','formalVerify','leanCommand','leanArgs','leanTimeoutMs','finalPaper','paperFormat','paperLanguage','paperCompilePdf','paperEditor','paperLatexCommand','leanAsync','leanInitiative','leanSearchPaths','leanJobsMaxParallel'].concat(MATH_PARAM_NAMES).map(k=>k+'='+(Array.isArray(params[k])?params[k].join(','):params[k])).join(', ') } }
2166
3578
  function formalReportText(){
2167
3579
  if(!formalOn()) return '- 未启用(`formalVerify` = off;可用 vibe_v4_set 切到 encourage / require)'
2168
3580
  const v=formalView()
@@ -2179,6 +3591,7 @@ export function apply(ctx) {
2179
3591
  pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null,
2180
3592
  parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
2181
3593
  formal: formalView(),
3594
+ paper: paperStatusSummary(),
2182
3595
  meetings:meetings.length, recentActivity: activityLog.slice(-8) } }
2183
3596
  /** Human-readable mirror of the formal state (kept OUT of the JSON report shape). */
2184
3597
  function formalReport(){ return {ok:true, formalMode:formalMode(), formalReport:'## Lean 形式化\n'+formalReportText(), formal:formalView()} }
@@ -2218,14 +3631,54 @@ export function apply(ctx) {
2218
3631
  // Normalize one parameter value to its intended type so a string from /v4 set or configure
2219
3632
  // becomes the right number/array. Keeps settings.json clean regardless of how it was set.
2220
3633
  function normalizeParam(k, v){
2221
- const INT_KEYS=['residentCount','compactThreshold','compactAfterRounds','maxParallel','activityTimeoutMs','verdictMaxRounds','meetingKeepEvery','stallAutoMeetingMs']
3634
+ const INT_KEYS=['residentCount','compactThreshold','compactAfterRounds','maxParallel','activityTimeoutMs','verdictMaxRounds','meetingKeepEvery','stallAutoMeetingMs','leanJobsMaxParallel']
2222
3635
  if(INT_KEYS.includes(k)){ const n=Number(v); if(!Number.isFinite(n)) return v; return Math.floor(n) }
2223
- if(k==='toolAllow'||k==='toolDeny'||k==='leanArgs'){ if(Array.isArray(v)) return v.map(x=>String(x).trim()).filter(Boolean); if(typeof v==='string') return v.split(',').map(x=>x.trim()).filter(Boolean); return [] }
3636
+ if(k==='toolAllow'||k==='toolDeny'||k==='leanArgs'||k==='leanSearchPaths'){ if(Array.isArray(v)) return v.map(x=>String(x).trim()).filter(Boolean); if(typeof v==='string') return v.split(',').map(x=>x.trim()).filter(Boolean); return [] }
2224
3637
  // ---- Lean formal verification (docs §1) ------------------------------------------
2225
3638
  // `formalVerify` is a three-way enum and MUST degrade to the no-op 'off' on anything else.
2226
3639
  // Degrading to a STRONGER mode would let a typo silently gate every conclusion — the exact
2227
3640
  // failure mode `require` is supposed to avoid.
2228
3641
  if(k==='formalVerify') return FORMAL_MODES.indexOf(String(v))!==-1?String(v):'off'
3642
+ // ---- final paper (docs/final-paper.md §B): booleans and enums are coerced HERE, because `/v4 set` hands
3643
+ // the parameter layer RAW STRINGS ('false' must become false, not stay truthy) and an unknown
3644
+ // value must fall back to the default instead of silently keeping a bogus strong setting. ----
3645
+ if(k==='finalPaper'||k==='paperCompilePdf'){
3646
+ if(typeof v==='boolean') return v
3647
+ const s=String(v==null?'':v).trim().toLowerCase()
3648
+ if(s==='true'||s==='1'||s==='yes'||s==='on') return true
3649
+ if(s==='false'||s==='0'||s==='no'||s==='off') return false
3650
+ return DEFAULT_PARAMS[k]
3651
+ }
3652
+ if(k==='paperFormat') return PAPER_FORMATS.indexOf(String(v))!==-1?String(v):DEFAULT_PARAMS.paperFormat
3653
+ if(k==='paperLanguage') return PAPER_LANGS.indexOf(String(v))!==-1?String(v):DEFAULT_PARAMS.paperLanguage
3654
+ // v2 §A5: `office` (the session root / human side) by default, or `resident:<id>`.
3655
+ if(k==='paperEditor'){ const s=String(v==null?'':v).trim(); return paperEditorOk(s)?s:DEFAULT_PARAMS.paperEditor }
3656
+ if(k==='paperLatexCommand'){ const s=String(v==null?'':v).trim(); return s }
3657
+ // math_computation P1: the six keys go through the SHARED normalizer (explicit
3658
+ // enum/array/integer coercion there), so `'false'`-style strings and unknown enum values can
3659
+ // never leak into `params` — v4's function returns unknown keys AS-IS, which is why an
3660
+ // explicit branch is mandatory. The two array keys additionally accept the comma-string form
3661
+ // that `/v4 set` produces (v4's own style for `leanArgs`), then the module is authoritative.
3662
+ if(MATH_PARAM_NAMES.indexOf(k)!==-1){
3663
+ let val=v
3664
+ if((k==='mathEngines'||k==='mathPackages')&&typeof val==='string') val=val.split(',').map(x=>x.trim()).filter(Boolean)
3665
+ const one=normalizeMathParams({[k]:val})
3666
+ if(Object.prototype.hasOwnProperty.call(one,k)) return one[k]
3667
+ const def=DEFAULT_PARAMS[k]
3668
+ return Array.isArray(def)?def.slice():def
3669
+ }
3670
+ // lean-incremental-async §1.1: an EXPLICIT branch — the string 'false' (what `/v4 set` hands us)
3671
+ // must normalise to boolean false, never survive as a truthy string.
3672
+ if(k==='leanAsync'){
3673
+ if(typeof v==='boolean') return v
3674
+ const s=String(v==null?'':v).trim().toLowerCase()
3675
+ if(s==='true'||s==='1'||s==='yes'||s==='on') return true
3676
+ if(s==='false'||s==='0'||s==='no'||s==='off') return false
3677
+ return DEFAULT_PARAMS.leanAsync
3678
+ }
3679
+ // AMENDMENT §1: three-way PROACTIVITY enum — anything unknown degrades to the default
3680
+ // ('normal'), never to a stronger setting.
3681
+ if(k==='leanInitiative') return ['off','normal','eager'].indexOf(String(v))!==-1?String(v):DEFAULT_PARAMS.leanInitiative
2229
3682
  // A blank command would make resolveExecutable('') fail confusingly; fall back to the default.
2230
3683
  if(k==='leanCommand'){ const s=String(v==null?'':v).trim(); return s||'lean' }
2231
3684
  // A non-positive timeout is meaningless (the run would be killed instantly) → default.
@@ -2243,7 +3696,7 @@ export function apply(ctx) {
2243
3696
  * 注意**不要**给 activityTimeoutMs / compactThreshold 之类加下界:文档明确建议测试时把
2244
3697
  * activityTimeoutMs 设成 40ms 这种小值,钳制它会破坏受支持的配置。
2245
3698
  */
2246
- const INT_MIN = { maxParallel: 1, residentCount: 1 }
3699
+ const INT_MIN = { maxParallel: 1, residentCount: 1, leanJobsMaxParallel: 1 }
2247
3700
  function clampInt(k, n){
2248
3701
  const min = INT_MIN[k]
2249
3702
  if(min===undefined) return n
@@ -2263,6 +3716,9 @@ export function apply(ctx) {
2263
3716
  const has = (o,k)=>Object.prototype.hasOwnProperty.call(o,k)
2264
3717
  const ignored=keys.filter(k=>!has(params,k))
2265
3718
  for(const k of keys){ if(has(params,k)){ const nv=normalizeParam(k, upd[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } }
3719
+ // A math parameter changes what the NEXT prompt must say (the availability line encodes the
3720
+ // enabled engines and the mode), so refresh it here — the probe is cached, so this is cheap.
3721
+ if(keys.some(k=>MATH_PARAM_NAMES.indexOf(k)!==-1)) refreshMathAvailability().catch(()=>{})
2266
3722
  saveSettings().catch(()=>{})
2267
3723
  if(ignored.length){
2268
3724
  logActivity('set','忽略未知参数:'+ignored.join(', '))
@@ -2297,6 +3753,8 @@ export function apply(ctx) {
2297
3753
  // can ever clear those marks). resume()/start() re-initialize anyway; this keeps status truthful
2298
3754
  // between abort and the next action.
2299
3755
  meetingState=null; verifyState=null; pendingMeeting=null; pendingVerify=[]; busy=new Set(); wakeKind=new Map(); currentResident=''; finalizeLock=null
3756
+ paperState=null // an aborted run is over: its paper flow (if any) must not keep driving wakes
3757
+ await disposeLean() // §2.5: abort terminates the in-flight compile and marks it interrupted
2300
3758
  await saveAll(); return {ok:true,message:'aborted'} }
2301
3759
  function setPause(){ clearHeartbeat(); running=false; return {ok:true,message:'paused'} }
2302
3760
 
@@ -2308,6 +3766,9 @@ export function apply(ctx) {
2308
3766
  rememberAgent, forgetAgent,
2309
3767
  setPause, initAbort, postMessage, startMeeting, saveAll, broadcast, configure, loadSettings,
2310
3768
  currentResident:()=>currentResident,
3769
+ // Final paper (docs/final-paper.md): the session API used by the `/v4 paper` command and by
3770
+ // the suites. `paperStatus` is the same view `status()`/`report()` expose.
3771
+ runPaperCommand, paperStatus:()=>paperStatusSummary(),
2311
3772
  /** Does this session own the given child id? (used by the `subagent/start` capture) */
2312
3773
  ownsChild,
2313
3774
  /** Stash a live Agent seen at `subagent/start` time; claimed by `spawnResident` on resolve. */
@@ -2341,6 +3802,17 @@ export function apply(ctx) {
2341
3802
  formalMode, formalOn, formalRecords, formalTodo, formalOf, formalView, formalReport,
2342
3803
  rebuildLeanLibIndexes, leanArchive, leanRunTool, writeFormalIndex, writeFormalTodo,
2343
3804
  leanRunToolApi: async (relPath,timeoutMs)=>await leanRunFile(relPath,timeoutMs),
3805
+ // lean-incremental-async §6.3: the queue is reachable from the suite/dispatcher exactly like
3806
+ // `leanRunToolApi`, so the async paths can be driven deterministically (no timer races).
3807
+ leanQueueApi:{ enqueue:enqueueLeanJob, runQueue:runLeanQueue, state:()=>({running:leanInflight.map(leanJobView),queued:leanQueue.slice(),jobs:listLeanJobs(),notices:leanNotices.slice(),cap:Math.max(1,Math.floor(Number(params.leanJobsMaxParallel))||1)}), dispose:disposeLean, recover:recoverLeanJobs, job:leanJobTool },
3808
+ leanRead, listLeanJobs, leanJobs:()=>Object.assign({},leanJobs), leanJobTool,
3809
+ /** math_computation host surface — the SAME object the per-session module instance is keyed
3810
+ * by (`mathInstanceFor`), so the tool path and the prompt-side probe share one instance per
3811
+ * session (and one probe cache), and no state is shared across sessions. These accessors read
3812
+ * LIVE state (never a snapshot — `/v4 set mathMode=typed` must change the very next call). */
3813
+ mathHost: mathProbeHost,
3814
+ /** Absolute paths the Lean layer uses (read-only display; `searchPaths` are injected per spawn). */
3815
+ leanPaths:()=>({searchPath:vibeRoot().replace(/\\/g,'/'),searchPaths:leanSearchPathList(),lib:formalLibRoot().replace(/\\/g,'/'),proved:formalProvedRoot().replace(/\\/g,'/'),jobs:leanJobsRel()}),
2344
3816
  /**
2345
3817
  * The prompt builders, addressed BY RESIDENT ID. "成员读到的文字就是产品"
2346
3818
  * (AUDIT-CHECKLIST §0.1): a suite that can only observe tool return values is blind to a
@@ -2431,7 +3903,7 @@ export function apply(ctx) {
2431
3903
  // MODE is dynamic — switching it changes the very next prompt), leanCommand/leanArgs select the
2432
3904
  // executable, leanTimeoutMs bounds one run. Invalid values fall back to the defaults and an
2433
3905
  // unknown mode degrades to 'off' (never to a STRONGER mode).
2434
- registerTool('vibe_v4_set','Set V4 parameters: residentCount (how many residents a start spawns), compactThreshold (resident context % that triggers a compaction) and compactAfterRounds (rounds between soft compactions), meetingKeepEvery (every N newly accumulated artifacts an automatic sync meeting is convened), maxParallel (how many residents may be woken concurrently), activityTimeoutMs (idle window before a resident is nudged; also the heartbeat/watchdog period), stallAutoMeetingMs (how long the group may make NO progress before an auto sync meeting is convened), verdictMaxRounds (how many verification rounds one object gets: 1 independent round + re-vote debate rounds), model/provider override resident LLM route (empty=inherit main); toolAllow/toolDeny restrict resident tools (arrays of tool names); residentPersona adds a persona line; formalVerify: "off" (default, a true no-op) | "encourage" (residents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a unanimous true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record); leanCommand/leanArgs/leanTimeoutMs configure the toolchain. A key outside this list is refused (ok:false, ignored:[...]) instead of being silently dropped.',objParams({residentCount:{type:'integer'},compactAfterRounds:{type:'integer'},compactThreshold:{type:'integer'},meetingKeepEvery:{type:'integer'},maxParallel:{type:'integer'},activityTimeoutMs:{type:'integer'},verdictMaxRounds:{type:'integer'},stallAutoMeetingMs:{type:'integer'},provider:{type:'string'},model:{type:'string'},residentPersona:{type:'string'},toolAllow:{type:'array',items:{type:'string'}},toolDeny:{type:'array',items:{type:'string'}},formalVerify:{type:'string',enum:['off','encourage','require']},leanCommand:{type:'string'},leanArgs:{type:'array',items:{type:'string'}},leanTimeoutMs:{type:'integer'}}),(s,a)=>{ const r=s.setParams(a); const st=s.status(); if(r&&r.ok===false) return Object.assign({},st,{ok:false,warning:r.message,ignored:r.ignored}); return st })
3906
+ 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; leanAsync (default true) ENQUEUES each compile to a per-session background queue (concurrency 1, budget = leanTimeoutMs) and returns immediately with async:{jobId,state:\'queued\'} — results are announced in the next prompt and visible in vibe_v4_lean_lib.jobs; set leanAsync=false for the historical synchronous await; leanInitiative: "off" | "normal" (default) | "eager" is how PROACTIVE the group should be while working (it never changes what formalVerify requires at voting time); leanSearchPaths (array, default empty) adds extra compiler search roots — they are injected BEFORE the automatic VibeMath root, de-duplicated, and an explicit --search-path/-R/--root in leanArgs still wins; leanJobsMaxParallel (default 1) caps how many background compiles run at once; mathComputation: "auto" (default) | "on" | "off" enables the math_computation tool (off = a true no-op: no probe and no prompt line), mathMode: "typed+shell" (default) | "typed" drops the host-shell fallback and refuses the cli engine, mathEngines limits which engines may run (default python|r|octave|julia|matlab|maple|wolfram|cli), mathTimeoutMs bounds one computation, mathPackages is the pre-checked package list, mathInstallScope: "user" (default) | "system" is the scope an approved install plan may use (system applies to that request only and is never remembered); 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'},leanAsync:{type:'boolean'},leanInitiative:{type:'string',enum:['off','normal','eager']},leanSearchPaths:{type:'array',items:{type:'string'}},leanJobsMaxParallel:{type:'integer'},mathComputation:{type:'string',enum:['auto','on','off']},mathMode:{type:'string',enum:['typed+shell','typed']},mathEngines:{type:'array',items:{type:'string'}},mathTimeoutMs:{type:'integer'},mathPackages:{type:'array',items:{type:'string'}},mathInstallScope:{type:'string',enum:['user','system']}}),(s,a)=>{ const r=s.setParams(a); const st=s.status(); if(r&&r.ok===false) return Object.assign({},st,{ok:false,warning:r.message,ignored:r.ignored}); return st })
2435
3907
  // resident-facing tools: route to the CALLING resident (exec.agent.id === childId);
2436
3908
  // fall back to the last-woken resident when called by the host/assistant.
2437
3909
  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) })
@@ -2466,19 +3938,94 @@ export function apply(ctx) {
2466
3938
  // if a human or an agent calls them deliberately, but no prompt mentions them.
2467
3939
  registerTool('vibe_v4_lean_run','(resident) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Never throws: a missing toolchain returns LEAN_NOT_FOUND, a non-zero exit returns the compiler output verbatim, a timeout returns LEAN_TIMEOUT. Pass target=<object id> to also record the run against that object.',objParams({file:{type:'string'},target:{type:'string'},timeout_ms:{type:'integer'}},['file']),(s,a,x)=>s.leanRunTool(s.residentIdOf(x),a))
2468
3940
  registerTool('vibe_v4_lean_archive',"(resident) Archive Lean code. kind=\"def\": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind=\"lemma\": a machine-checked lemma → Formal/Proved. kind=\"proof\": the formal proof of a project object → Formal/<target>.lean, and (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. kind=\"blocked\": record an explicit, reasoned \"cannot/not worth formalizing\" decision (note required).",objParams({kind:{type:'string',enum:['def','lemma','proof','blocked']},name:{type:'string'},target:{type:'string'},content:{type:'string'},from:{type:'string'},note:{type:'string'},run:{type:'boolean'}},['kind']),(s,a,x)=>s.leanArchive(s.residentIdOf(x),a))
2469
- registerTool('vibe_v4_lean_lib','(resident) List (and by default rebuild) the Lean reuse library: this project\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining.',objParams({refresh:{type:'boolean'}}),async (s,a)=>{
3941
+ registerTool('vibe_v4_lean_lib','(resident) List (and by default rebuild) the Lean reuse library: this project\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining. The `paths.searchPath` field is the module root the framework injects into the compiler, and `jobs` shows the state of every background compile (leanAsync).',objParams({refresh:{type:'boolean'}}),async (s,a)=>{
2470
3942
  const r=(a&&a.refresh===false)?{lib:null,proved:null,objects:Object.keys(s.formalRecords()).length}:await s.rebuildLeanLibIndexes()
2471
3943
  return { ok:true, mode:s.formalMode(), rebuilt:!(a&&a.refresh===false), counts:r, todo:s.formalTodo(),
2472
3944
  objects:s.formalView().objects,
2473
- paths:{project:'Formal/(相对项目根)',lib:'VibeMath/Formal/Lib/',proved:'VibeMath/Formal/Proved/',proofs:'Verified/Lean/'},
2474
- hint:"复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_v4_lean_archive kind='def' 归档,已证引理用 kind='lemma'。" }
3945
+ paths:{project:'Formal/(相对项目根)',lib:'VibeMath/Formal/Lib/',proved:'VibeMath/Formal/Proved/',proofs:'Verified/Lean/',searchPath:s.leanPaths().searchPath,searchPaths:s.leanPaths().searchPaths},
3946
+ searchPath:s.leanPaths().searchPath,
3947
+ importNamespace:{lib:'import Formal.Lib.<name>',proved:'import Formal.Proved.<name>'},
3948
+ jobs:s.listLeanJobs(),
3949
+ hint:"复用优先:先在 Lib/ 里找现成定义(vibe_v4_lean_read 可取原文逐字复制);新定义用 vibe_v4_lean_archive kind='def' 归档,已证引理用 kind='lemma';后台编译进度看 jobs。" }
2475
3950
  })
3951
+ // lean-incremental-async §1.2: the ONLY new (read-only) tool. Reading an archived definition or
3952
+ // lemma VERBATIM is how a member reuses it without re-deriving it — or copies it when the local
3953
+ // toolchain cannot follow the import.
3954
+ registerTool('vibe_v4_lean_read','(resident, read-only) Read ONE archived Lean library file verbatim (for import-or-copy reuse). name is a bare library name (no path); kind is auto|lib|proved. Refuses anything outside <VibeMath>/Formal/{Lib,Proved}, and truncates at 64KB (truncated:true).',objParams({name:{type:'string'},kind:{type:'string',enum:['auto','lib','proved']}},['name']),(s,a)=>s.leanRead(a))
3955
+ // AMENDMENT §3: the read-only job/result surface (no polling tool beyond this one).
3956
+ registerTool('vibe_v4_lean_job','(resident, read-only) Background-compile jobs. Without jobId: this session\'s job list (state/receipt/archive). With jobId: that job\'s state, exitCode, receipt path (Formal/Jobs/<jobId>.json) and archive path; waitMs>0 waits up to that many milliseconds and then reports the current state (it never blocks forever).',objParams({jobId:{type:'string'},waitMs:{type:'integer'}}),(s,a)=>s.leanJobTool(a))
3957
+
3958
+ // §2.5: the fiber's disposer must not leave an orphan compiler behind. Every session's in-flight
3959
+ // Lean job is terminated (and marked `interrupted`, never `passed`) when this preset subtree
3960
+ // unloads — the same lifecycle the tool/command registrations above already follow.
3961
+ ctx.effect(() => () => { for(const s of sessions.values()){ try { s.disposeLean() } catch(e){ /* best effort */ } } })
3962
+
3963
+ // (3) Per-session module instances: LAZY, cached by the session's host object. Each instance has
3964
+ // its own adapted host (and therefore its own probe cache), so no state is shared between
3965
+ // sessions — there is no "current session" slot to clobber, and concurrent calls from two
3966
+ // sessions are fully independent (v5 uses the same structure).
3967
+ const mathInstances = new WeakMap() // session host object -> module instance
3968
+ function mathInstanceFor(hostObj){
3969
+ if(!hostObj) return null
3970
+ const cached=mathInstances.get(hostObj)
3971
+ if(cached) return cached
3972
+ const inst=registerMathComputation({
3973
+ // Capture-only: the tool face was registered ONCE for the plugin instance (step 1/2 above).
3974
+ register: ()=>{},
3975
+ params: ()=>hostObj.params(),
3976
+ projectRoot: ()=>hostObj.projectRoot(),
3977
+ designator: 'vibe-math-v4',
3978
+ writeText: (rel, text)=>hostObj.writeText(rel, text),
3979
+ readText: (rel)=>hostObj.readText(rel),
3980
+ exists: (rel)=>hostObj.exists(rel),
3981
+ // audit-R2 lens-2: attached ONLY when the session surface really has it, so a surface without
3982
+ // listDir reaches the module as an ABSENT field (the module's optional-callback contract).
3983
+ ...(typeof hostObj.listDir === 'function' ? { listDir: (rel)=>hostObj.listDir(rel) } : {}),
3984
+ resolveExecutable: (cmd)=>hostObj.resolveExecutable(cmd),
3985
+ spawn: (o)=>hostObj.spawn(o),
3986
+ // Optional capability flag: a host that KNOWS it has no subprocess service reports
3987
+ // MATH_NO_SUBPROCESS up front instead of a misleading ENGINE_NOT_FOUND + install guide.
3988
+ hasSubprocess: ()=>!!subprocessOf(),
3989
+ log: (kind, msg)=>hostObj.log(kind, msg),
3990
+ })
3991
+ mathInstances.set(hostObj, inst)
3992
+ return inst
3993
+ }
3994
+ function mathHandlerFor(session){
3995
+ const inst=mathInstanceFor(session&&session.mathHost)
3996
+ return inst?inst.handler:async()=>({ok:false,code:'MATH_INVALID_ARGUMENT',message:'math_computation: no session'})
3997
+ }
3998
+
3999
+ // ================= math_computation registration (P1) =================
4000
+ // The tool CORE is the shared module (installed byte-identically in all four presets).
4001
+ // (1) A throwaway CAPTURE-ONLY host yields the frozen tool face once: the module owns the name,
4002
+ // description and closed schema, and this preset must advertise exactly those.
4003
+ registerMathComputation({
4004
+ register: (name, description, parameters, handler)=>{
4005
+ // The description/schema must be the module's frozen text (a local re-spelling would silently
4006
+ // diverge from the other three presets); referencing MATH_TOOL_DESCRIPTION is that check.
4007
+ if(description!==MATH_TOOL_DESCRIPTION) ctx.logger.warn('math_computation: 工具描述与共享模块的 MATH_TOOL_DESCRIPTION 不一致')
4008
+ mathToolFace={name, description, parameters, handler}
4009
+ },
4010
+ params: ()=>DEFAULT_PARAMS,
4011
+ projectRoot: ()=>'',
4012
+ writeText: async()=>false,
4013
+ readText: async()=>undefined,
4014
+ exists: async()=>false,
4015
+ resolveExecutable: async()=>{ throw new Error('NO_SUBPROCESS: capture-only host') },
4016
+ spawn: async()=>null,
4017
+ designator: 'vibe-math-v4',
4018
+ log: ()=>{},
4019
+ })
4020
+ // (2) ONE tool registration for the whole plugin instance; the handler resolves the CALLING
4021
+ // session's own module instance.
4022
+ registerTool(mathToolFace.name, mathToolFace.description, mathToolFace.parameters, (s,a)=>mathHandlerFor(s)(a||{}))
2476
4023
 
2477
4024
  // Same lifecycle rule as registerTool: commands.register() returns a disposer, so the
2478
4025
  // registration belongs to this fiber and must be unwound with it.
2479
4026
  ctx.effect(() => commands.register({
2480
4027
  name:'v4', description:'control the Vibe Math V4 framework',
2481
- input:{hint:'[configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set]'},
4028
+ input:{hint:'[configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|paper [lang=zh|en] [format=md|tex|both] [editor=office|resident:<id>] [force]|members|add|remove|set]'},
2482
4029
  handler: async function(inv){
2483
4030
  const s=getSession(inv&&inv.agent); if(!s) return {kind:'success',text:JSON.stringify({ok:false,error:'no session'})}
2484
4031
  const line=String(inv&&inv.rawInput?inv.rawInput:'').trim(); const parts=line.split(/\s+/); const cmd=parts[0]||''; const rest=parts.slice(1)
@@ -2519,7 +4066,15 @@ export function apply(ctx) {
2519
4066
  r=(res&&res.ok===false)?Object.assign({},st,{ok:false,warning:res.message,ignored:res.ignored}):Object.assign({},st,{applied:res.applied})
2520
4067
  }
2521
4068
  }
2522
- else r={ok:false,usage:'configure|start [problem]|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set <k=v>...'}
4069
+ else if(cmd==='paper'){
4070
+ // Manual trigger (docs/final-paper.md §2). `lang=` / `format=` / `editor=` override THIS invocation only;
4071
+ // a bare `force` rewrites an already-finalized paper. An unknown token becomes an unknown
4072
+ // OPTION and is rejected by runPaperCommand → kind:'error' (never a silent success).
4073
+ const o={}; let force=false
4074
+ 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 }
4075
+ r=await s.runPaperCommand(o,force)
4076
+ }
4077
+ else r={ok:false,usage:'configure|start [problem]|resume|pause|abort|status|report|message <to|all> <content>|meeting|paper [lang=zh|en] [format=md|tex|both] [editor=office|resident:<id>] [force]|members|add|remove|set <k=v>...'}
2523
4078
  // `kind:'error'` on a rejected business action, exactly as v2/v3 do: every branch above used to
2524
4079
  // report `success`, so the human's only non-tool control surface read as success on failure.
2525
4080
  const failed = !r || r.ok === false || (r.ok === undefined && !!r.error)