dsh-vibe-math 2.4.0 → 2.5.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 (45) hide show
  1. package/README.en.md +29 -29
  2. package/README.md +31 -30
  3. package/SECURITY.md +56 -0
  4. package/cordis.patch.yml +38 -15
  5. package/docs/AUDIT-CHECKLIST.md +518 -429
  6. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  7. package/docs/release-notes/RELEASE-NOTES-2.4.1.md +129 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  9. package/docs/test-timing.md +73 -40
  10. package/installer.js +225 -45
  11. package/package.json +6 -3
  12. package/preset-declaration.js +38 -7
  13. package/prompt-corpus-persona/persona-corpus.json +9 -9
  14. package/prompt-corpus-persona/persona-corpus.md +69 -23
  15. package/prompt-corpus-v2/formal-verify-v2.json +116 -92
  16. package/prompt-corpus-v2/formal-verify-v2.md +240 -93
  17. package/prompt-corpus-v3/formal-verify-v3.json +81 -81
  18. package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
  19. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  20. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  21. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  22. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  23. package/tests/audit-installer-compat.test.mjs +70 -4
  24. package/tests/audit-installer-policy.test.mjs +229 -4
  25. package/tests/audit-persona-sensitivity.mjs +11 -4
  26. package/tests/audit-persona-surface.test.mjs +5 -1
  27. package/tests/audit-v5-sensitivity.mjs +47 -6
  28. package/tests/e2e-v5-round2.test.mjs +354 -0
  29. package/tests/formal-verify-v2.test.mjs +54 -2
  30. package/tests/formal-verify-v3.test.mjs +63 -2
  31. package/tests/formal-verify-v4.test.mjs +120 -4
  32. package/tests/run-tests.mjs +104 -23
  33. package/vibe-math-v2/agent.cordis.yml +11 -5
  34. package/vibe-math-v2/preset.yml +1 -1
  35. package/vibe-math-v2/vibe-math-v2.js +607 -160
  36. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  37. package/vibe-math-v3/agent.cordis.yml +26 -10
  38. package/vibe-math-v3/vibe-math-v3.js +744 -305
  39. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -5
  40. package/vibe-math-v4/agent.cordis.yml +26 -6
  41. package/vibe-math-v4/vibe-math-v4.js +487 -89
  42. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  43. package/vibe-math-v5/agent.cordis.yml +8 -4
  44. package/vibe-math-v5/vibe-math-v5.js +607 -160
  45. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +73 -40
@@ -106,20 +106,17 @@ export function apply(ctx) {
106
106
  let finalizeLock = null // 'meeting'|'verify' while a consensus finalize is running (reentry guard)
107
107
  const verifiedRecently = new Map() // targetId -> timestamp when it was closed as Verified (dedup re-propose)
108
108
  let lastActivityAt = now(), lastProgressAt = now(), artifactCount = 0, lastSyncMeetingAt = 0, persistedEpoch = '', heartbeatDisposer = null
109
+ // `artifactBaseline` = the artifact count observed when the auto-sync-meeting counter was last
110
+ // re-based (see `countArtifacts`/`syncArtifactCount`): the documented behaviour is "every N NEW
111
+ // artifacts", so the modulo test must run against the number of artifacts written SINCE the run
112
+ // started, not against the raw file count of a possibly pre-existing project tree.
113
+ let artifactBaseline = null
109
114
  const activityLogCap = 200
110
115
 
111
116
  // ---- utils ----
112
- function now(){ return Date.now() }
113
- function uuid(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<36;i++){ if(i===8||i===13||i===18||i===23) s+='-'; else s+=h[Math.floor(Math.random()*16)] } return s }
114
- function shortId(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<8;i++) s+=h[Math.floor(Math.random()*16)]; return s }
115
- function clamp01(v){ const n=Number(v); if(!Number.isFinite(n)) return 0.5; return Math.max(0,Math.min(1,n)) }
116
- function fmtTime(ts){ try { return new Date(ts||now()).toISOString().replace('T',' ').slice(0,19) } catch(e){ return String(ts||'') } }
117
- function cl(x){ return clamp01(Number(x)) }
118
117
  // contextPct is a PERCENT (0-100); never clamp to 0-1 or the compactThreshold
119
118
  // comparison (e.g. 66) becomes `1.0 >= 66` and never fires.
120
- function clPct(x){ const n=Number(x); if(!Number.isFinite(n)) return 0; return Math.max(0,Math.min(100,n)) }
121
119
  function textBlock(t){ return { type:'text', text:String(t) } }
122
- function blocksToText(b){ if(!b) return ''; let out=''; for(const x of b){ if(x&&x.type==='text'&&typeof x.text==='string') out+=x.text+'\n' } return out.trim() }
123
120
  function logActivity(event,detail){ activityLog.push({at:now(),event,detail:String(detail||'')}); if(activityLog.length>activityLogCap) activityLog.shift() }
124
121
  function logDecision(kind,detail){ decisions.push({at:now(),kind,detail:String(detail||'')}) }
125
122
  // Record that the project made real progress (new artifact, meeting, verify, task, or a
@@ -135,7 +132,6 @@ export function apply(ctx) {
135
132
  // (misconfigured via vibe_v4_set) would otherwise make recoverStallMs negative → every meeting/
136
133
  // verify watchdog fires INSTANTLY (abandoning all consensus) and A-fill's idle window would never
137
134
  // elapse (waking everyone every pass). Guard every duration read with this.
138
- function posMs(v,def){ const n=Number(v); return (Number.isFinite(n)&&n>0)?n:(def||120000) }
139
135
  function recoverStallMs(){ return posMs(params.activityTimeoutMs,120000) * 2 }
140
136
  function pickProvider(){ try { const n=subagents.list?subagents.list():[]; if(n.indexOf('spawn')!==-1) return 'spawn'; if(n.indexOf('fork')!==-1) return 'fork' } catch(e){} return 'spawn' }
141
137
  // Per-resident model/provider inheritance: when params.provider / params.model are set,
@@ -155,16 +151,11 @@ export function apply(ctx) {
155
151
  function workspaceRoot(){ try { if(rootAgent&&rootAgent.session&&rootAgent.session.header&&rootAgent.session.header.cwd) return rootAgent.session.header.cwd } catch(e){} const sp=sandboxPolicyOf(); if(sp&&sp.workspaceRoot) return sp.workspaceRoot; return '.' }
156
152
  function vibeRoot(){ return (workspaceRoot()+'/VibeMath').replace(/\\/g,'/') }
157
153
  function frameworkRoot(){ return vibeRoot()+'/Projects/'+currentProject }
158
- function slugify(s){ const t=String(s==null?'':s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g,'-').replace(/^-+|-+$/g,''); return t||'project' }
159
154
  // Object ids (verify targets, recorded cards) become FILE NAMES and DIRECTORY PATHS
160
155
  // (Verified/命题/<id>.md, Shared/debates/<id>.md, Propos/<r>/<id>.md, source-card scans).
161
156
  // A hostile/sloppy id containing path separators ('../../x') or Windows-forbidden chars would
162
157
  // escape the project tree. Keep every harmless character (incl. Chinese) and replace only
163
158
  // separators/control chars; strip leading/trailing dots/dashes so the name is never '.'/'..'.
164
- function idSafe(s){
165
- const t=String(s==null?'':s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/-{2,}/g,'-').replace(/^[.\-]+|[.\-]+$/g,'')
166
- return t||'id'
167
- }
168
159
  let warnedNoPolicy = false
169
160
  function warnNoPolicyOnce(){ if(!warnedNoPolicy){ warnedNoPolicy=true; console.error('vibe-math-v4: sandboxPolicy unavailable; writes go out with no explicit policy') } }
170
161
  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 }
@@ -273,13 +264,6 @@ export function apply(ctx) {
273
264
  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 } }
274
265
  async function readCurrentProject(){ try { const t=await readTextAbs(vibeRoot()+'/.current'); if(t) return String(t).trim() } catch(e){} return currentProject }
275
266
  async function writeCurrentProject(){ try { await writeTextAbs(vibeRoot()+'/.current', currentProject) } catch(e){} }
276
- function tryJson(s){ try { return JSON.parse(s) } catch(e){ return undefined } }
277
- function parseReply(text){
278
- let obj; const fence=/```(?:json)?[ \t]*([\s\S]*?)```/gi; let m
279
- while((m=fence.exec(text))!==null){ const o=tryJson(m[1].trim()); if(o&&typeof o==='object'&&!Array.isArray(o)) obj=o }
280
- if(!obj){ const w=tryJson(text.trim()); if(w&&typeof w==='object'&&!Array.isArray(w)) obj=w }
281
- return obj||{}
282
- }
283
267
 
284
268
  // ================= Lean formal verification ==============================
285
269
  // Contract: docs/formal-verification.md (shared by v2/v3/v4/v5).
@@ -879,10 +863,21 @@ export function apply(ctx) {
879
863
  // `off` mode must not CREATE Lean state: a run that never used the feature leaves no
880
864
  // State/formal.json behind. Seeded/live records (or a mode that is on) still persist.
881
865
  if(formalOn()||formalPersisted||Object.keys(formal).length||formalTodos.length) await writeJson('State/formal.json', {records:formal,todo:formalTodos})
882
- await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount})
866
+ await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount,artifactBaseline,
867
+ // In-flight consensus must survive a restart too: a verification that had collected 3 of 4
868
+ // verdicts and a meeting that had collected half the speeches used to evaporate entirely
869
+ // (Shared/debates|meetings/<id>.md are only written by finalize*, so the partial debate was
870
+ // not even on disk) — and the queued proposals in pendingVerify were lost with it.
871
+ meetingState,verifyState,pendingVerify,pendingMeeting})
883
872
  }
