dsh-vibe-math 2.7.1 → 2.7.3

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 (114) hide show
  1. package/README.en.md +682 -690
  2. package/README.md +19 -27
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +744 -518
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/final-paper.md +172 -172
  8. package/docs/formal-verification.md +588 -588
  9. package/docs/math-computation.md +194 -163
  10. package/docs/parameter-schema.md +37 -0
  11. package/docs/release-notes/README.en.md +49 -0
  12. package/docs/release-notes/README.md +48 -0
  13. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  16. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  17. package/docs/release-notes/TEMPLATE.md +126 -0
  18. package/docs/status-report-fields.md +167 -0
  19. package/docs/test-timing.md +157 -154
  20. package/installer.js +27 -17
  21. package/package.json +55 -3
  22. package/prompt-corpus-persona/persona-corpus.json +9 -9
  23. package/prompt-corpus-persona/persona-corpus.md +225 -33
  24. package/prompt-corpus-v2/formal-verify-v2.json +305 -113
  25. package/prompt-corpus-v2/formal-verify-v2.md +2399 -279
  26. package/prompt-corpus-v3/formal-verify-v3.json +134 -110
  27. package/prompt-corpus-v3/formal-verify-v3.md +409 -141
  28. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  29. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  30. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  31. package/prompt-corpus-v5/prompt-corpus-v5.md +513 -259
  32. package/scripts/update-doc-counts.mjs +71 -0
  33. package/tests/audit-artifact-docs.mjs +72 -0
  34. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  35. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  36. package/tests/audit-installer-compat.test.mjs +52 -4
  37. package/tests/audit-installer-policy.test.mjs +41 -8
  38. package/tests/audit-math-computation-contract.mjs +107 -9
  39. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  40. package/tests/audit-math-computation-parity.mjs +164 -3
  41. package/tests/audit-math-computation-sensitivity.mjs +6 -4
  42. package/tests/audit-package-membership.mjs +57 -0
  43. package/tests/audit-package-membership.mutants.mjs +28 -0
  44. package/tests/audit-participant-set-parity.mjs +160 -0
  45. package/tests/audit-path-discipline.mjs +83 -0
  46. package/tests/audit-path-discipline.mutants.mjs +51 -0
  47. package/tests/audit-persona-sensitivity.mjs +14 -0
  48. package/tests/audit-persona-surface.mutants.mjs +69 -0
  49. package/tests/audit-persona-surface.test.mjs +63 -22
  50. package/tests/audit-prompt-duplication.mjs +143 -0
  51. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  52. package/tests/audit-prompt-invariants.mjs +43 -5
  53. package/tests/audit-readme-bilingual.test.mjs +152 -0
  54. package/tests/audit-readme-counts.mjs +95 -0
  55. package/tests/audit-readme-counts.mutants.mjs +52 -0
  56. package/tests/audit-registration.mjs +78 -0
  57. package/tests/audit-status-report-fields.mjs +172 -0
  58. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  59. package/tests/audit-v3-registration-parity.mjs +156 -0
  60. package/tests/audit-v5-integrity.mjs +300 -13
  61. package/tests/audit-v5-lean-abstention.mjs +392 -0
  62. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  63. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  64. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  65. package/tests/audit-v5-sensitivity.mjs +15 -8
  66. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  67. package/tests/e2e-v5-round2.test.mjs +566 -12
  68. package/tests/formal-verify-v2.test.mjs +91 -5
  69. package/tests/formal-verify-v3.mutants.mjs +117 -0
  70. package/tests/formal-verify-v3.test.mjs +85 -6
  71. package/tests/formal-verify-v4.mutants.mjs +152 -0
  72. package/tests/formal-verify-v4.test.mjs +628 -9
  73. package/tests/formal-verify-v5.test.mjs +68 -9
  74. package/tests/helpers/doc-counts-format.mjs +41 -0
  75. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  76. package/tests/math-computation-a1.mutants.mjs +72 -0
  77. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  78. package/tests/math-computation-discovery.mutants.mjs +76 -0
  79. package/tests/math-computation-shared.test.mjs +396 -2
  80. package/tests/math-computation-v2.test.mjs +767 -0
  81. package/tests/math-computation-v3.test.mjs +759 -0
  82. package/tests/math-computation-v4.mutants.mjs +133 -0
  83. package/tests/math-computation-v4.test.mjs +739 -0
  84. package/tests/prompt-v5-integrity.test.mjs +13 -2
  85. package/tests/run-tests.mjs +139 -9
  86. package/tests/run-tests.mutants.mjs +70 -0
  87. package/tests/selfdrive-v5.mjs +53 -1
  88. package/tests/selfdrive-v5.mutants.mjs +132 -0
  89. package/tests/v2-fix-probes.mutants.mjs +100 -0
  90. package/tests/v2-fix-probes.test.mjs +1039 -0
  91. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  92. package/tests/v3-fix-probes.mutants.mjs +95 -0
  93. package/tests/v3-fix-probes.test.mjs +891 -0
  94. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  95. package/vibe-math-v2/agent.cordis.yml +62 -6
  96. package/vibe-math-v2/math-computation.js +229 -29
  97. package/vibe-math-v2/math-engines.js +12 -12
  98. package/vibe-math-v2/vibe-math-v2.js +458 -119
  99. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  100. package/vibe-math-v3/agent.cordis.yml +72 -6
  101. package/vibe-math-v3/math-computation.js +229 -29
  102. package/vibe-math-v3/math-engines.js +12 -12
  103. package/vibe-math-v3/vibe-math-v3.js +482 -139
  104. package/vibe-math-v4/agent.cordis.yml +44 -8
  105. package/vibe-math-v4/math-computation.js +229 -29
  106. package/vibe-math-v4/math-engines.js +12 -12
  107. package/vibe-math-v4/vibe-math-v4.js +245 -52
  108. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  109. package/vibe-math-v5/agent.cordis.yml +46 -12
  110. package/vibe-math-v5/math-computation.js +229 -29
  111. package/vibe-math-v5/math-engines.js +12 -12
  112. package/vibe-math-v5/vibe-math-v5.js +1307 -243
  113. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1655
  114. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -93,6 +93,11 @@ export function apply(ctx) {
93
93
  // automatic parent creation). Reading on demand removes that dependency on mount order.
94
94
  const subprocessOf = () => { try { return ctx.get('subprocess') } catch(e){ return undefined } }
95
95
  const sandboxPolicyOf = () => { try { return ctx.get('sandboxPolicy') } catch(e){ return undefined } }
96
+ // G-7 invariant (class guard): AGENT-FACING compaction MUST take the agent-local instance via
97
+ // `compactionForAgent(agent)` - this preset mounts its compaction row inside the preset's `compaction`
98
+ // isolate realm, and a realm NEVER falls back to the host-root instance. Only NON-agent sites may use
99
+ // `compactionOf()`. Guarded by the text-level assertion in tests/formal-verify-v4.test.mjs.
100
+
96
101
  const compactionOf = () => { try { return ctx.get('compaction') } catch(e){ return undefined } }
97
102
 
98
103
  const sessions = new Map() // rootAgentId -> Session
@@ -255,6 +260,11 @@ export function apply(ctx) {
255
260
  }
256
261
  let running = false, autoDone = false, phase = 'idle'
257
262
  let residents = new Map(), mailboxes = new Map(), taskboard = [], decisions = []
263
+ // round-9 (P2/D4): the participant set is frozen per verify/meeting together with this version, so
264
+ // every view reads the SAME roster instead of re-deriving it from the live map (mid-flight
265
+ // addMember/removeMember is possible: removeMember reconciles in-flight work, addMember has no
266
+ // verify/meeting gate).
267
+ let rosterVersion = 0
258
268
  let meetings = [], reports = [], activityLog = []
259
269
  let problemText = '', problemId = 'problem', runId = 'run-' + shortId()
260
270
  // Lean formalization records, keyed by object id. v4 has no session-log projection, so this
@@ -334,8 +344,62 @@ export function apply(ctx) {
334
344
  // escape the project tree. Keep every harmless character (incl. Chinese) and replace only
335
345
  // separators/control chars; strip leading/trailing dots/dashes so the name is never '.'/'..'.
336
346
  let warnedNoPolicy = false
347
+ let warnedDeferFormalTodo = false // F-6: name a swallowed deferForFormal failure once, WITH its consequence
348
+ let warnedIndexRebuild = false // F-6: name a swallowed settleLeanJob failure once, WITH its consequence
349
+ let warnedCompaction = false // F-6: name a swallowed realCompact failure once, WITH its consequence
350
+ let warnedWriteFormalTodo = false // F-6 (1/4): name a swallowed verify-conclusion write once, WITH its consequence
351
+ let warnedContextProbe = false // F-6 (:786): name a failed build-context comparison once (unknown != unchanged)
352
+ let warnedPaperWrite = false // G3: name a paper whose required artifact is missing after finalize (once)
353
+ let warnedMkdir = false // F-4c: name a failed mkdir ONCE (the deferred/distorted-diagnosis fix)
354
+ let warnedFenceDrift = false // F-5: name a resolved fence root that is NOT this session workspace (once)
355
+ let warnedCurrentProject = false // F-6: name a failed `.current` marker write once, WITH its consequence
337
356
  function warnNoPolicyOnce(){ if(!warnedNoPolicy){ warnedNoPolicy=true; console.error('vibe-math-v4: sandboxPolicy unavailable; writes go out with no explicit policy') } }
338
- function getPolicy(){ const sp=sandboxPolicyOf(); if(!sp){ warnNoPolicyOnce(); return undefined } try { if(rootAgent&&rootAgent.session) return sp.resolve({session:rootAgent.session}) } catch(e){ warnNoPolicyOnce() } try { const p=sp.resolve({}); if(!warnedNoPolicy){ warnedNoPolicy=true; console.error('vibe-math-v4: falling back to sandboxPolicy.resolve({}) — the fence root is the host-configured workspace, not necessarily this session cwd') } return p } catch(e){ warnNoPolicyOnce() } return undefined }
357
+ function getPolicy(){ const sp=sandboxPolicyOf(); if(!sp){ warnNoPolicyOnce(); return undefined } try { if(rootAgent&&rootAgent.session){ const p=sp.resolve({session:rootAgent.session}); fenceRootDriftNote(p); return p } } catch(e){ warnNoPolicyOnce() } try { const p=sp.resolve({}); if(!warnedNoPolicy){ warnedNoPolicy=true; console.error('vibe-math-v4: falling back to sandboxPolicy.resolve({}) — the fence root is the host-configured workspace, not necessarily this session cwd') } return p } catch(e){ warnNoPolicyOnce() } return undefined }
358
+ /**
359
+ * F-5 (R9 ruling): the SESSION-scoped policy can resolve to a fence root that does not CONTAIN
360
+ * this session workspace - the host silently widened or re-homed the write fence. Shape mirrored
361
+ * from v3 (policyRootOf / warnFenceDriftOnce / checkFenceRoot returning p), with the conservative
362
+ * predicate the probe requires:
363
+ * - containment, never equality: an ANCESTOR root is a legitimate configuration, so equality
364
+ * alone would report normal setups as drift (once per session);
365
+ * - an array of roots (list-style fences) => accept when the workspace is inside ANY element;
366
+ * - no root field / non-string / an object shape we cannot interpret (e.g. {allow,deny}) =>
367
+ * NO-OP: today's behaviour. The live probe could not read the real host resolve() shape (no
368
+ * service is reachable from a plugin row in this sandbox), so we never guess - the no-op path
369
+ * is asserted positively in tests instead;
370
+ * - the `resolve({})` fallback is NEVER compared (it already has its own named warning and its
371
+ * root is by definition not this session cwd => comparing it could only add noise);
372
+ * - normalisation: separators -> "/", trailing slashes stripped, case-folded ONLY on win32 (on a
373
+ * case-sensitive host folding would hide a real drift = a false negative).
374
+ * Zero behaviour change: no refusal, and getPolicy() still returns the resolved policy.
375
+ */
376
+ function policyRootCandidatesOf(p){
377
+ try {
378
+ if(!p || typeof p !== 'object') return []
379
+ let pick
380
+ if(p.workspaceRoot !== undefined) pick = p.workspaceRoot
381
+ else if(p.root !== undefined) pick = p.root
382
+ else if(p.cwd !== undefined) pick = p.cwd
383
+ else if(p.config && p.config.workspaceRoot !== undefined) pick = p.config.workspaceRoot
384
+ if(Array.isArray(pick)) return pick.filter(x => typeof x === 'string' && x)
385
+ return (typeof pick === 'string' && pick) ? [pick] : []
386
+ } catch(e) { return [] }
387
+ }
388
+ function fenceRootDriftNote(p){
389
+ try {
390
+ const cands = policyRootCandidatesOf(p)
391
+ if(!cands.length) return // no comparable root => documented NO-OP (asserted in tests)
392
+ const norm = (x) => { let v = String(x).replace(/\\/g, '/'); if(v.length > 1) v = v.replace(/\/+$/, ''); return process.platform === 'win32' ? v.toLowerCase() : v }
393
+ const session = norm(workspaceRoot())
394
+ if(!session || session === '.') return
395
+ const contained = cands.some((c) => { const r = norm(c); return !!r && (session === r || session.indexOf(r + '/') === 0) })
396
+ if(contained) return
397
+ if(!warnedFenceDrift){
398
+ warnedFenceDrift = true
399
+ console.error('vibe-math-v4: sandbox fence root does not CONTAIN this session workspace (resolved="' + cands.join('", "') + '", session="' + workspaceRoot() + '") - writes may be fenced away from the session workspace; boundary: a policy exposing no interpretable root field cannot be compared')
400
+ }
401
+ } catch(e) { /* a diagnostic must never break policy resolution */ }
402
+ }
339
403
  function psQuote(p){ return "'"+String(p).replace(/'/g,"''")+"'" }
340
404
  /** POSIX 单引号引用:把 ' 换成 '\'' 以安全嵌入任意路径。 */
341
405
  function shQuote(p){ return "'"+String(p).replace(/'/g,"'\\''")+"'" }
@@ -435,12 +499,36 @@ export function apply(ctx) {
435
499
  // project tree, NOT inside it: cross-project reuse is the whole point (spec §3). It is
436
500
  // created here so the first `lean_archive kind='def'` never has to invent its parent.
437
501
  const globalDirs=['Formal/Lib','Formal/Proved']
438
- return await runShell(mkdirCmd([vibeRoot()+'/Projects'].concat(dirs.map(d=>base+'/'+d)).concat(globalDirs.map(d=>vibeRoot()+'/'+d))))
502
+ const res = await runShell(mkdirCmd([vibeRoot()+'/Projects'].concat(dirs.map(d=>base+'/'+d)).concat(globalDirs.map(d=>vibeRoot()+'/'+d))))
503
+ // F-4c: ensureDirs() returns the shell result and never throws, so a failed mkdir used to surface only
504
+ // later and as a DISTORTED diagnosis ('write failed' instead of 'mkdir failed, and where'). Name it here,
505
+ // once, through the same channel as warnNoPolicyOnce() (deferred/distorted diagnosis -> named warning).
506
+ try {
507
+ const _code = res && (res.exitCode !== undefined ? res.exitCode : res.code)
508
+ if (_code !== undefined && Number(_code) !== 0 && !warnedMkdir) {
509
+ warnedMkdir = true
510
+ console.error('vibe-math-v4: ensureDirs: mkdir failed (exit ' + _code + ') under ' + vibeRoot() + ' - later writes into a missing directory will fail; check the shell/policy')
511
+ }
512
+ } catch(e) { /* best effort: the warning must never break ensureDirs */ }
513
+ return res
439
514
  }
440
515
  async function readTextAbs(path){ try { const t=await fs.resolve(path); const s=await fs.stat(t); if(s===undefined) return undefined; return await fs.readText(t) } catch(e){ return undefined } }
441
516
  async function writeTextAbs(path,content){ try { const t=await fs.resolve(path); await fs.writeText(t,content,undefined,undefined,getPolicy()); return true } catch(e){ return false } }
442
517
  async function readCurrentProject(){ try { const t=await readTextAbs(vibeRoot()+'/.current'); if(t) return String(t).trim() } catch(e){} return currentProject }
443
- async function writeCurrentProject(){ try { await writeTextAbs(vibeRoot()+'/.current', currentProject) } catch(e){} }
518
+ // F-6 boundaries MEASURED on this revision (recorded, not fixed here - both would need a readText
519
+ // failure signal and therefore touch as many callers as writeTextAbs, which the lead deferred):
520
+ // - readCurrentProject(): a failed READ is swallowed inside readTextAbs (:514), so this function
521
+ // cannot tell "read failed" from "no marker"; its silent fallback stays.
522
+ // - assertWritable()'s outer `catch(e){}` (:491) CANNOT FIRE, because readText (:438) has already
523
+ // erased the failure: it swallows every error and returns undefined, so a present-but-unreadable
524
+ // file is treated as MISSING and the overwrite guard silently allows the write. The silent skip
525
+ // therefore happens ONE LAYER UP, not in the catch. Surfacing it means making readText REPORT
526
+ // failure - the same breadth as :515 writeTextAbs, which the lead deferred.
527
+ // - writeCurrentProject()'s own `catch` branch is likewise DEFENSIVE-ONLY: writeTextAbs never throws
528
+ // (it catches internally and returns a boolean), so the `ok===false` branch is the only reachable
529
+ // one; the exception path is kept as belt-and-braces and is recorded as unreachable (measured: a
530
+ // mutant that removes it cannot redden the N18 assertion, so it ships without a family entry).
531
+ async function writeCurrentProject(){ try { const ok=await writeTextAbs(vibeRoot()+'/.current', currentProject); if(ok===false && !warnedCurrentProject){ warnedCurrentProject=true; console.error('vibe-math-v4: the current-project marker was NOT persisted - the next session may load the previous project') } } catch(e){ if(!warnedCurrentProject){ warnedCurrentProject=true; console.error('vibe-math-v4: the current-project marker was NOT persisted - the next session may load the previous project') } } }
444
532
 
445
533
  // ================= Lean formal verification ==============================
446
534
  // Contract: docs/formal-verification.md (shared by v2/v3/v4/v5).
@@ -758,10 +846,16 @@ export function apply(ctx) {
758
846
  // the job was in flight, RECORD that the current context differs (the compile itself was done
759
847
  // with the recorded context, which is what the job id attests to).
760
848
  let contextChanged=false
761
- try { const nowCtx=await leanBuildContext()
849
+ try {
850
+ const nowCtx=await leanBuildContext()
762
851
  contextChanged=JSON.stringify([nowCtx.engine,nowCtx.argv,nowCtx.searchPaths])!==JSON.stringify([(job.build||{}).engine,(job.build||{}).argv,(job.build||{}).searchPaths])
763
- if(contextChanged) logActivity('formal','作业 '+job.jobId+' 的结果属于记录中的构建上下文(engine/argv/search-paths 已被改动,未影响该次编译的结论)')
764
- } catch(e){ /* best effort */ }
852
+ } catch(e){
853
+ // F-6 (:786): a comparison that could NOT run must not be recorded as "unchanged" -
854
+ // unknown != unchanged. Record null AND name it once, with the consequence.
855
+ contextChanged=null
856
+ if(!warnedContextProbe){ warnedContextProbe=true; console.error('vibe-math-v4: settleLeanJob: the build-context comparison could not be performed - contextChanged=false is NOT a measurement (recorded as null/unknown)') }
857
+ }
858
+ if(contextChanged===true) logActivity('formal','作业 '+job.jobId+' 的结果属于记录中的构建上下文(engine/argv/search-paths 已被改动,未影响该次编译的结论)')
765
859
  const ok=!!(run&&run.ok)&&hashMatch
766
860
  const timedOut=!!(run&&run.timedOut)
767
861
  const state=ok?'settled':(timedOut?'timeout':'failed')
@@ -796,7 +890,7 @@ export function apply(ctx) {
796
890
  }
797
891
  }
798
892
  await writeLeanJob(settled)
799
- try { await rebuildLeanLibIndexes() } catch(e){ /* best-effort */ }
893
+ try { await rebuildLeanLibIndexes() } catch(e){ if(!warnedIndexRebuild){ warnedIndexRebuild=true; console.error('vibe-math-v4: settleLeanJob: rebuildLeanLibIndexes failed - the library indexes are STALE; residents may reuse a stale index until the next successful rebuild') } /* best-effort */ }
800
894
  pushLeanNotice(settled,Object.assign({},run,{ok,hashMatch}))
801
895
  return settled
802
896
  }
@@ -851,6 +945,9 @@ export function apply(ctx) {
851
945
  * §2.6 crash/restart recovery: scan `<project>/Formal/Jobs/*.json` AFTER the project dirs exist.
852
946
  * It NEVER mints a `passed` anything unless the job record itself says exitCode===0 AND the file
853
947
  * on disk still hashes to the enqueued content.
948
+ * INVARIANT (F2): after redriving, the recovery DRAINS the queue itself (`await runLeanQueue()`) and only
949
+ * then arms one heartbeat - it must NOT rely on an already-armed heartbeat. Deleting that drain silently
950
+ * restores the cold-recovery stall (see the §20 tripwire in tests/formal-verify-v4.test.mjs).
854
951
  */
855
952
  async function recoverLeanJobs(opts){
856
953
  const o=opts||{}
@@ -903,7 +1000,7 @@ export function apply(ctx) {
903
1000
  }
904
1001
  }
905
1002
  }
906
- if(queued+redriven+stale+completed){ await writeLeanLibIndexesSafe() ; if(leanQueue.length) armHeartbeat() }
1003
+ if(queued+redriven+stale+completed){ await writeLeanLibIndexesSafe() ; if(leanQueue.length){ await runLeanQueue() ; armHeartbeat() } } // cold-recovery drain: the heartbeat alone did not drain while the run was not yet live (verified by the §20 tripwire)
907
1004
  return {scanned:(files||[]).length,queued,redriven,stale,completed}
908
1005
  }
909
1006
  async function writeLeanLibIndexesSafe(){ try { await rebuildLeanLibIndexes() } catch(e){ /* best-effort */ } }
@@ -1182,15 +1279,23 @@ export function apply(ctx) {
1182
1279
  return rows
1183
1280
  }
1184
1281
  const libRows=await scan(formalLibRoot(),'Formal/Lib','def')
1185
- await writeTextAbs(formalLibRoot()+'/Index.md',['# 可复用 Lean 定义库(跨项目)|'+currentProject,'',
1282
+ const _idxLibOk = await writeTextAbs(formalLibRoot()+'/Index.md',['# 可复用 Lean 定义库(跨项目)|'+currentProject,'',
1186
1283
  '> 写新定义之前先查这里:能复用就不要重新定义。复用方式:`import Formal.Lib.<名称>`(模块根 = VibeMath 根,框架已加入编译搜索路径),或用 vibe_v4_lean_read 取原文逐字复制。','',
1187
1284
  '| 名称 | 文件 | 类别 | 依赖 | 摘要 |','|---|---|---|---|---|']
1188
1285
  .concat(libRows.length?libRows:['| (暂无) | | | | |']).join('\n')+'\n')
1189
1286
  const provedRows=await scan(formalProvedRoot(),'Formal/Proved','lemma')
1190
- await writeTextAbs(formalProvedRoot()+'/Index.md',['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|'+currentProject,'',
1287
+ const _idxProvedOk = await writeTextAbs(formalProvedRoot()+'/Index.md',['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|'+currentProject,'',
1191
1288
  '> 这些文件是通过内核检查的引理,可直接 `import Formal.Proved.<名称>` 复用(模块根 = VibeMath 根)。','',
1192
1289
  '| 名称 | 文件 | 类别 | 依赖 | 陈述 |','|---|---|---|---|---|']
1193
1290
  .concat(provedRows.length?provedRows:['| (暂无) | | | | |']).join('\n')+'\n')
1291
+ // ③ MEASURED: these two writes ignored `writeTextAbs`'s boolean (it swallows and returns
1292
+ // false), so a failed index write looked like success - and that silence propagated to ALL SIX
1293
+ // callers (settleLeanJob, writeLeanLibIndexesSafe, the three leanArchive paths, the lean_lib
1294
+ // tool). Naming it HERE, once, covers every caller (same shape as ensureDirs/warnedMkdir).
1295
+ if((_idxLibOk === false || _idxProvedOk === false) && !warnedIndexRebuild){
1296
+ warnedIndexRebuild = true
1297
+ console.error('vibe-math-v4: rebuildLeanLibIndexes: the Lean library index could NOT be written (lib=' + _idxLibOk + ' proved=' + _idxProvedOk + ') - the index is STALE until the next successful rebuild, so reuse lookups may miss entries')
1298
+ }
1194
1299
  await writeFormalIndex()
1195
1300
  await writeFormalTodo()
1196
1301
  return {lib:libRows.length,proved:provedRows.length,objects:Object.keys(formalRecords()).length}
@@ -1550,23 +1655,24 @@ export function apply(ctx) {
1550
1655
  async function inboxText(rId){ const mb=mailboxes.get(rId)||[]; if(mb.length===0) return ' (no new messages)\n'; return mb.map(m=>' ['+senderLabel(m.from)+'] '+m.content).join('\n')+'\n' }
1551
1656
  function residentLibraries(){
1552
1657
  const base=frameworkRoot()
1553
- return '你的资料库根目录:'+base+'/\n'
1658
+ return '你的资料库根目录(**绝对前缀**,下面每条都已带库根):'+base+'/\n'
1554
1659
  +' Progress/<你>/progress.md —— 你的研究日志(叙述,可追加。主要内容是尝试过的各方法、路线、历程、进度,当前研究进展/进度、将来的计划与打算,及各路线、过程中遇到的障碍及其原因,对各路线、方法的看法、可行性评估,自己研究过程中的一些有价值看法、感想、猜想、理解。以及其它各种你认为有价值的值得记录的事物、经验、方法/想法、创新等都可进行记录)。\n'
1555
1660
  +' Propos/<你>/<id>.md —— 你的命题/引理。格式:\n'
1556
1661
  +' - ID: p-<id>; - 状态: 未定论; - 概率: <0-1>; - 价值程度: <0-1>; - 动机用途计划: <为何重要/打算怎么用>\n'
1557
1662
  +' 然后 ## 陈述 <陈述>;## 证明尝试;## 证伪尝试。\n'
1558
1663
  +' Methods/<你>/<id>.md —— 你的理论/方法/工具。格式:- ID: m-<id>; - 状态: 经验; - 可信断言: []; - 价值程度: <0-1>; - 动机用途计划: ...;然后 ## 核心内容;## 定义与记号;## 应用记录;## 改进历史。\n'
1559
1664
  +' Subproblems/<你>/<id>.md —— 你的子问题。格式:- ID: s-<id>; - 状态: 求解中; - 价值程度: <0-1>; - 动机用途计划: ...;然后 ## 陈述;## 进度。\n'
1665
+ +' ⚠ 你的文件工具(读/写)按**会话 cwd** 解析路径:上面列出的相对子路径都要先拼上**成员库根** '+base+'/ 再使用(例如 '+base+'/Propos/r-1/p-1.md);计算产物同理——请用回执里的**绝对**字段 receipt.scriptAbs,或把 receipt.cwd 与 receipt.scriptPath 拼起来,不要用相对路径。\n'
1560
1666
  }
1561
1667
  function toolList(){
1562
1668
  return 'vibe_v4_send_message {to, content} —— 给某常驻发消息(to=all 广播)。\n'
1563
1669
  +'vibe_v4_meeting {agenda} —— 发起/参与会议(框架会把各常驻的实际 input 转给其他人,让大家看到并讨论/辩论)。\n'
1564
1670
  +'vibe_v4_propose_task/claim_task/task_done/list_tasks —— 共享任务板(提议/认领/完成/查看;任务板是你们协调分工的载体)。\n'
1565
- +'vibe_v4_publish_progress/record_proposition/record_method/record_subproblem —— 便捷记录器(可选;推荐直接用 fs 写自己的文件)。\n'
1671
+ +'vibe_v4_publish_progress/record_proposition/record_method/record_subproblem —— 便捷记录器(可选;推荐直接用 fs 写自己的文件,路径必须带库根 )。\n'
1566
1672
  +'vibe_v4_read_progress {id} —— 只读某常驻的进展。\n'
1567
1673
  +'vibe_v4_list_residents / vibe_v4_list_tasks —— 查看团队组成 / 开放任务。\n'
1568
1674
  +'vibe_v4_report_context {pct} —— 上报上下文占比(框架据此压缩你的上下文)。\n'
1569
- +'fs (read/write/list) —— 读取任意文件;写入你自己的文件(推荐直接用 fs 直接写自己的 md)。\n'
1675
+ +'fs (read/write/list) —— 读取任意文件;写入你自己的文件(推荐直接用 fs 直接写自己的 md;**路径必须带库根 ,否则会落到别处**)。\n'
1570
1676
  }
1571
1677
  // A shared, complete context block so a resident always knows the situation: mission,
1572
1678
  // work model, what it can do, which files it owns (+ formats), what others' files are,
@@ -1582,14 +1688,14 @@ export function apply(ctx) {
1582
1688
  s.push('### 工作模式(会发生什么)')
1583
1689
  s.push('1. 每人有一份持久、全组可见的专属资料库(见下)。')
1584
1690
  s.push('2. 你们自由发消息、开会;**会议会把每个人实际说的话(input)转给其他人**,让你看得到、能回复、能讨论、能辩论。')
1585
- s.push('3. 你独立研究,并**直接用 fs 写入你自己的文件**(按格式),供全组阅读。')
1691
+ s.push('3. 你独立研究,并**直接用 fs 写入你自己的文件**(**路径必须带库根 **,按格式),供全组阅读。')
1586
1692
  s.push('4. 任何"已确立"的东西须**全组一致**验证(全真或全假)才作数;否则只是带概率的工作估计。')
1587
1693
  s.push('5. 只有**全组在会议上一致认为原问题已解决**,run 才停止。')
1588
1694
  s.push('')
1589
1695
  s.push('### 你负责的文件(你只写自己的;但可读任何人的)')
1590
1696
  s.push(residentLibraries())
1591
1697
  s.push('其他人把结论/进展写进他们的目录,你就能读到。**你应主动读别人的库**,对齐事实、彼此衔接、避免重复劳动。')
1592
- if(level!=='full'){ s.push('(格式见你最初的说明;直接用 fs 写自己的文件即可。)') }
1698
+ if(level!=='full'){ s.push('(格式见你最初的说明;直接用 fs 写自己的文件即可,**路径必须带库根 **。)') }
1593
1699
  s.push('')
1594
1700
  s.push('### 可用工具')
1595
1701
  s.push(toolList())
@@ -1628,7 +1734,7 @@ export function apply(ctx) {
1628
1734
  +(r.direction?('\n\n你被建议的初始方向(可自行调整/细化):\n'+r.direction+'\n'):'')
1629
1735
  +'## 这是你的第一轮:独立头脑风暴\n'
1630
1736
  +'独立地想清楚:你对这个问题的洞察 / 解决方向 / 关键子问题 / 可能的引理 / 粗略计划。你还未见到其他人,先独立产出。\n'
1631
- +'把有价值的产物**直接用 fs 写进你自己的文件**(按上面格式),并在 summary 里概述你的切入方向与初步结论(标注哪些是猜想、哪些凭你已确证)。\n'
1737
+ +'把有价值的产物**直接用 fs 写进你自己的文件**(**路径带库根 **,按上面格式),并在 summary 里概述你的切入方向与初步结论(标注哪些是猜想、哪些凭你已确证)。\n'
1632
1738
  +'Reply with ONLY a JSON object:\n'
1633
1739
  +'{"summary":"<your insight / direction / rough plan, one tight paragraph>","solved":false}'
1634
1740
  }
@@ -1640,7 +1746,7 @@ export function apply(ctx) {
1640
1746
  await refreshMathAvailability()
1641
1747
  const mathAvail=mathAvailabilityBlock()
1642
1748
  return (params.residentPersona?params.residentPersona+'\n':'')
1643
- +'Resident researcher '+r.rId+' — 第 '+r.rounds+' 轮。一切由你和团队讨论决定。动手前先**读别人的库**对齐事实、避免重复;把新进展/结论**直接用 fs 写进你自己的文件**;想对团队说的话放 "input"(会转给其他常驻)。\n'
1749
+ +'Resident researcher '+r.rId+' — 第 '+r.rounds+' 轮。一切由你和团队讨论决定。动手前先**读别人的库**对齐事实、避免重复;把新进展/结论**直接用 fs 写进你自己的文件**(**路径带库根 **);想对团队说的话放 "input"(会转给其他常驻)。\n'
1644
1750
  +'\n团队成员:\n'+banner()+'\n'
1645
1751
  +'New items:\n'+ (await inboxText(r.rId)) +'\n'
1646
1752
  // Settled background compiles are announced ONCE, in the next prompt constructed (spec §2.4):
@@ -1659,8 +1765,8 @@ export function apply(ctx) {
1659
1765
  const prior=Object.entries(st.inputs).filter(([k])=>k!==r.rId).map(([k,iv])=>' ['+k+'] '+String(iv.input||iv.summary||'')).join('\n')
1660
1766
  return (params.residentPersona?params.residentPersona+'\n':'')
1661
1767
  +'Resident '+r.rId+' — 团队会议进行中。 A meeting is in progress (agenda: '+st.agenda+').'
1662
- +'\n这是一场真实讨论:下面已有人发言(转给你),请先看,然后**加入讨论/补充/反驳/表决**。'
1663
- +(prior?('\n\n### 已有发言(他人 input,已转发给你)\n'+prior):'\n(目前还没人发言,你先说。)')
1768
+ +(prior?'\n这是一场真实讨论:下面已有人发言(转给你),请先看,然后**加入讨论/补充/反驳/表决**。':'\n本轮是**并行独立发言**:同轮内不会有人先发言给你(唤醒是并行的),你的 input 会在**下一次唤醒/下一轮**转给其他人;请先给出你自己的判断。')
1769
+ +(prior?('\n\n### 已有发言(他人 input,已转发给你)\n'+prior):'')
1664
1770
  +'\n\n你可以:提议任务(propose_task)、认领开放任务(claim_task)、提议验证对象(propose_verify)、或对"原问题是否已解决"表决(voteSolved)。请把**你的实际发言**写进 "input"。'
1665
1771
  +'\n**停止表决必须是绝对票**:`voteSolved:true` 只表示你认为原问题**已解决**;只要有一名在册常驻没投 `true`(投 false、弃权、或漏写这个字段),run 就**不会**停止。不确定就投 false。' +'\nReply with ONLY a JSON object:\n'
1666
1772
  +'{"input":"<your real contribution to this discussion>","propose_task":"<task title or null>","task_desc":"...","claim_task":"<task id or null>","propose_verify":"<id or null>","voteSolved":true}'
@@ -1673,7 +1779,7 @@ export function apply(ctx) {
1673
1779
  const others=Object.entries(src).map(([k,v])=>'- '+k+': 正确概率 '+String(v.prob!=null?Number(v.prob).toFixed(2):0.5)+' → '+v.reason).join('\n')
1674
1780
  const L=[]
1675
1781
  L.push((params.residentPersona?params.residentPersona+'\n':'')
1676
- +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+targetTypeWord(vs.targetType)+',提出者 '+vs.targetOwner+')。\n'
1782
+ +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+targetTypeWord(vs.targetType)+(String(vs.targetOwner||'').trim()?(',提出者 '+vs.targetOwner):'')+')。\n'
1677
1783
  +'请给出你对「该对象为真」的**正确概率 `verdict`**,仅一个 0–1 数值:**1 = 绝对为真,0 = 绝对为假,0.5 = 完全不确定,其余为介于其间的程度**(不要给 TRUE/FALSE,就给一个数值)。\n'
1678
1784
  // ── THE VOTE CONTRACT (why the example below is 1 and not 0.9) ──────────────────────────
1679
1785
  // Only an EXACT 1 or 0 is a vote; anything strictly between is the model's honest
@@ -1684,7 +1790,8 @@ export function apply(ctx) {
1684
1790
  +'**投票契约**:只有**恰好 1**(你认为是**绝对**为真)和**恰好 0**(你认为是**绝对**为假)算表决;**严格介于 0 与 1 之间**(例如 0.9、0.95、0.5)是**弃权**——它是你对"该对象为真"的**概率估计**,不是你的一票。\n'
1685
1791
  +' · 有把握认为它为真就投 **1**;不要为了"留一点余地"投 0.9——那会让全组永远无法定论。\n'
1686
1792
  +' · 弃权会被如实统计:本轮没有人全票 → 对象**不会**停止验证,而是把全组的**平均概率**写回它在库中的卡片(`- 概率:`),带概率继续留在库里。\n'
1687
- +' · 弃权的两种合法用途:① 你确实不确定(用 0.5 附近的值表达);② 已归档的机器检查证明与命题原文不一致、你**不能**用 0 表达"命题为假"(此时请用 `formal` 回执的 `decision:"defect"` 报告偏差,见下方形式化段)。\n'
1793
+ +' · 弃权的合法用途:你确实不确定(用 0.5 附近的值表达)。\n'
1794
+ +(formalOn()?(' · 另一种弃权:已归档的机器检查证明与命题原文不一致、你**不能**用 0 表达"命题为假"(此时请用 `formal` 回执的 `decision:"defect"` 报告偏差)。\n'):'')
1688
1795
  +'判定规则:**仅当全体在册常驻都恰好给 1(都认为是真)、或都恰好给 0(都认为是假)**,才按「真/假」写入 Verified/ 并回写来源卡的「已验证·真/假」;否则**只按概率数值(一种程度)保留在库中**,附全组平均正确概率,不写成真/假。\n'
1689
1796
  +'请给出你**诚实独立的判断**'
1690
1797
  +(vs.stage==='debate'?',并参考他人意见:\n':'。\n')
@@ -1898,10 +2005,23 @@ export function apply(ctx) {
1898
2005
  function byChild(childId){ for(const [,r] of residents){ if(r.childId===childId) return r } return undefined }
1899
2006
 
1900
2007
  // ---- artifact writers (resident-facing) ----
1901
- async function publishProgress(rId,content){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const rel='Progress/'+rId+'/progress.md'; const prev=(await readText(rel))||''; await writeText(rel, prev+'\n### '+fmtTime()+'|'+rId+'\n'+String(content||'')+'\n'); return {ok:true} }
1902
- async function recordProposition(rId,o){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const id=o.id?idSafe(o.id):('p-'+shortId()); const lines=['# 命题|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 类型: 命题','- 状态: 未定论','- 概率: '+cl(o.prob!=null?o.prob:0.5),'- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'- 依赖: []','','## 陈述',String(o.statement||''),'','## 证明尝试','','## 证伪尝试','']; await writeText('Propos/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 命题 '+id); bumpArtifacts(); return {ok:true,id,file:'Propos/'+rId+'/'+id+'.md'} }
1903
- async function recordMethod(rId,o){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const id=o.id?idSafe(o.id):('m-'+shortId()); const lines=['# 方法|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 类型: '+(o.type||'方法'),'- 状态: 经验','- 可信断言: []','- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'','## 核心内容',String(o.content||''),'','## 定义与记号',String(o.notation||''),'','## 应用记录','## 改进历史','']; await writeText('Methods/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 方法 '+id); bumpArtifacts(); return {ok:true,id,file:'Methods/'+rId+'/'+id+'.md'} }
1904
- async function recordSubproblem(rId,o){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const id=o.id?idSafe(o.id):('s-'+shortId()); const lines=['# 子问题|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 状态: 求解中','- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'- 依赖: []','','## 陈述',String(o.statement||''),'','## 进度','']; await writeText('Subproblems/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 子问题 '+id); bumpArtifacts(); return {ok:true,id,file:'Subproblems/'+rId+'/'+id+'.md'} }
2008
+ /** Cross-preset audit D1: a member-facing tool must never answer with a bare `no such resident`.
2009
+ * "not started yet" / "wrong id" / "already dismissed" are DIFFERENT problems, so the answer
2010
+ * carries a code, the LIVE roster state and an actionable next step (v5's `memberDiagnosis`). */
2011
+ function noSuchResident(id, tool){
2012
+ const ids=[...residents.keys()]
2013
+ const active=[...residents.values()].filter(r=>r&&r.childId).length
2014
+ const nextTool=tool||(running?'vibe_v4_list_members':'vibe_v4_start')
2015
+ return {ok:false,code:'V4_NO_SUCH_RESIDENT',
2016
+ message:'no such resident: '+String(id||'(none)')+(running?('(当前在册 '+residents.size+' 名,active '+active+' 名)'):'(本次运行尚未 start)'),
2017
+ status:{running:!!running,autoDone:!!autoDone,residentCount:residents.size,activeCount:active,ids:ids},
2018
+ next:{tool:nextTool,
2019
+ hint:running?'核对有效 id(新建成员可能仍在 spawn 中);列表用 vibe_v4_list_members':'先 vibe_v4_start 或 vibe_v4_add_member,再调用 '+nextTool}}
2020
+ }
2021
+ async function publishProgress(rId,content){ if(!rId||!residents.has(rId)) return noSuchResident(rId) ; const rel='Progress/'+rId+'/progress.md'; const prev=(await readText(rel))||''; await writeText(rel, prev+'\n### '+fmtTime()+'|'+rId+'\n'+String(content||'')+'\n'); return {ok:true} }
2022
+ async function recordProposition(rId,o){ if(!rId||!residents.has(rId)) return noSuchResident(rId) ; const id=o.id?idSafe(o.id):('p-'+shortId()); const lines=['# 命题|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 类型: 命题','- 状态: 未定论','- 概率: '+cl(o.prob!=null?o.prob:0.5),'- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'- 依赖: []','','## 陈述',String(o.statement||''),'','## 证明尝试','','## 证伪尝试','']; await writeText('Propos/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 命题 '+id); bumpArtifacts(); return {ok:true,id,file:'Propos/'+rId+'/'+id+'.md'} }
2023
+ async function recordMethod(rId,o){ if(!rId||!residents.has(rId)) return noSuchResident(rId) ; const id=o.id?idSafe(o.id):('m-'+shortId()); const lines=['# 方法|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 类型: '+(o.type||'方法'),'- 状态: 经验','- 可信断言: []','- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'','## 核心内容',String(o.content||''),'','## 定义与记号',String(o.notation||''),'','## 应用记录','## 改进历史','']; await writeText('Methods/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 方法 '+id); bumpArtifacts(); return {ok:true,id,file:'Methods/'+rId+'/'+id+'.md'} }
2024
+ async function recordSubproblem(rId,o){ if(!rId||!residents.has(rId)) return noSuchResident(rId) ; const id=o.id?idSafe(o.id):('s-'+shortId()); const lines=['# 子问题|'+(o.title||id),'- 标题: '+(o.title||id),'- ID: '+id,'- 状态: 求解中','- 价值程度: '+cl(o.value!=null?o.value:0.5),'- 动机用途计划: '+(o.motivation||''),'- 依赖: []','','## 陈述',String(o.statement||''),'','## 进度','']; await writeText('Subproblems/'+rId+'/'+id+'.md',lines.join('\n')); logActivity('record',rId+' 子问题 '+id); bumpArtifacts(); return {ok:true,id,file:'Subproblems/'+rId+'/'+id+'.md'} }
1905
2025
  // ── auto-sync meeting: every `meetingKeepEvery` NEW artifacts, convene a coordination meeting ──
1906
2026
  //
1907
2027
  // The counting basis is the RESIDENTS' card libraries themselves (Propos/Methods/Subproblems),
@@ -2015,7 +2135,7 @@ export function apply(ctx) {
2015
2135
 
2016
2136
  // ---- messaging ----
2017
2137
  async function postMessage(from,to,content){
2018
- const r=residents.get(to); if(!r) return {ok:false,message:'no such resident'}
2138
+ const r=residents.get(to); if(!r) return noSuchResident(to,'vibe_v4_send_message')
2019
2139
  if(!busy.has(to)){
2020
2140
  currentResident=r.rId
2021
2141
  const ok=await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
@@ -2075,7 +2195,7 @@ export function apply(ctx) {
2075
2195
  // who sees no one else's contribution"; a real discussion lets each member lead sometimes.
2076
2196
  const rot=Math.floor(Math.random()*Math.max(1,ids.length))
2077
2197
  const order=ids.slice(rot).concat(ids.slice(0,rot))
2078
- meetingState={id:'mt-'+shortId(),agenda,type:type||'general',targetId:targetId||null,round:0,asked:[],inputs:{},transcript:[],order,at:now(),lastInputAt:now()}
2198
+ meetingState={id:'mt-'+shortId(),agenda,type:type||'general',targetId:targetId||null,round:0,asked:[],inputs:{},transcript:[],order,at:now(),lastInputAt:now(),rosterSnapshot:order.slice(),rosterVersion}
2079
2199
  markProgress();
2080
2200
  logActivity('meeting','start: '+agenda); await saveAll(); await scheduleNext(); return {ok:true,id:meetingState.id}
2081
2201
  }
@@ -2090,7 +2210,7 @@ export function apply(ctx) {
2090
2210
  meetingState=null; wakeKind.clear(); logActivity('meeting','abandoned (stuck: no resident spoke)')
2091
2211
  await saveAll(); await scheduleNext(); return
2092
2212
  }
2093
- const ids=Array.from(residents.keys()); const allSpoke=ids.every(id=>st.inputs[id]!==undefined)
2213
+ const ids=(st.rosterSnapshot||Array.from(residents.keys())).slice(); const allSpoke=ids.every(id=>st.inputs[id]!==undefined)
2094
2214
  if(allSpoke){ await finalizeMeeting(); return }
2095
2215
  // only wake IDLE un-spoken residents (rotated order); in-flight ones re-trigger this on end.
2096
2216
  // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-spoken resident into a normal
@@ -2109,7 +2229,7 @@ export function apply(ctx) {
2109
2229
  let doSchedule=false
2110
2230
  try {
2111
2231
  const st=meetingState
2112
- const ids=Array.from(residents.keys()); const allSpoke=ids.length>0 && ids.every(id=>st.inputs[id]!==undefined)
2232
+ const ids=(st.rosterSnapshot||Array.from(residents.keys())).slice(); const allSpoke=ids.length>0 && ids.every(id=>st.inputs[id]!==undefined)
2113
2233
  const lines=['# 会议 '+st.id+'|'+fmtTime(),'','**议程**:'+st.agenda,'']
2114
2234
  for(const [id,iv] of Object.entries(st.inputs)){ lines.push('### '+id); lines.push(iv.input||''); lines.push('') }
2115
2235
  await writeText('Shared/meetings/'+st.id+'.md', lines.join('\n'))
@@ -2178,7 +2298,7 @@ export function apply(ctx) {
2178
2298
  await saveAll(); await scheduleNext(); return
2179
2299
  }
2180
2300
  }
2181
- verifyState={targetId:pv.targetId,targetType:pv.targetType,targetOwner:pv.proposer||'',stage:'independent',round:0,asked:[],verdicts:{},history:{},transcript:[],at:now(),lastVerdictAt:now()}
2301
+ verifyState={targetId:pv.targetId,targetType:pv.targetType,targetOwner:pv.proposer||'',stage:'independent',round:0,asked:[],verdicts:{},history:{},transcript:[],at:now(),lastVerdictAt:now(),rosterSnapshot:Array.from(residents.keys()),rosterVersion}
2182
2302
  markProgress();
2183
2303
  logActivity('verify','debate begin: '+pv.targetId+' ('+pv.targetType+')'); await saveAll(); await scheduleNext()
2184
2304
  }
@@ -2193,7 +2313,7 @@ export function apply(ctx) {
2193
2313
  verifyState=null; wakeKind.clear(); logActivity('verify',vs.targetId+' abandoned (stuck: no unanimous verdict reachable)')
2194
2314
  await saveAll(); await scheduleNext(); return
2195
2315
  }
2196
- const ids=Array.from(residents.keys()); const allVoted=ids.every(id=>vs.verdicts[id]!==undefined)
2316
+ const ids=(vs.rosterSnapshot||Array.from(residents.keys())).slice(); const allVoted=ids.every(id=>vs.verdicts[id]!==undefined)
2197
2317
  if(allVoted){ await finalizeVerify(); return }
2198
2318
  // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-voted resident into a normal
2199
2319
  // mail round would delay its verdict and can starve the verify past its watchdog when the backlog
@@ -2209,9 +2329,14 @@ export function apply(ctx) {
2209
2329
  finalizeLock='verify'
2210
2330
  let doSchedule=false
2211
2331
  try {
2212
- const vs=verifyState; const expected=Array.from(residents.keys()).length
2213
- const allVoted = expected>0 && Object.keys(vs.verdicts).length>=expected
2214
- const vals=Object.values(vs.verdicts)
2332
+ const vs=verifyState; // F1: the requirement set is the FROZEN snapshot (live map fallback) - the ONE
2333
+ // predicate is MEMBERSHIP, exactly like the on-end path at :2247. A count-only check could be
2334
+ // satisfied by a ballot from someone NOT in the frozen set (a member added after the freeze, or a
2335
+ // late reply from a removed one), which would promote 真/假 while a frozen voter is still silent.
2336
+ const ids=(vs.rosterSnapshot||Array.from(residents.keys())).slice()
2337
+ const expected=ids.length
2338
+ const allVoted = ids.length>0 && ids.every(id=>vs.verdicts[id]!==undefined)
2339
+ const vals=ids.filter(id=>vs.verdicts[id]!==undefined).map(id=>vs.verdicts[id])
2215
2340
  // verdict is a PURE 0-1 probability; only ALL=1 (true) or ALL=0 (false) is a binary verdict.
2216
2341
  const allTrue = allVoted && vals.every(x=>Number(x.prob)===1)
2217
2342
  const allFalse = allVoted && vals.every(x=>Number(x.prob)===0)
@@ -2259,7 +2384,7 @@ export function apply(ctx) {
2259
2384
  if(formalOn() && formalTodos.some(t=>t.id===target)){
2260
2385
  formalTodos=formalTodos.filter(t=>t.id!==target)
2261
2386
  await saveFormal()
2262
- try { await writeFormalTodo(); await writeFormalIndex() } catch(e){ /* best-effort */ }
2387
+ try { await writeFormalTodo(); await writeFormalIndex() } catch(e){ if(!warnedWriteFormalTodo){ warnedWriteFormalTodo=true; console.error('vibe-math-v4: closeVerify: writeFormalTodo/writeFormalIndex failed - the verify conclusion is NOT recorded on disk (best effort continues; it is lost on reload)') } /* best-effort */ }
2263
2388
  }
2264
2389
  logActivity('verify',target+' → Verified ('+(isTrue?'真':'假')+') by unanimous consensus'+(formalOn()?('|形式化: '+formalStatusLine(target)):''))
2265
2390
  verifyState=null; wakeKind.clear(); await saveAll()
@@ -2286,7 +2411,7 @@ export function apply(ctx) {
2286
2411
  await rewriteSourceProb(target,mean,vs.targetOwner)
2287
2412
  if(!formalTodos.some(t=>t.id===target)) formalTodos.push({id:target,at:now(),why,verdict:isTrue?1:0})
2288
2413
  await putFormal(target,rec,formalTodos) // single durable write of records + todo together
2289
- try { await writeFormalTodo(); await writeFormalIndex() } catch(e){ /* best-effort */ }
2414
+ try { await writeFormalTodo(); await writeFormalIndex() } catch(e){ if(!warnedDeferFormalTodo){ warnedDeferFormalTodo=true; console.error('vibe-math-v4: deferForFormal: writeFormalTodo/writeFormalIndex failed - the formal-required deferral (TODO + index) is NOT persisted; the object stays unverified and this decision is lost on reload') } /* best-effort */ }
2290
2415
  logActivity('verify',target+' 的表决结果为 '+(isTrue?'真':'假')+',但 **require 模式**要求先有 Lean 通过或显式阻塞记录,因此本轮**不定论**(已记入 Formal/TODO.md;原因 formal-required)')
2291
2416
  verifyState=null; wakeKind.clear(); await saveAll()
2292
2417
  // Scheduling is done by finalizeVerify (doSchedule=true) AFTER it releases finalizeLock:
@@ -2516,21 +2641,22 @@ export function apply(ctx) {
2516
2641
  if(!agent || !agent.session) return
2517
2642
  const compaction = compactionForAgent(agent)
2518
2643
  if(compaction===undefined) return
2519
- // `compactIfNeeded` is a POLICY call on the current host: it may return null without compacting
2520
- // and nothing records that, so a real /compact would silently do nothing. `compactNow` is the
2521
- // forcing verb; feature-detected so older hosts keep the policy call.
2522
- const force = typeof compaction.compactNow === 'function'
2523
- if(!force && typeof compaction.compactIfNeeded !== 'function') return
2644
+ // F2 (user ruling): compaction is driven by the CONFIGURED THRESHOLD only. `compactIfNeeded` is the
2645
+ // POLICY verb, so a host that also offers the FORCING verb (`compactNow`) must NOT be forced - the
2646
+ // per-turn call is a cheap no-op below the threshold, and the core-rules re-anchor happens ONLY when
2647
+ // the policy call reports that it actually compacted.
2648
+ if(typeof compaction.compactIfNeeded !== 'function') return
2524
2649
  try {
2525
- const signal = makeSignal(params.activityTimeoutMs||60000)
2526
- const result = force ? await compaction.compactNow(agent, signal) : await compaction.compactIfNeeded(agent, 'pressure', signal)
2527
- if(result && (result.shadowedSeqs||[]).length>0){
2650
+ const signal = makeSignal(params.activityTimeoutMs||60000)
2651
+ const result = await compaction.compactIfNeeded(agent, 'pressure', signal)
2652
+ const didCompact = !!(result && ((result.shadowedSeqs||[]).length>0 || Number(result.shadowedTokenCount||0)>0))
2653
+ if(didCompact){
2528
2654
  // the resident's real session was compacted → its context is now a summary.
2529
2655
  // Flag needCompact so the NEXT wake re-anchors the core rules (they may have been blurred).
2530
2656
  r.roundsSinceCompact=0; r.needCompact=true; r.contextPct=Math.min(r.contextPct||15,25)
2531
2657
  logActivity('compact', r.rId+' real /compact (shadowed '+result.shadowedSeqs.length+' items, ~'+String(result.shadowedTokenCount||0)+' tokens)')
2532
2658
  }
2533
- } catch(e){ /* real compaction unavailable/failed; the soft directive already covers it */ }
2659
+ } catch(e){ if(!warnedCompaction){ warnedCompaction=true; console.error('vibe-math-v4: realCompact: the compaction call failed - context pressure was NOT compacted; the round/flag heuristic may desync from the real context size') } /* real compaction unavailable/failed; the soft directive already covers it */ }
2534
2660
  }
2535
2661
 
2536
2662
  // ---- liveness / scheduling ----
@@ -2627,7 +2753,12 @@ export function apply(ctx) {
2627
2753
  // the top of the next pass — never in a tight loop here.
2628
2754
  if(!r.childId) continue
2629
2755
  let ok=false
2630
- try { ok = await wakeResident(r, await heartbeatPrompt(r), 'normal') } catch(e){ ok=false }
2756
+ // F4 (measured then reverted): this is the ONLY heartbeatPrompt wake site in v4 - the fairness/idle
2757
+ // fill. Both the soft-compaction design and the guards T13/A1 require THIS round to be a NORMAL
2758
+ // research round when contextPct >= compactThreshold (that is where the [CONTEXT COMPACT ...]
2759
+ // self-summary directive must land). Re-kinding it as its own kind therefore breaks them, and F4's
2760
+ // premise (a distinct non-research CHECKPOINT wake) has no separate path on this revision.
2761
+ try { ok = await wakeResident(r, await heartbeatPrompt(r), 'normal') } catch(e){ ok=false }
2631
2762
  if(ok) started++
2632
2763
  await saveAll()
2633
2764
  if(!ok) continue // a failed wake must NOT stop the fill; try the next idle resident
@@ -2719,7 +2850,15 @@ export function apply(ctx) {
2719
2850
  else if(typeof v.verdict==='string' && v.verdict.trim()!=='' && Number.isFinite(Number(v.verdict))){ p=clamp01(Number(v.verdict)) }
2720
2851
  else { p=clamp01(Number(v.confidence)) }
2721
2852
  // verdict is a PURE 0-1 probability (a degree); no binary TRUE/FALSE classification.
2722
- verifyState.verdicts[r.rId]={prob:p,confidence:p,reason:String(v.reason||parsed.summary||'')}
2853
+ // F1: a ballot from a member who is NOT in the frozen participant set must NOT be recorded -
2854
+ // otherwise a late reply from a removed member re-creates its key and the count predicate can
2855
+ // reach `expected` while a frozen voter has no verdict. v5 states the same rule explicitly.
2856
+ const _inRoster = !Array.isArray(verifyState.rosterSnapshot) || verifyState.rosterSnapshot.indexOf(r.rId)!==-1
2857
+ if(!_inRoster){ logActivity('verify', r.rId+' 的表决未计入(不在本次验证的冻结名单中)') }
2858
+ else {
2859
+ verifyState.verdicts[r.rId]={prob:p,confidence:p,reason:String(v.reason||parsed.summary||'')}
2860
+ verifyState.lastVerdictAt=now()
2861
+ }
2723
2862
  verifyState.lastVerdictAt=now()
2724
2863
  await saveAll()
2725
2864
  if(!running || autoDone) return // pause: freeze (resume refreshes the clocks and re-drives)
@@ -3339,6 +3478,7 @@ export function apply(ctx) {
3339
3478
  if(ps.compile&&ps.compile.result==='ok') ps.files.push('paper.pdf')
3340
3479
  ps.finalizedAt=now(); ps.status='done'
3341
3480
  await paperWriteMeta(); await paperWriteLog()
3481
+ await paperWriteMeta(); await paperWriteLog()
3342
3482
  // v2 §A1: the paper phase was entered from INSIDE the closing branch, BEFORE the completion
3343
3483
  // flags — so the run only becomes "done" here, once the paper exists (or has degraded safely).
3344
3484
  if(ps.deferCompletion && autoDone!==true){
@@ -3347,6 +3487,21 @@ export function apply(ctx) {
3347
3487
  logActivity('stop','all residents agree: problem solved(一致性停止票;最终论文已产出)')
3348
3488
  await saveAll()
3349
3489
  }
3490
+ // G3: `done` is a CLAIM that the deliverable is complete. `final.md` is written unchecked and the
3491
+ // meta/log results are discarded, so VERIFY THE ARTIFACTS EXIST instead of trusting return values.
3492
+ // (readTextAbs returns undefined for a missing file and never throws on ENOENT.)
3493
+ const _paperNeed=['final.md']
3494
+ if(ps.cfg.format!=='tex') _paperNeed.push('paper.md')
3495
+ if(ps.cfg.format!=='md') _paperNeed.push('paper.tex')
3496
+ if(_paperNeed.indexOf('paper.md')===-1&&ps.files.indexOf('paper.md')!==-1) _paperNeed.push('paper.md')
3497
+ if(_paperNeed.indexOf('paper.tex')===-1&&ps.files.indexOf('paper.tex')!==-1) _paperNeed.push('paper.tex')
3498
+ _paperNeed.push('paper.meta.json','paper.log.md')
3499
+ const _paperGone=[]
3500
+ for(const _f of _paperNeed){ if((await readText(ps.dir+'/'+_f))===undefined) _paperGone.push(_f) }
3501
+ if(_paperGone.length){
3502
+ ps.status='failed' // terminal, like a failed compile: no complete deliverable is claimed
3503
+ if(!warnedPaperWrite){ warnedPaperWrite=true; console.error('vibe-math-v4: paperFinalize: required paper artifact(s) MISSING after finalize (' + _paperGone.join(', ') + ') - the paper is marked FAILED instead of done; the deliverable is incomplete and `files` never claims a missing artifact') }
3504
+ }
3350
3505
  const warn=[ps.warning,ps.compile&&ps.compile.result==='failed'?ps.compile.error:''].filter(Boolean).join(';')
3351
3506
  if(warn) logActivity('paper','最终论文完成(有警告):'+ps.dir+'|'+warn)
3352
3507
  else logActivity('paper','最终论文完成:'+ps.dir+'('+ps.files.join('、')+(ps.compile?('|编译 '+(ps.compile.result||'')):'')+')')
@@ -3357,6 +3512,10 @@ export function apply(ctx) {
3357
3512
  return {
3358
3513
  params:{finalPaper:params.finalPaper,paperFormat:params.paperFormat,paperLanguage:params.paperLanguage,paperCompilePdf:params.paperCompilePdf,paperEditor:params.paperEditor,paperLatexCommand:params.paperLatexCommand},
3359
3514
  id:ps?ps.id:paperIdFor(), dir:ps?ps.dir:paperDirFor(), status:ps?ps.status:'idle',
3515
+ // round-9 (F2): with no paper state this path is a PROJECTION (f2: paperIdFor/paperDirFor are
3516
+ // pure - reading `status` touches no fs and creates no directory). Say so explicitly instead of
3517
+ // handing callers a path that looks like it exists.
3518
+ dirProjected:!ps, dirSource:ps?'paper-state':'projection', readSideEffect:false,
3360
3519
  running:!!(ps&&ps.status!=='done'&&ps.status!=='failed'), trigger:ps?ps.trigger:'',
3361
3520
  rounds:ps?ps.round:0, maxRounds:ps?ps.maxRounds:PAPER_MAX_ROUNDS, participants:ps?ps.participants:[],
3362
3521
  editor:ps?(ps.editorId||ps.cfg.editor):params.paperEditor, editorDowngraded:!!(ps&&ps.editorDowngraded),
@@ -3368,6 +3527,8 @@ export function apply(ctx) {
3368
3527
  function paperStatusSummary(){
3369
3528
  const v=paperStatusView()
3370
3529
  return {ok:true, id:v.id, dir:v.dir, status:v.status, running:v.running, trigger:v.trigger, lang:v.params.paperLanguage,
3530
+ // round-9 (F2): carry the projection flags through the SUMMARY too (this is what `status` returns).
3531
+ dirProjected:v.dirProjected, dirSource:v.dirSource, readSideEffect:v.readSideEffect,
3371
3532
  format:v.params.paperFormat, editor:v.editor, editorDowngraded:v.editorDowngraded, participants:v.participants,
3372
3533
  parts:v.parts, reviews:v.reviews, rounds:v.rounds+'/'+v.maxRounds, finalizedAt:v.finalizedAt, files:v.files,
3373
3534
  params:v.params,
@@ -3462,6 +3623,9 @@ export function apply(ctx) {
3462
3623
  async function start({problem,residentCount,seedDirections}){
3463
3624
  await loadSettings()
3464
3625
  currentProject=await readCurrentProject(); if(!currentProject||currentProject==='default'){ currentProject='default'; }
3626
+ // F-3/F-4c verdicts: tool registration is wrapped once, in the registerTool helper (ctx.effect),
3627
+ // so no call site needs its own wrapper; and a failed mkdir is named ONCE inside ensureDirs itself,
3628
+ // which covers every caller (including this bare one) - no caller-side return check is needed.
3465
3629
  await ensureDirs()
3466
3630
  if(problem) problemText=String(problem)
3467
3631
  if(!problemText) return {ok:false,message:'problem text required (pass problem, or use vibe_v4_configure first)'}
@@ -3565,7 +3729,23 @@ export function apply(ctx) {
3565
3729
  if(verifyState && verifyState.lastVerdictAt) verifyState.lastVerdictAt=now()
3566
3730
  logActivity('resume','restarted'+(crossProcess?' (cross-process: re-spawned)':needRespawn?' (re-spawned)':'')); await saveAll(); await scheduleNext(); return {ok:true,message:'resumed',project:currentProject}
3567
3731
  }
3732
+ /** Round A F1/F2: the roster version must never be observable as unchanged while the frozen
3733
+ * participant set changed, and a frozen set must always travel WITH its own version. Every
3734
+ * roster change calls this AFTER all snapshot mutations (meeting + verify prunes included), so
3735
+ * {snapshot.rosterVersion, snapshot.rosterSnapshot} is one atomic pair and the live counter
3736
+ * still strictly increases on hire/fire. */
3737
+ function bumpRoster(){ rosterVersion++; if(meetingState&&Array.isArray(meetingState.rosterSnapshot)) meetingState.rosterVersion=rosterVersion; if(verifyState&&Array.isArray(verifyState.rosterSnapshot)) verifyState.rosterVersion=rosterVersion; return rosterVersion }
3738
+ /** The frozen participant set an operator should read right now (verify wins while it is live). */
3739
+ function activeRosterSnapshot(){ return (verifyState&&Array.isArray(verifyState.rosterSnapshot))?verifyState:((meetingState&&Array.isArray(meetingState.rosterSnapshot))?meetingState:null) }
3568
3740
  function status(){ return { ok:true, running, phase, autoDone, project:currentProject, residentCount:residents.size,
3741
+ // round-9 (P2): the roster VERSION travels with every status read, and an active verify/meeting
3742
+ // reports the participant set it froze (never a re-derivation from the live map).
3743
+ rosterVersion,
3744
+ rosterVersion, liveRosterVersion: rosterVersion,
3745
+ // Round A F1: the pair is ATOMIC - `frozen.version` is the version the SNAPSHOT recorded when it
3746
+ // was frozen (and re-stamped on every roster change), never the live counter beside a stale set.
3747
+ frozen: (function(sn){ return {version: sn?sn.rosterVersion:null, participants: sn?sn.rosterSnapshot:null, kind: sn?(sn===verifyState?'verify':'meeting'):null} })(activeRosterSnapshot()),
3748
+ frozenParticipants: (activeRosterSnapshot()||{}).rosterSnapshot||null, // deprecated: use `frozen` (set + version together)
3569
3749
  residents:listResidents(), busy:[...busy], taskboard:taskboard.length,
3570
3750
  // Residents the host's live-child cap refused (maxActiveSubagents): queued, not lost. Without
3571
3751
  // this the only trace would be the one console line, and `residentCount` alone cannot tell a
@@ -3576,8 +3756,8 @@ export function apply(ctx) {
3576
3756
  // The coordination counters (`spoke k/N`, `voted k/N`, the verification round) used to live only
3577
3757
  // in `report()`: from `status()` alone an operator could not tell "the group is progressing"
3578
3758
  // from "the group is stuck at 2/4 votes", which is exactly how F1/F2/F7 stay invisible.
3579
- 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}
3580
- :(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),
3759
+ consensus: meetingState?{kind:'meeting',id:meetingState.id,agenda:meetingState.agenda,round:meetingState.round,rosterVersion:meetingState.rosterVersion,participants:meetingState.rosterSnapshot,spoke:Object.keys(meetingState.inputs).length,expected:(meetingState.rosterSnapshot||Array.from(residents.keys())).length,solvedVotes:Object.values(meetingState.inputs).filter(iv=>iv.voteSolved===true).length,rosterVersion:meetingState.rosterVersion}
3760
+ :(verifyState?{kind:'verify',target:verifyState.targetId,targetType:verifyState.targetType,stage:verifyState.stage,round:verifyState.round,rosterVersion:verifyState.rosterVersion,participants:verifyState.rosterSnapshot,voted:Object.keys(verifyState.verdicts).length,expected:(verifyState.rosterSnapshot||Array.from(residents.keys())).length,rosterVersion:verifyState.rosterVersion}:null),
3581
3761
  artifactCount: artifactCount, artifactBaseline: artifactBaseline,
3582
3762
  paper: paperStatusSummary(),
3583
3763
  // The Lean knobs and the per-object formal records are part of the readable status: without
@@ -3598,8 +3778,8 @@ export function apply(ctx) {
3598
3778
  }
3599
3779
  function report(){ return { ok:true, running, phase, autoDone, project:currentProject, problem:problemText,
3600
3780
  residents:listResidents(), taskboard:taskboard.filter(t=>t.status!=='done'),
3601
- meeting: meetingState?{id:meetingState.id, agenda:meetingState.agenda, spoke:Object.keys(meetingState.inputs).length+'/'+residents.size}:null,
3602
- verify: verifyState?{target:verifyState.targetId,stage:verifyState.stage, voted:Object.keys(verifyState.verdicts).length+'/'+residents.size}:null,
3781
+ meeting: meetingState?{id:meetingState.id, agenda:meetingState.agenda, spoke:Object.keys(meetingState.inputs).length+'/'+(meetingState.rosterSnapshot||Array.from(residents.keys())).length,rosterVersion:meetingState.rosterVersion}:null,
3782
+ verify: verifyState?{target:verifyState.targetId,stage:verifyState.stage, voted:Object.keys(verifyState.verdicts).length+'/'+(verifyState.rosterSnapshot||Array.from(residents.keys())).length,rosterVersion:verifyState.rosterVersion}:null,
3603
3783
  pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null,
3604
3784
  parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
3605
3785
  formal: formalView(),
@@ -3619,10 +3799,16 @@ export function apply(ctx) {
3619
3799
  // residents) can never be true for the new member (not in the snapshot order) and the meeting is
3620
3800
  // only ever released by the stuck watchdog instead of finalizing with everyone's input.
3621
3801
  if(meetingState){ if(!Array.isArray(meetingState.order)) meetingState.order=Array.from(residents.keys()); if(!meetingState.order.includes(r.rId)) meetingState.order.push(r.rId) }
3802
+ rosterVersion++ // additions never extend a frozen set: the live counter moves ahead (staleness stays visible)
3803
+ // P2: the roster CHANGED - bump the version so any frozen participant set is visibly stale.
3804
+
3622
3805
  // Mid-verify additions are automatically asked to vote (continueVerifyRound recomputes ids from
3623
3806
  // the live residents map), so no extra handling is needed there.
3624
3807
  return {ok:true,id:r.rId,direction:r.direction} }
3625
- async function removeMember(id){ const r=residents.get(id); if(!r) return {ok:false}; if(r.childId){ try{ subagents.interrupt(r.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } residents.delete(id); busy.delete(id); mailboxes.delete(id); wakeKind.delete(id); if(currentResident===id) currentResident=''
3808
+ async function removeMember(id){ const r=residents.get(id); if(!r) return noSuchResident(id,'vibe_v4_remove_member'); if(r.childId){ try{ subagents.interrupt(r.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } residents.delete(id); busy.delete(id); mailboxes.delete(id); wakeKind.delete(id); if(currentResident===id) currentResident=''
3809
+ // P2: the roster CHANGED - bump the version (a frozen participant set stays readable, but is
3810
+ // now visibly stale; every view reports the same version it snapshotted).
3811
+
3626
3812
  // Reconcile in-progress coordination so a removed member cannot hang consensus or crash a round:
3627
3813
  // drop its meeting speech / verify verdict and prune it from the meeting's speaking order so the
3628
3814
  // find() there never selects a ghost. Its QUEUED verify proposals are deliberately KEPT: a
@@ -3632,6 +3818,13 @@ export function apply(ctx) {
3632
3818
  // only the first entry, which may belong to the removed member).
3633
3819
  if(meetingState){ delete meetingState.inputs[id]; meetingState.order=(meetingState.order||[]).filter(x=>x!==id) }
3634
3820
  if(verifyState){ delete verifyState.verdicts[id] }
3821
+ // P2 (round-9): REMOVAL prunes the FROZEN participant set as well - v4's long-standing contract is
3822
+ // that removing an in-flight voter RELEASES the wait (T26/T31), while the snapshot still gives
3823
+ // every view one identical set + version. Additions never touch a frozen set (that is the point
3824
+ // of freezing); the version bump above marks the set as changed.
3825
+ if(meetingState&&Array.isArray(meetingState.rosterSnapshot)) meetingState.rosterSnapshot=meetingState.rosterSnapshot.filter(x=>x!==id)
3826
+ if(verifyState&&Array.isArray(verifyState.rosterSnapshot)) verifyState.rosterSnapshot=verifyState.rosterSnapshot.filter(x=>x!==id)
3827
+ bumpRoster() // F2: AFTER every snapshot mutation - version and set stay one atomic pair
3635
3828
  await saveAll()
3636
3829
  // Re-drive the scheduler right away. If the removed member was the ONLY turn in flight (e.g. the
3637
3830
  // last unspoken meeting speaker / the last unvoted voter, interrupted mid-turn), NO subagent/end