dsh-vibe-math 2.3.15 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.en.md +93 -87
  2. package/README.md +58 -53
  3. package/cordis.patch.yml +1123 -10
  4. package/docs/AUDIT-CHECKLIST.md +107 -43
  5. package/docs/COMPAT-AUDIT-ROUND2.md +3 -0
  6. package/docs/COMPAT-AUDIT-ROUND3.md +139 -0
  7. package/docs/generate_framework_diagram_v2_en.mjs +595 -0
  8. package/docs/generate_framework_diagram_v3_en.mjs +624 -0
  9. package/docs/generate_framework_diagram_v4.mjs +380 -125
  10. package/docs/generate_framework_diagram_v5.mjs +366 -108
  11. package/docs/release-notes/RELEASE-NOTES-2.3.16.md +165 -0
  12. package/docs/release-notes/RELEASE-NOTES-2.4.0.md +172 -0
  13. package/docs/test-timing.md +4 -3
  14. package/docs//346/236/266/346/236/204/345/233/276.md +3 -1
  15. package/installer.js +89 -21
  16. package/package.json +40 -9
  17. package/preset-declaration.js +62 -0
  18. package/prompt-corpus-v5/prompt-corpus-v5.json +7 -7
  19. package/prompt-corpus-v5/prompt-corpus-v5.md +8 -129
  20. package/scripts/build-preset-rows.mjs +146 -0
  21. package/tests/audit-installer-compat.test.mjs +9 -3
  22. package/tests/audit-preset-rows.test.mjs +185 -0
  23. package/tests/audit-v5-integrity.mjs +49 -3
  24. package/tests/e2e-v5-round2.test.mjs +137 -67
  25. package/tests/formal-verify-v5.test.mjs +43 -35
  26. package/tests/prompt-v5-integrity.test.mjs +49 -47
  27. package/tests/selfdrive-v5.mjs +42 -55
  28. package/vibe-math-v2/vibe-math-v2.js +6 -2
  29. package/vibe-math-v3/vibe-math-v3.js +6 -2
  30. package/vibe-math-v4/vibe-math-v4.js +191 -13
  31. package/vibe-math-v5/agent.cordis.yml +8 -8
  32. package/vibe-math-v5/preset.yml +1 -1
  33. package/vibe-math-v5/vibe-math-v5.js +215 -91
  34. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +56 -44
  35. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +24 -22
  36. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v2-en.svg +291 -0
  37. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v3-en.svg +316 -0
  38. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v4-en.svg +187 -0
  39. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5-en.svg +174 -0
  40. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +14 -14
@@ -25,17 +25,32 @@
25
25
  // `prompt-v5-integrity.test.mjs` asserts all of that against the real prompt text and
26
26
  // writes the full corpus to `prompt-corpus-v5/` for human review (实现方案.md §14.5).
27
27
  //
28
- // Durable state lives in a HOST-ONLY session projection unit (key `vibeMathV5`):
29
- // institute events are appended to the session log, never enter the model history
30
- // (zero context cost), are checkpointed by DSH, and are replayed on restore — which
31
- // structurally removes v4's `State/*.json` corruption/lost-write/resume-staleness
32
- // class of bugs. A hardened file backend is used only if the projection registry
33
- // is genuinely absent.
28
+ // Durable state lives in the hardened JSON file `State/<institute>.v5state.json` (see the
29
+ // PERSISTENCE note below): the same pure event fold is applied IN MEMORY and each commit writes the
30
+ // whole snapshot through a serial promise chain, so a late writer can never land a stale subset.
31
+ // Events never enter the model history (zero context cost), and recovery is one code path for both
32
+ // an in-process restart and a cross-process resume: read the file, then fold. That structurally
33
+ // removes v4's `State/*.json` corruption / lost-write / resume-staleness class of bugs without
34
+ // touching any host log. (Historically this was a HOST-ONLY session projection unit whose events were
35
+ // appended to the user's session; that design is gone — see the PERSISTENCE note for the exact
36
+ // reason, and note that the JSON file was once the fallback for hosts without that registry.)
34
37
  //
35
38
  // NOTE: must declare `inject` for every service read as a ctx property (the Guard
