dsh-vibe-math 2.0.21 → 2.1.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.
@@ -1,1111 +1,1275 @@
1
- // Vibe Math V4 — persistent self-organizing collaborative research framework.
2
- // FACILITATOR (message bus / meetings / per-resident artifact libraries /
3
- // unanimous-consensus verification / context compaction proxy / resume / human
4
- // intervention). It NEVER assigns tasks: residents message & meet and decide all
5
- // task allocation among themselves. Consumes HOST subagents/agents/fs/tools/commands.
6
- // NOTE: must declare `inject` for every service read as a ctx property (the Guard
7
- // rejects undeclared dependencies), and must use the `timer` Service (ctx.timeout),
8
- // not global setTimeout/clearTimeout, which do not exist in the plugin runtime.
9
- export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
10
- export function apply(ctx) {
11
- const subagents = ctx.subagents
12
- const agents = ctx.agents
13
- const fs = ctx.fs
14
- const tools = ctx.tools
15
- const commands = ctx.commands
16
- const subprocess = ctx.get('subprocess')
17
- const sandboxPolicy = ctx.get('sandboxPolicy')
18
- const compaction = ctx.get('compaction') // @deepseek-ai/dsh-compaction (CompactionEngine); optional
19
-
20
- const sessions = new Map() // rootAgentId -> Session
21
- const childOwner = new Map() // childId -> rootAgentId
22
- const fileOwner = {} // process-level write lock
23
- const processEpoch = String(Date.now()) + '-' + Math.random().toString(36).slice(2, 8)
24
-
25
- function sessionIdOf(agent){ try { return (agent&&agent.id)?String(agent.id):undefined } catch(e){ return undefined } }
26
- function rootOf(agent){ try { let cur=agent; const seen=new Set(); while(cur){ const id=cur.id; if(seen.has(id)) return cur; seen.add(id); const p=(cur.session&&cur.session.header)?cur.session.header.parentSession:undefined; if(p===undefined) return cur; const par=agents.get(p); if(!par) return cur; cur=par } } catch(e){} return agent }
27
- function getSession(agent){ const root=rootOf(agent); const sid=sessionIdOf(root); if(sid===undefined) return undefined; let s=sessions.get(sid); if(!s){ s=makeSession(root,sid); sessions.set(sid,s) } return s }
28
-
29
- function makeSession(rootAgent, sessionId) {
30
- let currentProject = 'default'
31
- const DEFAULT_PARAMS = {
32
- residentCount: 4, compactThreshold: 66, compactAfterRounds: 8,
33
- maxParallel: 3, activityTimeoutMs: 120000, verdictMaxRounds: 3,
34
- meetingKeepEvery: 5, // 每积累 N 个新产物自动触发一次同步会议
35
- stallAutoMeetingMs: 360000, // 团队空闲且无新产物的"停滞阈值":超过则自动召集同步会议(分级保活 B)
36
- // model/provider inheritance: '' = the resident inherits the parent (main assistant)
37
- // route (provider + model). Set them to override the resident's LLM backend/model.
38
- provider: '', model: '', residentPersona: '',
39
- // tool permissions: an allow/deny list of tool names applied via startContinuable's
40
- // toolFilter (scoped tools.restrict() in the child). Empty = inherit all tools.
41
- // CAUTION: only set one of these; an empty allow:[] would deny EVERY tool.
42
- toolAllow: [], toolDeny: [],
43
- }
44
- let params = Object.assign({}, DEFAULT_PARAMS)
45
- let running = false, autoDone = false, phase = 'idle'
46
- let residents = new Map(), mailboxes = new Map(), taskboard = [], decisions = []
47
- let meetings = [], reports = [], activityLog = []
48
- let problemText = '', problemId = 'problem', runId = 'run-' + shortId()
49
- let meetingState = null, verifyState = null, pendingVerify = [], pendingMeeting = null // pendingVerify: FIFO queue (several residents may independently propose different objects before any verify runs — a single slot silently DROPPED all but the last proposal)
50
- let busy = new Set(), wakeKind = new Map(), currentResident = ''
51
- let finalizeLock = null // 'meeting'|'verify' while a consensus finalize is running (reentry guard)
52
- const verifiedRecently = new Map() // targetId -> timestamp when it was closed as Verified (dedup re-propose)
53
- let lastActivityAt = now(), lastProgressAt = now(), artifactCount = 0, lastSyncMeetingAt = 0, persistedEpoch = '', heartbeatDisposer = null
54
- const activityLogCap = 200
55
-
56
- // ---- utils ----
57
- function now(){ return Date.now() }
58
- 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 }
59
- function shortId(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<8;i++) s+=h[Math.floor(Math.random()*16)]; return s }
60
- function clamp01(v){ const n=Number(v); if(!Number.isFinite(n)) return 0.5; return Math.max(0,Math.min(1,n)) }
61
- function fmtTime(ts){ try { return new Date(ts||now()).toISOString().replace('T',' ').slice(0,19) } catch(e){ return String(ts||'') } }
62
- function cl(x){ return clamp01(Number(x)) }
63
- // contextPct is a PERCENT (0-100); never clamp to 0-1 or the compactThreshold
64
- // comparison (e.g. 66) becomes `1.0 >= 66` and never fires.
65
- function clPct(x){ const n=Number(x); if(!Number.isFinite(n)) return 0; return Math.max(0,Math.min(100,n)) }
66
- function textBlock(t){ return { type:'text', text:String(t) } }
67
- 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() }
68
- function logActivity(event,detail){ activityLog.push({at:now(),event,detail:String(detail||'')}); if(activityLog.length>activityLogCap) activityLog.shift() }
69
- function logDecision(kind,detail){ decisions.push({at:now(),kind,detail:String(detail||'')}) }
70
- // Record that the project made real progress (new artifact, meeting, verify, task, or a
71
- // resident speaking to the group). The stall auto-sync meeting (B) fires only when this has
72
- // NOT advanced for stallAutoMeetingMs, so a group that is genuinely producing keeps working
73
- // and only a truly stalled group gets a coordination meeting to reboot itself.
74
- function markProgress(){ lastProgressAt = now(); }
75
- // How long a meeting/verify may run without collecting a new input/verdict before we treat it as
76
- // deadlocked and abandon it. A meeting round's own signal window is activityTimeoutMs, so a
77
- // resident should speak within that; 2× that without ANY new input/verdict means the meeting/verify
78
- // is stuck and must not keep the whole group blocked.
79
- // Positive duration with a safe fallback: a NEGATIVE/NaN activityTimeoutMs or stallAutoMeetingMs
80
- // (misconfigured via vibe_v4_set) would otherwise make recoverStallMs negative → every meeting/
81
- // verify watchdog fires INSTANTLY (abandoning all consensus) and A-fill's idle window would never
82
- // elapse (waking everyone every pass). Guard every duration read with this.
83
- function posMs(v,def){ const n=Number(v); return (Number.isFinite(n)&&n>0)?n:(def||120000) }
84
- function recoverStallMs(){ return posMs(params.activityTimeoutMs,120000) * 2 }
85
- 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' }
86
- // Per-resident model/provider inheritance: when params.provider / params.model are set,
87
- // the resident uses that exact route; when left '' the resident inherits the parent's
88
- // (main assistant) route — the documented DSH default (resolveChildAgentOptions merges
89
- // requested over parent). No override is applied for empty values.
90
- function residentAgentOptions(){ const ao={}; if(params.provider) ao.provider=params.provider; if(params.model) ao.model=params.model; return ao }
91
- // Tool permission (scoped toolFilter). Only emit a filter when allow or deny has entries;
92
- // an empty object is rejected by DSH ("must declare allow and/or deny").
93
- function residentToolFilter(){
94
- const allow=Array.isArray(params.toolAllow)?params.toolAllow.filter(x=>String(x).trim()):[]
95
- const deny=Array.isArray(params.toolDeny)?params.toolDeny.filter(x=>String(x).trim()):[]
96
- if(allow.length===0 && deny.length===0) return undefined
97
- const f={}; if(allow.length) f.allow=allow; if(deny.length) f.deny=deny; return f
98
- }
99
- function makeSignal(ms){ return AbortSignal.timeout(posMs(ms,30000)) }
100
- function workspaceRoot(){ try { if(rootAgent&&rootAgent.session&&rootAgent.session.header&&rootAgent.session.header.cwd) return rootAgent.session.header.cwd } catch(e){} if(sandboxPolicy&&sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot; return '.' }
101
- function vibeRoot(){ return (workspaceRoot()+'/VibeMath').replace(/\\/g,'/') }
102
- function frameworkRoot(){ return vibeRoot()+'/Projects/'+currentProject }
103
- function slugify(s){ const t=String(s==null?'':s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g,'-').replace(/^-+|-+$/g,''); return t||'project' }
104
- // Object ids (verify targets, recorded cards) become FILE NAMES and DIRECTORY PATHS
105
- // (Verified/命题/<id>.md, Shared/debates/<id>.md, Propos/<r>/<id>.md, source-card scans).
106
- // A hostile/sloppy id containing path separators ('../../x') or Windows-forbidden chars would
107
- // escape the project tree. Keep every harmless character (incl. Chinese) and replace only
108
- // separators/control chars; strip leading/trailing dots/dashes so the name is never '.'/'..'.
109
- function idSafe(s){
110
- const t=String(s==null?'':s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/-{2,}/g,'-').replace(/^[.\-]+|[.\-]+$/g,'')
111
- return t||'id'
112
- }
113
- function getPolicy(){ try { if(sandboxPolicy&&rootAgent&&rootAgent.session) return sandboxPolicy.resolve({session:rootAgent.session}) } catch(e){} try { if(sandboxPolicy) return sandboxPolicy.resolve({}) } catch(e){} return undefined }
114
- function psQuote(p){ return "'"+String(p).replace(/'/g,"''")+"'" }
115
- async function runShell(script,cwd){ if(subprocess===undefined) return {ok:false,error:'no-subprocess'}; try { const h=subprocess.spawn({argv:['powershell','-NoProfile','-NonInteractive','-Command',script],cwd:cwd||workspaceRoot(),stdio:{stdin:'ignore',stdout:'inherit',stderr:'inherit'},graceMs:20000}); const o=await h.done; return {ok:o.exitCode===0,exitCode:o.exitCode} } catch(e){ return {ok:false,error:String((e&&e.message)||e)} } }
116
- async function fsTarget(rel){ return await fs.resolve(rel,{cwd:frameworkRoot()}) }
117
- async function readText(rel){ try { const t=await fsTarget(rel); if(await fs.stat(t)===undefined) return undefined; return await fs.readText(t) } catch(e){ return undefined } }
118
- async function writeText(rel,content){ try { const t=await fsTarget(rel); await fs.writeText(t,content,undefined,undefined,getPolicy()); return true } catch(e){ return false } }
119
- // State files (taskboard/residents/session/mailboxes/decisions) are written by MANY concurrent
120
- // flows (parallel resident turns + end handlers + tools). Two near-simultaneous writers of the
121
- // SAME file each stringified their snapshot BEFORE their fs.writeText landed, so the writer with
122
- // the OLDER snapshot could land LAST and silently erase the other's entry (e.g. two residents
123
- // proposing tasks in the same tick → one task vanished from taskboard.json until the next save).
124
- // Fix: serialize writes PER FILE, and defer JSON.stringify until the write actually runs (so the
125
- // snapshot always reflects the newest in-memory state at execution time — late writers win with
126
- // the FULL state, never with a stale subset).
127
- const jsonQueues = new Map() // rel -> tail promise (per-session file write chain)
128
- function writeJson(rel,obj){
129
- const key='j:'+rel
130
- const prev=jsonQueues.get(key)||Promise.resolve(true)
131
- const run=prev.catch(()=>{}).then(async ()=>{ try { const t=await fsTarget(rel); await fs.writeText(t,JSON.stringify(obj,null,2),undefined,undefined,getPolicy()); return true } catch(e){ return false } })
132
- jsonQueues.set(key,run.catch(()=>{}))
133
- return run
134
- }
135
- async function readJson(rel){ const t=await readText(rel); if(t===undefined||t==='') return undefined; try { return JSON.parse(t) } catch(e){ return undefined } }
136
- async function ensureDirs(){ const base=frameworkRoot(); const dirs=['Problems','Progress','Propos','Methods','Subproblems','Shared/meetings','Shared/debates','Verified/命题','Verified/问题','Reliable','Notes','State']; return await runShell('New-Item -Force -ItemType Directory -Path '+[vibeRoot()+'/Projects'].concat(dirs.map(d=>base+'/'+d)).map(psQuote).join(',')+' | Out-Null') }
137
- async function readTextAbs(path){ try { const t=await fs.resolve(path); const s=await fs.stat(t); if(s===undefined) return undefined; return await fs.readText(t) } catch(e){ return undefined } }
138
- 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 } }
139
- async function readCurrentProject(){ try { const t=await readTextAbs(vibeRoot()+'/.current'); if(t) return String(t).trim() } catch(e){} return currentProject }
140
- async function writeCurrentProject(){ try { await writeTextAbs(vibeRoot()+'/.current', currentProject) } catch(e){} }
141
- function tryJson(s){ try { return JSON.parse(s) } catch(e){ return undefined } }
142
- function parseReply(text){
143
- let obj; const fence=/```(?:json)?[ \t]*([\s\S]*?)```/gi; let m
144
- while((m=fence.exec(text))!==null){ const o=tryJson(m[1].trim()); if(o&&typeof o==='object'&&!Array.isArray(o)) obj=o }
145
- if(!obj){ const w=tryJson(text.trim()); if(w&&typeof w==='object'&&!Array.isArray(w)) obj=w }
146
- return obj||{}
147
- }
148
-
149
- // ---- persistence ----
150
- async function saveAll(){
151
- await writeJson('State/residents.json', Object.fromEntries(residents))
152
- await writeJson('State/mailboxes.json', Object.fromEntries(mailboxes))
153
- await writeJson('State/taskboard.json', taskboard)
154
- await writeJson('State/decisions.json', decisions)
155
- await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount})
156
- }
157
- async function loadAll(){
158
- 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 }
159
- const rm=await readJson('State/residents.json'); if(rm&&typeof rm==='object') residents=new Map(Object.entries(rm))
160
- const mb=await readJson('State/mailboxes.json'); if(mb&&typeof mb==='object') mailboxes=new Map(Object.entries(mb))
161
- const tb=await readJson('State/taskboard.json'); if(Array.isArray(tb)) taskboard=tb
162
- const dc=await readJson('State/decisions.json'); if(Array.isArray(dc)) decisions=dc
163
- }
164
-
165
- // ---- resident prompts ----
166
- function banner(){ const o=[]; for(const [id,r] of residents) o.push('- '+id+'「'+(r.direction||'(未定)')+'」'+r.status+'·轮'+r.rounds); return o.join('\n') }
167
- 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' }
168
- function residentLibraries(){
169
- const base=frameworkRoot()
170
- return '你的资料库根目录:'+base+'/\n'
171
- +' Progress/<你>/progress.md —— 你的研究日志(叙述,可追加。主要内容是尝试过的各方法、路线、历程、进度,当前研究进展/进度、将来的计划与打算,及各路线、过程中遇到的障碍及其原因,对各路线、方法的看法、可行性评估,自己研究过程中的一些有价值看法、感想、猜想、理解。以及其它各种你认为有价值的值得记录的事物、经验、方法/想法、创新等都可进行记录)。\n'
172
- +' Propos/<你>/<id>.md —— 你的命题/引理。格式:\n'
173
- +' - ID: p-<id>; - 状态: 未定论; - 概率: <0-1>; - 价值程度: <0-1>; - 动机用途计划: <为何重要/打算怎么用>\n'
174
- +' 然后 ## 陈述 <陈述>;## 证明尝试;## 证伪尝试。\n'
175
- +' Methods/<你>/<id>.md —— 你的理论/方法/工具。格式:- ID: m-<id>; - 状态: 经验; - 可信断言: []; - 价值程度: <0-1>; - 动机用途计划: ...;然后 ## 核心内容;## 定义与记号;## 应用记录;## 改进历史。\n'
176
- +' Subproblems/<你>/<id>.md —— 你的子问题。格式:- ID: s-<id>; - 状态: 求解中; - 价值程度: <0-1>; - 动机用途计划: ...;然后 ## 陈述;## 进度。\n'
177
- }
178
- function toolList(){
179
- return 'vibe_v4_send_message {to, content} —— 给某常驻发消息(to=all 广播)。\n'
180
- +'vibe_v4_meeting {agenda} —— 发起/参与会议(框架会把各常驻的实际 input 转给其他人,让大家看到并讨论/辩论)。\n'
181
- +'vibe_v4_propose_task/claim_task/task_done/list_tasks —— 共享任务板(提议/认领/完成/查看;任务板是你们协调分工的载体)。\n'
182
- +'vibe_v4_publish_progress/record_proposition/record_method/record_subproblem —— 便捷记录器(可选;推荐直接用 fs 写自己的文件)。\n'
183
- +'vibe_v4_read_progress {id} —— 只读某常驻的进展。\n'
184
- +'vibe_v4_list_residents / vibe_v4_list_tasks —— 查看团队组成 / 开放任务。\n'
185
- +'vibe_v4_report_context {pct} —— 上报上下文占比(框架据此压缩你的上下文)。\n'
186
- +'fs (read/write/list) —— 读取任意文件;写入你自己的文件(推荐直接用 fs 直接写自己的 md)。\n'
187
- }
188
- // A shared, complete context block so a resident always knows the situation: mission,
189
- // work model, what it can do, which files it owns (+ formats), what others' files are,
190
- // and that it may READ anyone and WRITE its own directly. level 'full' = initial brief.
191
- function contextBrief(r, level){
192
- const s=[]
193
- s.push('## 背景 —— 你是常驻研究团队的一员')
194
- s.push('You are resident researcher '+r.rId+'(常驻研究者 '+r.rId+';共 '+params.residentCount+' 位常驻),正在协作解决:')
195
- s.push(problemText)
196
- s.push('')
197
- s.push('这像一个**真实的学术小组**:没有中央调度器、没有外部派活——你们自己通过 **互相发消息 + 开会讨论** 来决定一切:谁做什么、怎么分工、验证什么、何时停止。你的 Round 决定你这一轮做什么;团队的优先级与分工由大家的讨论涌现。')
198
- s.push('')
199
- s.push('### 工作模式(会发生什么)')
200
- s.push('1. 每人有一份持久、全组可见的专属资料库(见下)。')
201
- s.push('2. 你们自由发消息、开会;**会议会把每个人实际说的话(input)转给其他人**,让你看得到、能回复、能讨论、能辩论。')
202
- s.push('3. 你独立研究,并**直接用 fs 写入你自己的文件**(按格式),供全组阅读。')
203
- s.push('4. 任何"已确立"的东西须**全组一致**验证(全真或全假)才作数;否则只是带概率的工作估计。')
204
- s.push('5. 只有**全组在会议上一致认为原问题已解决**,run 才停止。')
205
- s.push('')
206
- s.push('### 你负责的文件(你只写自己的;但可读任何人的)')
207
- s.push(residentLibraries())
208
- s.push('其他人把结论/进展写进他们的目录,你就能读到。**你应主动读别人的库**,对齐事实、彼此衔接、避免重复劳动。')
209
- if(level!=='full'){ s.push('(格式见你最初的说明;直接用 fs 写自己的文件即可。)') }
210
- s.push('')
211
- s.push('### 可用工具')
212
- s.push(toolList())
213
- s.push('')
214
- if(level==='full'){
215
- s.push('### 可自主发明理论/工具(鼓励,但不强迫)')
216
- s.push('请注意:你可以(但**不强迫**,完全视实际需要而定)尝试自主构建新的理论框架或工具——例如对某种系统做抽象化、一般化,抽离/推广出更一般的结构或理论框架;然后不断完善这个理论框架,在该框架下推得各种定理、性质、结论,以利于该框架下问题的解决。这就像为解决方程问题发明了群论、为分析需要建立了泛函分析框架——它比单纯解决当前问题更有学术价值,因为你直接得到了一类更普遍的方法/理论体系。')
217
- s.push('若你发明了这样的理论/工具,请**阐明它对原问题的用处、价值**;后续可根据需要不断**完善、一般化、推广**它。把这类成果记入你的 Methods/<你>/ 库。')
218
- s.push('')
219
- }
220
- s.push('### 规则')
221
- s.push('- 只有 Verified/(或卡片标"已验证·真/假")算已确立;其余都是你的实验性工作,请区分"猜想/已知"。')
222
- s.push('- 验证必须**全组一致**(全真或全假);你只信全票结果。未全票的对象留在库里带概率。')
223
- s.push('- 你自己决定做什么,但**优先级/分工由团队讨论决定**,不是固定模式。若你认为问题已解决或接近解决,请**发起会议**让团队表决。')
224
- s.push('- 退出时**只**输出一个 JSON 对象(放在 ```json 代码围栏内;围栏外不要有文字)。')
225
- return s.join('\n')
226
- }
227
- // A SHORT core-rules recap, re-injected ONLY right after a compaction so the resident
228
- // never loses the ground rules (they are told fully once at brainstorm, but a /compact
229
- // could blank them).
230
- function coreRulesBrief(){
231
- const base=frameworkRoot()
232
- return '[核心规则重申] 只有 Verified/(及标记"已验证·真/假")算已确立;验证须全组一致(全真或全假)才作数,否则留库附平均概率;你只写自己的库('+base+'/ 的 Progress/<你>/、Propos/<你>/、Methods/<你>/、Subproblems/<你>/),可只读任何人的库;任务分工由团队讨论决定;退出只输出一个 JSON 对象。'
233
- }
234
- function brainstormPrompt(r){
235
- return (params.residentPersona?params.residentPersona+'\n':'')
236
- +contextBrief(r,'full')+'\n'
237
- +(r.direction?('\n\n你被建议的初始方向(可自行调整/细化):\n'+r.direction+'\n'):'')
238
- +'## 这是你的第一轮:独立头脑风暴\n'
239
- +'独立地想清楚:你对这个问题的洞察 / 解决方向 / 关键子问题 / 可能的引理 / 粗略计划。你还未见到其他人,先独立产出。\n'
240
- +'把有价值的产物**直接用 fs 写进你自己的文件**(按上面格式),并在 summary 里概述你的切入方向与初步结论(标注哪些是猜想、哪些凭你已确证)。\n'
241
- +'Reply with ONLY a JSON object:\n'
242
- +'{"summary":"<your insight / direction / rough plan, one tight paragraph>","solved":false}'
243
- }
244
- async function normalPrompt(r){
245
- return (params.residentPersona?params.residentPersona+'\n':'')
246
- +'Resident researcher '+r.rId+' — 第 '+r.rounds+' 轮。一切由你和团队讨论决定。动手前先**读别人的库**对齐事实、避免重复;把新进展/结论**直接用 fs 写进你自己的文件**;想对团队说的话放 "input"(会转给其他常驻)。\n'
247
- +'\n团队成员:\n'+banner()+'\n'
248
- +'New items:\n'+ (await inboxText(r.rId)) +'\n'
249
- +'Reply with ONLY a JSON object:\n'
250
- +'{"summary":"<what you did / decided this round, 1-3 sentences>","input":"<optional: a message to the whole team, or \\"\\">","solved":false,"propose_verify":"<id|null>","propose_meeting":"<agenda|null>","propose_task":"<task title|null>","task_desc":"<optional: why this task matters / what it covers|null>","claim_task":"<task id|null>","task_done":"<task id|null>","contextPct":40}'
251
- }
252
- function meetingPrompt(r, st){
253
- const prior=Object.entries(st.inputs).filter(([k])=>k!==r.rId).map(([k,iv])=>' ['+k+'] '+String(iv.input||iv.summary||'')).join('\n')
254
- return (params.residentPersona?params.residentPersona+'\n':'')
255
- +'Resident '+r.rId+' — 团队会议进行中。 A meeting is in progress (agenda: '+st.agenda+').'
256
- +(st.type==='verify'?('\n团队正在验证对象:'+st.targetId+'('+st.targetType+',提出者 '+st.targetOwner+')。请先看他人意见,再给独立判断。'):'')
257
- +'\n这是一场真实讨论:下面已有人发言(转给你),请先看,然后**加入讨论/补充/反驳/表决**。'
258
- +(prior?('\n\n### 已有发言(他人 input,已转发给你)\n'+prior):'\n(目前还没人发言,你先说。)')
259
- +'\n\n你可以:提议任务(propose_task)、认领开放任务(claim_task)、提议验证对象(propose_verify)、或对"原问题是否已解决"表决(voteSolved)。请把**你的实际发言**写进 "input"。'
260
- +'\nReply with ONLY a JSON object:\n'
261
- +'{"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}'
262
- }
263
- function verifyPrompt(r, vs){
264
- // In a DEBATE round, show the PREVIOUS round's opinions (kept in vs.history) so the resident can
265
- // see others' stances and give a fresh independent judgement; in the first (independent) round no
266
- // others' opinions exist yet. vs.verdicts only ever holds the CURRENT round's votes.
267
- const src = (vs.stage==='debate' && vs.history && Object.keys(vs.history).length>0) ? vs.history : (vs.stage==='debate' ? vs.verdicts : {})
268
- const others=Object.entries(src).map(([k,v])=>'- '+k+': 正确概率 '+String(v.prob!=null?Number(v.prob).toFixed(2):0.5)+' → '+v.reason).join('\n')
269
- return (params.residentPersona?params.residentPersona+'\n':'')
270
- +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+vs.targetType+',提出者 '+vs.targetOwner+')。\n'
271
- +'请给出你对「该对象为真」的**正确概率 `verdict`**,仅一个 0–1 数值:**1 = 绝对为真,0 = 绝对为假,0.5 = 完全不确定,其余为介于其间的程度**(不要给 TRUE/FALSE,就给一个数值)。\n'
272
- +'判定规则:仅当**全体常驻一致给 1(都认为是真)或一致给 0(都认为是假)**,才按「真/假」写入 Verified/;否则**只作为概率数值(一种程度)保留在库中**,附全组平均正确概率,不写成真/假。\n'
273
- +'请给出你**诚实独立的判断**'
274
- +(vs.stage==='debate'?',并参考他人意见:\n':'。\n')
275
- +(vs.stage==='debate'&&others?('### 他人上一轮意见(已转发给你)\n'+others+'\n'):'')
276
- +'\nReply with ONLY a JSON object:\n'
277
- +'{"vote":{"verdict":0.9,"reason":"<your logic>"}}'
278
- }
279
-
280
- // ---- resident lifecycle ----
281
- let residentSeq = 0
282
- 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} }
283
- async function spawnResident(r){
284
- const ao=residentAgentOptions(); const tf=residentToolFilter()
285
- const started=await subagents.startContinuable({provider:pickProvider(),label:r.rId,request:{prompt:[textBlock(brainstormPrompt(r))],parent:rootAgent,agentOptions:ao,...(tf?{toolFilter:tf}:{})},signal:makeSignal(params.activityTimeoutMs||60000)})
286
- r.childId=started.childId; r.status='brainstorm'; r.lastActiveAt=now()
287
- childOwner.set(started.childId,sessionId); busy.add(r.rId); wakeKind.set(r.rId,'normal'); currentResident=r.rId
288
- residents.set(r.rId,r); await saveAll(); logActivity('spawn',r.rId+' ('+(r.direction||'brainstorm')+')')
289
- }
290
- async function wakeResident(r, promptText, kind){
291
- if(!r || !r.childId) return false // a removed resident must never be woken (else r.childId would crash)
292
- clearHeartbeat()
293
- busy.add(r.rId); wakeKind.set(r.rId,kind||'normal'); currentResident=r.rId
294
- r.lastActiveAt=now(); r.rounds+=1; r.roundsSinceCompact+=1
295
- // Context compaction has TWO distinct needs. Confusing them is the bug that made
296
- // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the start of nearly every prompt:
297
- // (a) r.needCompact (set by a REAL /compact) => the resident's rules may be blurred, so
298
- // re-anchor the short core rules on the next wake of ANY kind, then CLEAR the flag.
299
- // (Short recap only; no self-summary directive — the real compact already condensed.)
300
- // (b) soft-compact trigger (contextPct>=threshold OR roundsSinceCompact>=afterRounds) =>
301
- // the resident's context genuinely grew; ask it to self-summary. ONLY on a normal
302
- // research round (kind==='normal'): a meeting/verify reply has no contextPct/compacted
303
- // fields, so a directive injected there is never acknowledged and would repeat forever.
304
- let prompt = promptText
305
- const isNormal = (kind||'normal')==='normal'
306
- const wantSoft = isNormal && (Number(r.contextPct)>=Number(params.compactThreshold) || Number(r.roundsSinceCompact)>=Number(params.compactAfterRounds))
307
- const wantReanchor = r.needCompact
308
- if(wantSoft){
309
- prompt = coreRulesBrief() + '\n' +
310
- '[CONTEXT COMPACT — your conversation is at/near the limit. Do NOT re-derive history.\n' +
311
- 'Condense your current working state into ONE tight self-summary (findings so far, active direction, key artifacts you recorded, next concrete steps, open questions), then answer this round in the normal JSON format as usual.\n' +
312
- 'Set "contextPct": 15 (your post-compact usage) and "compacted": true in the reply so the framework records the condensed seed.]\n\n' + promptText
313
- r.needCompact = true
314
- } else if(wantReanchor){
315
- prompt = coreRulesBrief() + '\n' + prompt
316
- r.needCompact = false
317
- }
318
- // The DSH continuable-wake API is subagents.sendMessage(sender, targetId, content, {signal}),
319
- // NOT subagents.followup (which is only Agent.followup, and does NOT exist on the subagents
320
- // service). Using a non-existent method threw TypeError and made EVERY wake fail silently →
321
- // the group went idle forever. Prefer sendMessage; fall back to a legacy followup if a host
322
- // still exposes it (older deployments), so this works across versions.
323
- try {
324
- if(typeof subagents.sendMessage==='function'){
325
- await subagents.sendMessage(rootAgent, r.childId, [textBlock(prompt)], {signal: makeSignal(params.activityTimeoutMs||60000)})
326
- } else if(typeof subagents.followup==='function'){
327
- await subagents.followup(rootAgent, r.childId, [textBlock(prompt)], {source:{kind:'user'},signal:makeSignal(params.activityTimeoutMs||60000)})
328
- } else {
329
- throw new Error('no subagent continuation API (need sendMessage or followup)')
330
- }
331
- return true
332
- }
333
- catch(e){ console.error('vibe-v4 wake '+r.rId+' failed: '+String((e&&e.message)||e)); busy.delete(r.rId); return false }
334
- }
335
- function byChild(childId){ for(const [,r] of residents){ if(r.childId===childId) return r } return undefined }
336
-
337
- // ---- artifact writers (resident-facing) ----
338
- async function publishProgress(rId,content){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const rel='Progress/'+rId+'/progress.md'; const prev=(await readText(rel))||''; await writeText(rel, prev+'\n### '+fmtTime()+'|'+rId+'\n'+String(content||'')+'\n'); return {ok:true} }
339
- 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'} }
340
- 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'} }
341
- 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'} }
342
- // auto-sync meeting: every meetingKeepEvery new artifacts, convene a general coordination meeting
343
- function bumpArtifacts(){ artifactCount+=1; markProgress(); if(!meetingState && !verifyState && !pendingMeeting && Number(params.meetingKeepEvery)>0 && artifactCount % Number(params.meetingKeepEvery)===0){ startMeeting('定期同步:分工/进展/是否需要验证','general',null).catch(()=>{}) } }
344
- 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):''})) }
345
- // identify WHICH resident is calling a resident-facing tool: match the caller's
346
- // subagent id to a resident's childId. Fall back to the last-woken resident when
347
- // the caller is the host/assistant (or an unknown agent). This makes per-resident
348
- // libraries correct under concurrency (e.g. all brainstorm residents in flight).
349
- function residentOfAgent(agent){ try { const id=agent&&agent.id?String(agent.id):''; if(!id) return ''; for(const [,r] of residents){ if(r.childId===id) return r.rId } } catch(e){} return '' }
350
-
351
- // ---- task board (residents propose / claim / complete; framework wakes the claimer) ----
352
- async function writeTaskboard(){ const lines=['# 任务板','']; for(const t of taskboard){ lines.push('- ['+t.status+'] '+t.title+(t.claimer?('(认领:'+t.claimer+')'):'')+(t.proposer?('(提议:'+t.proposer+')'):'')+(t.description?(':'+t.description):'')) } await writeText('Shared/taskboard.md',lines.join('\n')) }
353
- async function proposeTask(title,description,proposer){ const id='t-'+shortId(); taskboard.push({id,title:String(title),description:String(description||''),status:'open',proposer:proposer||'',claimer:'',source:''}); await saveTaskboard(); markProgress(); logActivity('task','proposed '+id+'「'+title+'」'); return {ok:true,id} }
354
- async function claimTask(id,claimer){ const t=taskboard.find(x=>x.id===id); if(!t) return {ok:false,message:'no such task'}; if(t.status!=='open') return {ok:false,message:'task already '+t.status}; t.status='claimed'; t.claimer=claimer; await saveTaskboard(); markProgress(); logActivity('task',claimer+' claimed '+id);
355
- // wake the claimer to work on it (framework moves the task, resident decides how).
356
- // NOT while paused/stopped: a paused run must not start new work — the claim is recorded on the
357
- // board and the resident (who claimed it) picks it up again after resume.
358
- 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() }
359
- return {ok:true} }
360
- 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} }
361
- async function saveTaskboard(){ await writeJson('State/taskboard.json',taskboard); await writeTaskboard() }
362
- function listTasks(){ return taskboard.filter(t=>t.status!=='done') }
363
- 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} }
364
- // Apply context/compact bookkeeping from a resident's reply, so the flag can clear even when
365
- // the reply came through a meeting/verify branch (defensive) as well as the normal branch.
366
- function postmark(r, parsed){
367
- const cp=Number(parsed.contextPct); if(Number.isFinite(cp)) r.contextPct=clPct(cp) // tolerate numeric strings ("40")
368
- if(parsed.compacted===true || (r.needCompact && parsed.summary)){
369
- r.contextSeed=String(parsed.summary||r.contextSeed||'')
370
- r.contextPct=Math.min(r.contextPct||15,25)
371
- r.roundsSinceCompact=0
372
- r.needCompact=false
373
- logActivity('compact', r.rId+' consolidated context')
374
- }
375
- }
376
-
377
- // ---- messaging ----
378
- async function postMessage(from,to,content){
379
- const r=residents.get(to); if(!r) return {ok:false,message:'no such resident'}
380
- if(!busy.has(to)){
381
- currentResident=r.rId
382
- await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
383
- await saveAll(); markProgress(); logActivity('message',from+'→'+to); return {ok:true}
384
- }
385
- 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}
386
- }
387
- async function broadcast(content, from){
388
- let n=0
389
- for(const [,r] of residents){ if(from && r.rId===from) continue; const res=await postMessage(from||'facilitator',r.rId,content); if(res&&res.ok) n++ }
390
- logActivity('broadcast','to '+n+' resident(s)'); await saveAll(); return {ok:true,message:'broadcast to '+n+' resident(s)'}
391
- }
392
- // group conversation relay: when a resident "speaks" (input in its round), forward its
393
- // words to every other resident's inbox so the whole group can see & react — a real group chat.
394
- async function relayToGroup(from, content){
395
- const text=String(content||'').trim()
396
- if(!text) return
397
- for(const [,r] of residents){
398
- if(r.rId===from) continue
399
- const mb=mailboxes.get(r.rId)||[]; mb.push({from,at:now(),content:'[群聊] '+text}); mailboxes.set(r.rId,mb)
400
- }
401
- logActivity('relay',from+' → 团队: '+text.slice(0,60)); markProgress(); await saveAll()
402
- }
403
-
404
- // ---- meeting ----
405
- async function startMeeting(agenda,type,targetId){
406
- if(meetingState) return {ok:false,message:'meeting already in progress'}
407
- // Park-and-resume (never lose a coordination request, never create a zombie): while a
408
- // verification holds the floor, while the group is still brainstorming (members are busy in
409
- // their first rounds — a meeting started there could not be serviced and the old code let its
410
- // stall watchdog silently ABANDON it minutes later), or while the run is paused, the meeting
411
- // request is parked in pendingMeeting (FIRST request wins) and starts as soon as the floor is
412
- // free. A never-started session (no residents to talk) and a concluded run (autoDone) refuse
413
- // instead — convening there previously created a meeting nobody could ever be woken into.
414
- if(!running || autoDone || phase==='brainstorm' || verifyState || pendingVerify.length>0){
415
- if(autoDone || (!running && residents.size===0)) return {ok:false,message:'run is not active (use vibe_v4_start or vibe_v4_resume first)'}
416
- if(!pendingMeeting) pendingMeeting = { agenda, type:type||'general', targetId:targetId||null }
417
- return {ok:true,deferred:true,during: phase==='brainstorm'?'brainstorm':(!running?'paused':'verify')}
418
- }
419
- clearHeartbeat()
420
- const ids=Array.from(residents.keys())
421
- // Rotate the per-meeting speaking order so the SAME resident isn't always the "first speaker
422
- // who sees no one else's contribution"; a real discussion lets each member lead sometimes.
423
- const rot=Math.floor(Math.random()*Math.max(1,ids.length))
424
- const order=ids.slice(rot).concat(ids.slice(0,rot))
425
- meetingState={id:'mt-'+shortId(),agenda,type:type||'general',targetId:targetId||null,round:0,asked:[],inputs:{},transcript:[],order,at:now(),lastInputAt:now()}
426
- markProgress();
427
- logActivity('meeting','start: '+agenda); await saveAll(); await scheduleNext(); return {ok:true,id:meetingState.id}
428
- }
429
- async function continueMeetingRound(){
430
- if(!meetingState) return
431
- const st=meetingState
432
- // STUCK watchdog: a meeting that has been active but collected NO new input for a long while
433
- // is deadlocked (e.g. an in-flight/hung resident, a run of failed wakes). Abandoning it returns
434
- // the group to normal self-organization (A heartbeat / B auto-sync can then re-drive) instead of
435
- // permanently blocking the whole group behind a broken meeting.
436
- if(now()-(st.lastInputAt||st.at||now())>=recoverStallMs()){
437
- meetingState=null; wakeKind.clear(); logActivity('meeting','abandoned (stuck: no resident spoke)')
438
- await saveAll(); await scheduleNext(); return
439
- }
440
- const ids=Array.from(residents.keys()); const allSpoke=ids.every(id=>st.inputs[id]!==undefined)
441
- if(allSpoke){ await finalizeMeeting(); return }
442
- // only wake IDLE un-spoken residents (rotated order); in-flight ones re-trigger this on end.
443
- // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-spoken resident into a normal
444
- // mail round would delay the meeting and can starve the consensus past its watchdog if the mail
445
- // backlog is large. Mail is delivered on scheduleNext passes when no consensus is in progress.
446
- const order=st.order||ids
447
- const id=order.find(x=>st.inputs[x]===undefined && !busy.has(x))
448
- if(!id){ armHeartbeat(); return } // no idle un-spoken resident (a busy/hung one): re-check later
449
- const r=residents.get(id)
450
- const ok = await wakeResident(r, meetingPrompt(r,st), 'meeting'); await saveAll()
451
- if(!ok) armHeartbeat() // a failed meeting wake must NOT silently hang the meeting
452
- }
453
- async function finalizeMeeting(){
454
- if(finalizeLock) return // reentry guard: two onResidentEnd may both see allSpoke → only finalize once
455
- finalizeLock='meeting'
456
- let doSchedule=false
457
- try {
458
- const st=meetingState
459
- const ids=Array.from(residents.keys()); const allSpoke=ids.length>0 && ids.every(id=>st.inputs[id]!==undefined)
460
- const lines=['# 会议 '+st.id+'|'+fmtTime(),'','**议程**:'+st.agenda,'']
461
- for(const [id,iv] of Object.entries(st.inputs)){ lines.push('### '+id); lines.push(iv.input||''); lines.push('') }
462
- await writeText('Shared/meetings/'+st.id+'.md', lines.join('\n'))
463
- // the full transcript lives on disk (Shared/meetings/<id>.md); the State array keeps a small
464
- // index (id/agenda/at) so session.json does not carry a second copy of every transcript and
465
- // rewrite it on EVERY saveAll during long runs (reports below are capped for the same reason).
466
- meetings.push({id:st.id,agenda:st.agenda,at:now()}); if(meetings.length>200) meetings.shift()
467
- logDecision('meeting',st.agenda)
468
- // handle what the meeting produced: task proposals/claims, verify targets, stop vote
469
- for(const [id,iv] of Object.entries(st.inputs)){
470
- if(iv.propose_task) await proposeTask(iv.propose_task, iv.task_desc||'', id)
471
- if(iv.claim_task) await claimTask(iv.claim_task, id)
472
- if(iv.propose_verify) maybeQueueVerify(iv.propose_verify, id)
473
- }
474
- const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
475
- const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
476
- logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
477
- if(allSolved){
478
- running=false; autoDone=true; phase='done'; clearHeartbeat()
479
- // symmetric with initAbort: the STOP path must also release the coordination state, else
480
- // status keeps reporting a phantom in-progress meeting (and stale wake kinds) forever.
481
- meetingState=null; wakeKind.clear(); pendingMeeting=null; verifyState=null; pendingVerify=[]
482
- logActivity('stop','all residents agree: problem solved'); await saveAll(); return
483
- }
484
- meetingState=null; wakeKind.clear(); await saveAll()
485
- doSchedule=true
486
- } finally { finalizeLock=null } // release BEFORE scheduling so a chained verify/meeting is not swallowed
487
- if(doSchedule) await scheduleNext()
488
- }
489
-
490
- // ---- verification (unanimous) ----
491
- async function beginVerify(pv){
492
- clearHeartbeat()
493
- // Re-check dedup at ACTUAL start, not just at propose time: a resident may propose object X while
494
- // X is already being verified (it does not know). That proposal sits in the pendingVerify queue;
495
- // when the current X verify closes, beginVerify would run X end-to-end a SECOND time (test9:
496
- // p-r3-04 was verified twice back-to-back). Drop it if X was closed within the dedup window.
497
- // (pv was already popped from the FIFO queue by scheduleNext — nothing else to clear here.)
498
- const tgt=pv&&pv.targetId?String(pv.targetId):''
499
- if(tgt){
500
- const last=verifiedRecently.get(tgt)
501
- if(last!==undefined && (now()-last) < recoverStallMs()){
502
- logActivity('verify',tgt+' queued verify dropped at start (just verified at '+fmtTime(last)+')')
503
- await saveAll(); await scheduleNext(); return
504
- }
505
- }
506
- verifyState={targetId:pv.targetId,targetType:pv.targetType,targetOwner:pv.proposer||'',stage:'independent',round:0,asked:[],verdicts:{},history:{},transcript:[],at:now(),lastVerdictAt:now()}
507
- markProgress();
508
- logActivity('verify','debate begin: '+pv.targetId+' ('+pv.targetType+')'); await saveAll(); await scheduleNext()
509
- }
510
- async function continueVerifyRound(){
511
- if(!verifyState) return
512
- const vs=verifyState
513
- // STUCK watchdog: a verification that has been active but collected no new verdict for a long
514
- // while is deadlocked (e.g. an in-flight/hung resident). Abandoning it keeps the object as an
515
- // unverified probability (no unanimous consensus was reachable) and returns the group to normal
516
- // scheduling instead of blocking the whole team behind a broken verification.
517
- if(now()-(vs.lastVerdictAt||vs.at||now())>=recoverStallMs()){
518
- verifyState=null; wakeKind.clear(); logActivity('verify',vs.targetId+' abandoned (stuck: no unanimous verdict reachable)')
519
- await saveAll(); await scheduleNext(); return
520
- }
521
- const ids=Array.from(residents.keys()); const allVoted=ids.every(id=>vs.verdicts[id]!==undefined)
522
- if(allVoted){ await finalizeVerify(); return }
523
- // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-voted resident into a normal
524
- // mail round would delay its verdict and can starve the verify past its watchdog when the backlog
525
- // is large. Mail is delivered on scheduleNext passes when no consensus is in progress.
526
- const id=ids.find(x=>vs.verdicts[x]===undefined && !busy.has(x))
527
- if(!id){ armHeartbeat(); return } // no idle un-voted resident (a busy/hung one): re-check later
528
- const r=residents.get(id)
529
- const ok = await wakeResident(r, verifyPrompt(r,vs), vs.stage==='independent'?'verif-ind':'verif-deb'); await saveAll()
530
- if(!ok) armHeartbeat() // a failed verify wake must NOT silently hang the verification
531
- }
532
- async function finalizeVerify(){
533
- if(finalizeLock) return // reentry guard (two onResidentEnd may both see allVoted)
534
- finalizeLock='verify'
535
- let doSchedule=false
536
- try {
537
- const vs=verifyState; const expected=Array.from(residents.keys()).length
538
- const allVoted = expected>0 && Object.keys(vs.verdicts).length>=expected
539
- const vals=Object.values(vs.verdicts)
540
- // verdict is a PURE 0-1 probability; only ALL=1 (true) or ALL=0 (false) is a binary verdict.
541
- const allTrue = allVoted && vals.every(x=>Number(x.prob)===1)
542
- const allFalse = allVoted && vals.every(x=>Number(x.prob)===0)
543
- if(allTrue||allFalse){ await closeVerify(vs,allTrue); doSchedule=true }
544
- else if(vs.round+1<params.verdictMaxRounds){
545
- // Move to a REAL debate round: snapshot the current votes into history (so the next round's
546
- // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
547
- // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
548
- // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
549
- vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
550
- vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
551
- 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
552
- }
553
- else {
554
- const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
555
- await writeDebateDoc(vs,false,avg); await rewriteSourceProb(vs.targetId, avg, vs.targetOwner); logActivity('verify',vs.targetId+' NOT unanimous → kept unverified (avg '+avg.toFixed(2)+')')
556
- verifyState=null; wakeKind.clear(); await saveAll(); doSchedule=true
557
- }
558
- } finally { finalizeLock=null } // release BEFORE scheduling (chained verifies must not be swallowed)
559
- if(doSchedule) await scheduleNext()
560
- }
561
- async function closeVerify(vs,isTrue){
562
- await writeDebateDoc(vs,true,isTrue?1:0)
563
- const target=vs.targetId
564
- await writeVerifiedCard(vs,isTrue)
565
- await rewriteSource(target,isTrue,vs.targetOwner)
566
- verifiedRecently.set(target, now()) // dedup: block an immediate re-proposal of the same object
567
- logActivity('verify',target+' → Verified ('+(isTrue?'真':'假')+') by unanimous consensus')
568
- verifyState=null; wakeKind.clear(); await saveAll()
569
- // scheduling is done by finalizeVerify AFTER it releases finalizeLock (so a chained verify is
570
- // never swallowed by the still-held reentry lock)
571
- }
572
- // Queue a verify proposal UNLESS the same object was just verified (closed as 真/假). In parallel
573
- // self-organization several residents may independently propose targets while a verify is already
574
- // settling — sometimes the SAME object (test9: p-r3-04 was Verified twice back-to-back), sometimes
575
- // DIFFERENT objects (e.g. a sync meeting where each member proposes its own target). pendingVerify
576
- // is therefore a FIFO queue with per-target dedup: every distinct proposal is honored in order, and
577
- // duplicates collapse to one entry. A resident who genuinely extends the object later can still
578
- // re-propose after the dedup window (recoverStallMs) has passed.
579
- function maybeQueueVerify(target, proposer){
580
- const t=idSafe(target) // sanitize BEFORE it becomes file names / dedup keys / status output
581
- if(!t || t==='id') return false
582
- const last=verifiedRecently.get(t)
583
- if(last!==undefined && (now()-last) < recoverStallMs()){
584
- logActivity('verify',t+' re-propose ignored (just verified at '+fmtTime(last)+')')
585
- return false
586
- }
587
- if(pendingVerify.some(p=>String(p.targetId)===t)) return true // already queued → keep ONE entry
588
- pendingVerify.push({targetId:t,targetType:guessTargetType(t),proposer:proposer||'',at:now()})
589
- return true
590
- }
591
- async function writeDebateDoc(vs,done,val){
592
- const lines=['# 验证辩论|'+vs.targetId+'('+vs.targetType+')|'+fmtTime(),'',(done?('**结论**:'+(val===1?'全体一致为真':'全体一致为假')):('**未达成全体一致**,平均概率 '+val.toFixed(2))),'','## 各常驻意见']
593
- for(const [k,v] of Object.entries(vs.verdicts)){ lines.push('### '+k+'|正确概率 '+(v.prob!=null?Number(v.prob).toFixed(2):'0.50')); lines.push(v.reason||''); lines.push('') }
594
- await writeText('Shared/debates/'+vs.targetId+'.md', lines.join('\n'))
595
- }
596
- async function writeVerifiedCard(vs,isTrue){
597
- const isSub=vs.targetType==='subproblem'
598
- const dir= isSub?'问题':'命题'
599
- const type= isSub?'问题': vs.targetType==='method'?'方法':'命题'
600
- const text='# 已验证|'+vs.targetId+'\n- ID: '+vs.targetId+'\n- 类型: '+type+'\n- 结论: '+(isTrue?'真':'假')+'\n- 概率: '+(isTrue?1:0)+'\n- 来源: 全体常驻一致\n## 陈述\n参见来源卡。\n'
601
- await writeText('Verified/'+dir+'/'+vs.targetId+'.md', text)
602
- }
603
- // Does `content` declare the target as its card ID? Accept both the exact `- ID: <id>` and the
604
- // compact single-line form (`- ID: <id>; - 状态: ...`). Residents write cards by hand via fs with
605
- // varying formats and (crucially) sometimes put a DIFFERENT file name than the declared ID (e.g.
606
- // Propos/r-3/p-01.md declares "- ID: p-r3-01"). Matching only on the file name then silently loses
607
- // the verified-status write-back, so we scan candidates' declared ID too.
608
- function cardDeclaresId(content, target){
609
- if(!content || !target) return false
610
- const m=/-\s*ID:\s*([^;\n]+)/.exec(content)
611
- return !!(m && String(m[1]).trim()===String(target).trim())
612
- }
613
- async function findSourceRel(target, owner){
614
- // 1) exact file name in the owner's library (fast path), then every resident's library
615
- const order = owner ? [owner, ...Array.from(residents.keys()).filter(k=>k!==owner)] : Array.from(residents.keys())
616
- for(const rid of order){
617
- for(const base of ['Propos','Methods','Subproblems']){
618
- const cand=base+'/'+rid+'/'+target+'.md'
619
- const t0=await readText(cand); if(t0!==undefined) return cand
620
- }
621
- }
622
- // 2) declared-ID scan: residents sometimes name the file differently from the declared card ID
623
- // (e.g. p-01.md declares ID p-r3-01). Look inside every card of every library for the target ID.
624
- try {
625
- for(const rid of order){
626
- for(const base of ['Propos','Methods','Subproblems']){
627
- const dirT=await fs.resolve(base+'/'+rid, {cwd: frameworkRoot()})
628
- if(await fs.stat(dirT)===undefined) continue
629
- const entries=await fs.listDir(dirT)
630
- for(const e of entries||[]){
631
- if(!e || e.type!=='file' || !/\.md$/.test(String(e.name))) continue
632
- const c=await readText(base+'/'+rid+'/'+e.name)
633
- if(c!==undefined && cardDeclaresId(c,target)) return base+'/'+rid+'/'+e.name
634
- }
635
- }
636
- }
637
- } catch(e){ /* scanning is best-effort */ }
638
- return null // NOT 'Propos/'+target+'.md': writing there would create a stray empty card
639
- }
640
- // Update the `- 状态:` / `- 概率:` fields of a source card. Residents hand-write cards in two
641
- // shapes: one field per line, or one line with `; `-separated fields. Accept both by allowing the
642
- // anchor anywhere on a line and consuming up to the next `;` when fields share the line.
643
- function rewriteCardField(text, field, newValue){
644
- if(!text) return text
645
- const esc=field.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')
646
- // one-per-line: `- 状态: ...\n` OR inline: `; - 状态: ...;` / `- 状态: ...; - 概率:`
647
- const re=new RegExp('(^|\\n|;\\s*)-\\s*'+esc+':[^;\\n]*','gm')
648
- const replaced=text.replace(re,'$1- '+field+': '+newValue)
649
- return replaced===text ? text : replaced
650
- }
651
- // non-unanimous verification: keep the object in its library but write back the
652
- // average probability (design §8: "留库附概率"), so the card reflects the consensus estimate.
653
- async function rewriteSourceProb(target,prob,owner){
654
- const rel=await findSourceRel(target,owner)
655
- if(!rel){ logActivity('verify',target+' source card NOT found; avg prob '+Number(prob).toFixed(2)+' not written back'); return }
656
- let text=(await readText(rel))||''
657
- const next=rewriteCardField(text,'概率',Number(prob).toFixed(2))
658
- await writeText(rel,next||text)
659
- }
660
- async function rewriteSource(target,isTrue,owner){
661
- // find & update the source card status/prob; best effort across per-resident libs
662
- const rel=await findSourceRel(target,owner)
663
- if(!rel){ logActivity('verify',target+' source card NOT found; verified status not written back'); return }
664
- let text=(await readText(rel))||''
665
- let next=rewriteCardField(text,'状态',isTrue?'已验证·真':'已验证·假')
666
- next=rewriteCardField(next,'概率',isTrue?'1':'0')
667
- await writeText(rel,next||text)
668
- }
669
- function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return 'proposition' }
670
-
671
- // ---- heartbeat / liveness helpers (boundary-A: event-driven + gated heartbeat) ----
672
- // A checkpoint wake is NOT "keep working forever": it nudges the least-recently-active
673
- // resident, after an idle timeout, to CONTINUE the work itself (self-drive), and only to
674
- // propose a meeting/verify/solved when it truly has nothing left. This keeps the group moving
675
- // on its own (framework never assigns work) but adds convergence pressure instead of letting
676
- // a stalled group sit idle forever.
677
- function heartbeatPrompt(r){
678
- return (params.residentPersona?params.residentPersona+'\n':'')
679
- +'Resident researcher '+r.rId+' — CHECKPOINT(团队空闲,请由你们继续自主推进)。当前项目尚未解决(除非你已确认)。团队在等待有人继续:请**继续解决这个问题**——读他人的库对齐、推进某个子问题/引理/方法、尝试一条路线;或向团队发消息(input)、提议任务(propose_task)让大家分工。若你确实认为问题已解决、或已彻底无路可走,才提议开会(propose_meeting)让团队表决/商量、或声明 solved=true。默认立场是:**请推进,而不是停在原地。**\n'
680
- +'Reply with ONLY a JSON object:\n'
681
- +'{"summary":"<what you will do / what you advanced this round>","input":"<optional: a message to the whole team, or \\"\\">","solved":false,"propose_verify":"<id|null>","propose_meeting":"<agenda|null>","propose_task":"<task title|null>","task_desc":"<optional: why this task matters / what it covers|null>","claim_task":"<id|null>","contextPct":40}'
682
- }
683
- function clearHeartbeat(){ if(heartbeatDisposer!==null){ try{ heartbeatDisposer() }catch(e){} heartbeatDisposer=null } }
684
- function armHeartbeat(){
685
- clearHeartbeat()
686
- const ms=posMs(params.activityTimeoutMs,120000)
687
- if(typeof ctx.timeout!=='function') return
688
- heartbeatDisposer=ctx.timeout(()=>{ heartbeatDisposer=null; scheduleNext().catch(()=>{}) }, ms)
689
- }
690
- // Real DSH /compact of a resident's OWN session via ctx.compaction (if the host provides it);
691
- // falling back silently to the resident self-summary directive when the service is absent.
692
- async function realCompact(r){
693
- if(!r || !r.childId) return
694
- if(compaction===undefined || !compaction.compactIfNeeded) return
695
- let agent
696
- try { agent = agents.get(r.childId) } catch(e){ agent = undefined }
697
- if(!agent || !agent.session) return
698
- try {
699
- const signal = makeSignal(params.activityTimeoutMs||60000)
700
- const result = await compaction.compactIfNeeded(agent, 'pressure', signal)
701
- if(result && (result.shadowedSeqs||[]).length>0){
702
- // the resident's real session was compacted → its context is now a summary.
703
- // Flag needCompact so the NEXT wake re-anchors the core rules (they may have been blurred).
704
- r.roundsSinceCompact=0; r.needCompact=true; r.contextPct=Math.min(r.contextPct||15,25)
705
- logActivity('compact', r.rId+' real /compact (shadowed '+result.shadowedSeqs.length+' items, ~'+String(result.shadowedTokenCount||0)+' tokens)')
706
- }
707
- } catch(e){ /* real compaction unavailable/failed; the soft directive already covers it */ }
708
- }
709
-
710
- // ---- liveness / scheduling ----
711
- async function scheduleNext(){
712
- if(!running||autoDone){ clearHeartbeat(); return }
713
- if(phase==='brainstorm'){ await maybeFinishBrainstorm(); return }
714
- if(meetingState){ await continueMeetingRound(); return }
715
- if(verifyState){ await continueVerifyRound(); return }
716
- if(pendingVerify.length){ const pv=pendingVerify.shift(); await beginVerify(pv); return }
717
- // A meeting requested while a verify held the floor is parked in pendingMeeting; once the
718
- // verify queue has truly drained (no verifyState / pendingVerify), resume it before anything else.
719
- if(pendingMeeting){ const pm=pendingMeeting; pendingMeeting=null; await startMeeting(pm.agenda, pm.type, pm.targetId); return }
720
- // mailbox delivery
721
- const delivered=await deliverNextMailbox(); if(delivered) return
722
- // maxParallel: don't start a new wake when the in-flight cap is reached
723
- const mp=Number(params.maxParallel)||0
724
- if(mp>0 && busy.size>=mp){ armHeartbeat(); return }
725
- // B) stall auto-sync meeting (分级保活 B): the group has been idle with NO progress for
726
- // stallAutoMeetingMs → convene a sync meeting so the residents coordinate their next move
727
- // (framework convenes & records; residents decide — never assigns work). Only when no
728
- // meeting/verify/pending work is active AND no resident is currently working (so it never
729
- // preempts an in-flight round).
730
- if(phase==='active' && !meetingState && !verifyState && pendingVerify.length===0 && busy.size===0){
731
- const stallMs=posMs(params.stallAutoMeetingMs, posMs(params.activityTimeoutMs,120000)*3)
732
- if(now()-lastProgressAt>=stallMs){
733
- await startMeeting('团队较长时间没有新进展。请你们自行讨论:当前问题是否已解决、开放难点是什么、谁负责哪部分、下一步如何推进,并自主决定是否继续。框架只负责转达与记录,不替你们决定。','general',null)
734
- return
735
- }
736
- }
737
- // A) heartbeat / coordination: wake IDLE residents after an idle timeout to SELF-DRIVE (continue
738
- // solving / message / propose task / meeting / verify). This is a CONCURRENCY FILL, not a
739
- // single nudge: scheduleNext should wake up to `maxParallel` idle residents in one pass so the
740
- // group can progress in parallel (design §A: "同一时刻可唤醒多个空闲常驻,受 maxParallel 上限").
741
- // On a FAILED wake we re-arm the heartbeat so a single follow-up error NEVER permanently stops
742
- // the group (a successful wake re-drives scheduleNext through its own onResidentEnd, which re-arms).
743
- clearHeartbeat()
744
- const atOs=posMs(params.activityTimeoutMs,120000)
745
- // `mp` (maxParallel) is already declared above in this function scope.
746
- // Collect idle (not busy) residents sorted by idle time, oldest-first (round-robin fairness).
747
- const idleCandidates = Array.from(residents.values())
748
- .filter(r=>!busy.has(r.rId))
749
- .sort((a,b)=>(now()-b.lastActiveAt)-(now()-a.lastActiveAt))
750
- // Fill the concurrency budget: keep waking the most-idle resident until either everyone idle is
751
- // started OR the in-flight cap (maxParallel) is reached. This turns the previous "one at a time"
752
- // serialization into genuine parallel progress.
753
- let started=0
754
- for(const r of idleCandidates){
755
- const free = mp>0 ? (mp - busy.size) : Number.MAX_SAFE_INTEGER
756
- if(free<=0) break // concurrency cap reached → stop filling
757
- if((now()-r.lastActiveAt)<atOs) break // the remaining are all busy-or-not-idle-enough
758
- let ok=false
759
- try { ok = await wakeResident(r, await heartbeatPrompt(r), 'normal') } catch(e){ ok=false }
760
- if(ok) started++
761
- await saveAll()
762
- if(!ok) continue // a failed wake must NOT stop the fill; try the next idle resident
763
- }
764
- if(started>0) { armHeartbeat(); return } // started some; re-drive comes via onResidentEnd, BUT also arm a
765
- // safety-net heartbeat so a woken resident whose subagent/end NEVER arrives (a hung normal round) does not
766
- // freeze the group. Below mp, the next scheduleNext will re-fill; if all woken residents are stuck busy the
767
- // heartbeat just re-arms harmlessly. (Meeting/verify already have recoverStallMs watchdogs; normal A-fill did not.)
768
- // everyone is busy or not idle-enough: arm a heartbeat to re-check later (no infinite spin)
769
- armHeartbeat()
770
- }
771
- async function maybeFinishBrainstorm(){
772
- const pending=[]; for(const [,r] of residents){ if(r.status==='brainstorm' && !r.insight) pending.push(r.rId) }
773
- if(pending.length===0){ phase='active'; logActivity('phase','active — residents now self-organize'); await saveBrainstormSummary(); await saveAll(); await scheduleNext() }
774
- }
775
- async function saveBrainstormSummary(){
776
- const lines=['# 头脑风暴','']; for(const [,r] of residents){ if(r.insight){ lines.push('## '+r.rId+'「'+(r.direction||'')+'」'); lines.push(r.insight); lines.push('') } }
777
- await writeText('Shared/meetings/brainstorm.md', lines.join('\n'))
778
- }
779
- async function deliverNextMailbox(){
780
- // Deliver queued messages to ALL currently-idle recipients in one pass (parallel), bounded by the
781
- // same maxParallel concurrency cap, so a group chat (relayToGroup → many non-busy recipients) is
782
- // not serialized one-message-at-a-time. Returns true if anything was delivered. A busy recipient
783
- // keeps its message queued (avoid starving others).
784
- let delivered=false
785
- for(const [to,msgs] of mailboxes){
786
- if(msgs.length===0) continue
787
- const r=residents.get(to); if(!r){ mailboxes.delete(to); continue } // stale recipient → drop the entry
788
- if(busy.has(to)) continue // recipient busy → leave the message queued for a later pass
789
- const mp=Number(params.maxParallel)||0
790
- if(mp>0 && busy.size>=mp) break // concurrency cap reached → stop delivering more now
791
- const m=msgs.shift()
792
- currentResident=to
793
- const ok = await wakeResident(r, (await normalPrompt(r))+'\n\n[MESSAGE from '+m.from+']\n'+m.content,'normal')
794
- await saveAll(); if(!ok) msgs.unshift(m); delivered=delivered||ok
795
- }
796
- return delivered
797
- }
798
-
799
- // ---- resident end handler ----
800
- async function onResidentEnd(childId, info){
801
- const r=byChild(childId); if(!r) return
802
- // A turn that is NOT marked busy is a duplicate/stale end (the same subagent/end delivered twice,
803
- // or an end for a turn already settled). Without this guard every side effect below — task
804
- // proposal, group relay, verify queueing, meetings.push — would run a SECOND time (the
805
- // duplicate-task/duplicate-stop class from test9 reappears whenever a host re-delivers an end).
806
- // Every legitimate end corresponds to a busy turn: busy is added at spawn/wake and cleared only
807
- // here, on wake failure, on removeMember, or on respawn (whose stale childIds no longer match).
808
- if(!busy.delete(r.rId)) return
809
- // Any resident turn that COMPLETED is real activity for the stall clock (B). Residents frequently
810
- // write their libraries via direct fs (not the record* tools), so relying only on
811
- // bumpArtifacts/markProgress would leave lastProgressAt stale and B would fire against an active
812
- // team. We count only a clean 'completed' turn: an error/max-tokens/refusal did NOT meaningfully
813
- // advance the work, so it must NOT mask a truly stalled group (B can then convene a recovery
814
- // meeting). A completed turn also refreshes the meeting/verify deadlock clock through lastInputAt.
815
- if(info && info.stopReason==='completed') markProgress()
816
- realCompact(r).catch(()=>{}) // best-effort real DSH /compact of this resident while idle
817
- const output=blocksToText(info&&info.lastAssistantMessage)
818
- const parsed=parseReply(output)
819
- postmark(r, parsed) // context/compact bookkeeping, regardless of wake kind (clears any leak)
820
- const kind=wakeKind.get(r.rId)||'normal'
821
- if(kind==='meeting' && meetingState){
822
- 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}
823
- meetingState.lastInputAt=now()
824
- if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
825
- await saveAll()
826
- // PAUSE/stop: record the in-flight input/verdict but do NOT start any NEW consensus wake —
827
- // a paused run must stay paused (resume() refreshes the consensus clocks and re-drives).
828
- if(!running || autoDone) return
829
- await continueMeetingRound(); return
830
- }
831
- if((kind==='verif-ind'||kind==='verif-deb') && verifyState){
832
- const v=(parsed&&parsed.vote)||{}
833
- // verdict = 0-1 probability the object is TRUE (1=绝对真, 0=绝对假, 0.5=不确定);
834
- // also accept legacy 'TRUE'/'FALSE' strings AND quoted numeric strings ("0.9"), which LLMs
835
- // occasionally emit — without this a confident "0.9" was silently misread as 0.5 (uncertainty).
836
- let p
837
- if(typeof v.verdict==='number'){ p=clamp01(v.verdict) }
838
- else if(/^TRUE$/i.test(String(v.verdict))){ p=1 }
839
- else if(/^FALSE$/i.test(String(v.verdict))){ p=0 }
840
- else if(typeof v.verdict==='string' && v.verdict.trim()!=='' && Number.isFinite(Number(v.verdict))){ p=clamp01(Number(v.verdict)) }
841
- else { p=clamp01(Number(v.confidence)) }
842
- // verdict is a PURE 0-1 probability (a degree); no binary TRUE/FALSE classification.
843
- verifyState.verdicts[r.rId]={prob:p,confidence:p,reason:String(v.reason||parsed.summary||'')}
844
- verifyState.lastVerdictAt=now()
845
- await saveAll()
846
- if(!running || autoDone) return // pause: freeze (resume refreshes the clocks and re-drives)
847
- await continueVerifyRound(); return
848
- }
849
- // normal turn
850
- if(r.status==='brainstorm'){ r.insight=parsed.summary||output; r.status='active' }
851
- if(typeof parsed.solved==='boolean'){ reports.push({rId:r.rId,solved:parsed.solved,summary:parsed.summary||'',at:now()}); if(reports.length>100) reports.shift() } // solved-signal ring (not surfaced in status/report)
852
- if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
853
- // group-conversation relay: the resident may choose to speak to the whole team (input) —
854
- // forward it to the others so this is a real discussion group, not private monologues.
855
- if(typeof parsed.input==='string' && parsed.input.trim()) await relayToGroup(r.rId, parsed.input.trim())
856
- // task actions via reply (a resident may propose or claim a task in its round)
857
- if(parsed.propose_task) await proposeTask(parsed.propose_task, parsed.task_desc||'', r.rId)
858
- if(parsed.claim_task) await claimTask(parsed.claim_task, r.rId)
859
- if(parsed.task_done) await taskDone(parsed.task_done, r.rId)
860
- // a resident may self-trigger a meeting (resident-driven coordination, closest to the philosophy).
861
- // If a verify is holding the floor the meeting is deferred (pendingMeeting) and we fall through
862
- // so the pending verify (or mailbox/heartbeat) still advances rather than being stuck behind it.
863
- if(parsed.propose_meeting && !meetingState){
864
- const mr=await startMeeting(String(parsed.propose_meeting),'general',null); await saveAll()
865
- if(mr && !mr.deferred) return
866
- }
867
- await saveAll(); await scheduleNext()
868
- }
869
-
870
- // ---- controls ----
871
- async function start({problem,residentCount,seedDirections}){
872
- await loadSettings()
873
- currentProject=await readCurrentProject(); if(!currentProject||currentProject==='default'){ currentProject='default'; }
874
- await ensureDirs()
875
- if(problem) problemText=String(problem)
876
- if(!problemText) return {ok:false,message:'problem text required (pass problem, or use vibe_v4_configure first)'}
877
- problemId=slugify(problemText.slice(0,40))||'problem'
878
- if(residentCount) params.residentCount=Number(residentCount)||4
879
- if(!(Number(params.residentCount)>=1)) params.residentCount=DEFAULT_PARAMS.residentCount // a 0/negative count (settings misconfig) would spawn nobody & idle forever
880
- running=true; autoDone=false; phase='brainstorm'
881
- await writeText('Problems/'+problemId+'.md','# 问题|'+problemId+'\n- ID: '+problemId+'\n- 类型: 问题\n- 状态: 求解中\n- 优先级: 1\n- 依赖: []\n\n## 陈述\n'+problemText+'\n')
882
- // A reused session may still have OLD residents in flight from a previous run (start is a FRESH
883
- // run that reuses the same r-1.. library paths). Interrupt them BEFORE resetting, otherwise their
884
- // still-running turns keep writing into the same per-resident files the new run is about to use.
885
- for(const [,or] of residents){ if(or.childId){ try{ subagents.interrupt(or.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } }
886
- residents=new Map(); mailboxes=new Map(); taskboard=[]; decisions=[]; meetings=[]; reports=[]; verifyState=null; meetingState=null; pendingVerify=[]; residentSeq=0; artifactCount=0; clearHeartbeat()
887
- 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
888
- lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
889
- const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
890
- for(let i=0;i<params.residentCount;i++){ const r=newResident(dirs[i]||''); await spawnResident(r) }
891
- await saveAll(); return {ok:true,message:'v4 started: '+params.residentCount+' resident(s) brainstorming',project:currentProject}
892
- }
893
- async function resume(){
894
- currentProject=await readCurrentProject(); await ensureDirs()
895
- // Resume is only meaningful for a stopped/paused/crashed run. If THIS process is already driving
896
- // a live run whose disk state belongs to it, loadAll below would overwrite the in-memory state
897
- // with a slightly stale snapshot (busy marks, wake round counters, mailbox contents from the last
898
- // saveAll) — a silent clobber for a useless "kick". No-op instead. A cross-process restart is
899
- // always allowed: its disk epoch differs, so the in-memory state is empty/stale anyway.
900
- const pre=await readJson('State/session.json')
901
- if(running && !autoDone && pre && pre.processEpoch===processEpoch) return {ok:true,message:'already running (no-op)'}
902
- await loadAll(); await loadSettings()
903
- // A run the group CONCLUDED (unanimous voteSolved → autoDone) must not be silently revived into
904
- // a zombie that keeps waking residents with no consensus that it should still run. The group
905
- // decided it is done; continuing means a NEW run (vibe_v4_start / vibe_v4_configure).
906
- if(autoDone) return {ok:false,message:'This run already concluded (all residents agreed solved). Start a fresh run with vibe_v4_start (vibe_v4_configure a new problem first if needed).'}
907
- // After loadAll the residentSeq counter is still whatever THIS process had (0 on a fresh process),
908
- // but persisted residents may already be r-1..r-N. Sync it to the max existing id so a later
909
- // addMember never collides with an existing resident (it would silently overwrite it).
910
- for(const key of residents.keys()){ const mm=/^r-(\d+)$/.exec(String(key)); if(mm) residentSeq=Math.max(residentSeq, Number(mm[1])) }
911
- lastActivityAt=now(); lastProgressAt=now() // pause must not count as stall time; a resumed run gets a fresh clock
912
- if(phase==='idle' && !running && residents.size===0) return {ok:false,message:'nothing to resume'}
913
- // If the persisted State came from a DIFFERENT process (crash/restart), the saved
914
- // childIds are stale; clear them so residents re-spawn (their libraries persist on
915
- // disk and re-seed the resumed run). Same-process pause→resume keeps continuable ids.
916
- const crossProcess = persistedEpoch !== processEpoch
917
- if(crossProcess){ for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.roundsSinceCompact=0 } }
918
- // ANY re-spawn (cross-process OR a same-process abort that already cleared childIds) must get a FRESH
919
- // coordination/concurrency state and a brainstorm phase. Otherwise: re-spawned brainstorm residents run
920
- // under phase='active' (brainstorm summary never written), and a LATE subagent/end from an interrupted
921
- // OLD resident (same rId) deletes the NEW resident's busy mark → A-fill can wake it mid-brainstorm.
922
- const needRespawn = Array.from(residents.values()).some(r=>!r.childId)
923
- if(needRespawn){
924
- for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.insight=''; r.roundsSinceCompact=0 }
925
- busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; pendingVerify=[]; verifyState=null; meetingState=null; finalizeLock=null; verifiedRecently.clear()
926
- }
927
- for(const [,r] of residents){ if(!r.childId){ await spawnResident(r) } }
928
- if(!running){ running=true; autoDone=false; if(phase==='idle') phase='active' }
929
- if(needRespawn && phase!=='brainstorm') phase='brainstorm' // let re-spawned residents re-bootstrap together
930
- // A pause froze an in-progress meeting/verify with its watchdog clock still running: refresh the
931
- // clocks so a resumed consensus gets a full fresh stall window instead of being abandoned the
932
- // instant it is serviced again (a short pause must never silently kill a real discussion).
933
- if(meetingState && meetingState.lastInputAt) meetingState.lastInputAt=now()
934
- if(verifyState && verifyState.lastVerdictAt) verifyState.lastVerdictAt=now()
935
- logActivity('resume','restarted'+(crossProcess?' (cross-process: re-spawned)':needRespawn?' (re-spawned)':'')); await saveAll(); await scheduleNext(); return {ok:true,message:'resumed',project:currentProject}
936
- }
937
- function status(){ return { ok:true, running, phase, autoDone, project:currentProject, residentCount:residents.size,
938
- residents:listResidents(), busy:[...busy], taskboard:taskboard.length,
939
- meetingInProgress: !!(meetingState), verifyInProgress: !!(verifyState), pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null, pendingVerifyCount: pendingVerify.length,
940
- parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
941
- params:['residentCount','compactAfterRounds','compactThreshold','maxParallel','activityTimeoutMs','meetingKeepEvery','verdictMaxRounds','stallAutoMeetingMs','provider','model','residentPersona','toolAllow','toolDeny'].map(k=>k+'='+(Array.isArray(params[k])?params[k].join(','):params[k])).join(', ') } }
942
- function report(){ return { ok:true, running, phase, autoDone, project:currentProject, problem:problemText,
943
- residents:listResidents(), taskboard:taskboard.filter(t=>t.status!=='done'),
944
- meeting: meetingState?{id:meetingState.id, agenda:meetingState.agenda, spoke:Object.keys(meetingState.inputs).length+'/'+residents.size}:null,
945
- verify: verifyState?{target:verifyState.targetId,stage:verifyState.stage, voted:Object.keys(verifyState.verdicts).length+'/'+residents.size}:null,
946
- pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null,
947
- parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
948
- meetings:meetings.length, recentActivity: activityLog.slice(-8) } }
949
- async function addMember(direction){
950
- // Adding a member starts a REAL resident turn (spawnResident → brainstorm) — refuse unless the
951
- // run is live: on a concluded (autoDone) or never-started/paused run the new member would work
952
- // with nobody to coordinate (zombie work on a project the group already declared done).
953
- if(!running || autoDone) return {ok:false,message:'no active run to join (start or resume first)'}
954
- const r=newResident(direction||''); await spawnResident(r)
955
- // Mid-meeting additions must join the meeting's speaking order; otherwise allSpoke (over CURRENT
956
- // residents) can never be true for the new member (not in the snapshot order) and the meeting is
957
- // only ever released by the stuck watchdog instead of finalizing with everyone's input.
958
- if(meetingState){ if(!Array.isArray(meetingState.order)) meetingState.order=Array.from(residents.keys()); if(!meetingState.order.includes(r.rId)) meetingState.order.push(r.rId) }
959
- // Mid-verify additions are automatically asked to vote (continueVerifyRound recomputes ids from
960
- // the live residents map), so no extra handling is needed there.
961
- return {ok:true,id:r.rId,direction:r.direction} }
962
- async function removeMember(id){ const r=residents.get(id); if(!r) return {ok:false}; if(r.childId){ try{ subagents.interrupt(r.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } residents.delete(id); busy.delete(id); mailboxes.delete(id); wakeKind.delete(id); if(currentResident===id) currentResident=''
963
- // Reconcile in-progress coordination so a removed member cannot hang consensus or crash a round:
964
- // drop its meeting speech / verify verdict and prune it from the meeting's speaking order so the
965
- // find() there never selects a ghost. Its QUEUED verify proposals are deliberately KEPT: a
966
- // proposal is a statement about an OBJECT the group can judge on its merits with its CURRENT
967
- // members (allVoted recomputes over the live residents), and dropping the queue entry would also
968
- // erase the intent of any OTHER member who independently proposed the same target (dedup keeps
969
- // only the first entry, which may belong to the removed member).
970
- if(meetingState){ delete meetingState.inputs[id]; meetingState.order=(meetingState.order||[]).filter(x=>x!==id) }
971
- if(verifyState){ delete verifyState.verdicts[id] }
972
- await saveAll()
973
- // Re-drive the scheduler right away. If the removed member was the ONLY turn in flight (e.g. the
974
- // last unspoken meeting speaker / the last unvoted voter, interrupted mid-turn), NO subagent/end
975
- // will ever arrive to trigger the next pass, and while a consensus is being serviced no heartbeat
976
- // is armed either — without this kick the meeting/verify would freeze forever behind members that
977
- // can already conclude. scheduleNext no-ops safely when the run is paused/stopped.
978
- await scheduleNext()
979
- return {ok:true} }
980
- // Normalize one parameter value to its intended type so a string from /v4 set or configure
981
- // becomes the right number/array. Keeps settings.json clean regardless of how it was set.
982
- function normalizeParam(k, v){
983
- const INT_KEYS=['residentCount','compactThreshold','compactAfterRounds','maxParallel','activityTimeoutMs','verdictMaxRounds','meetingKeepEvery','stallAutoMeetingMs']
984
- if(INT_KEYS.includes(k)){ const n=Number(v); return Number.isFinite(n)?n:v }
985
- if(k==='toolAllow'||k==='toolDeny'){ if(Array.isArray(v)) return v.map(x=>String(x).trim()).filter(Boolean); if(typeof v==='string') return v.split(',').map(x=>x.trim()).filter(Boolean); return [] }
986
- return v
987
- }
988
- function setParams(upd){ for(const k of Object.keys(upd||{})){ if(k in params) params[k]=normalizeParam(k, upd[k]) } saveSettings().catch(()=>{}); return {ok:true} }
989
- // ---- create / configure (no auto-start) + settings-file persistence ----
990
- 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) params[k]=s[k] } } }
991
- async function saveSettings(){ await writeJson('State/settings.json', params) }
992
- // Create/configure a project and set params/problem WITHOUT starting any resident.
993
- // The intended flow: vibe_v4_configure {project?, problem?, params?} → vibe_v4_start {}.
994
- async function configure(cfg){
995
- // configure is the PRE-START setup tool (project/problem/params). Switching the project while a
996
- // run is LIVE would split the run's state across two trees: residents' briefs & libraries point
997
- // at the OLD frameworkRoot while every subsequent saveAll/transcript/Verified card would go to the
998
- // NEW project. Params tuning mid-run belongs to vibe_v4_set.
999
- if(running && !autoDone) return {ok:false,message:'cannot configure while a run is running (pause or abort first; use vibe_v4_set to tune params)'}
1000
- if(cfg && cfg.project && String(cfg.project).trim()) currentProject=String(cfg.project).trim()
1001
- if(cfg && cfg.problem) problemText=String(cfg.problem)
1002
- if(cfg && cfg.params && typeof cfg.params==='object') setParams(cfg.params)
1003
- await writeCurrentProject(); await ensureDirs(); await saveSettings()
1004
- // create the problem card so the project is complete BEFORE the run starts
1005
- if(problemText){ const pid=slugify(problemText.slice(0,40))||'problem'; await writeText('Problems/'+pid+'.md','# 问题|'+pid+'\n- ID: '+pid+'\n- 类型: 问题\n- 状态: 求解中\n- 优先级: 1\n- 依赖: []\n\n## 陈述\n'+problemText+'\n') }
1006
- await saveAll()
1007
- return {ok:true,project:currentProject,problem:problemText?problemText.slice(0,60):'',params:Object.keys(params).map(k=>k+'='+params[k]).join(', ')}
1008
- }
1009
- async function initAbort(){ clearHeartbeat(); running=false; phase='idle'; autoDone=false; for(const [,r] of residents){ if(r.childId){ try{ subagents.interrupt(r.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } r.childId=''; r.lastActiveAt=0; r.roundsSinceCompact=0 }
1010
- // Wipe the coordination state too: an aborted run must not report an in-flight meeting/verify,
1011
- // a parked meeting, a verify queue, or busy residents (their childIds are gone, so no end event
1012
- // can ever clear those marks). resume()/start() re-initialize anyway; this keeps status truthful
1013
- // between abort and the next action.
1014
- meetingState=null; verifyState=null; pendingMeeting=null; pendingVerify=[]; busy=new Set(); wakeKind=new Map(); currentResident=''; finalizeLock=null
1015
- await saveAll(); return {ok:true,message:'aborted'} }
1016
- function setPause(){ clearHeartbeat(); running=false; return {ok:true,message:'paused'} }
1017
-
1018
- return {
1019
- sessionId, running:()=>running, autoDone:()=>autoDone, phase:()=>phase,
1020
- onResidentEnd, start, resume, status, report, addMember, removeMember, setParams,
1021
- setPause, initAbort, postMessage, startMeeting, saveAll, broadcast, configure, loadSettings,
1022
- currentResident:()=>currentResident,
1023
- // safety kick: drive one scheduler pass (used when an end handler errored, so an exceptional
1024
- // turn can never leave the group with no end-event and no heartbeat to continue it)
1025
- nudge:()=>scheduleNext().catch(()=>{}),
1026
- residentIdOf:(agent)=>{ const m=residentOfAgent(agent); if(m) return m; const c=currentResident; return (c && residents.has(c)) ? c : '' },
1027
- useResident:(id)=>{ currentResident=id },
1028
- publishProgress, recordProposition, recordMethod, recordSubproblem, listResidents, reportContext,
1029
- proposeTask, claimTask, taskDone, listTasks,
1030
- readProgress: async (rid)=>({text:(await readText('Progress/'+rid+'/progress.md'))||''}),
1031
- frameworkRoot:frameworkRoot, currentProject:()=>currentProject, problemText:()=>problemText,
1032
- residentCount:()=>residents.size,
1033
- busyCount:()=>busy.size,
1034
- }
1035
- } // end makeSession
1036
-
1037
- // ================= apply-level registration (ONCE) =================
1038
- function objParams(props, required){ return { type:'object', properties:props, additionalProperties:false, required:required||[] } }
1039
- function registerTool(name, description, parameters, fn){
1040
- tools.register({ name, description, parameters,
1041
- output:{ schema:{ type:'string' }, render:(_a,v)=>[{type:'text',text:String(v)}] },
1042
- execute: async (args, exec)=>{
1043
- try { const s=getSession(exec&&exec.agent); if(!s) return JSON.stringify({ok:false,error:'no session'}); return JSON.stringify(await fn(s,args||{},exec&&exec.agent)) }
1044
- catch(e){ return JSON.stringify({ok:false,error:String((e&&e.message)||e)}) }
1045
- } })
1046
- }
1047
- // host/assistant-facing
1048
- registerTool('vibe_v4_configure','Create/configure a project: set project name, problem, and params WITHOUT starting a run. Use this FIRST, then vibe_v4_start to actually spawn residents.',objParams({project:{type:'string'},problem:{type:'string'},params:{type:'object'}}),(s,a)=>s.configure(a))
1049
- registerTool('vibe_v4_start','Start V4: spawn N resident subagents (brainstorm then self-organize).',objParams({problem:{type:'string'},residentCount:{type:'integer'},seedDirections:{type:'array',items:{type:'string'}}}),(s,a)=>s.start(a))
1050
- registerTool('vibe_v4_resume','Resume a persisted V4 run.',objParams({}),(s)=>s.resume())
1051
- registerTool('vibe_v4_pause','Pause V4.',objParams({}),(s)=>s.setPause())
1052
- registerTool('vibe_v4_abort','Abort V4 and interrupt residents.',objParams({}),(s)=>s.initAbort())
1053
- registerTool('vibe_v4_status','Show V4 status.',objParams({}),(s)=>s.status())
1054
- registerTool('vibe_v4_report','Return the V4 progress report.',objParams({}),(s)=>s.report())
1055
- registerTool('vibe_v4_message','Inject a message to a resident (or all).',objParams({to:{type:'string'},content:{type:'string'}},['to','content']),(s,a)=>{ const to=a.to||'all'; if(to==='all') return s.broadcast(a.content); return s.postMessage('facilitator',to,a.content) })
1056
- registerTool('vibe_v4_meeting','Start a meeting (coordinate / allocate / propose verification).',objParams({agenda:{type:'string'}},['agenda']),(s,a)=>s.startMeeting(a.agenda))
1057
- registerTool('vibe_v4_list_members','List residents.',objParams({}),(s)=>({ok:true,residents:s.listResidents()}))
1058
- registerTool('vibe_v4_add_member','Add a resident.',objParams({direction:{type:'string'}}),(s,a)=>s.addMember(a.direction))
1059
- registerTool('vibe_v4_remove_member','Close a resident.',objParams({id:{type:'string'}},['id']),(s,a)=>s.removeMember(a.id))
1060
- // model/provider inheritance: set model/provider to override the residents' LLM route (''=inherit
1061
- // the main assistant's route). toolAllow/toolDeny are per-resident tool permissions (scoped
1062
- // restrict). residentPersona prepends a persona line to every resident prompt.
1063
- 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.',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'}}}),(s,a)=>{ s.setParams(a); return {ok:true} })
1064
- // resident-facing tools: route to the CALLING resident (exec.agent.id === childId);
1065
- // fall back to the last-woken resident when called by the host/assistant.
1066
- 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) })
1067
- registerTool('vibe_v4_publish_progress','(resident) Append to your own progress markdown.',objParams({content:{type:'string'}},['content']),(s,a,x)=>s.publishProgress(s.residentIdOf(x),a.content))
1068
- registerTool('vibe_v4_record_proposition','(resident) Record a proposition to your library.',objParams({id:{type:'string'},title:{type:'string'},statement:{type:'string'},prob:{type:'number'},value:{type:'number'},motivation:{type:'string'}}),(s,a,x)=>s.recordProposition(s.residentIdOf(x),a))
1069
- registerTool('vibe_v4_record_method','(resident) Record a method/theory to your library.',objParams({id:{type:'string'},title:{type:'string'},type:{type:'string'},content:{type:'string'},notation:{type:'string'},value:{type:'number'},motivation:{type:'string'}}),(s,a,x)=>s.recordMethod(s.residentIdOf(x),a))
1070
- registerTool('vibe_v4_record_subproblem','(resident) Record a sub-problem to your library.',objParams({id:{type:'string'},title:{type:'string'},statement:{type:'string'},value:{type:'number'},motivation:{type:'string'}}),(s,a,x)=>s.recordSubproblem(s.residentIdOf(x),a))
1071
- registerTool('vibe_v4_read_progress','(resident) Read another resident\'s progress (read-only).',objParams({id:{type:'string'}},['id']),async (s,a)=>{ const rp=await s.readProgress(a.id); return {ok:true,text:(rp&&rp.text)||''} })
1072
- registerTool('vibe_v4_list_residents','(resident) List fellow residents.',objParams({}),(s)=>({ok:true,residents:s.listResidents()}))
1073
- // task board (residents; board is the residents' own allocation mechanism)
1074
- registerTool('vibe_v4_propose_task','(resident) Propose a task to the shared task board.',objParams({title:{type:'string'},description:{type:'string'}},['title']),(s,a,x)=>s.proposeTask(a.title,a.description,s.residentIdOf(x)))
1075
- registerTool('vibe_v4_claim_task','(resident) Claim an open task from the board (framework then wakes you to work it).',objParams({id:{type:'string'}},['id']),(s,a,x)=>s.claimTask(a.id,s.residentIdOf(x)))
1076
- registerTool('vibe_v4_task_done','(resident) Mark a claimed task done.',objParams({id:{type:'string'},claimer:{type:'string'}},['id']),(s,a,x)=>s.taskDone(a.id,a.claimer||s.residentIdOf(x)))
1077
- registerTool('vibe_v4_list_tasks','(resident) List open tasks.',objParams({}),(s)=>({ok:true,tasks:s.listTasks()}))
1078
- // context / compact (resident reports its context usage so the framework can /compact-equivalent)
1079
- 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))
1080
- registerTool('vibe_v4_claim_write','Reserved: shared-file write lock (framework-managed).',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
1081
- registerTool('vibe_v4_release_write','Reserved: shared-file write lock release.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
1082
-
1083
- commands.register({
1084
- name:'v4', description:'control the Vibe Math V4 framework',
1085
- input:{hint:'[configure|start|resume|pause|abort|status|report|meeting|members|add|remove|set]'},
1086
- handler: async function(inv){
1087
- const s=getSession(inv&&inv.agent); if(!s) return {kind:'success',text:JSON.stringify({ok:false,error:'no session'})}
1088
- const line=String(inv&&inv.rawInput?inv.rawInput:'').trim(); const parts=line.split(/\s+/); const cmd=parts[0]||''; const rest=parts.slice(1)
1089
- let r
1090
- if(cmd==='configure') r=await s.configure({project:rest[0]||'', problem:parts.slice(2).join(' ')})
1091
- else if(cmd==='start') r=await s.start({})
1092
- else if(cmd==='resume') r=await s.resume()
1093
- else if(cmd==='pause') r=s.setPause()
1094
- else if(cmd==='abort') r=await s.initAbort()
1095
- else if(cmd==='status') r=s.status()
1096
- else if(cmd==='report') r=s.report()
1097
- else if(cmd==='meeting') r=await s.startMeeting(rest.join(' '))
1098
- else if(cmd==='members') r={ok:true,residents:s.listResidents()}
1099
- else if(cmd==='add') r=await s.addMember(rest.join(' '))
1100
- else if(cmd==='remove') r=await s.removeMember(rest[0]||'')
1101
- 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) }
1102
- else r={ok:false,usage:'configure|start|resume|pause|abort|status|report|message|meeting|members|add|remove|set'}
1103
- return {kind:'success',text:JSON.stringify(r,null,2)}
1104
- },
1105
- })
1106
-
1107
- ctx.on('subagent/end', function(info){
1108
- const sid=childOwner.get(info.id); const s=sid!==undefined?sessions.get(sid):undefined
1109
- if(s) s.onResidentEnd(info.id, info).catch(e=>{ console.error('vibe-v4 end: '+String((e&&e.stack)||e)); if(s.nudge) s.nudge() })
1110
- })
1111
- }
1
+ // Vibe Math V4 — persistent self-organizing collaborative research framework.
2
+ // FACILITATOR (message bus / meetings / per-resident artifact libraries /
3
+ // unanimous-consensus verification / context compaction proxy / resume / human
4
+ // intervention). It NEVER assigns tasks: residents message & meet and decide all
5
+ // task allocation among themselves. Consumes HOST subagents/agents/fs/tools/commands.
6
+ // NOTE: must declare `inject` for every service read as a ctx property (the Guard
7
+ // rejects undeclared dependencies), and must use the `timer` Service (ctx.timeout),
8
+ // not global setTimeout/clearTimeout, which do not exist in the plugin runtime.
9
+ export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
10
+ export function apply(ctx) {
11
+ const subagents = ctx.subagents
12
+ const agents = ctx.agents
13
+ const fs = ctx.fs
14
+ const tools = ctx.tools
15
+ const commands = ctx.commands
16
+ // Optional services are resolved LAZILY at call time, never snapshotted in apply().
17
+ // A `ctx.get()` snapshot taken here is order-sensitive: if the service has not been
18
+ // provided yet when this preset subtree mounts, the snapshot stays undefined for the
19
+ // whole session, so `runShell` would report 'no-subprocess' forever and `ensureDirs()`
20
+ // would silently stop creating the project tree (only masked by fs.writeText's
21
+ // automatic parent creation). Reading on demand removes that dependency on mount order.
22
+ const subprocessOf = () => { try { return ctx.get('subprocess') } catch(e){ return undefined } }
23
+ const sandboxPolicyOf = () => { try { return ctx.get('sandboxPolicy') } catch(e){ return undefined } }
24
+ const compactionOf = () => { try { return ctx.get('compaction') } catch(e){ return undefined } }
25
+
26
+ const sessions = new Map() // rootAgentId -> Session
27
+ const childOwner = new Map() // childId -> rootAgentId
28
+ const fileOwner = {} // process-level write lock
29
+ const processEpoch = String(Date.now()) + '-' + Math.random().toString(36).slice(2, 8)
30
+
31
+ function sessionIdOf(agent){ try { return (agent&&agent.id)?String(agent.id):undefined } catch(e){ return undefined } }
32
+ function rootOf(agent){ try { let cur=agent; const seen=new Set(); while(cur){ const id=cur.id; if(seen.has(id)) return cur; seen.add(id); const p=(cur.session&&cur.session.header)?cur.session.header.parentSession:undefined; if(p===undefined) return cur; const par=agents.get(p); if(!par) return cur; cur=par } } catch(e){} return agent }
33
+ function getSession(agent){ const root=rootOf(agent); const sid=sessionIdOf(root); if(sid===undefined) return undefined; let s=sessions.get(sid); if(!s){ s=makeSession(root,sid); sessions.set(sid,s) } return s }
34
+
35
+ function makeSession(rootAgent, sessionId) {
36
+ let currentProject = 'default'
37
+ const DEFAULT_PARAMS = {
38
+ residentCount: 4, compactThreshold: 66, compactAfterRounds: 8,
39
+ maxParallel: 3, activityTimeoutMs: 120000, verdictMaxRounds: 3,
40
+ meetingKeepEvery: 5, // 每积累 N 个新产物自动触发一次同步会议
41
+ stallAutoMeetingMs: 360000, // 团队空闲且无新产物的"停滞阈值":超过则自动召集同步会议(分级保活 B)
42
+ // model/provider inheritance: '' = the resident inherits the parent (main assistant)
43
+ // route (provider + model). Set them to override the resident's LLM backend/model.
44
+ provider: '', model: '', residentPersona: '',
45
+ // tool permissions: an allow/deny list of tool names applied via startContinuable's
46
+ // toolFilter (scoped tools.restrict() in the child). Empty = inherit all tools.
47
+ // CAUTION: only set one of these; an empty allow:[] would deny EVERY tool.
48
+ toolAllow: [], toolDeny: [],
49
+ }
50
+ let params = Object.assign({}, DEFAULT_PARAMS)
51
+ let running = false, autoDone = false, phase = 'idle'
52
+ let residents = new Map(), mailboxes = new Map(), taskboard = [], decisions = []
53
+ let meetings = [], reports = [], activityLog = []
54
+ let problemText = '', problemId = 'problem', runId = 'run-' + shortId()
55
+ let meetingState = null, verifyState = null, pendingVerify = [], pendingMeeting = null // pendingVerify: FIFO queue (several residents may independently propose different objects before any verify runs — a single slot silently DROPPED all but the last proposal)
56
+ let busy = new Set(), wakeKind = new Map(), currentResident = ''
57
+ let finalizeLock = null // 'meeting'|'verify' while a consensus finalize is running (reentry guard)
58
+ const verifiedRecently = new Map() // targetId -> timestamp when it was closed as Verified (dedup re-propose)
59
+ let lastActivityAt = now(), lastProgressAt = now(), artifactCount = 0, lastSyncMeetingAt = 0, persistedEpoch = '', heartbeatDisposer = null
60
+ const activityLogCap = 200
61
+
62
+ // ---- utils ----
63
+ function now(){ return Date.now() }
64
+ 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 }
65
+ function shortId(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<8;i++) s+=h[Math.floor(Math.random()*16)]; return s }
66
+ function clamp01(v){ const n=Number(v); if(!Number.isFinite(n)) return 0.5; return Math.max(0,Math.min(1,n)) }
67
+ function fmtTime(ts){ try { return new Date(ts||now()).toISOString().replace('T',' ').slice(0,19) } catch(e){ return String(ts||'') } }
68
+ function cl(x){ return clamp01(Number(x)) }
69
+ // contextPct is a PERCENT (0-100); never clamp to 0-1 or the compactThreshold
70
+ // comparison (e.g. 66) becomes `1.0 >= 66` and never fires.
71
+ function clPct(x){ const n=Number(x); if(!Number.isFinite(n)) return 0; return Math.max(0,Math.min(100,n)) }
72
+ function textBlock(t){ return { type:'text', text:String(t) } }
73
+ 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() }
74
+ function logActivity(event,detail){ activityLog.push({at:now(),event,detail:String(detail||'')}); if(activityLog.length>activityLogCap) activityLog.shift() }
75
+ function logDecision(kind,detail){ decisions.push({at:now(),kind,detail:String(detail||'')}) }
76
+ // Record that the project made real progress (new artifact, meeting, verify, task, or a
77
+ // resident speaking to the group). The stall auto-sync meeting (B) fires only when this has
78
+ // NOT advanced for stallAutoMeetingMs, so a group that is genuinely producing keeps working
79
+ // and only a truly stalled group gets a coordination meeting to reboot itself.
80
+ function markProgress(){ lastProgressAt = now(); }
81
+ // How long a meeting/verify may run without collecting a new input/verdict before we treat it as
82
+ // deadlocked and abandon it. A meeting round's own signal window is activityTimeoutMs, so a
83
+ // resident should speak within that; 2× that without ANY new input/verdict means the meeting/verify
84
+ // is stuck and must not keep the whole group blocked.
85
+ // Positive duration with a safe fallback: a NEGATIVE/NaN activityTimeoutMs or stallAutoMeetingMs
86
+ // (misconfigured via vibe_v4_set) would otherwise make recoverStallMs negative → every meeting/
87
+ // verify watchdog fires INSTANTLY (abandoning all consensus) and A-fill's idle window would never
88
+ // elapse (waking everyone every pass). Guard every duration read with this.
89
+ function posMs(v,def){ const n=Number(v); return (Number.isFinite(n)&&n>0)?n:(def||120000) }
90
+ function recoverStallMs(){ return posMs(params.activityTimeoutMs,120000) * 2 }
91
+ 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' }
92
+ // Per-resident model/provider inheritance: when params.provider / params.model are set,
93
+ // the resident uses that exact route; when left '' the resident inherits the parent's
94
+ // (main assistant) route — the documented DSH default (resolveChildAgentOptions merges
95
+ // requested over parent). No override is applied for empty values.
96
+ function residentAgentOptions(){ const ao={}; if(params.provider) ao.provider=params.provider; if(params.model) ao.model=params.model; return ao }
97
+ // Tool permission (scoped toolFilter). Only emit a filter when allow or deny has entries;
98
+ // an empty object is rejected by DSH ("must declare allow and/or deny").
99
+ function residentToolFilter(){
100
+ const allow=Array.isArray(params.toolAllow)?params.toolAllow.filter(x=>String(x).trim()):[]
101
+ const deny=Array.isArray(params.toolDeny)?params.toolDeny.filter(x=>String(x).trim()):[]
102
+ if(allow.length===0 && deny.length===0) return undefined
103
+ const f={}; if(allow.length) f.allow=allow; if(deny.length) f.deny=deny; return f
104
+ }
105
+ function makeSignal(ms){ return AbortSignal.timeout(posMs(ms,30000)) }
106
+ 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 '.' }
107
+ function vibeRoot(){ return (workspaceRoot()+'/VibeMath').replace(/\\/g,'/') }
108
+ function frameworkRoot(){ return vibeRoot()+'/Projects/'+currentProject }
109
+ function slugify(s){ const t=String(s==null?'':s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g,'-').replace(/^-+|-+$/g,''); return t||'project' }
110
+ // Object ids (verify targets, recorded cards) become FILE NAMES and DIRECTORY PATHS
111
+ // (Verified/命题/<id>.md, Shared/debates/<id>.md, Propos/<r>/<id>.md, source-card scans).
112
+ // A hostile/sloppy id containing path separators ('../../x') or Windows-forbidden chars would
113
+ // escape the project tree. Keep every harmless character (incl. Chinese) and replace only
114
+ // separators/control chars; strip leading/trailing dots/dashes so the name is never '.'/'..'.
115
+ function idSafe(s){
116
+ const t=String(s==null?'':s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/-{2,}/g,'-').replace(/^[.\-]+|[.\-]+$/g,'')
117
+ return t||'id'
118
+ }
119
+ let warnedNoPolicy = false
120
+ function warnNoPolicyOnce(){ if(!warnedNoPolicy){ warnedNoPolicy=true; console.error('vibe-math-v4: sandboxPolicy unavailable; writes go out with no explicit policy') } }
121
+ 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 }
122
+ function psQuote(p){ return "'"+String(p).replace(/'/g,"''")+"'" }
123
+ /** POSIX 单引号引用:把 ' 换成 '\'' 以安全嵌入任意路径。 */
124
+ function shQuote(p){ return "'"+String(p).replace(/'/g,"'\\''")+"'" }
125
+ /**
126
+ * 执行一段 shell 脚本。**按平台选择解释器**:此前硬编码 powershell,而预设用
127
+ * `disabled: !!js process.platform !== 'win32'` 在非 Windows 上关掉了 tool-pwsh 行——
128
+ * 插件会调用一个自己声明不提供的二进制,且返回值无人检查,表现为静默失效。
129
+ */
130
+ function isWindows(){ return process.platform === 'win32' }
131
+ function mkdirCmd(paths){
132
+ if(isWindows()) return 'New-Item -Force -ItemType Directory -Path '+paths.map(psQuote).join(',')+' | Out-Null'
133
+ return 'mkdir -p '+paths.map(shQuote).join(' ')
134
+ }
135
+ async function runShell(script,cwd){
136
+ const subprocess=subprocessOf(); if(subprocess===undefined) return {ok:false,error:'no-subprocess'}
137
+ try {
138
+ const argv = isWindows()
139
+ ? ['powershell','-NoProfile','-NonInteractive','-Command',script]
140
+ : ['/bin/sh','-c',script]
141
+ const h=subprocess.spawn({argv:argv,cwd:cwd||workspaceRoot(),stdio:{stdin:'ignore',stdout:'inherit',stderr:'inherit'},graceMs:20000})
142
+ const o=await h.done
143
+ return {ok:o.exitCode===0,exitCode:o.exitCode}
144
+ } catch(e){ return {ok:false,error:String((e&&e.message)||e)} }
145
+ }
146
+ async function fsTarget(rel){ return await fs.resolve(rel,{cwd:frameworkRoot()}) }
147
+ async function readText(rel){ try { const t=await fsTarget(rel); if(await fs.stat(t)===undefined) return undefined; return await fs.readText(t) } catch(e){ return undefined } }
148
+ async function writeText(rel,content){ try { const t=await fsTarget(rel); await fs.writeText(t,content,undefined,undefined,getPolicy()); return true } catch(e){ return false } }
149
+ // State files (taskboard/residents/session/mailboxes/decisions) are written by MANY concurrent
150
+ // flows (parallel resident turns + end handlers + tools). Two near-simultaneous writers of the
151
+ // SAME file each stringified their snapshot BEFORE their fs.writeText landed, so the writer with
152
+ // the OLDER snapshot could land LAST and silently erase the other's entry (e.g. two residents
153
+ // proposing tasks in the same tick → one task vanished from taskboard.json until the next save).
154
+ // Fix: serialize writes PER FILE, and defer JSON.stringify until the write actually runs (so the
155
+ // snapshot always reflects the newest in-memory state at execution time — late writers win with
156
+ // the FULL state, never with a stale subset).
157
+ const jsonQueues = new Map() // rel -> tail promise (per-session file write chain)
158
+ function writeJson(rel,obj){
159
+ const key='j:'+rel
160
+ const prev=jsonQueues.get(key)||Promise.resolve(true)
161
+ const run=prev.catch(()=>{}).then(async ()=>{ try { if(!(await assertWritable(rel))) return false; const t=await fsTarget(rel); await fs.writeText(t,JSON.stringify(obj,null,2),undefined,undefined,getPolicy()); return true } catch(e){ return false } })
162
+ jsonQueues.set(key,run.catch(()=>{}))
163
+ return run
164
+ }
165
+ async function readJson(rel){ const t=await readText(rel); if(t===undefined||t==='') return undefined; try { return JSON.parse(t) } catch(e){ noteSuspect(rel); return undefined } }
166
+ /**
167
+ * Corruption guard. `readJson` cannot tell "no file yet" from "file present but
168
+ * unparseable", yet loadAll() treats both as "no data" and the next saveAll()
169
+ * writes that emptiness back — so one externally damaged State/*.json silently
170
+ * reset the whole run (residents, taskboard, mailboxes). Any read that hits a
171
+ * present-but-unparseable JSON file records it; writeJson then REFUSES to write
172
+ * that path until the file is fixed or deleted. A missing file is still created
173
+ * normally, so first-run behaviour is unchanged.
174
+ */
175
+ const suspectFiles = new Set()
176
+ const warnedSuspect = {}
177
+ function noteSuspect(rel){
178
+ suspectFiles.add(rel)
179
+ if(warnedSuspect[rel]) return
180
+ warnedSuspect[rel]=true
181
+ console.error('vibe-math-v4: '+rel+' exists but is not parseable JSON — REFUSING to overwrite it so a corrupted file cannot silently erase your run. Fix or delete the file, then retry.')
182
+ }
183
+ function assertWritableSync(rel){
184
+ if(suspectFiles.has(rel)){ console.error('vibe-math-v4: write to '+rel+' blocked (file is unparseable; see the earlier warning)'); return false }
185
+ return true
186
+ }
187
+ /**
188
+ * Nothing read this path yet, so inspect the file actually on disk before
189
+ * replacing it. This closes the case where a fresh process never ran loadAll()
190
+ * (configure/start) and would otherwise write an empty state over a corrupt one
191
+ * the user might still want to inspect or repair.
192
+ */
193
+ async function assertWritable(rel){
194
+ if(!assertWritableSync(rel)) return false
195
+ try {
196
+ const raw=await readText(rel)
197
+ if(raw!==undefined && raw!==''){
198
+ try { JSON.parse(raw) } catch(e){ noteSuspect(rel); return false }
199
+ }
200
+ } catch(e){}
201
+ return true
202
+ }
203
+ async function ensureDirs(){ const base=frameworkRoot(); const dirs=['Problems','Progress','Propos','Methods','Subproblems','Shared/meetings','Shared/debates','Verified/命题','Verified/问题','Reliable','Notes','State']; return await runShell(mkdirCmd([vibeRoot()+'/Projects'].concat(dirs.map(d=>base+'/'+d)))) }
204
+ async function readTextAbs(path){ try { const t=await fs.resolve(path); const s=await fs.stat(t); if(s===undefined) return undefined; return await fs.readText(t) } catch(e){ return undefined } }
205
+ 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 } }
206
+ async function readCurrentProject(){ try { const t=await readTextAbs(vibeRoot()+'/.current'); if(t) return String(t).trim() } catch(e){} return currentProject }
207
+ async function writeCurrentProject(){ try { await writeTextAbs(vibeRoot()+'/.current', currentProject) } catch(e){} }
208
+ function tryJson(s){ try { return JSON.parse(s) } catch(e){ return undefined } }
209
+ function parseReply(text){
210
+ let obj; const fence=/```(?:json)?[ \t]*([\s\S]*?)```/gi; let m
211
+ while((m=fence.exec(text))!==null){ const o=tryJson(m[1].trim()); if(o&&typeof o==='object'&&!Array.isArray(o)) obj=o }
212
+ if(!obj){ const w=tryJson(text.trim()); if(w&&typeof w==='object'&&!Array.isArray(w)) obj=w }
213
+ return obj||{}
214
+ }
215
+
216
+ // ---- persistence ----
217
+ async function saveAll(){
218
+ await writeJson('State/residents.json', Object.fromEntries(residents))
219
+ await writeJson('State/mailboxes.json', Object.fromEntries(mailboxes))
220
+ await writeJson('State/taskboard.json', taskboard)
221
+ await writeJson('State/decisions.json', decisions)
222
+ await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount})
223
+ }
224
+ async function loadAll(){
225
+ 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 }
226
+ const rm=await readJson('State/residents.json'); if(rm&&typeof rm==='object') residents=new Map(Object.entries(rm))
227
+ const mb=await readJson('State/mailboxes.json'); if(mb&&typeof mb==='object') mailboxes=new Map(Object.entries(mb))
228
+ const tb=await readJson('State/taskboard.json'); if(Array.isArray(tb)) taskboard=tb
229
+ const dc=await readJson('State/decisions.json'); if(Array.isArray(dc)) decisions=dc
230
+ }
231
+
232
+ // ---- resident prompts ----
233
+ function banner(){ const o=[]; for(const [id,r] of residents) o.push('- '+id+'「'+(r.direction||'(未定)')+'」'+r.status+'·轮'+r.rounds); return o.join('\n') }
234
+ 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' }
235
+ function residentLibraries(){
236
+ const base=frameworkRoot()
237
+ return '你的资料库根目录:'+base+'/\n'
238
+ +' Progress/<你>/progress.md —— 你的研究日志(叙述,可追加。主要内容是尝试过的各方法、路线、历程、进度,当前研究进展/进度、将来的计划与打算,及各路线、过程中遇到的障碍及其原因,对各路线、方法的看法、可行性评估,自己研究过程中的一些有价值看法、感想、猜想、理解。以及其它各种你认为有价值的值得记录的事物、经验、方法/想法、创新等都可进行记录)。\n'
239
+ +' Propos/<你>/<id>.md —— 你的命题/引理。格式:\n'
240
+ +' - ID: p-<id>; - 状态: 未定论; - 概率: <0-1>; - 价值程度: <0-1>; - 动机用途计划: <为何重要/打算怎么用>\n'
241
+ +' 然后 ## 陈述 <陈述>;## 证明尝试;## 证伪尝试。\n'
242
+ +' Methods/<你>/<id>.md —— 你的理论/方法/工具。格式:- ID: m-<id>; - 状态: 经验; - 可信断言: []; - 价值程度: <0-1>; - 动机用途计划: ...;然后 ## 核心内容;## 定义与记号;## 应用记录;## 改进历史。\n'
243
+ +' Subproblems/<你>/<id>.md —— 你的子问题。格式:- ID: s-<id>; - 状态: 求解中; - 价值程度: <0-1>; - 动机用途计划: ...;然后 ## 陈述;## 进度。\n'
244
+ }
245
+ function toolList(){
246
+ return 'vibe_v4_send_message {to, content} —— 给某常驻发消息(to=all 广播)。\n'
247
+ +'vibe_v4_meeting {agenda} —— 发起/参与会议(框架会把各常驻的实际 input 转给其他人,让大家看到并讨论/辩论)。\n'
248
+ +'vibe_v4_propose_task/claim_task/task_done/list_tasks —— 共享任务板(提议/认领/完成/查看;任务板是你们协调分工的载体)。\n'
249
+ +'vibe_v4_publish_progress/record_proposition/record_method/record_subproblem —— 便捷记录器(可选;推荐直接用 fs 写自己的文件)。\n'
250
+ +'vibe_v4_read_progress {id} —— 只读某常驻的进展。\n'
251
+ +'vibe_v4_list_residents / vibe_v4_list_tasks —— 查看团队组成 / 开放任务。\n'
252
+ +'vibe_v4_report_context {pct} —— 上报上下文占比(框架据此压缩你的上下文)。\n'
253
+ +'fs (read/write/list) —— 读取任意文件;写入你自己的文件(推荐直接用 fs 直接写自己的 md)。\n'
254
+ }
255
+ // A shared, complete context block so a resident always knows the situation: mission,
256
+ // work model, what it can do, which files it owns (+ formats), what others' files are,
257
+ // and that it may READ anyone and WRITE its own directly. level 'full' = initial brief.
258
+ function contextBrief(r, level){
259
+ const s=[]
260
+ s.push('## 背景 —— 你是常驻研究团队的一员')
261
+ s.push('You are resident researcher '+r.rId+'(常驻研究者 '+r.rId+';共 '+params.residentCount+' 位常驻),正在协作解决:')
262
+ s.push(problemText)
263
+ s.push('')
264
+ s.push('这像一个**真实的学术小组**:没有中央调度器、没有外部派活——你们自己通过 **互相发消息 + 开会讨论** 来决定一切:谁做什么、怎么分工、验证什么、何时停止。你的 Round 决定你这一轮做什么;团队的优先级与分工由大家的讨论涌现。')
265
+ s.push('')
266
+ s.push('### 工作模式(会发生什么)')
267
+ s.push('1. 每人有一份持久、全组可见的专属资料库(见下)。')
268
+ s.push('2. 你们自由发消息、开会;**会议会把每个人实际说的话(input)转给其他人**,让你看得到、能回复、能讨论、能辩论。')
269
+ s.push('3. 你独立研究,并**直接用 fs 写入你自己的文件**(按格式),供全组阅读。')
270
+ s.push('4. 任何"已确立"的东西须**全组一致**验证(全真或全假)才作数;否则只是带概率的工作估计。')
271
+ s.push('5. 只有**全组在会议上一致认为原问题已解决**,run 才停止。')
272
+ s.push('')
273
+ s.push('### 你负责的文件(你只写自己的;但可读任何人的)')
274
+ s.push(residentLibraries())
275
+ s.push('其他人把结论/进展写进他们的目录,你就能读到。**你应主动读别人的库**,对齐事实、彼此衔接、避免重复劳动。')
276
+ if(level!=='full'){ s.push('(格式见你最初的说明;直接用 fs 写自己的文件即可。)') }
277
+ s.push('')
278
+ s.push('### 可用工具')
279
+ s.push(toolList())
280
+ s.push('')
281
+ if(level==='full'){
282
+ s.push('### 可自主发明理论/工具(鼓励,但不强迫)')
283
+ s.push('请注意:你可以(但**不强迫**,完全视实际需要而定)尝试自主构建新的理论框架或工具——例如对某种系统做抽象化、一般化,抽离/推广出更一般的结构或理论框架;然后不断完善这个理论框架,在该框架下推得各种定理、性质、结论,以利于该框架下问题的解决。这就像为解决方程问题发明了群论、为分析需要建立了泛函分析框架——它比单纯解决当前问题更有学术价值,因为你直接得到了一类更普遍的方法/理论体系。')
284
+ s.push('若你发明了这样的理论/工具,请**阐明它对原问题的用处、价值**;后续可根据需要不断**完善、一般化、推广**它。把这类成果记入你的 Methods/<你>/ 库。')
285
+ s.push('')
286
+ }
287
+ s.push('### 规则')
288
+ s.push('- 只有 Verified/(或卡片标"已验证·真/假")算已确立;其余都是你的实验性工作,请区分"猜想/已知"。')
289
+ s.push('- 验证必须**全组一致**(全真或全假);你只信全票结果。未全票的对象留在库里带概率。')
290
+ s.push('- 你自己决定做什么,但**优先级/分工由团队讨论决定**,不是固定模式。若你认为问题已解决或接近解决,请**发起会议**让团队表决。')
291
+ s.push('- 退出时**只**输出一个 JSON 对象(放在 ```json 代码围栏内;围栏外不要有文字)。')
292
+ return s.join('\n')
293
+ }
294
+ // A SHORT core-rules recap, re-injected ONLY right after a compaction so the resident
295
+ // never loses the ground rules (they are told fully once at brainstorm, but a /compact
296
+ // could blank them).
297
+ function coreRulesBrief(){
298
+ const base=frameworkRoot()
299
+ return '[核心规则重申] 只有 Verified/(及标记"已验证·真/假")算已确立;验证须全组一致(全真或全假)才作数,否则留库附平均概率;你只写自己的库('+base+'/ 的 Progress/<你>/、Propos/<你>/、Methods/<你>/、Subproblems/<你>/),可只读任何人的库;任务分工由团队讨论决定;退出只输出一个 JSON 对象。'
300
+ }
301
+ function brainstormPrompt(r){
302
+ return (params.residentPersona?params.residentPersona+'\n':'')
303
+ +contextBrief(r,'full')+'\n'
304
+ +(r.direction?('\n\n你被建议的初始方向(可自行调整/细化):\n'+r.direction+'\n'):'')
305
+ +'## 这是你的第一轮:独立头脑风暴\n'
306
+ +'独立地想清楚:你对这个问题的洞察 / 解决方向 / 关键子问题 / 可能的引理 / 粗略计划。你还未见到其他人,先独立产出。\n'
307
+ +'把有价值的产物**直接用 fs 写进你自己的文件**(按上面格式),并在 summary 里概述你的切入方向与初步结论(标注哪些是猜想、哪些凭你已确证)。\n'
308
+ +'Reply with ONLY a JSON object:\n'
309
+ +'{"summary":"<your insight / direction / rough plan, one tight paragraph>","solved":false}'
310
+ }
311
+ async function normalPrompt(r){
312
+ return (params.residentPersona?params.residentPersona+'\n':'')
313
+ +'Resident researcher '+r.rId+' — 第 '+r.rounds+' 轮。一切由你和团队讨论决定。动手前先**读别人的库**对齐事实、避免重复;把新进展/结论**直接用 fs 写进你自己的文件**;想对团队说的话放 "input"(会转给其他常驻)。\n'
314
+ +'\n团队成员:\n'+banner()+'\n'
315
+ +'New items:\n'+ (await inboxText(r.rId)) +'\n'
316
+ +'Reply with ONLY a JSON object:\n'
317
+ +'{"summary":"<what you did / decided this round, 1-3 sentences>","input":"<optional: a message to the whole team, or \\"\\">","solved":false,"propose_verify":"<id|null>","propose_meeting":"<agenda|null>","propose_task":"<task title|null>","task_desc":"<optional: why this task matters / what it covers|null>","claim_task":"<task id|null>","task_done":"<task id|null>","contextPct":40}'
318
+ }
319
+ function meetingPrompt(r, st){
320
+ const prior=Object.entries(st.inputs).filter(([k])=>k!==r.rId).map(([k,iv])=>' ['+k+'] '+String(iv.input||iv.summary||'')).join('\n')
321
+ return (params.residentPersona?params.residentPersona+'\n':'')
322
+ +'Resident '+r.rId+' — 团队会议进行中。 A meeting is in progress (agenda: '+st.agenda+').'
323
+ +(st.type==='verify'?('\n团队正在验证对象:'+st.targetId+'('+st.targetType+',提出者 '+st.targetOwner+')。请先看他人意见,再给独立判断。'):'')
324
+ +'\n这是一场真实讨论:下面已有人发言(转给你),请先看,然后**加入讨论/补充/反驳/表决**。'
325
+ +(prior?('\n\n### 已有发言(他人 input,已转发给你)\n'+prior):'\n(目前还没人发言,你先说。)')
326
+ +'\n\n你可以:提议任务(propose_task)、认领开放任务(claim_task)、提议验证对象(propose_verify)、或对"原问题是否已解决"表决(voteSolved)。请把**你的实际发言**写进 "input"。'
327
+ +'\nReply with ONLY a JSON object:\n'
328
+ +'{"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}'
329
+ }
330
+ function verifyPrompt(r, vs){
331
+ // In a DEBATE round, show the PREVIOUS round's opinions (kept in vs.history) so the resident can
332
+ // see others' stances and give a fresh independent judgement; in the first (independent) round no
333
+ // others' opinions exist yet. vs.verdicts only ever holds the CURRENT round's votes.
334
+ const src = (vs.stage==='debate' && vs.history && Object.keys(vs.history).length>0) ? vs.history : (vs.stage==='debate' ? vs.verdicts : {})
335
+ const others=Object.entries(src).map(([k,v])=>'- '+k+': 正确概率 '+String(v.prob!=null?Number(v.prob).toFixed(2):0.5)+' → '+v.reason).join('\n')
336
+ return (params.residentPersona?params.residentPersona+'\n':'')
337
+ +'Resident '+r.rId+' — 团队验证。 The group is verifying object '+vs.targetId+'('+vs.targetType+',提出者 '+vs.targetOwner+')。\n'
338
+ +'请给出你对「该对象为真」的**正确概率 `verdict`**,仅一个 0–1 数值:**1 = 绝对为真,0 = 绝对为假,0.5 = 完全不确定,其余为介于其间的程度**(不要给 TRUE/FALSE,就给一个数值)。\n'
339
+ +'判定规则:仅当**全体常驻一致给 1(都认为是真)或一致给 0(都认为是假)**,才按「真/假」写入 Verified/;否则**只作为概率数值(一种程度)保留在库中**,附全组平均正确概率,不写成真/假。\n'
340
+ +'请给出你**诚实独立的判断**'
341
+ +(vs.stage==='debate'?',并参考他人意见:\n':'。\n')
342
+ +(vs.stage==='debate'&&others?('### 他人上一轮意见(已转发给你)\n'+others+'\n'):'')
343
+ +'\nReply with ONLY a JSON object:\n'
344
+ +'{"vote":{"verdict":0.9,"reason":"<your logic>"}}'
345
+ }
346
+
347
+ // ---- resident lifecycle ----
348
+ let residentSeq = 0
349
+ 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} }
350
+ async function spawnResident(r){
351
+ const ao=residentAgentOptions(); const tf=residentToolFilter()
352
+ const started=await subagents.startContinuable({provider:pickProvider(),label:r.rId,request:{prompt:[textBlock(brainstormPrompt(r))],parent:rootAgent,agentOptions:ao,...(tf?{toolFilter:tf}:{})},signal:makeSignal(params.activityTimeoutMs||60000)})
353
+ r.childId=started.childId; r.status='brainstorm'; r.lastActiveAt=now()
354
+ childOwner.set(started.childId,sessionId); busy.add(r.rId); wakeKind.set(r.rId,'normal'); currentResident=r.rId
355
+ residents.set(r.rId,r); await saveAll(); logActivity('spawn',r.rId+' ('+(r.direction||'brainstorm')+')')
356
+ }
357
+ async function wakeResident(r, promptText, kind){
358
+ if(!r || !r.childId) return false // a removed resident must never be woken (else r.childId would crash)
359
+ clearHeartbeat()
360
+ busy.add(r.rId); wakeKind.set(r.rId,kind||'normal'); currentResident=r.rId
361
+ r.lastActiveAt=now(); r.rounds+=1; r.roundsSinceCompact+=1
362
+ // Context compaction has TWO distinct needs. Confusing them is the bug that made
363
+ // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the start of nearly every prompt:
364
+ // (a) r.needCompact (set by a REAL /compact) => the resident's rules may be blurred, so
365
+ // re-anchor the short core rules on the next wake of ANY kind, then CLEAR the flag.
366
+ // (Short recap only; no self-summary directive — the real compact already condensed.)
367
+ // (b) soft-compact trigger (contextPct>=threshold OR roundsSinceCompact>=afterRounds) =>
368
+ // the resident's context genuinely grew; ask it to self-summary. ONLY on a normal
369
+ // research round (kind==='normal'): a meeting/verify reply has no contextPct/compacted
370
+ // fields, so a directive injected there is never acknowledged and would repeat forever.
371
+ let prompt = promptText
372
+ const isNormal = (kind||'normal')==='normal'
373
+ const wantSoft = isNormal && (Number(r.contextPct)>=Number(params.compactThreshold) || Number(r.roundsSinceCompact)>=Number(params.compactAfterRounds))
374
+ const wantReanchor = r.needCompact
375
+ if(wantSoft){
376
+ prompt = coreRulesBrief() + '\n' +
377
+ '[CONTEXT COMPACT — your conversation is at/near the limit. Do NOT re-derive history.\n' +
378
+ 'Condense your current working state into ONE tight self-summary (findings so far, active direction, key artifacts you recorded, next concrete steps, open questions), then answer this round in the normal JSON format as usual.\n' +
379
+ 'Set "contextPct": 15 (your post-compact usage) and "compacted": true in the reply so the framework records the condensed seed.]\n\n' + promptText
380
+ r.needCompact = true
381
+ } else if(wantReanchor){
382
+ prompt = coreRulesBrief() + '\n' + prompt
383
+ r.needCompact = false
384
+ }
385
+ // The DSH continuable-wake API is subagents.sendMessage(sender, targetId, content, {signal}),
386
+ // NOT subagents.followup (which is only Agent.followup, and does NOT exist on the subagents
387
+ // service). Using a non-existent method threw TypeError and made EVERY wake fail silently →
388
+ // the group went idle forever. Prefer sendMessage; fall back to a legacy followup if a host
389
+ // still exposes it (older deployments), so this works across versions.
390
+ try {
391
+ if(typeof subagents.sendMessage==='function'){
392
+ await subagents.sendMessage(rootAgent, r.childId, [textBlock(prompt)], {signal: makeSignal(params.activityTimeoutMs||60000)})
393
+ } else if(typeof subagents.followup==='function'){
394
+ await subagents.followup(rootAgent, r.childId, [textBlock(prompt)], {source:{kind:'user'},signal:makeSignal(params.activityTimeoutMs||60000)})
395
+ } else {
396
+ throw new Error('no subagent continuation API (need sendMessage or followup)')
397
+ }
398
+ return true
399
+ }
400
+ catch(e){ console.error('vibe-v4 wake '+r.rId+' failed: '+String((e&&e.message)||e)); busy.delete(r.rId); return false }
401
+ }
402
+ function byChild(childId){ for(const [,r] of residents){ if(r.childId===childId) return r } return undefined }
403
+
404
+ // ---- artifact writers (resident-facing) ----
405
+ async function publishProgress(rId,content){ if(!rId||!residents.has(rId)) return {ok:false,message:'no such resident'} ; const rel='Progress/'+rId+'/progress.md'; const prev=(await readText(rel))||''; await writeText(rel, prev+'\n### '+fmtTime()+'|'+rId+'\n'+String(content||'')+'\n'); return {ok:true} }
406
+ 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'} }
407
+ 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'} }
408
+ 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'} }
409
+ // auto-sync meeting: every meetingKeepEvery artifact records, convene a general coordination meeting.
410
+ //
411
+ // ⚠ 已知设计缺口(有意保留,未修):计数口径**只有** record_* 三个便捷工具的调用。而提示词明确
412
+ // 告诉常驻:"vibe_v4_publish_progress/record_* 只是便捷记录器(可选;推荐直接用 fs 写自己的文件)",
413
+ // 所以一个完全按推荐方式(fs 直写)工作的团队不会让 artifactCount 增长,
414
+ // `artifactCount % meetingKeepEvery === 0` 永不成立 —— 文档承诺的"每积累 N 个新产物自动同步
415
+ // 一次"在推荐工作流下不可达(此时只有"停滞看门狗"那条时间触发路径会开会)。
416
+ // 试过把"完成的常驻轮次"也计入,但那会改变开会节奏,令 e2e-v4-fixes T4 与 selfdrive-v4 的
417
+ // 时序断言失败(两套测试都按当前节奏写死了预期)。这属于**设计参数取舍**,需要维护者决定:
418
+ // 要么改计数口径并同步调整测试预期,要么把"便捷记录器可选"的措辞改为"建议使用以便触发周期同步"。
419
+ function bumpArtifacts(){ artifactCount+=1; markProgress(); if(!meetingState && !verifyState && !pendingMeeting && Number(params.meetingKeepEvery)>0 && artifactCount % Number(params.meetingKeepEvery)===0){ startMeeting('定期同步:分工/进展/是否需要验证','general',null).catch(()=>{}) } }
420
+ 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):''})) }
421
+ // identify WHICH resident is calling a resident-facing tool: match the caller's
422
+ // subagent id to a resident's childId. Fall back to the last-woken resident when
423
+ // the caller is the host/assistant (or an unknown agent). This makes per-resident
424
+ // libraries correct under concurrency (e.g. all brainstorm residents in flight).
425
+ function residentOfAgent(agent){ try { const id=agent&&agent.id?String(agent.id):''; if(!id) return ''; for(const [,r] of residents){ if(r.childId===id) return r.rId } } catch(e){} return '' }
426
+
427
+ // ---- task board (residents propose / claim / complete; framework wakes the claimer) ----
428
+ async function writeTaskboard(){ const lines=['# 任务板','']; for(const t of taskboard){ lines.push('- ['+t.status+'] '+t.title+(t.claimer?('(认领:'+t.claimer+')'):'')+(t.proposer?('(提议:'+t.proposer+')'):'')+(t.description?(':'+t.description):'')) } await writeText('Shared/taskboard.md',lines.join('\n')) }
429
+ async function proposeTask(title,description,proposer){ const id='t-'+shortId(); taskboard.push({id,title:String(title),description:String(description||''),status:'open',proposer:proposer||'',claimer:'',source:''}); await saveTaskboard(); markProgress(); logActivity('task','proposed '+id+'「'+title+'」'); return {ok:true,id} }
430
+ async function claimTask(id,claimer){ const t=taskboard.find(x=>x.id===id); if(!t) return {ok:false,message:'no such task'}; if(t.status!=='open') return {ok:false,message:'task already '+t.status}; t.status='claimed'; t.claimer=claimer; await saveTaskboard(); markProgress(); logActivity('task',claimer+' claimed '+id);
431
+ // wake the claimer to work on it (framework moves the task, resident decides how).
432
+ // NOT while paused/stopped: a paused run must not start new work — the claim is recorded on the
433
+ // board and the resident (who claimed it) picks it up again after resume.
434
+ 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() }
435
+ return {ok:true} }
436
+ 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} }
437
+ async function saveTaskboard(){ await writeJson('State/taskboard.json',taskboard); await writeTaskboard() }
438
+ function listTasks(){ return taskboard.filter(t=>t.status!=='done') }
439
+ 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} }
440
+ // Apply context/compact bookkeeping from a resident's reply, so the flag can clear even when
441
+ // the reply came through a meeting/verify branch (defensive) as well as the normal branch.
442
+ function postmark(r, parsed){
443
+ const cp=Number(parsed.contextPct); if(Number.isFinite(cp)) r.contextPct=clPct(cp) // tolerate numeric strings ("40")
444
+ if(parsed.compacted===true || (r.needCompact && parsed.summary)){
445
+ r.contextSeed=String(parsed.summary||r.contextSeed||'')
446
+ r.contextPct=Math.min(r.contextPct||15,25)
447
+ r.roundsSinceCompact=0
448
+ r.needCompact=false
449
+ logActivity('compact', r.rId+' consolidated context')
450
+ }
451
+ }
452
+
453
+ // ---- messaging ----
454
+ async function postMessage(from,to,content){
455
+ const r=residents.get(to); if(!r) return {ok:false,message:'no such resident'}
456
+ if(!busy.has(to)){
457
+ currentResident=r.rId
458
+ await wakeResident(r, (await normalPrompt(r))+'\n\n[NEW MESSAGE from '+from+']\n'+content,'normal')
459
+ await saveAll(); markProgress(); logActivity('message',from+'→'+to); return {ok:true}
460
+ }
461
+ 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}
462
+ }
463
+ async function broadcast(content, from){
464
+ let n=0
465
+ for(const [,r] of residents){ if(from && r.rId===from) continue; const res=await postMessage(from||'facilitator',r.rId,content); if(res&&res.ok) n++ }
466
+ logActivity('broadcast','to '+n+' resident(s)'); await saveAll(); return {ok:true,message:'broadcast to '+n+' resident(s)'}
467
+ }
468
+ // group conversation relay: when a resident "speaks" (input in its round), forward its
469
+ // words to every other resident's inbox so the whole group can see & react — a real group chat.
470
+ async function relayToGroup(from, content){
471
+ const text=String(content||'').trim()
472
+ if(!text) return
473
+ for(const [,r] of residents){
474
+ if(r.rId===from) continue
475
+ const mb=mailboxes.get(r.rId)||[]; mb.push({from,at:now(),content:'[群聊] '+text}); mailboxes.set(r.rId,mb)
476
+ }
477
+ logActivity('relay',from+' → 团队: '+text.slice(0,60)); markProgress(); await saveAll()
478
+ }
479
+
480
+ // ---- meeting ----
481
+ async function startMeeting(agenda,type,targetId){
482
+ if(meetingState) return {ok:false,message:'meeting already in progress'}
483
+ // Park-and-resume (never lose a coordination request, never create a zombie): while a
484
+ // verification holds the floor, while the group is still brainstorming (members are busy in
485
+ // their first rounds — a meeting started there could not be serviced and the old code let its
486
+ // stall watchdog silently ABANDON it minutes later), or while the run is paused, the meeting
487
+ // request is parked in pendingMeeting (FIRST request wins) and starts as soon as the floor is
488
+ // free. A never-started session (no residents to talk) and a concluded run (autoDone) refuse
489
+ // instead — convening there previously created a meeting nobody could ever be woken into.
490
+ if(!running || autoDone || phase==='brainstorm' || verifyState || pendingVerify.length>0){
491
+ if(autoDone || (!running && residents.size===0)) return {ok:false,message:'run is not active (use vibe_v4_start or vibe_v4_resume first)'}
492
+ if(!pendingMeeting) pendingMeeting = { agenda, type:type||'general', targetId:targetId||null }
493
+ return {ok:true,deferred:true,during: phase==='brainstorm'?'brainstorm':(!running?'paused':'verify')}
494
+ }
495
+ clearHeartbeat()
496
+ const ids=Array.from(residents.keys())
497
+ // Rotate the per-meeting speaking order so the SAME resident isn't always the "first speaker
498
+ // who sees no one else's contribution"; a real discussion lets each member lead sometimes.
499
+ const rot=Math.floor(Math.random()*Math.max(1,ids.length))
500
+ const order=ids.slice(rot).concat(ids.slice(0,rot))
501
+ meetingState={id:'mt-'+shortId(),agenda,type:type||'general',targetId:targetId||null,round:0,asked:[],inputs:{},transcript:[],order,at:now(),lastInputAt:now()}
502
+ markProgress();
503
+ logActivity('meeting','start: '+agenda); await saveAll(); await scheduleNext(); return {ok:true,id:meetingState.id}
504
+ }
505
+ async function continueMeetingRound(){
506
+ if(!meetingState) return
507
+ const st=meetingState
508
+ // STUCK watchdog: a meeting that has been active but collected NO new input for a long while
509
+ // is deadlocked (e.g. an in-flight/hung resident, a run of failed wakes). Abandoning it returns
510
+ // the group to normal self-organization (A heartbeat / B auto-sync can then re-drive) instead of
511
+ // permanently blocking the whole group behind a broken meeting.
512
+ if(now()-(st.lastInputAt||st.at||now())>=recoverStallMs()){
513
+ meetingState=null; wakeKind.clear(); logActivity('meeting','abandoned (stuck: no resident spoke)')
514
+ await saveAll(); await scheduleNext(); return
515
+ }
516
+ const ids=Array.from(residents.keys()); const allSpoke=ids.every(id=>st.inputs[id]!==undefined)
517
+ if(allSpoke){ await finalizeMeeting(); return }
518
+ // only wake IDLE un-spoken residents (rotated order); in-flight ones re-trigger this on end.
519
+ // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-spoken resident into a normal
520
+ // mail round would delay the meeting and can starve the consensus past its watchdog if the mail
521
+ // backlog is large. Mail is delivered on scheduleNext passes when no consensus is in progress.
522
+ const order=st.order||ids
523
+ const id=order.find(x=>st.inputs[x]===undefined && !busy.has(x))
524
+ if(!id){ armHeartbeat(); return } // no idle un-spoken resident (a busy/hung one): re-check later
525
+ const r=residents.get(id)
526
+ const ok = await wakeResident(r, meetingPrompt(r,st), 'meeting'); await saveAll()
527
+ if(!ok) armHeartbeat() // a failed meeting wake must NOT silently hang the meeting
528
+ }
529
+ async function finalizeMeeting(){
530
+ if(finalizeLock) return // reentry guard: two onResidentEnd may both see allSpoke → only finalize once
531
+ finalizeLock='meeting'
532
+ let doSchedule=false
533
+ try {
534
+ const st=meetingState
535
+ const ids=Array.from(residents.keys()); const allSpoke=ids.length>0 && ids.every(id=>st.inputs[id]!==undefined)
536
+ const lines=['# 会议 '+st.id+'|'+fmtTime(),'','**议程**:'+st.agenda,'']
537
+ for(const [id,iv] of Object.entries(st.inputs)){ lines.push('### '+id); lines.push(iv.input||''); lines.push('') }
538
+ await writeText('Shared/meetings/'+st.id+'.md', lines.join('\n'))
539
+ // the full transcript lives on disk (Shared/meetings/<id>.md); the State array keeps a small
540
+ // index (id/agenda/at) so session.json does not carry a second copy of every transcript and
541
+ // rewrite it on EVERY saveAll during long runs (reports below are capped for the same reason).
542
+ meetings.push({id:st.id,agenda:st.agenda,at:now()}); if(meetings.length>200) meetings.shift()
543
+ logDecision('meeting',st.agenda)
544
+ // handle what the meeting produced: task proposals/claims, verify targets, stop vote
545
+ for(const [id,iv] of Object.entries(st.inputs)){
546
+ if(iv.propose_task) await proposeTask(iv.propose_task, iv.task_desc||'', id)
547
+ if(iv.claim_task) await claimTask(iv.claim_task, id)
548
+ if(iv.propose_verify) maybeQueueVerify(iv.propose_verify, id)
549
+ }
550
+ const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
551
+ const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
552
+ logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
553
+ if(allSolved){
554
+ running=false; autoDone=true; phase='done'; clearHeartbeat()
555
+ // symmetric with initAbort: the STOP path must also release the coordination state, else
556
+ // status keeps reporting a phantom in-progress meeting (and stale wake kinds) forever.
557
+ meetingState=null; wakeKind.clear(); pendingMeeting=null; verifyState=null; pendingVerify=[]
558
+ logActivity('stop','all residents agree: problem solved'); await saveAll(); return
559
+ }
560
+ meetingState=null; wakeKind.clear(); await saveAll()
561
+ doSchedule=true
562
+ } finally { finalizeLock=null } // release BEFORE scheduling so a chained verify/meeting is not swallowed
563
+ if(doSchedule) await scheduleNext()
564
+ }
565
+
566
+ // ---- verification (unanimous) ----
567
+ async function beginVerify(pv){
568
+ clearHeartbeat()
569
+ // Re-check dedup at ACTUAL start, not just at propose time: a resident may propose object X while
570
+ // X is already being verified (it does not know). That proposal sits in the pendingVerify queue;
571
+ // when the current X verify closes, beginVerify would run X end-to-end a SECOND time (test9:
572
+ // p-r3-04 was verified twice back-to-back). Drop it if X was closed within the dedup window.
573
+ // (pv was already popped from the FIFO queue by scheduleNext — nothing else to clear here.)
574
+ const tgt=pv&&pv.targetId?String(pv.targetId):''
575
+ if(tgt){
576
+ const last=verifiedRecently.get(tgt)
577
+ if(last!==undefined && (now()-last) < recoverStallMs()){
578
+ logActivity('verify',tgt+' queued verify dropped at start (just verified at '+fmtTime(last)+')')
579
+ await saveAll(); await scheduleNext(); return
580
+ }
581
+ }
582
+ verifyState={targetId:pv.targetId,targetType:pv.targetType,targetOwner:pv.proposer||'',stage:'independent',round:0,asked:[],verdicts:{},history:{},transcript:[],at:now(),lastVerdictAt:now()}
583
+ markProgress();
584
+ logActivity('verify','debate begin: '+pv.targetId+' ('+pv.targetType+')'); await saveAll(); await scheduleNext()
585
+ }
586
+ async function continueVerifyRound(){
587
+ if(!verifyState) return
588
+ const vs=verifyState
589
+ // STUCK watchdog: a verification that has been active but collected no new verdict for a long
590
+ // while is deadlocked (e.g. an in-flight/hung resident). Abandoning it keeps the object as an
591
+ // unverified probability (no unanimous consensus was reachable) and returns the group to normal
592
+ // scheduling instead of blocking the whole team behind a broken verification.
593
+ if(now()-(vs.lastVerdictAt||vs.at||now())>=recoverStallMs()){
594
+ verifyState=null; wakeKind.clear(); logActivity('verify',vs.targetId+' abandoned (stuck: no unanimous verdict reachable)')
595
+ await saveAll(); await scheduleNext(); return
596
+ }
597
+ const ids=Array.from(residents.keys()); const allVoted=ids.every(id=>vs.verdicts[id]!==undefined)
598
+ if(allVoted){ await finalizeVerify(); return }
599
+ // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-voted resident into a normal
600
+ // mail round would delay its verdict and can starve the verify past its watchdog when the backlog
601
+ // is large. Mail is delivered on scheduleNext passes when no consensus is in progress.
602
+ const id=ids.find(x=>vs.verdicts[x]===undefined && !busy.has(x))
603
+ if(!id){ armHeartbeat(); return } // no idle un-voted resident (a busy/hung one): re-check later
604
+ const r=residents.get(id)
605
+ const ok = await wakeResident(r, verifyPrompt(r,vs), vs.stage==='independent'?'verif-ind':'verif-deb'); await saveAll()
606
+ if(!ok) armHeartbeat() // a failed verify wake must NOT silently hang the verification
607
+ }
608
+ async function finalizeVerify(){
609
+ if(finalizeLock) return // reentry guard (two onResidentEnd may both see allVoted)
610
+ finalizeLock='verify'
611
+ let doSchedule=false
612
+ try {
613
+ const vs=verifyState; const expected=Array.from(residents.keys()).length
614
+ const allVoted = expected>0 && Object.keys(vs.verdicts).length>=expected
615
+ const vals=Object.values(vs.verdicts)
616
+ // verdict is a PURE 0-1 probability; only ALL=1 (true) or ALL=0 (false) is a binary verdict.
617
+ const allTrue = allVoted && vals.every(x=>Number(x.prob)===1)
618
+ const allFalse = allVoted && vals.every(x=>Number(x.prob)===0)
619
+ if(allTrue||allFalse){ await closeVerify(vs,allTrue); doSchedule=true }
620
+ else if(vs.round+1<params.verdictMaxRounds){
621
+ // Move to a REAL debate round: snapshot the current votes into history (so the next round's
622
+ // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
623
+ // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
624
+ // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
625
+ vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
626
+ vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
627
+ 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
628
+ }
629
+ else {
630
+ const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
631
+ await writeDebateDoc(vs,false,avg); await rewriteSourceProb(vs.targetId, avg, vs.targetOwner); logActivity('verify',vs.targetId+' NOT unanimous → kept unverified (avg '+avg.toFixed(2)+')')
632
+ verifyState=null; wakeKind.clear(); await saveAll(); doSchedule=true
633
+ }
634
+ } finally { finalizeLock=null } // release BEFORE scheduling (chained verifies must not be swallowed)
635
+ if(doSchedule) await scheduleNext()
636
+ }
637
+ async function closeVerify(vs,isTrue){
638
+ await writeDebateDoc(vs,true,isTrue?1:0)
639
+ const target=vs.targetId
640
+ await writeVerifiedCard(vs,isTrue)
641
+ await rewriteSource(target,isTrue,vs.targetOwner)
642
+ verifiedRecently.set(target, now()) // dedup: block an immediate re-proposal of the same object
643
+ logActivity('verify',target+' → Verified ('+(isTrue?'真':'假')+') by unanimous consensus')
644
+ verifyState=null; wakeKind.clear(); await saveAll()
645
+ // scheduling is done by finalizeVerify AFTER it releases finalizeLock (so a chained verify is
646
+ // never swallowed by the still-held reentry lock)
647
+ }
648
+ // Queue a verify proposal UNLESS the same object was just verified (closed as 真/假). In parallel
649
+ // self-organization several residents may independently propose targets while a verify is already
650
+ // settling — sometimes the SAME object (test9: p-r3-04 was Verified twice back-to-back), sometimes
651
+ // DIFFERENT objects (e.g. a sync meeting where each member proposes its own target). pendingVerify
652
+ // is therefore a FIFO queue with per-target dedup: every distinct proposal is honored in order, and
653
+ // duplicates collapse to one entry. A resident who genuinely extends the object later can still
654
+ // re-propose after the dedup window (recoverStallMs) has passed.
655
+ function maybeQueueVerify(target, proposer){
656
+ const t=idSafe(target) // sanitize BEFORE it becomes file names / dedup keys / status output
657
+ if(!t || t==='id') return false
658
+ const last=verifiedRecently.get(t)
659
+ if(last!==undefined && (now()-last) < recoverStallMs()){
660
+ logActivity('verify',t+' re-propose ignored (just verified at '+fmtTime(last)+')')
661
+ return false
662
+ }
663
+ if(pendingVerify.some(p=>String(p.targetId)===t)) return true // already queued → keep ONE entry
664
+ pendingVerify.push({targetId:t,targetType:guessTargetType(t),proposer:proposer||'',at:now()})
665
+ return true
666
+ }
667
+ async function writeDebateDoc(vs,done,val){
668
+ const lines=['# 验证辩论|'+vs.targetId+'('+vs.targetType+')|'+fmtTime(),'',(done?('**结论**:'+(val===1?'全体一致为真':'全体一致为假')):('**未达成全体一致**,平均概率 '+val.toFixed(2))),'','## 各常驻意见']
669
+ for(const [k,v] of Object.entries(vs.verdicts)){ lines.push('### '+k+'|正确概率 '+(v.prob!=null?Number(v.prob).toFixed(2):'0.50')); lines.push(v.reason||''); lines.push('') }
670
+ await writeText('Shared/debates/'+vs.targetId+'.md', lines.join('\n'))
671
+ }
672
+ async function writeVerifiedCard(vs,isTrue){
673
+ const isSub=vs.targetType==='subproblem'
674
+ const dir= isSub?'问题':'命题'
675
+ const type= isSub?'问题': vs.targetType==='method'?'方法':'命题'
676
+ const text='# 已验证|'+vs.targetId+'\n- ID: '+vs.targetId+'\n- 类型: '+type+'\n- 结论: '+(isTrue?'真':'假')+'\n- 概率: '+(isTrue?1:0)+'\n- 来源: 全体常驻一致\n## 陈述\n参见来源卡。\n'
677
+ await writeText('Verified/'+dir+'/'+vs.targetId+'.md', text)
678
+ }
679
+ // Does `content` declare the target as its card ID? Accept both the exact `- ID: <id>` and the
680
+ // compact single-line form (`- ID: <id>; - 状态: ...`). Residents write cards by hand via fs with
681
+ // varying formats and (crucially) sometimes put a DIFFERENT file name than the declared ID (e.g.
682
+ // Propos/r-3/p-01.md declares "- ID: p-r3-01"). Matching only on the file name then silently loses
683
+ // the verified-status write-back, so we scan candidates' declared ID too.
684
+ function cardDeclaresId(content, target){
685
+ if(!content || !target) return false
686
+ const m=/-\s*ID:\s*([^;\n]+)/.exec(content)
687
+ return !!(m && String(m[1]).trim()===String(target).trim())
688
+ }
689
+ async function findSourceRel(target, owner){
690
+ // 1) exact file name in the owner's library (fast path), then every resident's library
691
+ const order = owner ? [owner, ...Array.from(residents.keys()).filter(k=>k!==owner)] : Array.from(residents.keys())
692
+ for(const rid of order){
693
+ for(const base of ['Propos','Methods','Subproblems']){
694
+ const cand=base+'/'+rid+'/'+target+'.md'
695
+ const t0=await readText(cand); if(t0!==undefined) return cand
696
+ }
697
+ }
698
+ // 2) declared-ID scan: residents sometimes name the file differently from the declared card ID
699
+ // (e.g. p-01.md declares ID p-r3-01). Look inside every card of every library for the target ID.
700
+ try {
701
+ for(const rid of order){
702
+ for(const base of ['Propos','Methods','Subproblems']){
703
+ const dirT=await fs.resolve(base+'/'+rid, {cwd: frameworkRoot()})
704
+ if(await fs.stat(dirT)===undefined) continue
705
+ const entries=await fs.listDir(dirT)
706
+ for(const e of entries||[]){
707
+ if(!e || e.type!=='file' || !/\.md$/.test(String(e.name))) continue
708
+ const c=await readText(base+'/'+rid+'/'+e.name)
709
+ if(c!==undefined && cardDeclaresId(c,target)) return base+'/'+rid+'/'+e.name
710
+ }
711
+ }
712
+ }
713
+ } catch(e){ /* scanning is best-effort */ }
714
+ return null // NOT 'Propos/'+target+'.md': writing there would create a stray empty card
715
+ }
716
+ // Update the `- 状态:` / `- 概率:` fields of a source card. Residents hand-write cards in two
717
+ // shapes: one field per line, or one line with `; `-separated fields. Accept both by allowing the
718
+ // anchor anywhere on a line and consuming up to the next `;` when fields share the line.
719
+ function rewriteCardField(text, field, newValue){
720
+ if(!text) return text
721
+ const esc=field.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')
722
+ // one-per-line: `- 状态: ...\n` OR inline: `; - 状态: ...;` / `- 状态: ...; - 概率:`
723
+ const re=new RegExp('(^|\\n|;\\s*)-\\s*'+esc+':[^;\\n]*','gm')
724
+ const replaced=text.replace(re,'$1- '+field+': '+newValue)
725
+ return replaced===text ? text : replaced
726
+ }
727
+ // non-unanimous verification: keep the object in its library but write back the
728
+ // average probability (design §8: "留库附概率"), so the card reflects the consensus estimate.
729
+ async function rewriteSourceProb(target,prob,owner){
730
+ const rel=await findSourceRel(target,owner)
731
+ if(!rel){ logActivity('verify',target+' source card NOT found; avg prob '+Number(prob).toFixed(2)+' not written back'); return }
732
+ let text=(await readText(rel))||''
733
+ const next=rewriteCardField(text,'概率',Number(prob).toFixed(2))
734
+ await writeText(rel,next||text)
735
+ }
736
+ async function rewriteSource(target,isTrue,owner){
737
+ // find & update the source card status/prob; best effort across per-resident libs
738
+ const rel=await findSourceRel(target,owner)
739
+ if(!rel){ logActivity('verify',target+' source card NOT found; verified status not written back'); return }
740
+ let text=(await readText(rel))||''
741
+ let next=rewriteCardField(text,'状态',isTrue?'已验证·真':'已验证·假')
742
+ next=rewriteCardField(next,'概率',isTrue?'1':'0')
743
+ await writeText(rel,next||text)
744
+ }
745
+ function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return 'proposition' }
746
+
747
+ // ---- heartbeat / liveness helpers (boundary-A: event-driven + gated heartbeat) ----
748
+ // A checkpoint wake is NOT "keep working forever": it nudges the least-recently-active
749
+ // resident, after an idle timeout, to CONTINUE the work itself (self-drive), and only to
750
+ // propose a meeting/verify/solved when it truly has nothing left. This keeps the group moving
751
+ // on its own (framework never assigns work) but adds convergence pressure instead of letting
752
+ // a stalled group sit idle forever.
753
+ function heartbeatPrompt(r){
754
+ return (params.residentPersona?params.residentPersona+'\n':'')
755
+ +'Resident researcher '+r.rId+' — CHECKPOINT(团队空闲,请由你们继续自主推进)。当前项目尚未解决(除非你已确认)。团队在等待有人继续:请**继续解决这个问题**——读他人的库对齐、推进某个子问题/引理/方法、尝试一条路线;或向团队发消息(input)、提议任务(propose_task)让大家分工。若你确实认为问题已解决、或已彻底无路可走,才提议开会(propose_meeting)让团队表决/商量、或声明 solved=true。默认立场是:**请推进,而不是停在原地。**\n'
756
+ +'Reply with ONLY a JSON object:\n'
757
+ +'{"summary":"<what you will do / what you advanced this round>","input":"<optional: a message to the whole team, or \\"\\">","solved":false,"propose_verify":"<id|null>","propose_meeting":"<agenda|null>","propose_task":"<task title|null>","task_desc":"<optional: why this task matters / what it covers|null>","claim_task":"<id|null>","contextPct":40}'
758
+ }
759
+ function clearHeartbeat(){ if(heartbeatDisposer!==null){ try{ heartbeatDisposer() }catch(e){} heartbeatDisposer=null } }
760
+ function armHeartbeat(){
761
+ clearHeartbeat()
762
+ const ms=posMs(params.activityTimeoutMs,120000)
763
+ if(typeof ctx.timeout!=='function') return
764
+ heartbeatDisposer=ctx.timeout(()=>{ heartbeatDisposer=null; scheduleNext().catch(()=>{}) }, ms)
765
+ }
766
+ // Real DSH /compact of a resident's OWN session via ctx.compaction (if the host provides it);
767
+ // falling back silently to the resident self-summary directive when the service is absent.
768
+ //
769
+ // WHY THE LIVE-AGENT CACHE EXISTS: `subagent/end` fires AFTER the child's Activation has
770
+ // been torn down. The host's teardown order is
771
+ // dsh-subagent/lib/index.js:1231 await activation.handle.dispose()
772
+ // dsh-agent/lib/index.js:508 this.store.delete(entry.id) <- child leaves the registry
773
+ // dsh-subagent/lib/index.js:1241 activation.observer.settle(...) <- ONLY NOW is subagent/end emitted
774
+ // so `agents.get(childId)` inside an end handler ALWAYS returns undefined. Looking the child
775
+ // up there made this entire path dead code. Instead we capture the live Agent when
776
+ // `subagent/start` fires (the child is still registered then) and hold it in a WeakRef so a
777
+ // resident that is never released cannot pin its Agent forever.
778
+ const liveAgents = new Map() // childId -> WeakRef<Agent>
779
+ function rememberAgent(childId, agent){ if(childId && agent){ try { liveAgents.set(childId, new WeakRef(agent)) } catch(e){ liveAgents.set(childId, { deref:()=>agent }) } } }
780
+ function forgetAgent(childId){ liveAgents.delete(childId) }
781
+ function liveAgentOf(childId){
782
+ const ref=liveAgents.get(childId)
783
+ if(ref){ const a=typeof ref.deref==='function'?ref.deref():undefined; if(a) return a }
784
+ // fallback: a host that keeps the child registered through the end notification
785
+ try { return agents.get(childId) } catch(e){ return undefined }
786
+ }
787
+ /**
788
+ * Resolve the compaction service a CHILD AGENT should use — i.e. through that
789
+ * agent's own context, not this plugin's.
790
+ *
791
+ * Each preset group declares `isolate: { compaction: true, toolResultPruner: true }`
792
+ * (copied verbatim from DSH's own `standard` preset, whose comment states the realm's
793
+ * purpose: "What a preset chooses is whether its agent compacts at all, which is
794
+ * `compaction-basic` below"). A row sitting OUTSIDE that realm resolves the HOST ROOT
795
+ * instance instead, so this plugin's calls would ignore the preset's own compaction
796
+ * config, and the sub-agent's step-boundary compaction (which runs from inside the
797
+ * realm) would use a different instance than the framework's calls here.
798
+ *
799
+ * `Agent.ctx` is documented as "Agent-scoped context; its contributions are
800
+ * agent-local" (dsh-agent/lib/types/runtime-types.d.ts:148), so the child's own
801
+ * context resolves the preset plane the child actually lives on. This keeps the
802
+ * plugin row where it is (nothing else moves in or out of the realm) while making
803
+ * both compaction paths agree on one instance.
804
+ */
805
+ function compactionForAgent(agent){
806
+ try { const c = agent && agent.ctx ? agent.ctx.get('compaction') : undefined; if(c && c.compactIfNeeded) return c } catch(e){}
807
+ // fallback: this plugin's own plane (host root for a row outside the realm)
808
+ return compactionOf()
809
+ }
810
+ async function realCompact(r){
811
+ if(!r || !r.childId) return
812
+ const agent = liveAgentOf(r.childId)
813
+ if(!agent || !agent.session) return
814
+ const compaction = compactionForAgent(agent)
815
+ if(compaction===undefined || !compaction.compactIfNeeded) return
816
+ try {
817
+ const signal = makeSignal(params.activityTimeoutMs||60000)
818
+ const result = await compaction.compactIfNeeded(agent, 'pressure', signal)
819
+ if(result && (result.shadowedSeqs||[]).length>0){
820
+ // the resident's real session was compacted → its context is now a summary.
821
+ // Flag needCompact so the NEXT wake re-anchors the core rules (they may have been blurred).
822
+ r.roundsSinceCompact=0; r.needCompact=true; r.contextPct=Math.min(r.contextPct||15,25)
823
+ logActivity('compact', r.rId+' real /compact (shadowed '+result.shadowedSeqs.length+' items, ~'+String(result.shadowedTokenCount||0)+' tokens)')
824
+ }
825
+ } catch(e){ /* real compaction unavailable/failed; the soft directive already covers it */ }
826
+ }
827
+
828
+ // ---- liveness / scheduling ----
829
+ async function scheduleNext(){
830
+ if(!running||autoDone){ clearHeartbeat(); return }
831
+ if(phase==='brainstorm'){ await maybeFinishBrainstorm(); return }
832
+ if(meetingState){ await continueMeetingRound(); return }
833
+ if(verifyState){ await continueVerifyRound(); return }
834
+ if(pendingVerify.length){ const pv=pendingVerify.shift(); await beginVerify(pv); return }
835
+ // A meeting requested while a verify held the floor is parked in pendingMeeting; once the
836
+ // verify queue has truly drained (no verifyState / pendingVerify), resume it before anything else.
837
+ if(pendingMeeting){ const pm=pendingMeeting; pendingMeeting=null; await startMeeting(pm.agenda, pm.type, pm.targetId); return }
838
+ // mailbox delivery
839
+ const delivered=await deliverNextMailbox(); if(delivered) return
840
+ // maxParallel: don't start a new wake when the in-flight cap is reached
841
+ const mp=Number(params.maxParallel)||0
842
+ if(mp>0 && busy.size>=mp){ armHeartbeat(); return }
843
+ // B) stall auto-sync meeting (分级保活 B): the group has been idle with NO progress for
844
+ // stallAutoMeetingMs → convene a sync meeting so the residents coordinate their next move
845
+ // (framework convenes & records; residents decide — never assigns work). Only when no
846
+ // meeting/verify/pending work is active AND no resident is currently working (so it never
847
+ // preempts an in-flight round).
848
+ if(phase==='active' && !meetingState && !verifyState && pendingVerify.length===0 && busy.size===0){
849
+ const stallMs=posMs(params.stallAutoMeetingMs, posMs(params.activityTimeoutMs,120000)*3)
850
+ if(now()-lastProgressAt>=stallMs){
851
+ await startMeeting('团队较长时间没有新进展。请你们自行讨论:当前问题是否已解决、开放难点是什么、谁负责哪部分、下一步如何推进,并自主决定是否继续。框架只负责转达与记录,不替你们决定。','general',null)
852
+ return
853
+ }
854
+ }
855
+ // A) heartbeat / coordination: wake IDLE residents after an idle timeout to SELF-DRIVE (continue
856
+ // solving / message / propose task / meeting / verify). This is a CONCURRENCY FILL, not a
857
+ // single nudge: scheduleNext should wake up to `maxParallel` idle residents in one pass so the
858
+ // group can progress in parallel (design §A: "同一时刻可唤醒多个空闲常驻,受 maxParallel 上限").
859
+ // On a FAILED wake we re-arm the heartbeat so a single follow-up error NEVER permanently stops
860
+ // the group (a successful wake re-drives scheduleNext through its own onResidentEnd, which re-arms).
861
+ clearHeartbeat()
862
+ const atOs=posMs(params.activityTimeoutMs,120000)
863
+ // `mp` (maxParallel) is already declared above in this function scope.
864
+ // Collect idle (not busy) residents sorted by idle time, oldest-first (round-robin fairness).
865
+ const idleCandidates = Array.from(residents.values())
866
+ .filter(r=>!busy.has(r.rId))
867
+ .sort((a,b)=>(now()-b.lastActiveAt)-(now()-a.lastActiveAt))
868
+ // Fill the concurrency budget: keep waking the most-idle resident until either everyone idle is
869
+ // started OR the in-flight cap (maxParallel) is reached. This turns the previous "one at a time"
870
+ // serialization into genuine parallel progress.
871
+ let started=0
872
+ for(const r of idleCandidates){
873
+ const free = mp>0 ? (mp - busy.size) : Number.MAX_SAFE_INTEGER
874
+ if(free<=0) break // concurrency cap reached → stop filling
875
+ if((now()-r.lastActiveAt)<atOs) break // the remaining are all busy-or-not-idle-enough
876
+ let ok=false
877
+ try { ok = await wakeResident(r, await heartbeatPrompt(r), 'normal') } catch(e){ ok=false }
878
+ if(ok) started++
879
+ await saveAll()
880
+ if(!ok) continue // a failed wake must NOT stop the fill; try the next idle resident
881
+ }
882
+ if(started>0) { armHeartbeat(); return } // started some; re-drive comes via onResidentEnd, BUT also arm a
883
+ // safety-net heartbeat so a woken resident whose subagent/end NEVER arrives (a hung normal round) does not
884
+ // freeze the group. Below mp, the next scheduleNext will re-fill; if all woken residents are stuck busy the
885
+ // heartbeat just re-arms harmlessly. (Meeting/verify already have recoverStallMs watchdogs; normal A-fill did not.)
886
+ // everyone is busy or not idle-enough: arm a heartbeat to re-check later (no infinite spin)
887
+ armHeartbeat()
888
+ }
889
+ async function maybeFinishBrainstorm(){
890
+ const pending=[]; for(const [,r] of residents){ if(r.status==='brainstorm' && !r.insight) pending.push(r.rId) }
891
+ if(pending.length===0){ phase='active'; logActivity('phase','active — residents now self-organize'); await saveBrainstormSummary(); await saveAll(); await scheduleNext() }
892
+ }
893
+ async function saveBrainstormSummary(){
894
+ const lines=['# 头脑风暴','']; for(const [,r] of residents){ if(r.insight){ lines.push('## '+r.rId+'「'+(r.direction||'')+'」'); lines.push(r.insight); lines.push('') } }
895
+ await writeText('Shared/meetings/brainstorm.md', lines.join('\n'))
896
+ }
897
+ async function deliverNextMailbox(){
898
+ // Deliver queued messages to ALL currently-idle recipients in one pass (parallel), bounded by the
899
+ // same maxParallel concurrency cap, so a group chat (relayToGroup → many non-busy recipients) is
900
+ // not serialized one-message-at-a-time. Returns true if anything was delivered. A busy recipient
901
+ // keeps its message queued (avoid starving others).
902
+ let delivered=false
903
+ for(const [to,msgs] of mailboxes){
904
+ if(msgs.length===0) continue
905
+ const r=residents.get(to); if(!r){ mailboxes.delete(to); continue } // stale recipient → drop the entry
906
+ if(busy.has(to)) continue // recipient busy → leave the message queued for a later pass
907
+ const mp=Number(params.maxParallel)||0
908
+ if(mp>0 && busy.size>=mp) break // concurrency cap reached → stop delivering more now
909
+ const m=msgs.shift()
910
+ currentResident=to
911
+ const ok = await wakeResident(r, (await normalPrompt(r))+'\n\n[MESSAGE from '+m.from+']\n'+m.content,'normal')
912
+ await saveAll(); if(!ok) msgs.unshift(m); delivered=delivered||ok
913
+ }
914
+ return delivered
915
+ }
916
+
917
+ // ---- resident end handler ----
918
+ async function onResidentEnd(childId, info){
919
+ const r=byChild(childId); if(!r) return
920
+ // A turn that is NOT marked busy is a duplicate/stale end (the same subagent/end delivered twice,
921
+ // or an end for a turn already settled). Without this guard every side effect below — task
922
+ // proposal, group relay, verify queueing, meetings.push — would run a SECOND time (the
923
+ // duplicate-task/duplicate-stop class from test9 reappears whenever a host re-delivers an end).
924
+ // Every legitimate end corresponds to a busy turn: busy is added at spawn/wake and cleared only
925
+ // here, on wake failure, on removeMember, or on respawn (whose stale childIds no longer match).
926
+ if(!busy.delete(r.rId)) return
927
+ // Any resident turn that COMPLETED is real activity for the stall clock (B). Residents frequently
928
+ // write their libraries via direct fs (not the record* tools), so relying only on
929
+ // bumpArtifacts/markProgress would leave lastProgressAt stale and B would fire against an active
930
+ // team. We count only a clean 'completed' turn: an error/max-tokens/refusal did NOT meaningfully
931
+ // advance the work, so it must NOT mask a truly stalled group (B can then convene a recovery
932
+ // meeting). A completed turn also refreshes the meeting/verify deadlock clock through lastInputAt.
933
+ if(info && info.stopReason==='completed') markProgress()
934
+ realCompact(r).catch(()=>{}) // best-effort real DSH /compact of this resident while idle
935
+ const output=blocksToText(info&&info.lastAssistantMessage)
936
+ const parsed=parseReply(output)
937
+ postmark(r, parsed) // context/compact bookkeeping, regardless of wake kind (clears any leak)
938
+ const kind=wakeKind.get(r.rId)||'normal'
939
+ if(kind==='meeting' && meetingState){
940
+ 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}
941
+ meetingState.lastInputAt=now()
942
+ if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
943
+ await saveAll()
944
+ // PAUSE/stop: record the in-flight input/verdict but do NOT start any NEW consensus wake —
945
+ // a paused run must stay paused (resume() refreshes the consensus clocks and re-drives).
946
+ if(!running || autoDone) return
947
+ await continueMeetingRound(); return
948
+ }
949
+ if((kind==='verif-ind'||kind==='verif-deb') && verifyState){
950
+ const v=(parsed&&parsed.vote)||{}
951
+ // verdict = 0-1 probability the object is TRUE (1=绝对真, 0=绝对假, 0.5=不确定);
952
+ // also accept legacy 'TRUE'/'FALSE' strings AND quoted numeric strings ("0.9"), which LLMs
953
+ // occasionally emit — without this a confident "0.9" was silently misread as 0.5 (uncertainty).
954
+ let p
955
+ if(typeof v.verdict==='number'){ p=clamp01(v.verdict) }
956
+ else if(/^TRUE$/i.test(String(v.verdict))){ p=1 }
957
+ else if(/^FALSE$/i.test(String(v.verdict))){ p=0 }
958
+ else if(typeof v.verdict==='string' && v.verdict.trim()!=='' && Number.isFinite(Number(v.verdict))){ p=clamp01(Number(v.verdict)) }
959
+ else { p=clamp01(Number(v.confidence)) }
960
+ // verdict is a PURE 0-1 probability (a degree); no binary TRUE/FALSE classification.
961
+ verifyState.verdicts[r.rId]={prob:p,confidence:p,reason:String(v.reason||parsed.summary||'')}
962
+ verifyState.lastVerdictAt=now()
963
+ await saveAll()
964
+ if(!running || autoDone) return // pause: freeze (resume refreshes the clocks and re-drives)
965
+ await continueVerifyRound(); return
966
+ }
967
+ // normal turn
968
+ if(r.status==='brainstorm'){ r.insight=parsed.summary||output; r.status='active' }
969
+ if(typeof parsed.solved==='boolean'){ reports.push({rId:r.rId,solved:parsed.solved,summary:parsed.summary||'',at:now()}); if(reports.length>100) reports.shift() } // solved-signal ring (not surfaced in status/report)
970
+ if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
971
+ // group-conversation relay: the resident may choose to speak to the whole team (input) —
972
+ // forward it to the others so this is a real discussion group, not private monologues.
973
+ if(typeof parsed.input==='string' && parsed.input.trim()) await relayToGroup(r.rId, parsed.input.trim())
974
+ // task actions via reply (a resident may propose or claim a task in its round)
975
+ if(parsed.propose_task) await proposeTask(parsed.propose_task, parsed.task_desc||'', r.rId)
976
+ if(parsed.claim_task) await claimTask(parsed.claim_task, r.rId)
977
+ if(parsed.task_done) await taskDone(parsed.task_done, r.rId)
978
+ // a resident may self-trigger a meeting (resident-driven coordination, closest to the philosophy).
979
+ // If a verify is holding the floor the meeting is deferred (pendingMeeting) and we fall through
980
+ // so the pending verify (or mailbox/heartbeat) still advances rather than being stuck behind it.
981
+ if(parsed.propose_meeting && !meetingState){
982
+ const mr=await startMeeting(String(parsed.propose_meeting),'general',null); await saveAll()
983
+ if(mr && !mr.deferred) return
984
+ }
985
+ await saveAll(); await scheduleNext()
986
+ }
987
+
988
+ // ---- controls ----
989
+ async function start({problem,residentCount,seedDirections}){
990
+ await loadSettings()
991
+ currentProject=await readCurrentProject(); if(!currentProject||currentProject==='default'){ currentProject='default'; }
992
+ await ensureDirs()
993
+ if(problem) problemText=String(problem)
994
+ if(!problemText) return {ok:false,message:'problem text required (pass problem, or use vibe_v4_configure first)'}
995
+ problemId=slugify(problemText.slice(0,40))||'problem'
996
+ if(residentCount) params.residentCount=Number(residentCount)||4
997
+ if(!(Number(params.residentCount)>=1)) params.residentCount=DEFAULT_PARAMS.residentCount // a 0/negative count (settings misconfig) would spawn nobody & idle forever
998
+ running=true; autoDone=false; phase='brainstorm'
999
+ await writeText('Problems/'+problemId+'.md','# 问题|'+problemId+'\n- ID: '+problemId+'\n- 类型: 问题\n- 状态: 求解中\n- 优先级: 1\n- 依赖: []\n\n## 陈述\n'+problemText+'\n')
1000
+ // A reused session may still have OLD residents in flight from a previous run (start is a FRESH
1001
+ // run that reuses the same r-1.. library paths). Interrupt them BEFORE resetting, otherwise their
1002
+ // still-running turns keep writing into the same per-resident files the new run is about to use.
1003
+ for(const [,or] of residents){ if(or.childId){ try{ subagents.interrupt(or.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } }
1004
+ residents=new Map(); mailboxes=new Map(); taskboard=[]; decisions=[]; meetings=[]; reports=[]; verifyState=null; meetingState=null; pendingVerify=[]; residentSeq=0; artifactCount=0; clearHeartbeat()
1005
+ 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
1006
+ lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
1007
+ const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
1008
+ for(let i=0;i<params.residentCount;i++){ const r=newResident(dirs[i]||''); await spawnResident(r) }
1009
+ await saveAll(); return {ok:true,message:'v4 started: '+params.residentCount+' resident(s) brainstorming',project:currentProject}
1010
+ }
1011
+ async function resume(){
1012
+ currentProject=await readCurrentProject(); await ensureDirs()
1013
+ // Resume is only meaningful for a stopped/paused/crashed run. If THIS process is already driving
1014
+ // a live run whose disk state belongs to it, loadAll below would overwrite the in-memory state
1015
+ // with a slightly stale snapshot (busy marks, wake round counters, mailbox contents from the last
1016
+ // saveAll) — a silent clobber for a useless "kick". No-op instead. A cross-process restart is
1017
+ // always allowed: its disk epoch differs, so the in-memory state is empty/stale anyway.
1018
+ const pre=await readJson('State/session.json')
1019
+ if(running && !autoDone && pre && pre.processEpoch===processEpoch) return {ok:true,message:'already running (no-op)'}
1020
+ await loadAll(); await loadSettings()
1021
+ // A run the group CONCLUDED (unanimous voteSolved → autoDone) must not be silently revived into
1022
+ // a zombie that keeps waking residents with no consensus that it should still run. The group
1023
+ // decided it is done; continuing means a NEW run (vibe_v4_start / vibe_v4_configure).
1024
+ if(autoDone) return {ok:false,message:'This run already concluded (all residents agreed solved). Start a fresh run with vibe_v4_start (vibe_v4_configure a new problem first if needed).'}
1025
+ // After loadAll the residentSeq counter is still whatever THIS process had (0 on a fresh process),
1026
+ // but persisted residents may already be r-1..r-N. Sync it to the max existing id so a later
1027
+ // addMember never collides with an existing resident (it would silently overwrite it).
1028
+ for(const key of residents.keys()){ const mm=/^r-(\d+)$/.exec(String(key)); if(mm) residentSeq=Math.max(residentSeq, Number(mm[1])) }
1029
+ lastActivityAt=now(); lastProgressAt=now() // pause must not count as stall time; a resumed run gets a fresh clock
1030
+ if(phase==='idle' && !running && residents.size===0) return {ok:false,message:'nothing to resume'}
1031
+ // If the persisted State came from a DIFFERENT process (crash/restart), the saved
1032
+ // childIds are stale; clear them so residents re-spawn (their libraries persist on
1033
+ // disk and re-seed the resumed run). Same-process pause→resume keeps continuable ids.
1034
+ const crossProcess = persistedEpoch !== processEpoch
1035
+ if(crossProcess){ for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.roundsSinceCompact=0 } }
1036
+ // ANY re-spawn (cross-process OR a same-process abort that already cleared childIds) must get a FRESH
1037
+ // coordination/concurrency state and a brainstorm phase. Otherwise: re-spawned brainstorm residents run
1038
+ // under phase='active' (brainstorm summary never written), and a LATE subagent/end from an interrupted
1039
+ // OLD resident (same rId) deletes the NEW resident's busy mark → A-fill can wake it mid-brainstorm.
1040
+ const needRespawn = Array.from(residents.values()).some(r=>!r.childId)
1041
+ if(needRespawn){
1042
+ for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.insight=''; r.roundsSinceCompact=0 }
1043
+ busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; pendingVerify=[]; verifyState=null; meetingState=null; finalizeLock=null; verifiedRecently.clear()
1044
+ }
1045
+ for(const [,r] of residents){ if(!r.childId){ await spawnResident(r) } }
1046
+ if(!running){ running=true; autoDone=false; if(phase==='idle') phase='active' }
1047
+ if(needRespawn && phase!=='brainstorm') phase='brainstorm' // let re-spawned residents re-bootstrap together
1048
+ // A pause froze an in-progress meeting/verify with its watchdog clock still running: refresh the
1049
+ // clocks so a resumed consensus gets a full fresh stall window instead of being abandoned the
1050
+ // instant it is serviced again (a short pause must never silently kill a real discussion).
1051
+ if(meetingState && meetingState.lastInputAt) meetingState.lastInputAt=now()
1052
+ if(verifyState && verifyState.lastVerdictAt) verifyState.lastVerdictAt=now()
1053
+ logActivity('resume','restarted'+(crossProcess?' (cross-process: re-spawned)':needRespawn?' (re-spawned)':'')); await saveAll(); await scheduleNext(); return {ok:true,message:'resumed',project:currentProject}
1054
+ }
1055
+ function status(){ return { ok:true, running, phase, autoDone, project:currentProject, residentCount:residents.size,
1056
+ residents:listResidents(), busy:[...busy], taskboard:taskboard.length,
1057
+ meetingInProgress: !!(meetingState), verifyInProgress: !!(verifyState), pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null, pendingVerifyCount: pendingVerify.length,
1058
+ parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
1059
+ params:['residentCount','compactAfterRounds','compactThreshold','maxParallel','activityTimeoutMs','meetingKeepEvery','verdictMaxRounds','stallAutoMeetingMs','provider','model','residentPersona','toolAllow','toolDeny'].map(k=>k+'='+(Array.isArray(params[k])?params[k].join(','):params[k])).join(', ') } }
1060
+ function report(){ return { ok:true, running, phase, autoDone, project:currentProject, problem:problemText,
1061
+ residents:listResidents(), taskboard:taskboard.filter(t=>t.status!=='done'),
1062
+ meeting: meetingState?{id:meetingState.id, agenda:meetingState.agenda, spoke:Object.keys(meetingState.inputs).length+'/'+residents.size}:null,
1063
+ verify: verifyState?{target:verifyState.targetId,stage:verifyState.stage, voted:Object.keys(verifyState.verdicts).length+'/'+residents.size}:null,
1064
+ pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null,
1065
+ parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
1066
+ meetings:meetings.length, recentActivity: activityLog.slice(-8) } }
1067
+ async function addMember(direction){
1068
+ // Adding a member starts a REAL resident turn (spawnResident → brainstorm) — refuse unless the
1069
+ // run is live: on a concluded (autoDone) or never-started/paused run the new member would work
1070
+ // with nobody to coordinate (zombie work on a project the group already declared done).
1071
+ if(!running || autoDone) return {ok:false,message:'no active run to join (start or resume first)'}
1072
+ const r=newResident(direction||''); await spawnResident(r)
1073
+ // Mid-meeting additions must join the meeting's speaking order; otherwise allSpoke (over CURRENT
1074
+ // residents) can never be true for the new member (not in the snapshot order) and the meeting is
1075
+ // only ever released by the stuck watchdog instead of finalizing with everyone's input.
1076
+ if(meetingState){ if(!Array.isArray(meetingState.order)) meetingState.order=Array.from(residents.keys()); if(!meetingState.order.includes(r.rId)) meetingState.order.push(r.rId) }
1077
+ // Mid-verify additions are automatically asked to vote (continueVerifyRound recomputes ids from
1078
+ // the live residents map), so no extra handling is needed there.
1079
+ return {ok:true,id:r.rId,direction:r.direction} }
1080
+ async function removeMember(id){ const r=residents.get(id); if(!r) return {ok:false}; if(r.childId){ try{ subagents.interrupt(r.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } residents.delete(id); busy.delete(id); mailboxes.delete(id); wakeKind.delete(id); if(currentResident===id) currentResident=''
1081
+ // Reconcile in-progress coordination so a removed member cannot hang consensus or crash a round:
1082
+ // drop its meeting speech / verify verdict and prune it from the meeting's speaking order so the
1083
+ // find() there never selects a ghost. Its QUEUED verify proposals are deliberately KEPT: a
1084
+ // proposal is a statement about an OBJECT the group can judge on its merits with its CURRENT
1085
+ // members (allVoted recomputes over the live residents), and dropping the queue entry would also
1086
+ // erase the intent of any OTHER member who independently proposed the same target (dedup keeps
1087
+ // only the first entry, which may belong to the removed member).
1088
+ if(meetingState){ delete meetingState.inputs[id]; meetingState.order=(meetingState.order||[]).filter(x=>x!==id) }
1089
+ if(verifyState){ delete verifyState.verdicts[id] }
1090
+ await saveAll()
1091
+ // Re-drive the scheduler right away. If the removed member was the ONLY turn in flight (e.g. the
1092
+ // last unspoken meeting speaker / the last unvoted voter, interrupted mid-turn), NO subagent/end
1093
+ // will ever arrive to trigger the next pass, and while a consensus is being serviced no heartbeat
1094
+ // is armed either — without this kick the meeting/verify would freeze forever behind members that
1095
+ // can already conclude. scheduleNext no-ops safely when the run is paused/stopped.
1096
+ await scheduleNext()
1097
+ return {ok:true} }
1098
+ // Normalize one parameter value to its intended type so a string from /v4 set or configure
1099
+ // becomes the right number/array. Keeps settings.json clean regardless of how it was set.
1100
+ function normalizeParam(k, v){
1101
+ const INT_KEYS=['residentCount','compactThreshold','compactAfterRounds','maxParallel','activityTimeoutMs','verdictMaxRounds','meetingKeepEvery','stallAutoMeetingMs']
1102
+ if(INT_KEYS.includes(k)){ const n=Number(v); if(!Number.isFinite(n)) return v; return Math.floor(n) }
1103
+ if(k==='toolAllow'||k==='toolDeny'){ if(Array.isArray(v)) return v.map(x=>String(x).trim()).filter(Boolean); if(typeof v==='string') return v.split(',').map(x=>x.trim()).filter(Boolean); return [] }
1104
+ return v
1105
+ }
1106
+ /**
1107
+ * 并发闸门相关参数的下界。
1108
+ *
1109
+ * `maxParallel` 在调度里被当成"0 或负数 = 不限流"(见 scheduleNext 的 `mp>0` 守卫与
1110
+ * `free = mp>0 ? mp-busy.size : MAX_SAFE_INTEGER`),所以用户设成 0 会让闸门**完全失效**、
1111
+ * 一次唤醒全部常驻,与"同时唤醒的常驻上限"语义正好相反 —— 这里抬到最小合法值 1。
1112
+ * `residentCount` 同理(0 会一个常驻都不建)。
1113
+ *
1114
+ * 注意**不要**给 activityTimeoutMs / compactThreshold 之类加下界:文档明确建议测试时把
1115
+ * activityTimeoutMs 设成 40ms 这种小值,钳制它会破坏受支持的配置。
1116
+ */
1117
+ const INT_MIN = { maxParallel: 1, residentCount: 1 }
1118
+ function clampInt(k, n){
1119
+ const min = INT_MIN[k]
1120
+ if(min===undefined) return n
1121
+ return n < min ? min : n
1122
+ }
1123
+ 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} }
1124
+ // ---- create / configure (no auto-start) + settings-file persistence ----
1125
+ 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 } } } }
1126
+ async function saveSettings(){ await writeJson('State/settings.json', params) }
1127
+ // Create/configure a project and set params/problem WITHOUT starting any resident.
1128
+ // The intended flow: vibe_v4_configure {project?, problem?, params?} → vibe_v4_start {}.
1129
+ async function configure(cfg){
1130
+ // configure is the PRE-START setup tool (project/problem/params). Switching the project while a
1131
+ // run is LIVE would split the run's state across two trees: residents' briefs & libraries point
1132
+ // at the OLD frameworkRoot while every subsequent saveAll/transcript/Verified card would go to the
1133
+ // NEW project. Params tuning mid-run belongs to vibe_v4_set.
1134
+ if(running && !autoDone) return {ok:false,message:'cannot configure while a run is running (pause or abort first; use vibe_v4_set to tune params)'}
1135
+ if(cfg && cfg.project && String(cfg.project).trim()) currentProject=String(cfg.project).trim()
1136
+ if(cfg && cfg.problem) problemText=String(cfg.problem)
1137
+ if(cfg && cfg.params && typeof cfg.params==='object') setParams(cfg.params)
1138
+ await writeCurrentProject(); await ensureDirs(); await saveSettings()
1139
+ // create the problem card so the project is complete BEFORE the run starts
1140
+ if(problemText){ const pid=slugify(problemText.slice(0,40))||'problem'; await writeText('Problems/'+pid+'.md','# 问题|'+pid+'\n- ID: '+pid+'\n- 类型: 问题\n- 状态: 求解中\n- 优先级: 1\n- 依赖: []\n\n## 陈述\n'+problemText+'\n') }
1141
+ await saveAll()
1142
+ return {ok:true,project:currentProject,problem:problemText?problemText.slice(0,60):'',params:Object.keys(params).map(k=>k+'='+params[k]).join(', ')}
1143
+ }
1144
+ async function initAbort(){ clearHeartbeat(); running=false; phase='idle'; autoDone=false; for(const [,r] of residents){ if(r.childId){ try{ subagents.interrupt(r.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } r.childId=''; r.lastActiveAt=0; r.roundsSinceCompact=0 }
1145
+ // Wipe the coordination state too: an aborted run must not report an in-flight meeting/verify,
1146
+ // a parked meeting, a verify queue, or busy residents (their childIds are gone, so no end event
1147
+ // can ever clear those marks). resume()/start() re-initialize anyway; this keeps status truthful
1148
+ // between abort and the next action.
1149
+ meetingState=null; verifyState=null; pendingMeeting=null; pendingVerify=[]; busy=new Set(); wakeKind=new Map(); currentResident=''; finalizeLock=null
1150
+ await saveAll(); return {ok:true,message:'aborted'} }
1151
+ function setPause(){ clearHeartbeat(); running=false; return {ok:true,message:'paused'} }
1152
+
1153
+ return {
1154
+ sessionId, running:()=>running, autoDone:()=>autoDone, phase:()=>phase,
1155
+ onResidentEnd, start, resume, status, report, addMember, removeMember, setParams,
1156
+ // live-Agent cache for the real /compact path (see `liveAgents`): the child is
1157
+ // captured at subagent/start and released once its end handler has run.
1158
+ rememberAgent, forgetAgent,
1159
+ setPause, initAbort, postMessage, startMeeting, saveAll, broadcast, configure, loadSettings,
1160
+ currentResident:()=>currentResident,
1161
+ // safety kick: drive one scheduler pass (used when an end handler errored, so an exceptional
1162
+ // turn can never leave the group with no end-event and no heartbeat to continue it)
1163
+ nudge:()=>scheduleNext().catch(()=>{}),
1164
+ residentIdOf:(agent)=>{ const m=residentOfAgent(agent); if(m) return m; const c=currentResident; return (c && residents.has(c)) ? c : '' },
1165
+ useResident:(id)=>{ currentResident=id },
1166
+ publishProgress, recordProposition, recordMethod, recordSubproblem, listResidents, reportContext,
1167
+ proposeTask, claimTask, taskDone, listTasks,
1168
+ readProgress: async (rid)=>({text:(await readText('Progress/'+rid+'/progress.md'))||''}),
1169
+ frameworkRoot:frameworkRoot, currentProject:()=>currentProject, problemText:()=>problemText,
1170
+ residentCount:()=>residents.size,
1171
+ busyCount:()=>busy.size,
1172
+ }
1173
+ } // end makeSession
1174
+
1175
+ // ================= apply-level registration (ONCE) =================
1176
+ function objParams(props, required){ return { type:'object', properties:props, additionalProperties:false, required:required||[] } }
1177
+ function registerTool(name, description, parameters, fn){
1178
+ // tools.register() returns a Cordis effect disposer. Both v2 and v3 keep the
1179
+ // registration inside ctx.effect() so it is wound back when the preset subtree
1180
+ // unloads; v4 used to drop the disposer, so a second mount of this preset in the
1181
+ // same process collided on the already-registered tool names and the entries
1182
+ // survived an unload. Route it through ctx.effect() like the other two.
1183
+ ctx.effect(() => tools.register({ name, description, parameters,
1184
+ output:{ schema:{ type:'string' }, render:(_a,v)=>[{type:'text',text:String(v)}] },
1185
+ execute: async (args, exec)=>{
1186
+ try { const s=getSession(exec&&exec.agent); if(!s) return JSON.stringify({ok:false,error:'no session'}); return JSON.stringify(await fn(s,args||{},exec&&exec.agent)) }
1187
+ catch(e){ return JSON.stringify({ok:false,error:String((e&&e.message)||e)}) }
1188
+ } }))
1189
+ }
1190
+ // host/assistant-facing
1191
+ registerTool('vibe_v4_configure','Create/configure a project: set project name, problem, and params WITHOUT starting a run. Use this FIRST, then vibe_v4_start to actually spawn residents.',objParams({project:{type:'string'},problem:{type:'string'},params:{type:'object'}}),(s,a)=>s.configure(a))
1192
+ registerTool('vibe_v4_start','Start V4: spawn N resident subagents (brainstorm then self-organize).',objParams({problem:{type:'string'},residentCount:{type:'integer'},seedDirections:{type:'array',items:{type:'string'}}}),(s,a)=>s.start(a))
1193
+ registerTool('vibe_v4_resume','Resume a persisted V4 run.',objParams({}),(s)=>s.resume())
1194
+ registerTool('vibe_v4_pause','Pause V4.',objParams({}),(s)=>s.setPause())
1195
+ registerTool('vibe_v4_abort','Abort V4 and interrupt residents.',objParams({}),(s)=>s.initAbort())
1196
+ registerTool('vibe_v4_status','Show V4 status.',objParams({}),(s)=>s.status())
1197
+ registerTool('vibe_v4_report','Return the V4 progress report.',objParams({}),(s)=>s.report())
1198
+ registerTool('vibe_v4_message','Inject a message to a resident (or all).',objParams({to:{type:'string'},content:{type:'string'}},['to','content']),(s,a)=>{ const to=a.to||'all'; if(to==='all') return s.broadcast(a.content); return s.postMessage('facilitator',to,a.content) })
1199
+ registerTool('vibe_v4_meeting','Start a meeting (coordinate / allocate / propose verification).',objParams({agenda:{type:'string'}},['agenda']),(s,a)=>s.startMeeting(a.agenda))
1200
+ registerTool('vibe_v4_list_members','List residents.',objParams({}),(s)=>({ok:true,residents:s.listResidents()}))
1201
+ registerTool('vibe_v4_add_member','Add a resident.',objParams({direction:{type:'string'}}),(s,a)=>s.addMember(a.direction))
1202
+ registerTool('vibe_v4_remove_member','Close a resident.',objParams({id:{type:'string'}},['id']),(s,a)=>s.removeMember(a.id))
1203
+ // model/provider inheritance: set model/provider to override the residents' LLM route (''=inherit
1204
+ // the main assistant's route). toolAllow/toolDeny are per-resident tool permissions (scoped
1205
+ // restrict). residentPersona prepends a persona line to every resident prompt.
1206
+ 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.',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'}}}),(s,a)=>{ s.setParams(a); return {ok:true} })
1207
+ // resident-facing tools: route to the CALLING resident (exec.agent.id === childId);
1208
+ // fall back to the last-woken resident when called by the host/assistant.
1209
+ 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) })
1210
+ registerTool('vibe_v4_publish_progress','(resident) Append to your own progress markdown.',objParams({content:{type:'string'}},['content']),(s,a,x)=>s.publishProgress(s.residentIdOf(x),a.content))
1211
+ registerTool('vibe_v4_record_proposition','(resident) Record a proposition to your library.',objParams({id:{type:'string'},title:{type:'string'},statement:{type:'string'},prob:{type:'number'},value:{type:'number'},motivation:{type:'string'}}),(s,a,x)=>s.recordProposition(s.residentIdOf(x),a))
1212
+ registerTool('vibe_v4_record_method','(resident) Record a method/theory to your library.',objParams({id:{type:'string'},title:{type:'string'},type:{type:'string'},content:{type:'string'},notation:{type:'string'},value:{type:'number'},motivation:{type:'string'}}),(s,a,x)=>s.recordMethod(s.residentIdOf(x),a))
1213
+ registerTool('vibe_v4_record_subproblem','(resident) Record a sub-problem to your library.',objParams({id:{type:'string'},title:{type:'string'},statement:{type:'string'},value:{type:'number'},motivation:{type:'string'}}),(s,a,x)=>s.recordSubproblem(s.residentIdOf(x),a))
1214
+ registerTool('vibe_v4_read_progress','(resident) Read another resident\'s progress (read-only).',objParams({id:{type:'string'}},['id']),async (s,a)=>{ const rp=await s.readProgress(a.id); return {ok:true,text:(rp&&rp.text)||''} })
1215
+ registerTool('vibe_v4_list_residents','(resident) List fellow residents.',objParams({}),(s)=>({ok:true,residents:s.listResidents()}))
1216
+ // task board (residents; board is the residents' own allocation mechanism)
1217
+ registerTool('vibe_v4_propose_task','(resident) Propose a task to the shared task board.',objParams({title:{type:'string'},description:{type:'string'}},['title']),(s,a,x)=>s.proposeTask(a.title,a.description,s.residentIdOf(x)))
1218
+ registerTool('vibe_v4_claim_task','(resident) Claim an open task from the board (framework then wakes you to work it).',objParams({id:{type:'string'}},['id']),(s,a,x)=>s.claimTask(a.id,s.residentIdOf(x)))
1219
+ registerTool('vibe_v4_task_done','(resident) Mark a claimed task done.',objParams({id:{type:'string'},claimer:{type:'string'}},['id']),(s,a,x)=>s.taskDone(a.id,a.claimer||s.residentIdOf(x)))
1220
+ registerTool('vibe_v4_list_tasks','(resident) List open tasks.',objParams({}),(s)=>({ok:true,tasks:s.listTasks()}))
1221
+ // context / compact (resident reports its context usage so the framework can /compact-equivalent)
1222
+ 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))
1223
+ registerTool('vibe_v4_claim_write','Reserved: shared-file write lock (framework-managed).',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
1224
+ registerTool('vibe_v4_release_write','Reserved: shared-file write lock release.',objParams({target:{type:'string'}},['target']),(s,a)=>({ok:true,key:a.target}))
1225
+
1226
+ // Same lifecycle rule as registerTool: commands.register() returns a disposer, so the
1227
+ // registration belongs to this fiber and must be unwound with it.
1228
+ ctx.effect(() => commands.register({
1229
+ name:'v4', description:'control the Vibe Math V4 framework',
1230
+ input:{hint:'[configure|start|resume|pause|abort|status|report|meeting|members|add|remove|set]'},
1231
+ handler: async function(inv){
1232
+ const s=getSession(inv&&inv.agent); if(!s) return {kind:'success',text:JSON.stringify({ok:false,error:'no session'})}
1233
+ const line=String(inv&&inv.rawInput?inv.rawInput:'').trim(); const parts=line.split(/\s+/); const cmd=parts[0]||''; const rest=parts.slice(1)
1234
+ let r
1235
+ if(cmd==='configure') r=await s.configure({project:rest[0]||'', problem:parts.slice(2).join(' ')})
1236
+ else if(cmd==='start') r=await s.start({})
1237
+ else if(cmd==='resume') r=await s.resume()
1238
+ else if(cmd==='pause') r=s.setPause()
1239
+ else if(cmd==='abort') r=await s.initAbort()
1240
+ else if(cmd==='status') r=s.status()
1241
+ else if(cmd==='report') r=s.report()
1242
+ else if(cmd==='meeting') r=await s.startMeeting(rest.join(' '))
1243
+ else if(cmd==='members') r={ok:true,residents:s.listResidents()}
1244
+ else if(cmd==='add') r=await s.addMember(rest.join(' '))
1245
+ else if(cmd==='remove') r=await s.removeMember(rest[0]||'')
1246
+ 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) }
1247
+ else r={ok:false,usage:'configure|start|resume|pause|abort|status|report|message|meeting|members|add|remove|set'}
1248
+ return {kind:'success',text:JSON.stringify(r,null,2)}
1249
+ },
1250
+ }))
1251
+
1252
+ // Capture the live child Agent while it is still registered. `subagent/end` is
1253
+ // emitted only AFTER the child's Activation teardown has removed it from the agent
1254
+ // registry (dsh-subagent:1231 dispose -> dsh-agent:508 store.delete ->
1255
+ // dsh-subagent:1241 settle/emit), so an end-time `agents.get(childId)` can never
1256
+ // resolve. See `liveAgents` in the session body.
1257
+ ctx.on('subagent/start', function(info){
1258
+ if(!info || !info.id) return
1259
+ const sid=childOwner.get(info.id); const s=sid!==undefined?sessions.get(sid):undefined
1260
+ if(!s) return
1261
+ let agent
1262
+ try { agent = agents.get(info.id) } catch(e){ agent = undefined }
1263
+ if(agent) s.rememberAgent(info.id, agent)
1264
+ })
1265
+
1266
+ ctx.on('subagent/end', function(info){
1267
+ const sid=childOwner.get(info.id); const s=sid!==undefined?sessions.get(sid):undefined
1268
+ if(s) s.onResidentEnd(info.id, info)
1269
+ .catch(e=>{ console.error('vibe-v4 end: '+String((e&&e.stack)||e)); if(s.nudge) s.nudge() })
1270
+ // Release the captured live Agent only AFTER this end has been processed, so the
1271
+ // real /compact inside onResidentEnd still sees it. The WeakRef means a missed
1272
+ // release only delays collection rather than leaking the Agent.
1273
+ .finally(()=>{ if(s.forgetAgent) s.forgetAgent(info.id) })
1274
+ })
1275
+ }