dsh-vibe-math 2.4.1 → 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 (42) hide show
  1. package/README.en.md +14 -14
  2. package/README.md +15 -14
  3. package/cordis.patch.yml +33 -10
  4. package/docs/AUDIT-CHECKLIST.md +25 -6
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  7. package/docs/test-timing.md +73 -40
  8. package/installer.js +225 -45
  9. package/package.json +3 -2
  10. package/preset-declaration.js +38 -7
  11. package/prompt-corpus-persona/persona-corpus.json +9 -9
  12. package/prompt-corpus-persona/persona-corpus.md +67 -21
  13. package/prompt-corpus-v2/formal-verify-v2.json +116 -92
  14. package/prompt-corpus-v2/formal-verify-v2.md +240 -93
  15. package/prompt-corpus-v3/formal-verify-v3.json +81 -81
  16. package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
  17. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  18. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  19. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  20. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  21. package/tests/audit-installer-compat.test.mjs +70 -4
  22. package/tests/audit-installer-policy.test.mjs +229 -4
  23. package/tests/audit-persona-sensitivity.mjs +11 -4
  24. package/tests/audit-persona-surface.test.mjs +5 -1
  25. package/tests/audit-v5-sensitivity.mjs +47 -6
  26. package/tests/e2e-v5-round2.test.mjs +354 -0
  27. package/tests/formal-verify-v2.test.mjs +54 -2
  28. package/tests/formal-verify-v3.test.mjs +63 -2
  29. package/tests/formal-verify-v4.test.mjs +120 -4
  30. package/tests/run-tests.mjs +104 -23
  31. package/vibe-math-v2/agent.cordis.yml +6 -0
  32. package/vibe-math-v2/vibe-math-v2.js +503 -99
  33. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  34. package/vibe-math-v3/agent.cordis.yml +26 -10
  35. package/vibe-math-v3/vibe-math-v3.js +555 -175
  36. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  37. package/vibe-math-v4/agent.cordis.yml +26 -6
  38. package/vibe-math-v4/vibe-math-v4.js +404 -51
  39. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  40. package/vibe-math-v5/agent.cordis.yml +8 -4
  41. package/vibe-math-v5/vibe-math-v5.js +513 -105
  42. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +73 -40
@@ -106,6 +106,11 @@ 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 ----
@@ -858,10 +863,21 @@ export function apply(ctx) {
858
863
  // `off` mode must not CREATE Lean state: a run that never used the feature leaves no
859
864
  // State/formal.json behind. Seeded/live records (or a mode that is on) still persist.
860
865
  if(formalOn()||formalPersisted||Object.keys(formal).length||formalTodos.length) await writeJson('State/formal.json', {records:formal,todo:formalTodos})
861
- await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount})
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})
862
872
  }
863
873
  async function loadAll(){
864
- const s=await readJson('State/session.json'); if(s){ running=!!s.running; autoDone=!!s.autoDone; phase=s.phase||'idle'; problemId=s.problemId||problemId; problemText=s.problemText||problemText; runId=s.runId||runId; meetings=s.meetings||[]; reports=s.reports||[]; lastActivityAt=s.lastActivityAt||now(); lastProgressAt=s.lastProgressAt||now(); activityLog=s.activityLog||activityLog; persistedEpoch=s.processEpoch||''; artifactCount=s.artifactCount||0 }
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 }
865
881
  const rm=await readJson('State/residents.json'); if(rm&&typeof rm==='object') residents=new Map(Object.entries(rm))
866
882
  const mb=await readJson('State/mailboxes.json'); if(mb&&typeof mb==='object') mailboxes=new Map(Object.entries(mb))
867
883
  const tb=await readJson('State/taskboard.json'); if(Array.isArray(tb)) taskboard=tb
