dsh-vibe-math 2.7.1 → 2.7.3

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 (114) hide show
  1. package/README.en.md +682 -690
  2. package/README.md +19 -27
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +744 -518
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/final-paper.md +172 -172
  8. package/docs/formal-verification.md +588 -588
  9. package/docs/math-computation.md +194 -163
  10. package/docs/parameter-schema.md +37 -0
  11. package/docs/release-notes/README.en.md +49 -0
  12. package/docs/release-notes/README.md +48 -0
  13. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  16. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  17. package/docs/release-notes/TEMPLATE.md +126 -0
  18. package/docs/status-report-fields.md +167 -0
  19. package/docs/test-timing.md +157 -154
  20. package/installer.js +27 -17
  21. package/package.json +55 -3
  22. package/prompt-corpus-persona/persona-corpus.json +9 -9
  23. package/prompt-corpus-persona/persona-corpus.md +225 -33
  24. package/prompt-corpus-v2/formal-verify-v2.json +305 -113
  25. package/prompt-corpus-v2/formal-verify-v2.md +2399 -279
  26. package/prompt-corpus-v3/formal-verify-v3.json +134 -110
  27. package/prompt-corpus-v3/formal-verify-v3.md +409 -141
  28. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  29. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  30. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  31. package/prompt-corpus-v5/prompt-corpus-v5.md +513 -259
  32. package/scripts/update-doc-counts.mjs +71 -0
  33. package/tests/audit-artifact-docs.mjs +72 -0
  34. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  35. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  36. package/tests/audit-installer-compat.test.mjs +52 -4
  37. package/tests/audit-installer-policy.test.mjs +41 -8
  38. package/tests/audit-math-computation-contract.mjs +107 -9
  39. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  40. package/tests/audit-math-computation-parity.mjs +164 -3
  41. package/tests/audit-math-computation-sensitivity.mjs +6 -4
  42. package/tests/audit-package-membership.mjs +57 -0
  43. package/tests/audit-package-membership.mutants.mjs +28 -0
  44. package/tests/audit-participant-set-parity.mjs +160 -0
  45. package/tests/audit-path-discipline.mjs +83 -0
  46. package/tests/audit-path-discipline.mutants.mjs +51 -0
  47. package/tests/audit-persona-sensitivity.mjs +14 -0
  48. package/tests/audit-persona-surface.mutants.mjs +69 -0
  49. package/tests/audit-persona-surface.test.mjs +63 -22
  50. package/tests/audit-prompt-duplication.mjs +143 -0
  51. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  52. package/tests/audit-prompt-invariants.mjs +43 -5
  53. package/tests/audit-readme-bilingual.test.mjs +152 -0
  54. package/tests/audit-readme-counts.mjs +95 -0
  55. package/tests/audit-readme-counts.mutants.mjs +52 -0
  56. package/tests/audit-registration.mjs +78 -0
  57. package/tests/audit-status-report-fields.mjs +172 -0
  58. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  59. package/tests/audit-v3-registration-parity.mjs +156 -0
  60. package/tests/audit-v5-integrity.mjs +300 -13
  61. package/tests/audit-v5-lean-abstention.mjs +392 -0
  62. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  63. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  64. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  65. package/tests/audit-v5-sensitivity.mjs +15 -8
  66. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  67. package/tests/e2e-v5-round2.test.mjs +566 -12
  68. package/tests/formal-verify-v2.test.mjs +91 -5
  69. package/tests/formal-verify-v3.mutants.mjs +117 -0
  70. package/tests/formal-verify-v3.test.mjs +85 -6
  71. package/tests/formal-verify-v4.mutants.mjs +152 -0
  72. package/tests/formal-verify-v4.test.mjs +628 -9
  73. package/tests/formal-verify-v5.test.mjs +68 -9
  74. package/tests/helpers/doc-counts-format.mjs +41 -0
  75. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  76. package/tests/math-computation-a1.mutants.mjs +72 -0
  77. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  78. package/tests/math-computation-discovery.mutants.mjs +76 -0
  79. package/tests/math-computation-shared.test.mjs +396 -2
  80. package/tests/math-computation-v2.test.mjs +767 -0
  81. package/tests/math-computation-v3.test.mjs +759 -0
  82. package/tests/math-computation-v4.mutants.mjs +133 -0
  83. package/tests/math-computation-v4.test.mjs +739 -0
  84. package/tests/prompt-v5-integrity.test.mjs +13 -2
  85. package/tests/run-tests.mjs +139 -9
  86. package/tests/run-tests.mutants.mjs +70 -0
  87. package/tests/selfdrive-v5.mjs +53 -1
  88. package/tests/selfdrive-v5.mutants.mjs +132 -0
  89. package/tests/v2-fix-probes.mutants.mjs +100 -0
  90. package/tests/v2-fix-probes.test.mjs +1039 -0
  91. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  92. package/tests/v3-fix-probes.mutants.mjs +95 -0
  93. package/tests/v3-fix-probes.test.mjs +891 -0
  94. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  95. package/vibe-math-v2/agent.cordis.yml +62 -6
  96. package/vibe-math-v2/math-computation.js +229 -29
  97. package/vibe-math-v2/math-engines.js +12 -12
  98. package/vibe-math-v2/vibe-math-v2.js +458 -119
  99. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  100. package/vibe-math-v3/agent.cordis.yml +72 -6
  101. package/vibe-math-v3/math-computation.js +229 -29
  102. package/vibe-math-v3/math-engines.js +12 -12
  103. package/vibe-math-v3/vibe-math-v3.js +482 -139
  104. package/vibe-math-v4/agent.cordis.yml +44 -8
  105. package/vibe-math-v4/math-computation.js +229 -29
  106. package/vibe-math-v4/math-engines.js +12 -12
  107. package/vibe-math-v4/vibe-math-v4.js +245 -52
  108. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  109. package/vibe-math-v5/agent.cordis.yml +46 -12
  110. package/vibe-math-v5/math-computation.js +229 -29
  111. package/vibe-math-v5/math-engines.js +12 -12
  112. package/vibe-math-v5/vibe-math-v5.js +1307 -243
  113. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1655
  114. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -0,0 +1,767 @@
