dsh-vibe-math 2.7.2 → 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 (112) hide show
  1. package/README.en.md +11 -10
  2. package/README.md +13 -12
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +229 -3
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/final-paper.md +2 -2
  7. package/docs/math-computation.md +13 -6
  8. package/docs/parameter-schema.md +37 -0
  9. package/docs/release-notes/README.en.md +1 -0
  10. package/docs/release-notes/README.md +1 -0
  11. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
  12. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
  13. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  15. package/docs/release-notes/TEMPLATE.md +6 -0
  16. package/docs/status-report-fields.md +167 -0
  17. package/docs/test-timing.md +8 -5
  18. package/installer.js +27 -17
  19. package/package.json +250 -203
  20. package/prompt-corpus-persona/persona-corpus.json +9 -9
  21. package/prompt-corpus-persona/persona-corpus.md +225 -33
  22. package/prompt-corpus-v2/formal-verify-v2.json +295 -115
  23. package/prompt-corpus-v2/formal-verify-v2.md +2287 -283
  24. package/prompt-corpus-v3/formal-verify-v3.json +105 -105
  25. package/prompt-corpus-v3/formal-verify-v3.md +213 -143
  26. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  27. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  28. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  29. package/prompt-corpus-v5/prompt-corpus-v5.md +451 -207
  30. package/scripts/update-doc-counts.mjs +71 -0
  31. package/tests/audit-artifact-docs.mjs +72 -0
  32. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  33. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  34. package/tests/audit-installer-compat.test.mjs +52 -4
  35. package/tests/audit-installer-policy.test.mjs +41 -8
  36. package/tests/audit-math-computation-contract.mjs +64 -8
  37. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  38. package/tests/audit-math-computation-parity.mjs +164 -3
  39. package/tests/audit-math-computation-sensitivity.mjs +4 -2
  40. package/tests/audit-package-membership.mjs +57 -0
  41. package/tests/audit-package-membership.mutants.mjs +28 -0
  42. package/tests/audit-participant-set-parity.mjs +160 -0
  43. package/tests/audit-path-discipline.mjs +83 -0
  44. package/tests/audit-path-discipline.mutants.mjs +51 -0
  45. package/tests/audit-persona-sensitivity.mjs +14 -0
  46. package/tests/audit-persona-surface.mutants.mjs +69 -0
  47. package/tests/audit-persona-surface.test.mjs +63 -22
  48. package/tests/audit-prompt-duplication.mjs +143 -0
  49. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  50. package/tests/audit-prompt-invariants.mjs +43 -5
  51. package/tests/audit-readme-bilingual.test.mjs +152 -0
  52. package/tests/audit-readme-counts.mjs +95 -0
  53. package/tests/audit-readme-counts.mutants.mjs +52 -0
  54. package/tests/audit-registration.mjs +78 -0
  55. package/tests/audit-status-report-fields.mjs +172 -0
  56. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  57. package/tests/audit-v3-registration-parity.mjs +156 -0
  58. package/tests/audit-v5-integrity.mjs +300 -13
  59. package/tests/audit-v5-lean-abstention.mjs +392 -0
  60. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  61. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  62. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  63. package/tests/audit-v5-sensitivity.mjs +12 -7
  64. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  65. package/tests/e2e-v5-round2.test.mjs +458 -13
  66. package/tests/formal-verify-v2.test.mjs +91 -43
  67. package/tests/formal-verify-v3.mutants.mjs +117 -0
  68. package/tests/formal-verify-v3.test.mjs +85 -39
  69. package/tests/formal-verify-v4.mutants.mjs +152 -0
  70. package/tests/formal-verify-v4.test.mjs +541 -9
  71. package/tests/formal-verify-v5.test.mjs +68 -9
  72. package/tests/helpers/doc-counts-format.mjs +41 -0
  73. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  74. package/tests/math-computation-a1.mutants.mjs +72 -0
  75. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  76. package/tests/math-computation-discovery.mutants.mjs +76 -0
  77. package/tests/math-computation-shared.test.mjs +274 -5
  78. package/tests/math-computation-v2.test.mjs +767 -0
  79. package/tests/math-computation-v3.test.mjs +759 -0
  80. package/tests/math-computation-v4.mutants.mjs +133 -0
  81. package/tests/math-computation-v4.test.mjs +739 -0
  82. package/tests/prompt-v5-integrity.test.mjs +13 -2
  83. package/tests/run-tests.mjs +139 -9
  84. package/tests/run-tests.mutants.mjs +70 -0
  85. package/tests/selfdrive-v5.mjs +53 -1
  86. package/tests/selfdrive-v5.mutants.mjs +132 -0
  87. package/tests/v2-fix-probes.mutants.mjs +100 -0
  88. package/tests/v2-fix-probes.test.mjs +1039 -0
  89. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  90. package/tests/v3-fix-probes.mutants.mjs +95 -0
  91. package/tests/v3-fix-probes.test.mjs +891 -0
  92. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  93. package/vibe-math-v2/agent.cordis.yml +62 -6
  94. package/vibe-math-v2/math-computation.js +130 -41
  95. package/vibe-math-v2/math-engines.js +12 -12
  96. package/vibe-math-v2/vibe-math-v2.js +388 -113
  97. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  98. package/vibe-math-v3/agent.cordis.yml +72 -6
  99. package/vibe-math-v3/math-computation.js +130 -41
  100. package/vibe-math-v3/math-engines.js +12 -12
  101. package/vibe-math-v3/vibe-math-v3.js +422 -126
  102. package/vibe-math-v4/agent.cordis.yml +44 -8
  103. package/vibe-math-v4/math-computation.js +130 -41
  104. package/vibe-math-v4/math-engines.js +12 -12
  105. package/vibe-math-v4/vibe-math-v4.js +196 -43
  106. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  107. package/vibe-math-v5/agent.cordis.yml +46 -12
  108. package/vibe-math-v5/math-computation.js +130 -41
  109. package/vibe-math-v5/math-engines.js +12 -12
  110. package/vibe-math-v5/vibe-math-v5.js +1173 -218
  111. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +80 -12
  112. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +2 -2