884
873
  async function loadAll(){
885
- const s=await readJson('State/session.json'); if(s){ running=!!s.running; autoDone=!!s.autoDone; phase=s.phase||'idle'; problemId=s.problemId||problemId; problemText=s.problemText||problemText; runId=s.runId||runId; meetings=s.meetings||[]; reports=s.reports||[]; lastActivityAt=s.lastActivityAt||now(); lastProgressAt=s.lastProgressAt||now(); activityLog=s.activityLog||activityLog; persistedEpoch=s.processEpoch||''; artifactCount=s.artifactCount||0 }
874
+ const s=await readJson('State/session.json'); if(s){ running=!!s.running; autoDone=!!s.autoDone; phase=s.phase||'idle'; problemId=s.problemId||problemId; problemText=s.problemText||problemText; runId=s.runId||runId; meetings=s.meetings||[]; reports=s.reports||[]; lastActivityAt=s.lastActivityAt||now(); lastProgressAt=s.lastProgressAt||now(); activityLog=s.activityLog||activityLog; persistedEpoch=s.processEpoch||''; artifactCount=s.artifactCount||0; artifactBaseline=(s.artifactBaseline===undefined?null:Number(s.artifactBaseline))
875
+ // Restore the in-flight consensus (see saveAll). `resume()` refreshes the watchdog clocks right
876
+ // after this, so a run resumed after a crash gets a fresh stall window instead of being
877
+ // abandoned by the meeting/verify watchdog on its first serviced pass.
878
+ meetingState=s.meetingState||null; verifyState=s.verifyState||null
879
+ pendingVerify=Array.isArray(s.pendingVerify)?s.pendingVerify:[]
880
+ pendingMeeting=s.pendingMeeting||null }
886
881
  const rm=await readJson('State/residents.json'); if(rm&&typeof rm==='object') residents=new Map(Object.entries(rm))
887
882
  const mb=await readJson('State/mailboxes.json'); if(mb&&typeof mb==='object') mailboxes=new Map(Object.entries(mb))
888
883
  const tb=await readJson('State/taskboard.json'); if(Array.isArray(tb)) taskboard=tb
@@ -899,7 +894,12 @@ export function apply(ctx) {
899
894
 
900
895
  // ---- resident prompts ----
901
896
  function banner(){ const o=[]; for(const [id,r] of residents) o.push('- '+id+'「'+(r.direction||'(未定)')+'」'+r.status+'·轮'+r.rounds); return o.join('\n') }
902
- async function inboxText(rId){ const mb=mailboxes.get(rId)||[]; if(mb.length===0) return ' (no new messages)\n'; return mb.map(m=>' ['+m.from+'] '+m.content).join('\n')+'\n' }
897
+ // Who sent this? `facilitator` is the framework/human messenger (NOT a resident, not on the
898
+ // roster): a resident that reads a bare `[facilitator]` line tries to reply to it and gets
899
+ // `no such resident`. The definition travels WITH the frame, so it is present on the very wake
900
+ // that shows the message — not only in the once-only onboarding brief.
901
+ function senderLabel(from){ return String(from)==='facilitator'?'facilitator(框架/人类信使,不是常驻成员,不要向它回信;要回话请用本轮回执的 "input" 或 vibe_v4_send_message {to:"all"})':String(from) }
902
+ 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' }
903
903
  function residentLibraries(){
904
904
  const base=frameworkRoot()
905
905
  return '你的资料库根目录:'+base+'/\n'
@@ -955,6 +955,7 @@ export function apply(ctx) {
955
955
  s.push('### 规则')
956
956
  s.push('- 只有 Verified/(或卡片标"已验证·真/假")算已确立;其余都是你的实验性工作,请区分"猜想/已知"。')
957
957
  s.push('- 验证必须**全组一致**(全真或全假);你只信全票结果。未全票的对象留在库里带概率。')
958
+ s.push('- `facilitator` 是**框架/人类介入的信使名**(不是常驻成员,不在编制里):它转达人类或框架的话,但**不要向它回信**(会返回 no such resident);要回话请用本轮回执的 "input" 字段(会转给全组)或 `vibe_v4_send_message {to:"all"}`。')
958
959
  s.push('- 你自己决定做什么,但**优先级/分工由团队讨论决定**,不是固定模式。若你认为问题已解决或接近解决,请**发起会议**让团队表决。')
959
960
  s.push('- 退出时**只**输出一个 JSON 对象(放在 ```json 代码围栏内;围栏外不要有文字)。')
960
961
  return s.join('\n')
@@ -968,6 +969,7 @@ export function apply(ctx) {
968
969
  // dynamic: after a /compact the resident must re-anchor on the rules it is actually
969
970
  // living under right now.
970
971
  return '[核心规则重申] 只有 Verified/(及标记"已验证·真/假")算已确立;验证须全组一致(全真或全假)才作数,否则留库附平均概率;你只写自己的库('+base+'/ 的 Progress/<你>/、Propos/<你>/、Methods/<你>/、Subproblems/<你>/),可只读任何人的库;任务分工由团队讨论决定;退出只输出一个 JSON 对象。'
972
+ +'\n`facilitator` 是**框架/人类介入的信使名**,不是常驻成员,也不在编制里——**不要向它回信**(`vibe_v4_send_message` 会返回 no such resident);要回话请用本轮回执的 "input" 字段(会转给全组)或 `vibe_v4_send_message {to:"all"}`。'
971
973
  +(formalOn()?('\n'+formalWorkLine()):'')
972
974
  }
973
975
  function brainstormPrompt(r){
@@ -996,11 +998,10 @@ export function apply(ctx) {
996
998
  const prior=Object.entries(st.inputs).filter(([k])=>k!==r.rId).map(([k,iv])=>' ['+k+'] '+String(iv.input||iv.summary||'')).join('\n')
997
999
  return (params.residentPersona?params.residentPersona+'\n':'')
998
1000
  +'Resident '+r.rId+' — 团队会议进行中。 A meeting is in progress (agenda: '+st.agenda+').'
999
- +(st.type==='verify'?('\n团队正在验证对象:'+st.targetId+'('+st.targetType+',提出者 '+st.targetOwner+')。请先看他人意见,再给独立判断。'):'')
1000
1001
  +'\n这是一场真实讨论:下面已有人发言(转给你),请先看,然后**加入讨论/补充/反驳/表决**。'
1001
1002
  +(prior?('\n\n### 已有发言(他人 input,已转发给你)\n'+prior):'\n(目前还没人发言,你先说。)')
1002
1003
  +'\n\n你可以:提议任务(propose_task)、认领开放任务(claim_task)、提议验证对象(propose_verify)、或对"原问题是否已解决"表决(voteSolved)。请把**你的实际发言**写进 "input"。'
1003
- +'\nReply with ONLY a JSON object:\n'
1004
+ +'\n**停止表决必须是绝对票**:`voteSolved:true` 只表示你认为原问题**已解决**;只要有一名在册常驻没投 `true`(投 false、弃权、或漏写这个字段),run 就**不会**停止。不确定就投 false。' +'\nReply with ONLY a JSON object:\n'
1004
1005
  +'{"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}'
1005
1006
  }
1006
1007
  function verifyPrompt(r, vs){
@@ -1011,9 +1012,19 @@ export function apply(ctx) {
1011
1012
  const others=Object.entries(src).map(([k,v])=>'- '+k+': 正确概率 '+String(v.prob!=null?Number(v.prob).toFixed(2):0.5)+' → '+v.reason).join('\n')
1012
1013
  const L=[]
1013
1014
  L.push((params.residentPersona?params.residentPersona+'\n':'')
1014
- +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+vs.targetType+',提出者 '+vs.targetOwner+')。\n'
1015
+ +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+targetTypeWord(vs.targetType)+',提出者 '+vs.targetOwner+')。\n'
1015
1016
  +'请给出你对「该对象为真」的**正确概率 `verdict`**,仅一个 0–1 数值:**1 = 绝对为真,0 = 绝对为假,0.5 = 完全不确定,其余为介于其间的程度**(不要给 TRUE/FALSE,就给一个数值)。\n'
1016
- +'判定规则:仅当**全体常驻一致给 1(都认为是真)或一致给 0(都认为是假)**,才按「真/假」写入 Verified/;否则**只作为概率数值(一种程度)保留在库中**,附全组平均正确概率,不写成真/假。\n'
1017
+ // ── THE VOTE CONTRACT (why the example below is 1 and not 0.9) ──────────────────────────
1018
+ // Only an EXACT 1 or 0 is a vote; anything strictly between is the model's honest
1019
+ // probability of truth, i.e. an ABSTENTION. The old text demonstrated `verdict:0.9` while
1020
+ // the code required `Number(prob)===1`, so a cautious-but-convinced group could never
1021
+ // converge: every round burned and the object stayed unverified forever. The rule is now
1022
+ // stated HERE, the example shows `1`, and 0.9 is explained instead of merely tolerated.
1023
+ +'**投票契约**:只有**恰好 1**(你认为是**绝对**为真)和**恰好 0**(你认为是**绝对**为假)算表决;**严格介于 0 与 1 之间**(例如 0.9、0.95、0.5)是**弃权**——它是你对"该对象为真"的**概率估计**,不是你的一票。\n'
1024
+ +' · 有把握认为它为真就投 **1**;不要为了"留一点余地"投 0.9——那会让全组永远无法定论。\n'
1025
+ +' · 弃权会被如实统计:本轮没有人全票 → 对象**不会**停止验证,而是把全组的**平均概率**写回它在库中的卡片(`- 概率:`),带概率继续留在库里。\n'
1026
+ +' · 弃权的两种合法用途:① 你确实不确定(用 0.5 附近的值表达);② 已归档的机器检查证明与命题原文不一致、你**不能**用 0 表达"命题为假"(此时请用 `formal` 回执的 `decision:"defect"` 报告偏差,见下方形式化段)。\n'
1027
+ +'判定规则:**仅当全体在册常驻都恰好给 1(都认为是真)、或都恰好给 0(都认为是假)**,才按「真/假」写入 Verified/ 并回写来源卡的「已验证·真/假」;否则**只按概率数值(一种程度)保留在库中**,附全组平均正确概率,不写成真/假。\n'
1017
1028
  +'请给出你**诚实独立的判断**'
1018
1029
  +(vs.stage==='debate'?',并参考他人意见:\n':'。\n')
1019
1030
  +(vs.stage==='debate'&&others?('### 他人上一轮意见(已转发给你)\n'+others+'\n'):''))
@@ -1025,7 +1036,7 @@ export function apply(ctx) {
1025
1036
  }
1026
1037
  L.push('')
1027
1038
  L.push('Reply with ONLY a JSON object:')
1028
- L.push('{"vote":{"verdict":0.9,"reason":"<your logic>"}}')
1039
+ L.push('{"vote":{"verdict":1,"reason":"<your logic>"}}')
1029
1040
  if(formalOn()){
1030
1041
  // The formal field belongs in the VOTING contract too: voters are exactly the agents who
1031
1042
  // must either formalize the object or record why they judged it infeasible.
@@ -1089,6 +1100,33 @@ export function apply(ctx) {
1089
1100
 
1090
1101
  // ---- resident lifecycle ----
1091
1102
  let residentSeq = 0
1103
+ // Spawns that are currently IN FLIGHT, plus the `subagent/start` payloads seen while they were.
1104
+ //
1105
+ // ORDERING FACT (verified against the installed host, dsh-subagent 0.2.0-rc.2): `subagent/start`
1106
+ // is emitted SYNCHRONOUSLY from inside `startContinuable`'s await — `emit('subagent/start', …)` at
1107
+ // `dsh-subagent/lib/index.js:279` (and again from the lifecycle observer at `:306` once
1108
+ // `observer.start(handle.agent)` runs at `:1116`) — and `startContinuable` resolves only
1109
+ // afterwards. `spawnResident` therefore cannot know `started.childId` when the event fires, and
1110
+ // the payload carries no label (see `SubagentRunInfo`: runId/provider/id/local only), so the ONLY
1111
+ // information available at that moment is "a child of mine just started".
1112
+ //
1113
+ // So: the start handler STASHES the live Agent by child id (no ownership guess at all), and
1114
+ // `spawnResident` CLAIMS the record for its child id as soon as the call resolves — at which point
1115
+ // ownership is known exactly. `labelHints` bridges the (mock-ish) hosts that embed the label in
1116
+ // the id for the handler's own `ownsChild` check.
1117
+ const pendingSpawnLabels = new Set()
1118
+ const pendingStartAgents = new Map() // childId -> Agent, seen but not yet owned
1119
+ function stashStartAgent(childId, agent){
1120
+ if(!childId || !agent) return
1121
+ if(pendingStartAgents.size>=64) pendingStartAgents.delete(pendingStartAgents.keys().next().value)
1122
+ pendingStartAgents.set(String(childId), agent)
1123
+ }
1124
+ function claimStartAgent(childId){
1125
+ const id=String(childId==null?'':childId)
1126
+ const agent=pendingStartAgents.get(id)
1127
+ if(agent) pendingStartAgents.delete(id)
1128
+ return agent
1129
+ }
1092
1130
  function newResident(dir){ const rId='r-'+(++residentSeq); return {rId,childId:'',direction:dir||'',status:'brainstorm',rounds:0,roundsSinceCompact:0,lastActiveAt:now(),insight:'',contextPct:0,contextSeed:'',needCompact:false} }
1093
1131
  async function spawnResident(r){
1094
1132
  // Skip BEFORE touching the host when we already KNOW the ceiling and our own live children
@@ -1097,6 +1135,8 @@ export function apply(ctx) {
1097
1135
  if(hostChildLimit!==undefined && liveChildCount()>=hostChildLimit){ noteSpawnRefused(r, hostChildLimit); return false }
1098
1136
  const ao=residentAgentOptions(); const tf=residentToolFilter()
1099
1137
  let started
1138
+ // Register the label for the whole in-flight window: `subagent/start` fires inside this await.
1139
+ pendingSpawnLabels.add(r.rId)
1100
1140
  try {
1101
1141
  started=await startWithToolFilter(tf, function(f){ return {provider:pickProvider(),label:r.rId,request:{prompt:[textBlock(brainstormPrompt(r))],parent:rootAgent,agentOptions:ao,...(f?{toolFilter:f}:{})},signal:makeSignal(params.activityTimeoutMs||60000)} })
1102
1142
  } catch(e){
@@ -1106,17 +1146,46 @@ export function apply(ctx) {
1106
1146
  // failure keeps its previous behaviour and still propagates.
1107
1147
  if(!isActivationLimitReached(e)) throw e
1108
1148
  noteSpawnRefused(r, noteChildLimit(e)); return false
1109
- }
1149
+ } finally { pendingSpawnLabels.delete(r.rId) }
1150
+ // The child just started, so its `subagent/start` payload has already been seen (the host emits
1151
+ // it inside the await above). Claim the stashed Agent for it NOW — ownership is exact here — and
1152
+ // that is what makes the REAL `/compact` path reachable (see the `subagent/start` listener).
1153
+ const claimed=claimStartAgent(started.childId)
1154
+ if(claimed) rememberAgent(started.childId, claimed)
1110
1155
  r.childId=started.childId; r.status='brainstorm'; r.lastActiveAt=now()
1111
1156
  childOwner.set(started.childId,sessionId); busy.add(r.rId); wakeKind.set(r.rId,'normal'); currentResident=r.rId
1112
1157
  residents.set(r.rId,r); await saveAll(); logActivity('spawn',r.rId+' ('+(r.direction||'brainstorm')+')')
1113
1158
  return true
1114
1159
  }
1160
+ /**
1161
+ * Is `childId` one of THIS session's residents — including a spawn that is still IN FLIGHT?
1162
+ *
1163
+ * `spawnResident` sets `r.childId` (and `childOwner`) only after `startContinuable` resolves, but
1164
+ * the host emits `subagent/start` from inside that await. So "is this mine?" must also consult the
1165
+ * pending-spawn labels registered around the call; without that, the start handler rejects every
1166
+ * child of a fresh spawn and `liveAgents` never gets populated.
1167
+ */
1168
+ function ownsChild(childId){
1169
+ const id=String(childId)
1170
+ for(const [,r] of residents){ if(r.childId===id) return true }
1171
+ for(const r of pendingSpawns){ if(r.childId===id) return true }
1172
+ // During the spawn await the child's id is not recorded anywhere yet, but the framework chose
1173
+ // the spawn's LABEL and `spawnResident` registers it for exactly this window. A host id that
1174
+ // embeds the label (`child-r-1`, the common test/mock shape) is matched by containment; an
1175
+ // opaque host id cannot be matched here, which is why the label is only a
1176
+ // best-effort bridge in addition to the roster checks above.
1177
+ for(const label of pendingSpawnLabels){ if(id===label || id.indexOf(label)>=0) return true }
1178
+ return false
1179
+ }
1115
1180
  async function wakeResident(r, promptText, kind){
1116
1181
  if(!r || !r.childId) return false // a removed resident must never be woken (else r.childId would crash)
1117
1182
  clearHeartbeat()
1118
1183
  busy.add(r.rId); wakeKind.set(r.rId,kind||'normal'); currentResident=r.rId
1119
- r.lastActiveAt=now(); r.rounds+=1; r.roundsSinceCompact+=1
1184
+ // `rounds`/`roundsSinceCompact` are advanced only AFTER the send actually succeeded (below), so
1185
+ // a wake that never reached the child cannot consume a "round" of the compaction heuristic nor
1186
+ // inflate the round number the resident is told. `lastActiveAt` still moves here on purpose: a
1187
+ // failed wake must back the A-fill off for one activityTimeoutMs instead of hammering the child.
1188
+ r.lastActiveAt=now()
1120
1189
  // Context compaction has TWO distinct needs. Confusing them is the bug that made
1121
1190
  // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the start of nearly every prompt:
1122
1191
  // (a) r.needCompact (set by a REAL /compact) => the resident's rules may be blurred, so
@@ -1156,6 +1225,8 @@ export function apply(ctx) {
1156
1225
  } else {
1157
1226
  throw new Error('no subagent continuation API (need sendMessage or followup)')
1158
1227
  }
1228
+ // The turn is really in flight now: count it (F10 — a failed send must not consume a round).
1229
+ r.rounds+=1; r.roundsSinceCompact+=1
1159
1230
  return true
1160
1231
  }
1161
1232
  catch(e){ console.error('vibe-v4 wake '+r.rId+' failed: '+String((e&&e.message)||e)); busy.delete(r.rId); return false }
@@ -1167,18 +1238,74 @@ export function apply(ctx) {
1167
1238
  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'} }
1168
1239
  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'} }
1169
1240
  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'} }
1170
- // auto-sync meeting: every meetingKeepEvery artifact records, convene a general coordination meeting.
1241
+ // ── auto-sync meeting: every `meetingKeepEvery` NEW artifacts, convene a coordination meeting ──
1171
1242
  //
1172
- // ⚠ 已知设计缺口(有意保留,未修):计数口径**只有** record_* 三个便捷工具的调用。而提示词明确
1173
- // 告诉常驻:"vibe_v4_publish_progress/record_* 只是便捷记录器(可选;推荐直接用 fs 写自己的文件)",
1174
- // 所以一个完全按推荐方式(fs 直写)工作的团队不会让 artifactCount 增长,
1175
- // `artifactCount % meetingKeepEvery === 0` 永不成立 —— 文档承诺的"每积累 N 个新产物自动同步
1176
- // 一次"在推荐工作流下不可达(此时只有"停滞看门狗"那条时间触发路径会开会)。
1177
- // 试过把"完成的常驻轮次"也计入,但那会改变开会节奏,令 e2e-v4-fixes T4 与 selfdrive-v4 的
1178
- // 时序断言失败(两套测试都按当前节奏写死了预期)。这属于**设计参数取舍**,需要维护者决定:
1179
- // 要么改计数口径并同步调整测试预期,要么把"便捷记录器可选"的措辞改为"建议使用以便触发周期同步"。
1180
- function bumpArtifacts(){ artifactCount+=1; markProgress(); if(!meetingState && !verifyState && !pendingMeeting && Number(params.meetingKeepEvery)>0 && artifactCount % Number(params.meetingKeepEvery)===0){ startMeeting('定期同步:分工/进展/是否需要验证','general',null).catch(()=>{}) } }
1181
- function listResidents(){ return Array.from(residents.values()).map(r=>({id:r.rId,direction:r.direction,status:r.status,rounds:r.rounds,contextPct:r.contextPct,insight:r.insight?r.insight.slice(0,80):''})) }
1243
+ // The counting basis is the RESIDENTS' card libraries themselves (Propos/Methods/Subproblems),
1244
+ // NOT the three `record_*` convenience tools. The prompt tells residents verbatim that those tools
1245
+ // are optional and that they are recommended to write their own files directly with `fs`, so a
1246
+ // team that follows the recommendation never called `bumpArtifacts` and
1247
+ // `artifactCount % meetingKeepEvery === 0` could never fire — the documented "every N new
1248
+ // artifacts auto-sync" was UNREACHABLE in the very workflow the prompt recommends, leaving the
1249
+ // stall watchdog as the only path that ever convenes an auto-meeting.
1250
+ //
1251
+ // Setting `artifactBaseline` also means a tile count of the correct sign, since residents write
1252
+ // the paths `Propos/<r>/<id>.md` explicitly.
1253
+ async function countArtifacts(){
1254
+ let n=0
1255
+ for(const base of ['Propos','Methods','Subproblems']){
1256
+ try {
1257
+ const t=await fs.resolve(base,{cwd:frameworkRoot()})
1258
+ if(await fs.stat(t)===undefined) continue
1259
+ const ents=await fs.listDir(t)
1260
+ for(const e of ents||[]){
1261
+ if(!e||e.type!=='directory') continue
1262
+ try {
1263
+ const dt=await fs.resolve(base+'/'+e.name,{cwd:frameworkRoot()})
1264
+ if(await fs.stat(dt)===undefined) continue
1265
+ const files=await fs.listDir(dt)
1266
+ for(const f of files||[]) if(f&&f.type==='file'&&/\.md$/.test(String(f.name))) n++
1267
+ } catch(e2){ /* a single unreadable library must not stop the scan */ }
1268
+ }
1269
+ } catch(e){ /* best-effort: an absent library counts as zero */ }
1270
+ }
1271
+ return n
1272
+ }
1273
+ /**
1274
+ * Re-base the artifact counter on what is on disk. Called from `start()` (a fresh run: the count
1275
+ * of NEW artifacts starts at 0 even if the project tree already held cards) and from
1276
+ * `scheduleNext` (the single choke point of every scheduling pass, right before the auto-meeting
1277
+ * test), so a group that writes its cards with `fs` still drives the documented cadence.
1278
+ * Costs no writes and never throws into the scheduler.
1279
+ */
1280
+ async function syncArtifactCount(){
1281
+ const n=await countArtifacts()
1282
+ if(artifactBaseline===null) artifactBaseline=n
1283
+ artifactCount=Math.max(artifactCount, n-(artifactBaseline||0))
1284
+ return artifactCount
1285
+ }
1286
+ /**
1287
+ * The `meetingKeepEvery` cadence: every N newly accumulated artifacts, convene ONE coordination
1288
+ * meeting. Called from BOTH paths that can observe the count:
1289
+ * · `bumpArtifacts` (a `record_*` convenience-tool call — the counter moves immediately), and
1290
+ * · `scheduleNext` right after `syncArtifactCount()` re-bases the counter on the files on disk,
1291
+ * which is the ONLY way an `fs`-written card can be seen.
1292
+ * Without the second call site the documented cadence is unreachable in the very workflow the
1293
+ * prompt recommends (`record_*` 可选;推荐直接用 fs 写自己的文件). `lastSyncMeetingAt` is reused as
1294
+ * the count at which the last sync meeting was convened, so the same crossing cannot fire twice.
1295
+ */
1296
+ function maybeArtifactSyncMeeting(){
1297
+ const every=Number(params.meetingKeepEvery)
1298
+ if(!(every>0)) return false
1299
+ if(meetingState || verifyState || pendingMeeting) return false
1300
+ if(!(artifactCount>0) || artifactCount % every !== 0) return false
1301
+ if(artifactCount<=lastSyncMeetingAt) return false // this crossing already convened a meeting
1302
+ lastSyncMeetingAt=artifactCount
1303
+ logActivity('meeting','定期同步触发:新增产物达到 '+every+' 的倍数(累计 '+artifactCount+')')
1304
+ startMeeting('定期同步:分工/进展/是否需要验证','general',null).catch(()=>{})
1305
+ return true
1306
+ }
1307
+ function bumpArtifacts(){ artifactCount+=1; markProgress(); maybeArtifactSyncMeeting() }
1308
+ function listResidents(){ return Array.from(residents.values()).map(r=>({id:r.rId,direction:r.direction,status:r.status,rounds:r.rounds,contextPct:r.contextPct,insight:r.insight?r.insight.slice(0,80):'',roundsSinceCompact:r.roundsSinceCompact||0,needCompact:!!r.needCompact,wakeKind:wakeKind.get(r.rId)||''})) }
1182
1309
  // identify WHICH resident is calling a resident-facing tool: match the caller's
1183
1310
  // subagent id to a resident's childId. Fall back to the last-woken resident when
1184
1311
  // the caller is the host/assistant (or an unknown agent). This makes per-resident
@@ -1192,17 +1319,28 @@ export function apply(ctx) {
1192
1319
  // wake the claimer to work on it (framework moves the task, resident decides how).
1193
1320
  // NOT while paused/stopped: a paused run must not start new work — the claim is recorded on the
1194
1321
  // board and the resident (who claimed it) picks it up again after resume.
1195
- const r=residents.get(claimer); if(r && !busy.has(claimer) && running && !autoDone){ currentResident=claimer; await wakeResident(r, (await normalPrompt(r))+'\n\n[YOU CLAIMED TASK '+id+'] '+t.title+' — '+t.description,'normal'); await saveAll() }
1322
+ const r=residents.get(claimer); if(r && !busy.has(claimer) && running && !autoDone){ currentResident=claimer; const ok=await wakeResident(r, (await normalPrompt(r))+'\n\n[YOU CLAIMED TASK '+id+'] '+t.title+' — '+t.description,'normal'); await saveAll(); if(!ok) armHeartbeat() }
1196
1323
  return {ok:true} }
1197
1324
  async function taskDone(id,claimer){ const t=taskboard.find(x=>x.id===id); if(!t) return {ok:false}; t.status='done'; t.doneBy=claimer; await saveTaskboard(); await writeTaskboard(); markProgress(); logActivity('task','done '+id); return {ok:true} }
1198
1325
  async function saveTaskboard(){ await writeJson('State/taskboard.json',taskboard); await writeTaskboard() }
1199
1326
  function listTasks(){ return taskboard.filter(t=>t.status!=='done') }
1200
1327
  async function reportContext(rId,pct){ const r=residents.get(rId); if(r){ r.contextPct=clPct(pct); if(Number(pct)<30) r.needCompact=false; } return {ok:true} }
1201
- // Apply context/compact bookkeeping from a resident's reply, so the flag can clear even when
1202
- // the reply came through a meeting/verify branch (defensive) as well as the normal branch.
1203
- function postmark(r, parsed){
1328
+ /**
1329
+ * Apply context/compact bookkeeping from a resident's reply.
1330
+ *
1331
+ * `kind` is the wake kind. It matters because the OLD version treated ANY non-empty `summary`
1332
+ * on ANY turn as the acknowledgement of a requested compaction: a meeting/verify reply that
1333
+ * happened to echo a summary (their contracts do not forbid it) cleared `needCompact`, zeroed
1334
+ * `roundsSinceCompact` and clamped `contextPct` to <=25 WITHOUT any compaction happening — so the
1335
+ * soft-compact trigger stayed below threshold and the compaction the flag was asking for was
1336
+ * never issued. Only a real normal research turn can acknowledge the soft directive it received
1337
+ * (`wakeResident` injects `[CONTEXT COMPACT …]` on normal rounds only); `parsed.compacted===true`
1338
+ * stays honoured on every kind, because that is the resident explicitly saying it condensed.
1339
+ */
1340
+ function postmark(r, parsed, kind){
1204
1341
  const cp=Number(parsed.contextPct); if(Number.isFinite(cp)) r.contextPct=clPct(cp) // tolerate numeric strings ("40")
1205
- if(parsed.compacted===true || (r.needCompact && parsed.summary)){
1342
+ const isNormalTurn=(kind||'normal')==='normal'
1343
+ if(parsed.compacted===true || (isNormalTurn && r.needCompact && parsed.summary)){
1206
1344
  r.contextSeed=String(parsed.summary||r.contextSeed||'')
1207
1345
  r.contextPct=Math.min(r.contextPct||15,25)
1208
1346
  r.roundsSinceCompact=0
@@ -1216,8 +1354,22 @@ export function apply(ctx) {
1216
1354
  const r=residents.get(to); if(!r) return {ok:false,message:'no such resident'}
1217
1355
  if(!busy.has(to)){
1218
1356
  currentResident=r.rId
1219
- await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
1220
- await saveAll(); markProgress(); logActivity('message',from+'→'+to); return {ok:true}
1357
+ const ok=await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
1358
+ await saveAll(); markProgress()
1359
+ if(ok){ logActivity('message',from+'→'+to); return {ok:true} }
1360
+ // The immediate wake FAILED (and `wakeResident` already cleared the `busy` mark, so no turn
1361
+ // actually started). Round C: the message must fall back to the MAILBOX instead of being
1362
+ // discarded — the old code logged "(wake failed; heartbeat re-armed)" and then dropped the
1363
+ // text, so the re-armed heartbeat had nothing left to do and the message was lost forever
1364
+ // while the recipient stayed cold (this is what the audit's F1 probe measured: it reported
1365
+ // "queued mail already consumed: false" but the mailbox was in fact already empty, so no
1366
+ // later pass could ever deliver anything). The mailbox is the one place a pass is guaranteed
1367
+ // to look again — including during `brainstorm`, see the delivery call at the top of
1368
+ // scheduleNext — and a successful retry marks the recipient busy and clears the queue.
1369
+ const mb=mailboxes.get(to)||[]; mb.push({from,at:now(),content}); mailboxes.set(to,mb)
1370
+ await saveAll(); armHeartbeat()
1371
+ logActivity('message',from+'→'+to+' (immediate wake failed; queued for retry)')
1372
+ return {ok:true}
1221
1373
  }
1222
1374
  const mb=mailboxes.get(to)||[]; mb.push({from,at:now(),content}); mailboxes.set(to,mb); await saveAll(); logActivity('message',from+'→'+to+' (queued)'); return {ok:true}
1223
1375
  }
@@ -1308,8 +1460,16 @@ export function apply(ctx) {
1308
1460
  if(iv.claim_task) await claimTask(iv.claim_task, id)
1309
1461
  if(iv.propose_verify) maybeQueueVerify(iv.propose_verify, id)
1310
1462
  }
1311
- const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
1312
- const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
1463
+ // ── the STOP vote must be UNANIMOUS, abstentions included ────────────────────────────────
1464
+ // `onResidentEnd` stores `voteSolved` only when it is a real boolean, otherwise `null`. The
1465
+ // old code filtered those nulls OUT and then asked `votes.every(v=>v===true)`, so a resident
1466
+ // that omitted the field (the meeting contract makes every key except `input` optional) or
1467
+ // wrote the string "true" simply dropped out of the electorate — and 3-of-4 voting true read
1468
+ // as "the whole team agrees the problem is solved" and STOPPED the run. `finalizeVerify` was
1469
+ // already written correctly (every resident must have a verdict); this mirrors it: every
1470
+ // speaker must have produced an explicit `true`.
1471
+ const speakers=Object.values(st.inputs)
1472
+ const allSolved = allSpoke && speakers.length>0 && speakers.every(iv=>iv.voteSolved===true)
1313
1473
  logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
1314
1474
  if(allSolved){
1315
1475
  running=false; autoDone=true; phase='done'; clearHeartbeat()
@@ -1390,14 +1550,18 @@ export function apply(ctx) {
1390
1550
  else await closeVerify(vs,allTrue)
1391
1551
  doSchedule=true
1392
1552
  }
1393
- else if(vs.round+1<params.verdictMaxRounds){
1553
+ else if(vs.round<params.verdictMaxRounds){
1554
+ // `verdictMaxRounds` is documented (README / 实现方案 / persona) as the maximum number of
1555
+ // verification rounds: 1 independent first vote + (verdictMaxRounds-1) debate re-votes. The
1556
+ // old `vs.round+1<verdictMaxRounds` gave one round FEWER than the name and the docs promise
1557
+ // (default 3 → only 2), so the parameter read as an off-by-one from every documented surface.
1394
1558
  // Move to a REAL debate round: snapshot the current votes into history (so the next round's
1395
1559
  // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
1396
1560
  // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
1397
1561
  // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
1398
1562
  vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
1399
1563
  vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
1400
- vs.stage='debate'; vs.round+=1; vs.asked=[]; logActivity('verify',vs.targetId+' round '+vs.round+' → debate (re-vote after seeing others)'); await saveAll(); doSchedule=true
1564
+ vs.stage='debate'; vs.round+=1; vs.asked=[]; logActivity('verify',vs.targetId+' round '+vs.round+'/'+params.verdictMaxRounds+' → debate (re-vote after seeing others)'); await saveAll(); doSchedule=true
1401
1565
  }
1402
1566
  else {
1403
1567
  const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
@@ -1461,13 +1625,25 @@ export function apply(ctx) {
1461
1625
  function maybeQueueVerify(target, proposer){
1462
1626
  const t=idSafe(target) // sanitize BEFORE it becomes file names / dedup keys / status output
1463
1627
  if(!t || t==='id') return false
1628
+ // The object KIND is derived from the id prefix, and it decides BOTH the directory the Verified
1629
+ // card lands in (Verified/命题/ vs Verified/问题/) and the `- 类型:` line of the source card's
1630
+ // write-back. The old fallback mapped EVERY unrecognised id to 'proposition', so proposing a
1631
+ // method/sub-problem whose id does not start with m-/s- (e.g. `2.1`, `lemma-A`) silently wrote a
1632
+ // card into Verified/命题/ carrying `类型: 命题` — a card whose location and declaration disagree.
1633
+ // The prefix IS the coordination contract (the prompt hands residents `p-`/`m-`/`s-`), so an id
1634
+ // that does not carry one is refused HERE, at the single queueing funnel, instead of guessing.
1635
+ const tt=guessTargetType(t)
1636
+ if(!tt){
1637
+ logActivity('verify',t+' 的提议被拒绝:对象 id 必须以 p-(命题)/ m-(方法)/ s-(子问题)开头,否则框架无法确定它属于哪个库(V4_INVALID_ARGUMENT)')
1638
+ return false
1639
+ }
1464
1640
  const last=verifiedRecently.get(t)
1465
1641
  if(last!==undefined && (now()-last) < recoverStallMs()){
1466
1642
  logActivity('verify',t+' re-propose ignored (just verified at '+fmtTime(last)+')')
1467
1643
  return false
1468
1644
  }
1469
1645
  if(pendingVerify.some(p=>String(p.targetId)===t)) return true // already queued → keep ONE entry
1470
- pendingVerify.push({targetId:t,targetType:guessTargetType(t),proposer:proposer||'',at:now()})
1646
+ pendingVerify.push({targetId:t,targetType:tt,proposer:proposer||'',at:now()})
1471
1647
  return true
1472
1648
  }
1473
1649
  async function writeDebateDoc(vs,done,val){
@@ -1553,7 +1729,11 @@ export function apply(ctx) {
1553
1729
  next=rewriteCardField(next,'概率',isTrue?'1':'0')
1554
1730
  await writeText(rel,next||text)
1555
1731
  }
1556
- function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return 'proposition' }
1732
+ function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return '' }
1733
+ // Human-readable word for the target kind, used in the prompts. Never print the RAW internal
1734
+ // token: the voter is a model, and `proposition`/`method`/`subproblem` in an otherwise Chinese
1735
+ // contract is exactly the kind of untranslated placeholder these audits look for.
1736
+ function targetTypeWord(t){ return t==='method'?'方法':(t==='subproblem'?'子问题':'命题') }
1557
1737
 
1558
1738
  // ---- heartbeat / liveness helpers (boundary-A: event-driven + gated heartbeat) ----
1559
1739
  // A checkpoint wake is NOT "keep working forever": it nudges the least-recently-active
@@ -1622,23 +1802,6 @@ export function apply(ctx) {
1622
1802
  // fail with no operator-facing message. The host's own rejection lists every registered tool, so
1623
1803
  // the retry below never guesses. FAIL CLOSED: if nothing survives the filter we rethrow instead of
1624
1804
  // spawning WITHOUT one (that would grant exactly what the operator denied).
1625
- function registeredToolsFromError(message){
1626
- const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
1627
- if(!m) return undefined
1628
- const names = m[1].split(',').map(function(s){ return s.trim() }).filter(Boolean)
1629
- return names.length > 0 ? new Set(names) : undefined
1630
- }
1631
- function sanitizeToolFilter(filter, known){
1632
- if(!filter || !(known instanceof Set) || known.size === 0) return filter
1633
- const out = {}
1634
- for(const key of ['allow','deny']){
1635
- const list = filter[key]
1636
- if(!Array.isArray(list)) continue
1637
- const kept = list.filter(function(n){ return known.has(String(n).trim()) })
1638
- if(kept.length > 0) out[key] = kept
1639
- }
1640
- return (out.allow || out.deny) ? out : undefined
1641
- }
1642
1805
  async function startWithToolFilter(toolFilter, makeSpec){
1643
1806
  try {
1644
1807
  return await subagents.startContinuable(makeSpec(toolFilter))
@@ -1691,6 +1854,18 @@ export function apply(ctx) {
1691
1854
  // scheduling pass goes through — so a refused spawn is deferred by a whole round (and paced by
1692
1855
  // the heartbeat) instead of being retried inside the loop that discovered the refusal.
1693
1856
  if(pendingSpawns.length) await retryPendingSpawns()
1857
+ // Queued mail MUST also be delivered while the group is still brainstorming (Round C, F1).
1858
+ // A human/team message is not "work": the brainstorm phase used to return at the next line
1859
+ // before ever reaching the delivery point below, so a wake that FAILED left the message
1860
+ // re-queued and every subsequent heartbeat firing a no-op (`maybeFinishBrainstorm` only flips
1861
+ // the phase once every resident has an insight). The scheduler then stayed cold with mail
1862
+ // still queued — the exact permanent stall F1 exists to remove, narrowed to this phase
1863
+ // (_oneoff/probe-v4-mailbox-stall.mjs used to print REPRODUCED here).
1864
+ // Deliberately conditioned on "no consensus in progress": continueMeetingRound /
1865
+ // continueVerifyRound refuse to flush mailboxes on purpose (a backlog must not starve a
1866
+ // consensus past its watchdog, see their comments), so a parked meeting/verify keeps that
1867
+ // guarantee and the mail goes out on the first pass after the consensus closes.
1868
+ if(!meetingState && !verifyState){ const delivered=await deliverNextMailbox(); if(delivered) return }
1694
1869
  if(phase==='brainstorm'){ await maybeFinishBrainstorm(); return }
1695
1870
  if(meetingState){ await continueMeetingRound(); return }
1696
1871
  if(verifyState){ await continueVerifyRound(); return }
@@ -1698,11 +1873,24 @@ export function apply(ctx) {
1698
1873
  // A meeting requested while a verify held the floor is parked in pendingMeeting; once the
1699
1874
  // verify queue has truly drained (no verifyState / pendingVerify), resume it before anything else.
1700
1875
  if(pendingMeeting){ const pm=pendingMeeting; pendingMeeting=null; await startMeeting(pm.agenda, pm.type, pm.targetId); return }
1701
- // mailbox delivery
1876
+ // mailbox delivery. `wakeResident` CLEARS the heartbeat as its first act, so a pass that delivers
1877
+ // NOTHING must not return without re-arming one: a rejected `sendMessage`
1878
+ // (subagent/delivery-unavailable, a cold-resumed child whose Activation closed, a transient
1879
+ // persistence failure) re-queues the message, refreshes the recipient's lastActiveAt (so the fill
1880
+ // loop below skips it for a whole activityTimeoutMs) and used to end the pass with no wake, no
1881
+ // heartbeat and no in-flight turn — nothing else could ever re-drive the scheduler, so the whole
1882
+ // group stopped permanently with mail still queued. Falling through instead lets the branches
1883
+ // below re-arm (and B/A still get their chance); on a SUCCESSFUL delivery the recipient's own
1884
+ // subagent/end re-drives the pump, so the pass can end here as before.
1702
1885
  const delivered=await deliverNextMailbox(); if(delivered) return
1703
1886
  // maxParallel: don't start a new wake when the in-flight cap is reached
1704
1887
  const mp=Number(params.maxParallel)||0
1705
1888
  if(mp>0 && busy.size>=mp){ armHeartbeat(); return }
1889
+ // Refresh the artifact counter from the residents' own card libraries (fs-written cards count
1890
+ // too) before the auto-sync test below, so `meetingKeepEvery` measures NEW artifacts — and
1891
+ // convene the sync meeting here, because this is the only place an `fs`-written card is seen.
1892
+ await syncArtifactCount()
1893
+ if(maybeArtifactSyncMeeting()) return
1706
1894
  // B) stall auto-sync meeting (分级保活 B): the group has been idle with NO progress for
1707
1895
  // stallAutoMeetingMs → convene a sync meeting so the residents coordinate their next move
1708
1896
  // (framework convenes & records; residents decide — never assigns work). Only when no
@@ -1801,8 +1989,8 @@ export function apply(ctx) {
1801
1989
  realCompact(r).catch(()=>{}) // best-effort real DSH /compact of this resident while idle
1802
1990
  const output=blocksToText(info&&info.lastAssistantMessage)
1803
1991
  const parsed=parseReply(output)
1804
- postmark(r, parsed) // context/compact bookkeeping, regardless of wake kind (clears any leak)
1805
1992
  const kind=wakeKind.get(r.rId)||'normal'
1993
+ postmark(r, parsed, kind) // context/compact bookkeeping — a mid-consensus turn may NOT ack a compaction it never received
1806
1994
  if(kind==='meeting' && meetingState){
1807
1995
  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}
1808
1996
  meetingState.lastInputAt=now()
@@ -1881,6 +2069,9 @@ export function apply(ctx) {
1881
2069
  // carrying a previous run's records over would let a stale `passed` open the new gate.
1882
2070
  formal={}; formalTodos=[]
1883
2071
  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
2072
+ // Re-base the artifact counter on the cards already on disk, so the auto-sync meeting counts
2073
+ // artifacts written by THIS run (the project tree may already hold cards from an earlier run).
2074
+ artifactBaseline=null; artifactCount=0; await syncArtifactCount()
1884
2075
  lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
1885
2076
  const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
1886
2077
  pendingSpawns=[] // a fresh run replaces the roster: nothing is queued from before
@@ -1921,7 +2112,15 @@ export function apply(ctx) {
1921
2112
  // If the persisted State came from a DIFFERENT process (crash/restart), the saved
1922
2113
  // childIds are stale; clear them so residents re-spawn (their libraries persist on
1923
2114
  // disk and re-seed the resumed run). Same-process pause→resume keeps continuable ids.
2115
+ //
2116
+ // `persistedEpoch` is only ever READ here, so before this fix a same-process pause→resume
2117
+ // compared this process's random epoch against the epoch saved by ITSELF and always concluded
2118
+ // "cross-process": every resident was re-spawned, `busy`/`wakeKind` were thrown away while the
2119
+ // OLD children were still running (`resume()` never interrupts them, so the late
2120
+ // `subagent/end` that the code's own comment warns about was guaranteed, not hypothetical).
2121
+ // Re-sync it on a same-process load, which is the contract `paused` already assumes.
1924
2122
  const crossProcess = persistedEpoch !== processEpoch
2123
+ if(!crossProcess) persistedEpoch = processEpoch
1925
2124
  if(crossProcess){ for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.roundsSinceCompact=0 } }
1926
2125
  // ANY re-spawn (cross-process OR a same-process abort that already cleared childIds) must get a FRESH
1927
2126
  // coordination/concurrency state and a brainstorm phase. Otherwise: re-spawned brainstorm residents run
@@ -1951,9 +2150,18 @@ export function apply(ctx) {
1951
2150
  pendingSpawns: pendingSpawns.length, hostChildLimit: (hostChildLimit===undefined?null:hostChildLimit),
1952
2151
  meetingInProgress: !!(meetingState), verifyInProgress: !!(verifyState), pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null, pendingVerifyCount: pendingVerify.length,
1953
2152
  parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
2153
+ // The coordination counters (`spoke k/N`, `voted k/N`, the verification round) used to live only
2154
+ // in `report()`: from `status()` alone an operator could not tell "the group is progressing"
2155
+ // from "the group is stuck at 2/4 votes", which is exactly how F1/F2/F7 stay invisible.
2156
+ 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
+ :(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
+ artifactCount: artifactCount, artifactBaseline: artifactBaseline,
1954
2159
  // The Lean knobs and the per-object formal records are part of the readable status: without
1955
2160
  // them a `require`-mode run that keeps returning 未定论 would be undiagnosable from outside.
1956
2161
  formal: formalView(),
2162
+ // The parameter NAMES (not just the rendered string) so a caller — the `/v4 set` handler in
2163
+ // particular — can reject an unknown key instead of silently dropping it.
2164
+ paramsKeys: Object.keys(params),
1957
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(', ') } }
1958
2166
  function formalReportText(){
1959
2167
  if(!formalOn()) return '- 未启用(`formalVerify` = off;可用 vibe_v4_set 切到 encourage / require)'
@@ -2041,9 +2249,30 @@ export function apply(ctx) {
2041
2249
  if(min===undefined) return n
2042
2250
  return n < min ? min : n
2043
2251
  }
2044
- function setParams(upd){ for(const k of Object.keys(upd||{})){ if(k in params){ const nv=normalizeParam(k, upd[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } saveSettings().catch(()=>{}); return {ok:true} }
2252
+ /**
2253
+ * Apply a parameter update. Keys the parameter layer does not know are NOT silently dropped: the
2254
+ * caller gets them back (`ok:false` + `ignored`), because `{ok:true}` for a call that changed
2255
+ * nothing is the "declared but not received" silent failure AUDIT-CHECKLIST §1.9 names.
2256
+ */
2257
+ function setParams(upd){
2258
+ const keys=Object.keys(upd||{})
2259
+ // `k in params` 会沿原型链判真:`toString` / `constructor` / `__proto__` 这类键会被当成"已知参数"
2260
+ // 写进 params(`params['__proto__']=…` 还会真的改掉 params 的原型),而 schema 的
2261
+ // additionalProperties:false 在 v4 上**不产生运行期拒绝**(裸 tools.register,宿主不校验实参)。
2262
+ // 已知参数一律以**自有属性**为准(AUDIT-CHECKLIST §1.9 的"声明=接收"只能在自有键上成立)。
2263
+ const has = (o,k)=>Object.prototype.hasOwnProperty.call(o,k)
2264
+ const ignored=keys.filter(k=>!has(params,k))
2265
+ for(const k of keys){ if(has(params,k)){ const nv=normalizeParam(k, upd[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } }
2266
+ saveSettings().catch(()=>{})
2267
+ if(ignored.length){
2268
+ logActivity('set','忽略未知参数:'+ignored.join(', '))
2269
+ return {ok:false,message:'unknown parameter(s): '+ignored.join(', ')+' — nothing was changed'+(ignored.length<keys.length?('(已应用:'+keys.filter(k=>!(ignored.indexOf(k)>=0)).join(', ')+')'):''),ignored,applied:keys.filter(k=>!(ignored.indexOf(k)>=0))}
2270
+ }
2271
+ return {ok:true,applied:keys}
2272
+ }
2045
2273
  // ---- create / configure (no auto-start) + settings-file persistence ----
2046
- async function loadSettings(){ const s=await readJson('State/settings.json'); if(s&&typeof s==='object'){ for(const k of Object.keys(s)){ if(k in params){ const nv=normalizeParam(k, s[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } } }
2274
+ // 同一把守护:State/settings.json 是用户可编辑的,原型链上的键名(toString/__proto__)不是参数。
2275
+ async function loadSettings(){ const s=await readJson('State/settings.json'); if(s&&typeof s==='object'){ for(const k of Object.keys(s)){ if(Object.prototype.hasOwnProperty.call(params,k)){ const nv=normalizeParam(k, s[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } } }
2047
2276
  async function saveSettings(){ await writeJson('State/settings.json', params) }
2048
2277
  // Create/configure a project and set params/problem WITHOUT starting any resident.
2049
2278
  // The intended flow: vibe_v4_configure {project?, problem?, params?} → vibe_v4_start {}.
@@ -2079,10 +2308,27 @@ export function apply(ctx) {
2079
2308
  rememberAgent, forgetAgent,
2080
2309
  setPause, initAbort, postMessage, startMeeting, saveAll, broadcast, configure, loadSettings,
2081
2310
  currentResident:()=>currentResident,
2311
+ /** Does this session own the given child id? (used by the `subagent/start` capture) */
2312
+ ownsChild,
2313
+ /** Stash a live Agent seen at `subagent/start` time; claimed by `spawnResident` on resolve. */
2314
+ stashStartAgent,
2082
2315
  // safety kick: drive one scheduler pass (used when an end handler errored, so an exceptional
2083
2316
  // turn can never leave the group with no end-event and no heartbeat to continue it)
2084
2317
  nudge:()=>scheduleNext().catch(()=>{}),
2085
- residentIdOf:(agent)=>{ const m=residentOfAgent(agent); if(m) return m; const c=currentResident; return (c && residents.has(c)) ? c : '' },
2318
+ /**
2319
+ * Which resident is calling a resident-facing tool?
2320
+ * 1. the caller's subagent id matched against a resident's childId — the exact answer;
2321
+ * 2. the last-woken resident, but ONLY when no caller identity exists at all (the mock/harness
2322
+ * path, where `exec.agent` is undefined).
2323
+ * An agent that IS present but matches no resident is NOT guessed at: attributing a host call to
2324
+ * whichever resident happened to be woken last writes into that resident's private library with
2325
+ * no indication that the attribution was invented (AUDIT-CHECKLIST §0.2: identity is never
2326
+ * inferred from global mutable state). Such a call now fails explicitly with 'no such resident'.
2327
+ */
2328
+ residentIdOf:(agent)=>{ const m=residentOfAgent(agent); if(m) return m
2329
+ let hasId=false; try { hasId=!!(agent&&agent.id) } catch(e){ hasId=false }
2330
+ if(hasId) return ''
2331
+ const c=currentResident; return (c && residents.has(c)) ? c : '' },
2086
2332
  useResident:(id)=>{ currentResident=id },
2087
2333
  publishProgress, recordProposition, recordMethod, recordSubproblem, listResidents, reportContext,
2088
2334
  proposeTask, claimTask, taskDone, listTasks,
@@ -2109,6 +2355,12 @@ export function apply(ctx) {
2109
2355
  // `vs` mirrors the live verification state ({targetId,targetType,targetOwner,stage,history,verdicts});
2110
2356
  // pass one explicitly to ask "what WOULD the voters read for this object right now?".
2111
2357
  verify:(rId,vs)=>{ const r=residents.get(String(rId)); return r?verifyPrompt(r, vs||verifyState||{targetId:'',targetType:'proposition',targetOwner:'',stage:'independent',verdicts:{}}):'' },
2358
+ // The meeting prompt is the ONLY group-chat / task-allocation / stop-vote surface this preset
2359
+ // has, so a host (or the audit corpus) must be able to READ it. Pass `st` to ask "what would
2360
+ // the next speaker read right now?"; the live meeting state is the default.
2361
+ meeting:(rId,st)=>{ const r=residents.get(String(rId)); if(!r) return ''
2362
+ const s=st||meetingState||{agenda:'(无进行中的会议)',type:'general',inputs:{},order:[]}
2363
+ return meetingPrompt(r,s) },
2112
2364
  formalBlock:(target)=>formalPromptBlock(target),
2113
2365
  formalWorkLine:()=>formalWorkLine(),
2114
2366
  },
@@ -2119,9 +2371,15 @@ export function apply(ctx) {
2119
2371
  if(which==='brainstorm') return brainstormPrompt(r)
2120
2372
  if(which==='heartbeat') return heartbeatPrompt(r)
2121
2373
  if(which==='coreRules') return coreRulesBrief()
2374
+ if(which==='meeting') return (arg&&arg.meeting)?meetingPrompt(r,arg.meeting):(meetingState?meetingPrompt(r,meetingState):meetingPrompt(r,{agenda:'(无进行中的会议)',type:'general',inputs:{},order:[]}))
2122
2375
  if(which==='verify'){
2123
2376
  const target=idSafe(String((arg&&arg.target)||''))
2124
- return verifyPrompt(r,{targetId:target,targetType:guessTargetType(target),targetOwner:'',stage:String((arg&&arg.stage)||'independent'),history:{},verdicts:{}})
2377
+ // F14 的同一条尺子:前缀(p-/m-/s-)就是对象归属的契约。此前的兜底把**任何**认不出的 id
2378
+ // 都当成 proposition,于是这个"读真实提示词"的工具会展示一条带着错误对象类型的投票提示词
2379
+ // (而真正入队的入口 maybeQueueVerify 已经拒绝这种 id)。认不出就如实拒绝,不猜。
2380
+ const tt=guessTargetType(target)
2381
+ if(!tt) return 'V4_INVALID_ARGUMENT: target must start with p- (proposition) / m- (method) / s- (subproblem) — cannot render a verification prompt for an unknown object kind'
2382
+ return verifyPrompt(r,{targetId:target,targetType:tt,targetOwner:'',stage:String((arg&&arg.stage)||'independent'),history:{},verdicts:{}})
2125
2383
  }
2126
2384
  return normalPrompt(r)
2127
2385
  },
@@ -2155,7 +2413,7 @@ export function apply(ctx) {
2155
2413
  // The exact text a resident would receive. "成员读到的文字就是产品" (AUDIT-CHECKLIST §0.1): a host
2156
2414
  // (or an audit) must be able to READ the prompt, not just the tool return values, or a prompt
2157
2415
  // defect stays invisible. Pure builder calls — no side effects on the run.
2158
- registerTool('vibe_v4_prompts','Read the exact prompt text a resident would receive (which: brainstorm|normal|heartbeat|verify|coreRules). member = resident id; target/stage describe the object for `verify`. Prompt text is the product — this makes it auditable.',objParams({which:{type:'string',enum:['brainstorm','normal','heartbeat','verify','coreRules']},member:{type:'string'},target:{type:'string'},stage:{type:'string'}},['which']),async (s,a)=>{
2416
+ registerTool('vibe_v4_prompts','Read the exact prompt text a resident would receive (which: brainstorm|normal|heartbeat|meeting|verify|coreRules). member = resident id; target/stage describe the object for `verify`. Prompt text is the product — this makes it auditable.',objParams({which:{type:'string',enum:['brainstorm','normal','heartbeat','meeting','verify','coreRules']},member:{type:'string'},target:{type:'string'},stage:{type:'string'}},['which']),async (s,a)=>{
2159
2417
  const which=String(a.which||'normal')
2160
2418
  if(which==='coreRules') return {ok:true,which,text:await s.promptApi.coreRules()}
2161
2419
  const text=await s.promptFor(which,String(a.member||'r-1'),a)
@@ -2173,7 +2431,7 @@ export function apply(ctx) {
2173
2431
  // MODE is dynamic — switching it changes the very next prompt), leanCommand/leanArgs select the
2174
2432
  // executable, leanTimeoutMs bounds one run. Invalid values fall back to the defaults and an
2175
2433
  // unknown mode degrades to 'off' (never to a STRONGER mode).
2176
- registerTool('vibe_v4_set','Set V4 parameters. model/provider override resident LLM route (empty=inherit main); toolAllow/toolDeny restrict resident tools (arrays of tool names); residentPersona adds a persona line; formalVerify: "off" (default, a true no-op) | "encourage" (residents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a unanimous true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record); leanCommand/leanArgs/leanTimeoutMs configure the toolchain.',objParams({residentCount:{type:'integer'},compactAfterRounds:{type:'integer'},compactThreshold:{type:'integer'},meetingKeepEvery:{type:'integer'},maxParallel:{type:'integer'},activityTimeoutMs:{type:'integer'},verdictMaxRounds:{type:'integer'},stallAutoMeetingMs:{type:'integer'},provider:{type:'string'},model:{type:'string'},residentPersona:{type:'string'},toolAllow:{type:'array',items:{type:'string'}},toolDeny:{type:'array',items:{type:'string'}},formalVerify:{type:'string',enum:['off','encourage','require']},leanCommand:{type:'string'},leanArgs:{type:'array',items:{type:'string'}},leanTimeoutMs:{type:'integer'}}),(s,a)=>{ s.setParams(a); return s.status() })
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 })
2177
2435
  // resident-facing tools: route to the CALLING resident (exec.agent.id === childId);
2178
2436
  // fall back to the last-woken resident when called by the host/assistant.
2179
2437
  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) })
@@ -2190,8 +2448,16 @@ export function apply(ctx) {
2190
2448
  registerTool('vibe_v4_list_tasks','(resident) List open tasks.',objParams({}),(s)=>({ok:true,tasks:s.listTasks()}))
2191
2449
  // context / compact (resident reports its context usage so the framework can /compact-equivalent)
2192
2450
  registerTool('vibe_v4_report_context','(resident) Report your context usage %; the framework compacts (self-summary) when it reaches compactThreshold.',objParams({pct:{type:'number'}},['pct']),(s,a,x)=>s.reportContext(s.residentIdOf(x),a.pct))
2193
- registerTool('vibe_v4_claim_write','Reserved: shared-file write lock (framework-managed).',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
2194
- registerTool('vibe_v4_release_write','Reserved: shared-file write lock release.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
2451
+ // NOT IMPLEMENTED — and the description says so. These two tools were registered with
2452
+ // "Reserved: shared-file write lock", which reads as "the lock exists and works": a model following
2453
+ // the v3 convention called `claim_write`, got `{ok:true}` and concluded it held a lock that no code
2454
+ // anywhere implements (this preset has no shared file a resident writes — see the write-scope rule
2455
+ // in `contextBrief`, which restricts every resident to its OWN Progress/Propos/Methods/Subproblems
2456
+ // library, while the framework alone writes Shared/ and State/). They stay registered (removing them
2457
+ // would change the model-visible tool surface) and keep echoing the key, but they now tell the
2458
+ // truth: nothing is reserved, nothing is serialized, write only your own files.
2459
+ registerTool('vibe_v4_claim_write','NOT IMPLEMENTED: no lock exists. This returns {ok:true} without reserving anything. Each resident writes only its own library (Progress/<you>/, Propos/<you>/, Methods/<you>/, Subproblems/<you>/), so overlapping writers are not expected; the framework alone writes Shared/ and State/ (serially, per file). Do not rely on this to exclude another resident.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target,locked:false,note:'no lock is implemented; write only your own library files'}))
2460
+ registerTool('vibe_v4_release_write','NOT IMPLEMENTED: there is no lock to release. Returns {ok:true} as a no-op so an agent that calls it out of habit is not misled into thinking it held a reservation.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target,locked:false,note:'no lock is implemented; nothing was reserved'}))
2195
2461
 
2196
2462
  // ── Lean formal verification (docs/formal-verification.md §5) ─────────────
2197
2463
  // These three tools are registered UNCONDITIONALLY. Registration is STATIC (a mode-dependent
@@ -2218,7 +2484,10 @@ export function apply(ctx) {
2218
2484
  const line=String(inv&&inv.rawInput?inv.rawInput:'').trim(); const parts=line.split(/\s+/); const cmd=parts[0]||''; const rest=parts.slice(1)
2219
2485
  let r
2220
2486
  if(cmd==='configure') r=await s.configure({project:rest[0]||'', problem:parts.slice(2).join(' ')})
2221
- else if(cmd==='start') r=await s.start({})
2487
+ // `/v4 start <problem>` is the natural way to try this out and the tool it mirrors accepts a
2488
+ // problem, so the arguments are honoured instead of silently dropped (a bare `/v4 start` keeps
2489
+ // using the configured problem).
2490
+ else if(cmd==='start') r=await s.start(rest.length?{problem:rest.join(' ')}:{})
2222
2491
  else if(cmd==='resume') r=await s.resume()
2223
2492
  else if(cmd==='pause') r=s.setPause()
2224
2493
  else if(cmd==='abort') r=await s.initAbort()
@@ -2229,9 +2498,32 @@ export function apply(ctx) {
2229
2498
  else if(cmd==='members') r={ok:true,residents:s.listResidents()}
2230
2499
  else if(cmd==='add') r=await s.addMember(rest.join(' '))
2231
2500
  else if(cmd==='remove') r=await s.removeMember(rest[0]||'')
2232
- else if(cmd==='set'){ const upd={}; for(const tok of rest){ const eq=tok.indexOf('='); if(eq>0){ const k=tok.slice(0,eq); const rv=tok.slice(eq+1); const n=Number(rv); upd[k]=Number.isFinite(n)?n:rv } } r=s.setParams(upd) }
2233
- else r={ok:false,usage:'configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set'}
2234
- return {kind:'success',text:JSON.stringify(r,null,2)}
2501
+ else if(cmd==='set'){
2502
+ // Keys are validated HERE and the value is handed to the same `normalizeParam` the tool path
2503
+ // uses, so `/v4 set` cannot (a) coerce `provider=123` into a number for a string-typed key,
2504
+ // or (b) report success for a typo that `setParams` would silently ignore.
2505
+ const upd={}
2506
+ const known=s.status().paramsKeys||[]
2507
+ for(const tok of rest){
2508
+ const eq=tok.indexOf('='); if(eq<=0) continue
2509
+ const k=tok.slice(0,eq)
2510
+ if(known.indexOf(k)<0){ r={ok:false,message:'unknown parameter: '+k+'(用 /v4 status 查看可调参数)'}; break }
2511
+ upd[k]=tok.slice(eq+1)
2512
+ }
2513
+ if(r===undefined){
2514
+ // Apply, then answer with the SAME shape the `vibe_v4_set` TOOL answers with (the current
2515
+ // status + the applied/ignored bookkeeping): reporting only the raw setParams result would
2516
+ // make the slash command a different, less useful surface than the tool it mirrors.
2517
+ const res=s.setParams(upd)
2518
+ const st=s.status()
2519
+ r=(res&&res.ok===false)?Object.assign({},st,{ok:false,warning:res.message,ignored:res.ignored}):Object.assign({},st,{applied:res.applied})
2520
+ }
2521
+ }
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>...'}
2523
+ // `kind:'error'` on a rejected business action, exactly as v2/v3 do: every branch above used to
2524
+ // report `success`, so the human's only non-tool control surface read as success on failure.
2525
+ const failed = !r || r.ok === false || (r.ok === undefined && !!r.error)
2526
+ return {kind: failed?'error':'success', text:JSON.stringify(r,null,2)}
2235
2527
  },
2236
2528
  }))
2237
2529
 
@@ -2240,13 +2532,36 @@ export function apply(ctx) {
2240
2532
  // registry (dsh-subagent:1231 dispose -> dsh-agent:508 store.delete ->
2241
2533
  // dsh-subagent:1241 settle/emit), so an end-time `agents.get(childId)` can never
2242
2534
  // resolve. See `liveAgents` in the session body.
2535
+ //
2536
+ // DO NOT key this handler on `childOwner`: the host emits `subagent/start` INSIDE
2537
+ // `startContinuable`'s await (dsh-subagent/lib/index.js:1116 → `observer.start` → emit at `:306`,
2538
+ // and the direct `emit('subagent/start', …)` at `:279`), i.e. BEFORE that call resolves — and
2539
+ // `spawnResident` can only learn `started.childId` after the resolve. Keying on it made this
2540
+ // handler return for EVERY child, so `liveAgents` stayed empty for the whole run and the real
2541
+ // `/compact` path (`liveAgentOf` → `if(!agent || !agent.session) return`) was dead code.
2542
+ //
2543
+ // Ownership is therefore resolved the other way round: the handler finds the (usually single)
2544
+ // session whose spawn window is open and STASHES the Agent under the child id from the payload;
2545
+ // `spawnResident` claims that stash the moment its call resolves. For a host that embeds the spawn
2546
+ // label in the id, `ownsChild` can be decided from the payload alone — that path is used when no
2547
+ // spawn window is open, so a `subagent/start` for a child we did not spawn is never recorded.
2243
2548
  ctx.on('subagent/start', function(info){
2244
2549
  if(!info || !info.id) return
2245
- const sid=childOwner.get(info.id); const s=sid!==undefined?sessions.get(sid):undefined
2246
- if(!s) return
2247
2550
  let agent
2248
2551
  try { agent = agents.get(info.id) } catch(e){ agent = undefined }
2249
- if(agent) s.rememberAgent(info.id, agent)
2552
+ if(!agent) return
2553
+ let s
2554
+ try {
2555
+ for(const [,ss] of sessions){
2556
+ const open=ss.ownsChild && ss.ownsChild(info.id)
2557
+ if(open){ s=ss; break }
2558
+ }
2559
+ if(!s){ const sid=childOwner.get(info.id); if(sid!==undefined) s=sessions.get(sid) }
2560
+ } catch(e){ s=undefined }
2561
+ if(s){ s.rememberAgent(info.id, agent); return }
2562
+ // No owner yet — the spawn this child belongs to is still inside `startContinuable`. Stash it for
2563
+ // the first session whose spawn window is open, so the claim after the resolve can find it.
2564
+ for(const [,ss] of sessions){ if(ss.stashStartAgent){ ss.stashStartAgent(info.id, agent); return } }
2250
2565
  })
2251
2566
 
2252
2567
  ctx.on('subagent/end', function(info){
@@ -2259,3 +2574,86 @@ export function apply(ctx) {
2259
2574
  .finally(()=>{ if(s.forgetAgent) s.forgetAgent(info.id) })
2260
2575
  })
2261
2576
  }
2577
+
2578
+ // ---- test seam: pure, stateless helpers --------------------------------
2579
+ // These helpers were declared inside `apply()` and are now declared at module scope, so
2580
+ // `apply()` closes over exactly the same function objects this export hands out. The audit
2581
+ // suites therefore exercise the REAL implementations by importing this module, instead of
2582
+ // extracting source text and compiling function bodies through the Function constructor
2583
+ // (dynamic code execution, rejected by the plugin-catalog security scan as
2584
+ // DANGEROUS_DYNAMIC_EXECUTION).
2585
+ //
2586
+ // Contract: no member may touch `ctx`, session state or mutable module state. Most are pure;
2587
+ // three are deliberately non-deterministic (`uuid`/`shortId` use Math.random, `fmtTime` falls back
2588
+ // to the clock) and `parseProgress` normalises the object it is handed in place (pre-existing).
2589
+ // Nothing here is used by the plugin at runtime except through `apply()`, and behaviour is
2590
+ // byte-identical to the previous in-`apply` declarations.
2591
+ export const __testHelpers = {
2592
+ uuid,
2593
+ shortId,
2594
+ clamp01,
2595
+ fmtTime,
2596
+ cl,
2597
+ clPct,
2598
+ blocksToText,
2599
+ posMs,
2600
+ slugify,
2601
+ idSafe,
2602
+ tryJson,
2603
+ parseReply,
2604
+ sanitizeToolFilter,
2605
+ registeredToolsFromError,
2606
+ }
2607
+
2608
+ function now(){ return Date.now() }
2609
+
2610
+ function uuid(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<36;i++){ if(i===8||i===13||i===18||i===23) s+='-'; else s+=h[Math.floor(Math.random()*16)] } return s }
2611
+
2612
+ function shortId(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<8;i++) s+=h[Math.floor(Math.random()*16)]; return s }
2613
+
2614
+ function clamp01(v){ const n=Number(v); if(!Number.isFinite(n)) return 0.5; return Math.max(0,Math.min(1,n)) }
2615
+
2616
+ function fmtTime(ts){ try { return new Date(ts||now()).toISOString().replace('T',' ').slice(0,19) } catch(e){ return String(ts||'') } }
2617
+
2618
+ function cl(x){ return clamp01(Number(x)) }
2619
+
2620
+ function clPct(x){ const n=Number(x); if(!Number.isFinite(n)) return 0; return Math.max(0,Math.min(100,n)) }
2621
+
2622
+ function blocksToText(b){ if(!b) return ''; let out=''; for(const x of b){ if(x&&x.type==='text'&&typeof x.text==='string') out+=x.text+'\n' } return out.trim() }
2623
+
2624
+ function posMs(v,def){ const n=Number(v); return (Number.isFinite(n)&&n>0)?n:(def||120000) }
2625
+
2626
+ function slugify(s){ const t=String(s==null?'':s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g,'-').replace(/^-+|-+$/g,''); return t||'project' }
2627
+
2628
+ function idSafe(s){
2629
+ const t=String(s==null?'':s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/-{2,}/g,'-').replace(/^[.\-]+|[.\-]+$/g,'')
2630
+ return t||'id'
2631
+ }
2632
+
2633
+ function tryJson(s){ try { return JSON.parse(s) } catch(e){ return undefined } }
2634
+
2635
+ function parseReply(text){
2636
+ let obj; const fence=/```(?:json)?[ \t]*([\s\S]*?)```/gi; let m
2637
+ while((m=fence.exec(text))!==null){ const o=tryJson(m[1].trim()); if(o&&typeof o==='object'&&!Array.isArray(o)) obj=o }
2638
+ if(!obj){ const w=tryJson(text.trim()); if(w&&typeof w==='object'&&!Array.isArray(w)) obj=w }
2639
+ return obj||{}
2640
+ }
2641
+
2642
+ function sanitizeToolFilter(filter, known){
2643
+ if(!filter || !(known instanceof Set) || known.size === 0) return filter
2644
+ const out = {}
2645
+ for(const key of ['allow','deny']){
2646
+ const list = filter[key]
2647
+ if(!Array.isArray(list)) continue
2648
+ const kept = list.filter(function(n){ return known.has(String(n).trim()) })
2649
+ if(kept.length > 0) out[key] = kept
2650
+ }
2651
+ return (out.allow || out.deny) ? out : undefined
2652
+ }
2653
+
2654
+ function registeredToolsFromError(message){
2655
+ const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
2656
+ if(!m) return undefined
2657
+ const names = m[1].split(',').map(function(s){ return s.trim() }).filter(Boolean)
2658
+ return names.length > 0 ? new Set(names) : undefined
2659
+ }