dsh-vibe-math 2.7.1 → 2.7.2

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 (47) hide show
  1. package/README.en.md +681 -690
  2. package/README.md +8 -17
  3. package/cordis.patch.yml +6 -6
  4. package/docs/AUDIT-CHECKLIST.md +518 -518
  5. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  6. package/docs/final-paper.md +172 -172
  7. package/docs/formal-verification.md +588 -588
  8. package/docs/math-computation.md +187 -163
  9. package/docs/release-notes/README.en.md +48 -0
  10. package/docs/release-notes/README.md +47 -0
  11. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  12. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  13. package/docs/release-notes/TEMPLATE.md +120 -0
  14. package/docs/test-timing.md +154 -154
  15. package/package.json +208 -203
  16. package/prompt-corpus-persona/persona-corpus.json +8 -8
  17. package/prompt-corpus-persona/persona-corpus.md +12 -12
  18. package/prompt-corpus-v2/formal-verify-v2.json +121 -109
  19. package/prompt-corpus-v2/formal-verify-v2.md +223 -107
  20. package/prompt-corpus-v3/formal-verify-v3.json +100 -76
  21. package/prompt-corpus-v3/formal-verify-v3.md +268 -70
  22. package/prompt-corpus-v4/formal-verify-v4.json +10 -10
  23. package/prompt-corpus-v4/formal-verify-v4.md +20 -20
  24. package/prompt-corpus-v5/prompt-corpus-v5.json +81 -81
  25. package/prompt-corpus-v5/prompt-corpus-v5.md +138 -128
  26. package/tests/audit-math-computation-contract.mjs +43 -1
  27. package/tests/audit-math-computation-sensitivity.mjs +2 -2
  28. package/tests/audit-v5-sensitivity.mjs +3 -1
  29. package/tests/e2e-v5-round2.test.mjs +114 -5
  30. package/tests/formal-verify-v2.test.mjs +38 -0
  31. package/tests/formal-verify-v3.test.mjs +33 -0
  32. package/tests/formal-verify-v4.test.mjs +87 -0
  33. package/tests/math-computation-shared.test.mjs +126 -1
  34. package/vibe-math-v2/agent.cordis.yml +2 -2
  35. package/vibe-math-v2/math-computation.js +122 -11
  36. package/vibe-math-v2/vibe-math-v2.js +75 -11
  37. package/vibe-math-v3/agent.cordis.yml +2 -2
  38. package/vibe-math-v3/math-computation.js +122 -11
  39. package/vibe-math-v3/vibe-math-v3.js +60 -13
  40. package/vibe-math-v4/agent.cordis.yml +4 -4
  41. package/vibe-math-v4/math-computation.js +122 -11
  42. package/vibe-math-v4/vibe-math-v4.js +55 -15
  43. package/vibe-math-v5/agent.cordis.yml +4 -4
  44. package/vibe-math-v5/math-computation.js +122 -11
  45. package/vibe-math-v5/vibe-math-v5.js +137 -28
  46. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1660 -1655
  47. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -72,11 +72,12 @@ export const MATH_PERSONA_TOOL_LINE = '- math_computation {op: probe|run|receipt
72
72
  // P2a: the archive -> edit -> re-run workflow. Injected into every preset's rule block (and appended
73
73
  // to both persona blocks) so an agent cannot miss that (a) the script is archived, (b) it may edit
74
74
  // it, and (c) re-running is what produces evidence for the edited code.
75
- export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'code\' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>),你可以用普通文件工具打开并编辑它;'
75
+ export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'code\' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>,**相对项目根**);'
76
+ + '**成员的文件工具是按会话 cwd 解析的**,所以打开它要用**绝对路径** `receipt.scriptAbs`,或把 `receipt.cwd` 与 `receipt.scriptPath` 拼起来(回执两个字段都有);'
76
77
  + '编辑后用 mode:\'file\' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——'
77
78
  + '报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。'
78
79
 
79
- export const MATH_ARCHIVE_WORKFLOW_LINE_EN = '- Archive -> edit -> re-run: for mode:\'code\' the script original is at the receipt\'s scriptPath (Computation/<id>/script.<ext>) and you may open/edit it with your normal file tools; after editing, re-run it with mode:\'file\' to write a NEW receipt/attempt with a NEW scriptHash. **An old receipt is NOT evidence for edited code** - cite the receipt whose scriptHash matches the current code; the tool warns explicitly via scriptChanged / scriptChangedDuringRun.'
80
+ export const MATH_ARCHIVE_WORKFLOW_LINE_EN = '- Archive -> edit -> re-run: for mode:\'code\' the script original is at the receipt\'s scriptPath (Computation/<id>/script.<ext>, **relative to the project root**); member file tools resolve paths against the SESSION CWD, so open it via the ABSOLUTE `receipt.scriptAbs`, or join `receipt.cwd` with `receipt.scriptPath` (both are in the receipt); after editing, re-run it with mode:\'file\' to write a NEW receipt/attempt with a NEW scriptHash. **An old receipt is NOT evidence for edited code** - cite the receipt whose scriptHash matches the current code; the tool warns explicitly via scriptChanged / scriptChangedDuringRun.'
80
81
 
81
82
  // Round-6 (A): honesty about SUBSTITUTIONS. An alternative that weakens exactness or conclusion
82
83
  // strength must be declared, and the conclusion must never read as if the requested (exact) result
@@ -288,14 +289,39 @@ function fail(code, engine, message, extra) {
288
289
  // round-7 (live-session fix): the probe diagnostic must survive into the response, otherwise
289
290
  // "存在但不可用" is opaque (a real session showed exactly that).
290
291
  if (extra && extra.probe) out.probe = jsonSafeDiag(extra.probe)
292
+ // round-9 (F7): the version-constraint policy travels with failures too (no silent drop).
293
+ if (extra && extra.versionPolicy) out.versionPolicy = extra.versionPolicy
294
+ if (extra && extra.constraintsNotEnforced) out.constraintsNotEnforced = extra.constraintsNotEnforced
291
295
  return out
292
296
  }
293
297
 