@@ -878,7 +894,12 @@ export function apply(ctx) {
878
894
 
879
895
  // ---- resident prompts ----
880
896
  function banner(){ const o=[]; for(const [id,r] of residents) o.push('- '+id+'「'+(r.direction||'(未定)')+'」'+r.status+'·轮'+r.rounds); return o.join('\n') }
881
- async function inboxText(rId){ const mb=mailboxes.get(rId)||[]; if(mb.length===0) return ' (no new messages)\n'; return mb.map(m=>' ['+m.from+'] '+m.content).join('\n')+'\n' }
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' }
882
903
  function residentLibraries(){
883
904
  const base=frameworkRoot()
884
905
  return '你的资料库根目录:'+base+'/\n'
@@ -934,6 +955,7 @@ export function apply(ctx) {
934
955
  s.push('### 规则')
935
956
  s.push('- 只有 Verified/(或卡片标"已验证·真/假")算已确立;其余都是你的实验性工作,请区分"猜想/已知"。')
936
957
  s.push('- 验证必须**全组一致**(全真或全假);你只信全票结果。未全票的对象留在库里带概率。')
958
+ s.push('- `facilitator` 是**框架/人类介入的信使名**(不是常驻成员,不在编制里):它转达人类或框架的话,但**不要向它回信**(会返回 no such resident);要回话请用本轮回执的 "input" 字段(会转给全组)或 `vibe_v4_send_message {to:"all"}`。')
937
959
  s.push('- 你自己决定做什么,但**优先级/分工由团队讨论决定**,不是固定模式。若你认为问题已解决或接近解决,请**发起会议**让团队表决。')
938
960
  s.push('- 退出时**只**输出一个 JSON 对象(放在 ```json 代码围栏内;围栏外不要有文字)。')
939
961
  return s.join('\n')
@@ -947,6 +969,7 @@ export function apply(ctx) {
947
969
  // dynamic: after a /compact the resident must re-anchor on the rules it is actually
948
970
  // living under right now.
949
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"}`。'
950
973
  +(formalOn()?('\n'+formalWorkLine()):'')
951
974
  }
952
975
  function brainstormPrompt(r){
@@ -975,11 +998,10 @@ export function apply(ctx) {
975
998
  const prior=Object.entries(st.inputs).filter(([k])=>k!==r.rId).map(([k,iv])=>' ['+k+'] '+String(iv.input||iv.summary||'')).join('\n')
976
999
  return (params.residentPersona?params.residentPersona+'\n':'')
977
1000
  +'Resident '+r.rId+' — 团队会议进行中。 A meeting is in progress (agenda: '+st.agenda+').'
978
- +(st.type==='verify'?('\n团队正在验证对象:'+st.targetId+'('+st.targetType+',提出者 '+st.targetOwner+')。请先看他人意见,再给独立判断。'):'')
979
1001
  +'\n这是一场真实讨论:下面已有人发言(转给你),请先看,然后**加入讨论/补充/反驳/表决**。'
980
1002
  +(prior?('\n\n### 已有发言(他人 input,已转发给你)\n'+prior):'\n(目前还没人发言,你先说。)')
981
1003
  +'\n\n你可以:提议任务(propose_task)、认领开放任务(claim_task)、提议验证对象(propose_verify)、或对"原问题是否已解决"表决(voteSolved)。请把**你的实际发言**写进 "input"。'
982
- +'\nReply with ONLY a JSON object:\n'
1004
+ +'\n**停止表决必须是绝对票**:`voteSolved:true` 只表示你认为原问题**已解决**;只要有一名在册常驻没投 `true`(投 false、弃权、或漏写这个字段),run 就**不会**停止。不确定就投 false。' +'\nReply with ONLY a JSON object:\n'
983
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}'
984
1006
  }
985
1007
  function verifyPrompt(r, vs){
@@ -990,9 +1012,19 @@ export function apply(ctx) {
990
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')
991
1013
  const L=[]
992
1014
  L.push((params.residentPersona?params.residentPersona+'\n':'')
993
- +'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'
994
1016
  +'请给出你对「该对象为真」的**正确概率 `verdict`**,仅一个 0–1 数值:**1 = 绝对为真,0 = 绝对为假,0.5 = 完全不确定,其余为介于其间的程度**(不要给 TRUE/FALSE,就给一个数值)。\n'
995
- +'判定规则:仅当**全体常驻一致给 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'
996
1028
  +'请给出你**诚实独立的判断**'
997
1029
  +(vs.stage==='debate'?',并参考他人意见:\n':'。\n')
998
1030
  +(vs.stage==='debate'&&others?('### 他人上一轮意见(已转发给你)\n'+others+'\n'):''))
@@ -1004,7 +1036,7 @@ export function apply(ctx) {
1004
1036
  }
1005
1037
  L.push('')
1006
1038
  L.push('Reply with ONLY a JSON object:')
1007
- L.push('{"vote":{"verdict":0.9,"reason":"<your logic>"}}')
1039
+ L.push('{"vote":{"verdict":1,"reason":"<your logic>"}}')
1008
1040
  if(formalOn()){
1009
1041
  // The formal field belongs in the VOTING contract too: voters are exactly the agents who
1010
1042
  // must either formalize the object or record why they judged it infeasible.
@@ -1068,6 +1100,33 @@ export function apply(ctx) {
1068
1100
 
1069
1101
  // ---- resident lifecycle ----
1070
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
+ }
1071
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} }
1072
1131
  async function spawnResident(r){
1073
1132
  // Skip BEFORE touching the host when we already KNOW the ceiling and our own live children
@@ -1076,6 +1135,8 @@ export function apply(ctx) {
1076
1135
  if(hostChildLimit!==undefined && liveChildCount()>=hostChildLimit){ noteSpawnRefused(r, hostChildLimit); return false }
1077
1136
  const ao=residentAgentOptions(); const tf=residentToolFilter()
1078
1137
  let started
1138
+ // Register the label for the whole in-flight window: `subagent/start` fires inside this await.
1139
+ pendingSpawnLabels.add(r.rId)
1079
1140
  try {
1080
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)} })
1081
1142
  } catch(e){
@@ -1085,17 +1146,46 @@ export function apply(ctx) {
1085
1146
  // failure keeps its previous behaviour and still propagates.
1086
1147
  if(!isActivationLimitReached(e)) throw e
1087
1148
  noteSpawnRefused(r, noteChildLimit(e)); return false
1088
- }
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)
1089
1155
  r.childId=started.childId; r.status='brainstorm'; r.lastActiveAt=now()
1090
1156
  childOwner.set(started.childId,sessionId); busy.add(r.rId); wakeKind.set(r.rId,'normal'); currentResident=r.rId
1091
1157
  residents.set(r.rId,r); await saveAll(); logActivity('spawn',r.rId+' ('+(r.direction||'brainstorm')+')')
1092
1158
  return true
1093
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
+ }
1094
1180
  async function wakeResident(r, promptText, kind){
1095
1181
  if(!r || !r.childId) return false // a removed resident must never be woken (else r.childId would crash)
1096
1182
  clearHeartbeat()
1097
1183
  busy.add(r.rId); wakeKind.set(r.rId,kind||'normal'); currentResident=r.rId
1098
- 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()
1099
1189
  // Context compaction has TWO distinct needs. Confusing them is the bug that made
1100
1190
  // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the start of nearly every prompt:
1101
1191
  // (a) r.needCompact (set by a REAL /compact) => the resident's rules may be blurred, so
@@ -1135,6 +1225,8 @@ export function apply(ctx) {
1135
1225
  } else {
1136
1226
  throw new Error('no subagent continuation API (need sendMessage or followup)')
1137
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
1138
1230
  return true
1139
1231
  }
1140
1232
  catch(e){ console.error('vibe-v4 wake '+r.rId+' failed: '+String((e&&e.message)||e)); busy.delete(r.rId); return false }
@@ -1146,18 +1238,74 @@ export function apply(ctx) {
1146
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'} }
1147
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'} }
1148
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'} }
1149
- // auto-sync meeting: every meetingKeepEvery artifact records, convene a general coordination meeting.
1241
+ // ── auto-sync meeting: every `meetingKeepEvery` NEW artifacts, convene a coordination meeting ──
1150
1242
  //
1151
- // ⚠ 已知设计缺口(有意保留,未修):计数口径**只有** record_* 三个便捷工具的调用。而提示词明确
1152
- // 告诉常驻:"vibe_v4_publish_progress/record_* 只是便捷记录器(可选;推荐直接用 fs 写自己的文件)",
1153
- // 所以一个完全按推荐方式(fs 直写)工作的团队不会让 artifactCount 增长,
1154
- // `artifactCount % meetingKeepEvery === 0` 永不成立 —— 文档承诺的"每积累 N 个新产物自动同步
1155
- // 一次"在推荐工作流下不可达(此时只有"停滞看门狗"那条时间触发路径会开会)。
1156
- // 试过把"完成的常驻轮次"也计入,但那会改变开会节奏,令 e2e-v4-fixes T4 与 selfdrive-v4 的
1157
- // 时序断言失败(两套测试都按当前节奏写死了预期)。这属于**设计参数取舍**,需要维护者决定:
1158
- // 要么改计数口径并同步调整测试预期,要么把"便捷记录器可选"的措辞改为"建议使用以便触发周期同步"。
1159
- function bumpArtifacts(){ artifactCount+=1; markProgress(); if(!meetingState && !verifyState && !pendingMeeting && Number(params.meetingKeepEvery)>0 && artifactCount % Number(params.meetingKeepEvery)===0){ startMeeting('定期同步:分工/进展/是否需要验证','general',null).catch(()=>{}) } }
1160
- function listResidents(){ return Array.from(residents.values()).map(r=>({id:r.rId,direction:r.direction,status:r.status,rounds:r.rounds,contextPct:r.contextPct,insight:r.insight?r.insight.slice(0,80):''})) }
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)||''})) }
1161
1309
  // identify WHICH resident is calling a resident-facing tool: match the caller's