36
- // rejects undeclared dependencies), and must use the `timer` Service (ctx.timeout),
37
- // not global setTimeout/clearTimeout, which do not exist in the plugin runtime.
38
- export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer', 'sessions']
39
+ // rejects undeclared dependencies). `timer` IS injected and used (ctx.timeout) so every
40
+ // timer here is a fiber-owned disposer — but the global setTimeout/clearTimeout DO exist
41
+ // in this preset's runtime: a preset is a FILE row loaded by a plain host-realm import().
42
+ // The vm sandbox that traps require/setTimeout/setInterval/fetch wraps only a DYNAMIC
43
+ // package's host half (@deepseek-ai/dsh-cordis-host-runner/lib/types/sandbox.js, reached
44
+ // only from the dynamic-package start path); v2/v3 use those globals and work. An earlier
45
+ // version of this note claimed the globals do not exist — false for file rows, and it would
46
+ // only become true if this preset were ever converted to a dynamic package.
47
+ //
48
+ // PERSISTENCE (fixed after the DSH 0.2.0 audit): institute state lives ONLY in the hardened JSON file
49
+ // `State/<institute>.v5state.json` (see makeFileBackend/installBackend below). It is never appended
50
+ // to a host session log: DSH's session persistence refuses to load a session that carries an unknown
51
+ // event type unless the writer marked it `ignorable: true`, and `Session.append` cannot set that
52
+ // field — so the old projection-based primary made the user's own session unresumable.
53
+ export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
39
54
 
40
55
  const PROJECTION_KEY = 'vibeMathV5'
41
56
  const PROJECTION_VERSION = 1
@@ -61,13 +76,40 @@ function v5err(code, message) {
61
76
  return e
62
77
  }
63
78
 