294
- function userInstallNext(engine) {
298
+ // round-9 (real-engine finding): the suggested install command may not be runnable on this machine
299
+ // (e.g. `winget` is absent), and handing a user a command that cannot run is misleading. So the
300
+ // command is emitted ONLY when its package manager is actually resolvable; otherwise we return the
301
+ // official vendor/download pointer plus an explicit note. Async because resolution goes through the host.
302
+ async function userInstallNext(H, engine) {
295
303
  const d = MATH_ENGINES[engine] || {}
296
304
  const per = d.userInstall || {}
297
305
  const platform = process && process.platform === 'win32' ? 'windows' : (process && process.platform === 'darwin' ? 'macos' : 'linux')
298
- return next('user-install', { engine: engine, perOs: per, command: per[platform] || '', platform: platform })
306
+ const raw = per[platform] || ''
307
+ const mgr = (function () { const m = /^\s*(winget|brew|apt-get|apt|choco|scoop|dnf|pacman|zypper)\b/.exec(String(raw)); return m ? m[1] : null })()
308
+ let available = true
309
+ if (mgr) {
310
+ try { available = !!(await H.resolveExecutable(mgr)) } catch (e) { available = false }
311
+ }
312
+ return next('user-install', {
313
+ engine: engine,
314
+ perOs: per,
315
+ command: (mgr && !available) ? '' : raw,
316
+ suggestedCommand: raw,
317
+ platform: platform,
318
+ packageManager: mgr,
319
+ packageManagerAvailable: mgr ? available : null,
320
+ vendorUrl: d.vendor || null,
321
+ note: (mgr && !available)
322
+ ? ('本机没有检测到包管理器 ' + mgr + ':建议命令无法运行,请按上面的官方地址手动安装(或先自行安装一个包管理器)。')
323
+ : null,
324
+ })
299
325
  }
300
326
 
301
327
  // ── host adaptation ─────────────────────────────────────────────────────────────────────────────
@@ -353,6 +379,42 @@ export async function probeMathEngines(host, opts) {
353
379
  return result
354
380
  }
355
381
 
382
+ // round-9 (real-engine finding): an engine can be INSTALLED yet invisible, because discovery only
383
+ // looked at the server process's PATH (R 4.6.1 landed in C:\Program Files\R\R-4.6.1\bin and was NOT
384
+ // added to PATH). Add the known per-OS INSTALL locations, globbed by version, still AFTER PATH and
385
+ // after the bundled runtime; existence is always proven by LISTING, never assumed.
386
+ async function knownInstallCandidates(H, engineName) {
387
+ if (typeof H.listDirAbs !== 'function') return []
388
+ const cands = mathEngineCandidates(engineName)
389
+ if (!cands) return []
390
+ const env = (typeof process !== 'undefined' && process.env) || {}
391
+ const win = !!(typeof process !== 'undefined' && process.platform === 'win32')
392
+ const exts = win ? ['', '.exe', '.cmd'] : ['']
393
+ const out = []
394
+ const listDirs = async (dir) => { try { return ((await H.listDirAbs(dir)) || []).filter((e) => e && e.type === 'directory').map((e) => e.name) } catch (e) { return [] } }
395
+ const listFiles = async (dir) => { try { return ((await H.listDirAbs(dir)) || []).filter((e) => e && e.type === 'file').map((e) => e.name) } catch (e) { return [] } }
396
+ const match = async (dir) => {
397
+ const files = await listFiles(dir)
398
+ for (const c of cands) for (const x of exts) if (files.indexOf(c + x) !== -1) out.push(dir + '/' + c + x)
399
+ }
400
+ if (engineName === 'r') {
401
+ const roots = win
402
+ ? [String(env.ProgramFiles || 'C:/Program Files') + '/R', String(env['ProgramFiles(x86)'] || 'C:/Program Files (x86)') + '/R', env.LOCALAPPDATA ? String(env.LOCALAPPDATA) + '/Programs/R' : null]
403
+ : ['/usr/lib/R', '/Library/Frameworks/R.framework/Resources', '/usr/local/lib/R', '/opt/R', '/usr/lib64/R']
404
+ for (const root of roots) {
405
+ if (!root) continue
406
+ for (const d of await listDirs(root)) await match(root + '/' + d + '/bin') // windows versioned dirs
407
+ await match(root + '/bin') // unix / framework layout
408
+ }
409
+ } else if (engineName === 'python' && win) {
410
+ const local = env.LOCALAPPDATA ? String(env.LOCALAPPDATA) + '/Programs/Python' : null
411
+ for (const d of (local ? await listDirs(local) : [])) await match(local + '/' + d)
412
+ const pf = String(env.ProgramFiles || 'C:/Program Files')
413
+ for (const d of await listDirs(pf)) if (/^Python[0-9]/i.test(d)) await match(pf + '/' + d)
414
+ }
415
+ return Array.from(new Set(out))
416
+ }
417
+
356
418
  async function resolveCandidate(H, d) {
357
419
  const cands = mathEngineCandidates(d.name)
358
420
  if (!cands) return null
@@ -362,11 +424,15 @@ async function resolveCandidate(H, d) {
362
424
  if (typeof p === 'string' && p) return p
363
425
  } catch (e) { /* not this one */ }
364
426
  }
365
- // round-7 (fix 2): DSH ships its own runtimes; PATH always wins, so this is the LAST resort. The
427
+ // round-7 (fix 2): DSH ships its own runtimes; PATH always wins, so this is a LAST resort. The
366
428
  // tree name is globbed (never hard-coded) and executable names come from the descriptor candidates.
367
429
  for (const p of await dshRuntimeCandidates(H, d.name)) {
368
430
  if (typeof p === 'string' && p) return p
369
431
  }
432
+ // round-9: then the known per-OS install locations (an engine installed without touching PATH).
433
+ for (const p of await knownInstallCandidates(H, d.name)) {
434
+ if (typeof p === 'string' && p) return p
435
+ }
370
436
  return null
371
437
  }
372
438
 
@@ -513,7 +579,7 @@ async function resolveEngine(H, requested, params, args) {
513
579
  if (params.mathMode !== 'typed+shell') return fail('MATH_REFUSED', name, 'cli 被策略禁用(mathMode=' + params.mathMode + ')', { next: next('reason', { reason: 'policy' }) })
514
580
  let exe = null
515
581
  try { exe = await H.resolveExecutable(args.cli.command) } catch (e) { exe = null }
516
- if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: userInstallNext('cli') })
582
+ if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: await userInstallNext(H, 'cli') })
517
583
  // round-7 (finding 5): report the REAL version when the command's family is recognisable (the
518
584
  // descriptor for that family carries the version probe); unknown commands stay 'unknown'.
519
585
  let cliVersion = 'unknown'