1162
1310
  // subagent id to a resident's childId. Fall back to the last-woken resident when
1163
1311
  // the caller is the host/assistant (or an unknown agent). This makes per-resident
@@ -1171,17 +1319,28 @@ export function apply(ctx) {
1171
1319
  // wake the claimer to work on it (framework moves the task, resident decides how).
1172
1320
  // NOT while paused/stopped: a paused run must not start new work — the claim is recorded on the
1173
1321
  // board and the resident (who claimed it) picks it up again after resume.
1174
- const r=residents.get(claimer); if(r && !busy.has(claimer) && running && !autoDone){ currentResident=claimer; await wakeResident(r, (await normalPrompt(r))+'\n\n[YOU CLAIMED TASK '+id+'] '+t.title+' — '+t.description,'normal'); await saveAll() }
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() }
1175
1323
  return {ok:true} }
1176
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} }
1177
1325
  async function saveTaskboard(){ await writeJson('State/taskboard.json',taskboard); await writeTaskboard() }
1178
1326
  function listTasks(){ return taskboard.filter(t=>t.status!=='done') }
1179
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} }
1180
- // Apply context/compact bookkeeping from a resident's reply, so the flag can clear even when
1181
- // the reply came through a meeting/verify branch (defensive) as well as the normal branch.
1182
- function postmark(r, parsed){
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){
1183
1341
  const cp=Number(parsed.contextPct); if(Number.isFinite(cp)) r.contextPct=clPct(cp) // tolerate numeric strings ("40")
1184
- if(parsed.compacted===true || (r.needCompact && parsed.summary)){
1342
+ const isNormalTurn=(kind||'normal')==='normal'
1343
+ if(parsed.compacted===true || (isNormalTurn && r.needCompact && parsed.summary)){
1185
1344
  r.contextSeed=String(parsed.summary||r.contextSeed||'')
1186
1345
  r.contextPct=Math.min(r.contextPct||15,25)
1187
1346
  r.roundsSinceCompact=0
@@ -1195,8 +1354,22 @@ export function apply(ctx) {
1195
1354
  const r=residents.get(to); if(!r) return {ok:false,message:'no such resident'}
1196
1355
  if(!busy.has(to)){
1197
1356
  currentResident=r.rId
1198
- await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
1199
- await saveAll(); markProgress(); logActivity('message',from+'→'+to); return {ok:true}
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}
1200
1373
  }
1201
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}
1202
1375
  }
@@ -1287,8 +1460,16 @@ export function apply(ctx) {
1287
1460
  if(iv.claim_task) await claimTask(iv.claim_task, id)
1288
1461
  if(iv.propose_verify) maybeQueueVerify(iv.propose_verify, id)
1289
1462
  }
1290
- const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
1291
- const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
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)
1292
1473
  logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
