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