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
@@ -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,22 +117,25 @@ 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
- const now = () => Date.now()
82
132
  function hex(n) { let s = ''; for (let i = 0; i < n; i++) s += '0123456789abcdef'[Math.floor(Math.random() * 16)]; return s }
83
133
  const shortId = () => hex(8)
84
- function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
85
134
  // contextPct is a PERCENT (0-100); never clamp to 0-1 or the compactThreshold
86
135
  // comparison (e.g. 66) becomes `1.0 >= 66` and never fires (v4 §17 defect).
87
- function clPct(x) { const n = Number(x); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, n)) }
88
136
  // Positive duration with a safe fallback: a NEGATIVE/NaN duration parameter must
89
137
  // never make a watchdog fire instantly or an idle window never elapse (v4 §30-T41).
90
- function posMs(v, def) { const n = Number(v); return (Number.isFinite(n) && n > 0) ? n : (def || 120000) }
91
138
  const textBlock = (t) => ({ type: 'text', text: String(t) })
92
- function blocksToText(b) { if (!b) return ''; let o = ''; for (const x of b) { if (x && x.type === 'text' && typeof x.text === 'string') o += x.text + '\n' } return o }
93
- function fmtTime(ts) { try { return new Date(ts || now()).toISOString().replace('T', ' ').slice(0, 19) } catch (e) { return String(ts || '') } }
94
139
  function makeSignal(ms) { try { return AbortSignal.timeout(posMs(ms, 30000)) } catch (e) { return undefined } }
95
140
 
96
141
  // Object ids (verify targets, card ids, member ids) become FILE NAMES and DIRECTORY