1
+ // Defect class: A1 skip ledger: "the package manager exists ⇒ a runnable command exists" must actually be exercised (a platform condition may not silently swallow the assertion).
2
+ // Every assertion below must be falsifiable: tests/*.mutants.mjs contains the matching single-site
3
+ // mutants whose named reds prove it (see docs/parameter-schema.md for the F-B exception table).
4
+ // Run: node tests/math-computation-v2.test.mjs
5
+ // tests/math-computation-v2.test.mjs — v2 接线面验收(math_computation P1)
6
+ //
7
+ // 覆盖 guards.md §1 里属于"预设接线"的部分(1–13、18–20、22 的接线侧 + 16 的提示词面):
8
+ // 参数六键归一化(含 `'false'` 这类错类型必须回退默认,绝不从 `else out[k]=v` 尾巴漏过)、
9
+ // 双层注册(会话层 handler + apply 层工具面,且**每预设只注册一次**)、
10
+ // 假引擎 seam 上的 probe/缺包/无许可/超时 kill/非零退出/超大输出截断/路径守卫/cwd、
11
+ // cli 默认开启 + 两种禁用、argv 回显、bad-argv 不折叠、安装计划→确认两步、
12
+ // 提示词可用性行(typed+shell 有 shell 兜底句、typed 没有、off 零提及)、persona 两块、Computation/ 目录。
13
+ //
14
+ // 假引擎**只走注入的 subprocess seam**:不碰真实引擎,任何机器(没有装任何引擎)都能跑。
15
+ // 变异说明(写 3–5 个即可;每条都对应能被本文件打红的断言):
16
+ // M1 把 `sanitizeParams` 的 `MATH_PARAM_NAMES` 分支删掉(六键从 `else out[k]=v` 漏过)⇒ §1 的 'false' 断言红。
17
+ // M2 删掉 apply 层的 `registerMathComputation`(或会话层那一次)⇒ §2 双层注册/工具面断言红。
18
+ // M3 `mathSpawn` 去掉计时器与 `handle.terminate()` ⇒ §6 的 MATH_TIMEOUT/terminated 断言红。
19
+ // M4 `mathSpawn` 把 stdio 上限压到 64KB(或对 stdout 先截断再返回)⇒ §8 的"完整落盘 >64KB"断言红。
20
+ // M5 `mathWorkLine` 不看 `params.mathMode`(直接从 MATH_RULE_LINES 拼)⇒ §15 的 typed 档"不得出现 shell 兜底句"断言红。
21
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs'
22
+ import { join, dirname } from 'node:path'
23
+ import { tmpdir } from 'node:os'
24
+ import { fileURLToPath } from 'node:url'
25
+ import {
26
+ MATH_PARAM_NAMES,
27
+ MATH_PARAM_DEFAULTS,
28
+ MATH_TOOL_DESCRIPTION,
29
+ MATH_PERSONA_TOOL_LINE,
30
+ MATH_ARCHIVE_WORKFLOW_LINE,
31
+ MATH_SUBSTITUTION_RULE_LINE,
32
+ } from '../vibe-math-v2/math-computation.js'
33
+ // 引擎表在姊妹模块里(模块自己 import 它;测试为了拼 argv 模板也直接读一次,只读不写)。
34
+ import { MATH_ENGINES } from '../vibe-math-v2/math-engines.js'
35
+
36
+ const HERE = dirname(fileURLToPath(import.meta.url))
37
+ const PLUGIN = process.env.MC_V2_PLUGIN
38
+ ? new URL('file:///' + String(process.env.MC_V2_PLUGIN).replace(/\\/g, '/'))
39
+ : new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url)
40
+
41
+ let passed = 0
42
+ let failed = 0
43
+ const failures = []
44
+ const skips = [] // A1:显式跳过清单(任何 skip 都会出现在摘要里,不再冒充通过)
45
+ const skip = (why) => { skips.push(String(why)); console.log(' skip - ' + why) }
46
+ let mgrBranchExercised = false // A1:证明「管理器存在 ⇒ 可执行命令」这半契约真的跑过
47
+ function assert(cond, msg) {
48
+ if (cond) { passed++; console.log(' ok - ' + msg) } else { failed++; failures.push(msg); console.error(' FAIL - ' + msg) }
49
+ }
50
+ const section = (t) => console.log('\n[' + t + ']')
51
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
52
+
53
+ const WS = mkdtempSync(join(tmpdir(), 'mc-v2-'))
54
+ const PROJECT = 'mcp'
55
+ const projRoot = () => join(WS, 'VibeMath', 'Projects', PROJECT)
56
+
57
+ // ── 假引擎 seam ────────────────────────────────────────────────────────────────────────────────
58
+ const CANDIDATES = { python3: 'python', python: 'python', Rscript: 'r', octave: 'octave', 'octave-cli': 'octave', julia: 'julia', matlab: 'matlab', maple: 'maple', wolframscript: 'wolfram' }
59
+ let fake = null
60
+ function freshFake() {
61
+ return {
62
+ installed: {}, // { python: '3.11.4', maple: '2024' }
63
+ licensed: {}, // { maple: true }(商业引擎)
64
+ packages: {}, // { numpy: 'ok', sympy: null }
65
+ run: 'ok', // ok | hang | fail3 | argerr | big
66
+ spawnDelayMs: 0, // 每次 spawn 的人为延迟(跨会话交错用例用)
67
+ noSubprocess: false, // true ⇒ ctx.get('subprocess') 返回 undefined(hasSubprocess:false 用例)
68
+ fsListDirAccess: 0, // 假 fs 的 listDir 属性被读取的次数(能力门闩)
69
+ fsListDirCalls: 0, // 假 fs 的 listDir 被真正调用(列目录)的次数
70
+ cliCommands: {}, // { echo: '/fake/cli/echo' }
71
+ spawns: [], terminated: 0,
72
+ }
73
+ }
74
+ function engineOfExe(exe) { return CANDIDATES[String(exe).split(/[\\/]/).pop()] || null }
75
+ function looksLikeRun(argv) { return argv.join(' ').indexOf('/script.') !== -1 }
76
+ const VERSION_RE = { python: /(\d+\.\d+\.\d+)/, r: /(\d+\.\d+\.\d+)/, octave: /(\d+\.\d+\.\d+)/, julia: /(\d+\.\d+\.\d+)/, matlab: /(\d+\.\d+)/, maple: /(\d+\.\d+)/, wolfram: /(\d+\.\d+(\.\d+)?)/ }
77
+ function spawnResult(exit, stdout, stderr) {
78
+ // 可选延迟:跨会话并发用例(§17)需要两次调用真的在 await 期间交错,默认 0。
79
+ const done = (fake && fake.spawnDelayMs)
80
+ ? new Promise(function (r) { setTimeout(function () { r({ exitCode: exit, signal: null }) }, fake.spawnDelayMs) })
81
+ : Promise.resolve({ exitCode: exit, signal: null })
82
+ return {
83
+ done: done,
84
+ collected: {
85
+ stdout: { readFrom: () => ({ text: stdout || '', nextOffset: (stdout || '').length, lossy: false }) },
86
+ stderr: { readFrom: () => ({ text: stderr || '', nextOffset: (stderr || '').length, lossy: false }) },
87
+ },
88
+ terminate() {},
89
+ }
90
+ }
91
+ const subprocess = {
92
+ async resolveExecutable(cmd) {
93
+ const name = String(cmd)
94
+ if (fake.cliCommands[name]) return fake.cliCommands[name]
95
+ const key = CANDIDATES[name]
96
+ if (!key || !(key in fake.installed)) throw new Error('spawn ' + name + ' ENOENT')
97
+ return '/fake/' + name
98
+ },
99
+ spawn(spec) {
100
+ const argv = (spec.argv || []).map(String)
101
+ fake.spawns.push({ argv, cwd: spec.cwd, stdio: spec.stdio, graceMs: spec.graceMs })
102
+ const exe = argv[0] || ''
103
+ // 目录/删除类 shell(ensureDirs 的 New-Item / POSIX mkdir -p):插件用同一 seam 建骨架目录,
104
+ // mock 必须照做,否则 §16 会误判成"框架没建目录"。
105
+ const script = argv[argv.length - 1] || ''
106
+ if (/New-Item/.test(script)) {
107
+ const head = script.match(/-Path\s+([^|]*)/)
108
+ const raw = (head && head[1]) || ''
109
+ for (const p of raw.split(',').map((x) => x.trim().replace(/^'|'$/g, '').replace(/^"|"$/g, '').replace(/''/g, "'"))) if (p) mkdirSync(p, { recursive: true })
110
+ return spawnResult(0, '', '')
111
+ }
112
+ if (/^mkdir -p /.test(script)) { for (const p of script.replace(/^mkdir -p /, '').split(/\s+/)) { const q = p.replace(/^'|'$/g, ''); if (q) mkdirSync(q, { recursive: true }) } return spawnResult(0, '', '') }
113
+ if (/Remove-Item/.test(script)) { const m = script.match(/-LiteralPath\s+'((?:[^']|'')*)'/); if (m) rmSync(m[1].replace(/''/g, "'"), { force: true, recursive: true }); return spawnResult(0, '', '') }
114
+ const name = engineOfExe(exe) || (argv.length === 1 ? null : null)
115
+ // cli:用户给的命令,走"运行"分支
116
+ if (!name && /[\\/]cli[\\/]/.test(exe)) return runBehaviour(argv)
117
+ const d = MATH_ENGINES[name] || null
118
+ // 运行分支必须**先判**:run 的 argv 与版本探针**长度相同**([exe, script] vs [exe, --version]),
119
+ // 只有"末元素是落盘的 script.<ext>"能把两者分开(否则 run 会被当成版本探针,永远 exit 0)。
120
+ if (looksLikeRun(argv)) return runBehaviour(argv)
121
+ if (d && d.versionArgv && argv.length === 1 + d.versionArgv.length) {
122
+ const inst = fake.installed[name]
123
+ if (!inst) return spawnResult(1, '', 'not runnable')
124
+ return spawnResult(0, name + ' ' + inst + '\n', '')
125
+ }
126
+ if (d && d.licenseProbe && argv.length === 1 + d.licenseProbe.argv.length) {
127
+ const ok = !!fake.licensed[name]
128
+ if (d.licenseProbe.okWhen === 'trim-1') return spawnResult(0, ok ? '1\n' : '0\n', '')
129
+ if (d.licenseProbe.okWhen === 'exit-0') return spawnResult(ok ? 0 : 1, '', ok ? '' : 'license error')
130
+ return spawnResult(0, ok ? 'Licensed\n' : 'Unlicensed\n', '')
131
+ }
132
+ if (looksLikeRun(argv)) return runBehaviour(argv)
133
+ // 包探针(parse: 'pairs'):<name>:ok|...
134
+ const keys = Object.keys(fake.packages)
135
+ return spawnResult(0, keys.map((k) => k + ':' + (fake.packages[k] ? 'ok' : 'missing')).join('|') + '\n', '')
136
+ },
137
+ }
138
+ function runBehaviour(argv) {
139
+ if (fake.run === 'hang') {
140
+ let settled = false
141
+ let finish
142
+ const done = new Promise((resolve) => { finish = resolve })
143
+ return {
144
+ done,
145
+ collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
146
+ terminate() { if (settled) return; settled = true; fake.terminated++; finish({ exitCode: null, signal: 'SIGKILL' }) },
147
+ }
148
+ }
149
+ if (fake.run === 'fail3') return spawnResult(3, '', 'boom: computation failed\n')
150
+ if (fake.run === 'argerr') return spawnResult(2, '', "unknown option '-q'\n")
151
+ if (fake.run === 'big') return spawnResult(0, 'x'.repeat(200 * 1024), '')
152
+ return spawnResult(0, 'ok\n', '')
153
+ }
154
+
155
+ // ── host mock(v2 的 standing-mount 形状:一个插件实例,多会话) ────────────────────────────────
156
+ function makeHost() {
157
+ const listeners = {}
158
+ const toolRegs = []
159
+ const cmdRegs = []
160
+ const spawns = []
161
+ const ctx = {
162
+ get(name) {
163
+ if (name === 'subprocess') return (fake && fake.noSubprocess) ? undefined : subprocess
164
+ if (name === 'sandboxPolicy') return { resolve: () => ({ workspaceRoot: WS }), workspaceRoot: WS }
165
+ return undefined
166
+ },
167
+ on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
168
+ effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
169
+ logger: { info() {}, warn() {}, error() {} },
170
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
171
+ commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
172
+ timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
173
+ interval(cb, ms) { const h = setInterval(cb, Math.min(Number(ms) || 0, 25)); return () => clearInterval(h) },
174
+ subagents: {
175
+ list() { return ['spawn'] },
176
+ async startContinuable({ label, request, signal }) {
177
+ const childId = 'c' + (spawns.length + 1)
178
+ spawns.push({ label, request, childId })
179
+ return { childId }
180
+ },
181
+ async sendMessage() { return 'w' },
182
+ async followup() { return 'w' },
183
+ interrupt() {},
184
+ },
185
+ fs: {
186
+ async resolve(rel, opts) { const base = (opts && opts.cwd) || WS; const p = String(rel).replace(/\\/g, '/'); return /^([A-Za-z]:|\/)/.test(p) ? p : join(base, ...p.split('/')) },
187
+ async stat(t) { return existsSync(t) ? { type: 'file' } : undefined },
188
+ async readText(t) { return readFileSync(t, 'utf8') },
189
+ async writeText(t, content) { mkdirSync(dirname(t), { recursive: true }); writeFileSync(t, String(content), 'utf8') },
190
+ async listDir(t) { try { return readdirSync(t, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) } catch (e) { return [] } },
191
+ },
192
+ agents: { roots() { return [] } },
193
+ }
194
+ // fs 服务外面包一层计数器:`listDir` 的"读属性"(能力门闩)与"真调用"(列目录)分开计,
195
+ // 这样 §18 才能证明"宿主没有 listDir 时预设一次都没查过",而不是只看结果。
196
+ const fsBase = ctx.fs
197
+ ctx.fs = new Proxy(fsBase, {
198
+ get(t, p) {
199
+ if (p === 'listDir') {
200
+ fake.fsListDirAccess = (fake.fsListDirAccess || 0) + 1
201
+ const v = t.listDir
202
+ if (typeof v !== 'function') return v
203
+ return function () { fake.fsListDirCalls = (fake.fsListDirCalls || 0) + 1; return v.apply(t, arguments) }
204
+ }
205
+ return t[p]
206
+ },
207
+ })
208
+ return { ctx, toolRegs, cmdRegs, spawns, listeners, fs: fsBase }
209
+ }
210
+ function makeRoot(id) { return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, followup() {} } }
211
+
212
+ const H = makeHost()
213
+ const mod = await import(PLUGIN.href + '?t=' + Date.now())
214
+ ;(mod.default || mod).apply(H.ctx)
215
+ const ROOT = makeRoot('sess-mc-v2')
216
+ async function call(name, args, root) {
217
+ const spec = H.toolRegs.find((s) => s.name === name)
218
+ if (!spec) throw new Error('no tool ' + name)
219
+ return JSON.parse(await spec.execute(args || {}, { agent: root || ROOT }))
220
+ }
221
+ const specOf = (name) => H.toolRegs.find((s) => s.name === name)
222
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
223
+ const receiptOf = (runId) => JSON.parse(readIf(join(projRoot(), 'Computation', runId, 'receipt.json')) || '{}')
224
+
225
+ fake = freshFake()
226
+ await call('vibe_math_new_project', { name: PROJECT })
227
+ await call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2 })
228
+
229
+ // ── §1 六参数:默认值 / 显式归一化 / schema 面 ─────────────────────────────────────────────────
230
+ section('§1 六参数(默认值、归一化、schema 面、off/typed+shell 语义)')
231
+ {
232
+ const setup = await call('vibe_math_setup', {})
233
+ const names = setup.parameters.map((p) => p.name)
234
+ assert(MATH_PARAM_NAMES.every((k) => names.indexOf(k) !== -1), '★ vibe_math_setup 列出全部六个 math 参数')
235
+ const st = await call('vibe_math_status', {})
236
+ assert(MATH_PARAM_NAMES.every((k) => JSON.stringify(st.params[k]) === JSON.stringify(MATH_PARAM_DEFAULTS[k])), '★ 默认值与共享模块的 MATH_PARAM_DEFAULTS 逐字一致(' + JSON.stringify(MATH_PARAM_NAMES.map((k) => st.params[k])) + ')')
237
+ assert(st.params.mathEngines !== MATH_PARAM_DEFAULTS.mathEngines, '★ 默认数组是**拷贝**,不与模块共享同一对象(否则跨预设互相污染)')
238
+ const spec = specOf('math_computation')
239
+ assert(!!spec && spec.description === MATH_TOOL_DESCRIPTION, '★ 工具面注册了 math_computation,描述逐字等于 MATH_TOOL_DESCRIPTION')
240
+ const props = (spec.parameters && spec.parameters.properties) || {}
241
+ assert(['op', 'engine', 'mode', 'code', 'file', 'expr', 'packages', 'timeoutMs', 'cli', 'scope', 'dryRun', 'confirm'].every((k) => k in props), '工具参数 schema 是模块的闭合 schema(op/engine/mode/…/confirm)')
242
+ // 'false' 字符串等错类型:必须回退默认,绝不从 `else out[k]=v` 尾巴漏过
243
+ const bad = await call('vibe_math_set_params', { mathComputation: 'false', mathMode: 'false', mathEngines: 'false', mathTimeoutMs: 'false', mathPackages: 'false', mathInstallScope: 'false' })
244
+ assert(bad.ok === true, 'set_params 接受(错误类型的)六个 math 键而不抛错')
245
+ const after = (await call('vibe_math_status', {})).params
246
+ assert(after.mathComputation === 'auto' && after.mathMode === 'typed+shell' && after.mathInstallScope === 'user', '★★ 五个 enum/bool 型键的 \'false\' 字符串回退默认(' + JSON.stringify({ c: after.mathComputation, m: after.mathMode, s: after.mathInstallScope }) + ')')
247
+ assert(Array.isArray(after.mathEngines) && after.mathEngines.length === MATH_PARAM_DEFAULTS.mathEngines.length && after.mathPackages.length === 0, '★★ 两个数组键的 \'false\' 字符串回退默认(不把字符串当数组)')
248
+ assert(after.mathTimeoutMs === MATH_PARAM_DEFAULTS.mathTimeoutMs, '★★ mathTimeoutMs 的 \'false\' 回退默认(Number(\'false\')=NaN ⇒ 不留 NaN)')
249
+ const mixed = await call('vibe_math_set_params', { mathTimeoutMs: 0, mathEngines: ['nope', 'python', 'python'], mathComputation: 'bogus' })
250
+ assert(mixed.params.mathTimeoutMs === MATH_PARAM_DEFAULTS.mathTimeoutMs, 'mathTimeoutMs=0 回退默认(下界 1000 由模块决定)')
251
+ assert(JSON.stringify(mixed.params.mathEngines) === JSON.stringify(['python']), 'mathEngines 只保留已知引擎并去重(' + JSON.stringify(mixed.params.mathEngines) + ')')
252
+ assert(mixed.params.mathComputation === 'auto', '未知 mathComputation 回退 auto(不会静默变成 on/off)')
253
+ await call('vibe_math_set_params', { mathEngines: MATH_PARAM_DEFAULTS.mathEngines.slice() })
254
+ }
255
+
256
+ // ── §2 注册:双层 + 每预设一次 ────────────────────────────────────────────────────────────────
257
+ section('§2 注册(会话层 + apply 层;每预设只注册一次)')
258
+ {
259
+ assert(H.toolRegs.filter((s) => s.name === 'math_computation').length === 1, '★ math_computation 在工具面只注册一次(不是每会话一次)')
260
+ fake.installed = { python: '3.11.4' }
261
+ const r = await call('math_computation', { op: 'probe', packages: ['refresh'] })
262
+ assert(r.ok === true && r.op === 'probe' && (r.engines || []).length === 1, '★★ 经 apply 层工具面调用能路由到**会话层** handler(两层都在)(got ' + JSON.stringify({ ok: r.ok, op: r.op, engines: (r.engines || []).map((e) => e.name) }) + ')')
263
+ assert(H.cmdRegs.length === 1 && String(H.cmdRegs[0].input.hint).indexOf('math') === -1, '★ 未新增 /vibe math 子命令:hint 不含 math(P1 不动命令面)')
264
+ }
265
+
266
+ // ── §3 probe:命中 / 未命中 ──────────────────────────────────────────────────────────────────
267
+ section('§3 op=probe:命中与未命中')
268
+ {
269
+ fake = freshFake()
270
+ fake.installed = { python: '3.11.4' }
271
+ const hit = await call('math_computation', { op: 'probe', packages: ['refresh'] })
272
+ const eng = (hit.engines || []).find((e) => e.name === 'python')
273
+ assert(hit.ok === true && hit.available === true && !!eng && eng.version === '3.11.4' && !!eng.path, '★ probe 命中:available=true + path/version(' + JSON.stringify(hit) + ')')
274
+ fake = freshFake()
275
+ const miss = await call('math_computation', { op: 'probe', packages: ['refresh'] })
276
+ assert(miss.ok === false && miss.code === 'MATH_ENGINE_NOT_FOUND', '★ 没有引擎 ⇒ MATH_ENGINE_NOT_FOUND')
277
+ assert(!!miss.next && miss.next.kind === 'user-install', '★★ 未命中必须带可执行的下一步:next.kind=user-install')
278
+ const perOs = (miss.next && miss.next.perOs) || {}
279
+ assert(['windows', 'macos', 'linux'].every((k) => typeof perOs[k] === 'string' && perOs[k].length > 0), '★★ user-install 指引带 perOs 三平台的官方命令(实测 ' + JSON.stringify(perOs) + ')')
280
+ assert(!!miss.next && miss.next.suggestedCommand === miss.next.perOs[miss.next.platform], '★★ suggestedCommand === perOs[platform](platform=' + (miss.next && miss.next.platform) + ')')
281
+ assert(!!miss.next && (miss.next.packageManagerAvailable === false ? miss.next.command === '' : typeof miss.next.command === 'string'), '★★★ 包管理器不可用时**绝不**给出跑不了的命令(command 必须是空串),可用时才是字符串(实测 ' + JSON.stringify(miss.next && { mgr: miss.next.packageManager, avail: miss.next.packageManagerAvailable, command: miss.next.command }) + ')')
282
+ assert(!miss.next || miss.next.packageManagerAvailable !== false || (String(miss.next.note || '').length > 0 && String(miss.next.suggestedCommand || '').length > 0), '★★ 命令被撤下时必须给出替代出口:note + perOs 里的官方命令(vendorUrl 只有商业引擎才有,可为 null;实测 ' + JSON.stringify(miss.next && { note: String(miss.next.note || '').slice(0, 40), suggested: miss.next.suggestedCommand }) + ')')
283
+ // 另一半:包管理器**存在**时给出可执行命令(command === suggestedCommand)
284
+ // A1:旧写法在「本平台模板无包管理器前缀」时 `assert(true, …跳过…)` 会给 passed +1 却什么都没验。
285
+ // 这里把被探测引擎钉到 python(其 userInstall 模板三平台都带管理器前缀 winget/brew/apt)⇒
286
+ // **强制**走完「管理器存在 ⇒ 给出可执行命令」这一半契约(平台差异不再能吃掉断言)。
287
+ await call('vibe_math_set_params', { mathEngines: ['python'] })
288
+ const missMgr = await call('math_computation', { op: 'probe', packages: ['refresh'] })
289
+ const mgrName = missMgr.next && missMgr.next.packageManager
290
+ assert(!!mgrName, '★★★ A1:python 的 userInstall 模板在三平台都带包管理器前缀(实测 ' + JSON.stringify(mgrName) + ')')
291
+ // F1(守卫验证 HIGH):flag 必须在「管理器存在 ⇒ 真的算出可执行命令」这一支里才置位;
292
+ // 只是执行到这一行不算跑过该契约(旧写法在 `if (mgrName)` 之外,任何执行都会置位 ⇒ 装饰性守卫)。
293
+ if (mgrName) {
294
+ fake.cliCommands = {}
295
+ fake.cliCommands[mgrName] = '/fake/' + mgrName + '.exe'
296
+ const withMgr = await call('math_computation', { op: 'probe', packages: ['refresh'] })
297
+ const okCmd = !!(withMgr.next && withMgr.next.packageManagerAvailable === true && typeof withMgr.next.command === 'string' && withMgr.next.command.length > 0 && withMgr.next.command === withMgr.next.suggestedCommand)
298
+ assert(okCmd, '★★★ 包管理器存在时给出**可执行**命令(command === suggestedCommand,实测 ' + JSON.stringify(withMgr.next && { mgr: withMgr.next.packageManager, avail: withMgr.next.packageManagerAvailable, command: withMgr.next.command }) + ')')
299
+ if (okCmd) mgrBranchExercised = true
300
+ }
301
+ // 另一半:没有管理器前缀的模板(vendor 下载)仍必须给官方命令、且不得谎称可运行
302
+ await call('vibe_math_set_params', { mathEngines: ['wolfram'] })
303
+ const vendor = await call('math_computation', { op: 'probe', packages: ['refresh'] })
304
+ assert(!!vendor.next && vendor.next.packageManager === null && typeof vendor.next.suggestedCommand === 'string' && vendor.next.suggestedCommand.length > 0, '★★ A1:无管理器前缀的模板 packageManager=null 且仍给官方命令(platform=' + (vendor.next && vendor.next.platform) + ')')
305
+ await call('vibe_math_set_params', { mathEngines: ['python', 'r', 'octave', 'julia', 'matlab', 'maple', 'wolfram', 'cli'] })
306
+ }
307
+
308
+ // ── §4 缺包:只报告、不执行 ──────────────────────────────────────────────────────────────────
309
+ section('§4 缺包(MATH_MISSING_PACKAGES + agent-install,且不执行脚本)')
310
+ {
311
+ fake = freshFake()
312
+ fake.installed = { python: '3.11.4' }
313
+ fake.packages = { numpy: 'ok', sympy: null }
314
+ const r = await call('math_computation', { op: 'run', mode: 'code', code: 'print(1)', packages: ['numpy', 'sympy'] })
315
+ assert(r.ok === false && r.code === 'MATH_MISSING_PACKAGES' && JSON.stringify(r.missing) === JSON.stringify(['sympy']), '★ 缺包 ⇒ MATH_MISSING_PACKAGES{missing:[sympy]}(' + JSON.stringify(r.missing) + ')')
316
+ assert(!!r.next && r.next.kind === 'agent-install' && r.next.dryRun === true, '★★ next.kind=agent-install + dryRun(先计划再确认)')
317
+ assert(fake.spawns.filter((s) => looksLikeRun(s.argv)).length === 0, '★★ 缺包时**没有执行**脚本(假引擎运行调用计数 0)')
318
+ }
319
+
320
+ // ── §5 商业引擎无许可 ────────────────────────────────────────────────────────────────────────
321
+ section('§5 商业引擎无许可(只给厂商指引、不出安装计划)')
322
+ {
323
+ fake = freshFake()
324
+ fake.installed = { maple: '2024' }
325
+ fake.licensed = { maple: false }
326
+ const r = await call('math_computation', { op: 'run', engine: 'maple', mode: 'code', code: 'x' })
327
+ assert(r.ok === false && r.code === 'MATH_ENGINE_LICENSE_REQUIRED', '★ 已装但无许可 ⇒ MATH_ENGINE_LICENSE_REQUIRED')
328
+ assert(!!r.next && r.next.kind === 'vendor' && String(r.next.url || '').indexOf('http') === 0, '★★ next.kind=vendor + 厂商链接')
329
+ const inst = await call('math_computation', { op: 'install', engine: 'maple', packages: ['x'], dryRun: true })
330
+ assert(inst.ok === false && inst.code === 'MATH_REFUSED' && !!inst.next && inst.next.kind === 'vendor', '★★★ 商业引擎的 install 一律 REFUSED{vendor}(永不代装)')
331
+ assert(fake.spawns.filter((s) => looksLikeRun(s.argv)).length === 0, '商业引擎没有产生安装/运行调用')
332
+ }
333
+
334
+ // ── §6 超时:kill + MATH_TIMEOUT ─────────────────────────────────────────────────────────────
335
+ section('§6 超时(mathTimeoutMs 到点主动 terminate)')
336
+ {
337
+ fake = freshFake()
338
+ fake.installed = { python: '3.11.4' }
339
+ fake.run = 'hang'
340
+ await call('vibe_math_set_params', { mathTimeoutMs: 1000 })
341
+ const t0 = Date.now()
342
+ const r = await call('math_computation', { op: 'run', mode: 'code', code: 'while True: pass' })
343
+ const dt = Date.now() - t0
344
+ assert(r.ok === false && r.code === 'MATH_TIMEOUT' && r.timedOut === true, '★ 挂起引擎 ⇒ MATH_TIMEOUT + timedOut:true(got ' + JSON.stringify({ok:r.ok,code:r.code,timedOut:r.timedOut,message:String(r.message||'').slice(0,120)}) + ')')
345
+ assert(fake.terminated >= 1, '★★ 超时**主动调用** handle.terminate()(终止计数 ' + fake.terminated + ')')
346
+ assert(dt >= 900 && dt <= 4000, '耗时 ≈ timeoutMs(' + dt + 'ms)')
347
+ await call('vibe_math_set_params', { mathTimeoutMs: 60000 })
348
+ }
349
+
350
+ // ── §7 非零退出 ──────────────────────────────────────────────────────────────────────────────
351
+ section('§7 非零退出(MATH_NONZERO_EXIT + stderr 尾部)')
352
+ {
353
+ fake = freshFake()
354
+ fake.installed = { python: '3.11.4' }
355
+ fake.run = 'fail3'
356
+ const r = await call('math_computation', { op: 'run', mode: 'code', code: 'boom' })
357
+ assert(r.ok === false && r.code === 'MATH_NONZERO_EXIT' && r.exit === 3, '★ exit=3 ⇒ MATH_NONZERO_EXIT{exit:3}(got ' + JSON.stringify({ok:r.ok,code:r.code,exit:r.exit,message:String(r.message||'').slice(0,120)}) + ')')
358
+ assert(/boom/.test(String(r.stderr || '')), 'stderr 尾部原样返回(' + JSON.stringify(String(r.stderr).trim()) + ')')
359
+ }
360
+
361
+ // ── §8 超大输出:返回体截断 + 完整版落盘 ──────────────────────────────────────────────────────
362
+ section('§8 超大输出(64KB 返回截断、完整版落盘)')
363
+ {
364
+ fake = freshFake()
365
+ fake.installed = { python: '3.11.4' }
366
+ fake.run = 'big'
367
+ const r = await call('math_computation', { op: 'run', mode: 'code', code: 'print("x"*200000)' })
368
+ assert(r.ok === true && r.truncated && r.truncated.stdout === true, '★ 200KB 输出 ⇒ ok:true + truncated.stdout:true(got ' + JSON.stringify({ok:r.ok,code:r.code,truncated:r.truncated,len:String(r.stdout||'').length}) + ')')
369
+ assert(Array.isArray(r.warnings) && r.warnings.some((w) => w.code === 'OUTPUT_TRUNCATED'), '★★ warnings 里给出 OUTPUT_TRUNCATED')
370
+ assert(String(r.stdout || '').length <= 64 * 1024, '返回体的 stdout 被截到 ≤64KB(' + String(r.stdout).length + ')')
371
+ const full = join(projRoot(), r.receipt.dir, 'stdout.txt')
372
+ assert(existsSync(full) && statSync(full).size > 64 * 1024, '★★★ 落盘的 stdout.txt 是**完整** 200KB(' + (existsSync(full) ? statSync(full).size : 'missing') + ' bytes)——说明 seam 没有被 64KB stdio 上限截断')
373
+ }
374
+
375
+ // ── §9 路径守卫 + cwd ────────────────────────────────────────────────────────────────────────
376
+ section('§9 路径守卫与 cwd')
377
+ {
378
+ fake = freshFake()
379
+ fake.installed = { python: '3.11.4' }
380
+ const esc = await call('math_computation', { op: 'run', mode: 'file', file: '../outside.py' })
381
+ assert(esc.ok === false && esc.code === 'MATH_REFUSED', '★★ 项目根之外的 file ⇒ MATH_REFUSED(' + esc.code + ')')
382
+ const abs = await call('math_computation', { op: 'run', mode: 'file', file: join(WS, 'x.py') })
383
+ assert(abs.ok === false && abs.code === 'MATH_REFUSED', '★ 绝对路径同样拒绝')
384
+ fake.spawns.length = 0
385
+ writeFileSync(join(projRoot(), 'ok.py'), 'print(1)\n', 'utf8')
386
+ const ok = await call('math_computation', { op: 'run', mode: 'file', file: 'ok.py' })
387
+ assert(ok.ok === true, '项目内的 file 正常执行')
388
+ const runSpawn = fake.spawns.filter((s) => looksLikeRun(s.argv)).slice(-1)[0]
389
+ assert(!!runSpawn && String(runSpawn.cwd).replace(/\\/g, '/') === projRoot().replace(/\\/g, '/'), '★★ spawn 的 cwd === 项目根(' + (runSpawn && runSpawn.cwd) + ')')
390
+ assert(!!runSpawn && runSpawn.argv[runSpawn.argv.length - 1].indexOf('script.py') !== -1, '★ mode=file 的 argv 末元素是落盘脚本的绝对路径(可照抄复跑)')
391
+ }
392
+
393
+ // ── §10 cli 默认开启 + 两种禁用 ──────────────────────────────────────────────────────────────
394
+ section('§10 cli 默认开启(mathMode / mathEngines 两条禁用路径)')
395
+ {
396
+ fake = freshFake()
397
+ fake.cliCommands = { echo: '/fake/cli/echo' }
398
+ fake.spawns.length = 0
399
+ const ok = await call('math_computation', { op: 'run', engine: 'cli', mode: 'code', code: 'MATHCLI', cli: { command: 'echo', argv: ['MATHCLI'] } })
400
+ assert(ok.ok === true && /^cli/.test(String(ok.engine)), '★ 默认档 cli 能跑通(engine=cli 或 cli:<command>)(got ' + JSON.stringify(ok).slice(0, 400) + ')')
401
+ assert(!!ok.receipt && existsSync(join(projRoot(), ok.receipt.json)), '★ cli 也走同一套回执管线(receipt.json 落盘)')
402
+ const cliSpawn = fake.spawns.filter((s) => s.argv[0] === '/fake/cli/echo').slice(-1)[0]
403
+ // round-7 (finding 4): mode:'code' appends the ARCHIVED script path (the caller cannot know the
404
+ // runId), so the executed argv is [command, ...cli.argv, <script>]; the receipt keeps the user argv.
405
+ assert(!!cliSpawn && cliSpawn.argv[0] === '/fake/cli/echo' && cliSpawn.argv[1] === 'MATHCLI' && /script\.txt$/.test(String(cliSpawn.argv[2] || '')), '★★ cli 的 argv === [<command>, ...<cli.argv>, <归档脚本>](不是 shell 拼接)')
406
+ assert(ok.cliScriptAppended === true, '★★ cli 回执标注 scriptAppended(mode:code 真的把脚本传给了命令)')
407
+ await call('vibe_math_set_params', { mathMode: 'typed' })
408
+ fake.spawns.length = 0
409
+ const pol = await call('math_computation', { op: 'run', engine: 'cli', mode: 'code', code: 'MATHCLI', cli: { command: 'echo', argv: ['MATHCLI'] } })
410
+ assert(pol.ok === false && pol.code === 'MATH_REFUSED' && /policy|mathMode=typed/.test(((pol.next && pol.next.reason) || '') + ' ' + String(pol.message || '')), '★★ mathMode=typed ⇒ cli 被策略拒绝(MATH_REFUSED{polic…};module 在 validateMathArgs 里拦下,故 message 而非 next.reason 承载 policy 语义)(got ' + JSON.stringify(pol).slice(0, 220) + ')')
411
+ assert(fake.spawns.length === 0, '★ 策略禁用下假引擎**零调用**')
412
+ await call('vibe_math_set_params', { mathMode: 'typed+shell', mathEngines: MATH_PARAM_DEFAULTS.mathEngines.filter((e) => e !== 'cli') })
413
+ fake.spawns.length = 0
414
+ const out = await call('math_computation', { op: 'run', engine: 'cli', mode: 'code', code: 'MATHCLI', cli: { command: 'echo', argv: ['MATHCLI'] } })
415
+ assert(out.ok === false && out.code === 'MATH_REFUSED', '★★ 从 mathEngines 移除 cli ⇒ 同样 REFUSED')
416
+ assert(fake.spawns.length === 0, '★ 第二种禁用路径同样零调用')
417
+ await call('vibe_math_set_params', { mathEngines: MATH_PARAM_DEFAULTS.mathEngines.slice() })
418
+ }
419
+
420
+ // ── §11/§12 argv 回显 + bad-argv 不折叠 ──────────────────────────────────────────────────────
421
+ section('§11/§12 argv 回显(receipt + 返回外壳)与 MATH_ENGINE_BAD_ARGV')
422
+ {
423
+ fake = freshFake()
424
+ fake.installed = { python: '3.11.4' }
425
+ fake.spawns.length = 0
426
+ const r = await call('math_computation', { op: 'run', mode: 'code', code: 'print("echo")' })
427
+ const actual = fake.spawns.filter((s) => looksLikeRun(s.argv)).slice(-1)[0].argv
428
+ assert(JSON.stringify(r.argv) === JSON.stringify(actual), '★★ 返回外壳的 argv 与**实际执行**的 argv 逐元素相等')
429
+ const rec = receiptOf(r.receipt.dir.split('/').pop())
430
+ assert(JSON.stringify(rec.argv) === JSON.stringify(actual), '★★★ receipt.json 里的 argv 也逐元素相等(回执可照抄复跑);receipt.cwd === 项目根')
431
+ assert(String(rec.cwd).replace(/\\/g, '/') === projRoot().replace(/\\/g, '/'), '★★ receipt 记录 cwd = 项目根')
432
+ // bad-argv:商业引擎(有许可)× 选项错 ⇒ 可执行建议;普通 exit 3 ⇒ 仍是 NONZERO_EXIT
433
+ fake = freshFake()
434
+ fake.installed = { maple: '2024' }
435
+ fake.licensed = { maple: true }
436
+ fake.run = 'argerr'
437
+ const bad = await call('math_computation', { op: 'run', engine: 'maple', mode: 'code', code: 'x' })
438
+ assert(bad.ok === false && bad.code === 'MATH_ENGINE_BAD_ARGV', '★★ 选项/用法错 ⇒ MATH_ENGINE_BAD_ARGV(不是裸 NONZERO_EXIT)')
439
+ assert(!!bad.next && bad.next.kind === 'engine-override' && JSON.stringify(bad.next.argv) === JSON.stringify(bad.argv), '★★ next.kind=engine-override + next.argv === 实际 argv')
440
+ assert(/mathEngineOverride/.test(String(bad.message || '')), '★ message 指向 mathEngineOverride')
441
+ const badRec = receiptOf(bad.receipt.dir.split('/').pop())
442
+ assert(JSON.stringify(badRec.argv) === JSON.stringify(bad.argv), '★ 失败时同样回显 argv(回执里)')
443
+ fake.run = 'fail3'
444
+ const plain = await call('math_computation', { op: 'run', engine: 'maple', mode: 'code', code: 'y' })
445
+ assert(plain.ok === false && plain.code === 'MATH_NONZERO_EXIT', '对照:普通 exit 3 仍是 MATH_NONZERO_EXIT')
446
+ }
447
+
448
+ // ── §13 安装两步 + 作用域 ────────────────────────────────────────────────────────────────────
449
+ section('§13 op=install:plan → confirm 两步、作用域不记忆、审计记录')
450
+ {
451
+ fake = freshFake()
452
+ fake.installed = { python: '3.11.4' }
453
+ fake.spawns.length = 0
454
+ const plan = await call('math_computation', { op: 'install', engine: 'python', packages: ['sympy'], dryRun: true })
455
+ assert(plan.ok === true && !!plan.plan && Array.isArray(plan.plan.commands) && plan.plan.commands.length === 1 && typeof plan.planToken === 'string' && plan.planToken.length > 8, '★ dryRun 返回 plan.commands + planToken(' + String(plan.planToken).slice(0, 8) + '…)')
456
+ assert(plan.plan.commands[0].argv.indexOf('--user') !== -1, '★★ 默认 mathInstallScope=user ⇒ 命令含 --user')
457
+ assert(fake.spawns.length === 0, '★★★ dryRun **零调用**(永不自动安装)')
458
+ const wrong = await call('math_computation', { op: 'install', engine: 'python', packages: ['sympy'], confirm: 'deadbeef' })
459
+ assert(wrong.ok === false && wrong.code === 'MATH_REFUSED', '★ token 不匹配 ⇒ MATH_REFUSED')
460
+ assert(fake.spawns.length === 0, '★ token 不匹配时零调用(不会误装)')
461
+ const done = await call('math_computation', { op: 'install', engine: 'python', packages: ['sympy'], confirm: plan.planToken })
462
+ assert(done.ok === true && done.executed === true && fake.spawns.length === 1, '★★ 正确 token ⇒ 执行一次(调用数 ' + fake.spawns.length + ')')
463
+ const auditPath = join(projRoot(), 'Computation', 'installs', plan.planToken + '.json')
464
+ const audit = JSON.parse(readIf(auditPath) || '{}')
465
+ assert(existsSync(auditPath) && audit.scope === 'user' && audit.manager === 'pip' && !!audit.rollback && Array.isArray(audit.rollback.commands) && audit.network === 'not-enforced-by-plugin', '★★★ 审计记录含 scope/manager/rollback.commands/network(' + JSON.stringify(Object.keys(audit)) + ')')
466
+ // system:默认绝不出现;显式给出才出现,且**不记忆**
467
+ fake.spawns.length = 0
468
+ const sys = await call('math_computation', { op: 'install', engine: 'python', packages: ['sympy'], scope: 'system', dryRun: true })
469
+ assert(sys.ok === true && sys.plan.scope === 'system' && sys.plan.commands[0].argv.indexOf('--user') === -1, '★★ 显式 scope=system ⇒ 命令不含 --user')
470
+ const again = await call('math_computation', { op: 'install', engine: 'python', packages: ['sympy'], dryRun: true })
471
+ assert(again.plan.scope === 'user' && again.plan.commands[0].argv.indexOf('--user') !== -1, '★★★ system **不被记住**:下一次不带 scope 仍是 user')
472
+ }
473
+
474
+ // ── §14 回执幂等(同输入 ⇒ 同 runId,无墙钟) ─────────────────────────────────────────────────
475
+ section('§14 回执幂等(同输入 ⇒ 同 runId)')
476
+ {
477
+ fake = freshFake()
478
+ fake.installed = { python: '3.11.4' }
479
+ const a = await call('math_computation', { op: 'run', mode: 'code', code: 'print("same")' })
480
+ const b = await call('math_computation', { op: 'run', mode: 'code', code: 'print("same")' })
481
+ // P2a:归档是**追加式**的——同 runId 的第 2 次运行落 attempts/2/,所以 dir 不再相等;
482
+ // 语义不变:同输入 ⇒ 同一 archive id(baseRunDir),无墙钟;已有归档永不覆盖。
483
+ assert(a.baseRunDir === b.baseRunDir && b.attempt === 2 && b.attemptDir !== a.attemptDir,
484
+ '★★ 同输入两次运行命中同一 archive id(baseRunDir 相同、第 2 次进 attempts/2,无墙钟)(' + JSON.stringify({ base: a.baseRunDir, a1: a.attempt, a2: b.attempt, d1: a.attemptDir, d2: b.attemptDir }) + ')')
485
+ // 注意:返回体外壳里的 receipt 是**引用**(dir/json/md/sha256),scriptHash 要从回执文件里读。
486
+ const recA = JSON.parse(readIf(join(projRoot(), a.attemptDir, 'receipt.json')) || '{}')
487
+ assert(a.scriptHash === b.scriptHash && recA.scriptHash === a.scriptHash, '★★ 同代码 ⇒ scriptHash 相同(返回体与回执文件都给,' + String(a.scriptHash).slice(0, 12) + '…)')
488
+ const firstOut = join(projRoot(), a.attemptDir, 'stdout.txt')
489
+ assert(a.attemptDir !== b.attemptDir && existsSync(firstOut) && readFileSync(firstOut, 'utf8') === 'ok\n',
490
+ '★★ 追加式归档:第 1 次 attempt 的文件**未被改写**(stdout.txt 仍是首次内容)')
491
+ assert(/^vibe-math-v2-/.test(String(a.baseRunDir).replace('Computation/', '')), '★ runId 前缀是 designator(vibe-math-v2)')
492
+ const rec = receiptOf(a.baseRunDir.split('/').pop())
493
+ assert(rec.preset === 'vibe-math-v2' && rec.determinism && rec.determinism.noWallClockInId === true, '★★ 回执 preset=vibe-math-v2,且声明 runId 无墙钟')
494
+ }
495
+
496
+ // ── §14b P2a:scriptPath/scriptHash(code)、编辑→重跑(file)⇒ scriptChanged + 新 attempt ─────
497
+ section('§14b P2a:scriptPath/scriptHash、mode:file 的编辑→重跑(追加 attempt + scriptChanged 告警)')
498
+ {
499
+ fake = freshFake()
500
+ fake.installed = { python: '3.11.4' }
501
+ const code = await call('math_computation', { op: 'run', mode: 'code', code: 'print("p2a")' })
502
+ const rec = JSON.parse(readIf(join(projRoot(), code.attemptDir, 'receipt.json')) || '{}')
503
+ assert(typeof code.scriptPath === 'string' && /script\.py$/.test(code.scriptPath), '★ P2a:mode=code 返回体含 scriptPath(' + code.scriptPath + ')')
504
+ assert(typeof code.scriptHash === 'string' && code.scriptHash.length === 64 && code.scriptHash === rec.scriptHash, '★★ P2a:返回体与回执的 scriptHash 一致(64 hex)')
505
+ assert(existsSync(join(projRoot(), code.scriptPath)), '★ P2a:scriptPath 指向的回执原件真的在盘上(可用普通文件工具编辑)')
506
+ // mode:file:改内容后重跑 ⇒ 同一 archive id 的新 attempt + scriptChanged 告警
507
+ writeFileSync(join(projRoot(), 'flow.py'), 'print("v1")\n', 'utf8')
508
+ const first = await call('math_computation', { op: 'run', mode: 'file', file: 'flow.py' })
509
+ writeFileSync(join(projRoot(), 'flow.py'), 'print("v2-edited")\n', 'utf8')
510
+ const second = await call('math_computation', { op: 'run', mode: 'file', file: 'flow.py' })
511
+ assert(second.ok === true && second.baseRunDir === first.baseRunDir && second.attemptDir !== first.attemptDir,
512
+ '★★ P2a:mode=file 按**路径**归属 archive id,编辑后重跑落同一 id 的新 attempt(' + JSON.stringify({ base: second.baseRunDir, first: first.attemptDir, second: second.attemptDir }) + ')')
513
+ assert(second.scriptChanged === true, '★★★ P2a:内容变了 ⇒ scriptChanged:true')
514
+ assert(Array.isArray(second.warnings) && second.warnings.some((w) => w.code === 'SCRIPT_CHANGED_SINCE_LAST_RECEIPT'),
515
+ '★★★ P2a:warnings 含 SCRIPT_CHANGED_SINCE_LAST_RECEIPT(旧回执不代表新代码)')
516
+ assert(second.scriptHash !== first.scriptHash, '★ 两次的 scriptHash 不同(分别对应编辑前后)')
517
+ }
518
+
519
+ // ── §15 提示词面:可用性行(typed+shell / typed / off)+ persona 两块 ──────────────────────────
520
+ section('§15 提示词面:可用性行三档 + persona 两块')
521
+ {
522
+ const promptText = () => H.spawns.map((s) => (s.request && s.request.prompt && s.request.prompt[0] && s.request.prompt[0].text) || '').join('\n')
523
+ fake = freshFake()
524
+ fake.installed = { python: '3.11.4' }
525
+ await call('vibe_math_set_params', { mathComputation: 'auto', mathMode: 'typed+shell' })
526
+ await call('vibe_math_add_problem', { id: 'pq1', description: '提示词面用例' })
527
+ await call('vibe_math_start', {})
528
+ for (let i = 0; i < 40 && H.spawns.length === 0; i++) await sleep(100)
529
+ await call('vibe_math_pause', {})
530
+ const shellMode = promptText()
531
+ assert(/math_computation/.test(shellMode), '★ 工作提示词里出现 math_computation 可用性行')
532
+ assert(/先 probe 再 run/.test(shellMode), '★★ 可用性行含"先 probe 再 run"')
533
+ assert(/未经工具归档/.test(shellMode), '★★ typed+shell 档含 shell 兜底标注(未经工具归档)')
534
+ assert(shellMode.indexOf(MATH_SUBSTITUTION_RULE_LINE) !== -1, '★★★ A 项:注入的可用性行含「替代必须声明」规则(工作提示词里逐字可见)')
535
+ const toolDesc = String((specOf('math_computation') || {}).description || '')
536
+ assert(toolDesc.indexOf(MATH_SUBSTITUTION_RULE_LINE) !== -1 || /替代方案若改变精确性|替代必须声明/.test(toolDesc), '★★★ A 项:工具描述写明「替代改变精确性/强度必须声明」(逐字或同义重述,实测片段:' + JSON.stringify(toolDesc.slice(Math.max(0, toolDesc.indexOf('替代')), toolDesc.indexOf('替代') + 46)) + ')')
537
+ // typed:不得出现 shell 兜底句
538
+ H.spawns.length = 0
539
+ await call('vibe_math_set_params', { mathMode: 'typed' })
540
+ await call('vibe_math_abort', {})
541
+ await call('vibe_math_start', {})
542
+ for (let i = 0; i < 40 && H.spawns.length === 0; i++) await sleep(100)
543
+ await call('vibe_math_pause', {})
544
+ const typedMode = promptText()
545
+ assert(/先 probe 再 run/.test(typedMode), 'typed 档仍注入可用性行(只是不提 shell)')
546
+ assert(!/未经工具归档/.test(typedMode), '★★★ mathMode=typed 时**不得**出现 shell 兜底句(守卫要的正是这条)')
547
+ // off:零提及
548
+ H.spawns.length = 0
549
+ await call('vibe_math_set_params', { mathComputation: 'off' })
550
+ await call('vibe_math_abort', {})
551
+ await call('vibe_math_start', {})
552
+ for (let i = 0; i < 40 && H.spawns.length === 0; i++) await sleep(100)
553
+ await call('vibe_math_pause', {})
554
+ const offMode = promptText()
555
+ assert(offMode.length > 0 && !/math_computation/.test(offMode), '★★★ mathComputation=off ⇒ 提示词**零提及**(真 no-op)')
556
+ const offCall = await call('math_computation', { op: 'probe' })
557
+ assert(offCall.ok === false && offCall.code === 'MATH_NOT_AVAILABLE', '★ off 档调用工具 ⇒ MATH_NOT_AVAILABLE{next:enable}')
558
+ assert(!!offCall.next && offCall.next.kind === 'enable' && offCall.next.param === 'mathComputation', '★ 并给出开启指引(不会静默失败)')
559
+ await call('vibe_math_set_params', { mathComputation: 'auto', mathMode: 'typed+shell' })
560
+ // persona:两个文本块都要有工具行
561
+ const yml = readFileSync(join(HERE, '..', 'vibe-math-v2', 'agent.cordis.yml'), 'utf8')
562
+ const blocks = yml.split(/\n\s*(?:prefix|text):\s*\|/)
563
+ const hits = blocks.filter((b) => b.indexOf('math_computation') !== -1).length
564
+ assert(hits >= 2, '★★★ persona 的 prefix 与 text **两个**块都含 math_computation 工具行(命中 ' + hits + ' 块)')
565
+ assert(yml.indexOf(MATH_PERSONA_TOOL_LINE) !== -1, '★★★ persona 工具行与共享模块的 MATH_PERSONA_TOOL_LINE 逐字一致(四套同一行)')
566
+ // P2a:两个块都要含「归档→编辑→重跑」规则,且文本与模块常量逐字一致(不手抄)
567
+ const wfBlocks = yml.split(/\n\s*(?:prefix|text):\s*\|/).filter((b) => b.indexOf(MATH_ARCHIVE_WORKFLOW_LINE) !== -1).length
568
+ assert(wfBlocks >= 2, '★★★ P2a:persona 两个文本块都含 MATH_ARCHIVE_WORKFLOW_LINE(命中 ' + wfBlocks + ' 块)')
569
+ assert((yml.match(/scriptChanged/g) || []).length >= 2 && (yml.match(/mode:'file'|mode: 'file'/g) || []).length >= 2, '★★ P2a:yml 里 scriptChanged 与 mode:file 各出现两次(两个块各一次)')
570
+ // A 项:替代必须声明(诚实性)。**追加**语义:含这条规则的块里,原有工具行与归档工作流行都必须在。
571
+ const subBlocks = blocks.filter((b) => b.indexOf(MATH_SUBSTITUTION_RULE_LINE) !== -1)
572
+ assert(subBlocks.length >= 2, '★★★ A 项:persona 两个文本块都含「替代必须声明」规则(命中 ' + subBlocks.length + ' 块)')
573
+ assert(subBlocks.every((b) => b.indexOf(MATH_PERSONA_TOOL_LINE) !== -1 && b.indexOf(MATH_ARCHIVE_WORKFLOW_LINE) !== -1), '★★★ A 项:是**追加**不是替换——含新规则的块里原有工具行/归档工作流行都还在')
574
+ }
575
+
576
+ // ── §16 ensureDirs 含 Computation ────────────────────────────────────────────────────────────
577
+ section('§16 ensureDirs:项目骨架含 Computation/')
578
+ {
579
+ await call('vibe_math_new_project', { name: PROJECT })
580
+ const need = ['Computation', 'Formal', 'Verified/Lean', 'VibeMath_State'].map((d) => join(projRoot(), d))
581
+ const missing = need.filter((p) => !existsSync(p))
582
+ assert(missing.length === 0, '★★ 项目骨架含 Computation/(缺失:' + missing.join(',') + ')|最近 shell: ' + JSON.stringify(fake.spawns.filter((s) => /New-Item|mkdir/.test(s.argv.join(' '))).slice(-1)) + ')')
583
+ assert(existsSync(join(projRoot(), 'Computation', 'installs')), '★ 安装审计的落点 Computation/installs/ 也存在(§13 已写入审计记录)')
584
+ }
585
+
586
+ // ── §17 跨会话隔离(审计 C#2):同一插件实例、两个会话并发调用 ─────────────────────────────────
587
+ // 要害:math 调用里所有会话相关的东西(params/projectRoot/writeText/spawn)必须来自**发起调用的那个会话**。
588
+ // 若实现里存在一个"当前 math 调用会话"的单槽,A 在 await 期间被 B 覆盖,A 就会用 B 的引擎/模式/根目录。
589
+ // 这里用不同 mathEngines/mathMode 的两个会话 + Promise.all + 有延迟的假引擎把交错逼出来。
590
+ section('§17 跨会话隔离:两个会话并发调用 math_computation(各用各的参数)')
591
+ {
592
+ fake = freshFake()
593
+ fake.installed = { python: '3.11.4', r: '4.3.2' }
594
+ fake.spawnDelayMs = 40 // 每次 spawn 至少 40ms ⇒ 两次调用在 await 期间真的交错
595
+ const RA = makeRoot('sess-iso-A')
596
+ const RB = makeRoot('sess-iso-B')
597
+ const projA = join(WS, 'VibeMath', 'Projects', 'isoA')
598
+ const projB = join(WS, 'VibeMath', 'Projects', 'isoB')
599
+ await call('vibe_math_new_project', { name: 'isoA' }, RA)
600
+ await call('vibe_math_new_project', { name: 'isoB' }, RB)
601
+ await call('vibe_math_set_params', { mathEngines: ['python'], mathMode: 'typed+shell' }, RA)
602
+ await call('vibe_math_set_params', { mathEngines: ['r'], mathMode: 'typed' }, RB)
603
+ const [ra, rb] = await Promise.all([
604
+ call('math_computation', { op: 'run', mode: 'code', code: 'print("A")' }, RA),
605
+ call('math_computation', { op: 'run', mode: 'code', code: 'print("B")' }, RB),
606
+ ])
607
+ fake.spawnDelayMs = 0
608
+ assert(ra.ok === true && rb.ok === true, '★★ 两次并发调用都执行完成(没有任何一个被丢弃)(' + JSON.stringify({ a: ra.ok, b: rb.ok, ac: ra.code, bc: rb.code }) + ')')
609
+ assert(/^python/.test(String(ra.engine)) && /^r(\b|:)/.test(String(rb.engine)), '★★★ 各自用**自己会话**的引擎(A=python, B=r;实测 ' + JSON.stringify({ a: ra.engine, b: rb.engine }) + ')')
610
+ assert(String(ra.argv[0]).indexOf('python') !== -1 && /Rscript|R$/.test(String(rb.argv[0])), '★★★ 实际 spawn 的 argv[0] 各是自己的引擎 shim(' + JSON.stringify({ a: ra.argv[0], b: rb.argv[0] }) + ')')
611
+ const recA = JSON.parse(readIf(join(projA, ra.attemptDir, 'receipt.json')) || '{}')
612
+ const recB = JSON.parse(readIf(join(projB, rb.attemptDir, 'receipt.json')) || '{}')
613
+ assert(recA.engine && recA.engine.name === 'python' && recB.engine && recB.engine.name === 'r', '★★★ 回执里的 engine 分别是 python / r(A 的回执没有写进 B 的引擎)')
614
+ assert(String(recA.cwd).replace(/\\/g, '/').toLowerCase() === projA.replace(/\\/g, '/').toLowerCase() && String(recB.cwd).replace(/\\/g, '/').toLowerCase() === projB.replace(/\\/g, '/').toLowerCase(), '★★★ 两次调用的 cwd 各是自己的项目根(' + JSON.stringify({ a: recA.cwd, b: recB.cwd }) + ')')
615
+ assert(recA.engine.name !== recB.engine.name, '★ 参数确实不同(否则本用例不能证伪单槽污染)')
616
+ }
617
+
618
+ // ── §18 可选 host 回调(FREEZE §4):listDir 与 hasSubprocess 的预设接线 ────────────────────────
619
+ // 这两个字段是**可选**的,最容易被"接一半"而没人发现:模块在缺它们时会静默降级,
620
+ // 套件若只看结果就永远是绿的。这里对每一侧都做可观测断言(列目录调用计数 / 短路返回码)。
621
+ section('§18 可选 host 回调:listDir(保留上限只告警)与 hasSubprocess(no-subprocess 短路)')
622
+ {
623
+ // (a) 宿主有 listDir 且 Computation/ 下 >200 个 run 目录 ⇒ ARCHIVE_RETENTION_EXCEEDED 告警,且**不删任何东西**
624
+ fake = freshFake()
625
+ fake.installed = { python: '3.11.4' }
626
+ await call('vibe_math_new_project', { name: PROJECT })
627
+ for (let i = 0; i < 201; i++) mkdirSync(join(projRoot(), 'Computation', 'run-' + i), { recursive: true })
628
+ const runsBefore = readdirSync(join(projRoot(), 'Computation')).filter((n) => n.startsWith('run-')).length
629
+ fake.fsListDirCalls = 0
630
+ const ra = await call('math_computation', { op: 'run', mode: 'code', code: 'print("retention")' })
631
+ assert(ra.ok === true, '★ (a) 归档数超限只告警、不影响计算(' + JSON.stringify({ ok: ra.ok, code: ra.code }) + ')')
632
+ assert(fake.fsListDirCalls >= 1, '★★★ (a) 宿主提供 listDir ⇒ 模块真的去列了 Computation/(listDir 调用 ' + fake.fsListDirCalls + ' 次)')
633
+ assert((ra.warnings || []).some((w) => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★★★ (a) >200 个 run 目录 ⇒ 返回体 warnings 含 ARCHIVE_RETENTION_EXCEEDED(实测 ' + JSON.stringify((ra.warnings || []).map((w) => w.code)) + ')')
634
+ const runsAfter = readdirSync(join(projRoot(), 'Computation')).filter((n) => n.startsWith('run-')).length
635
+ assert(runsBefore === 201 && runsAfter === 201, '★★★ (a) 保留上限**只告警、永不删除**(run 目录数 ' + runsBefore + ' → ' + runsAfter + ')')
636
+
637
+ // (b) 宿主**没有** listDir ⇒ 预设不声明该回调:一次都不查、也不告警
638
+ const savedListDir = H.fs.listDir
639
+ H.fs.listDir = undefined
640
+ fake = freshFake()
641
+ fake.installed = { python: '3.11.4' }
642
+ const RB = makeRoot('sess-nolistdir')
643
+ const projB = join(WS, 'VibeMath', 'Projects', 'nolistdir')
644
+ await call('vibe_math_new_project', { name: 'nolistdir' }, RB) // 这句建成 RB 的会话:能力门闩在此刻读 fs.listDir
645
+ mkdirSync(join(projB, 'Computation'), { recursive: true })
646
+ // 判定只看 calls:别的预设代码(如 refreshProject)也会读这个属性,access 只作参考。
647
+ fake.fsListDirCalls = 0
648
+
649
+ const rb = await call('math_computation', { op: 'run', mode: 'code', code: 'print("no-listdir")' }, RB)
650
+ H.fs.listDir = savedListDir
651
+ assert(rb.ok === true, '★ (b) 宿主没有 listDir 仍能正常计算(' + JSON.stringify({ ok: rb.ok, code: rb.code, message: String(rb.message || '').slice(0, 80) }) + ')')
652
+ assert(fake.fsListDirCalls === 0, '★★★ (b) 没有 listDir ⇒ 预设不声明该回调:运行期**零次**列目录调用(calls=' + fake.fsListDirCalls + ';access=' + fake.fsListDirAccess + ' 只作参考)')
653
+ assert(!(rb.warnings || []).some((w) => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★★ (b) 因此也不会出现 ARCHIVE_RETENTION_EXCEEDED(不误报)')
654
+
655
+ // (c) hasSubprocess:false ⇒ op=probe 与 op=run **都**短路成 MATH_NO_SUBPROCESS(不是 ENGINE_NOT_FOUND)
656
+ fake = freshFake()
657
+ fake.noSubprocess = true
658
+ fake.spawns.length = 0
659
+ const pc = await call('math_computation', { op: 'probe' })
660
+ const rc = await call('math_computation', { op: 'run', mode: 'code', code: 'print(1)' })
661
+ assert(pc.ok === false && pc.code === 'MATH_NO_SUBPROCESS', '★★★ (c) op=probe ⇒ MATH_NO_SUBPROCESS(实测 ' + pc.code + ',不许退化成 MATH_ENGINE_NOT_FOUND)')
662
+ assert(rc.ok === false && rc.code === 'MATH_NO_SUBPROCESS', '★★★ (c) op=run ⇒ MATH_NO_SUBPROCESS(实测 ' + rc.code + ')')
663
+ assert(fake.spawns.length === 0, '★★ (c) 短路发生在引擎探测之前:零次 spawn 尝试(实测 ' + fake.spawns.length + ')')
664
+ fake.noSubprocess = false
665
+ }
666
+
667
+ // ── 19. round-2 lens-1: EVERY daily-line injection site is pinned, not just one ─────────────────
668
+ // The daily line belongs to the initiative axis, so at each site the producer call must not sit
669
+ // behind a `formalOn()` gate. Before this section, reverting ONE site to `formalOn()`-only still left
670
+ // the suite green (the dynamic eager case only exercised a different site).
671
+ {
672
+ const src = readFileSync(fileURLToPath(PLUGIN), 'utf8')
673
+ const lines = src.split(/\r?\n/)
674
+ const sites = []
675
+ for (let i = 0; i < lines.length; i++) {
676
+ const line = lines[i]
677
+ if (line.indexOf('function formalWorkSection') !== -1) continue // the definition itself
678
+ if (line.indexOf('formalWorkSection()') === -1) continue
679
+ sites.push({ n: i + 1, gated: line.indexOf('formalOn()') !== -1 })
680
+ }
681
+ assert(sites.length >= 3, '★★★ [sites] all three daily-line injection sites are present (found ' + sites.length + ')')
682
+ for (const s of sites) {
683
+ assert(!s.gated, '★★★ [site @line ' + s.n + '] the daily-line producer is NOT behind a formalOn() gate (the initiative axis is independent)')
684
+ }
685
+ }
686
+
687
+ // ── §19 成员/常驻作用域失败诊断(审计 D1):不许静默成功、不许裸消息 ─────────────────────────────
688
+ section('§19 成员作用域失败诊断:interruptChild 显式失败 + decision 诊断(code + 解释 + next)')
689
+ {
690
+ // 直接执行工具面(不经过 JSON.parse),这样"旧实现返回 undefined"这种静默成功也能被断言到。
691
+ const rawInterrupt = async (args) => {
692
+ const spec = specOf('vibe_math_interrupt_agent')
693
+ const out = await spec.execute(args, { agent: ROOT })
694
+ return out === undefined ? { ok: true, silentUndefined: true } : JSON.parse(out)
695
+ }
696
+ const unknown = await rawInterrupt({ childId: 'no-such-child' })
697
+ assert(unknown.ok !== true && unknown.code === 'VIBE_MATH_CHILD_NOT_FOUND', '★ [D1] interruptChild 未知 child 必须显式失败并带 code(实测 ' + JSON.stringify(unknown).slice(0, 170) + ')')
698
+ assert(!!unknown.next && typeof unknown.next.tool === 'string' && !!unknown.next.hint, '★ [D1] 未知 child 的诊断带 next{tool,hint}(实测 ' + JSON.stringify(unknown.next || null) + ')')
699
+ const empty = await rawInterrupt({ childId: ' ' })
700
+ assert(empty.ok !== true && empty.code === 'VIBE_MATH_INVALID_ARGUMENT' && !!empty.next, '★ [D1] 空 childId 同样显式失败并带 code+next(实测 ' + JSON.stringify(empty).slice(0, 140) + ')')
701
+ const listed = await call('vibe_math_list_agents', {})
702
+ assert(!!listed && listed.ok === true, '对照:vibe_math_list_agents 可调用——诊断里指向的工具必须真实存在(否则诊断本身是坑)')
703
+ const badDecide = await call('vibe_math_decide', { id: 'no-such-decision', action: 'approve' })
704
+ assert(badDecide.ok === false && badDecide.code === 'VIBE_MATH_DECISION_NOT_FOUND', '★ [D1] 未知 decision id ⇒ code=VIBE_MATH_DECISION_NOT_FOUND,不是裸 message(实测 ' + JSON.stringify(badDecide).slice(0, 170) + ')')
705
+ assert(!!badDecide.next && badDecide.next.tool === 'vibe_math_list_decisions', '★ [D1] 决策诊断的 next 指向真实存在的 vibe_math_list_decisions(实测 ' + JSON.stringify(badDecide.next || null) + ')')
706
+ }
707
+ // ── §20 审计 P0(**文本层断言**):成员可见材料必须写明"文件工具按会话 cwd 解析相对路径 ⇒ 下面列出的
708
+ // 相对路径要先拼项目根的绝对前缀;计算产物用回执的绝对字段 receipt.scriptAbs"。断言直接读**产出文本**
709
+ // (paper-writer 子代理的提示词,即 buildPaperDigest/paperEvidenceIndex 的输出),不是读源码字符串。
710
+ section('§20 审计 P0:成员可见路径说明(文本层断言,断言的是产出文本)')
711
+ {
712
+ const cmd = H.cmdRegs[0]
713
+ const paperPrompts = async () => {
714
+ // 审计 F2:先在项目里放一份 Reliable/ 参考文献——提示词要求"引用 Reliable/ 要给出处",
715
+ // 材料证据索引必须把它收进来(否则被要求引用的那一层永远进不了论文材料)。
716
+ writeFileSync(join(projRoot(), 'Reliable', 'ref.md'), '# 可信参考\n\n已被引用的外部结论。\n', 'utf8')
717
+ await cmd.handler({ agent: ROOT, rawInput: 'paper' })
718
+ for (let i = 0; i < 60; i++) { if (H.spawns.some((s) => String(s.label).indexOf('paper-writer:') === 0)) break; await sleep(100) }
719
+ return H.spawns.filter((s) => String(s.label).indexOf('paper-writer:') === 0)
720
+ .map((s) => (s.request && s.request.prompt && s.request.prompt[0] && s.request.prompt[0].text) || '').join('\n')
721
+ }
722
+ const produced = await paperPrompts()
723
+ assert(produced.length > 0, '对照:paper-writer 子代理确实被派出(产出文本非空,' + produced.length + ' 字符)')
724
+ assert(produced.indexOf('Reliable/ref.md') !== -1, '★★ [F2] 被要求引用的 Reliable/ 可信来源真的进了论文材料证据索引(文本层断言,实测片段 ' + JSON.stringify(produced.slice(Math.max(0, produced.indexOf('Reliable')), produced.indexOf('Reliable') + 40)) + ')')
725
+ assert(produced.indexOf('会话 cwd') !== -1 && produced.indexOf('绝对前缀') !== -1, '★★ [P0] 产出材料写着:文件工具按**会话 cwd** 解析相对路径 ⇒ 列出的相对路径需先拼**项目根的绝对前缀**(文本层断言)')
726
+ assert(produced.indexOf('receipt.scriptAbs') !== -1 || produced.indexOf('receipt.cwd') !== -1, '★★ [P0] 计算产物指向回执里的**绝对**字段(receipt.scriptAbs / receipt.cwd+receipt.scriptPath)')
727
+ // V2-ONLY-BEGIN(v2 的证据索引形状:v3 用 State/*.json 与 md 卡,没有同型断言)
728
+ // D3:证据索引("only files that exist")条目里只能有真实路径——"路径说明"这类散文规则不得混入
729
+ const evBlock = produced.slice(produced.indexOf('[EVIDENCE INDEX]'))
730
+ assert(produced.indexOf('[EVIDENCE INDEX]') !== -1 && evBlock.indexOf('路径说明') === -1,
731
+ '★★ [D3] 证据索引条目里没有混入散文规则("路径说明"只出现在 [PROPOSITIONS] 段;实测块首 ' + JSON.stringify(evBlock.slice(0, 100)) + ')')
732
+ assert(produced.indexOf('路径说明') !== -1, '对照:路径说明仍随材料下发(只是不再占用证据索引条目)')
733
+ // D5:索引只列**存在**的文件(qs/qs.json 也必须存在才列)
734
+ const evPaths = evBlock.split('\n').filter((l) => l.trim().indexOf('- ') === 0).map((l) => l.trim().slice(2))
735
+ assert(evPaths.filter((p) => /^qs\/qs\.json$/.test(p)).every((p) => existsSync(join(projRoot(), p))),
736
+ '★★ [D5] 证据索引里的 qs/qs.json 在磁盘上真实存在(实测索引前几项 ' + JSON.stringify(evPaths.slice(0, 4)) + ')')
737
+ const v2src = readFileSync(join(HERE, '..', 'vibe-math-v2', 'vibe-math-v2.js'), 'utf8')
738
+ assert(v2src.indexOf("if (await paperPathExists('qs/qs.json')) out.push('qs/qs.json')") !== -1,
739
+ '★★ [D5] 代码层:qs/qs.json 也是**存在才列**(不是无条件 push),"only files that exist" 才名副其实')
740
+ // V2-ONLY-END
741
+
742
+ await call('vibe_math_abort', {})
743
+ }
744
+
745
+ // ── §21 审计 F7:status/report 的 project 语义(当前会话项目 vs 磁盘上已存在的项目)────────────
746
+ section('§21 审计 F7:project / projectExists / projects 三者语义不歧义')
747
+ {
748
+ const fresh = makeRoot('sess-f7-fresh')
749
+ const st0 = await call('vibe_math_status', {}, fresh)
750
+ assert(st0.project === 'default' && st0.projects.indexOf('default') === -1 && st0.projectExists === false,
751
+ '★★ [F7] 尚未建项目时:project=default、projects 不含它、projectExists=false(不再靠调用方猜;实测 ' + JSON.stringify({ project: st0.project, projects: st0.projects, projectExists: st0.projectExists }) + ')')
752
+ await call('vibe_math_new_project', { name: PROJECT }, fresh)
753
+ const st1 = await call('vibe_math_status', {}, fresh)
754
+ assert(st1.projectExists === true && st1.projects.indexOf(PROJECT) !== -1,
755
+ '★★ [F7] new_project 之后 projectExists=true 且 projects 含该 slug(实测 ' + JSON.stringify({ project: st1.project, projectExists: st1.projectExists, projects: st1.projects }) + ')')
756
+ const rp = await call('vibe_math_report', {}, fresh)
757
+ assert(rp.projectExists === true && rp.project === st1.project,
758
+ '★★ [F7] report 与 status 两个视图语义一致(同一来源;实测 report=' + JSON.stringify({ project: rp.project, projectExists: rp.projectExists }) + ')')
759
+ }
760
+
761
+ assert(mgrBranchExercised === true, '★★★ A1:「包管理器存在 ⇒ 可执行命令」这半契约必须真的跑过(不允许平台条件静默吃掉断言)')
762
+ assert(skips.length === 0, '★★ A1:本次运行没有静默跳过(实测 skips=' + JSON.stringify(skips) + ')')
763
+ console.log('\n=== MATH COMPUTATION V2: ' + passed + ' passed, ' + failed + ' failed, ' + skips.length + ' skipped ===')
764
+ rmSync(WS, { recursive: true, force: true })
765
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
766
+ console.log('ALL GREEN')
767
+ process.exit(0)