dsh-vibe-math 2.3.16 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.en.md +64 -61
  2. package/README.md +64 -61
  3. package/SECURITY.md +56 -0
  4. package/cordis.patch.yml +1123 -10
  5. package/docs/AUDIT-CHECKLIST.md +499 -384
  6. package/docs/COMPAT-AUDIT-ROUND2.md +3 -0
  7. package/docs/COMPAT-AUDIT-ROUND3.md +139 -0
  8. package/docs/generate_framework_diagram_v5.mjs +27 -27
  9. package/docs/release-notes/RELEASE-NOTES-2.4.0.md +172 -0
  10. package/docs/release-notes/RELEASE-NOTES-2.4.1.md +129 -0
  11. package/docs/test-timing.md +2 -1
  12. package/installer.js +89 -21
  13. package/package.json +36 -10
  14. package/preset-declaration.js +62 -0
  15. package/prompt-corpus-persona/persona-corpus.json +2 -2
  16. package/prompt-corpus-persona/persona-corpus.md +2 -2
  17. package/prompt-corpus-v5/prompt-corpus-v5.json +7 -7
  18. package/prompt-corpus-v5/prompt-corpus-v5.md +8 -129
  19. package/scripts/build-preset-rows.mjs +146 -0
  20. package/tests/audit-installer-compat.test.mjs +9 -3
  21. package/tests/audit-preset-rows.test.mjs +185 -0
  22. package/tests/audit-v5-integrity.mjs +49 -3
  23. package/tests/e2e-v5-round2.test.mjs +137 -67
  24. package/tests/formal-verify-v5.test.mjs +43 -35
  25. package/tests/prompt-v5-integrity.test.mjs +49 -47
  26. package/tests/selfdrive-v5.mjs +42 -55
  27. package/vibe-math-v2/agent.cordis.yml +5 -5
  28. package/vibe-math-v2/preset.yml +1 -1
  29. package/vibe-math-v2/vibe-math-v2.js +111 -64
  30. package/vibe-math-v3/vibe-math-v3.js +213 -150
  31. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -2
  32. package/vibe-math-v4/vibe-math-v4.js +257 -34
  33. package/vibe-math-v5/agent.cordis.yml +8 -8
  34. package/vibe-math-v5/preset.yml +1 -1
  35. package/vibe-math-v5/vibe-math-v5.js +292 -129
  36. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +56 -44
  37. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +24 -22
  38. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5-en.svg +14 -14
  39. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +14 -14
@@ -4,9 +4,44 @@
4
4
  // intervention). It NEVER assigns tasks: residents message & meet and decide all
5
5
  // task allocation among themselves. Consumes HOST subagents/agents/fs/tools/commands.
6
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.
7
+ // rejects undeclared dependencies). `timer` IS injected and used (ctx.timeout) so every
8
+ // timer here is a fiber-owned disposer — but the global setTimeout/clearTimeout DO exist
9
+ // in this preset's runtime: a preset is a FILE row loaded by a plain host-realm import().
10
+ // The vm sandbox that traps require/setTimeout/setInterval/fetch wraps only a DYNAMIC
11
+ // package's host half (@deepseek-ai/dsh-cordis-host-runner/lib/types/sandbox.js, reached
12
+ // only from the dynamic-package start path); v2/v3 use those globals and work. An earlier
13
+ // version of this note claimed the globals do not exist — false for file rows, and it would
14
+ // only become true if this preset were ever converted to a dynamic package.
9
15
  export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