1293
1474
  if(allSolved){
1294
1475
  running=false; autoDone=true; phase='done'; clearHeartbeat()
@@ -1369,14 +1550,18 @@ export function apply(ctx) {
1369
1550
  else await closeVerify(vs,allTrue)
1370
1551
  doSchedule=true
1371
1552
  }
1372
- 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.
1373
1558
  // Move to a REAL debate round: snapshot the current votes into history (so the next round's
1374
1559
  // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
1375
1560
  // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
1376
1561
  // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
1377
1562
  vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
1378
1563
  vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
1379
- vs.stage='debate'; vs.round+=1; vs.asked=[]; logActivity('verify',vs.targetId+' round '+vs.round+' → debate (re-vote after seeing others)'); await saveAll(); doSchedule=true
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
1380
1565
  }
1381
1566
  else {
1382
1567
  const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
@@ -1440,13 +1625,25 @@ export function apply(ctx) {
1440
1625
  function maybeQueueVerify(target, proposer){
1441
1626
  const t=idSafe(target) // sanitize BEFORE it becomes file names / dedup keys / status output
1442
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
+ }
1443
1640
  const last=verifiedRecently.get(t)
1444
1641
  if(last!==undefined && (now()-last) < recoverStallMs()){
1445
1642
  logActivity('verify',t+' re-propose ignored (just verified at '+fmtTime(last)+')')
1446
1643
  return false
1447
1644
  }
1448
1645
  if(pendingVerify.some(p=>String(p.targetId)===t)) return true // already queued → keep ONE entry
1449
- pendingVerify.push({targetId:t,targetType:guessTargetType(t),proposer:proposer||'',at:now()})
1646
+ pendingVerify.push({targetId:t,targetType:tt,proposer:proposer||'',at:now()})
1450
1647
  return true
1451
1648
  }
1452
1649
  async function writeDebateDoc(vs,done,val){
@@ -1532,7 +1729,11 @@ export function apply(ctx) {
1532
1729
  next=rewriteCardField(next,'概率',isTrue?'1':'0')
1533
1730
  await writeText(rel,next||text)
1534
1731
  }
1535
- 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'?'子问题':'命题') }
1536
1737
 
1537
1738
  // ---- heartbeat / liveness helpers (boundary-A: event-driven + gated heartbeat) ----
1538
1739
  // A checkpoint wake is NOT "keep working forever": it nudges the least-recently-active
@@ -1653,6 +1854,18 @@ export function apply(ctx) {
1653
1854
  // scheduling pass goes through — so a refused spawn is deferred by a whole round (and paced by
1654
1855
  // the heartbeat) instead of being retried inside the loop that discovered the refusal.
1655
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 }
1656
1869
  if(phase==='brainstorm'){ await maybeFinishBrainstorm(); return }
1657
1870
  if(meetingState){ await continueMeetingRound(); return }
1658
1871
  if(verifyState){ await continueVerifyRound(); return }
@@ -1660,11 +1873,24 @@ export function apply(ctx) {
1660
1873
  // A meeting requested while a verify held the floor is parked in pendingMeeting; once the
1661
1874
  // verify queue has truly drained (no verifyState / pendingVerify), resume it before anything else.
1662
1875
  if(pendingMeeting){ const pm=pendingMeeting; pendingMeeting=null; await startMeeting(pm.agenda, pm.type, pm.targetId); return }
1663
- // 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.
1664
1885
  const delivered=await deliverNextMailbox(); if(delivered) return
1665
1886
  // maxParallel: don't start a new wake when the in-flight cap is reached
1666
1887
  const mp=Number(params.maxParallel)||0
1667
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
1668
1894
  // B) stall auto-sync meeting (分级保活 B): the group has been idle with NO progress for
1669
1895
  // stallAutoMeetingMs → convene a sync meeting so the residents coordinate their next move
1670
1896
  // (framework convenes & records; residents decide — never assigns work). Only when no
@@ -1763,8 +1989,8 @@ export function apply(ctx) {
1763
1989
  realCompact(r).catch(()=>{}) // best-effort real DSH /compact of this resident while idle
1764
1990
  const output=blocksToText(info&&info.lastAssistantMessage)
1765
1991
  const parsed=parseReply(output)
1766
- postmark(r, parsed) // context/compact bookkeeping, regardless of wake kind (clears any leak)
1767
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
1768
1994
  if(kind==='meeting' && meetingState){
1769
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}
1770
1996
  meetingState.lastInputAt=now()
@@ -1843,6 +2069,9 @@ export function apply(ctx) {
1843
2069
  // carrying a previous run's records over would let a stale `passed` open the new gate.
1844
2070
  formal={}; formalTodos=[]
1845
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()
1846
2075
  lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
1847
2076
  const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
1848
2077
  pendingSpawns=[] // a fresh run replaces the roster: nothing is queued from before
@@ -1883,7 +2112,15 @@ export function apply(ctx) {
1883
2112
  // If the persisted State came from a DIFFERENT process (crash/restart), the saved
1884
2113
  // childIds are stale; clear them so residents re-spawn (their libraries persist on
1885
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.
1886
2122
  const crossProcess = persistedEpoch !== processEpoch
2123
+ if(!crossProcess) persistedEpoch = processEpoch
1887
2124
  if(crossProcess){ for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.roundsSinceCompact=0 } }
1888
2125
  // ANY re-spawn (cross-process OR a same-process abort that already cleared childIds) must get a FRESH
1889
2126
  // coordination/concurrency state and a brainstorm phase. Otherwise: re-spawned brainstorm residents run
@@ -1913,9 +2150,18 @@ export function apply(ctx) {
1913
2150
  pendingSpawns: pendingSpawns.length, hostChildLimit: (hostChildLimit===undefined?null:hostChildLimit),
1914
2151
  meetingInProgress: !!(meetingState), verifyInProgress: !!(verifyState), pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null, pendingVerifyCount: pendingVerify.length,
1915
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,
1916
2159
  // The Lean knobs and the per-object formal records are part of the readable status: without
1917
2160
  // them a `require`-mode run that keeps returning 未定论 would be undiagnosable from outside.
1918
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),
1919
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(', ') } }
1920
2166
  function formalReportText(){
1921
2167
  if(!formalOn()) return '- 未启用(`formalVerify` = off;可用 vibe_v4_set 切到 encourage / require)'
@@ -2003,9 +2249,30 @@ export function apply(ctx) {
2003
2249
  if(min===undefined) return n
2004
2250
  return n < min ? min : n
2005
2251
  }
2006
- function setParams(upd){ for(const k of Object.keys(upd||{})){ if(k in params){ const nv=normalizeParam(k, upd[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } saveSettings().catch(()=>{}); return {ok:true} }
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
+ }
2007
2273
  // ---- create / configure (no auto-start) + settings-file persistence ----
2008
- async function loadSettings(){ const s=await readJson('State/settings.json'); if(s&&typeof s==='object'){ for(const k of Object.keys(s)){ if(k in params){ const nv=normalizeParam(k, s[k]); params[k]= (typeof nv==='number') ? clampInt(k, nv) : nv } } } }
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 } } } }
2009
2276
  async function saveSettings(){ await writeJson('State/settings.json', params) }
2010
2277
  // Create/configure a project and set params/problem WITHOUT starting any resident.
2011
2278
  // The intended flow: vibe_v4_configure {project?, problem?, params?} → vibe_v4_start {}.
@@ -2041,10 +2308,27 @@ export function apply(ctx) {
2041
2308
  rememberAgent, forgetAgent,
2042
2309
  setPause, initAbort, postMessage, startMeeting, saveAll, broadcast, configure, loadSettings,
2043
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,
2044
2315
  // safety kick: drive one scheduler pass (used when an end handler errored, so an exceptional
2045
2316
  // turn can never leave the group with no end-event and no heartbeat to continue it)
2046
2317
  nudge:()=>scheduleNext().catch(()=>{}),
2047
- 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 : '' },
2048
2332
  useResident:(id)=>{ currentResident=id },
2049
2333
  publishProgress, recordProposition, recordMethod, recordSubproblem, listResidents, reportContext,
2050
2334
  proposeTask, claimTask, taskDone, listTasks,
@@ -2071,6 +2355,12 @@ export function apply(ctx) {
2071
2355
  // `vs` mirrors the live verification state ({targetId,targetType,targetOwner,stage,history,verdicts});
2072
2356
  // pass one explicitly to ask "what WOULD the voters read for this object right now?".
2073
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) },
2074
2364
  formalBlock:(target)=>formalPromptBlock(target),
2075
2365
  formalWorkLine:()=>formalWorkLine(),
2076
2366
  },