@@ -98,10 +143,6 @@ export function apply(ctx) {
98
143
  // characters would escape the project tree. Keep every harmless character (incl.
99
144
  // Chinese) and replace only separators/control chars; strip leading/trailing dots
100
145
  // and dashes so the name is never '.' or '..' (v4 §30-T39).
101
- function idSafe(s) {
102
- const t = String(s == null ? '' : s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').replace(/-{2,}/g, '-').replace(/^[.\-]+|[.\-]+$/g, '')
103
- return t
104
- }
105
146
  // Advisory write-scope normalisation, ported from DSH agent-teams: backslashes to
106
147
  // '/', strip a leading './' and trailing '/', reject empty/absolute/drive-letter/
107
148
  // '..'-segment scopes.
@@ -119,10 +160,10 @@ export function apply(ctx) {
119
160
  return true
120
161
  }
121
162
 
122
- // ---- projection: pure fold --------------------------------------------
163
+ // ---- state fold: the one fold every entry point shares ----------------
123
164
  // The fold is shared by BOTH persistence backends, so the state machine is
124
165
  // defined exactly once. It must return a NEW top-level reference whenever
125
- // anything changed (the projection's change feed compares by Object.is).
166
+ // anything changed (the state store compares by Object.is).
126
167
  function emptyInstitute(key, project, institute) {
127
168
  return {
128
169
  key, project, institute,
@@ -137,7 +178,7 @@ export function apply(ctx) {
137
178
  debates: [],
138
179
  verdicts: {},
139
180
  // 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.
181
+ // state so it survives restore with zero context cost, exactly like verdicts.
141
182
  formal: {},
142
183
  todo: [],
143
184
  queue: [],
@@ -289,38 +330,10 @@ export function apply(ctx) {
289
330
  }
290
331
  }
291
332
 
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
333
  // ---- 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
- }
334
+ // The hardened JSON file is the ONLY backend. It applies the same event fold in memory and writes
335
+ // the whole snapshot under a per-file serialization chain; nothing is written to a host session log
336
+ // (see the PERSISTENCE note at the top of this file for why the projection backend was removed).
324
337
  // `pathOf` is a FUNCTION, not a captured string: the state path depends on the
325
338
  // project/institute, which the first successful load syncs back into this session —
326
339
  // a captured path would keep writing to the pre-load guess forever.
@@ -491,11 +504,7 @@ export function apply(ctx) {
491
504
  async function writeTextRel(rel, content) { return await writeTextAbs(instRoot() + '/' + rel, content) }
492
505
 
493
506
  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')
507
+ backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
499
508
  return backend
500
509
  }
501
510
  // Every entry point that READS state must await this first. Without it the file
@@ -1195,6 +1204,31 @@ export function apply(ctx) {
1195
1204
  } catch (e) { /* ignore */ }
1196
1205
  return 'spawn'
1197
1206
  }
1207
+ // ---- host live-child cap: a refused provisioning must be named, not opaque -----
1208
+ // See the module-scope note. `spawnMember` is the ONLY place a member child is created, so the
1209
+ // pre-check and the cap-naming error live here; the callers (founding loop, resume, hire,
1210
+ // addResearcher) only have to say what happened.
1211
+ let childLimitLoggedThisRound = false // ONE actionable line per ROUND, not one per refused member
1212
+ function beginSpawnRound() { childLimitLoggedThisRound = false }
1213
+ // LIVE continuable children of THIS institute: a member that holds a childId and has not been
1214
+ // stopped. A member whose spawn was refused keeps childId '' (see the rollback in spawnMember
1215
+ // and fire()), so it can never inflate the count it is measured against.
1216
+ function liveChildCount() {
1217
+ try { return inst().members.filter((m) => m.childId && m.phase !== 'stopped').length } catch (e) { return 0 }
1218
+ }
1219
+ // Say ONCE per round what the host refused and what raises the ceiling. The member itself is
1220
+ // recorded as `failed` by the caller with the same sentence, so the refusal stays auditable.
1221
+ function noteSpawnLimitOnce(member) {
1222
+ if (childLimitLoggedThisRound) return
1223
+ childLimitLoggedThisRound = true
1224
+ console.error('vibe-math-v5: ' + activationLimitText(hostChildLimit) + (member ? ('(本轮被拒:' + member.id + ')') : ''))
1225
+ }
1226
+ // The operator-facing sentence used for BOTH the typed error and the durable member record.
1227
+ function activationLimitText(limit) {
1228
+ return hostChildLimitHint(limit) + ';本所已有的在活成员已占满该上限,暂时无法再创建成员。'
1229
+ + '可在宿主的 subagent 行把 maxActiveSubagents 调大后重试,或先 vibe_v5_fire 解雇不用的临时工腾出位置。'
1230
+ }
1231
+
1198
1232
  // Bring a member into being. ORDER IS LOAD-BEARING and is the fix for the
1199
1233
  // "every brief describes the wrong person" bug:
1200
1234
  // 1. commit the member as ACTIVE first, so that everything derived from
@@ -1211,6 +1245,14 @@ export function apply(ctx) {
1211
1245
  // session is being rebuilt: the latter must NOT be told it "just joined the
1212
1246
  // institute" and must not be shown the induction blurb.
1213
1247
  async function spawnMember(member, initialTask, mode) {
1248
+ // Host live-child cap (DSH ≥0.2): once we KNOW the ceiling and our OWN live members fill it,
1249
+ // do not even call the host — it would refuse with the bare "subagent limit reached (active
1250
+ // child limit: N)". One line per ROUND (not per member); the caller records the member as
1251
+ // `failed` with the same sentence, and resume() retries cap-failed members once a slot frees.
1252
+ if (hostChildLimit !== undefined && liveChildCount() >= hostChildLimit) {
1253
+ noteSpawnLimitOnce(member)
1254
+ throw v5err('ACTIVATION_LIMIT_REACHED', activationLimitText(hostChildLimit))
1255
+ }
1214
1256
  const provider = member.provider || pickProvider()
1215
1257
  const ao = memberAgentOptions()
1216
1258
  const tf = memberToolFilter(member)
@@ -1235,15 +1277,17 @@ export function apply(ctx) {
1235
1277
  await writeRosterMirror()
1236
1278
  let started
1237
1279
  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),
1280
+ started = await startWithToolFilter(tf, function (f) {
1281
+ return {
1282
+ provider,
1283
+ label: 'vibe5 ' + member.id + ' (' + kindLabel(member.kind) + ')',
1284
+ request: Object.assign({
1285
+ prompt: [textBlock(prompt)],
1286
+ parent: rootAgent,
1287
+ persona,
1288
+ }, Object.keys(ao).length ? { agentOptions: ao } : {}, f ? { toolFilter: f } : {}),
1289
+ signal: makeSignal(params.activityTimeoutMs),
1290
+ }
1247
1291
  })
1248
1292
  } catch (e) {
1249
1293
  // Roll the in-memory marks back so a failed provisioning leaves no phantom
@@ -1253,7 +1297,15 @@ export function apply(ctx) {
1253
1297
  wakeKind.delete(member.id)
1254
1298
  rounds.delete(member.id)
1255
1299
  roundsSinceCompact.delete(member.id)
1256
- await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
1300
+ // The host's live-child cap is a HOST limit (maxActiveSubagents on the `subagent` row), not
1301
+ // a defect in this member: remember the ceiling, report it BY NAME instead of relaying the
1302
+ // opaque host string, and throw the TYPED error so the founding loop / hire / addResearcher
1303
+ // name it too instead of surfacing the raw host message.
1304
+ const limitHit = isActivationLimitReached(e)
1305
+ if (limitHit) { noteChildLimit(e); noteSpawnLimitOnce(member) }
1306
+ const message = limitHit ? activationLimitText(hostChildLimit) : String((e && e.message) || e)
1307
+ await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: message }))
1308
+ if (limitHit) throw v5err('ACTIVATION_LIMIT_REACHED', message)
1257
1309
  throw e
1258
1310
  }
1259
1311
  member.childId = started.childId
@@ -1269,6 +1321,10 @@ export function apply(ctx) {
1269
1321
  // Deliver one prompt to a member. MUST use `subagents.sendMessage` — the
1270
1322
  // `subagents` SERVICE has no `followup` (that is only an Agent method); calling
1271
1323
  // it threw a TypeError on every wake and silently stalled the whole group (v4 §25).
1324
+ // Unlike v4 there is no legacy fallback branch here, and none is needed: the service
1325
+ // method existed only up to DSH 0.1.2-alpha.*, was dropped in 0.1.2-rc.1, and every
1326
+ // release this preset targets exposes startContinuable/sendMessage/
1327
+ // drainContinuableChildren only (checked against dsh-subagent 0.2.0-rc.2).
1272
1328
  async function wakeMember(member, promptText, kind) {
1273
1329
  if (!member || !member.childId || member.phase !== 'active') return false
1274
1330
  clearHeartbeat()
@@ -1889,7 +1945,7 @@ export function apply(ctx) {
1889
1945
  async function writeFormalIndex() {
1890
1946
  const recs = formalRecords()
1891
1947
  const L = ['# Lean 形式化索引|' + instituteName + '|' + fmtTime(), '',
1892
- '> 本文件由框架维护(工具调用时更新;`vibe_v5_lean_lib` 会重建)。权威状态在会话日志投影里。', '',
1948
+ '> 本文件由框架维护(工具调用时更新;`vibe_v5_lean_lib` 会重建)。权威状态在 State/<研究所>.v5state.json 里。', '',
1893
1949
  '| 对象 | 状态 | 形式化文件 | 归档证明 | 最近运行 | 难度判断 / 阻塞原因 |', '|---|---|---|---|---|---|']
1894
1950
  const keys = Object.keys(recs)
1895
1951
  if (!keys.length) L.push('| (暂无) | | | | | |')
@@ -2440,7 +2496,7 @@ export function apply(ctx) {
2440
2496
  async function writeTaskboardMirror() {
2441
2497
  const ts = listTasks()
2442
2498
  const lines = ['# 任务板(人读镜像)|' + instituteName + '|' + fmtTime(), '',
2443
- '> 权威状态在会话日志投影里;本文件只是给人和所办看的快照,勿手改。', '']
2499
+ '> 权威状态在 State/<研究所>.v5state.json 里;本文件只是给人和所办看的快照,勿手改。', '']
2444
2500
  if (!ts.length) lines.push('(暂无任务)')
2445
2501
  for (const t of ts) {
2446
2502
  lines.push('- [' + t.status + '] ' + t.id + '|' + t.subject + '|owner=' + (t.owner || t.ownerName || '(未认领)') +
@@ -2452,12 +2508,12 @@ export function apply(ctx) {
2452
2508
  await writeTextRel('Shared/TaskBoard.md', lines.join('\n'))
2453
2509
  }
2454
2510
  // 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,
2511
+ // mirror it is WRITE-ONLY — the authoritative roster is State/<研究所>.v5state.json,
2456
2512
  // so losing or hand-editing this file can never corrupt the institute.
2457
2513
  async function writeRosterMirror() {
2458
2514
  const s = inst()
2459
2515
  const lines = ['# 研究所编制表(人读镜像)|' + instituteName + '|' + fmtTime(), '',
2460
- '> 权威状态在会话日志投影里;本文件只是快照,勿手改。', '']
2516
+ '> 权威状态在 State/<研究所>.v5state.json 里;本文件只是快照,勿手改。', '']
2461
2517
  lines.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
2462
2518
  lines.push('- 阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
2463
2519
  lines.push('')
@@ -3171,6 +3227,10 @@ export function apply(ctx) {
3171
3227
  await spawnMember(member, initialTask)
3172
3228
  } catch (e) {
3173
3229
  await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
3230
+ // Name the host's live-child cap (maxActiveSubagents) instead of relaying its opaque
3231
+ // "subagent limit reached (active child limit: N)" string, and use the typed code so the
3232
+ // hirer can tell a HOST ceiling from a broken provider.
3233
+ if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: '临时工创建失败:' + activationLimitText(hostChildLimit) } }
3174
3234
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '临时工创建失败:' + String((e && e.message) || e) }
3175
3235
  }
3176
3236
  await saveChatLine('【雇佣】' + (office ? '所办' : callerId) + ' 雇入临时工 ' + member.id + ',用途:' + purpose)
@@ -3406,30 +3466,6 @@ export function apply(ctx) {
3406
3466
  }
3407
3467
 
3408
3468
  // ---- reply parsing -----------------------------------------------------
3409
- function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
3410
- function parseReply(text) {
3411
- let obj
3412
- const fence = /```(?:json)?[ \t]*([\s\S]*?)```/gi
3413
- let m
3414
- while ((m = fence.exec(text)) !== null) {
3415
- const o = tryJson(String(m[1]).trim())
3416
- if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
3417
- }
3418
- if (!obj) {
3419
- const w = tryJson(String(text || '').trim())
3420
- if (w && typeof w === 'object' && !Array.isArray(w)) obj = w
3421
- }
3422
- if (!obj) {
3423
- // Last resort: the outermost {...} span (models sometimes wrap prose around it).
3424
- const t = String(text || '')
3425
- const i = t.indexOf('{'), j = t.lastIndexOf('}')
3426
- if (i !== -1 && j > i) {
3427
- const o = tryJson(t.slice(i, j + 1))
3428
- if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
3429
- }
3430
- }
3431
- return obj || {}
3432
- }
3433
3469
  function normVerdictNumber(v) {
3434
3470
  // verdict is a PURE 0-1 probability. Models often send a quoted number, and a
3435
3471
  // quoted "0.9" used to fall through to a 0.5 default and be silently recorded
@@ -3630,18 +3666,46 @@ export function apply(ctx) {
3630
3666
  if (member.activeMeetingId) delete member.activeMeetingId
3631
3667
  await scheduleNext()
3632
3668
  }
3669
+ // ---- toolFilter names the host may not register -------------------------
3670
+ // Same guard as v2/v3/v4: `tools.restrict` throws for an unregistered name and the throw escapes
3671
+ // child creation, so a stale `vibe_v5_set{toolAllow|toolDeny|tempToolAllow|tempToolDeny}` would
3672
+ // make every member spawn fail silently. The host names the registered tools in its rejection.
3673
+ async function startWithToolFilter(toolFilter, makeSpec){
3674
+ try {
3675
+ return await subagents.startContinuable(makeSpec(toolFilter))
3676
+ } catch(e){
3677
+ const message = String((e && e.message) || e)
3678
+ const retry = sanitizeToolFilter(toolFilter, registeredToolsFromError(message))
3679
+ if(retry===undefined){
3680
+ console.error('vibe-math-v5: 配置的工具过滤只包含本宿主未注册的工具名,拒绝在不带过滤的情况下启动成员。filter=' + JSON.stringify(toolFilter) + ' 宿主提示:' + message)
3681
+ throw e
3682
+ }
3683
+ if(JSON.stringify(retry) === JSON.stringify(toolFilter)) throw e
3684
+ console.error('vibe-math-v5: 工具过滤里有本宿主未注册的名字,已只保留已注册的名字重试。dropped=' + JSON.stringify(toolFilter) + ' kept=' + JSON.stringify(retry))
3685
+ // a FRESH spec (and a fresh timeout signal) for the retry
3686
+ return await subagents.startContinuable(makeSpec(retry))
3687
+ }
3688
+ }
3689
+
3633
3690
  async function maybeRealCompact(childId, member) {
3634
3691
  const roundN = roundsSinceCompact.get(member.id) || 0
3635
3692
  const pct = contextPct.get(member.id) || 0
3636
3693
  const soft = pct >= Number(params.compactThreshold) || roundN >= Number(params.compactAfterRounds)
3637
3694
  if (!soft) return
3638
- const comp = compactionOf()
3639
3695
  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 */ }
3696
+ const comp = compactionForAgent(agent)
3697
+ if (comp && agent && agent.session) {
3698
+ // Prefer the FORCING verb when the host has it: `compactIfNeeded` is a policy call that may
3699
+ // decide not to compact and return null without saying so, while `compactNow` does what the
3700
+ // operator asked for. Feature-detected so older hosts keep the policy call.
3701
+ const force = typeof comp.compactNow === 'function'
3702
+ const ask = typeof comp.compactIfNeeded === 'function'
3703
+ if (force || ask) {
3704
+ try {
3705
+ const r = force ? await comp.compactNow(agent, makeSignal(params.activityTimeoutMs)) : await comp.compactIfNeeded(agent, 'pressure', makeSignal(params.activityTimeoutMs))
3706
+ if (r) { roundsSinceCompact.set(member.id, 0); needReanchor.add(member.id); return }
3707
+ } catch (e) { /* fall through to the soft path */ }
3708
+ }
3645
3709
  }
3646
3710
  needReanchor.add(member.id)
3647
3711
  }
@@ -3779,20 +3843,16 @@ export function apply(ctx) {
3779
3843
  await writeTextRel('State/README.md', [
3780
3844
  '# 关于 State/',
3781
3845
  '',
3782
- '本研究所的**权威状态在会话日志投影里**(投影键 `vibeMathV5`),不是这里的文件。',
3846
+ '本研究所的**权威状态就在本目录的 `<研究所>.v5state.json` 里**,不在任何宿主会话日志里。',
3783
3847
  '本目录只存放人可读的镜像/说明,**请勿手改**;改动不会影响真正的状态。',
3784
3848
  '要查看状态请用 `vibe_v5_status` / `vibe_v5_report`。',
3785
3849
  '',
3786
- '唯一例外:当宿主没有 `sessionProjections` 服务时,v5 会回退到',
3850
+ '研究所状态只写在加固 JSON 里(见下),不写入宿主会话日志:',
3787
3851
  '`State/<institute>.v5state.json`(加固 JSON 后端),此时它才是权威源。',
3788
3852
  '安装器会在启动自检里报告这一降级。',
3789
3853
  '',
3790
3854
  ].join('\n'))
3791
3855
  }
3792
- function slugify(s) {
3793
- const t = String(s == null ? '' : s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, '')
3794
- return t || ''
3795
- }
3796
3856
  async function doStart(args) {
3797
3857
  const a = args || {}
3798
3858
  if (running && !autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'already running' }
@@ -3829,6 +3889,10 @@ export function apply(ctx) {
3829
3889
  '从已知的相近结论出发,看能否推广或加强得到所需结果。',
3830
3890
  ]
3831
3891
  const spawned = []
3892
+ // A refused provisioning (the host's live-child cap) is remembered for this founding round so
3893
+ // the aggregate failure below can NAME the cap instead of blaming the provider.
3894
+ let limitRefused = false
3895
+ beginSpawnRound() // the whole founding loop is ONE round for the cap notice
3832
3896
  // The academician is founded FIRST so it is on the roster for every later member's
3833
3897
  // induction brief, and so it can begin overseeing the founding round. Each member
3834
3898
  // is committed to the ACTIVE roster before its own brief is built (see
@@ -3841,6 +3905,7 @@ export function apply(ctx) {
3841
3905
  + '你打算如何组织全所(谁适合做什么、先做什么)?把你的判断写进你的 Progress/,并把关键结论在群聊里说出来。')
3842
3906
  spawned.push(m.id)
3843
3907
  } catch (e) {
3908
+ if (isActivationLimitReached(e)) limitRefused = true
3844
3909
  await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
3845
3910
  }
3846
3911
  }
@@ -3851,11 +3916,18 @@ export function apply(ctx) {
3851
3916
  await spawnMember(m, null)
3852
3917
  spawned.push(m.id)
3853
3918
  } catch (e) {
3919
+ if (isActivationLimitReached(e)) limitRefused = true
3854
3920
  await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
3855
3921
  }
3856
3922
  }
3857
3923
  if (!spawned.length) {
3858
3924
  await patchInstitute({ phase: 'idle' })
3925
+ // When the HOST's per-root child cap refused every member, say so: the generic
3926
+ // "check the provider" message made a real host ceiling look like a broken host.
3927
+ if (limitRefused) {
3928
+ return { ok: false, code: 'ACTIVATION_LIMIT_REACHED',
3929
+ message: '没有任何成员创建成功:' + activationLimitText(hostChildLimit) }
3930
+ }
3859
3931
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '没有任何成员创建成功;请检查 subagents 提供者与会话持久化是否可用' }
3860
3932
  }
3861
3933
  running = true
@@ -3895,9 +3967,16 @@ export function apply(ctx) {
3895
3967
  syncParamsFromState()
3896
3968
  if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this institute already concluded; configure a new one in a new session' }
3897
3969
  const members = activeMembers()
3898
- if (!members.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
3970
+ // Members the host's live-child cap refused are NOT dead: they are still wanted, and a slot
3971
+ // may have been released since (a temp was fired, another child settled). They are the
3972
+ // "queued" half of the cap handling, retried in this round — gated on the RECORDED cap error
3973
+ // so an ordinary failed member (broken provider, rejected tool filter) is never resurrected
3974
+ // by a resume. `members` keeps its meaning for the return value below.
3975
+ const queued = inst().members.filter((m) => m.phase === 'failed' && /active child limit|maxActiveSubagents/.test(String(m.error || '')))
3976
+ if (!members.length && !queued.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
3899
3977
  let respawned = 0
3900
- for (const m of members) {
3978
+ beginSpawnRound() // the whole re-spawn loop is ONE round for the cap notice
3979
+ for (const m of members.concat(queued)) {
3901
3980
  if (m.childId) continue
3902
3981
  try {
3903
3982
  const seedText = (await readTextRel('Members/' + m.id + '/Progress/progress.md')) || ''
@@ -4071,6 +4150,7 @@ export function apply(ctx) {
4071
4150
  await spawnMember(m, null)
4072
4151
  } catch (e) {
4073
4152
  await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
4153
+ if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: activationLimitText(hostChildLimit) } }
4074
4154
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: String((e && e.message) || e) }
4075
4155
  }
4076
4156
  await saveChatLine('【编制】所办增聘常驻研究员 ' + m.id + (direction ? '(方向:' + direction + ')' : '') +
@@ -4156,7 +4236,7 @@ export function apply(ctx) {
4156
4236
  registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s) => s.initStop())
4157
4237
  registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
4158
4238
  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({
4239
+ 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
4240
  academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
4161
4241
  quorumCap: I, quorumMode: S, verdictMaxRounds: I,
4162
4242
  maxTempPerMember: I, maxTempTotal: I,
@@ -4315,22 +4395,11 @@ export function apply(ctx) {
4315
4395
  },
4316
4396
  }))
4317
4397
 
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
- }
4398
+ // ── institute state on disk ──────────────────────────────────────────────
4399
+ // State/<institute>.v5state.json is written by the file backend above (installBackend); it is the
4400
+ // only durable authority. The host's session projections are deliberately NOT used: DSH refuses to
4401
+ // load a session whose log carries event types outside its known set, so writing institute events
4402
+ // into the user's session made that session unresumable.
4334
4403
 
4335
4404
  // ── agent lifecycle wiring ───────────────────────────────────────────────
4336
4405
  // Capture the live child Agent while it is STILL registered: `subagent/end` is
@@ -4362,3 +4431,97 @@ export function apply(ctx) {
4362
4431
  .finally(() => s.forgetAgent(info.id))
4363
4432
  })
4364
4433
  }
4434
+
4435
+ const now = () => Date.now()
4436
+
4437
+ // ---- test seam: pure, stateless helpers --------------------------------
4438
+ // These helpers were declared inside `apply()` and are now declared at module scope, so
4439
+ // `apply()` closes over exactly the same function objects this export hands out. The audit
4440
+ // suites therefore exercise the REAL implementations by importing this module, instead of
4441
+ // extracting source text and compiling function bodies through the Function constructor
4442
+ // (dynamic code execution, rejected by the plugin-catalog security scan as
4443
+ // DANGEROUS_DYNAMIC_EXECUTION).
4444
+ //
4445
+ // Contract: no member may touch `ctx`, session state or mutable module state. Most are pure;
4446
+ // three are deliberately non-deterministic (`uuid`/`shortId` use Math.random, `fmtTime` falls back
4447
+ // to the clock) and `parseProgress` normalises the object it is handed in place (pre-existing).
4448
+ // Nothing here is used by the plugin at runtime except through `apply()`, and behaviour is
4449
+ // byte-identical to the previous in-`apply` declarations.
4450
+ export const __testHelpers = {
4451
+ clamp01,
4452
+ clPct,
4453
+ posMs,
4454
+ blocksToText,
4455
+ fmtTime,
4456
+ idSafe,
4457
+ slugify,
4458
+ tryJson,
4459
+ parseReply,
4460
+ sanitizeToolFilter,
4461
+ registeredToolsFromError,
4462
+ }
4463
+
4464
+ function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
4465
+
4466
+ function clPct(x) { const n = Number(x); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, n)) }
4467
+
4468
+ function posMs(v, def) { const n = Number(v); return (Number.isFinite(n) && n > 0) ? n : (def || 120000) }
4469
+
4470
+ function blocksToText(b) { if (!b) return ''; let o = ''; for (const x of b) { if (x && x.type === 'text' && typeof x.text === 'string') o += x.text + '\n' } return o }
4471
+
4472
+ function fmtTime(ts) { try { return new Date(ts || now()).toISOString().replace('T', ' ').slice(0, 19) } catch (e) { return String(ts || '') } }
4473
+
4474
+ function idSafe(s) {
4475
+ const t = String(s == null ? '' : s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').replace(/-{2,}/g, '-').replace(/^[.\-]+|[.\-]+$/g, '')
4476
+ return t
4477
+ }
4478
+
4479
+ function slugify(s) {
4480
+ const t = String(s == null ? '' : s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, '')
4481
+ return t || ''
4482
+ }
4483
+
4484
+ function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
4485
+
4486
+ function parseReply(text) {
4487
+ let obj
4488
+ const fence = /```(?:json)?[ \t]*([\s\S]*?)```/gi
4489
+ let m
4490
+ while ((m = fence.exec(text)) !== null) {
4491
+ const o = tryJson(String(m[1]).trim())
4492
+ if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
4493
+ }
4494
+ if (!obj) {
4495
+ const w = tryJson(String(text || '').trim())
4496
+ if (w && typeof w === 'object' && !Array.isArray(w)) obj = w
4497
+ }
4498
+ if (!obj) {
4499
+ // Last resort: the outermost {...} span (models sometimes wrap prose around it).
4500
+ const t = String(text || '')
4501
+ const i = t.indexOf('{'), j = t.lastIndexOf('}')
4502
+ if (i !== -1 && j > i) {
4503
+ const o = tryJson(t.slice(i, j + 1))
4504
+ if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
4505
+ }
4506
+ }
4507
+ return obj || {}
4508
+ }
4509
+
4510
+ function sanitizeToolFilter(filter, known){
4511
+ if(!filter || !(known instanceof Set) || known.size === 0) return filter
4512
+ const out = {}
4513
+ for(const key of ['allow','deny']){
4514
+ const list = filter[key]
4515
+ if(!Array.isArray(list)) continue
4516
+ const kept = list.filter(function(n){ return known.has(String(n).trim()) })
4517
+ if(kept.length > 0) out[key] = kept
4518
+ }
4519
+ return (out.allow || out.deny) ? out : undefined
4520
+ }
4521
+
4522
+ function registeredToolsFromError(message){
4523
+ const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
4524
+ if(!m) return undefined
4525
+ const names = m[1].split(',').map(function(s){ return s.trim() }).filter(Boolean)
4526
+ return names.length > 0 ? new Set(names) : undefined
4527
+ }