79
+ // ---- host live-child cap (DSH ≥ 0.2) ----------------------------------------
80
+ // The host caps the number of LIVE continuable children PER ROOT AGENT. Evidence (installed
81
+ // dsh-subagent 0.2.0-rc.2): `materialize` calls `ActivationPool.reserve(this.maxActiveSubagents())`
82
+ // and `reserve` throws `SubagentError('subagent limit reached (active child limit: <capacity>);
83
+ // wait for an existing child to finish or complete this work with the current agents',
84
+ // 'ACTIVATION_LIMIT_REACHED')` once every slot is taken. The capacity is the `subagent` row's
85
+ // `maxActiveSubagents` Config key (default 8; no preset sets it), the cap is shared by all
86
+ // descendants of that root, and a slot is released when a child settles. The `.d.ts` does NOT
87
+ // document the throw.
88
+ //
89
+ // The detector and the remembered limit live at MODULE scope because the cap is a property of the
90
+ // HOST (one number for every root of this process), not of one institute. On DSH 0.1.x
91
+ // `subagents.startContinuable` never throws this code, so every branch below is inert there — the
92
+ // error code itself is the feature test, there is no version check anywhere.
93
+ let hostChildLimit // undefined until the host tells us its ceiling (via a refusal)
94
+ function isActivationLimitReached(e) { return String((e && e.code) || '') === 'ACTIVATION_LIMIT_REACHED' }
95
+ function noteChildLimit(e) {
96
+ const m = /active child limit:\s*(\d+)/.exec(String((e && e.message) || e || ''))
97
+ if (m) hostChildLimit = Number(m[1])
98
+ return hostChildLimit
99
+ }
100
+ // One actionable sentence naming the HOST ceiling and the knob that raises it.
101
+ function hostChildLimitHint(limit) {
102
+ const n = (limit === undefined || limit === null) ? '' : ('=' + limit)
103
+ return '本宿主对「同时在活的续聊子代理」有上限(宿主 subagent 行的 maxActiveSubagents 参数' + n
104
+ + ',写满后子代理服务抛 ACTIVATION_LIMIT_REACHED)'
105
+ }
106
+
64
107
  export function apply(ctx) {
65
108
  const subagents = ctx.subagents
66
109
  const agents = ctx.agents
67
110
  const fs = ctx.fs
68
111
  const tools = ctx.tools
69
112
  const commands = ctx.commands
70
- const store = ctx.sessions
71
113
 
72
114
  // Optional services are resolved LAZILY at call time, never snapshotted in apply():
73
115
  // a `ctx.get()` snapshot taken here is order-sensitive, so a service provided later
@@ -75,7 +117,16 @@ export function apply(ctx) {
75
117
  const sandboxPolicyOf = () => { try { return ctx.get('sandboxPolicy') } catch (e) { return undefined } }
76
118
  const subprocessOf = () => { try { return ctx.get('subprocess') } catch (e) { return undefined } }
77
119
  const compactionOf = () => { try { return ctx.get('compaction') } catch (e) { return undefined } }
78
- const projectionsOf = () => { try { return ctx.get('sessionProjections') } catch (e) { return undefined } }
120
+ // Prefer the AGENT's own context: this preset mounts its compaction row inside the preset's
121
+ // `compaction` isolate realm, and a realm never falls back to the host-root instance, so the root
122
+ // object can be a different policy. v4 resolves it this way already.
123
+ const compactionForAgent = (agent) => {
124
+ try {
125
+ const own = agent && agent.ctx && typeof agent.ctx.get === 'function' ? agent.ctx.get('compaction') : undefined
126
+ if (own !== undefined) return own
127
+ } catch (e) { /* fall back to the plugin plane */ }
128
+ return compactionOf()
129
+ }
79
130
 
80
131
  // ---- utils -------------------------------------------------------------
81
132
  const now = () => Date.now()
@@ -119,10 +170,10 @@ export function apply(ctx) {
119
170
  return true
120
171
  }
121
172
 
122
- // ---- projection: pure fold --------------------------------------------
173
+ // ---- state fold: the one fold every entry point shares ----------------
123
174
  // The fold is shared by BOTH persistence backends, so the state machine is
124
175
  // defined exactly once. It must return a NEW top-level reference whenever
125
- // anything changed (the projection's change feed compares by Object.is).
176
+ // anything changed (the state store compares by Object.is).
126
177
  function emptyInstitute(key, project, institute) {
127
178
  return {
128
179
  key, project, institute,
@@ -137,7 +188,7 @@ export function apply(ctx) {
137
188
  debates: [],
138
189
  verdicts: {},
139
190
  // Per-object Lean formalization record (docs/formal-verification.md). Kept in the
140
- // projection so it survives restore with zero context cost, exactly like verdicts.
191
+ // state so it survives restore with zero context cost, exactly like verdicts.
141
192
  formal: {},
142
193
  todo: [],
143
194
  queue: [],
@@ -289,38 +340,10 @@ export function apply(ctx) {
289
340
  }
290
341
  }
291
342
 
292
- // Minimal structural validator standing in for a zod schema. The projection
293
- // registry only ever calls `stateSchema.parse(row.val)` when it reloads a value
294
- // from a CHECKPOINT row, so this is the guard against a corrupt persisted row —
295
- // a plain object with `.parse` is sufficient and needs no module import (a
296
- // preset-local file cannot reliably resolve `zod`).
297
- const STATE_SCHEMA = {
298
- parse(v) {
299
- if (!v || typeof v !== 'object') throw new Error('vibe-math-v5: projection state is not an object')
300
- if (v.v !== PROJECTION_VERSION) throw new Error('vibe-math-v5: projection state version mismatch')
301
- if (!v.institutes || typeof v.institutes !== 'object') throw new Error('vibe-math-v5: projection state lacks institutes')
302
- if (!Array.isArray(v.order)) throw new Error('vibe-math-v5: projection state lacks order')
303
- return v
304
- },
305
- }
306
-
307
343
  // ---- persistence backend ----------------------------------------------
308
- // Primary: append an event to the session log and read the folded state back.
309
- // Fallback: apply the SAME fold in memory and persist hardened JSON.
310
- function makeProjectionBackend(proj, session) {
311
- return {
312
- kind: 'projection',
313
- read() {
314
- const s = proj.stateOf(session, PROJECTION_KEY)
315
- return s === undefined ? initState() : s
316
- },
317
- async commit(type, data) {
318
- session.append(type, data)
319
- try { await store.flush(session) } catch (e) { /* flush is a durability hint */ }
320
- return this.read()
321
- },
322
- }
323
- }
344
+ // The hardened JSON file is the ONLY backend. It applies the same event fold in memory and writes
345
+ // the whole snapshot under a per-file serialization chain; nothing is written to a host session log
346
+ // (see the PERSISTENCE note at the top of this file for why the projection backend was removed).
324
347
  // `pathOf` is a FUNCTION, not a captured string: the state path depends on the
325
348
  // project/institute, which the first successful load syncs back into this session —
326
349
  // a captured path would keep writing to the pre-load guess forever.
@@ -491,11 +514,7 @@ export function apply(ctx) {
491
514
  async function writeTextRel(rel, content) { return await writeTextAbs(instRoot() + '/' + rel, content) }
492
515
 
493
516
  function installBackend() {
494
- const proj = projectionsOf()
495
- const sess = (rootAgent && rootAgent.session) ? rootAgent.session : undefined
496
- if (proj && sess && typeof proj.stateOf === 'function') backend = makeProjectionBackend(proj, sess)
497
- else if (proj && sess && typeof proj.register === 'function') backend = makeProjectionBackend(proj, sess)
498
- else backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
517
+ backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
499
518
  return backend
500
519
  }
501
520
  // Every entry point that READS state must await this first. Without it the file
@@ -1195,6 +1214,31 @@ export function apply(ctx) {
1195
1214
  } catch (e) { /* ignore */ }
1196
1215
  return 'spawn'
1197
1216
  }
1217
+ // ---- host live-child cap: a refused provisioning must be named, not opaque -----
1218
+ // See the module-scope note. `spawnMember` is the ONLY place a member child is created, so the
1219
+ // pre-check and the cap-naming error live here; the callers (founding loop, resume, hire,
1220
+ // addResearcher) only have to say what happened.
1221
+ let childLimitLoggedThisRound = false // ONE actionable line per ROUND, not one per refused member
1222
+ function beginSpawnRound() { childLimitLoggedThisRound = false }
1223
+ // LIVE continuable children of THIS institute: a member that holds a childId and has not been
1224
+ // stopped. A member whose spawn was refused keeps childId '' (see the rollback in spawnMember
1225
+ // and fire()), so it can never inflate the count it is measured against.
1226
+ function liveChildCount() {
1227
+ try { return inst().members.filter((m) => m.childId && m.phase !== 'stopped').length } catch (e) { return 0 }
1228
+ }
1229
+ // Say ONCE per round what the host refused and what raises the ceiling. The member itself is
1230
+ // recorded as `failed` by the caller with the same sentence, so the refusal stays auditable.
1231
+ function noteSpawnLimitOnce(member) {
1232
+ if (childLimitLoggedThisRound) return
1233
+ childLimitLoggedThisRound = true
1234
+ console.error('vibe-math-v5: ' + activationLimitText(hostChildLimit) + (member ? ('(本轮被拒:' + member.id + ')') : ''))
1235
+ }
1236
+ // The operator-facing sentence used for BOTH the typed error and the durable member record.
1237
+ function activationLimitText(limit) {
1238
+ return hostChildLimitHint(limit) + ';本所已有的在活成员已占满该上限,暂时无法再创建成员。'
1239
+ + '可在宿主的 subagent 行把 maxActiveSubagents 调大后重试,或先 vibe_v5_fire 解雇不用的临时工腾出位置。'
1240
+ }
1241
+
1198
1242
  // Bring a member into being. ORDER IS LOAD-BEARING and is the fix for the
1199
1243
  // "every brief describes the wrong person" bug:
1200
1244
  // 1. commit the member as ACTIVE first, so that everything derived from
@@ -1211,6 +1255,14 @@ export function apply(ctx) {
1211
1255
  // session is being rebuilt: the latter must NOT be told it "just joined the
1212
1256
  // institute" and must not be shown the induction blurb.
1213
1257
  async function spawnMember(member, initialTask, mode) {
1258
+ // Host live-child cap (DSH ≥0.2): once we KNOW the ceiling and our OWN live members fill it,
1259
+ // do not even call the host — it would refuse with the bare "subagent limit reached (active
1260
+ // child limit: N)". One line per ROUND (not per member); the caller records the member as
1261
+ // `failed` with the same sentence, and resume() retries cap-failed members once a slot frees.
1262
+ if (hostChildLimit !== undefined && liveChildCount() >= hostChildLimit) {
1263
+ noteSpawnLimitOnce(member)
1264
+ throw v5err('ACTIVATION_LIMIT_REACHED', activationLimitText(hostChildLimit))
1265
+ }
1214
1266
  const provider = member.provider || pickProvider()
1215
1267
  const ao = memberAgentOptions()
1216
1268
  const tf = memberToolFilter(member)
@@ -1235,15 +1287,17 @@ export function apply(ctx) {
1235
1287
  await writeRosterMirror()
1236
1288
  let started
1237
1289
  try {
1238
- started = await subagents.startContinuable({
1239
- provider,
1240
- label: 'vibe5 ' + member.id + ' (' + kindLabel(member.kind) + ')',
1241
- request: Object.assign({
1242
- prompt: [textBlock(prompt)],
1243
- parent: rootAgent,
1244
- persona,
1245
- }, Object.keys(ao).length ? { agentOptions: ao } : {}, tf ? { toolFilter: tf } : {}),
1246
- signal: makeSignal(params.activityTimeoutMs),
1290
+ started = await startWithToolFilter(tf, function (f) {
1291
+ return {
1292
+ provider,
1293
+ label: 'vibe5 ' + member.id + ' (' + kindLabel(member.kind) + ')',
1294
+ request: Object.assign({
1295
+ prompt: [textBlock(prompt)],
1296
+ parent: rootAgent,
1297
+ persona,
1298
+ }, Object.keys(ao).length ? { agentOptions: ao } : {}, f ? { toolFilter: f } : {}),
1299
+ signal: makeSignal(params.activityTimeoutMs),
1300
+ }
1247
1301
  })
1248
1302
  } catch (e) {
1249
1303
  // Roll the in-memory marks back so a failed provisioning leaves no phantom
@@ -1253,7 +1307,15 @@ export function apply(ctx) {
1253
1307
  wakeKind.delete(member.id)
1254
1308
  rounds.delete(member.id)
1255
1309
  roundsSinceCompact.delete(member.id)
1256
- await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
1310
+ // The host's live-child cap is a HOST limit (maxActiveSubagents on the `subagent` row), not
1311
+ // a defect in this member: remember the ceiling, report it BY NAME instead of relaying the
1312
+ // opaque host string, and throw the TYPED error so the founding loop / hire / addResearcher
1313
+ // name it too instead of surfacing the raw host message.
1314
+ const limitHit = isActivationLimitReached(e)
1315
+ if (limitHit) { noteChildLimit(e); noteSpawnLimitOnce(member) }
1316
+ const message = limitHit ? activationLimitText(hostChildLimit) : String((e && e.message) || e)
1317
+ await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: message }))
1318
+ if (limitHit) throw v5err('ACTIVATION_LIMIT_REACHED', message)
1257
1319
  throw e
1258
1320
  }
1259
1321
  member.childId = started.childId
@@ -1269,6 +1331,10 @@ export function apply(ctx) {
1269
1331
  // Deliver one prompt to a member. MUST use `subagents.sendMessage` — the
1270
1332
  // `subagents` SERVICE has no `followup` (that is only an Agent method); calling
1271
1333
  // it threw a TypeError on every wake and silently stalled the whole group (v4 §25).
1334
+ // Unlike v4 there is no legacy fallback branch here, and none is needed: the service
1335
+ // method existed only up to DSH 0.1.2-alpha.*, was dropped in 0.1.2-rc.1, and every
1336
+ // release this preset targets exposes startContinuable/sendMessage/
1337
+ // drainContinuableChildren only (checked against dsh-subagent 0.2.0-rc.2).
1272
1338
  async function wakeMember(member, promptText, kind) {
1273
1339
  if (!member || !member.childId || member.phase !== 'active') return false
1274
1340
  clearHeartbeat()
@@ -1889,7 +1955,7 @@ export function apply(ctx) {
1889
1955
  async function writeFormalIndex() {
1890
1956
  const recs = formalRecords()
1891
1957
  const L = ['# Lean 形式化索引|' + instituteName + '|' + fmtTime(), '',
1892
- '> 本文件由框架维护(工具调用时更新;`vibe_v5_lean_lib` 会重建)。权威状态在会话日志投影里。', '',
1958
+ '> 本文件由框架维护(工具调用时更新;`vibe_v5_lean_lib` 会重建)。权威状态在 State/<研究所>.v5state.json 里。', '',
1893
1959
  '| 对象 | 状态 | 形式化文件 | 归档证明 | 最近运行 | 难度判断 / 阻塞原因 |', '|---|---|---|---|---|---|']
1894
1960
  const keys = Object.keys(recs)
1895
1961
  if (!keys.length) L.push('| (暂无) | | | | | |')
@@ -2440,7 +2506,7 @@ export function apply(ctx) {
2440
2506
  async function writeTaskboardMirror() {
2441
2507
  const ts = listTasks()
2442
2508
  const lines = ['# 任务板(人读镜像)|' + instituteName + '|' + fmtTime(), '',
2443
- '> 权威状态在会话日志投影里;本文件只是给人和所办看的快照,勿手改。', '']
2509
+ '> 权威状态在 State/<研究所>.v5state.json 里;本文件只是给人和所办看的快照,勿手改。', '']
2444
2510
  if (!ts.length) lines.push('(暂无任务)')
2445
2511
  for (const t of ts) {
2446
2512
  lines.push('- [' + t.status + '] ' + t.id + '|' + t.subject + '|owner=' + (t.owner || t.ownerName || '(未认领)') +
@@ -2452,12 +2518,12 @@ export function apply(ctx) {
2452
2518
  await writeTextRel('Shared/TaskBoard.md', lines.join('\n'))
2453
2519
  }
2454
2520
  // The roster mirror (§8.4): a human-readable staffing table. Like the task-board
2455
- // mirror it is WRITE-ONLY — the authoritative roster is the session-log projection,
2521
+ // mirror it is WRITE-ONLY — the authoritative roster is State/<研究所>.v5state.json,
2456
2522
  // so losing or hand-editing this file can never corrupt the institute.
2457
2523
  async function writeRosterMirror() {
2458
2524
  const s = inst()
2459
2525
  const lines = ['# 研究所编制表(人读镜像)|' + instituteName + '|' + fmtTime(), '',
2460
- '> 权威状态在会话日志投影里;本文件只是快照,勿手改。', '']
2526
+ '> 权威状态在 State/<研究所>.v5state.json 里;本文件只是快照,勿手改。', '']
2461
2527
  lines.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
2462
2528
  lines.push('- 阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
2463
2529
  lines.push('')
@@ -3171,6 +3237,10 @@ export function apply(ctx) {
3171
3237
  await spawnMember(member, initialTask)
3172
3238
  } catch (e) {
3173
3239
  await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
3240
+ // Name the host's live-child cap (maxActiveSubagents) instead of relaying its opaque
3241
+ // "subagent limit reached (active child limit: N)" string, and use the typed code so the
3242
+ // hirer can tell a HOST ceiling from a broken provider.
3243
+ if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: '临时工创建失败:' + activationLimitText(hostChildLimit) } }
3174
3244
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '临时工创建失败:' + String((e && e.message) || e) }
3175
3245
  }
3176
3246
  await saveChatLine('【雇佣】' + (office ? '所办' : callerId) + ' 雇入临时工 ' + member.id + ',用途:' + purpose)
@@ -3630,18 +3700,63 @@ export function apply(ctx) {
3630
3700
  if (member.activeMeetingId) delete member.activeMeetingId
3631
3701
  await scheduleNext()
3632
3702
  }
3703
+ // ---- toolFilter names the host may not register -------------------------
3704
+ // Same guard as v2/v3/v4: `tools.restrict` throws for an unregistered name and the throw escapes
3705
+ // child creation, so a stale `vibe_v5_set{toolAllow|toolDeny|tempToolAllow|tempToolDeny}` would
3706
+ // make every member spawn fail silently. The host names the registered tools in its rejection.
3707
+ function registeredToolsFromError(message){
3708
+ const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
3709
+ if(!m) return undefined
3710
+ const names = m[1].split(',').map(function(s){ return s.trim() }).filter(Boolean)
3711
+ return names.length > 0 ? new Set(names) : undefined
3712
+ }
3713
+ function sanitizeToolFilter(filter, known){
3714
+ if(!filter || !(known instanceof Set) || known.size === 0) return filter
3715
+ const out = {}
3716
+ for(const key of ['allow','deny']){
3717
+ const list = filter[key]
3718
+ if(!Array.isArray(list)) continue
3719
+ const kept = list.filter(function(n){ return known.has(String(n).trim()) })
3720
+ if(kept.length > 0) out[key] = kept
3721
+ }
3722
+ return (out.allow || out.deny) ? out : undefined
3723
+ }
3724
+ async function startWithToolFilter(toolFilter, makeSpec){
3725
+ try {
3726
+ return await subagents.startContinuable(makeSpec(toolFilter))
3727
+ } catch(e){
3728
+ const message = String((e && e.message) || e)
3729
+ const retry = sanitizeToolFilter(toolFilter, registeredToolsFromError(message))
3730
+ if(retry===undefined){
3731
+ console.error('vibe-math-v5: 配置的工具过滤只包含本宿主未注册的工具名,拒绝在不带过滤的情况下启动成员。filter=' + JSON.stringify(toolFilter) + ' 宿主提示:' + message)
3732
+ throw e
3733
+ }
3734
+ if(JSON.stringify(retry) === JSON.stringify(toolFilter)) throw e
3735
+ console.error('vibe-math-v5: 工具过滤里有本宿主未注册的名字,已只保留已注册的名字重试。dropped=' + JSON.stringify(toolFilter) + ' kept=' + JSON.stringify(retry))
3736
+ // a FRESH spec (and a fresh timeout signal) for the retry
3737
+ return await subagents.startContinuable(makeSpec(retry))
3738
+ }
3739
+ }
3740
+
3633
3741
  async function maybeRealCompact(childId, member) {
3634
3742
  const roundN = roundsSinceCompact.get(member.id) || 0
3635
3743
  const pct = contextPct.get(member.id) || 0
3636
3744
  const soft = pct >= Number(params.compactThreshold) || roundN >= Number(params.compactAfterRounds)
3637
3745
  if (!soft) return
3638
- const comp = compactionOf()
3639
3746
  const agent = liveAgentOf(childId)
3640
- if (comp && agent && agent.session && typeof comp.compactIfNeeded === 'function') {
3641
- try {
3642
- const r = await comp.compactIfNeeded(agent, 'pressure', makeSignal(params.activityTimeoutMs))
3643
- if (r) { roundsSinceCompact.set(member.id, 0); needReanchor.add(member.id); return }
3644
- } catch (e) { /* fall through to the soft path */ }
3747
+ const comp = compactionForAgent(agent)
3748
+ if (comp && agent && agent.session) {
3749
+ // Prefer the FORCING verb when the host has it: `compactIfNeeded` is a policy call that may
3750
+ // decide not to compact and return null without saying so, while `compactNow` does what the
3751
+ // operator asked for. Feature-detected so older hosts keep the policy call.
3752
+ const force = typeof comp.compactNow === 'function'
3753
+ const ask = typeof comp.compactIfNeeded === 'function'
3754
+ if (force || ask) {
3755
+ try {
3756
+ const r = force ? await comp.compactNow(agent, makeSignal(params.activityTimeoutMs)) : await comp.compactIfNeeded(agent, 'pressure', makeSignal(params.activityTimeoutMs))
3757
+ if (r) { roundsSinceCompact.set(member.id, 0); needReanchor.add(member.id); return }
3758
+ } catch (e) { /* fall through to the soft path */ }
3759
+ }
3645
3760
  }
3646
3761
  needReanchor.add(member.id)
3647
3762
  }
@@ -3779,11 +3894,11 @@ export function apply(ctx) {
3779
3894
  await writeTextRel('State/README.md', [
3780
3895
  '# 关于 State/',
3781
3896
  '',
3782
- '本研究所的**权威状态在会话日志投影里**(投影键 `vibeMathV5`),不是这里的文件。',
3897
+ '本研究所的**权威状态就在本目录的 `<研究所>.v5state.json` 里**,不在任何宿主会话日志里。',
3783
3898
  '本目录只存放人可读的镜像/说明,**请勿手改**;改动不会影响真正的状态。',
3784
3899
  '要查看状态请用 `vibe_v5_status` / `vibe_v5_report`。',
3785
3900
  '',
3786
- '唯一例外:当宿主没有 `sessionProjections` 服务时,v5 会回退到',
3901
+ '研究所状态只写在加固 JSON 里(见下),不写入宿主会话日志:',
3787
3902
  '`State/<institute>.v5state.json`(加固 JSON 后端),此时它才是权威源。',
3788
3903
  '安装器会在启动自检里报告这一降级。',
3789
3904
  '',
@@ -3829,6 +3944,10 @@ export function apply(ctx) {
3829
3944
  '从已知的相近结论出发,看能否推广或加强得到所需结果。',
3830
3945
  ]
3831
3946
  const spawned = []
3947
+ // A refused provisioning (the host's live-child cap) is remembered for this founding round so
3948
+ // the aggregate failure below can NAME the cap instead of blaming the provider.
3949
+ let limitRefused = false
3950
+ beginSpawnRound() // the whole founding loop is ONE round for the cap notice
3832
3951
  // The academician is founded FIRST so it is on the roster for every later member's
3833
3952
  // induction brief, and so it can begin overseeing the founding round. Each member
3834
3953
  // is committed to the ACTIVE roster before its own brief is built (see
@@ -3841,6 +3960,7 @@ export function apply(ctx) {
3841
3960
  + '你打算如何组织全所(谁适合做什么、先做什么)?把你的判断写进你的 Progress/,并把关键结论在群聊里说出来。')
3842
3961
  spawned.push(m.id)
3843
3962
  } catch (e) {
3963
+ if (isActivationLimitReached(e)) limitRefused = true
3844
3964
  await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
3845
3965
  }
3846
3966
  }
@@ -3851,11 +3971,18 @@ export function apply(ctx) {
3851
3971
  await spawnMember(m, null)
3852
3972
  spawned.push(m.id)
3853
3973
  } catch (e) {
3974
+ if (isActivationLimitReached(e)) limitRefused = true
3854
3975
  await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
3855
3976
  }
3856
3977
  }
3857
3978
  if (!spawned.length) {
3858
3979
  await patchInstitute({ phase: 'idle' })
3980
+ // When the HOST's per-root child cap refused every member, say so: the generic
3981
+ // "check the provider" message made a real host ceiling look like a broken host.
3982
+ if (limitRefused) {
3983
+ return { ok: false, code: 'ACTIVATION_LIMIT_REACHED',
3984
+ message: '没有任何成员创建成功:' + activationLimitText(hostChildLimit) }
3985
+ }
3859
3986
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '没有任何成员创建成功;请检查 subagents 提供者与会话持久化是否可用' }
3860
3987
  }
3861
3988
  running = true
@@ -3895,9 +4022,16 @@ export function apply(ctx) {
3895
4022
  syncParamsFromState()
3896
4023
  if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this institute already concluded; configure a new one in a new session' }
3897
4024
  const members = activeMembers()
3898
- if (!members.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
4025
+ // Members the host's live-child cap refused are NOT dead: they are still wanted, and a slot
4026
+ // may have been released since (a temp was fired, another child settled). They are the
4027
+ // "queued" half of the cap handling, retried in this round — gated on the RECORDED cap error
4028
+ // so an ordinary failed member (broken provider, rejected tool filter) is never resurrected
4029
+ // by a resume. `members` keeps its meaning for the return value below.
4030
+ const queued = inst().members.filter((m) => m.phase === 'failed' && /active child limit|maxActiveSubagents/.test(String(m.error || '')))
4031
+ if (!members.length && !queued.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
3899
4032
  let respawned = 0
3900
- for (const m of members) {
4033
+ beginSpawnRound() // the whole re-spawn loop is ONE round for the cap notice
4034
+ for (const m of members.concat(queued)) {
3901
4035
  if (m.childId) continue
3902
4036
  try {
3903
4037
  const seedText = (await readTextRel('Members/' + m.id + '/Progress/progress.md')) || ''
@@ -4071,6 +4205,7 @@ export function apply(ctx) {
4071
4205
  await spawnMember(m, null)
4072
4206
  } catch (e) {
4073
4207
  await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
4208
+ if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: activationLimitText(hostChildLimit) } }
4074
4209
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: String((e && e.message) || e) }
4075
4210
  }
4076
4211
  await saveChatLine('【编制】所办增聘常驻研究员 ' + m.id + (direction ? '(方向:' + direction + ')' : '') +
@@ -4156,7 +4291,7 @@ export function apply(ctx) {
4156
4291
  registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s) => s.initStop())
4157
4292
  registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
4158
4293
  registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
4159
- registerTool('vibe_v5_set', 'Tune institute parameters (persisted in the session-log projection). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents 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 true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record).', objParams({
4294
+ registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents 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 true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record).', objParams({
4160
4295
  academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
4161
4296
  quorumCap: I, quorumMode: S, verdictMaxRounds: I,
4162
4297
  maxTempPerMember: I, maxTempTotal: I,
@@ -4315,22 +4450,11 @@ export function apply(ctx) {
4315
4450
  },
4316
4451
  }))
4317
4452
 
4318
- // ── the institute-state projection unit (registered ONCE, host-only) ──────
4319
- // No `wire`, so this unit is omitted from client snapshots but is checkpointed like
4320
- // every other unit — verified on this host: checkpoint() carries it, restore()
4321
- // refolds it from checkpoint + log tail, and it never enters `deriveMessages()`.
4322
- const projections = projectionsOf()
4323
- if (projections && typeof projections.register === 'function') {
4324
- ctx.effect(() => projections.register({
4325
- key: PROJECTION_KEY,
4326
- stateVersion: PROJECTION_VERSION,
4327
- stateSchema: STATE_SCHEMA,
4328
- init: initState,
4329
- apply: applyV5Event,
4330
- }))
4331
- } else {
4332
- console.error('vibe-math-v5: sessionProjections unavailable — falling back to the hardened JSON state file (State/<institute>.v5state.json)')
4333
- }
4453
+ // ── institute state on disk ──────────────────────────────────────────────
4454
+ // State/<institute>.v5state.json is written by the file backend above (installBackend); it is the
4455
+ // only durable authority. The host's session projections are deliberately NOT used: DSH refuses to
4456
+ // load a session whose log carries event types outside its known set, so writing institute events
4457
+ // into the user's session made that session unresumable.
4334
4458
 
4335
4459
  // ── agent lifecycle wiring ───────────────────────────────────────────────
4336
4460
  // Capture the live child Agent while it is STILL registered: `subagent/end` is