16
+
17
+ // ---- host live-child cap (DSH ≥ 0.2) ----------------------------------------
18
+ // The host caps the number of LIVE continuable children PER ROOT AGENT. Evidence (installed
19
+ // dsh-subagent 0.2.0-rc.2): `materialize` calls `ActivationPool.reserve(this.maxActiveSubagents())`
20
+ // and `reserve` throws `SubagentError('subagent limit reached (active child limit: <capacity>);
21
+ // wait for an existing child to finish or complete this work with the current agents',
22
+ // 'ACTIVATION_LIMIT_REACHED')` when every slot is taken. The capacity comes from the `subagent`
23
+ // row's `maxActiveSubagents` Config key (default 8; no preset sets it), the cap is shared by all
24
+ // descendants of that root, and a slot is released when a child settles. The `.d.ts` does NOT
25
+ // document the throw.
26
+ //
27
+ // The detector and the remembered limit live at MODULE scope because the cap is a property of the
28
+ // HOST (the same number for every root of this process), not of one session. On DSH 0.1.x
29
+ // `subagents.startContinuable` never throws this code, so every branch below is inert there — the
30
+ // error code itself is the feature test, there is no version check anywhere.
31
+ let hostChildLimit // undefined until the host tells us its ceiling (via a refusal)
32
+ function isActivationLimitReached(e){ return String((e && e.code) || '') === 'ACTIVATION_LIMIT_REACHED' }
33
+ function noteChildLimit(e){
34
+ const m = /active child limit:\s*(\d+)/.exec(String((e && e.message) || e || ''))
35
+ if(m) hostChildLimit = Number(m[1])
36
+ return hostChildLimit
37
+ }
38
+ // One actionable sentence naming the HOST ceiling and the knob that raises it.
39
+ function hostChildLimitHint(limit){
40
+ const n = (limit === undefined || limit === null) ? '' : ('=' + limit)
41
+ return '本宿主对「同时在活的续聊子代理」有上限(宿主 subagent 行的 maxActiveSubagents 参数' + n
42
+ + ',写满后子代理服务抛 ACTIVATION_LIMIT_REACHED)'
43
+ }
44
+
10
45
  export function apply(ctx) {
11
46
  const subagents = ctx.subagents
12
47
  const agents = ctx.agents
@@ -74,17 +109,9 @@ export function apply(ctx) {
74
109
  const activityLogCap = 200
75
110
 
76
111
  // ---- utils ----
77
- function now(){ return Date.now() }
78
- 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 }
79
- function shortId(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<8;i++) s+=h[Math.floor(Math.random()*16)]; return s }
80
- function clamp01(v){ const n=Number(v); if(!Number.isFinite(n)) return 0.5; return Math.max(0,Math.min(1,n)) }
81
- function fmtTime(ts){ try { return new Date(ts||now()).toISOString().replace('T',' ').slice(0,19) } catch(e){ return String(ts||'') } }
82
- function cl(x){ return clamp01(Number(x)) }
83
112
  // contextPct is a PERCENT (0-100); never clamp to 0-1 or the compactThreshold
84
113
  // comparison (e.g. 66) becomes `1.0 >= 66` and never fires.
85
- function clPct(x){ const n=Number(x); if(!Number.isFinite(n)) return 0; return Math.max(0,Math.min(100,n)) }
86
114
  function textBlock(t){ return { type:'text', text:String(t) } }
87
- 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() }
88
115
  function logActivity(event,detail){ activityLog.push({at:now(),event,detail:String(detail||'')}); if(activityLog.length>activityLogCap) activityLog.shift() }
89
116
  function logDecision(kind,detail){ decisions.push({at:now(),kind,detail:String(detail||'')}) }
90
117
  // Record that the project made real progress (new artifact, meeting, verify, task, or a
@@ -100,7 +127,6 @@ export function apply(ctx) {
100
127
  // (misconfigured via vibe_v4_set) would otherwise make recoverStallMs negative → every meeting/
101
128
  // verify watchdog fires INSTANTLY (abandoning all consensus) and A-fill's idle window would never
102
129
  // elapse (waking everyone every pass). Guard every duration read with this.
103
- function posMs(v,def){ const n=Number(v); return (Number.isFinite(n)&&n>0)?n:(def||120000) }
104
130
  function recoverStallMs(){ return posMs(params.activityTimeoutMs,120000) * 2 }
105
131
  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' }
106
132
  // Per-resident model/provider inheritance: when params.provider / params.model are set,
@@ -120,16 +146,11 @@ export function apply(ctx) {
120
146
  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 '.' }
121
147
  function vibeRoot(){ return (workspaceRoot()+'/VibeMath').replace(/\\/g,'/') }
122
148
  function frameworkRoot(){ return vibeRoot()+'/Projects/'+currentProject }
123
- function slugify(s){ const t=String(s==null?'':s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g,'-').replace(/^-+|-+$/g,''); return t||'project' }
124
149
  // Object ids (verify targets, recorded cards) become FILE NAMES and DIRECTORY PATHS
125
150
  // (Verified/命题/<id>.md, Shared/debates/<id>.md, Propos/<r>/<id>.md, source-card scans).
126
151
  // A hostile/sloppy id containing path separators ('../../x') or Windows-forbidden chars would
127
152
  // escape the project tree. Keep every harmless character (incl. Chinese) and replace only
128
153
  // separators/control chars; strip leading/trailing dots/dashes so the name is never '.'/'..'.
129
- function idSafe(s){
130
- const t=String(s==null?'':s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/-{2,}/g,'-').replace(/^[.\-]+|[.\-]+$/g,'')
131
- return t||'id'
132
- }
133
154
  let warnedNoPolicy = false
134
155
  function warnNoPolicyOnce(){ if(!warnedNoPolicy){ warnedNoPolicy=true; console.error('vibe-math-v4: sandboxPolicy unavailable; writes go out with no explicit policy') } }
135
156
  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 }
