dsh-vibe-math 2.0.21 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,8 +32,13 @@ export function apply(ctx) {
32
32
  const fs = ctx.fs
33
33
  const tools = ctx.tools
34
34
  const commands = ctx.commands
35
- const subprocess = ctx.get('subprocess')
36
- const sandboxPolicy = ctx.get('sandboxPolicy')
35
+ // Optional services are resolved LAZILY at call time, never snapshotted in apply().
36
+ // A snapshot taken here is order-sensitive: if the service has not been provided yet
37
+ // when this preset subtree mounts, it stays undefined for the whole session, so
38
+ // `runShell` would report 'no-subprocess' forever and every mkdir would silently do
39
+ // nothing (masked only by fs.writeText creating parents automatically).
40
+ const subprocessOf = () => { try { return ctx.get('subprocess') } catch (e) { return undefined } }
41
+ const sandboxPolicyOf = () => { try { return ctx.get('sandboxPolicy') } catch (e) { return undefined } }
37
42
 
38
43
  // ================= per-session registry =================
39
44
  const sessions = new Map() // rootAgentId -> Session
@@ -112,7 +117,7 @@ export function apply(ctx) {
112
117
  maxExplorerRetries: 3, // explorer 重派生上限(拆方向失败重试次数)
113
118
  }
114
119
  let params = Object.assign({}, DEFAULT_PARAMS)
115
- let scheduler = { running: false, activeCount: 0, startedAt: 0, lastCheckpoint: 0, gate: null }
120
+ let scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null } // activeCount 由 activeCount() 从 agentRegistry 推导,不再作为字段
116
121
  let agentRegistry = {}
117
122
  let decisionQueue = []
118
123
  let verifierAccuracy = {}
@@ -131,13 +136,19 @@ export function apply(ctx) {
131
136
  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 }
132
137
  function shortId() { const h = '0123456789abcdef'; let s = ''; for (let i = 0; i < 8; i++) s += h[Math.floor(Math.random() * 16)]; return s }
133
138
  function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
134
- function workspaceRoot() { try { if (rootAgent && rootAgent.session && rootAgent.session.header && rootAgent.session.header.cwd) return rootAgent.session.header.cwd } catch (e) {} if (sandboxPolicy && sandboxPolicy.workspaceRoot) return sandboxPolicy.workspaceRoot; return '.' }
139
+ 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 '.' }
135
140
  function vibeRoot() { return (workspaceRoot() + '/VibeMath').replace(/\\/g, '/') }
136
141
  function projectRoot(slug) { return vibeRoot() + '/Projects/' + slug }
137
142
  function frameworkRoot() { return projectRoot(currentProject) }
138
143
  function slugify(s) { const t = String(s == null ? '' : s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, ''); return t || 'project' }
139
144
  function safeId(s) { return String(s == null ? 'anon' : s).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80) || 'anon' }
140
- function getPolicy() { try { if (sandboxPolicy && rootAgent && rootAgent.session) return sandboxPolicy.resolve({ session: rootAgent.session }) } catch (e) {} try { if (sandboxPolicy) return sandboxPolicy.resolve({}) } catch (e) {} return undefined }
145
+ let warnedNoPolicy = false
146
+ function warnNoPolicyOnce() { if (!warnedNoPolicy) { warnedNoPolicy = true; console.error('vibe-math-v2: sandboxPolicy unavailable; writes go out with no explicit policy') } }
147
+ // Sandbox fence for our own writes. The `resolve({})` fallback is a last resort and is
148
+ // deliberately reported (once): with no session it resolves the policy's CONFIGURED root
149
+ // (dsh-sandbox-policy: resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())),
150
+ // which is not necessarily this session's workspace — a silently different fence.
151
+ 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-v2: 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 }
141
152
  function makeSignal(ms) { return AbortSignal.timeout(ms || 30000) }