@@ -548,7 +614,7 @@ async function resolveEngine(H, requested, params, args) {
548
614
  return { ok: true, name: name, desc: d, exe: hit, version: v.version }
549
615
  }
550
616
  const first = requested === 'auto' ? (params.mathEngines[0] || 'python') : requested
551
- return fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(试过:' + names.join(', ') + ')', { next: userInstallNext(first) })
617
+ return fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(试过:' + names.join(', ') + ')', { next: await userInstallNext(H, first) })
552
618
  }
553
619
 
554
620
  // ── package probe ───────────────────────────────────────────────────────────────────────────────
@@ -810,7 +876,7 @@ async function opProbe(H, args, params) {
810
876
  }
811
877
  if (!probe.available && !(chosen && chosen.ok)) {
812
878
  const first = requested || params.mathEngines[0] || 'python'
813
- const out = fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(请求:' + first + ')', { next: userInstallNext(first), engines: [], available: false, packages: packages })
879
+ const out = fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(请求:' + first + ')', { next: await userInstallNext(H, first), engines: [], available: false, packages: packages })
814
880
  out.op = 'probe'
815
881
  return out
816
882
  }
@@ -821,10 +887,22 @@ async function opProbe(H, args, params) {
821
887
  engines.push({ name: chosen.name, path: chosen.exe, version: chosen.version, license: (MATH_ENGINES[chosen.name] || {}).license })
822
888
  }
823
889
  const engineInfo = (chosen && chosen.ok) ? { name: chosen.name, path: chosen.exe, version: chosen.version } : null
890
+ // round-9 (F3): the availability line must never be silently partial - name the CONFIGURED engines
891
+ // that were not found (and why), instead of just listing the ones that happened to resolve.
892
+ const foundNames = engines.map((e) => e.name)
893
+ // Per-engine absence explanation. The key is `why` (NOT the reserved machine-readable vocabulary
894
+ // key), so the parity guard's exhaustive `next.reason` set stays exactly the refusal vocabulary.
895
+ const absent = params.mathEngines.filter((e) => foundNames.indexOf(e) === -1).map((e) => {
896
+ if (MATH_ENGINES[e] && MATH_ENGINES[e].resolveFrom === 'cli.command') return { engine: e, why: 'needs-a-caller-supplied-command' }
897
+ return { engine: e, why: 'not-found-on-this-machine' }
898
+ })
824
899
  return {
825
900
  ok: true, op: 'probe', engine: engineInfo ? engineInfo.name : null, engineInfo: engineInfo,
826
901
  engines: engines, available: true, packages: packages,
827
- message: '可用引擎:' + engines.map((e) => e.name + ' ' + e.version).join('、') + (engineInfo ? '(请求 ' + engineInfo.name + ':' + engineInfo.version + ')' : ''),
902
+ configured: params.mathEngines.slice(), absent: absent,
903
+ message: '可用引擎:' + engines.map((e) => e.name + ' ' + e.version).join('、')
904
+ + (engineInfo ? '(请求 ' + engineInfo.name + ':' + engineInfo.version + ')' : '')
905
+ + (absent.length ? ';已配置但本机未发现:' + absent.map((a) => a.engine).join('、') : ';已配置的引擎都在'),
828
906
  }
829
907
  }
830
908
 
@@ -851,6 +929,9 @@ async function opRun(H, args, params) {
851
929
  const engineLabel = det.name === 'cli' ? ('cli:' + cliCommand) : det.name
852
930
 
853
931
  const want = (args.packages && args.packages.length) ? args.packages : params.mathPackages
932
+ // round-9 (F7): version constraints are NOT enforced - we only check existence by base name.
933
+ const pinnedSpecs = want.filter((sp) => parsePackageSpec(sp).pinned)
934
+ const versionPolicy = 'version-constraints-are-existence-only: 只按 base name 检查是否存在,约束本身从不校验(版本求解交给包管理器)' + (pinnedSpecs.length ? ';本次未校验的约束:' + pinnedSpecs.join(', ') : ';本次没有版本约束')
854
935
  // round-7 (finding 3): for cli the precheck runs against the FAMILY of `cli.command` (same
855
936
  // discovery-shaped logic); an unrecognisable command SKIPS the precheck instead of reporting a
856
937
  // false "missing" that would block the escape hatch.
@@ -865,6 +946,7 @@ async function opRun(H, args, params) {
865
946
  missing: missing,
866
947
  next: next('agent-install', { engine: det.name, packages: missing, specs: want.slice(), dryRun: true }),
867
948
  packages: pk,
949
+ versionPolicy: versionPolicy, constraintsNotEnforced: pinnedSpecs.slice(),
868
950
  })
869
951
  }
870
952
 