@@ -238,13 +259,6 @@ export function apply(ctx) {
238
259
  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 } }
239
260
  async function readCurrentProject(){ try { const t=await readTextAbs(vibeRoot()+'/.current'); if(t) return String(t).trim() } catch(e){} return currentProject }
240
261
  async function writeCurrentProject(){ try { await writeTextAbs(vibeRoot()+'/.current', currentProject) } catch(e){} }
241
- function tryJson(s){ try { return JSON.parse(s) } catch(e){ return undefined } }
242
- function parseReply(text){
243
- let obj; const fence=/```(?:json)?[ \t]*([\s\S]*?)```/gi; let m
244
- while((m=fence.exec(text))!==null){ const o=tryJson(m[1].trim()); if(o&&typeof o==='object'&&!Array.isArray(o)) obj=o }
245
- if(!obj){ const w=tryJson(text.trim()); if(w&&typeof w==='object'&&!Array.isArray(w)) obj=w }
246
- return obj||{}
247
- }
248
262
 
249
263
  // ================= Lean formal verification ==============================
250
264
  // Contract: docs/formal-verification.md (shared by v2/v3/v4/v5).
@@ -384,9 +398,10 @@ export function apply(ctx) {
384
398
  // contract (§7) additionally requires `handle.terminate()` on timeout, and a host that
385
399
  // ignores/exceeds graceMs would otherwise leave the Lean process running while the framework
386
400
  // reports LEAN_TIMEOUT. Race `done` against a cap-ms timer that terminates the handle and
387
- // resolves a synthetic outcome. Note: the plugin runtime has no global setTimeout — the
388
- // `timer` service (ctx.timeout) is the only timer, and it returns the disposer we clear
389
- // below when `done` wins the race.
401
+ // resolves a synthetic outcome. Note: the `timer` service (ctx.timeout) is the timer used here
402
+ // because it returns the disposer we clear below when `done` wins the race (a fiber-owned
403
+ // disposer), NOT because global timers are missing: they exist for file rows like this preset
404
+ // (see the header note — only a DYNAMIC package's host half is vm-sandboxed).
390
405
  let killedByUs=false, timerDispose=null
391
406
  let outcome
392
407
  try {
@@ -999,15 +1014,82 @@ export function apply(ctx) {
999
1014
  return L.join('\n')
1000
1015
  }
1001
1016
 
1017
+ // ---- host live-child cap: a refused spawn must never kill the run --------------
1018
+ // A ROOT agent's live continuable children are capped by the host (see the module-scope note):
1019
+ // `startContinuable` throws ACTIVATION_LIMIT_REACHED once the cap is full. Without the handling
1020
+ // below that throw escaped `start()` mid-loop, so the run was left with `running=true`, a
1021
+ // half-built team and an unhandled rejection.
1022
+ let childLimitLoggedThisRound = false // ONE actionable line per ROUND, not one per refused child
1023
+ let pendingSpawns = [] // residents the cap refused: retried a round later
1024
+ function beginSpawnRound(){ childLimitLoggedThisRound = false }
1025
+ // LIVE continuable children of THIS framework: a resident that holds a childId and has not been
1026
+ // stopped. A refused resident carries childId '' so it can never inflate (or hide behind) the
1027
+ // real ceiling, and is never counted as a voter/speaker either (see noteSpawnRefused).
1028
+ function liveChildCount(){ let n=0; for(const [,r] of residents){ if(r.childId && r.status!=='stopped') n++ } return n }
1029
+ // Mark a resident whose spawn the host refused and say ONCE per round what the operator can do.
1030
+ // The resident is kept OUT of the live roster: a childless member must not be counted by the
1031
+ // meeting/verify consensus (they require EVERY resident to speak/vote, and no end event can ever
1032
+ // arrive for a child that was never created), and `wakeResident` could never deliver to it. It is
1033
+ // queued in `pendingSpawns` instead, so the work is deferred rather than lost.
1034
+ function noteSpawnRefused(r, limit){
1035
+ r.childId=''; r.status='refused'; r.lastActiveAt=now()
1036
+ // `start()`/`addMember` never put the resident in the roster, but a RESPAWN (resume) does:
1037
+ // drop it here (the queued retry re-registers it, and its own end event resumes the flow).
1038
+ if(residents.get(r.rId)===r) residents.delete(r.rId)
1039
+ busy.delete(r.rId); wakeKind.delete(r.rId)
1040
+ if(currentResident===r.rId) currentResident=''
1041
+ if(pendingSpawns.indexOf(r)<0) pendingSpawns.push(r)
1042
+ if(childLimitLoggedThisRound) return
1043
+ childLimitLoggedThisRound=true
1044
+ const line='vibe-math-v4: '+hostChildLimitHint(limit)+'。'+r.rId+' 等常驻本轮未能创建;'
1045
+ +'可在宿主的 subagent 行把 maxActiveSubagents 调大(或调小 residentCount),未创建的常驻已排队,有空位时自动重试。'
1046
+ console.error(line)
1047
+ logActivity('spawn-refused', line)
1048
+ }
1049
+ // One retry ROUND for the queued residents. Called from scheduleNext (the single choke point of
1050
+ // every scheduling pass) and paced by the heartbeat, so a capped team is never retried in a
1051
+ // tight loop; a successful retry registers the resident normally and its own end event resumes
1052
+ // the normal flow.
1053
+ async function retryPendingSpawns(){
1054
+ if(!pendingSpawns.length) return
1055
+ const queue=pendingSpawns.slice(); pendingSpawns=[]; beginSpawnRound()
1056
+ for(const r of queue){
1057
+ try { await spawnResident(r) }
1058
+ catch(e){
1059
+ // A non-cap failure has no other caller to report to here: say it once and drop this
1060
+ // resident (retrying a broken tool filter every round forever would be worse).
1061
+ console.error('vibe-math-v4: 重试创建常驻 '+r.rId+' 失败,已放弃该常驻:'+String((e&&e.message)||e))
1062
+ logActivity('spawn-failed', r.rId+' '+String((e&&e.message)||e))
1063
+ }
1064
+ }
1065
+ await saveAll()
1066
+ if(pendingSpawns.length) armHeartbeat() // still capped: re-check on the next heartbeat window
1067
+ }
1068
+
1002
1069
  // ---- resident lifecycle ----
1003
1070
  let residentSeq = 0
1004
1071
  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} }