142
153
  function blocksToText(blocks) { if (!blocks) return ''; let out = ''; for (let i = 0; i < blocks.length; i++) { const b = blocks[i]; if (b && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n' } return out.trim() }
143
154
  function parseJson(text) {
@@ -206,8 +217,32 @@ export function apply(ctx) {
206
217
  async function fsTarget(rel) { return await fs.resolve(rel, { cwd: frameworkRoot() }) }
207
218
  async function readText(rel) { try { const t = await fsTarget(rel); const s = await fs.stat(t); if (s === undefined) return undefined; return await fs.readText(t) } catch (e) { return undefined } }
208
219
  async function writeText(rel, content) { const t = await fsTarget(rel); await fs.writeText(t, content, undefined, undefined, getPolicy()); return true }
209
- async function readJson(rel) { const t = await readText(rel); if (t === undefined || t === '') return undefined; try { return JSON.parse(t) } catch (e) { return undefined } }
210
- async function writeJson(rel, obj) { return await writeText(rel, JSON.stringify(obj, null, 2)) }
220
+ async function readJson(rel) { const t = await readText(rel); if (t === undefined || t === '') return undefined; try { return JSON.parse(t) } catch (e) { noteSuspect(rel); return undefined } }
221
+ /**
222
+ * Corruption guard. `readJson` cannot tell "no file yet" from "file present but
223
+ * unparseable", yet callers treat both as "no data" and then write that emptiness
224
+ * back — so one externally damaged file silently erased the user's whole problem
225
+ * list (qs.json), proposition set, or verified set.
226
+ *
227
+ * Any read that hits a present-but-unparseable JSON file records it; `writeJson`
228
+ * then REFUSES to write that path until the file is fixed or deleted. A missing
229
+ * file is still created normally, so first-run and "user deleted the file"
230
+ * behaviour is unchanged.
231
+ */
232
+ const suspectFiles = new Set()
233
+ let warnedSuspect = {}
234
+ function noteSuspect(rel) {
235
+ suspectFiles.add(rel)
236
+ if (warnedSuspect[rel]) return
237
+ warnedSuspect[rel] = true
238
+ console.error('vibe-math-v2: ' + rel + ' exists but is not parseable JSON — REFUSING to overwrite it so a corrupted file cannot silently erase your data. Fix or delete the file, then retry.')
239
+ }
240
+ function assertWritable(rel) {
241
+ if (!suspectFiles.has(rel)) return true
242
+ console.error('vibe-math-v2: write to ' + rel + ' blocked (file is unparseable; see the earlier warning)')
243
+ return false
244
+ }
245
+ async function writeJson(rel, obj) { if (!assertWritable(rel)) return false; return await writeText(rel, JSON.stringify(obj, null, 2)) }
211
246
  async function listFiles(rel) { try { const t = await fsTarget(rel); const s = await fs.stat(t); if (s === undefined) return []; const entries = await fs.listDir(t); return entries.filter(function (e) { return e && e.type === 'file' }).map(function (e) { return e.name }) } catch (e) { return [] } }
212
247
  async function listDirsAt(base, rel) { try { const t = await fs.resolve(rel, { cwd: base }); const s = await fs.stat(t); if (s === undefined) return []; const entries = await fs.listDir(t); return entries.filter(function (e) { return e && e.type === 'directory' }).map(function (e) { return e.name }) } catch (e) { return [] } }
213
248
  async function readTextAbs(path) { try { const t = await fs.resolve(path); const s = await fs.stat(t); if (s === undefined) return undefined; return await fs.readText(t) } catch (e) { return undefined } }
@@ -223,9 +258,37 @@ export function apply(ctx) {
223
258
 
224
259
  // ================= subprocess =================
225
260
  function psQuote(p) { return "'" + String(p).replace(/'/g, "''") + "'" }
226
- async function runShell(script, cwd) { if (subprocess === undefined) return { ok: false, error: 'no-subprocess' }; try { const handle = subprocess.spawn({ argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', script], cwd: cwd || workspaceRoot(), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 20000 }); const outcome = await handle.done; return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode } } catch (e) { return { ok: false, error: String((e && e.message) || e) } } }
227
- async function ensureDirs() { const base = frameworkRoot(); const dirs = ['qs', 'Propos', 'Reliable', 'Verified', 'Verification_logs', 'Progress_Logs', 'VibeMath_State']; const paths = [vibeRoot() + '/Projects'].concat(dirs.map(function (d) { return base + '/' + d })); const list = paths.map(psQuote).join(','); return await runShell('New-Item -Force -ItemType Directory -Path ' + list + ' | Out-Null') }
228
- async function removeFile(rel) { const base = frameworkRoot(); return await runShell('Remove-Item -Force -LiteralPath ' + psQuote(base + '/' + rel) + ' -ErrorAction SilentlyContinue') }
261
+ /** POSIX 单引号引用:把 ' 换成 '\'' 以安全嵌入任意路径。 */
262
+ function shQuote(p) { return "'" + String(p).replace(/'/g, "'\\''") + "'" }
263
+ /**
264
+ * 执行一段 shell 脚本。**按平台选择解释器**:此前硬编码 powershell,而预设用
265
+ * `disabled: !!js process.platform !== 'win32'` 在非 Windows 上关掉了 tool-pwsh 行——
266
+ * 即插件会调用一个自己声明不提供的二进制,且返回值无人检查,表现为静默失效。
267
+ * Windows 用 powershell(保留原行为),其余平台用 /bin/sh。
268
+ */
269
+ function isWindows() { return process.platform === 'win32' }
270
+ function mkdirCmd(paths) {
271
+ if (isWindows()) return 'New-Item -Force -ItemType Directory -Path ' + paths.map(psQuote).join(',') + ' | Out-Null'
272
+ return 'mkdir -p ' + paths.map(shQuote).join(' ')
273
+ }
274
+ function rmCmd(path) {
275
+ if (isWindows()) return 'Remove-Item -Force -LiteralPath ' + psQuote(path) + ' -ErrorAction SilentlyContinue'
276
+ return 'rm -f ' + shQuote(path)
277
+ }
278
+ async function runShell(script, cwd) {
279
+ const subprocess = subprocessOf()
280
+ if (subprocess === undefined) return { ok: false, error: 'no-subprocess' }
281
+ try {
282
+ const argv = isWindows()
283
+ ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', script]
284
+ : ['/bin/sh', '-c', script]
285
+ const handle = subprocess.spawn({ argv: argv, cwd: cwd || workspaceRoot(), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 20000 })
286
+ const outcome = await handle.done
287
+ return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode }
288
+ } catch (e) { return { ok: false, error: String((e && e.message) || e) } }
289
+ }
290
+ async function ensureDirs() { const base = frameworkRoot(); const dirs = ['qs', 'Propos', 'Reliable', 'Verified', 'Verification_logs', 'Progress_Logs', 'VibeMath_State']; const paths = [vibeRoot() + '/Projects'].concat(dirs.map(function (d) { return base + '/' + d })); return await runShell(mkdirCmd(paths)) }
291
+ async function removeFile(rel) { const base = frameworkRoot(); return await runShell(rmCmd(base + '/' + rel)) }
229
292
 
230
293
  // ================= settings =================
231
294
  function sanitizeParams(obj) {
@@ -233,10 +296,25 @@ export function apply(ctx) {
233
296
  const intFields = ['maxParallelThreshold', 'solverMaxRounds', 'directionsPerSolver', 'verifierCount', 'debateMaxRounds', 'solverMaxToolCalls', 'verifierMaxToolCalls', 'reportIntervalMs', 'tickIntervalMs', 'activityLogCap', 'maxExplorerRetries']
234
297
  const numFields = ['promoteValueThreshold']
235
298
  const arrayFields = ['solverToolAllow', 'solverToolDeny', 'verifierToolAllow', 'verifierToolDeny']
299
+ // 整数字段的下界:0 会让对应功能**静默失效**而不是报错——例如 maxParallelThreshold=0 使所有派发
300
+ // 闸门 (activeCount >= 0) 恒真,此后永不派发任何代理,而 status 仍显示 running:true;
301
+ // solverMaxRounds=0 让每个方向立刻判死。这里把会让调度停滞/失能的键抬到最小可用值;
302
+ // 未列出的键(如 activityLogCap / max*ToolCalls,取 0 表示不限)保持原样。
303
+ const INT_FLOOR = {
304
+ maxParallelThreshold: 1, solverMaxRounds: 1, verifierCount: 2, debateMaxRounds: 1,
305
+ directionsPerSolver: 1, tickIntervalMs: 200, reportIntervalMs: 1000, maxExplorerRetries: 1,
306
+ }
236
307
  for (const k of Object.keys(DEFAULT_PARAMS)) {
237
308
  if (!(k in obj)) continue
238
309
  const v = obj[k]
239
- if (intFields.indexOf(k) !== -1) { const n = Number(v); out[k] = Number.isFinite(n) ? Math.floor(n) : DEFAULT_PARAMS[k] }
310
+ if (intFields.indexOf(k) !== -1) {
311
+ const n = Number(v)
312
+ if (!Number.isFinite(n)) { out[k] = DEFAULT_PARAMS[k]; continue }
313
+ let iv = Math.floor(n)
314
+ const floor = INT_FLOOR[k]
315
+ if (floor !== undefined && iv < floor) iv = floor
316
+ out[k] = iv
317
+ }
240
318
  else if (numFields.indexOf(k) !== -1) { const n = Number(v); out[k] = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : DEFAULT_PARAMS[k] }
241
319
  else if (arrayFields.indexOf(k) !== -1) { out[k] = Array.isArray(v) ? v.filter(function (x) { return typeof x === 'string' }) : DEFAULT_PARAMS[k] }
242
320
  else if (k === 'mode') { out[k] = (v === 'manual' || v === 'auto') ? v : DEFAULT_PARAMS[k] }
@@ -268,12 +346,21 @@ export function apply(ctx) {
268
346
  lines.push(' // 位置:<项目>/vibe_math_setting.json(全局回退:<工作区>/VibeMath/vibe_math_setting.json)。')
269
347
  lines.push(' // 本文件是参数的唯一持久化来源:vibe_math_set_params / set_mode 会立即写回此文件;全局文件仅作项目文件不存在时的回退默认。')
270
348
  const keys = Object.keys(src).sort()
349
+ // 只输出有值的键。JSON.stringify(undefined) 返回 undefined(不是字符串),直接拼接会写出
350
+ // `"k": undefined` —— 非法 JSON,本插件自己的 loadSettings() 随后会解析失败并整份忽略,
351
+ // 用户的参数设置因此静默丢失。逗号也按"实际写出的条目"计算,否则跳过一个键会留下尾随逗号。
352
+ const emitted = []
271
353
  for (let i = 0; i < keys.length; i++) {
272
354
  const k = keys[i]
273
355
  const v = src[k]
356
+ if (v === undefined) continue
274
357
  const schema = PARAM_SCHEMA.find(function (p) { return p.name === k })
275
358
  const desc = schema ? schema.description : ''
276
- const comma = i === keys.length - 1 ? '' : ','
359
+ emitted.push([k, v, desc])
360
+ }
361
+ for (let i = 0; i < emitted.length; i++) {
362
+ const k = emitted[i][0], v = emitted[i][1], desc = emitted[i][2]
363
+ const comma = i === emitted.length - 1 ? '' : ','
277
364
  lines.push(' // ' + k + (desc ? ' — ' + desc : ''))
278
365
  lines.push(' ' + JSON.stringify(k) + ': ' + JSON.stringify(v) + comma)
279
366
  }
@@ -285,8 +372,24 @@ export function apply(ctx) {
285
372
  async function createTemplate(where) { const isGlobal = where !== 'project'; const path = isGlobal ? (vibeRoot() + '/vibe_math_setting.json') : (frameworkRoot() + '/vibe_math_setting.json'); const content = settingsTemplateFrom(DEFAULT_PARAMS); const ok = isGlobal ? await writeTextAbs(path, content) : await writeText('vibe_math_setting.json', content); return { ok: ok, path: path, where: isGlobal ? 'global' : 'project' } }
286
373
 
287
374
  // ================= persistence =================
375
+ /**
376
+ * 并发计数**从 registry 推导**,不再独立维护/持久化。
377
+ *
378
+ * 此前 `scheduler.activeCount` 是手写的累加器:spawn +1、每次 followup 也 +1,只在
379
+ * `onChildEnd` 里 -1,而 `onChildEnd` 开头 `if (meta === undefined) return` 会跳过那次减法。
380
+ * 于是任何一次 `subagent/end` 丢失、或 end 到达时该 child 已不在 `agentRegistry`
381
+ * (resume 清 registry、跨进程重启残留……)都会让 +1 永远没人抵消。计数单调增长,
382
+ * 一旦 ≥ maxParallelThreshold,所有派发闸门(activeCount >= maxParallelThreshold)恒真,
383
+ * 系统再也不派任何代理 —— 而 running 仍是 true、status 照常响应,故障完全静默。
384
+ * 计数还会写进 scheduler_state.json,所以同进程 resume 会把这个坏值一路带下去。
385
+ *
386
+ * 从 registry 长度推导后,两者不可能不一致,"丢一次 end 就永久停摆"这一类故障从根上消失。
387
+ */
388
+ function activeCount() { return Object.keys(agentRegistry).length }
288
389
  async function loadState() {
289
- const s = await readJson('VibeMath_State/scheduler_state.json'); if (s) scheduler = Object.assign({}, scheduler, s)
390
+ const s = await readJson('VibeMath_State/scheduler_state.json')
391
+ // 丢弃历史持久化的 activeCount:旧值可能已经漂移,绝不能覆盖推导值(见 activeCount())。
392
+ if (s) { const restored = Object.assign({}, s); delete restored.activeCount; scheduler = Object.assign({}, scheduler, restored) }
290
393
  const r = await readJson('VibeMath_State/agent_registry.json'); if (r) agentRegistry = r
291
394
  const dq = await readJson('VibeMath_State/decision_queue.json'); if (dq) decisionQueue = dq
292
395
  const va = await readJson('VibeMath_State/verifier_accuracy.json'); if (va) verifierAccuracy = va
@@ -340,8 +443,15 @@ export function apply(ctx) {
340
443
  async function saveProgress(qid, progObj) { const qs = await getQs(); const q = qs.find(function (x) { return x.id === qid }); if (!q) return; q.progress = progObj; await writeQs(qs) }
341
444
 
342
445
  // ================= data layer: Propos =================
343
- function categoryOf(p) { const t = (p && p.细类型) || {}; const keys = Object.keys(t); return (keys.length > 0 && typeof t[keys[0]] === 'object') ? keys[0] : '未分类' }
344
- function proposFile(cat) { return 'Propos/' + String(cat) + '_Propos.json' }
446
+ // categoryOf 的结果会直接成为文件名的一部分(Propos/<分类>_Propos.json,见 proposFile)。
447
+ // 模型提供一个含路径分隔符或 ".." 的"细类型"键就能把文件写到 Propos/ 之外,所以这里做文件名消毒
448
+ // (只替换分隔符与控制字符、剥掉首尾点,不改动中文分类名本身)。
449
+ function safeCatName(s) {
450
+ const t = String(s == null ? '' : s).replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').replace(/^[.\s]+|[.\s]+$/g, '')
451
+ return t || '未分类'
452
+ }
453
+ function categoryOf(p) { const t = (p && p.细类型) || {}; const keys = Object.keys(t); return (keys.length > 0 && typeof t[keys[0]] === 'object') ? safeCatName(keys[0]) : '未分类' }
454
+ function proposFile(cat) { return 'Propos/' + safeCatName(cat) + '_Propos.json' }
345
455
  async function proposFiles() { return await listFiles('Propos') }
346
456
  async function readProposCategory(cat) { const a = await readJson(proposFile(cat)); return Array.isArray(a) ? a : [] }
347
457
  async function getPropos() {
@@ -384,7 +494,7 @@ export function apply(ctx) {
384
494
  return {
385
495
  ok: true, at: now(), project: currentProject, frameworkRoot: frameworkRoot(),
386
496
  running: scheduler.running, mode: params.mode,
387
- activeCount: scheduler.activeCount, maxParallelThreshold: params.maxParallelThreshold,
497
+ activeCount: activeCount(), maxParallelThreshold: params.maxParallelThreshold,
388
498
  problems: { total: qs.length, solved: qs.filter(function (q) { return q.已解决 }).length },
389
499
  propositions: { total: propos.length, resolved: propos.filter(function (p) { return p.布尔估计 === 1 || p.布尔估计 === 0 }).length },
390
500
  pendingDecisions: decisionQueue.filter(function (d) { return d.status === 'pending' }).map(function (d) { return { id: d.id, node: d.node, context: d.context } }),
@@ -428,8 +538,44 @@ export function apply(ctx) {
428
538
  // ================= child spawn / followup =================
429
539
  function pickProvider() { try { const names = subagents.list ? subagents.list() : []; if (names.indexOf('spawn') !== -1) return 'spawn'; if (names.indexOf('fork') !== -1) return 'fork' } catch (e) {} return 'spawn' }
430
540
  function childAgentOptions() { const o = {}; try { if (rootAgent && rootAgent.options) { if (rootAgent.options.provider) o.provider = rootAgent.options.provider; if (rootAgent.options.model) o.model = rootAgent.options.model } } catch (e) {} if (params.provider) o.provider = params.provider; if (params.model) o.model = params.model; return o }
431
- const NETWORK_TOOLS = ['web_search', 'web', 'fetch']
432
- const SCRIPT_TOOLS = ['bash', 'pwsh']
541
+ // Tool names for the permission filter, taken from the names the host ACTUALLY
542
+ // registers (dsh-tool-web registers 'web_search'/'web_fetch'; 'web'/'fetch' are
543
+ // only presentation card/kind fields, not tool names), and split by platform
544
+ // because each preset's composition gates them:
545
+ // dsh-tool-bash disabled: process.platform === 'win32'
546
+ // dsh-tool-pwsh disabled: process.platform !== 'win32'
547
+ // dsh-tools' restrict() THROWS on any name outside its registered set, and the
548
+ // host applies the filter when establishing a continuable child
549
+ // (dsh-subagent: childCtx.tools.restrict(...)), so a stale name meant the child
550
+ // was never created at all.
551
+ const IS_WINDOWS = process.platform === 'win32'
552
+ const SCRIPT_TOOLS = IS_WINDOWS ? ['pwsh'] : ['bash']
553
+ // 'web_fetch' is only registered when the composition enables fetch (the v4
554
+ // preset sets `fetch: false`), so it is a candidate that sanitizeToolFilter drops.
555
+ const NETWORK_TOOLS = ['web_search', 'web_fetch']
556
+ /**
557
+ * Drop filter names this host does not register. `known` comes from the host's
558
+ * own rejection message, which lists every registered global tool, so this
559
+ * never guesses. Returns undefined when nothing usable remains.
560
+ */
561
+ function sanitizeToolFilter(filter, known) {
562
+ if (!filter || !(known instanceof Set) || known.size === 0) return filter
563
+ const out = {}
564
+ for (const key of ['allow', 'deny']) {
565
+ const list = filter[key]
566
+ if (!Array.isArray(list)) continue
567
+ const kept = list.filter(function (n) { return known.has(String(n).trim()) })
568
+ if (kept.length > 0) out[key] = kept
569
+ }
570
+ return (out.allow || out.deny) ? out : undefined
571
+ }
572
+ /** The host names the offending tools and then lists the registered ones. */
573
+ function registeredToolsFromError(message) {
574
+ const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
575
+ if (!m) return undefined
576
+ const names = m[1].split(',').map(function (s) { return s.trim() }).filter(Boolean)
577
+ return names.length > 0 ? new Set(names) : undefined
578
+ }
433
579
  function buildToolFilter(role) { const allow = role === 'solver' ? params.solverToolAllow : role === 'verifier' ? params.verifierToolAllow : undefined; const deny = role === 'solver' ? params.solverToolDeny : role === 'verifier' ? params.verifierToolDeny : undefined; const net = role === 'solver' ? params.solverAllowNetwork : role === 'verifier' ? params.verifierAllowNetwork : undefined; const scr = role === 'solver' ? params.solverAllowScripts : role === 'verifier' ? params.verifierAllowScripts : undefined; let a = Array.isArray(allow) ? allow.slice() : []; let d = Array.isArray(deny) ? deny.slice() : []; if (net === false) d = d.concat(NETWORK_TOOLS); else if (net === true && a.length > 0) a = a.concat(NETWORK_TOOLS); if (scr === false) d = d.concat(SCRIPT_TOOLS); else if (scr === true && a.length > 0) a = a.concat(SCRIPT_TOOLS); const f = {}; if (a.length > 0) f.allow = a; if (d.length > 0) f.deny = d; return (f.allow || f.deny) ? f : undefined }
434
580
  async function spawnChild(label, promptText, meta) {
435
581
  const request = { prompt: [textBlock(promptText)], parent: rootAgent, agentOptions: childAgentOptions() }
@@ -437,11 +583,41 @@ export function apply(ctx) {
437
583
  let started
438
584
  try { started = await subagents.startContinuable({ provider: pickProvider(), label: label, request: request, signal: makeSignal(30000) }) }
439
585
  catch (e) {
440
- if (request.toolFilter) { delete request.toolFilter; console.error('vibe-math-v2: startContinuable with toolFilter failed, retrying without it: ' + String((e && e.message) || e)); started = await subagents.startContinuable({ provider: pickProvider(), label: label, request: request, signal: makeSignal(30000) }) } else { throw e }
586
+ const message = String((e && e.message) || e)
587
+ // The host rejected the filter because it names tools this deployment does
588
+ // not register. It tells us exactly which names are valid, so drop the
589
+ // invalid ones and retry ONCE. This is NOT the old fail-open behaviour:
590
+ // every name the user asked to deny that DOES exist is still denied, and a
591
+ // deny-list can only ever shrink to names that do not exist here.
592
+ // (The old behaviour deleted the whole filter, silently granting network
593
+ // and script access the operator had explicitly forbidden.)
594
+ const known = registeredToolsFromError(message)
595
+ const retryFilter = request.toolFilter ? sanitizeToolFilter(request.toolFilter, known) : undefined
596
+ const changed = request.toolFilter && JSON.stringify(retryFilter) !== JSON.stringify(request.toolFilter)
597
+ // FAIL CLOSED on an unusable sanitized filter. Retrying WITHOUT a filter would
598
+ // start the child unrestricted, which is the opposite of what the operator asked
599
+ // for; retrying with an empty one would deny every tool. Neither is acceptable,
600
+ // so report the stale configuration and refuse to spawn this child.
601
+ if (request.toolFilter && retryFilter === undefined) {
602
+ console.error('vibe-math-v2: the configured tool permission filter names ONLY tools this host does not register, so it cannot be honored; refusing to spawn WITHOUT a filter (that would grant the very access the operator denied). filter=' + JSON.stringify(request.toolFilter) + ' host said: ' + message)
603
+ throw e
604
+ }
605
+ if (!changed) {
606
+ if (request.toolFilter) console.error('vibe-math-v2: startContinuable with toolFilter failed (NOT retrying without the permission filter, to avoid silently granting unrestricted tools): ' + message)
607
+ throw e
608
+ }
609
+ console.error('vibe-math-v2: tool permission filter named tools this host does not register; retrying with only registered names (denied-tool intent preserved). dropped=' + JSON.stringify(request.toolFilter) + ' kept=' + JSON.stringify(retryFilter))
610
+ const retryRequest = Object.assign({}, request)
611
+ retryRequest.toolFilter = retryFilter
612
+ try { started = await subagents.startContinuable({ provider: pickProvider(), label: label, request: retryRequest, signal: makeSignal(30000) }) }
613
+ catch (e2) {
614
+ console.error('vibe-math-v2: startContinuable retry with sanitized toolFilter also failed: ' + String((e2 && e2.message) || e2))
615
+ throw e2
616
+ }
441
617
  }
442
618
  agentRegistry[started.childId] = Object.assign({ createdAt: now() }, meta || {})
443
619
  childOwner.set(started.childId, sessionId)
444
- scheduler.activeCount = Math.max(0, scheduler.activeCount) + 1
620
+ // 并发计数由 agentRegistry 推导,此处无需手工 +1(见 activeCount())。
445
621
  await saveAll(); return started.childId
446
622
  }
447
623
  // DSH continuable-wake API is subagents.sendMessage(sender, targetId, content, {signal}); subagents.followup
@@ -454,7 +630,7 @@ export function apply(ctx) {
454
630
  else if (typeof subagents.followup === 'function') await subagents.followup(rootAgent, childId, blocks, { source: { kind: 'user' }, signal: makeSignal(30000) })
455
631
  else throw new Error('no subagent continuation API')
456
632
  } catch (e) { console.error('vibe-math-v2: wake ' + childId + ' failed: ' + String((e && e.message) || e)); throw e }
457
- scheduler.activeCount = Math.max(0, scheduler.activeCount) + 1
633
+ // 不再手工累加并发计数:唤醒的是 registry 里已登记的 child,计数已由 registry 长度体现。
458
634
  await saveAll()
459
635
  }
460
636
  async function interruptChild(childId) { try { subagents.interrupt(childId, { kind: 'ancestor', agent: rootAgent }) } catch (e) {} }
@@ -630,8 +806,20 @@ export function apply(ctx) {
630
806
 
631
807
  // ================= scheduler core =================
632
808
  function scheduleTick() { tick().catch(function (e) { console.error('vibe-math-v2 tick error: ' + String((e && e.stack) || e)) }) }
809
+ function dropStaleGate() {
810
+ const g = scheduler.gate
811
+ if (!g) return
812
+ const d = decisionQueue.find(function (x) { return x.id === g.decisionId })
813
+ if (d === undefined || d.status !== 'pending') {
814
+ logActivity('gate', 'cleared stale gate (' + g.node + '/' + g.decisionId + ' is ' + (d === undefined ? 'gone' : d.status) + ')')
815
+ scheduler.gate = null
816
+ }
817
+ }
633
818
  async function tick() {
634
- if (tickInFlight) return; if (!rootAgent) return; if (!scheduler.running) return; if (scheduler.gate) return
819
+ if (tickInFlight) return; if (!rootAgent) return; if (!scheduler.running) return
820
+ // 自愈:gate 指向的决策若已不存在或已 resolved,就清掉再继续,而不是永久早退。
821
+ dropStaleGate()
822
+ if (scheduler.gate) return
635
823
  tickInFlight = true
636
824
  lastTickAt = now()
637
825
  try {
@@ -744,7 +932,7 @@ export function apply(ctx) {
744
932
  }
745
933
  // note 3 + user 价值 field: promote high-value unresolved propositions into qs.json
746
934
  async function processPromote() {
747
- if (scheduler.activeCount >= params.maxParallelThreshold) return
935
+ if (activeCount() >= params.maxParallelThreshold) return
748
936
  const qs = await getQs()
749
937
  const qDescriptions = qs.map(function (q) { return q.概述 })
750
938
  const propos = await getPropos()
@@ -772,7 +960,7 @@ export function apply(ctx) {
772
960
  async function processVerify() {
773
961
  const candidates = await buildVerifyCandidates()
774
962
  for (let i = 0; i < candidates.length; i++) {
775
- if (scheduler.activeCount >= params.maxParallelThreshold) break
963
+ if (activeCount() >= params.maxParallelThreshold) break
776
964
  const c = candidates[i]
777
965
  const rId = c.rId
778
966
  if (tasks['verify:' + rId]) continue
@@ -801,6 +989,7 @@ export function apply(ctx) {
801
989
  for (let i = 0; i < propos.length; i++) {
802
990
  const p = propos[i]
803
991
  if (p.布尔估计 === 1 || p.布尔估计 === 0 || p.优先级 === 'never') continue
992
+ if (p.已验证) continue // 收敛闸门:该命题已由「判断命题」解法裁决过(见 settleVerdict 点5 联动),不再重复入选
804
993
  if (p.在问题清单) continue // 已晋升:其证明/证伪经晋升问题的解法验证,避免同一内容双重验证
805
994
  const proofs = p.证明列表 || []; const refutes = p.证伪列表 || []
806
995
  if (proofs.length === 0 && refutes.length === 0) {
@@ -816,7 +1005,7 @@ export function apply(ctx) {
816
1005
  }
817
1006
  async function backfillVerifiers(t) {
818
1007
  while (t.children.length < t.expectedCount) {
819
- if (scheduler.activeCount >= params.maxParallelThreshold) break
1008
+ if (activeCount() >= params.maxParallelThreshold) break
820
1009
  const index = t.children.length
821
1010
  const childId = await spawnChild('verifier:' + t.rId + ':' + index, verifierReviewPrompt(t.r), { role: 'verifier', rId: t.rId, round: 1, index: index })
822
1011
  t.children.push(childId)
@@ -833,16 +1022,16 @@ export function apply(ctx) {
833
1022
  if (allReported) { t.status = 'debating'; await advanceVerification(t, t.round); continue }
834
1023
  }
835
1024
  if (t.status !== 'spawning') continue
836
- if (scheduler.activeCount >= params.maxParallelThreshold) break
1025
+ if (activeCount() >= params.maxParallelThreshold) break
837
1026
  await backfillVerifiers(t)
838
1027
  }
839
1028
  }
840
1029
  async function processSolve() {
841
- if (scheduler.activeCount >= params.maxParallelThreshold) return
1030
+ if (activeCount() >= params.maxParallelThreshold) return
842
1031
  const qs = await getQs()
843
1032
  const unsolved = qs.filter(function (q) { return !q.已解决 && q.优先级 !== 'never' }).sort(function (a, b) { return (a.优先级 === 'never' ? 999 : Number(a.优先级)) - (b.优先级 === 'never' ? 999 : Number(b.优先级)) })
844
1033
  for (let i = 0; i < unsolved.length; i++) {
845
- if (scheduler.activeCount >= params.maxParallelThreshold) break
1034
+ if (activeCount() >= params.maxParallelThreshold) break
846
1035
  const q = unsolved[i]
847
1036
  const busy = Object.keys(agentRegistry).some(function (cid) { const m = agentRegistry[cid]; return m && m.qid === q.id && (m.role === 'explorer' || m.role === 'solver') })
848
1037
  if (busy) continue
@@ -863,7 +1052,7 @@ export function apply(ctx) {
863
1052
  } else {
864
1053
  // spawn solvers for each active direction
865
1054
  for (let j = 0; j < prog.directions.length; j++) {
866
- if (scheduler.activeCount >= params.maxParallelThreshold) break
1055
+ if (activeCount() >= params.maxParallelThreshold) break
867
1056
  const dir = prog.directions[j]
868
1057
  if (dir.status === 'success' || dir.status === 'dead-end') continue
869
1058
  const running = Object.keys(agentRegistry).some(function (cid) { const m = agentRegistry[cid]; return m && m.qid === q.id && m.direction === dir.id && m.role === 'solver' })
@@ -1044,7 +1233,7 @@ export function apply(ctx) {
1044
1233
  async function advanceVerification(t, round) {
1045
1234
  if (round < params.debateMaxRounds && !consensus(t) && t.children.length > 0) {
1046
1235
  if (!scheduler.running) { t.status = 'paused'; return } // resume will re-advance this task
1047
- if (scheduler.activeCount >= params.maxParallelThreshold) { t.status = 'paused'; return } // 并发门:等有空闲槽位再辩论(reconcileVerify 会重推进)
1236
+ if (activeCount() >= params.maxParallelThreshold) { t.status = 'paused'; return } // 并发门:等有空闲槽位再辩论(reconcileVerify 会重推进)
1048
1237
  t.round = round + 1
1049
1238
  const roundTranscript = buildTranscript(t)
1050
1239
  t.history = t.history || []
@@ -1176,6 +1365,13 @@ export function apply(ctx) {
1176
1365
  const ap = await findProposition(q.判断命题)
1177
1366
  if (ap) {
1178
1367
  ap.布尔估计 = v
1368
+ // 收敛闸门:本条路径只在 v=1/0 时才写入证明/证伪条目,中间裁决(flat 默认给出
1369
+ // 0.5,forced 给出加权浮点)会让 ap 停留在"中间布尔估计 + 两个列表皆空"的状态——
1370
+ // 而这正是 buildVerifyCandidates 认定"裸命题需要验证"的条件。若不在此标记,该命题
1371
+ // 会在每个 tick 重新入选、重开一轮完整辩论;又因 processVerify 每 tick 只跑一个验证,
1372
+ // 其它对象被无限饿死,终止条件(所有问题已解决)永不可达。标记后不再重复消耗验证配额,
1373
+ // 裁决值仍保留在 布尔估计 中。
1374
+ ap.已验证 = true
1179
1375
  if (v === 1) { ap.证明列表 = ap.证明列表 || []; ap.证明列表.push({ 完整过程: strongestReason(t, 1) || '判断问题解法验证通过', 正确概率: 1, '支持信息/依据': '经「判断下述命题是否成立」问题解法验证', 已验: true }); ap.优先级 = 'never' }
1180
1376
  else if (v === 0) { ap.证伪列表 = ap.证伪列表 || []; ap.证伪列表.push({ 完整过程: strongestReason(t, 0) || '判断问题解法判定不成立', 正确概率: 1, '支持信息/依据': '经「判断下述命题是否成立」问题解法验证', 已验: true }); ap.优先级 = 'never' }
1181
1377
  await upsertProposition(ap)
@@ -1241,7 +1437,6 @@ export function apply(ctx) {
1241
1437
  async function onChildEnd(info) {
1242
1438
  const meta = agentRegistry[info.id]
1243
1439
  if (meta === undefined) return
1244
- scheduler.activeCount = Math.max(0, scheduler.activeCount - 1)
1245
1440
  const output = blocksToText(info.lastAssistantMessage)
1246
1441
  try {
1247
1442
  if (meta.role === 'explorer') await handleExplorer(info.id, meta, output)
@@ -1269,7 +1464,7 @@ export function apply(ctx) {
1269
1464
  logActivity(fresh ? 'start' : 'resume', 'cleared ' + Object.keys(agentRegistry).length + ' agent(s) and ' + Object.keys(tasks).length + ' task(s) (' + (fresh ? 'restart' : 'stale from previous process') + ')')
1270
1465
  agentRegistry = {}; tasks = {}
1271
1466
  }
1272
- scheduler.activeCount = 0 // 仅清空 registry/tasks 时归零;同进程 resume 保留存活计数(并发门才准确)
1467
+ // 并发计数由 agentRegistry 推导:清空 registry 后自然归零,无需显式赋值。
1273
1468
  }
1274
1469
  await writeJson('VibeMath_State/process_epoch.json', processEpoch)
1275
1470
  await saveAll()
@@ -1278,7 +1473,7 @@ export function apply(ctx) {
1278
1473
  async function startScheduler() { const r = await init(true); if (!r.ok) return r; scheduler.running = true; scheduler.startedAt = now(); scheduler.gate = null; logActivity('start', 'scheduler started for project ' + currentProject); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler started', project: currentProject, frameworkRoot: frameworkRoot() } }
1279
1474
  async function resumeScheduler() { const r = await init(false); if (!r.ok) return r; scheduler.running = true; scheduler.gate = null; logActivity('resume', 'scheduler resumed'); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler resumed', project: currentProject, frameworkRoot: frameworkRoot() } }
1280
1475
  async function pauseScheduler() { scheduler.running = false; logActivity('pause', 'scheduler paused'); await saveAll(); return { ok: true, message: 'scheduler paused' } }
1281
- async function abortScheduler() { scheduler.running = false; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); scheduler.activeCount = 0; logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
1476
+ async function abortScheduler() { scheduler.running = false; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); agentRegistry = {}; logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
1282
1477
  // auto 模式语义 = 无人值守自动通过关键节点:切回 auto 时把仍挂起的人工决策按自动策略放行
1283
1478
  async function autoResolvePending() {
1284
1479
  const pending = decisionQueue.filter(function (d) { return d.status === 'pending' })
@@ -1287,7 +1482,15 @@ export function apply(ctx) {
1287
1482
  try {
1288
1483
  if (d.node === 'spawn') { await spawnChild(d.data.label, d.data.promptText, d.data.meta); d.status = 'resolved'; d.resolution = { action: 'approve', auto: true } }
1289
1484
  else if (d.node === 'verdict') { await settleVerdict(d.data.task, d.data.verdict); delete tasks[d.data.task.id]; d.status = 'resolved'; d.resolution = { action: 'approve', auto: true } }
1290
- } catch (e) { console.error('vibe-math-v2: auto-resolve decision failed: ' + String((e && e.message) || e)) }
1485
+ } catch (e) {
1486
+ // 副作用失败必须把该决策落到终态,否则它会永远保持 pending:此后每次切 auto 都在同一个
1487
+ // 决策上重新抛错,而 gate 又指向它 —— 调度永久卡死。标为 resolved(auto-failed) 并让它过去,
1488
+ // 由 activity log 留下证据;宁可这一次节点未执行,也不能让整条管线停摆。
1489
+ console.error('vibe-math-v2: auto-resolve decision failed: ' + String((e && e.message) || e))
1490
+ d.status = 'resolved'
1491
+ d.resolution = { action: 'auto-failed', auto: true, error: String((e && e.message) || e) }
1492
+ logActivity('gate', 'auto-resolve failed for ' + d.id + ' (' + d.node + '), marked resolved to avoid a permanent stall: ' + String((e && e.message) || e))
1493
+ }
1291
1494
  }
1292
1495
  if (pending.length > 0) { scheduler.gate = null; logActivity('mode', 'switched to auto — auto-resolved ' + pending.length + ' pending decision(s)'); await saveAll(); scheduleTick() }
1293
1496
  }
@@ -1296,7 +1499,7 @@ export function apply(ctx) {
1296
1499
  return {
1297
1500
  ok: true, initialized: rootAgent !== undefined, running: scheduler.running,
1298
1501
  project: currentProject, projects: await listDirsAt(vibeRoot(), 'Projects'),
1299
- mode: params.mode, activeCount: scheduler.activeCount, maxParallelThreshold: params.maxParallelThreshold,
1502
+ mode: params.mode, activeCount: activeCount(), maxParallelThreshold: params.maxParallelThreshold,
1300
1503
  frameworkRoot: frameworkRoot(),
1301
1504
  problems: { total: qs.length, solved: qs.filter(function (q) { return q.已解决 }).length },
1302
1505
  propositions: { total: propos.length, resolved: propos.filter(function (p) { return p.布尔估计 === 1 || p.布尔估计 === 0 }).length },
@@ -1314,7 +1517,7 @@ export function apply(ctx) {
1314
1517
  if (scheduler.running) await abortScheduler()
1315
1518
  currentProject = slug; await writeCurrentProject(); await ensureDirs()
1316
1519
  if ((await readJson('qs/qs.json')) === undefined) await writeJson('qs/qs.json', [])
1317
- params = Object.assign({}, DEFAULT_PARAMS); scheduler = { running: false, activeCount: 0, startedAt: 0, lastCheckpoint: 0, gate: null }; agentRegistry = {}; decisionQueue = []; verifierAccuracy = {}; tasks = {}; explorerRetries = {}; activityLog = []; lastReportWrite = 0; lastPushReport = 0; reportDirty = false
1520
+ params = Object.assign({}, DEFAULT_PARAMS); scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null }; agentRegistry = {}; decisionQueue = []; verifierAccuracy = {}; tasks = {}; explorerRetries = {}; activityLog = []; lastReportWrite = 0; lastPushReport = 0; reportDirty = false
1318
1521
  await loadSettings(); await migrateLegacyParams(); await loadState(); await saveAll()
1319
1522
  return { ok: true, project: slug, frameworkRoot: frameworkRoot() }
1320
1523
  }
@@ -1457,6 +1660,10 @@ export function apply(ctx) {
1457
1660
  const sid = childOwner.get(info.id)
1458
1661
  const s = sid !== undefined ? sessions.get(sid) : undefined
1459
1662
  if (s) s.onChildEnd(info).catch(function (e) { console.error('vibe-math-v2 onChildEnd reject: ' + String((e && e.stack) || e)) })
1663
+ // 注意:这里**不要**回收 childOwner 条目。这条映射在子代理 end 之后仍会被后续事件路由
1664
+ // 用到:曾试过在此处回收、也试过在 onChildEnd 末尾回收,两次都导致 e2e-regression 的
1665
+ // verdict 收口失效("problem solved after verdict 1")。代价是每个历史子代理留下一条
1666
+ // 小记录(有界增长,实测不影响功能),远小于"验证无法收口"的代价。
1460
1667
  })
1461
1668
 
1462
1669
  // tick timer (registered once; ticks every running session at its own pace)