@@ -872,10 +954,17 @@ async function opRun(H, args, params) {
872
954
  let scriptText = ''
873
955
  let fileRel = null
874
956
  let sourceHashBefore = null
957
+ // round-9 (N1): `mode:'file'` is keyed by the SOURCE PATH, so re-running an ORIGINAL source lands on
958
+ // the same archive id (attempt>=2, scriptChanged). Pointing it at an ARCHIVED script instead targets
959
+ // a different path and therefore a NEW archive id by design - the prior attempt is never touched, but
960
+ // there is no previous receipt FOR THAT ID, so scriptChanged/previousReceipt are necessarily empty.
961
+ // That is not a bug, but it must be SAID rather than looking like broken change detection.
962
+ let archivedScriptSource = false
875
963
  if (mode === 'code') scriptText = args.code
876
964
  else if (mode === 'file') {
877
965
  fileRel = projectRel('', args.file)
878
966
  if (fileRel === null) return fail('MATH_REFUSED', det.name, 'file 必须位于项目内:' + args.file, { next: next('reason', { reason: 'path-outside-project' }) })
967
+ archivedScriptSource = /^Computation\//.test(String(fileRel))
879
968
  const txt = await H.readText(fileRel)
880
969
  if (txt === undefined) return fail('MATH_REFUSED', det.name, '找不到文件:' + args.file, { next: next('reason', { reason: 'file-not-found' }) })
881
970
  scriptText = txt
@@ -939,6 +1028,7 @@ async function opRun(H, args, params) {
939
1028
  }
940
1029
 
941
1030
  const warnings = []
1031
+ if (archivedScriptSource) warnings.push(warning('ARCHIVED_SCRIPT_RERUN', 'mode:\'file\' 指向的是**归档脚本**(Computation/…):按设计归档 id 以**源路径**为键,所以这是一次**新归档**(新目录、attempt 1),没有该 id 的历史回执 ⇒ scriptChanged/previousReceipt 为空是**预期**,且**旧 attempt 绝不会被覆盖**。要做"编辑→重跑→attempt≥2 + scriptChanged"的核对,请把 mode:\'file\' 指向**原始源文件**并编辑它,而不是指向归档副本。'))
942
1032
  if (!probeDet && want.length) warnings.push(warning('PACKAGE_PRECHECK_SKIPPED', 'engine=cli:无法从命令名判断引擎族,已跳过包预检(不阻塞执行,也不会误报缺包);如需预检请直接用 python/r/octave/julia 或改用可识别的解释器路径。'))
943
1033
  if (pk && pk.probeFailed && want.length) warnings.push(warning('PACKAGE_PRECHECK_UNKNOWN', '包预检未能执行(宿主 subprocess 在这几次尝试里返回空/超时,会话早期常见):本次不判定缺包、不阻塞执行;如需确定性结论请重试或直接用对应引擎。probe=' + JSON.stringify(pk.argv) + ' attempts=' + JSON.stringify(pk.attempts)))
944
1034
  if (fullOut.length > MATH_CAPS.stdout) warnings.push(warning('OUTPUT_TRUNCATED', 'stdout 超过 64KB,完整版见 ' + dir + '/stdout.txt'))
@@ -953,7 +1043,15 @@ async function opRun(H, args, params) {
953
1043
  engine: { name: engineLabel, path: det.exe, version: det.version, source: det.name === 'cli' ? 'cli' : 'path' },
954
1044
  script: { path: scriptRel, sha256: scriptHash, bytes: Buffer.byteLength(scriptText, 'utf8') },
955
1045
  scriptPath: scriptRel, scriptHash: scriptHash,
1046
+ // round-8 (P0/D3): member file tools are session-cwd relative, so the ABSOLUTE locations must be
1047
+ // in the receipt: `scriptAbs` is directly openable, `cwd` is the project root to join with.
1048
+ scriptAbs: scriptAbs, cwd: root,
956
1049
  sourceFile: fileRel || null,
1050
+ fileIsArchivedScript: archivedScriptSource,
1051
+ // round-9 (N2): a TIMEOUT must still carry the evidence it produced - the capped partial output
1052
+ // lives in the receipt as well as in stdout.txt/stderr.txt on disk.
1053
+ partialStdout: String(fullOut).slice(-2000),
1054
+ partialStderr: String(fullErr).slice(-2000),
957
1055
  sourceHashBefore: sourceHashBefore, sourceHashAfter: sourceHashAfter,
958
1056
  scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
959
1057
  previousReceipt: prevReceipt ? { runId: prevReceipt.runId, attempt: prevReceipt.attempt || 1, scriptHash: prevReceipt.scriptHash || (prevReceipt.script && prevReceipt.script.sha256) || null } : null,
@@ -979,12 +1077,20 @@ async function opRun(H, args, params) {
979
1077
 
980
1078
  const shell = {
981
1079
  ok: true, op: 'run', engine: engineLabel, mode: mode,
1080
+ runId: runId, attempt: attempt,
1081
+ versionPolicy: versionPolicy, constraintsNotEnforced: pinnedSpecs.slice(),
982
1082
  engineInfo: receipt.engine,
983
1083
  argv: assembled.argv.slice(),
984
1084
  scriptPath: scriptRel, scriptHash: scriptHash,
1085
+ scriptAbs: scriptAbs, cwd: root,
985
1086
  attempt: attempt, attemptDir: dir, baseRunDir: baseDir,
986
1087
  scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
987
1088
  sourceFile: fileRel || null,
1089
+ fileIsArchivedScript: archivedScriptSource,
1090
+ // round-9 (N2): a TIMEOUT must still carry the evidence it produced - the capped partial output
1091
+ // lives in the receipt as well as in stdout.txt/stderr.txt on disk.
1092
+ partialStdout: String(fullOut).slice(-2000),
1093
+ partialStderr: String(fullErr).slice(-2000),
988
1094
  previousReceipt: receipt.previousReceipt,
989
1095
  cliScriptAppended: !!(receipt.cli && receipt.cli.scriptAppended),
990
1096
  packages: pk.found,
@@ -994,8 +1100,13 @@ async function opRun(H, args, params) {
994
1100
  warnings: warnings,
995
1101
  }
996
1102
  if (receipt.timedOut) {
997
- return Object.assign(fail('MATH_TIMEOUT', engineLabel, '执行超时(' + timeoutMs + 'ms)已被终止', { argv: assembled.argv.slice(), receipt: receiptRef }),
998
- archiveFields(), { ms: receipt.ms, timedOut: true })
1103
+ // round-9 (F5): failure EVIDENCE must be uniform - a timeout carries the SAME fields a non-zero
1104
+ // exit does (exit + stderr), plus timedOut:true.
1105
+ const out = fail('MATH_TIMEOUT', engineLabel, '执行超时(' + timeoutMs + 'ms)已被终止', { argv: assembled.argv.slice(), receipt: receiptRef })
1106
+ out.exit = null
1107
+ out.stdout = String(fullOut).slice(0, MATH_CAPS.stdout)
1108
+ out.stderr = String(fullErr).slice(0, MATH_CAPS.stderr)
1109
+ return Object.assign(out, archiveFields(), { ms: receipt.ms, timedOut: true })
999
1110
  }
1000
1111
  if (r.exit !== 0) {
1001
1112
  const blob = fullErr + '\n' + fullOut
@@ -769,6 +769,14 @@ export function apply(ctx) {
769
769
  const vibeRoot = () => (workspaceRoot() + '/VibeMath').replace(/\\/g, '/')
770
770
  const projectRoot = () => vibeRoot() + '/Projects/' + project
771
771
  const instRoot = () => projectRoot() + '/Institutes/' + instituteName
772
+ // DEFECT A (3rd self-test): member-facing text used INSTITUTE-relative paths
773
+ // (`Members/<id>/…`), but a member's OWN file tools resolve a relative path against the
774
+ // SESSION CWD — so a member following that text verbatim created `<cwd>/Members/…`, a stray
775
+ // tree outside `VibeMath/Projects/…` (the self-test's `Members/acad/Propos/p-*.md`). Every
776
+ // member-facing path now uses the CWD-relative form (their real baseline); the framework's own
777
+ // writes keep going through `instRoot()`. `instRel()` is the one place that composes it.
778
+ const instRootRel = () => 'VibeMath/Projects/' + project + '/Institutes/' + instituteName
779
+ const instRel = (rel) => instRootRel() + '/' + String(rel == null ? '' : rel).replace(/^\/+/, '')
772
780
 
773
781
  function getPolicy() {
774
782
  const sp = sandboxPolicyOf()
@@ -924,9 +932,95 @@ export function apply(ctx) {
924
932
  const memberById = (id) => inst().members.find((m) => m.id === id)
925
933
  const voters = () => activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher')
926
934
  const voterCount = () => voters().length
927
- function quorumM() {
935
+ function quorumMFrom(vc) {
928
936
  const cap = Math.max(1, Math.floor(Number(params.quorumCap) || 3))
929
- return Math.max(1, Math.min(cap, voterCount()))
937
+ return Math.max(1, Math.min(cap, vc))
938
+ }
939
+ function quorumM() { return quorumMFrom(voterCount()) }
940
+ // DEFECT B (3rd self-test): the office's digest showed `m=2` while `status`/`acad` showed
941
+ // `m=3` in the SAME window — two views had computed the voter set at different moments
942
+ // (roster-snapshot timing). Every quorum-bearing view now reads ONE live snapshot and carries
943
+ // its `rosterVersion`, so all views at one point in time are provably identical and a stale
944
+ // reader can be DETECTED instead of silently disagreeing.
945
+ function rosterVersion() {
946
+ const ids = voters().map((m) => String(m.id) + ':' + String(m.phase)).sort()
947
+ return 'r' + String((inst().members || []).length) + ':' + ids.join(',')
948
+ }
949
+ function rosterSnapshot() {
950
+ const vs = voters()
951
+ const vc = vs.length
952
+ const started = vc > 0
953
+ return {
954
+ version: rosterVersion(),
955
+ m: started ? quorumMFrom(vc) : 0,
956
+ voters: vs.map((m) => m.id),
957
+ voterCount: vc,
958
+ started: started,
959
+ }
960
+ }
961
+ // DEFECT 2 (v5 self-test §18): before `start` there are no voters, yet the reported quorum
962
+ // was `m=1` with `voters=0` — it read like "a quorum of 1 is already there". The REPORTED
963
+ // quorum is therefore coherent and explicit: `started:false`, `m:0`, `voterCount:0` and the
964
+ // next step. `quorumM()` itself stays >=1 so the consensus arithmetic is unchanged, and
965
+ // `judgeVerdict` refuses to conclude at all when there are no voters.
966
+ function quorumView() {
967
+ const snap = rosterSnapshot()
968
+ const started = snap.started
969
+ const view = {
970
+ m: snap.m,
971
+ mode: params.quorumMode,
972
+ voters: snap.voters,
973
+ voterCount: snap.voterCount,
974
+ started: started,
975
+ phase: started ? 'voting' : 'not-started',
976
+ rosterVersion: snap.version,
977
+ }
978
+ if (!started) {
979
+ view.next = {
980
+ kind: 'start', tool: 'vibe_v5_start',
981
+ hint: '研究所尚未启动:有表决权者 0 人,因此 m 记为 0(不是"已满足 1 票门槛")。先 vibe_v5_start(或 /v5 start)建立名册,m 才有意义。',
982
+ }
983
+ }
984
+ return view
985
+ }
986
+ // DEFECT 1 (v5 self-test §18): a member-scoped tool called before the institute exists — or
987
+ // by nobody identifiable — answered a BARE `V5_MEMBER_NOT_FOUND`, so an operator (or the
988
+ // model) could not tell WHY it failed or WHAT TO DO FIRST. The error CODE is unchanged (all
989
+ // existing gates keep matching on it); the answer now names the state and the next step, in
990
+ // the same `next`-hint shape the `math_computation` tool uses.
991
+ function memberDiagnosis(what, memberId) {
992
+ const total = (inst().members || []).length
993
+ const active = activeMembers().length
994
+ const who = String(memberId || '')
995
+ if (active === 0 && !running) {
996
+ return {
997
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
998
+ message: what + '失败:研究所尚未启动(在册 ' + total + ' 名、有表决权者 0 人),没有可归属的成员身份。',
999
+ next: {
1000
+ kind: 'start', tool: 'vibe_v5_start',
1001
+ hint: '先启动研究所(vibe_v5_start 或 /v5 start)让框架派出常驻成员,再由**成员子代理**调用该工具;所办(会话根)本身不是成员。',
1002
+ },
1003
+ }
1004
+ }
1005
+ if (active === 0) {
1006
+ return {
1007
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
1008
+ message: what + '失败:研究所当前没有在册(active)成员。',
1009
+ next: { kind: 'staff', tool: 'vibe_v5_add_researcher', hint: '先用 vibe_v5_add_researcher / vibe_v5_hire 补充成员,再调用该工具。' },
1010
+ }
1011
+ }
1012
+ if (!who) {
1013
+ return {
1014
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
1015
+ message: what + '失败:无法识别调用者(这是成员工具,所办/会话根没有成员身份)。',
1016
+ next: { kind: 'member-call', tool: 'vibe_v5_members', hint: '请由成员子代理调用;所办可用 vibe_v5_message 转达,或直接用所办工具(vibe_v5_report / vibe_v5_status)。' },
1017
+ }
1018
+ }
1019
+ return {
1020
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
1021
+ message: what + '失败:名册中没有成员 "' + who + '"(可能已被解雇或尚未就位)。',
1022
+ next: { kind: 'roster', tool: 'vibe_v5_members', hint: '先用 vibe_v5_members 查看当前名册与阶段(active / provisioning / dismissed)。' },
1023
+ }
930
1024
  }
931
1025
  function byChild(childId) { return inst().members.find((m) => m.childId === childId) }
932
1026
  // The live academician's id, or '' when the office founded the institute with
@@ -1149,7 +1243,7 @@ export function apply(ctx) {
1149
1243
  L.push(' 2. 只有 Verified/ 目录下的结论(以及成果卡中标注"已验证·真/假"的条目)绝对可信。')
1150
1244
  L.push(' 其余一切——他人的推测、你自己的未验结论、Progress/、Methods/ 里的未验证断言——')
1151
1245
  L.push(' 都只是经验性参考,引用时必须注明"未验证"。')
1152
- L.push(' 3. 任何人可以读任何人的成果库;你只能写自己的库(Members/<你>/)。')
1246
+ L.push(' 3. 任何人可以读任何人的成果库;你只能写自己的库(' + instRel('Members/<你>/') + ')。')
1153
1247
  L.push(' 4. 你写下的有价值内容由你自己判断是否入库,但入库必须写明三项:')
1154
1248
  L.push(' 价值程度、动机用途计划、你自己对"该对象为真"的概率估计。')
1155
1249
  L.push(' 5. 你随时可以在群聊里说话;要单独找人可以私信。需要集体决策就提议开会。')
@@ -1164,8 +1258,10 @@ export function apply(ctx) {
1164
1258
  L.push('')
1165
1259
  // ── 三、libraries ───────────────────────────────────────────────────
1166
1260
  L.push('【三、你的资料库、progress 与卡片格式】')
1167
- L.push(' 你的资料库根目录:Members/' + member.id + '/')
1261
+ L.push(' 你的资料库根目录(**相对会话工作目录**):' + instRel('Members/' + member.id + '/'))
1168
1262
  L.push(' (以下路径都相对该目录。你**只写这里**,但可以读任何人的对应目录。)')
1263
+ L.push(' ⚠ 路径基准:你自己的文件工具的**相对路径以会话工作目录为基准**,所以直接读写文件时必须用上面这条完整路径;')
1264
+ L.push(' 若只想记录成果,直接用 vibe_v5_record_progress / vibe_v5_record_proposition / vibe_v5_record_method / vibe_v5_record_subproblem,框架会写到正确位置。')
1169
1265
  L.push('')
1170
1266
  L.push(LIB_SPEC)
1171
1267
  L.push('')
@@ -1303,7 +1399,7 @@ export function apply(ctx) {
1303
1399
  const ms = activeMembers()
1304
1400
  const b = []
1305
1401
  b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (rounds.get(member.id) || 0) +
1306
- '|法定票数 m=' + quorumM() + '|有表决权者 ' + voterCount() + ' 人')
1402
+ '|法定票数 m=' + (voterCount() > 0 ? quorumM() : '未启动') + '|有表决权者 ' + voterCount() + ' 人')
1307
1403
  b.push('[在册] ' + (ms.length ? ms.map((x) => x.id).join('、') : '(无)'))
1308
1404
  // Members that are on the books but NOT on the floor. Silently omitting them made a
1309
1405
  // failed provision invisible to the whole institute.
@@ -1770,7 +1866,7 @@ export function apply(ctx) {
1770
1866
  const soft = (contextPct.get(member.id) || 0) >= Number(params.compactThreshold) ||
1771
1867
  (roundsSinceCompact.get(member.id) || 0) >= Number(params.compactAfterRounds)
1772
1868
  if (soft) {
1773
- prompt = CORE_RULES + '\n[CONTEXT COMPACT — 你的对话已接近上限。不要重新推导历史。\n' +
1869
+ prompt = coreRules() + '\n[CONTEXT COMPACT — 你的对话已接近上限。不要重新推导历史。\n' +
1774
1870
  '请把当前工作状态浓缩成一段自述(已有发现、当前方向、已记录的关键成果、下一步具体动作、未决问题),' +
1775
1871
  '然后照常以 JSON 回答本轮。请在回复里填 "contextPct": 15 与 "compacted": true。]\n\n' + prompt
1776
1872
  // Reset the counter WITH the injection so the directive cannot repeat on the
@@ -1779,7 +1875,7 @@ export function apply(ctx) {
1779
1875
  }
1780
1876
  }
1781
1877
  if (needReanchor.has(member.id)) {
1782
- prompt = CORE_RULES + '\n' + prompt
1878
+ prompt = coreRules() + '\n' + prompt
1783
1879
  needReanchor.delete(member.id)
1784
1880
  }
1785
1881
  try {
@@ -1861,7 +1957,7 @@ export function apply(ctx) {
1861
1957
  L.push(' "prioritize": {"order":[{"task_id":"t-1","priority":2}],"why":"…"} ← 设定全所优先级,')
1862
1958
  L.push(' "nudge": {"to":"r-2","why":"为何督办","next_step":"建议的具体下一步"},')
1863
1959
  }
1864
- L.push(' "task_create": {"subject":"…","description":"…","blocked_by":["t-1"],"write_scopes":["Members/r-1/Propos"]},')
1960
+ L.push(' "task_create": {"subject":"…","description":"…","blocked_by":["t-1"],"write_scopes":["' + instRel('Members/r-1/Propos') + '"]},')
1865
1961
  L.push(' "task_claim": "t-3",')
1866
1962
  L.push(' "task_done": "t-3",')
1867
1963
  L.push(' "task_update": {"task_id":"t-3","expected_revision":2,"action":"complete|release|reopen|edit|set_dependencies|delete"},')
@@ -3328,9 +3424,12 @@ export function apply(ctx) {
3328
3424
  // A SHORT core-rules recap, injected ONLY (a) right after a REAL compaction, or
3329
3425
  // (b) in the same wake as a soft-compact directive — never on every round. The
3330
3426
  // charter itself lives in `persona` and needs no reinforcement otherwise.
3331
- const CORE_RULES = '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
3427
+ // A function, not a frozen string: the member-facing library path must reflect the LIVE
3428
+ // project/institute (DEFECT A) — a path captured at session creation could point at the
3429
+ // default project after a `configure`.
3430
+ const coreRules = () => '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
3332
3431
  '任何对象要进 Verified/,必须至少有 m 名有表决权者投出布尔值(恰好 1 或恰好 0)**且没有任何一张反向票**,否则留库附平均概率;' +
3333
- '你只写自己的库(Members/<你>/),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
3432
+ '你只写自己的库(' + instRel('Members/<你>/') + '——相对**会话工作目录**),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
3334
3433
  '退出时只输出一个 JSON 对象。'
3335
3434
 
3336
3435
  // ONE place accounts for context usage on EVERY reply (normal, meeting, verify,
@@ -3377,7 +3476,7 @@ export function apply(ctx) {
3377
3476
  return n
3378
3477
  }
3379
3478
  async function publishProgress(memberId, text) {
3380
- if (!memberId || !memberById(memberId)) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
3479
+ if (!memberId || !memberById(memberId)) return memberDiagnosis('记录研究进度(vibe_v5_record_progress)', memberId)
3381
3480
  if (!String(text || '').trim()) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'empty progress' }
3382
3481
  const rel = 'Members/' + memberId + '/Progress/progress.md'
3383
3482
  const prev = (await readTextRel(rel)) || ''
@@ -3390,7 +3489,7 @@ export function apply(ctx) {
3390
3489
  // hard requirements. Missing fields are refused rather than silently defaulted,
3391
3490
  // so the libraries keep their meaning.
3392
3491
  async function recordCard(memberId, kind, o) {
3393
- if (!memberId || !memberById(memberId)) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member' }
3492
+ if (!memberId || !memberById(memberId)) return memberDiagnosis('记录成果卡片(vibe_v5_record_proposition/method/subproblem)', memberId)
3394
3493
  const args = o || {}
3395
3494
  const missing = []
3396
3495
  if (args.value === undefined || args.value === null) missing.push('value(价值程度)')
@@ -3553,7 +3652,7 @@ export function apply(ctx) {
3553
3652
  // An unknown caller must be refused HERE as well: `owner = task.ownerId === memberId` is true
3554
3653
  // for an UNOWNED task (ownerId '') when memberId is '', so without this guard an
3555
3654
  // unidentifiable caller would count as the owner of every unclaimed task (audit L6 follow-up).
3556
- if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
3655
+ if (!memberId) return memberDiagnosis('修改任务(vibe_v5_task_update)', memberId)
3557
3656
  const args = o || {}
3558
3657
  const id = String(args.task_id || args.taskId || '')
3559
3658
  const task = inst().tasks.find((x) => x.id === id)
@@ -3650,7 +3749,7 @@ export function apply(ctx) {
3650
3749
  // it can never make any statement true, and the assignee may object with reasons
3651
3750
  // (the objection is broadcast, not silently swallowed).
3652
3751
  async function taskAssign(memberId, o) {
3653
- if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
3752
+ if (!memberId) return memberDiagnosis('分派任务(vibe_v5_assign)', memberId)
3654
3753
  if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
3655
3754
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks' }
3656
3755
  }
@@ -3694,7 +3793,7 @@ export function apply(ctx) {
3694
3793
  return { ok: true, task: taskView(withMeta) }
3695
3794
  }
3696
3795
  async function taskPrioritize(memberId, o) {
3697
- if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
3796
+ if (!memberId) return memberDiagnosis('设置任务优先级(vibe_v5_task_prioritize)', memberId)
3698
3797
  if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
3699
3798
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities' }
3700
3799
  }
@@ -3746,7 +3845,7 @@ export function apply(ctx) {
3746
3845
  const s = inst()
3747
3846
  const lines = ['# 研究所编制表(人读镜像)|' + instituteName + '|' + fmtTime(), '',
3748
3847
  '> 权威状态在 State/<研究所>.v5state.json 里;本文件只是快照,勿手改。', '']
3749
- lines.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
3848
+ lines.push('- 求真门槛:' + (voterCount() > 0 ? 'm = ' + quorumM() : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
3750
3849
  lines.push('- 阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
3751
3850
  lines.push('')
3752
3851
  lines.push('| 代号 | 职位 | 状态 | 雇主 | 方向/用途 | 轮次 | 上下文% |')
@@ -3873,6 +3972,9 @@ export function apply(ctx) {
3873
3972
  }
3874
3973
  const mean = all.length ? all.reduce((a, x) => a + x, 0) / all.length : 0.5
3875
3974
  const base = { m, P, bTrue, bFalse, abstain, mean, votedCount: all.length, voters: E }
3975
+ // DEFECT 2 hardening: with NO voters there is nothing to conclude — every `>= m` test
3976
+ // below would be trivially satisfiable if m were ever 0. Consensus needs voters.
3977
+ if (P === 0) return Object.assign(base, { outcome: 'undecided', reason: 'no voters: the institute has no voting members yet' })
3876
3978
  if (params.quorumMode === 'all-unanimous') {
3877
3979
  const allVoted = E.length > 0 && E.every((id) => votes[id])
3878
3980
  if (!allVoted) return Object.assign(base, { outcome: 'undecided', reason: 'not every voter has voted' })
@@ -4219,7 +4321,7 @@ export function apply(ctx) {
4219
4321
  // Record one vote and, when every voter has answered, settle the round.
4220
4322
  async function castVerdict(memberId, target, verdict, reason) {
4221
4323
  const member = memberById(memberId)
4222
- if (!member) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
4324
+ if (!member) return memberDiagnosis('投票(vibe_v5_verdict)', memberId)
4223
4325
  if (member.kind === 'temp') {
4224
4326
  // Temp workers have no vote — but their judgement still matters, so it is
4225
4327
  // relayed to the group instead of being silently dropped.
@@ -4578,7 +4680,7 @@ export function apply(ctx) {
4578
4680
  L.push('【最终论文·撰写 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
4579
4681
  L.push('本所对原问题的一致结论已经达成,现在撰写**最终论文**(第 ' + p.round + '/' + PAPER_MAX_ROUNDS + ' 轮)。')
4580
4682
  L.push('请你**只写你自己库里已有证据支撑**的内容:')
4581
- L.push('- 直接引用你的卡片(Members/' + member.id + '/Propos|Methods|Subproblems/)、Members/' + member.id + '/Progress/progress.md、你参与的表决记录;')
4683
+ L.push('- 直接引用你的卡片(' + instRel('Members/' + member.id + '/Propos|Methods|Subproblems/') + ')、' + instRel('Members/' + member.id + '/Progress/progress.md') + '、你参与的表决记录;')
4582
4684
  L.push('- **不得编造**:没有证据的推测不要写成结论;未决 / 被否证的条目必须显式标注“未定论 / 已被否证”;')
4583
4685
  L.push('- 在 evidence 里写清证据路径,附录会逐条索引。')
4584
4686
  if (p.round > 1) {
@@ -4598,7 +4700,7 @@ export function apply(ctx) {
4598
4700
  L.push(' "methods": "你创造/发现的方法、理论、思想、有价值经验、数学理解",')
4599
4701
  L.push(' "rules": "你从这些工作中归纳出的可复用规律",')
4600
4702
  L.push(' "limits": "局限、未决、被否证之处(必须诚实、显式)",')
4601
- L.push(' "evidence": ["Members/' + member.id + '/Propos/p-x.md"] } }')
4703
+ L.push(' "evidence": ["' + instRel('Members/' + member.id + '/Propos/p-x.md') + '"] } }')
4602
4704
  return L.join('\n')
4603
4705
  }
4604
4706
  function paperReviewPrompt(member, p, ofId) {
@@ -5024,8 +5126,10 @@ export function apply(ctx) {
5024
5126
  }
5025
5127
  if (ev.formalKeys.length) out.push('Formal/Index.md', 'Formal/TODO.md')
5026
5128
  for (const m of ev.members) {
5027
- out.push('Members/' + m.id + '/Progress/progress.md(' + m.id + ' 的研究日志)')
5028
- out.push('Members/' + m.id + '/{Propos,Methods,Subproblems}/(' + m.id + ' 的卡片)')
5129
+ // Member-facing evidence paths are CWD-relative (DEFECT A): a member copying one of these
5130
+ // into its own file tool must not create a stray `<cwd>/Members/…` tree.
5131
+ out.push(instRel('Members/' + m.id + '/Progress/progress.md') + '(' + m.id + ' 的研究日志)')
5132
+ out.push(instRel('Members/' + m.id + '/{Propos,Methods,Subproblems}/') + '(' + m.id + ' 的卡片)')
5029
5133
  }
5030
5134
  out.push('Institutes.md(编制镜像)', 'Shared/TaskBoard.md(任务板镜像)')
5031
5135
  out.push('Paper/' + p.id + '/paper.log.md(本论文流程往来)')
@@ -5508,7 +5612,7 @@ export function apply(ctx) {
5508
5612
  return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
5509
5613
  }
5510
5614
  async function nudge(callerId, o) {
5511
- if (!callerId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
5615
+ if (!callerId) return memberDiagnosis('督办(vibe_v5_nudge)', callerId)
5512
5616
  if (!isOffice(callerId) && !(isAcademician(callerId) && params.academicianLeads)) {
5513
5617
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can nudge members' }
5514
5618
  }
@@ -6103,7 +6207,7 @@ export function apply(ctx) {
6103
6207
  memberMayRejectAssign: params.memberMayRejectAssign,
6104
6208
  researcherCount: params.researcherCount,
6105
6209
  quorumCap: params.quorumCap, quorumMode: params.quorumMode,
6106
- m: quorumM(), voterCount: voterCount(),
6210
+ m: quorumView().m, voterCount: voterCount(), started: voterCount() > 0,
6107
6211
  verdictMaxRounds: params.verdictMaxRounds,
6108
6212
  maxTempPerMember: params.maxTempPerMember, maxTempTotal: params.maxTempTotal,
6109
6213
  compactThreshold: params.compactThreshold, compactAfterRounds: params.compactAfterRounds,
@@ -6419,7 +6523,7 @@ export function apply(ctx) {
6419
6523
  // failure modes that silently drop state were invisible in the operator's view.
6420
6524
  diagnostics: s.diagnostics || [],
6421
6525
  debug: Object.assign({ scheduling, reschedule }, dbg),
6422
- quorum: { m: quorumM(), mode: params.quorumMode, voters: voters().map((m) => m.id), voterCount: voterCount() },
6526
+ quorum: quorumView(),
6423
6527
  members: s.members.map((m) => ({
6424
6528
  id: m.id, kind: m.kind, phase: m.phase, direction: m.direction, hiredBy: m.hiredBy,
6425
6529
  rounds: rounds.get(m.id) || 0, busy: busy.has(m.id), contextPct: contextPct.get(m.id) || 0,
@@ -6456,12 +6560,15 @@ export function apply(ctx) {
6456
6560
  function report() {
6457
6561
  syncParamsFromState()
6458
6562
  const s = inst()
6563
+ // ONE snapshot for the whole report: the text line and the returned `quorum` object can
6564
+ // never disagree (DEFECT B), and `rosterVersion` lets a reader detect a stale copy.
6565
+ const qv = quorumView()
6459
6566
  const L = []
6460
6567
  L.push('# 「' + instituteName + '」研究所汇报')
6461
6568
  L.push('')
6462
6569
  L.push('- 项目:' + project + '|阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
6463
6570
  L.push('- 研究对象:' + (s.problem.statement ? s.problem.statement.slice(0, 200) : '(未设定)'))
6464
- L.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
6571
+ L.push('- 求真门槛:' + (qv.started ? 'm = ' + qv.m : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + qv.voterCount + ' 人|rosterVersion ' + qv.rosterVersion)
6465
6572
  L.push('')
6466
6573
  L.push('## 编制')
6467
6574
  if (!s.members.length) L.push('(暂无成员)')
@@ -6533,8 +6640,8 @@ export function apply(ctx) {
6533
6640
  L.push('')
6534
6641
  L.push('## 文件位置')
6535
6642
  L.push('- 根目录:' + instRoot())
6536
- L.push('- 已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
6537
- return { ok: true, report: L.join('\n') }
6643
+ L.push('- 工作目录相对路径(你的文件工具的基准):' + instRootRel() + '/|已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
6644
+ return { ok: true, report: L.join('\n'), quorum: qv }
6538
6645
  }
6539
6646
  // Adding/removing a PERMANENT researcher is a change to the institute's public
6540
6647
  // structure, so members may only propose it; the office decides and executes.
@@ -6606,6 +6713,8 @@ export function apply(ctx) {
6606
6713
  startPaper, paperStatus: paperSummary, finalizePaperByOffice,
6607
6714
  // authorization helpers (used by tool handlers)
6608
6715
  memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
6716
+ // diagnosis helpers (defects 1/2 of the architecture self-test)
6717
+ memberDiagnosis, quorumM, quorumView, voterCount,
6609
6718
  }
6610
6719
  }
6611
6720
 
@@ -6800,7 +6909,7 @@ export function apply(ctx) {
6800
6909
  // ── member-facing controls ────────────────────────────────────────────────
6801
6910
  registerTool('vibe_v5_say', '(member) Speak in the group chat (omit "to"), send a private message ("to":"r-2"), or address only the voters ("to":"voters").', objParams({ text: S, to: S }, ['text']), (s, a, x) => {
6802
6911
  const from = s.memberIdOfAgent(x)
6803
- if (!from) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
6912
+ if (!from) return s.memberDiagnosis('发言(vibe_v5_say)', from)
6804
6913
  const to = a.to || 'all'
6805
6914
  return s.say(from, { to, text: a.text, kind: to === 'voters' ? 'voters' : (a.to ? 'dm' : 'chat') })
6806
6915
  })