1005
1072
  async function spawnResident(r){
1073
+ // Skip BEFORE touching the host when we already KNOW the ceiling and our own live children
1074
+ // fill it: the host would refuse this spawn too, and one line per ROUND (not per child) is
1075
+ // enough. The resident is queued and the next round retries it.
1076
+ if(hostChildLimit!==undefined && liveChildCount()>=hostChildLimit){ noteSpawnRefused(r, hostChildLimit); return false }
1006
1077
  const ao=residentAgentOptions(); const tf=residentToolFilter()
1007
- 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)})
1078
+ let started
1079
+ try {
1080
+ started=await startWithToolFilter(tf, function(f){ return {provider:pickProvider(),label:r.rId,request:{prompt:[textBlock(brainstormPrompt(r))],parent:rootAgent,agentOptions:ao,...(f?{toolFilter:f}:{})},signal:makeSignal(params.activityTimeoutMs||60000)} })
1081
+ } catch(e){
1082
+ // The host's live-child cap is a HOST limit (maxActiveSubagents on the `subagent` row), not
1083
+ // a defect in this preset: remember the ceiling, keep the resident for a later round, and
1084
+ // do NOT let the throw escape (it used to abort start()'s spawn loop). Every OTHER start
1085
+ // failure keeps its previous behaviour and still propagates.
1086
+ if(!isActivationLimitReached(e)) throw e
1087
+ noteSpawnRefused(r, noteChildLimit(e)); return false
1088
+ }
1008
1089
  r.childId=started.childId; r.status='brainstorm'; r.lastActiveAt=now()
1009
1090
  childOwner.set(started.childId,sessionId); busy.add(r.rId); wakeKind.set(r.rId,'normal'); currentResident=r.rId
1010
1091
  residents.set(r.rId,r); await saveAll(); logActivity('spawn',r.rId+' ('+(r.direction||'brainstorm')+')')
1092
+ return true
1011
1093
  }