@@ -0,0 +1,1039 @@
1
+ // Defect class: silent-failure surfacing (F6a/F6b/F6c), declared asymmetry (F-A), path-base mixing (F6), fence-root drift (F5).
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/v2-fix-probes.test.mjs
5
+ // Probes for the remaining v2 fixes (H3, H4/M12/M13, M10, M11, M15, H5).
6
+ // Each scenario drives the REAL plugin through the public tool API + the host's service mocks.
7
+ //
8
+ // Run: node tests/v2-fix-probes.test.mjs
9
+ import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join, dirname, resolve as pathResolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ let passed = 0, failed = 0
15
+ const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; process.stderr.write(' FAIL - ' + m + '\n') } }
16
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms))
17
+ const text = (o) => [{ type: 'text', text: '```json\n' + JSON.stringify(o) + '\n```' }]
18
+
19
+ function harness(opts) {
20
+ const o = opts || {}
21
+ const WS = mkdtempSync(join(tmpdir(), 'v2-fix-'))
22
+ const listeners = {}, toolRegs = [], spawns = [], errors = [], cmdRegs = [], latexRuns = []
23
+ let attempts = 0
24
+ const realError = console.error
25
+ console.error = (...a) => { errors.push(a.map(String).join(' ')); if (o.echoErrors) realError(...a) }
26
+ // 假 LaTeX 编译器(spec §6):engines 里列出的命令可解析;failFirst=true 时**第一个**引擎的
27
+ // 第一次尝试失败(走"换引擎修复"路径);alwaysFail=true 时永不成功(走"降级 + 警告"路径)。
28
+ // 成功 = 在 cwd(= Paper/<id>/)里写出 paper.pdf —— 与真编译器一致(插件只 stat,绝不写二进制)。
29
+ const latex = o.latex || null
30
+ const latexSeen = {}
31
+ const subprocess = o.noSubprocess ? undefined : {
32
+ async resolveExecutable(cmd) {
33
+ if (!latex) return undefined // 真机无 LaTeX 的形态
34
+ if (latex.mode === 'resolver-empty') return undefined // resolver 在但什么都解析不到
35
+ return (latex.engines || []).indexOf(cmd) !== -1 ? ('C:/fake/' + cmd) : undefined
36
+ },
37
+ spawn({ argv, cwd }) {
38
+ const script = argv[argv.length - 1] || ''
39
+ if (/New-Item/.test(script)) { const m = script.match(/-Path\s+'((?:[^']|'')*)'/); if (m) m[1].split(',').forEach((p) => { if (p) mkdirSync(p.replace(/''/g, "'"), { recursive: true }) }) }
40
+ if (/paper\.tex/.test(argv.join(' '))) {
41
+ const name = String(argv[0]).split(/[\\/]/).pop().replace(/\.(exe|cmd|bat)$/i, '')
42
+ latexSeen[name] = (latexSeen[name] || 0) + 1
43
+ const firstEngine = (latex.engines || [])[0]
44
+ // failFirst = 第一个引擎**整个尝试**都失败(两遍都失败)⇒ 走"换引擎"修复路径
45
+ const failThis = !!latex.alwaysFail || (!!latex.failFirst && name === firstEngine)
46
+ latexRuns.push({ name, fail: failThis, mode: /\\begin\{document\}/.test(script) ? 'tex' : 'tex' })
47
+ if (!failThis && cwd) { try { mkdirSync(cwd, { recursive: true }); writeFileSync(join(cwd, 'paper.pdf'), '%PDF-1.4 fake\n', 'utf8') } catch (e) { /* ignore */ } }
48
+ return {
49
+ done: Promise.resolve({ exitCode: failThis ? 1 : 0, signal: null }),
50
+ collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: failThis ? '! LaTeX Error: fake failure' : '', nextOffset: 0, lossy: false }) } },
51
+ terminate() {},
52
+ }
53
+ }
54
+ return { done: Promise.resolve({ exitCode: 0 }) }
55
+ },
56
+ }
57
+ const ctx = {
58
+ get(n) {
59
+ if (n === 'subprocess') return subprocess
60
+ if (n === 'sandboxPolicy' && o.policyRoot) return { resolve(req) { return Object.assign({}, req && req.session ? { session: true } : {}, { workspaceRoot: o.policyRoot }) } }
61
+ return undefined
62
+ },
63
+ on(e, f) { (listeners[e] = listeners[e] || []).push(f) },
64
+ effect(f) { const d = f(); return () => { if (typeof d === 'function') d() } },
65
+ logger: { info() {}, warn() {}, error() {} },
66
+ tools: Object.assign({ register(s) { toolRegs.push(s) } }, o.toolSchemas ? { schemas: () => o.toolSchemas } : {}),
67
+ commands: { register(s) { cmdRegs.push(s); return () => {} } },
68
+ subagents: {
69
+ list() { if (o.failSubagentsList) throw new Error('mock list failure'); return ['spawn'] },
70
+ async startContinuable({ label, request }) {
71
+ // 宿主激活上限报错文本与 0.2.0 宿主一致(ACTIVATION_LIMIT_REACHED / active child limit: N)
72
+ attempts++
73
+ if (o.refuseAttempts && attempts <= o.refuseAttempts) throw new Error('ACTIVATION_LIMIT_REACHED: cannot start a new child: active child limit: ' + (o.limit || 2))
74
+ const childId = 'c' + (spawns.length + 1); spawns.push({ label, request, childId }); return { childId }
75
+ },
76
+ async sendMessage() {},
77
+ interrupt() { if (o.failInterrupt) throw new Error('mock interrupt failure') },
78
+ },
79
+ agents: { roots() { return [] }, get() { return undefined } },
80
+ fs: {
81
+ async resolve(rel, oc) { return pathResolve((oc && oc.cwd) || WS, ...String(rel).split('/')) },
82
+ async stat(t) { return existsSync(t) ? { type: 'file' } : undefined },
83
+ async readText(t) { return readFileSync(t, 'utf8') },
84
+ async writeText(t, c) { if (o.failWritePath && String(t).includes(o.failWritePath)) throw new Error('mock write failure: ' + o.failWritePath); mkdirSync(dirname(t), { recursive: true }); writeFileSync(t, c, 'utf8') },
85
+ async listDir(t) { if (!existsSync(t)) return []; return readdirSync(t, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
86
+ },
87
+ }
88
+ const ROOT = { id: 'S', options: { provider: 'mock', model: 'mock' }, session: { id: 'S', header: { cwd: WS } } }
89
+ return { WS, listeners, toolRegs, cmdRegs, spawns, errors, latexRuns, ctx, ROOT, restore() { console.error = realError }, project: join(WS, 'VibeMath', 'Projects', 'p') }
90
+ }
91
+ // Seam: V2_PLUGIN points the probe at a mutant copy (same pattern as v3-fix-probes' V3_PLUGIN).
92
+ // Module scope so probes that re-instantiate a FRESH copy take it from the same (possibly mutant) source.
93
+ // The source-TEXT assertions must read the same artifact the behavioural assertions run: with
94
+ // V2_PLUGIN pointing at a mutant copy, a text assertion that read the repo file could never redden
95
+ // (R19: this is what a mutant harness needs to reach it). Default is unchanged: the repo file.
96
+ const readPluginSrc = () => (process.env.V2_PLUGIN
97
+ ? readFileSync(String(process.env.V2_PLUGIN), 'utf8')
98
+ : readFileSync(new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url), 'utf8'))
99
+ const PLUGIN_URL = process.env.V2_PLUGIN
100
+ ? new URL('file:///' + String(process.env.V2_PLUGIN).replace(/\\/g, '/'))
101
+ : new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url)
102
+ async function load(h) {
103
+ const CACHE_KEY = '__v2PluginPromise:' + PLUGIN_URL.href
104
+ if (!globalThis[CACHE_KEY]) globalThis[CACHE_KEY] = import(PLUGIN_URL.href)
105
+ const mod = await globalThis[CACHE_KEY] // ONE module instance per URL (no ?t= cache-buster)
106
+ ;(mod.default || mod).apply(h.ctx)
107
+ h.call = async (n, a) => JSON.parse(await (h.toolRegs.find((s) => s.name === n)).execute(a || {}, { agent: h.ROOT }))
108
+ h.fireEnd = (info) => { for (const fn of (h.listeners['subagent/end'] || [])) fn(info) }
109
+ h.readQs = () => JSON.parse(readFileSync(join(h.project, 'qs', 'qs.json'), 'utf8'))
110
+ h.writeQs = (v) => writeFileSync(join(h.project, 'qs', 'qs.json'), JSON.stringify(v, null, 2), 'utf8')
111
+ h.readProps = () => {
112
+ const dir = join(h.project, 'Propos'); const out = []
113
+ if (!existsSync(dir)) return out
114
+ for (const f of readdirSync(dir)) if (f.endsWith('_Propos.json')) for (const p of JSON.parse(readFileSync(join(dir, f), 'utf8'))) out.push(p)
115
+ return out
116
+ }
117
+ // —— final paper 探针用(spec §6):模块测试缝 + /vibe 命令入口 ——
118
+ h.H = mod.__testHelpers || {}
119
+ h.cmd = async (raw) => {
120
+ const spec = h.cmdRegs.find((s) => s.name === 'vibe')
121
+ if (!spec) throw new Error('no /vibe command registered')
122
+ return await spec.handler({ agent: h.ROOT, rawInput: raw })
123
+ }
124
+ h.paperDir = (id) => join(h.project, 'Paper', id === undefined ? 'p' : id)
125
+ h.find = async (pred, tries) => { for (let i = 0; i < (tries || 60); i++) { const v = pred(); if (v) return v; await wait(150) } return undefined }
126
+ h.findAsync = async (pred, tries) => { for (let i = 0; i < (tries || 60); i++) { const v = await pred(); if (v) return v; await wait(150) } return undefined }
127
+ // wait for a label's reviewers, then answer them with a per-index Result list
128
+ h.review = async (label, results, tries) => {
129
+ const vs = await h.find(() => { const x = h.spawns.filter((s) => s.label.startsWith(label)); return x.length >= results.length ? x : undefined }, tries)
130
+ for (let i = 0; i < (vs || []).length && i < results.length; i++) {
131
+ h.fireEnd({ id: vs[i].childId, runId: 'r' + i + '-' + Math.random().toString(36).slice(2, 6), stopReason: 'completed', lastAssistantMessage: text({ Result: results[i], Reason: 'probe' }) })
132
+ }
133
+ return vs || []
134
+ }
135
+ // same, but ignore the first `before` spawns (a re-verification of the same object)
136
+ h.reviewAfter = async (label, before, results, tries) => {
137
+ const vs = await h.find(() => { const x = h.spawns.filter((s) => s.label.startsWith(label)); return x.length >= before + results.length ? x.slice(before) : undefined }, tries)
138
+ for (let i = 0; i < (vs || []).length && i < results.length; i++) {
139
+ h.fireEnd({ id: vs[i].childId, runId: 'n' + i + '-' + Math.random().toString(36).slice(2, 6), stopReason: 'completed', lastAssistantMessage: text({ Result: results[i], Reason: 'probe' }) })
140
+ }
141
+ return vs || []
142
+ }
143
+ h.answer = (label, result, tries) => (async () => {
144
+ const vs = await h.find(() => { const x = h.spawns.filter((s) => s.label.startsWith(label)); return x.length ? x : undefined }, tries)
145
+ for (let i = 0; i < (vs || []).length; i++) h.fireEnd({ id: vs[i].childId, runId: 'a' + i + '-' + Math.random().toString(36).slice(2, 6), stopReason: 'completed', lastAssistantMessage: text({ Result: result, Reason: 'probe' }) })
146
+ return vs || []
147
+ })()
148
+ }
149
+
150
+ // ---------------------------------------------------------------- H3
151
+ console.log('\n-- H3: a proof judged 0 must not fabricate "the proposition is false" --')
152
+ {
153
+ const h = harness(); await load(h)
154
+ await h.call('vibe_math_new_project', { name: 'p' })
155
+ await h.call('vibe_math_set_params', { maxParallelThreshold: 8, verifierCount: 2 })
156
+ await h.call('vibe_math_add_proposition', { id: 'pProof', 概述: 'P', 布尔估计: 0.6, 优先级: 1, 细类型: { 数论: {} } })
157
+ const f = join(h.project, 'Propos', '数论_Propos.json')
158
+ const list = JSON.parse(readFileSync(f, 'utf8'))
159
+ list[0].证明列表 = [{ 完整过程: 'the proof text', 正确概率: 0.7, 已验: false }]
160
+ writeFileSync(f, JSON.stringify(list, null, 2), 'utf8')
161
+ await h.call('vibe_math_start', {})
162
+ const vs = await h.find(() => { const x = h.spawns.filter((s) => s.label.startsWith('verifier:r-pProof-pf0:')); return x.length >= 2 ? x : undefined })
163
+ assert(!!vs, 'two reviewers were spawned for the proof entry')
164
+ for (let i = 0; i < (vs || []).length; i++) h.fireEnd({ id: vs[i].childId, runId: 'z' + i, stopReason: 'completed', lastAssistantMessage: text({ Result: 0, Reason: 'the proof is invalid' }) })
165
+ const after = await h.find(() => { const p = h.readProps().find((x) => x.id === 'pProof'); return (p && p.证明列表 && p.证明列表[0] && p.证明列表[0].正确概率 === 0) ? p : undefined })
166
+ assert(!!after, 'the judged proof is marked invalid (正确概率=0)')
167
+ assert(!!after && after.布尔估计 === 0.6, 'the proposition\u2019s 布尔估计 is UNCHANGED (0.6) \u2014 "this proof is invalid" is not "the proposition is false"')
168
+ assert(!!after && !(after.证伪列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 refutation entry was fabricated')
169
+ assert(!!after && !(after.证明列表 || []).some((x) => x.正确概率 === 1), 'and no probability-1 proof entry either')
170
+ assert(!!after && after.证明列表[0].已验 === true, 'the invalid proof itself is recorded as 已验')
171
+ const cards = existsSync(join(h.project, 'Verified', '数论_Verified.json')) ? JSON.parse(readFileSync(join(h.project, 'Verified', '数论_Verified.json'), 'utf8')) : []
172
+ assert(!cards.some((c) => c.id === 'pProof'), 'no Verified card was written for the proposition')
173
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
174
+ }
175
+
176
+ // ---------------------------------------------------------------- H4 + M12 + M13
177
+ console.log('\n-- H4/M12/M13: mid-range is not an absolute verdict; accuracy is stable-keyed and deferred --')
178
+ {
179
+ const h = harness(); await load(h)
180
+ await h.call('vibe_math_new_project', { name: 'p' })
181
+ await h.call('vibe_math_set_params', { maxParallelThreshold: 8, verifierCount: 2, debateMaxRounds: 1 })
182
+ await h.call('vibe_math_add_problem', { id: 'qA', description: 'accuracy problem' })
183
+ h.writeQs([{ id: 'qA', 概述: 'accuracy problem', 已解决: false, 优先级: 0, 解法列表: [{ 完整解法: 'solution', 正确概率: 0.5, 验证记录: [] }], progress: { directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.5, status: 'active', round: 0, survival: 0.5, routes: [], blockers: [] }] } }])
184
+ await h.call('vibe_math_start', {})
185
+ await h.review('verifier:r-qA-s0:', [0.6, 0.9], 60)
186
+ const mid = await h.find(() => { const q = h.readQs()[0]; const s = (q.解法列表 || [])[0]; return (s && s.正确概率 !== 0.5 && s.正确概率 !== 1) ? s : undefined })
187
+ assert(!!mid && mid.正确概率 === 0.75, 'flat mode takes the MEAN of the reported probabilities (0.6/0.9 -> ' + (mid && mid.正确概率) + ')')
188
+ assert(!!mid && mid.已验 === true && Number(mid.最近验证时间) > 0, 'the mid-range verdict is stamped for re-verification (0<p<1 is not parked forever)')
189
+ const accFile = join(h.project, 'VibeMath_State', 'verifier_accuracy.json')
190
+ const acc0 = existsSync(accFile) ? JSON.parse(readFileSync(accFile, 'utf8')) : {}
191
+ assert(Object.keys(acc0).length === 0, 'no accuracy was scored against the round\u2019s OWN aggregate (no self-reference)')
192
+ // force the re-verification cooldown to expire
193
+ const before = h.spawns.filter((s) => s.label.startsWith('verifier:r-qA-s0:')).length
194
+ const qs = h.readQs(); qs[0].解法列表[0].最近验证时间 = 0; h.writeQs(qs)
195
+ await wait(500)
196
+ await h.reviewAfter('verifier:r-qA-s0:', before, [1, 1], 60)
197
+ const solved = await h.find(() => { const q = h.readQs()[0]; return (q.已解决) ? q : undefined })
198
+ assert(!!solved, 'the cooled-down object WAS re-verified and then settled (mid-range entries are re-verifiable)')
199
+ assert(!!solved && solved.解法列表[0].正确概率 === 1, 'the second (unanimous) round gives the boolean verdict 1')
200
+ const acc1 = existsSync(accFile) ? JSON.parse(readFileSync(accFile, 'utf8')) : {}
201
+ const keys = Object.keys(acc1)
202
+ assert(keys.length === 1 && keys[0] === 'm:mock/mock', 'accuracy is keyed by the STABLE provider/model identity (keys=' + JSON.stringify(keys) + ')')
203
+ assert(!!acc1['m:mock/mock'] && acc1['m:mock/mock'].total === 2 && acc1['m:mock/mock'].correct === 0,
204
+ 'only the earlier round was scored against the later boolean truth (got ' + JSON.stringify(acc1['m:mock/mock']) + '), never the round grading itself')
205
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
206
+ }
207
+
208
+ // ---------------------------------------------------------------- M10
209
+ console.log('\n-- M10: leaving a gated run abandons (does not strand) the pending decision --')
210
+ {
211
+ const h = harness(); await load(h)
212
+ await h.call('vibe_math_new_project', { name: 'p' })
213
+ await h.call('vibe_math_set_mode', { mode: 'manual' })
214
+ await h.call('vibe_math_add_problem', { id: 'q1', description: 'gated problem' })
215
+ await h.call('vibe_math_start', {})
216
+ const dec = await h.findAsync(async () => { const d = await h.call('vibe_math_list_decisions', {}); return d.decisions.length ? d : undefined })
217
+ assert(!!dec && dec.decisions.length === 1, 'manual mode produced exactly one pending gate decision')
218
+ const gatedId = dec.decisions[0].id
219
+ await h.call('vibe_math_resume', {})
220
+ await wait(300)
221
+ const after = await h.call('vibe_math_list_decisions', {})
222
+ assert(!after.decisions.some((d) => d.id === gatedId), 'after resume the gated decision is no longer pending (it used to reappear forever)')
223
+ const queue = JSON.parse(readFileSync(join(h.project, 'VibeMath_State', 'decision_queue.json'), 'utf8'))
224
+ const resolved = queue.find((d) => d.id === gatedId && d.resolution && d.resolution.action === 'abandoned')
225
+ assert(!!resolved, 'it is recorded as resolved with action=abandoned (an auditable terminal state)')
226
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
227
+ }
228
+
229
+ // ---------------------------------------------------------------- M11
230
+ console.log('\n-- M11: one surviving reviewer cannot produce a verdict --')
231
+ {
232
+ const h = harness(); await load(h)
233
+ await h.call('vibe_math_new_project', { name: 'p' })
234
+ await h.call('vibe_math_set_params', { maxParallelThreshold: 1, verifierCount: 2, debateMaxRounds: 1 })
235
+ await h.call('vibe_math_add_problem', { id: 'qB', description: 'one-slot problem' })
236
+ h.writeQs([{ id: 'qB', 概述: 'one-slot problem', 已解决: false, 优先级: 0, 解法列表: [{ 完整解法: 'solution', 正确概率: 0.5, 验证记录: [] }], progress: { directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.4, status: 'active', round: 0, survival: 0.4, routes: [], blockers: [] }] } }])
237
+ await h.call('vibe_math_start', {})
238
+ for (let i = 0; i < 10; i++) { await h.answer('verifier:r-qB-s0:', 1, 40); await wait(300) }
239
+ const q = h.readQs()[0]
240
+ assert((q.解法列表 || [])[0].正确概率 === 0.5, 'the single-reviewer verdict was REFUSED (正确概率 still 0.5, got ' + (q.解法列表 || [])[0].正确概率 + ')')
241
+ assert(q.已解决 !== true, 'and the problem was not marked solved')
242
+ const acts = (await h.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
243
+ assert(/有效评审不足 2 份/.test(acts), 'the shortfall is announced on the activity log (why no verdict was written)')
244
+ assert(!existsSync(join(h.project, 'Verified', '问题_Verified.json')), 'no Verified card exists for it')
245
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
246
+ }
247
+
248
+ // ---------------------------------------------------------------- D4
249
+ console.log('\n-- D4: the debate prompt asks for "changed" AND the framework keeps it --')
250
+ {
251
+ const h = harness(); await load(h)
252
+ await h.call('vibe_math_new_project', { name: 'p' })
253
+ await h.call('vibe_math_set_params', { maxParallelThreshold: 6, verifierCount: 2, debateMaxRounds: 2, verdictMode: 'flat' })
254
+ await h.call('vibe_math_add_proposition', { id: 'pD4', 概述: 'D4 改判理由', 概率: 0.6, 分类: '数论' })
255
+ await h.call('vibe_math_start', {})
256
+ const vs = await h.find(() => { const x = h.spawns.filter((s) => String(s.label).indexOf('verifier:r-pD4') === 0); return x.length >= 2 ? x : undefined })
257
+ assert(!!vs, 'D4 两个验证器被派出(' + (vs || []).length + ')')
258
+ const fire = (childId, result, changed) => h.fireEnd({ id: childId, runId: 'd4-' + Math.random().toString(36).slice(2, 7), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify(changed ? { Result: result, Reason: '理由', changed: changed } : { Result: result, Reason: '理由' }) + '\n```' }] })
259
+ // round 1: 1 vs 0.5 ⇒ no consensus ⇒ a real debate round 2
260
+ fire(vs[0].childId, 1); fire(vs[1].childId, 0.5)
261
+ await wait(400)
262
+ // round 2 (re-wake the same children; fireEnd is a no-op without an in-flight turn): one states a change
263
+ for (let i = 0; i < 6; i++) { fire(vs[0].childId, 1, '被对方的反例说服后提高了置信'); fire(vs[1].childId, 1); await wait(250) }
264
+ const logDir = join(h.project, 'Verification_logs')
265
+ const got = await h.find(() => {
266
+ if (!existsSync(logDir)) return undefined
267
+ const fs2 = readdirSync(logDir).filter((f) => f.endsWith('.json'))
268
+ if (!fs2.length) return undefined
269
+ const newest = fs2.sort().pop()
270
+ const j = JSON.parse(readFileSync(join(logDir, newest), 'utf8'))
271
+ return (j.transcript || '').indexOf('[changed:') !== -1 ? j : undefined
272
+ })
273
+ assert(!!got, '★ [D4] 裁决落库的辩论 transcript 里保留了评审的改判理由(changed)')
274
+ await h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
275
+ }
276
+
277
+ // ---------------------------------------------------------------- P1
278
+ console.log('\n-- P1: add_proposition 拒绝覆盖已有 id(与自动路径同语义) --')
279
+ {
280
+ const h = harness(); await load(h)
281
+ await h.call('vibe_math_new_project', { name: 'p' })
282
+ const a1 = await h.call('vibe_math_add_proposition', { id: 'pP1', 概述: '第一版陈述', 布尔估计: 0.6 })
283
+ assert(a1.ok === true, 'P1 首次新建成功')
284
+ const a2 = await h.call('vibe_math_add_proposition', { id: 'pP1', 概述: '冒名覆盖', 布尔估计: 0.9 })
285
+ assert(a2.ok !== true && String(a2.code) === 'PROPOSITION_ID_EXISTS',
286
+ '★★ [P1] 同 id 再次新建被拒绝(实测 code=' + JSON.stringify(a2.code || a2.message) + ')')
287
+ assert(typeof a2.next === 'string' && a2.next.length > 0, '★★ [P1] 拒绝时给出可执行的 next(换新 id / 编辑该卡)')
288
+ const list = await h.call('vibe_math_list_propositions', {})
289
+ const kept = (list.propositions || []).filter((x) => x.id === 'pP1')[0]
290
+ assert(kept && kept.概述 === '第一版陈述', '★★ [P1] 已有卡内容未被覆盖(实测 概述=' + JSON.stringify(kept && kept.概述) + ')')
291
+ const a3 = await h.call('vibe_math_add_proposition', { id: 'pP1b', 概述: '新卡', 布尔估计: 0.5 })
292
+ assert(a3.ok === true, '对照:换一个新 id 仍可正常新建')
293
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
294
+ }
295
+
296
+ // ---------------------------------------------------------------- P2
297
+ console.log('\n-- P2: 状态提交有完整性标记,撕裂提交可检测 --')
298
+ {
299
+ const h = harness(); await load(h)
300
+ await h.call('vibe_math_new_project', { name: 'p' })
301
+ await h.call('vibe_math_set_params', { verdictMode: 'flat' })
302
+ const st = await h.call('vibe_math_status', {})
303
+ assert(st.stateCommit && Number(st.stateCommit.seq) >= 1,
304
+ '★★ [P2] 提交标记存在且带序号(实测 seq=' + (st.stateCommit && st.stateCommit.seq) + ')')
305
+ assert(st.stateCommit.complete === true && Array.isArray(st.stateCommit.files) && st.stateCommit.files.length >= 6,
306
+ '★★ [P2] 标记列出全部状态文件且都非空(files=' + ((st.stateCommit.files || []).length) + ',mode=' + st.stateCommit.mode + ')')
307
+ assert(st.stateCommit.mode === 'stage-commit' || st.stateCommit.mode === 'direct',
308
+ '对照:提交模式被如实记录(' + st.stateCommit.mode + ')')
309
+ // 撕裂提交:删掉标记列出的一份状态文件(模拟崩溃落在两次写之间),再 resume ⇒ 必须检测到并留痕
310
+ const victimRel = st.stateCommit.files.filter((f) => /tasks\.json$/.test(f))[0] || st.stateCommit.files[0]
311
+ rmSync(join(h.project, victimRel), { force: true })
312
+ await h.call('vibe_math_resume', {})
313
+ const acts = (await h.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
314
+ assert(acts.indexOf('上一次状态提交不完整') !== -1,
315
+ '★★ [P2] 撕裂提交被明确报出(活动日志:' + JSON.stringify(acts.slice(-200)) + ')')
316
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
317
+ }
318
+
319
+ // ---------------------------------------------------------------- P3
320
+ console.log('\n-- P3: 待计分评审样本必须落盘(崩溃不再静默丢样本) --')
321
+ {
322
+ const h = harness(); await load(h)
323
+ await h.call('vibe_math_new_project', { name: 'p' })
324
+ await h.call('vibe_math_set_params', { verifierCount: 2, maxParallelThreshold: 4, debateMaxRounds: 1, verdictMode: 'flat' })
325
+ await h.call('vibe_math_add_proposition', { id: 'pP3', 概述: '待计分样本', 布尔估计: 0.6 })
326
+ await h.call('vibe_math_start', {})
327
+ const vs = await h.find(() => { const x = h.spawns.filter((s) => String(s.label).indexOf('verifier:r-pP3') === 0); return x.length >= 2 ? x : undefined })
328
+ assert(!!vs, 'P3 两个验证器被派出(' + (vs || []).length + ')')
329
+ for (let i = 0; i < (vs || []).length; i++) h.answer('verifier:r-pP3', 0.5, 40)
330
+ await wait(600)
331
+ const rel = join('VibeMath_State', 'pending_review_scores.json')
332
+ const persisted = await h.find(() => {
333
+ const f = join(h.project, rel)
334
+ if (!existsSync(f)) return undefined
335
+ const j = JSON.parse(readFileSync(f, 'utf8'))
336
+ return Object.keys(j).length > 0 ? j : undefined
337
+ })
338
+ assert(!!persisted, '★★ [P3] 待计分样本落盘(VibeMath_State/pending_review_scores.json 非空)')
339
+ assert(persisted && Object.keys(persisted).some((k) => k.indexOf('pP3') !== -1),
340
+ '★★ [P3] 落盘内容包含刚投票的对象(keys=' + JSON.stringify(Object.keys(persisted || {}).slice(0, 3)) + ')')
341
+ await h.call('vibe_math_resume', {})
342
+ const after = JSON.parse(readFileSync(join(h.project, rel), 'utf8'))
343
+ assert(Object.keys(after).some((k) => k.indexOf('pP3') !== -1),
344
+ '★★ [P3] 恢复后样本仍在(loadState 回读,不是被清空重来)')
345
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
346
+ }
347
+
348
+ // ---------------------------------------------------------------- P4
349
+ console.log('\n-- P4: 被拒绝/失败的落盘必须可见 --')
350
+ {
351
+ const h = harness(); await load(h)
352
+ await h.call('vibe_math_new_project', { name: 'p' })
353
+ await h.call('vibe_math_set_params', { verdictMode: 'flat' })
354
+ // 制造"存在但无法解析"的状态文件 ⇒ 损坏守卫必须拒绝写入,而且这次拒绝要**可见**
355
+ writeFileSync(join(h.project, 'VibeMath_State', 'tasks.json'), '{not json', 'utf8')
356
+ await h.call('vibe_math_resume', {}) // 先让插件**读到**这个坏文件(损坏守卫据此标记它)
357
+ await h.call('vibe_math_pause', {})
358
+ const st = await h.call('vibe_math_status', {})
359
+ assert(st.stateWriteFailures && Number(st.stateWriteFailures.count) >= 1,
360
+ '★★ [P4] 被拒绝的写入计入诊断(count=' + (st.stateWriteFailures && st.stateWriteFailures.count) + ')')
361
+ assert(st.stateWriteFailures.last && /tasks\.json/.test(String(st.stateWriteFailures.last.rel)),
362
+ '★★ [P4] 诊断指出具体文件(last.rel=' + (st.stateWriteFailures.last && st.stateWriteFailures.last.rel) + ')')
363
+ const rep = await h.call('vibe_math_report', {})
364
+ assert(rep.stateWriteFailures && Number(rep.stateWriteFailures.count) >= 1, '★★ [P4] report 同样暴露该诊断(不只 status)')
365
+ const acts = (await h.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
366
+ assert(/拒绝写入/.test(acts), '★★ [P4] 活动日志有"拒绝写入"留痕(实测 ' + JSON.stringify(acts.slice(-140)) + ')')
367
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
368
+ }
369
+
370
+ // ---------------------------------------------------------------- P5–P8
371
+ console.log('\n-- P5–P8: 状态完整性(日志回读 / 论文排队 / 日志名 / 短 id 唯一) --')
372
+ {
373
+ const h = harness(); await load(h)
374
+ await h.call('vibe_math_new_project', { name: 'p' })
375
+ await h.call('vibe_math_pause', {})
376
+ const st0 = await h.call('vibe_math_status', {})
377
+ const rel = (f) => join(h.project, 'VibeMath_State', f)
378
+ assert(existsSync(rel('activity_log.json')), '★★ [P5] activityLog 落盘(VibeMath_State/activity_log.json)')
379
+ assert(existsSync(rel('paper.json')), '★★ [P6] 论文排队/回收计数落盘(VibeMath_State/paper.json)')
380
+ const pj = JSON.parse(readFileSync(rel('paper.json'), 'utf8'))
381
+ assert('pending' in pj && 'reaps' in pj, '★★ [P6] paper.json 含 pending/reaps(实测 ' + JSON.stringify(pj) + ')')
382
+ // P5 回读:resume 之后活动日志不为空(旧实现每次都从零开始)
383
+ await h.call('vibe_math_resume', {})
384
+ const st1 = await h.call('vibe_math_status', {})
385
+ assert((st1.recentActivity || []).length > 0,
386
+ '★★ [P5] 恢复后 recentActivity 非空(从磁盘回读;实测 ' + (st1.recentActivity || []).length + ' 条)')
387
+ const src = readPluginSrc()
388
+ assert(/Date\.now\(\) \+ '_' \+ shortId\(\) \+ '\.json'/.test(src),
389
+ '★★ [P7] 裁决日志名带 shortId 后缀(同一毫秒的两次裁决不再互相覆盖)')
390
+ assert(/function shortIdUnique\(isTaken\)/.test(src) && /'p-' \+ shortIdUnique\(/.test(src),
391
+ '★★ [P8] 框架分配命题 id 走 shortIdUnique(分配前查重,不再靠概率)')
392
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
393
+ }
394
+
395
+ console.log('\n-- F1: pendingDecisions 在 status/report 同形 --');
396
+ {
397
+ const h = harness(); await load(h)
398
+ await h.call('vibe_math_new_project', { name: 'pf1' })
399
+ const st = await h.call('vibe_math_status', {});
400
+ const rep = await h.call('vibe_math_report', {});
401
+ assert(typeof st.pendingDecisions === 'number' && typeof rep.pendingDecisions === 'number',
402
+ '★★★ [F1] pendingDecisions 两面同形(都是数字;实测 status=' + typeof st.pendingDecisions + ' report=' + typeof rep.pendingDecisions + ')');
403
+ assert(Array.isArray(rep.pendingDecisionItems) && rep.pendingDecisionItems.length === rep.pendingDecisions,
404
+ '★★ [F1] 明细用独立键名 pendingDecisionItems,且长度与计数一致(实测 ' + JSON.stringify(rep.pendingDecisionItems && rep.pendingDecisionItems.length) + ')');
405
+ assert(st.pendingDecisions === rep.pendingDecisions, '★★★ [F-A] 两面数字一致(合同):status=' + st.pendingDecisions + ' report=' + rep.pendingDecisions);
406
+ assert(st.pendingDecisionItems === undefined, '★★★ [F-A] status **不得**携带明细——有意拆分:数字是合同、明细只进 report(实测 ' + JSON.stringify(st.pendingDecisionItems) + ')');
407
+ const srcA = readFileSync(PLUGIN_URL, 'utf8');
408
+ assert(/F-A(有意拆分)/.test(srcA), '★★ [F-A] 设计句在代码里(拆分理由可判定,不靠口头约定)');
409
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
410
+ }
411
+ console.log('\n-- F3: fieldScopes 标注会话 vs 耐久 --');
412
+ {
413
+ const h = harness(); await load(h)
414
+ await h.call('vibe_math_new_project', { name: 'pf3' })
415
+ const st = await h.call('vibe_math_status', {});
416
+ const sc = st.fieldScopes;
417
+ assert(sc && Array.isArray(sc.session) && Array.isArray(sc.durable) && typeof sc.note === 'string' && sc.note.length > 0, '★★★ [F3] status 带 fieldScopes(session/durable/note 齐备)');
418
+ const getPath = (p) => p.split('.').reduce((o, k) => (o === undefined || o === null ? undefined : o[k]), st);
419
+ const missing = (sc ? sc.session.concat(sc.durable) : []).filter((p) => getPath(p) === undefined);
420
+ assert(missing.length === 0, '★★★ [F3] fieldScopes 列的每个字段在载荷里真实存在(列出不存在的名字会在此变红;缺 ' + JSON.stringify(missing) + ')');
421
+ assert(sc && sc.session.indexOf('stateWriteFailures') !== -1 && sc.session.indexOf('registeredAgents') !== -1 && sc.durable.indexOf('formal') !== -1 && sc.durable.indexOf('stateWriteFailures') === -1, '★★ [F3] 语义抽查:stateWriteFailures/registeredAgents 属会话、formal 属耐久,两组互斥');
422
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
423
+ }
424
+ console.log('\n-- F5/F6: 两面字段集与 paths 基准 --');
425
+ {
426
+ const h = harness(); await load(h);
427
+ await h.call('vibe_math_new_project', { name: 'pf56' });
428
+ const st = await h.call('vibe_math_status', {});
429
+ const rep = await h.call('vibe_math_report', {});
430
+ assert('paper' in st && 'paper' in rep && 'at' in st && 'at' in rep, '★★★ [F5] paper/at 两面都有(v2 status/report 字段集对齐)');
431
+ const need = ['base', 'project', 'proofs', 'lib', 'proved', 'note'];
432
+ assert(st.formal && st.formal.paths && need.every((k) => k in st.formal.paths), '★★★ [F6] status.formal.paths 带 base + note(缺 ' + JSON.stringify(need.filter((k) => !st.formal.paths || !(k in st.formal.paths))) + ')');
433
+ const pp = st.formal.paths || {};
434
+ assert(typeof pp.base === 'string' && pp.base.indexOf('/') !== -1 && /^([A-Za-z]:[\\/]|\/)/.test(pp.base), '★★ [F6] base 是绝对项目根(实测 ' + JSON.stringify(pp.base) + ')');
435
+ const rpj = rep.formal && rep.formal.paths ? rep.formal.paths : null;
436
+ assert(!!rpj && 'base' in rpj && 'note' in rpj, '★★ [F6] report 侧的 paths 同样带 base/note(两面一致)');
437
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true })
438
+ }
439
+ console.log('\n-- F4: push 帧点名各代理状态的来源工具 --')
440
+ {
441
+ const src = readPluginSrc()
442
+ assert(/请调用 vibe_math_report 汇总当前进展,并用 vibe_math_list_agents 取各代理/.test(src), '★★ [F4] push 帧点名 vibe_math_list_agents(report 只给计数,逐代理状态在那个工具里)')
443
+ assert(/下一步:把该对象形式化到 Lean 通过/.test(src), '★★ [A5] require-gate 反馈行携带下一步(与 formal-verify-v2 的行为断言同源)')
444
+ }
445
+
446
+ // ---------------------------------------------------------------- F5 (fence-root drift)
447
+ console.log('\n-- F5: 围栏根与会话工作区不一致必须一次性告警 --');
448
+ {
449
+ const h5 = harness({ policyRoot: join(tmpdir(), 'not-this-session') });
450
+ await load(h5);
451
+ await h5.call('vibe_math_new_project', { name: 'p' });
452
+ await h5.call('vibe_math_add_problem', { id: 'qF5', description: 'x' });
453
+ await h5.call('vibe_math_start', {});
454
+ await h5.find(() => h5.spawns.length > 0);
455
+ const e5 = h5.errors.join('\n');
456
+ const hit5 = e5.split('\n').filter((l) => /sandbox fence root differs from this session workspace/.test(l));
457
+ assert(hit5.length === 1, '★★★ [F5] 围栏根漂移给一次性具名告警(实测 ' + JSON.stringify(hit5[0] || e5.slice(-160)) + ')');
458
+ h5.restore(); await wait(250); rmSync(h5.WS, { recursive: true, force: true });
459
+ }
460
+ // ---------------------------------------------------------------- F6a/F6b (silent-failure surfacing)
461
+ console.log('\n-- F6a/F6b: 静默回退与中断失败必须留痕 --');
462
+ {
463
+ // 5× 矩阵发现:一次性告警若已被本进程早先的段落消费,本段就再也看不到它。
464
+ // 因此这里走**文档化的 seam**加载一个**全新模块实例**(临时目录里复制 preset + 其相对导入),
465
+ // 断言真正的契约:告警内容正确、且在**同一实例内至多出现一次**。
466
+ const srcDir = dirname(fileURLToPath(PLUGIN_URL)); // seam-resolved: a V2_PLUGIN mutant is re-instantiated too
467
+ const cdir = mkdtempSync(join(tmpdir(), 'v2-f6a-'));
468
+ for (const f of ['vibe-math-v2.js', 'math-computation.js', 'math-engines.js']) copyFileSync(join(srcDir, f), join(cdir, f));
469
+ const prevPlugin = process.env.V2_PLUGIN;
470
+ process.env.V2_PLUGIN = join(cdir, 'vibe-math-v2.js');
471
+ const h1 = harness({ failSubagentsList: true });
472
+ try {
473
+ await load(h1);
474
+ await h1.call('vibe_math_new_project', { name: 'p' });
475
+ await h1.call('vibe_math_set_params', { maxParallelThreshold: 8 });
476
+ await h1.call('vibe_math_add_problem', { id: 'qA6', description: 'x' });
477
+ await h1.call('vibe_math_start', {});
478
+ await h1.find(() => h1.spawns.length > 0); // 异步 tick 才产出子代理
479
+ await h1.find(() => h1.spawns.length > 1, 20); // 尽量制造第二次 pickProvider 调用(至多一次的正面证据)
480
+ const hits = h1.errors.join('\n').split('\n').filter((l) => /pickProvider\(\) falling back to 'spawn'/.test(l));
481
+ assert(hits.length === 1 && /subagents\.list\(\) failed/.test(hits[0] || ''), '★★★ [F6a] 回退告警内容正确且在**同一实例内恰好一次**(实测 ' + JSON.stringify(hits) + ',spawns=' + h1.spawns.length + ')');
482
+ } finally {
483
+ if (prevPlugin === undefined) delete process.env.V2_PLUGIN; else process.env.V2_PLUGIN = prevPlugin;
484
+ h1.restore(); await wait(250); rmSync(h1.WS, { recursive: true, force: true }); rmSync(cdir, { recursive: true, force: true });
485
+ }
486
+ const h2 = harness({ failInterrupt: true });
487
+ await load(h2);
488
+ await h2.call('vibe_math_new_project', { name: 'p' });
489
+ await h2.call('vibe_math_add_problem', { id: 'qA6', description: 'x' });
490
+ await h2.call('vibe_math_start', {});
491
+ await h2.find(() => h2.spawns.length > 0);
492
+ await h2.call('vibe_math_abort', {});
493
+ const st2 = await h2.call('vibe_math_status', {});
494
+ // 收紧(round-4 守卫验证):不能只看"日志里有『中断失败』"——必须点名**站点**(event + 子代理 id + why 标签)。
495
+ const abortedId = h2.spawns.length ? h2.spawns[0].childId : 'c1';
496
+ const hits2 = (st2.recentActivity || []).filter((a) => String(a.event) === 'interrupt' && String(a.detail).indexOf(abortedId) !== -1 && /batch clear \(start\/abort\)/.test(String(a.detail)) && /中断失败/.test(String(a.detail)));
497
+ assert(hits2.length === 1, '★★★ [F6b/v2] 中断失败进入活动日志并**点名站点**(event=interrupt + 子代理 id + why 标签 batch clear(start/abort);实测 ' + JSON.stringify((st2.recentActivity || []).filter((a) => String(a.event) === 'interrupt').slice(-1)[0] || null) + ')');
498
+ h2.restore(); await wait(250); rmSync(h2.WS, { recursive: true, force: true });
499
+ }
500
+ // ---------------------------------------------------------------- F6c (project pointer must not fail silently)
501
+ console.log('\n-- F6c: 项目指针写失败必须可观察 --');
502
+ {
503
+ const h = harness({ failWritePath: 'current.' });
504
+ await load(h);
505
+ await h.call('vibe_math_new_project', { name: 'p' });
506
+ const st = await h.call('vibe_math_status', {});
507
+ const last = st.stateWriteFailures && st.stateWriteFailures.last;
508
+ assert(!!last && /current\./.test(JSON.stringify(last)), '★★★ [F6c] 项目指针写失败进入 status.stateWriteFailures(实测 ' + JSON.stringify(st.stateWriteFailures) + ')');
509
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true })
510
+ }
511
+ // ---------------------------------------------------------------- F2 (proactive tool-name intersection)
512
+ console.log('\n-- F2: 候选工具名与宿主可见工具面求交(composedToolList)--');
513
+ {
514
+ const schemas = ['vibe_math_status', 'vibe_math_report', 'web_search', 'pwsh', 'math_computation'].map((n) => ({ name: n }));
515
+ const h = harness({ toolSchemas: schemas });
516
+ await load(h);
517
+ await h.call('vibe_math_new_project', { name: 'p' });
518
+ await h.call('vibe_math_set_params', { maxParallelThreshold: 8, verifierCount: 2, verifierToolAllow: ['vibe_math_status'], verifierAllowNetwork: true });
519
+ await h.call('vibe_math_add_proposition', { id: 'pF2', 概述: 'P', 布尔估计: 0.6, 优先级: 1, 细类型: { 数论: {} } });
520
+ const f = join(h.project, 'Propos', '数论_Propos.json');
521
+ const list = JSON.parse(readFileSync(f, 'utf8'));
522
+ list[0].证明列表 = [{ 完整过程: 'proof', 正确概率: 0.7, 已验: false }];
523
+ writeFileSync(f, JSON.stringify(list, null, 2), 'utf8');
524
+ await h.call('vibe_math_start', {});
525
+ const v = await h.find(() => h.spawns.find((s2) => s2.label.startsWith('verifier:r-pF2-pf0:')));
526
+ assert(!!v, 'F2: 验证者子代理已派出(用于观察 toolFilter)');
527
+ const allow = (v && v.request && v.request.toolFilter && v.request.toolFilter.allow) || [];
528
+ assert(allow.indexOf('web_search') !== -1, '★ [F2] 宿主注册的 web_search 进入 allow(实测 ' + JSON.stringify(allow) + ')');
529
+ assert(allow.indexOf('web_fetch') === -1, '★★★ [F2] 宿主**未**注册的 web_fetch 绝不进入 filter(候选 ∩ tools.schemas;实测 ' + JSON.stringify(allow) + ')');
530
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true })
531
+ }
532
+ // ---------------------------------------------------------------- M15
533
+ console.log('\n-- M15: status and report share one recentActivity bound (30) --')
534
+ {
535
+ const h = harness(); await load(h)
536
+ await h.call('vibe_math_new_project', { name: 'p' })
537
+ for (let i = 0; i < 14; i++) { await h.call('vibe_math_pause', {}); await h.call('vibe_math_resume', {}) }
538
+ const st = await h.call('vibe_math_status', {})
539
+ const rep = await h.call('vibe_math_report', {})
540
+ assert(st.recentActivity.length > 10, 'status shows more than the old hardcoded 10 entries (' + st.recentActivity.length + ')')
541
+ assert(st.recentActivity.length === rep.recentActivity.length, 'status and report show the SAME number of entries (' + st.recentActivity.length + ' vs ' + rep.recentActivity.length + ')')
542
+ assert(st.recentActivity.length <= 30, 'and never more than the documented 30 (' + st.recentActivity.length + ')')
543
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
544
+ }
545
+
546
+ // ---------------------------------------------------------------- H5
547
+ console.log('\n-- H5: a host without subprocess reports the failure instead of degrading silently --')
548
+ {
549
+ const h = harness({ noSubprocess: true }); await load(h)
550
+ await h.call('vibe_math_new_project', { name: 'p' })
551
+ // migrateLegacyParams -> removeFile() on a host with no shell
552
+ writeFileSync(join(h.project, 'VibeMath_State', 'params.json'), JSON.stringify({ verdictMode: 'forced' }), 'utf8')
553
+ await h.call('vibe_math_status', {})
554
+ await wait(200)
555
+ const joined = h.errors.join('\n')
556
+ assert(/ensureDirs/.test(joined) && /no-subprocess/.test(joined), 'the failed mkdir is logged WITH its reason (ensureDirs ... no-subprocess)')
557
+ assert(/removeFile\(VibeMath_State\/params\.json\)/.test(joined) && /no-subprocess/.test(joined), 'the degraded delete is logged with its reason too')
558
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
559
+ }
560
+
561
+ // ---------------------------------------------------------------- L20
562
+ console.log('\n-- L20: a verdict the require gate withheld is reported as NOT applied --')
563
+ {
564
+ const h = harness(); await load(h)
565
+ await h.call('vibe_math_new_project', { name: 'p' })
566
+ await h.call('vibe_math_set_params', { maxParallelThreshold: 8, verifierCount: 2, formalVerify: 'require', mode: 'manual' })
567
+ await h.call('vibe_math_add_proposition', { id: 'pDefer', 概述: 'P', 布尔估计: 0.5, 优先级: 1, 细类型: { 数论: {} } })
568
+ await h.call('vibe_math_start', {})
569
+ const vs = await h.find(() => { const x = h.spawns.filter((s) => s.label.startsWith('verifier:r-pDefer:')); return x.length >= 2 ? x : undefined })
570
+ assert(!!vs, 'two reviewers were spawned for the bare proposition')
571
+ for (let i = 0; i < (vs || []).length; i++) h.fireEnd({ id: vs[i].childId, runId: 'd' + i, stopReason: 'completed', lastAssistantMessage: text({ Result: 1, Reason: 'probe' }) })
572
+ const dec = await h.findAsync(async () => { const d = await h.call('vibe_math_list_decisions', {}); return d.decisions.length ? d : undefined })
573
+ assert(!!dec && dec.decisions.length === 1, 'manual mode raised the verdict decision')
574
+ if (dec && dec.decisions.length) {
575
+ const res = await h.call('vibe_math_decide', { id: dec.decisions[0].id, action: 'approve' })
576
+ assert(res.ok === true && res.applied && res.applied.applied === false,
577
+ '★ the approval reports applied=false: the require gate withheld the verdict (got ' + JSON.stringify(res.applied) + ')')
578
+ const p = h.readProps().find((x) => x.id === 'pDefer') || {}
579
+ assert(p.布尔估计 === 0.5, 'and the proposition is untouched (no verdict was applied)')
580
+ const queue = JSON.parse(readFileSync(join(h.project, 'VibeMath_State', 'decision_queue.json'), 'utf8'))
581
+ const resolved = queue.find((d) => d.id === dec.decisions[0].id)
582
+ assert(!!resolved && resolved.status === 'resolved', 'the human decision itself is still resolved (a terminal state)')
583
+ const todo = existsSync(join(h.project, 'Formal', 'TODO.md')) ? readFileSync(join(h.project, 'Formal', 'TODO.md'), 'utf8') : ''
584
+ assert(/pDefer/.test(todo) && /formal-required/.test(todo), 'and the deferral is on the formalization TODO')
585
+ }
586
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
587
+ }
588
+
589
+ // ---------------------------------------------------------------- L19
590
+ console.log('\n-- L19: the string fallback must not read `r-pAmb-s1` as the same-prefix neighbour pAmb --')
591
+ {
592
+ const h = harness(); await load(h)
593
+ await h.call('vibe_math_new_project', { name: 'p' })
594
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8, tickIntervalMs: 200 })
595
+ await h.call('vibe_math_add_proposition', { id: 'pAmb', 概述: '同前缀邻居', 布尔估计: 0.5, 优先级: 1, 细类型: { 数论: {} } })
596
+ await h.call('vibe_math_add_proposition', { id: 'pAmb-s1', 概述: '对象 id 本身以 -s1 结尾', 布尔估计: 0.5, 优先级: 1, 细类型: { 数论: {} } })
597
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度的占位问题', priority: 9 })
598
+ await h.call('vibe_math_start', {})
599
+ await h.call('vibe_math_pause', {})
600
+ // A tick that started before the pause may still be in flight and call saveAll() AFTER our write —
601
+ // let it drain first, or the hand-written state file would be overwritten before resume reads it.
602
+ await wait(900)
603
+ // Simulate a legacy/hand-edited record: the authoritative `objectId` is absent, so ONLY the string
604
+ // fallback can tell `r-pAmb-s1` (object pAmb-s1) from `r-pAmb` + suffix (object pAmb). The neighbour
605
+ // carries its own `passed` record, so a wrong parse would visibly downgrade the WRONG object.
606
+ const f = join(h.project, 'VibeMath_State', 'formal.json')
607
+ mkdirSync(join(h.project, 'Verified', 'Lean'), { recursive: true })
608
+ writeFileSync(join(h.project, 'Verified', 'Lean', 'pAmb-s1.lean'), 'theorem p_amb_s1 : 3 + 3 = 6 := by decide\n', 'utf8')
609
+ const base = { status: 'passed', file: 'Formal/pAmb-s1.lean', proof: 'Verified/Lean/pAmb-s1.lean', decision: 'used', updatedAt: 1 }
610
+ writeFileSync(f, JSON.stringify({ records: {
611
+ 'r-pAmb-s1': Object.assign({}, base), // ← no objectId: the shape under test
612
+ pAmb: Object.assign({}, base, { file: 'Formal/pAmb.lean', proof: 'Verified/Lean/pAmb.lean' }),
613
+ }, todo: [] }), 'utf8')
614
+ assert(!!JSON.parse(readFileSync(f, 'utf8')).records['r-pAmb-s1'], 'the hand-edited record survived until resume (no in-flight tick overwrote it)')
615
+ await h.call('vibe_math_resume', {})
616
+ assert(JSON.parse(readFileSync(f, 'utf8')).records['r-pAmb-s1'].objectId === undefined, 'precondition: the alias record really carries no authoritative owner (the fallback is what gets exercised)')
617
+ const ex = await h.find(() => h.spawns.find((s) => s.label.startsWith('explorer:qKeep')), 60)
618
+ assert(!!ex, 'the explorer is running (the work-round reply path is live)')
619
+ if (ex) {
620
+ h.fireEnd({ id: ex.childId, runId: 'l19', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: text({ directions: [], formal: { target: 'r-pAmb-s1', decision: 'defect', note: 'Lean 只证了 n>0 的情形' } }) })
621
+ // Read the WHOLE record set once the defect landed on EITHER id, so the neighbour check also fails
622
+ // when the wrong owner was picked (a `find` that only looks for the right id would hide that).
623
+ const landed = await h.find(() => {
624
+ const d = JSON.parse(readFileSync(f, 'utf8')).records || {}
625
+ return ((d['pAmb-s1'] && d['pAmb-s1'].decision === 'defect') || (d.pAmb && d.pAmb.decision === 'defect')) ? d : undefined
626
+ }, 40)
627
+ const after = landed || JSON.parse(readFileSync(f, 'utf8')).records || {}
628
+ assert(!!after['pAmb-s1'] && after['pAmb-s1'].decision === 'defect', '★★ a defect named by r-pAmb-s1 reached its REAL owner pAmb-s1 through the string fallback (got ' + JSON.stringify(Object.keys(after).map((k) => k + ':' + after[k].decision)) + ')')
629
+ assert(!after['pAmb'] || after['pAmb'].decision !== 'defect', '★★ and the same-prefix neighbour pAmb was NOT downgraded (its own passed proof survives)')
630
+ }
631
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
632
+ }
633
+
634
+ // ================================================================ final paper (spec §6)
635
+ const PAPER_REPLY = {
636
+ title: '假论文|收敛后的整理',
637
+ abstract: '原问题是「假问题」;主要结论是假定理成立。',
638
+ sections: [
639
+ { name: '引言与问题背景', body: '背景:变量 a_b 与占比 50%,以及行内数学 $x^2+y^2$。' },
640
+ { name: '原问题的完整解法', body: '解法步骤:\n- 第一步\n- 第二步' },
641
+ { name: '已检验通过的命题', body: '命题 pClosed 的布尔估计为 1(经 ≥2 名验证者定论)。' },
642
+ { name: '已解决的子问题与中间成果', body: '子问题 qSub 已解决。' },
643
+ { name: '创造或发现的有价值之物', body: '方法 m1(可复用)。' },
644
+ { name: '规律总结', body: '规律:先化简再归纳。' },
645
+ { name: '讨论、局限与展望', body: '局限:pOpen 仍为 0.5(未定论),不得当成已成立。' },
646
+ ],
647
+ }
648
+ const paperWriterReplies = (h) => h.spawns.filter((s) => s.label.startsWith('paper-writer:'))
649
+ const firePaper = (h, reply) => {
650
+ const w = paperWriterReplies(h)[paperWriterReplies(h).length - 1]
651
+ if (!w) return false
652
+ h.fireEnd({ id: w.childId, runId: 'pw-' + w.childId, provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: text(reply || PAPER_REPLY) })
653
+ return true
654
+ }
655
+ const readMeta = (h, id) => JSON.parse(readFileSync(join(h.paperDir(id), 'paper.meta.json'), 'utf8'))
656
+
657
+ console.log('\n-- PAPER §6.1: params — defaults, schema presence, coercion, rejection --')
658
+ {
659
+ const h = harness(); await load(h)
660
+ const setup = await h.call('vibe_math_setup', {})
661
+ const by = {}; for (const p of setup.parameters) by[p.name] = p
662
+ for (const k of ['finalPaper', 'paperFormat', 'paperLanguage', 'paperCompilePdf', 'paperLatexCommand']) assert(!!by[k], 'setup schema exposes ' + k)
663
+ assert(by.finalPaper && by.finalPaper.default === true && by.paperFormat.default === 'both' && by.paperLanguage.default === 'zh' && by.paperCompilePdf.default === true && by.paperLatexCommand.default === '', 'defaults are true/both/zh/true/""')
664
+ assert(JSON.stringify(by.paperFormat.options) === JSON.stringify(['both', 'md', 'tex']) && JSON.stringify(by.paperLanguage.options) === JSON.stringify(['zh', 'en']), 'enums are documented in the schema/help text')
665
+ const bad = await h.call('vibe_math_set_params', { paperFormat: 'weird', paperLanguage: 'xx', finalPaper: 'false', paperCompilePdf: 'no', paperLatexCommand: ' ' })
666
+ assert(bad.params.paperFormat === 'both' && bad.params.paperLanguage === 'zh', 'illegal enum values fall back to the defaults (got ' + bad.params.paperFormat + '/' + bad.params.paperLanguage + ')')
667
+ assert(bad.params.finalPaper === true && bad.params.paperCompilePdf === true, '★★ a string "false"/"no" is NOT accepted as a boolean (spec v2 §B: unknown-style pass-through would leave it truthy)')
668
+ assert(bad.params.paperLatexCommand === '', 'a blank engine name falls back to auto-detection')
669
+ const good = await h.call('vibe_math_set_params', { paperFormat: 'tex', paperLanguage: 'en', finalPaper: false, paperCompilePdf: false, paperLatexCommand: 'xelatex' })
670
+ assert(good.params.paperFormat === 'tex' && good.params.paperLanguage === 'en' && good.params.finalPaper === false && good.params.paperCompilePdf === false && good.params.paperLatexCommand === 'xelatex', 'legal values are accepted verbatim')
671
+ const regs = h.toolRegs.filter((s) => s.name === 'vibe_math_set_params')
672
+ assert(regs.length === 1 && ['finalPaper', 'paperFormat', 'paperLanguage', 'paperCompilePdf', 'paperLatexCommand'].every((k) => !!regs[0].parameters.properties[k]), 'the registered tool schema carries all five paper keys')
673
+ const src = readPluginSrc()
674
+ console.log('\n-- F5/v2: 围栏根与会话工作区不一致必须一次性告警 --');
675
+ {
676
+ const realErr5v2 = console.error; const buf5v2 = [];
677
+ const h5v2 = harness({ policyRoot: join(tmpdir(), 'not-this-session') });
678
+ try {
679
+ console.error = (...a) => buf5v2.push(a.map(String).join(' '));
680
+ await load(h5v2);
681
+ await h5v2.call('vibe_math_new_project', { name: 'p' });
682
+ await h5v2.call('vibe_math_pause', {});
683
+ await h5v2.call('vibe_math_resume', {});
684
+ } finally { console.error = realErr5v2; }
685
+ h5v2.restore(); await wait(250); rmSync(h5v2.WS, { recursive: true, force: true });
686
+ const hits5v2 = buf5v2.join('\n').split('\n').filter((l) => /sandbox fence root differs from this session workspace/.test(l));
687
+ assert(hits5v2.length === 1, '★★★ [F5/v2] 围栏根漂移一次性具名告警(实测 ' + JSON.stringify(hits5v2[0] || buf5v2.slice(-160)) + ')');
688
+ }
689
+ // ---- F-B(单一真源):机读 parameters 由 PARAM_SCHEMA 派生,两处注册点、真源与文档三方一致 ----
690
+ {
691
+ const srcFb = readFileSync(PLUGIN_URL, 'utf8')
692
+ const sitesFb = srcFb.match(/objParams\(paramProps\(\)\)/g) || []
693
+ assert(sitesFb.length === 2, '★★★ [F-B/v2] 两处注册点都由 paramProps() 从 PARAM_SCHEMA 派生(实测 ' + sitesFb.length + ' 处;写回硬编码字面量会在此变红)')
694
+ const keysFb = (() => { const m = /const PARAM_PROPS_KEYS = \[([^\]]*)\]/.exec(srcFb); return m ? m[1].split(/,/).map((x) => x.trim().replace(/^.|.$/g, "")).filter(Boolean) : [] })()
695
+ const propsFb = regs[0].parameters.properties
696
+ assert(keysFb.length > 0 && keysFb.length === Object.keys(propsFb).length, '★★★ [F-B/v2] 机读键集合 == PARAM_PROPS_KEYS(声明 ' + keysFb.length + ' vs schema ' + Object.keys(propsFb).length + ')')
697
+ const byNameFb = {}; for (const p of setup.parameters) byNameFb[p.name] = p
698
+ const missingFb = keysFb.filter((k) => !propsFb[k] || !propsFb[k].description)
699
+ assert(missingFb.length === 0, '★★★ [F-B/v2] 每个机读描述都来自真源且非空(缺失 ' + JSON.stringify(missingFb) + ')')
700
+ const mismatchFb = keysFb.filter((k) => byNameFb[k] && propsFb[k].description !== byNameFb[k].description)
701
+ assert(mismatchFb.length === 0, '★★★ [F-B/v2] 机读 description == PARAM_SCHEMA 真源逐字(不一致 ' + JSON.stringify(mismatchFb) + ')')
702
+ const extraFb = (() => { const m = /const PARAM_PROPS_EXTRA = \{([\s\S]*?)\n\}/.exec(srcFb); return m ? [...m[1].matchAll(/^\s*([A-Za-z_][A-Za-z0-9_]*):/gm)].map((x) => x[1]) : [] })()
703
+ const docFb = readFileSync(new URL('../docs/parameter-schema.md', import.meta.url), 'utf8')
704
+ const docKeysFb = new Set([...docFb.matchAll(/`([A-Za-z_][A-Za-z0-9_]*)`/g)].map((m) => m[1]))
705
+ const undocumentedFb = extraFb.filter((k) => !docKeysFb.has(k))
706
+ const nFb = docFb.indexOf('## v2(') >= 0 ? Number((docFb.slice(docFb.indexOf('## v2(')).match(/(\d+) 条例外/) || [])[1] || 0) : 0
707
+ assert(extraFb.length > 0 && undocumentedFb.length === 0 && extraFb.length === nFb, '★★★ [F-B/v2] 例外集合 == docs/parameter-schema.md(实测 ' + extraFb.length + ' vs 文档 ' + nFb + ',未记录 ' + JSON.stringify(undocumentedFb) + ')')
708
+ }
709
+ assert(/paper \[lang=zh\|en\] \[format=both\|md\|tex\] \[force\]/.test(src), 'the /vibe hint and usage advertise `paper`')
710
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
711
+ }
712
+
713
+ console.log('\n-- PAPER §6.2/§6.3: closure trigger, idempotence, 9-section content, evidence index --')
714
+ {
715
+ const h = harness({ latex: { engines: ['xelatex'] } }); await load(h)
716
+ await h.call('vibe_math_new_project', { name: 'p' })
717
+ await h.call('vibe_math_set_params', { tickIntervalMs: 200, finalPaper: true, paperFormat: 'both', paperCompilePdf: true })
718
+ await h.call('vibe_math_status', {}) // ensures the session state exists
719
+ // 证据:一个命题(未定论,必须在论文里被标注)
720
+ await h.call('vibe_math_add_proposition', { id: 'pOpen', 概述: '仍未定论的命题', 布尔估计: 0.5, 优先级: 1, 细类型: { 数论: {} } })
721
+ // 负用例:还有未解决问题时不得派遣(严格终止未满足)
722
+ await h.call('vibe_math_add_problem', { id: 'qOpen', description: '未解决问题', priority: 0 })
723
+ await h.call('vibe_math_start', {})
724
+ await wait(700)
725
+ assert(paperWriterReplies(h).length === 0, '★ NOT converged → no paper writer is dispatched')
726
+ // 收口:把问题标成已解决/never、把命题停靠为 优先级=never(未定论但不再入选验证)——
727
+ // 这正是 v2 真正能收口的状态(任何仍为验证候选的对象都会阻止严格终止),并让论文里
728
+ // 有一个必须显式标注的未决项。随后清掉在途 explorer/verifier(终止判据含 agentRegistry==={})。
729
+ await h.call('vibe_math_pause', {})
730
+ const qs = h.readQs(); qs[0].已解决 = true; qs[0].优先级 = 'never'; h.writeQs(qs)
731
+ const pf = join(h.project, 'Propos', '数论_Propos.json')
732
+ const props = JSON.parse(readFileSync(pf, 'utf8'))
733
+ props[0].优先级 = 'never'
734
+ writeFileSync(pf, JSON.stringify(props, null, 2), 'utf8')
735
+ await h.call('vibe_math_abort', {})
736
+ await h.call('vibe_math_start', {})
737
+ const w = await h.find(() => { const x = paperWriterReplies(h); return x.length ? x : undefined }, 40)
738
+ assert(!!w, '★ closure (strict termination) dispatched exactly one paper writer')
739
+ assert(paperWriterReplies(h).length === 1, 'exactly one writer for this run (got ' + paperWriterReplies(h).length + ')')
740
+ const prompt = (w && w[0].request.prompt[0].text) || ''
741
+ assert(prompt.indexOf('DEDICATED PAPER WRITER') !== -1, 'the writer prompt is the single-author paper contract')
742
+ for (const s of h.H.PAPER_SKELETON) assert(prompt.indexOf(s.key) !== -1, 'the prompt carries skeleton section: ' + s.key)
743
+ assert(/\[UNRESOLVED \/ REFUTED/.test(prompt) && /pOpen/.test(prompt), '★★ the prompt lists the still-undecided item explicitly (spec v2 §A4)')
744
+ assert(/NEVER invent content/.test(prompt), 'the prompt forbids inventing content')
745
+ const stRun = await h.call('vibe_math_status', {})
746
+ assert(!!stRun.paper && stRun.paper.inFlight === (w && w[0].childId), 'status reports the in-flight paper writer')
747
+ assert(typeof stRun.paper.finalizedAt !== 'number' || stRun.paper.finalizedAt === null, 'no finalization recorded before the writer replies')
748
+ firePaper(h)
749
+ const metaFile = join(h.paperDir('p'), 'paper.meta.json')
750
+ const meta = await h.find(() => (existsSync(metaFile) ? readMeta(h, 'p') : undefined), 40)
751
+ assert(!!meta, '★ paper.meta.json was written when the writer ended')
752
+ const md = readFileSync(join(h.paperDir('p'), 'paper.md'), 'utf8')
753
+ const tex = readFileSync(join(h.paperDir('p'), 'paper.tex'), 'utf8')
754
+ const heads = (md.match(/^## /gm) || []).length
755
+ assert(heads === 9, '★ the md carries exactly the 9-section skeleton (got ' + heads + ')')
756
+ for (const s of h.H.PAPER_SKELETON) assert(md.indexOf('## ' + s.key) !== -1, 'md section present: ' + s.key)
757
+ assert(md.indexOf('### 证据与文件索引') !== -1 && md.indexOf('`qs/qs.json`') !== -1, '★ the appendix carries the evidence index (existing files only)')
758
+ assert(md.indexOf('未定论') !== -1, 'the unresolved item is labelled in the paper (不得编造)')
759
+ assert((tex.match(/\\section\{/g) || []).length === 9, '★ the tex carries the same 9 sections')
760
+ assert(tex.indexOf('\\documentclass[11pt]{ctexart}') !== -1, 'zh uses ctexart')
761
+ assert(tex.indexOf('a\\_b') !== -1 && tex.indexOf('50\\%') !== -1, '★★ underscore/percent are tex-escaped')
762
+ assert(tex.indexOf('a_b') === -1 && tex.indexOf('50%') === -1, '★★ and the raw forms are gone')
763
+ assert(tex.indexOf('$x^2+y^2$') !== -1, 'inline math is passed through unescaped')
764
+ assert(meta.trigger === 'auto' && Number(meta.runStartedAt) > 0, 'meta records the auto trigger + run id')
765
+ assert(meta.artifacts.md === true && meta.artifacts.tex === true && meta.artifacts.pdf === true, 'meta records md+tex+pdf')
766
+ assert(meta.compile === 'ok' && meta.compileEngine === 'xelatex', '★ the fake compiler produced the pdf (compile=' + meta.compile + ')')
767
+ const log = readFileSync(join(h.paperDir('p'), 'paper.log.md'), 'utf8')
768
+ assert(/\[dispatch\]/.test(log) && /\[finalize\]/.test(log) && /\[compile\]/.test(log), 'paper.log.md carries the fixed single-author log format')
769
+ // 幂等:同一 run 手动再触发 → 不重复派遣、不重写
770
+ const again = await h.cmd('paper')
771
+ const againBody = JSON.parse(again.text)
772
+ assert(again.kind === 'success' && againBody.skipped === true && againBody.reason === 'already-finalized-this-run', '★ a repeat trigger in the same run is idempotent (skipped, got ' + JSON.stringify(againBody.reason) + ')')
773
+ assert(paperWriterReplies(h).length === 1, '★★ the idempotent path did NOT dispatch a second writer')
774
+ // 缺产物 → 只补写(不派遣作者)
775
+ rmSync(join(h.paperDir('p'), 'paper.tex'), { force: true })
776
+ const filled = JSON.parse((await h.cmd('paper')).text)
777
+ assert(filled.skipped === true && filled.reason === 'filled-missing-artifacts' && Array.isArray(filled.filled) && filled.filled.indexOf('paper.tex') !== -1, '★ a missing artifact is re-derived from the finalized content (no new writer)')
778
+ assert(existsSync(join(h.paperDir('p'), 'paper.tex')), 'the missing paper.tex is back on disk')
779
+ assert(paperWriterReplies(h).length === 1, 'still no second writer')
780
+ // force → 重新撰写
781
+ const beforeForce = meta.finalizedAt
782
+ const forced = JSON.parse((await h.cmd('paper force')).text)
783
+ assert(forced.dispatched === true, '★ /vibe paper force re-dispatches')
784
+ assert(paperWriterReplies(h).length === 2, 'force really did dispatch a second writer')
785
+ firePaper(h)
786
+ const meta2 = await h.find(() => { const x = readMeta(h, 'p'); return x.finalizedAt !== beforeForce ? x : undefined }, 40)
787
+ assert(!!meta2, '★ the forced run replaced the finalized paper (finalizedAt changed)')
788
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
789
+ }
790
+
791
+ console.log('\n-- PAPER §6.2b: finalPaper=false → the AUTO trigger does not fire (manual still does) --')
792
+ {
793
+ const h = harness({ latex: null }); await load(h)
794
+ await h.call('vibe_math_new_project', { name: 'p' })
795
+ await h.call('vibe_math_set_params', { tickIntervalMs: 200, finalPaper: false })
796
+ await h.call('vibe_math_start', {}) // 空项目 ⇒ 立刻严格终止(收口)
797
+ await wait(1200)
798
+ assert(paperWriterReplies(h).length === 0, '★★ finalPaper=false → closure does NOT dispatch a paper writer')
799
+ const st = await h.call('vibe_math_status', {})
800
+ assert(!!st.paper && st.paper.autoFinalPaper === false, 'status reports the automatic paper trigger as disabled')
801
+ const man = JSON.parse((await h.cmd('paper')).text)
802
+ assert(man.dispatched === true && man.autoDisabled === true, 'the manual command still works and says the automatic trigger is off')
803
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
804
+ }
805
+
806
+ console.log('\n-- PAPER §6.4: compile branches (success / repaired / persistent failure / no LaTeX) --')
807
+ {
808
+ // (a) 成功
809
+ const okH = harness({ latex: { engines: ['xelatex'] } }); await load(okH)
810
+ await okH.call('vibe_math_new_project', { name: 'p' })
811
+ await okH.cmd('paper')
812
+ firePaper(okH)
813
+ let m = await okH.find(() => (existsSync(join(okH.paperDir('p'), 'paper.meta.json')) ? readMeta(okH, 'p') : undefined), 40)
814
+ assert(!!m && m.compile === 'ok' && m.artifacts.pdf === true, '★ (a) success → paper.pdf generated, compile=ok')
815
+ okH.restore(); rmSync(okH.WS, { recursive: true, force: true })
816
+
817
+ // (b) 失败一次后换引擎修复成功
818
+ const fixH = harness({ latex: { engines: ['xelatex', 'latexmk'], failFirst: true } }); await load(fixH)
819
+ await fixH.call('vibe_math_new_project', { name: 'p' })
820
+ await fixH.cmd('paper')
821
+ firePaper(fixH)
822
+ m = await fixH.find(() => (existsSync(join(fixH.paperDir('p'), 'paper.meta.json')) ? readMeta(fixH, 'p') : undefined), 40)
823
+ assert(!!m && m.compile === 'repaired', '★ (b) first engine fails → repair path reports repaired (got ' + (m && m.compile) + ')')
824
+ assert(!!m && m.compileEngine === 'latexmk' && m.compileAttempts[0].ok === false && m.compileAttempts[1].ok === true, '★ (b) the attempt trail shows the engine swap (xelatex failed → latexmk ok)')
825
+ assert(!!m && m.artifacts.pdf === true, '(b) the repaired compile produced the pdf')
826
+ fixH.restore(); rmSync(fixH.WS, { recursive: true, force: true })
827
+
828
+ // (c) 持续失败 → 降级:保留 tex+md + 警告 + 上报
829
+ const badH = harness({ latex: { engines: ['xelatex', 'latexmk'], alwaysFail: true } }); await load(badH)
830
+ await badH.call('vibe_math_new_project', { name: 'p' })
831
+ await badH.cmd('paper')
832
+ firePaper(badH)
833
+ m = await badH.find(() => (existsSync(join(badH.paperDir('p'), 'paper.meta.json')) ? readMeta(badH, 'p') : undefined), 40)
834
+ assert(!!m && m.compile === 'failed' && m.artifacts.pdf === false, '★ (c) persistent failure → compile=failed, no pdf claimed')
835
+ assert(!!m && m.artifacts.md === true && m.artifacts.tex === true, '★ (c) tex+md are kept (degrade, do not block finalization)')
836
+ assert(!existsSync(join(badH.paperDir('p'), 'paper.pdf')), '(c) no pdf exists (never faked)')
837
+ const st = await badH.call('vibe_math_status', {})
838
+ assert(st.recentActivity.some((a) => /编译失败/.test(a.detail)), '★ (c) the failure is reported on the v2-readable channel (activity log)')
839
+ assert(readFileSync(join(badH.paperDir('p'), 'paper.log.md'), 'utf8').indexOf('所有尝试均失败') !== -1, '(c) the log states the bounded retry sequence is exhausted')
840
+ badH.restore(); rmSync(badH.WS, { recursive: true, force: true })
841
+
842
+ // (d) 真机无 LaTeX(本机就是这一档)→ 干净降级,不报错
843
+ const noH = harness(); await load(noH)
844
+ await noH.call('vibe_math_new_project', { name: 'p' })
845
+ const before = noH.errors.length
846
+ await noH.cmd('paper')
847
+ firePaper(noH)
848
+ m = await noH.find(() => (existsSync(join(noH.paperDir('p'), 'paper.meta.json')) ? readMeta(noH, 'p') : undefined), 40)
849
+ assert(!!m && m.compile === 'not-detected', '★ (d) no LaTeX on the host → compile=not-detected (got ' + (m && m.compile) + ')')
850
+ assert(!!m && m.artifacts.tex === true && m.artifacts.md === true && m.artifacts.pdf === false, '★ (d) only tex+md are delivered')
851
+ assert(noH.errors.length === before, '★★ (d) the not-detected branch logs no error (clean degradation)')
852
+ assert(readFileSync(join(noH.paperDir('p'), 'paper.log.md'), 'utf8').indexOf('未检测到任何 LaTeX 引擎') !== -1, '(d) the log records why no pdf was produced')
853
+ noH.restore(); rmSync(noH.WS, { recursive: true, force: true })
854
+
855
+ // (e) resolver 在、但一个引擎都解析不到(真机另一种形态)
856
+ const emptyH = harness({ latex: { mode: 'resolver-empty' } }); await load(emptyH)
857
+ await emptyH.call('vibe_math_new_project', { name: 'p' })
858
+ await emptyH.cmd('paper')
859
+ firePaper(emptyH)
860
+ m = await emptyH.find(() => (existsSync(join(emptyH.paperDir('p'), 'paper.meta.json')) ? readMeta(emptyH, 'p') : undefined), 40)
861
+ assert(!!m && m.compile === 'not-detected', '★ (e) an empty resolver also degrades to not-detected')
862
+ emptyH.restore(); rmSync(emptyH.WS, { recursive: true, force: true })
863
+ }
864
+
865
+ console.log('\n-- PAPER §6.5/§6.6: path confinement, id normalisation, command surface + finalPaper=false --')
866
+ {
867
+ const h = harness({ latex: { engines: ['xelatex'] } }); await load(h)
868
+ // 纯函数:id 归一化不可能带出路径分隔符
869
+ for (const raw of ['../../etc/passwd', 'a/b', '..', 'C:\\x\\y']) {
870
+ const id = h.H.paperDirId(raw)
871
+ assert(id.indexOf('/') === -1 && id.indexOf('\\') === -1 && id.indexOf('..') === -1, 'paperDirId(' + JSON.stringify(raw) + ') is a single safe segment (' + JSON.stringify(id) + ')')
872
+ }
873
+ await h.call('vibe_math_new_project', { name: 'p' })
874
+ await h.call('vibe_math_set_params', { finalPaper: false })
875
+ const r = JSON.parse((await h.cmd('paper')).text)
876
+ assert(r.ok === true && r.dispatched === true, '★ /vibe paper still works with finalPaper=false (auto only)')
877
+ assert(r.autoDisabled === true && /自动触发已关闭/.test(r.message), '★ the reply says the automatic trigger is disabled')
878
+ firePaper(h)
879
+ const m = await h.find(() => (existsSync(join(h.paperDir('p'), 'paper.meta.json')) ? readMeta(h, 'p') : undefined), 40)
880
+ assert(!!m && m.dir === 'Paper/p' && m.trigger === 'manual', 'the manual run is recorded with trigger=manual under Paper/<id>/')
881
+ const files = readdirSync(h.paperDir('p')).sort()
882
+ assert(files.every((f) => ['paper.md', 'paper.tex', 'paper.pdf', 'paper.meta.json', 'paper.log.md', 'paper.lock.json'].indexOf(f) !== -1), '★ only the paper artifacts live in Paper/<id>/ (got ' + JSON.stringify(files) + ')')
883
+ assert(files.indexOf('paper.md') !== -1 && files.indexOf('paper.meta.json') !== -1, 'the expected artifacts exist')
884
+ assert(!existsSync(join(h.WS, 'VibeMath', 'Projects', 'p', 'Space', 'p')), 'no escaped paper directory was created')
885
+ assert(!existsSync(join(h.WS, 'etc')) && !existsSync(join(h.WS, 'y')), 'no file/dir was written outside the project tree')
886
+ // 非法命令参数 → kind:'error'
887
+ const badLang = await h.cmd('paper lang=xx')
888
+ const badFmt = await h.cmd('paper format=nope')
889
+ const badOpt = await h.cmd('paper what=1')
890
+ assert(badLang.kind === 'error' && /lang=/.test(badLang.text), '★ /vibe paper lang=xx is rejected as kind:error')
891
+ assert(badFmt.kind === 'error' && /format=/.test(badFmt.text), '★ /vibe paper format=nope is rejected as kind:error')
892
+ assert(badOpt.kind === 'error', '★ an unknown paper option is rejected as kind:error')
893
+ // lang=/format= 覆盖本次参数(md 模式不产出 tex / 不编译)
894
+ await h.call('vibe_math_set_params', { finalPaper: true })
895
+ const beforeMd = readMeta(h, 'p').finalizedAt
896
+ const mdRun = JSON.parse((await h.cmd('paper lang=en format=md force')).text)
897
+ assert(mdRun.dispatched === true, 'lang=/format= overrides dispatch a new writer')
898
+ firePaper(h)
899
+ const m2 = await h.find(() => { const x = readMeta(h, 'p'); return x.finalizedAt !== beforeMd ? x : undefined }, 40)
900
+ assert(!!m2 && m2.params.paperFormat === 'md' && m2.params.paperLanguage === 'en', '★ the override is recorded in meta (md/en)')
901
+ assert(!!m2 && m2.compile === 'skipped', '★★ paperFormat=md skips compilation entirely (§E: no "missing tex" warning)')
902
+ assert(readFileSync(join(h.paperDir('p'), 'paper.log.md'), 'utf8').indexOf('缺少') === -1, 'no misleading compile warning for md-only runs')
903
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
904
+ }
905
+
906
+ console.log('\n-- PAPER §A2: ACTIVATION_LIMIT_REACHED → queue + retry + visible warning --')
907
+ {
908
+ const h = harness({ refuseAttempts: 1, latex: null }); await load(h)
909
+ await h.call('vibe_math_new_project', { name: 'p' })
910
+ const r = JSON.parse((await h.cmd('paper')).text)
911
+ assert(r.ok === true && r.queued === true && r.reason === 'activation-limit-reached', '★ the refused dispatch is QUEUED (not silently lost), got ' + JSON.stringify(r.reason))
912
+ assert(r.tries === 1 && r.limit === 2, 'the refusal records the host limit from the error text')
913
+ const st = await h.call('vibe_math_status', {})
914
+ assert(!!st.paper && !!st.paper.queued && st.paper.queued.tries === 1, 'status exposes the queued retry')
915
+ assert(h.errors.some((e) => /activation-limit refusal/.test(e)), '★★ the queueing is a VISIBLE warning (console)')
916
+ const acts = (await h.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
917
+ assert(/激活上限/.test(acts), '★★ and it is visible on the v2-readable channel (activity log)')
918
+ const retried = await h.find(() => (paperWriterReplies(h).length ? paperWriterReplies(h) : undefined), 70)
919
+ assert(!!retried, '★★ the paper heartbeat retried after the refusal and dispatched the writer (independent of scheduler.running)')
920
+ firePaper(h)
921
+ const m = await h.find(() => (existsSync(join(h.paperDir('p'), 'paper.meta.json')) ? readMeta(h, 'p') : undefined), 40)
922
+ assert(!!m, 'the retried writer still finalized the paper')
923
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
924
+ }
925
+
926
+ console.log('\n-- PAPER §1: a hung writer can never wedge the paper (force refuses early, reaps after the window) --')
927
+ {
928
+ const h = harness({ latex: null }); await load(h)
929
+ await h.call('vibe_math_new_project', { name: 'p' })
930
+ await h.call('vibe_math_set_params', { finalPaper: false }) // 本节只考 force 的回收判定:关掉自动重派以保证确定性
931
+ // 纯函数判定(两条分支 + child 已消失)
932
+ assert(h.H.paperWriterVerdict({ inFlight: false }) === 'none', 'no in-flight writer → none')
933
+ assert(h.H.paperWriterVerdict({ inFlight: true, force: false, ageMs: 1000, staleMs: 600000, childInRegistry: true }) === 'none', 'fresh writer + no force → none (a normal skip)')
934
+ assert(h.H.paperWriterVerdict({ inFlight: true, force: true, ageMs: 1000, staleMs: 600000, childInRegistry: true }) === 'refuse', '★ force before the stale window → refuse (actionable error, not a wedge)')
935
+ assert(h.H.paperWriterVerdict({ inFlight: true, force: false, ageMs: 700000, staleMs: 600000, childInRegistry: true }) === 'reap', '★ past the stale window → reap (the heartbeat degrades automatically)')
936
+ assert(h.H.paperWriterVerdict({ inFlight: true, force: true, ageMs: 700000, staleMs: 600000, childInRegistry: true }) === 'reap', '★ force past the stale window → reap')
937
+ assert(h.H.paperWriterVerdict({ inFlight: true, force: false, ageMs: 10, staleMs: 600000, childInRegistry: false }) === 'reap', '★ child gone from the registry → reap immediately, whatever the age')
938
+ // 驱动:派遣一个永不返回的撰写者
939
+ const r1 = JSON.parse((await h.cmd('paper')).text)
940
+ assert(r1.dispatched === true, 'a writer is dispatched')
941
+ const w1 = h.spawns.filter((s) => s.label.startsWith('paper-writer:'))[0]
942
+ const refused = await h.cmd('paper force')
943
+ const rb = JSON.parse(refused.text)
944
+ assert(refused.kind === 'error' && rb.reason === 'writer-in-flight', '★★ force before the window returns kind:error with reason=writer-in-flight (old code silently skipped: the wedge)')
945
+ assert(/在途/.test(rb.message) && /10 分钟/.test(rb.message) && /回收/.test(rb.message), '★★ and the message is ACTIONABLE (names the in-flight child, the window and the automatic reap)')
946
+ assert(h.spawns.filter((s) => s.label.startsWith('paper-writer:')).length === 1, 'no second writer was dispatched by the refused force')
947
+ const st = await h.call('vibe_math_status', {})
948
+ assert(st.paper.inFlight === w1.childId && st.paper.staleMs === 600000 && st.paper.inFlightAgeMs !== null, 'status exposes the in-flight writer, its age and the stale window')
949
+ // 时间旅行到窗口之后:force 必须回收并重派("死掉的撰写者不会永久卡住论文")
950
+ const realNow = Date.now
951
+ let after
952
+ try {
953
+ Date.now = () => realNow.call(Date) + 11 * 60 * 1000
954
+ await wait(50)
955
+ after = JSON.parse((await h.cmd('paper force')).text)
956
+ } finally { Date.now = realNow }
957
+ assert(after.dispatched === true, '★ force past the stale window reaps the hung writer and dispatches a fresh one')
958
+ assert(h.spawns.filter((s) => s.label.startsWith('paper-writer:')).length === 2, 'a fresh writer really was dispatched')
959
+ const log = readFileSync(join(h.paperDir('p'), 'paper.log.md'), 'utf8')
960
+ assert(/\[reap\]/.test(log), '★★ the reap is logged honestly (with its reason)')
961
+ assert(/\[force-refused\]/.test(log), 'the earlier force refusal is logged too')
962
+ firePaper(h)
963
+ const meta = await h.find(() => (existsSync(join(h.paperDir('p'), 'paper.meta.json')) ? readMeta(h, 'p') : undefined), 40)
964
+ assert(!!meta, '★★ after reap+force the paper is produced — a dead writer can no longer wedge it')
965
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
966
+ }
967
+
968
+ console.log('\n-- PAPER §1b: the heartbeat reaps a stalled writer and re-dispatches automatically --')
969
+ {
970
+ const h = harness({ latex: null }); await load(h)
971
+ await h.call('vibe_math_new_project', { name: 'p' })
972
+ await h.call('vibe_math_set_params', { finalPaper: true })
973
+ const r = JSON.parse((await h.cmd('paper')).text)
974
+ assert(r.dispatched === true, 'a writer is dispatched')
975
+ const realNow = Date.now
976
+ Date.now = () => realNow.call(Date) + 11 * 60 * 1000
977
+ const second = await h.find(() => (h.spawns.filter((s) => s.label.startsWith('paper-writer:')).length >= 2 ? true : undefined), 60)
978
+ Date.now = realNow
979
+ assert(!!second, '★★ the apply-level heartbeat reaped the stalled writer and auto-dispatched a replacement (independent of scheduler.running)')
980
+ const log = readFileSync(join(h.paperDir('p'), 'paper.log.md'), 'utf8')
981
+ assert(/\[reap\]/.test(log) && /\[retry-after-reap\]/.test(log), 'the reap and the bounded auto-retry are both logged')
982
+ const acts = (await h.call('vibe_math_status', {})).recentActivity.map((a) => a.detail).join('\n')
983
+ assert(/回收卡死的论文撰写子代理/.test(acts), 'and the reap is visible on the activity log (not silent)')
984
+ firePaper(h)
985
+ const meta = await h.find(() => (existsSync(join(h.paperDir('p'), 'paper.meta.json')) ? readMeta(h, 'p') : undefined), 40)
986
+ assert(!!meta, 'the auto-recovered writer finalized the paper (no permanent wedge even without /vibe paper force)')
987
+ h.restore(); await wait(250); rmSync(h.WS, { recursive: true, force: true }) // deferred deletion: let the plugin's pending async work finish first (harness flake fix)
988
+ }
989
+
990
+ console.log('\n-- PAPER §2: a pre-existing paper.pdf is never deleted or overwritten --')
991
+ {
992
+ const PDF = '%PDF-1.4 pre-existing bytes\n'
993
+ // (a) 无 LaTeX ⇒ not-detected:既有 pdf 必须逐字节不变
994
+ const a = harness({ latex: null }); await load(a)
995
+ await a.call('vibe_math_new_project', { name: 'p' })
996
+ await a.cmd('paper')
997
+ mkdirSync(a.paperDir('p'), { recursive: true })
998
+ writeFileSync(join(a.paperDir('p'), 'paper.pdf'), PDF, 'utf8')
999
+ firePaper(a)
1000
+ let m = await a.find(() => (existsSync(join(a.paperDir('p'), 'paper.meta.json')) ? readMeta(a, 'p') : undefined), 40)
1001
+ assert(!!m && m.compile === 'not-detected' && m.pdfPreserved === true, '★ (a) no engine → not-detected and meta records pdfPreserved')
1002
+ assert(readFileSync(join(a.paperDir('p'), 'paper.pdf'), 'utf8') === PDF, '★★ (a) the pre-existing paper.pdf is byte-identical after a not-detected run')
1003
+ a.restore(); rmSync(a.WS, { recursive: true, force: true })
1004
+ // (b) 编译器持续失败 ⇒ failed:既有 pdf 仍必须逐字节不变
1005
+ const b = harness({ latex: { engines: ['xelatex', 'latexmk'], alwaysFail: true } }); await load(b)
1006
+ await b.call('vibe_math_new_project', { name: 'p' })
1007
+ await b.cmd('paper')
1008
+ mkdirSync(b.paperDir('p'), { recursive: true })
1009
+ writeFileSync(join(b.paperDir('p'), 'paper.pdf'), PDF, 'utf8')
1010
+ firePaper(b)
1011
+ m = await b.find(() => (existsSync(join(b.paperDir('p'), 'paper.meta.json')) ? readMeta(b, 'p') : undefined), 40)
1012
+ assert(!!m && m.compile === 'failed' && m.pdfPreserved === true, '★ (b) persistent failure → failed + pdfPreserved')
1013
+ assert(readFileSync(join(b.paperDir('p'), 'paper.pdf'), 'utf8') === PDF, '★★ (b) the pre-existing paper.pdf survives a failed compile byte-identically (never deleted, never overwritten)')
1014
+ b.restore(); rmSync(b.WS, { recursive: true, force: true })
1015
+ // (c) paperFormat=md ⇒ 连引擎探测都不做,pdf 更不可能被碰
1016
+ const c = harness({ latex: { engines: ['xelatex'] } }); await load(c)
1017
+ await c.call('vibe_math_new_project', { name: 'p' })
1018
+ const runsAtStart = c.latexRuns.length
1019
+ await c.cmd('paper')
1020
+ firePaper(c)
1021
+ const meta1 = await c.find(() => (existsSync(join(c.paperDir('p'), 'paper.meta.json')) ? readMeta(c, 'p') : undefined), 40)
1022
+ assert(!!meta1 && meta1.artifacts.tex === true, '(c) the first (default both) run produced tex and probed the engines')
1023
+ const runsAfterTex = c.latexRuns.length
1024
+ assert(runsAfterTex > runsAtStart, '(c) the both-format run really probed/ran an engine')
1025
+ // 这台编译器的产物已经落到 paper.pdf;现在放一个**哨兵** pdf,验证 md-only 运行绝不碰它
1026
+ writeFileSync(join(c.paperDir('p'), 'paper.pdf'), PDF, 'utf8')
1027
+ const beforeAt = meta1.finalizedAt
1028
+ const mdRun = JSON.parse((await c.cmd('paper format=md force')).text)
1029
+ assert(mdRun.dispatched === true, '(c) a forced md-only rewrite is dispatched')
1030
+ firePaper(c)
1031
+ const m2 = await c.find(() => { const x = readMeta(c, 'p'); return x.finalizedAt !== beforeAt ? x : undefined }, 40)
1032
+ assert(!!m2 && m2.params.paperFormat === 'md' && m2.compile === 'skipped', '★ (c) paperFormat=md skips compilation entirely')
1033
+ assert(c.latexRuns.length === runsAfterTex, '★★ (c) md-only never even probes the LaTeX engines')
1034
+ assert(readFileSync(join(c.paperDir('p'), 'paper.pdf'), 'utf8') === PDF, '★★ (c) the pre-existing paper.pdf is untouched by an md-only run')
1035
+ c.restore(); rmSync(c.WS, { recursive: true, force: true })
1036
+ }
1037
+
1038
+ console.log('\nFIXES PROBE: ' + passed + ' passed, ' + failed + ' failed')
1039
+ process.exit(failed === 0 ? 0 : 1)