@@ -2081,9 +2371,15 @@ export function apply(ctx) {
2081
2371
  if(which==='brainstorm') return brainstormPrompt(r)
2082
2372
  if(which==='heartbeat') return heartbeatPrompt(r)
2083
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:[]}))
2084
2375
  if(which==='verify'){
2085
2376
  const target=idSafe(String((arg&&arg.target)||''))
2086
- return verifyPrompt(r,{targetId:target,targetType:guessTargetType(target),targetOwner:'',stage:String((arg&&arg.stage)||'independent'),history:{},verdicts:{}})
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:{}})
2087
2383
  }
2088
2384
  return normalPrompt(r)
2089
2385
  },
@@ -2117,7 +2413,7 @@ export function apply(ctx) {
2117
2413
  // The exact text a resident would receive. "成员读到的文字就是产品" (AUDIT-CHECKLIST §0.1): a host
2118
2414
  // (or an audit) must be able to READ the prompt, not just the tool return values, or a prompt
2119
2415
  // defect stays invisible. Pure builder calls — no side effects on the run.
2120
- registerTool('vibe_v4_prompts','Read the exact prompt text a resident would receive (which: brainstorm|normal|heartbeat|verify|coreRules). member = resident id; target/stage describe the object for `verify`. Prompt text is the product — this makes it auditable.',objParams({which:{type:'string',enum:['brainstorm','normal','heartbeat','verify','coreRules']},member:{type:'string'},target:{type:'string'},stage:{type:'string'}},['which']),async (s,a)=>{
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)=>{
2121
2417
  const which=String(a.which||'normal')
2122
2418
  if(which==='coreRules') return {ok:true,which,text:await s.promptApi.coreRules()}
2123
2419
  const text=await s.promptFor(which,String(a.member||'r-1'),a)
@@ -2135,7 +2431,7 @@ export function apply(ctx) {
2135
2431
  // MODE is dynamic — switching it changes the very next prompt), leanCommand/leanArgs select the
2136
2432
  // executable, leanTimeoutMs bounds one run. Invalid values fall back to the defaults and an
2137
2433
  // unknown mode degrades to 'off' (never to a STRONGER mode).
2138
- registerTool('vibe_v4_set','Set V4 parameters. model/provider override resident LLM route (empty=inherit main); toolAllow/toolDeny restrict resident tools (arrays of tool names); residentPersona adds a persona line; formalVerify: "off" (default, a true no-op) | "encourage" (residents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a unanimous true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record); leanCommand/leanArgs/leanTimeoutMs configure the toolchain.',objParams({residentCount:{type:'integer'},compactAfterRounds:{type:'integer'},compactThreshold:{type:'integer'},meetingKeepEvery:{type:'integer'},maxParallel:{type:'integer'},activityTimeoutMs:{type:'integer'},verdictMaxRounds:{type:'integer'},stallAutoMeetingMs:{type:'integer'},provider:{type:'string'},model:{type:'string'},residentPersona:{type:'string'},toolAllow:{type:'array',items:{type:'string'}},toolDeny:{type:'array',items:{type:'string'}},formalVerify:{type:'string',enum:['off','encourage','require']},leanCommand:{type:'string'},leanArgs:{type:'array',items:{type:'string'}},leanTimeoutMs:{type:'integer'}}),(s,a)=>{ s.setParams(a); return s.status() })
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 })
2139
2435
  // resident-facing tools: route to the CALLING resident (exec.agent.id === childId);
2140
2436
  // fall back to the last-woken resident when called by the host/assistant.
2141
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) })
@@ -2152,8 +2448,16 @@ export function apply(ctx) {
2152
2448
  registerTool('vibe_v4_list_tasks','(resident) List open tasks.',objParams({}),(s)=>({ok:true,tasks:s.listTasks()}))
2153
2449
  // context / compact (resident reports its context usage so the framework can /compact-equivalent)
2154
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))
2155
- registerTool('vibe_v4_claim_write','Reserved: shared-file write lock (framework-managed).',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
2156
- registerTool('vibe_v4_release_write','Reserved: shared-file write lock release.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
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'}))
2157
2461
 
2158
2462
  // ── Lean formal verification (docs/formal-verification.md §5) ─────────────
2159
2463
  // These three tools are registered UNCONDITIONALLY. Registration is STATIC (a mode-dependent
@@ -2180,7 +2484,10 @@ export function apply(ctx) {
2180
2484
  const line=String(inv&&inv.rawInput?inv.rawInput:'').trim(); const parts=line.split(/\s+/); const cmd=parts[0]||''; const rest=parts.slice(1)
2181
2485
  let r
2182
2486
  if(cmd==='configure') r=await s.configure({project:rest[0]||'', problem:parts.slice(2).join(' ')})
2183
- 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(' ')}:{})
2184
2491
  else if(cmd==='resume') r=await s.resume()
2185
2492
  else if(cmd==='pause') r=s.setPause()
2186
2493
  else if(cmd==='abort') r=await s.initAbort()
@@ -2191,9 +2498,32 @@ export function apply(ctx) {
2191
2498
  else if(cmd==='members') r={ok:true,residents:s.listResidents()}
2192
2499
  else if(cmd==='add') r=await s.addMember(rest.join(' '))
2193
2500
  else if(cmd==='remove') r=await s.removeMember(rest[0]||'')
2194
- else if(cmd==='set'){ const upd={}; for(const tok of rest){ const eq=tok.indexOf('='); if(eq>0){ const k=tok.slice(0,eq); const rv=tok.slice(eq+1); const n=Number(rv); upd[k]=Number.isFinite(n)?n:rv } } r=s.setParams(upd) }
2195
- else r={ok:false,usage:'configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set'}
2196
- return {kind:'success',text:JSON.stringify(r,null,2)}
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)}
2197
2527
  },
2198
2528
  }))
2199
2529
 
@@ -2202,13 +2532,36 @@ export function apply(ctx) {
2202
2532
  // registry (dsh-subagent:1231 dispose -> dsh-agent:508 store.delete ->
2203
2533
  // dsh-subagent:1241 settle/emit), so an end-time `agents.get(childId)` can never
2204
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.
2205
2548
  ctx.on('subagent/start', function(info){
2206
2549
  if(!info || !info.id) return
2207
- const sid=childOwner.get(info.id); const s=sid!==undefined?sessions.get(sid):undefined
2208
- if(!s) return
2209
2550
  let agent
2210
2551
  try { agent = agents.get(info.id) } catch(e){ agent = undefined }
2211
- 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 } }
2212
2565
  })
2213
2566
 
2214
2567
  ctx.on('subagent/end', function(info){