1012
1094
  async function wakeResident(r, promptText, kind){
1013
1095
  if(!r || !r.childId) return false // a removed resident must never be woken (else r.childId would crash)
@@ -1040,8 +1122,11 @@ export function apply(ctx) {
1040
1122
  // The DSH continuable-wake API is subagents.sendMessage(sender, targetId, content, {signal}),
1041
1123
  // NOT subagents.followup (which is only Agent.followup, and does NOT exist on the subagents
1042
1124
  // service). Using a non-existent method threw TypeError and made EVERY wake fail silently →
1043
- // the group went idle forever. Prefer sendMessage; fall back to a legacy followup if a host
1044
- // still exposes it (older deployments), so this works across versions.
1125
+ // the group went idle forever. Prefer sendMessage; the legacy `subagents.followup` branch is
1126
+ // kept for the earliest supported hosts only: that service method was dropped in DSH
1127
+ // 0.1.2-rc.1, so it is reachable only on 0.1.2-alpha.* — 0.1.2-rc.1 and every later release
1128
+ // this package targets expose startContinuable/sendMessage/drainContinuableChildren and
1129
+ // nothing else (checked against dsh-subagent 0.2.0-rc.2).
1045
1130
  try {
1046
1131
  if(typeof subagents.sendMessage==='function'){
1047
1132
  await subagents.sendMessage(rootAgent, r.childId, [textBlock(prompt)], {signal: makeSignal(params.activityTimeoutMs||60000)})
@@ -1510,6 +1595,29 @@ export function apply(ctx) {
1510
1595
  * plugin row where it is (nothing else moves in or out of the realm) while making
1511
1596
  * both compaction paths agree on one instance.
1512
1597
  */
1598
+ // ---- toolFilter names the host may not register -------------------------
1599
+ // `tools.restrict` throws for a name outside the host's restrictable set, and that throw escapes
1600
+ // child creation, so one stale name in `vibe_v4_set{toolAllow|toolDeny}` would make every spawn
1601
+ // fail with no operator-facing message. The host's own rejection lists every registered tool, so
1602
+ // the retry below never guesses. FAIL CLOSED: if nothing survives the filter we rethrow instead of
1603
+ // spawning WITHOUT one (that would grant exactly what the operator denied).
1604
+ async function startWithToolFilter(toolFilter, makeSpec){
1605
+ try {
1606
+ return await subagents.startContinuable(makeSpec(toolFilter))
1607
+ } catch(e){
1608
+ const message = String((e && e.message) || e)
1609
+ const retry = sanitizeToolFilter(toolFilter, registeredToolsFromError(message))
1610
+ if(retry===undefined){
1611
+ console.error('vibe-math-v4: 配置的工具过滤只包含本宿主未注册的工具名,拒绝在不带过滤的情况下启动子代理。filter=' + JSON.stringify(toolFilter) + ' 宿主提示:' + message)
1612
+ throw e
1613
+ }
1614
+ if(JSON.stringify(retry) === JSON.stringify(toolFilter)) throw e
1615
+ console.error('vibe-math-v4: 工具过滤里有本宿主未注册的名字,已只保留已注册的名字重试。dropped=' + JSON.stringify(toolFilter) + ' kept=' + JSON.stringify(retry))
1616
+ // a FRESH spec (and a fresh timeout signal) for the retry
1617
+ return await subagents.startContinuable(makeSpec(retry))
1618
+ }
1619
+ }
1620
+
1513
1621
  function compactionForAgent(agent){
1514
1622
  try { const c = agent && agent.ctx ? agent.ctx.get('compaction') : undefined; if(c && c.compactIfNeeded) return c } catch(e){}
1515
1623
  // fallback: this plugin's own plane (host root for a row outside the realm)
@@ -1520,10 +1628,15 @@ export function apply(ctx) {
1520
1628
  const agent = liveAgentOf(r.childId)
1521
1629
  if(!agent || !agent.session) return
1522
1630
  const compaction = compactionForAgent(agent)
1523
- if(compaction===undefined || !compaction.compactIfNeeded) return
1631
+ if(compaction===undefined) return
1632
+ // `compactIfNeeded` is a POLICY call on the current host: it may return null without compacting
1633
+ // and nothing records that, so a real /compact would silently do nothing. `compactNow` is the
1634
+ // forcing verb; feature-detected so older hosts keep the policy call.
1635
+ const force = typeof compaction.compactNow === 'function'
1636
+ if(!force && typeof compaction.compactIfNeeded !== 'function') return
1524
1637
  try {
1525
1638
  const signal = makeSignal(params.activityTimeoutMs||60000)
1526
- const result = await compaction.compactIfNeeded(agent, 'pressure', signal)
1639
+ const result = force ? await compaction.compactNow(agent, signal) : await compaction.compactIfNeeded(agent, 'pressure', signal)
1527
1640
  if(result && (result.shadowedSeqs||[]).length>0){
1528
1641
  // the resident's real session was compacted → its context is now a summary.
1529
1642
  // Flag needCompact so the NEXT wake re-anchors the core rules (they may have been blurred).
@@ -1536,6 +1649,10 @@ export function apply(ctx) {
1536
1649
  // ---- liveness / scheduling ----
1537
1650
  async function scheduleNext(){
1538
1651
  if(!running||autoDone){ clearHeartbeat(); return }
1652
+ // Residents the host's live-child cap refused are retried here — the single choke point every
1653
+ // scheduling pass goes through — so a refused spawn is deferred by a whole round (and paced by
1654
+ // the heartbeat) instead of being retried inside the loop that discovered the refusal.
1655
+ if(pendingSpawns.length) await retryPendingSpawns()
1539
1656
  if(phase==='brainstorm'){ await maybeFinishBrainstorm(); return }
1540
1657
  if(meetingState){ await continueMeetingRound(); return }
1541
1658
  if(verifyState){ await continueVerifyRound(); return }
@@ -1581,6 +1698,10 @@ export function apply(ctx) {
1581
1698
  const free = mp>0 ? (mp - busy.size) : Number.MAX_SAFE_INTEGER
1582
1699
  if(free<=0) break // concurrency cap reached → stop filling
1583
1700
  if((now()-r.lastActiveAt)<atOs) break // the remaining are all busy-or-not-idle-enough
1701
+ // A resident with no childId has nothing to wake (wakeResident would refuse): that is a
1702
+ // resident whose spawn the host cap refused, and it is retried by retryPendingSpawns() at
1703
+ // the top of the next pass — never in a tight loop here.
1704
+ if(!r.childId) continue
1584
1705
  let ok=false
1585
1706
  try { ok = await wakeResident(r, await heartbeatPrompt(r), 'normal') } catch(e){ ok=false }
1586
1707
  if(ok) started++
@@ -1724,8 +1845,20 @@ export function apply(ctx) {
1724
1845
  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
1725
1846
  lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
1726
1847
  const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
1727
- for(let i=0;i<params.residentCount;i++){ const r=newResident(dirs[i]||''); await spawnResident(r) }
1728
- await saveAll(); return {ok:true,message:'v4 started: '+params.residentCount+' resident(s) brainstorming',project:currentProject}
1848
+ pendingSpawns=[] // a fresh run replaces the roster: nothing is queued from before
1849
+ beginSpawnRound() // the whole spawn loop is ONE round (one cap notice, not N)
1850
+ let startedCount=0
1851
+ // spawnResident no longer throws for the host's live-child cap: it queues the refused resident
1852
+ // and returns false, so a capped host leaves a RUNNING team of the residents it accepted
1853
+ // instead of an aborted half-built run.
1854
+ for(let i=0;i<params.residentCount;i++){ const r=newResident(dirs[i]||''); if(await spawnResident(r)) startedCount++ }
1855
+ await saveAll()
1856
+ if(pendingSpawns.length) armHeartbeat() // retry the queued residents on the next round
1857
+ // Report the LIVE count when the host refused part of the team: "4 resident(s)" while only 2
1858
+ // exist would be exactly the kind of half-truth this fix exists to remove.
1859
+ const refused=pendingSpawns.length
1860
+ return {ok:true,message:'v4 started: '+(refused?(startedCount+'/'+params.residentCount):params.residentCount)+' resident(s) brainstorming'
1861
+ +(refused?('(宿主同时在活的子代理已达上限,'+refused+' 个常驻已排队、有空位时自动重试;'+hostChildLimitHint(hostChildLimit)+')'):''),project:currentProject}
1729
1862
  }
1730
1863
  async function resume(){
1731
1864
  currentProject=await readCurrentProject(); await ensureDirs()
@@ -1761,6 +1894,7 @@ export function apply(ctx) {
1761
1894
  for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.insight=''; r.roundsSinceCompact=0 }
1762
1895
  busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; pendingVerify=[]; verifyState=null; meetingState=null; finalizeLock=null; verifiedRecently.clear()
1763
1896
  }
1897
+ beginSpawnRound() // the whole re-spawn loop is ONE round for the cap notice
1764
1898
  for(const [,r] of residents){ if(!r.childId){ await spawnResident(r) } }
1765
1899
  if(!running){ running=true; autoDone=false; if(phase==='idle') phase='active' }
1766
1900
  if(needRespawn && phase!=='brainstorm') phase='brainstorm' // let re-spawned residents re-bootstrap together
@@ -1773,6 +1907,10 @@ export function apply(ctx) {
1773
1907
  }
1774
1908
  function status(){ return { ok:true, running, phase, autoDone, project:currentProject, residentCount:residents.size,
1775
1909
  residents:listResidents(), busy:[...busy], taskboard:taskboard.length,
1910
+ // Residents the host's live-child cap refused (maxActiveSubagents): queued, not lost. Without
1911
+ // this the only trace would be the one console line, and `residentCount` alone cannot tell a
1912
+ // short team from a deliberately small one.
1913
+ pendingSpawns: pendingSpawns.length, hostChildLimit: (hostChildLimit===undefined?null:hostChildLimit),
1776
1914
  meetingInProgress: !!(meetingState), verifyInProgress: !!(verifyState), pendingVerify: pendingVerify.length?pendingVerify[0].targetId:null, pendingVerifyCount: pendingVerify.length,
1777
1915
  parkedMeeting: pendingMeeting?pendingMeeting.agenda:null,
1778
1916
  // The Lean knobs and the per-object formal records are part of the readable status: without
@@ -1803,7 +1941,9 @@ export function apply(ctx) {
1803
1941
  // run is live: on a concluded (autoDone) or never-started/paused run the new member would work
1804
1942
  // with nobody to coordinate (zombie work on a project the group already declared done).
1805
1943
  if(!running || autoDone) return {ok:false,message:'no active run to join (start or resume first)'}
1806
- const r=newResident(direction||''); await spawnResident(r)
1944
+ const r=newResident(direction||'')
1945
+ beginSpawnRound()
1946
+ if(!await spawnResident(r)) return {ok:false,code:'ACTIVATION_LIMIT_REACHED',message:'本宿主同时在活的子代理已达上限,'+r.rId+' 未能创建(已排队、有空位时自动重试)。'+hostChildLimitHint(hostChildLimit)}
1807
1947
  // Mid-meeting additions must join the meeting's speaking order; otherwise allSpoke (over CURRENT
1808
1948
  // residents) can never be true for the new member (not in the snapshot order) and the meeting is
1809
1949
  // only ever released by the stuck watchdog instead of finalizing with everyone's input.
@@ -2081,3 +2221,86 @@ export function apply(ctx) {
2081
2221
  .finally(()=>{ if(s.forgetAgent) s.forgetAgent(info.id) })
2082
2222
  })
2083
2223
  }
2224
+
2225
+ // ---- test seam: pure, stateless helpers --------------------------------
2226
+ // These helpers were declared inside `apply()` and are now declared at module scope, so
2227
+ // `apply()` closes over exactly the same function objects this export hands out. The audit
2228
+ // suites therefore exercise the REAL implementations by importing this module, instead of
2229
+ // extracting source text and compiling function bodies through the Function constructor
2230
+ // (dynamic code execution, rejected by the plugin-catalog security scan as
2231
+ // DANGEROUS_DYNAMIC_EXECUTION).
2232
+ //
2233
+ // Contract: no member may touch `ctx`, session state or mutable module state. Most are pure;
2234
+ // three are deliberately non-deterministic (`uuid`/`shortId` use Math.random, `fmtTime` falls back
2235
+ // to the clock) and `parseProgress` normalises the object it is handed in place (pre-existing).
2236
+ // Nothing here is used by the plugin at runtime except through `apply()`, and behaviour is
2237
+ // byte-identical to the previous in-`apply` declarations.
2238
+ export const __testHelpers = {
2239
+ uuid,
2240
+ shortId,
2241
+ clamp01,
2242
+ fmtTime,
2243
+ cl,
2244
+ clPct,
2245
+ blocksToText,
2246
+ posMs,
2247
+ slugify,
2248
+ idSafe,
2249
+ tryJson,
2250
+ parseReply,
2251
+ sanitizeToolFilter,
2252
+ registeredToolsFromError,
2253
+ }
2254
+
2255
+ function now(){ return Date.now() }
2256
+
2257
+ 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 }
2258
+
2259
+ function shortId(){ const h='0123456789abcdef'; let s=''; for(let i=0;i<8;i++) s+=h[Math.floor(Math.random()*16)]; return s }
2260
+
2261
+ function clamp01(v){ const n=Number(v); if(!Number.isFinite(n)) return 0.5; return Math.max(0,Math.min(1,n)) }
2262
+
2263
+ function fmtTime(ts){ try { return new Date(ts||now()).toISOString().replace('T',' ').slice(0,19) } catch(e){ return String(ts||'') } }
2264
+
2265
+ function cl(x){ return clamp01(Number(x)) }
2266
+
2267
+ function clPct(x){ const n=Number(x); if(!Number.isFinite(n)) return 0; return Math.max(0,Math.min(100,n)) }
2268
+
2269
+ 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() }
2270
+
2271
+ function posMs(v,def){ const n=Number(v); return (Number.isFinite(n)&&n>0)?n:(def||120000) }
2272
+
2273
+ function slugify(s){ const t=String(s==null?'':s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g,'-').replace(/^-+|-+$/g,''); return t||'project' }
2274
+
2275
+ function idSafe(s){
2276
+ const t=String(s==null?'':s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g,'-').replace(/-{2,}/g,'-').replace(/^[.\-]+|[.\-]+$/g,'')
2277
+ return t||'id'
2278
+ }
2279
+
2280
+ function tryJson(s){ try { return JSON.parse(s) } catch(e){ return undefined } }
2281
+
2282
+ function parseReply(text){
2283
+ let obj; const fence=/```(?:json)?[ \t]*([\s\S]*?)```/gi; let m
2284
+ while((m=fence.exec(text))!==null){ const o=tryJson(m[1].trim()); if(o&&typeof o==='object'&&!Array.isArray(o)) obj=o }
2285
+ if(!obj){ const w=tryJson(text.trim()); if(w&&typeof w==='object'&&!Array.isArray(w)) obj=w }
2286
+ return obj||{}
2287
+ }
2288
+
2289
+ function sanitizeToolFilter(filter, known){
2290
+ if(!filter || !(known instanceof Set) || known.size === 0) return filter
2291
+ const out = {}
2292
+ for(const key of ['allow','deny']){
2293
+ const list = filter[key]
2294
+ if(!Array.isArray(list)) continue
2295
+ const kept = list.filter(function(n){ return known.has(String(n).trim()) })
2296
+ if(kept.length > 0) out[key] = kept
2297
+ }
2298
+ return (out.allow || out.deny) ? out : undefined
2299
+ }
2300
+
2301
+ function registeredToolsFromError(message){
2302
+ const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
2303
+ if(!m) return undefined
2304
+ const names = m[1].split(',').map(function(s){ return s.trim() }).filter(Boolean)
2305
+ return names.length > 0 ? new Set(names) : undefined
2306
+ }
@@ -19,9 +19,9 @@
19
19
  # tasks — the academician does that, as a member subject to the same m-vote rule.
20
20
  #
21
21
  # The vibe-math-v5 plugin row consumes HOST subagents/agents/fs/tools/commands/
22
- # timer/sessionProjections/sessions services and publishes nothing, so it needs
23
- # no isolate realm. (Registering a host-only session projection UNIT is a
24
- # registration on a host registry, like tools.register — not publishing a service.)
22
+ # timer services and publishes nothing, so it needs no isolate realm. Institute
23
+ # state lives in its own hardened JSON file (State/<institute>.v5state.json):
24
+ # the plugin appends NOTHING to any host session log.
25
25
 
26
26
  # ── identity ────────────────────────────────────────────────────────────────
27
27
 
@@ -373,10 +373,10 @@
373
373
  # organization & task assignment / m-vote boolean consensus verification /
374
374
  # hire & fire / context compaction / resume / human intervention).
375
375
  #
376
- # It consumes HOST subagents/agents/fs/tools/commands/timer services and the HOST
377
- # projection registry (sessionProjections) + sessions store; it provides no
378
- # service of its own, so it sits loose (no isolate realm). Registering a
379
- # host-only projection unit is a registration on a host registry (exactly like
380
- # tools.register / commands.register), not the publication of a service.
376
+ # It consumes HOST subagents/agents/fs/tools/commands/timer services; it provides
377
+ # no service of its own, so it sits loose (no isolate realm). Institute state is
378
+ # persisted by the plugin itself in State/<institute>.v5state.json — the host
379
+ # session log is never written (DSH refuses to load a session that carries an
380
+ # unknown event type, and Session.append cannot mark one ignorable).
381
381
  - id: vibe-math-v5
382
382
  name: './vibe-math-v5.js'
@@ -1,2 +1,2 @@
1
1
  name: Vibe Math V5
2
- description: 研究所体系(第五代架构):设院士(领头人/组织协调中心)、常驻研究员、临时工三类职员;成员可群聊/开会、自主分工与雇佣解雇临时工;院士统筹全所、拆解并分派任务、设定优先级、主持会议、督导进度;每位成员有自己的 Progress/命题/方法/子问题库(progress 有完整定义与用途说明);任何结论须至少 m 名有表决权者一致给出布尔概率 1 或 0 才写入 Verified/,否则留库附全组平均概率;上下文达阈值自动压缩;仅当全体有表决权者一致认为原问题已解决才停止;状态经会话日志投影持久化,支持断点续跑与多会话隔离。
2
+ description: 研究所体系(第五代架构):设院士(领头人/组织协调中心)、常驻研究员、临时工三类职员;成员可群聊/开会、自主分工与雇佣解雇临时工;院士统筹全所、拆解并分派任务、设定优先级、主持会议、督导进度;每位成员有自己的 Progress/命题/方法/子问题库(progress 有完整定义与用途说明);任何结论须至少 m 名有表决权者一致给出布尔概率 1 或 0 才写入 Verified/,否则留库附全组平均概率;上下文达阈值自动压缩;仅当全体有表决权者一致认为原问题已解决才停止;状态存于加固 JSON(State/<研究所>.v5state.json),支持断点续跑与多会话隔离。