dsh-vibe-math 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/AUDIT-CHECKLIST.md +28 -0
  2. package/README.md +98 -1
  3. package/RELEASE-NOTES-2.3.0.md +207 -0
  4. package/audit-formal-sensitivity.mjs +247 -0
  5. package/audit-persona-sensitivity.mjs +249 -0
  6. package/audit-persona-surface.test.mjs +349 -0
  7. package/audit-v5-integrity.mjs +40 -1
  8. package/audit-v5-sensitivity.mjs +77 -6
  9. package/docs/formal-verification.md +321 -0
  10. package/docs/generate_framework_diagram_v5.mjs +22 -16
  11. package/formal-verify-v2.test.mjs +672 -0
  12. package/formal-verify-v3.test.mjs +824 -0
  13. package/formal-verify-v4.test.mjs +603 -0
  14. package/formal-verify-v5.test.mjs +526 -0
  15. package/package.json +15 -2
  16. package/prompt-corpus-persona/persona-corpus.json +32 -0
  17. package/prompt-corpus-persona/persona-corpus.md +674 -0
  18. package/prompt-corpus-v3/formal-verify-v3.json +280 -0
  19. package/prompt-corpus-v3/formal-verify-v3.md +2826 -0
  20. package/prompt-corpus-v5/prompt-corpus-v5.json +75 -9
  21. package/prompt-corpus-v5/prompt-corpus-v5.md +384 -65
  22. package/prompt-v5-integrity.test.mjs +111 -10
  23. package/vibe-math-v2/agent.cordis.yml +40 -2
  24. package/vibe-math-v2/vibe-math-v2.js +627 -19
  25. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +145 -1
  26. package/vibe-math-v3/agent.cordis.yml +46 -2
  27. package/vibe-math-v3/vibe-math-v3.js +749 -21
  28. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +87 -2
  29. package/vibe-math-v4/agent.cordis.yml +46 -4
  30. package/vibe-math-v4/vibe-math-v4.js +652 -15
  31. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +226 -0
  32. package/vibe-math-v5/agent.cordis.yml +41 -5
  33. package/vibe-math-v5/vibe-math-v5.js +562 -9
  34. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +108 -4
  35. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +57 -0
  36. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +51 -46
@@ -0,0 +1,672 @@
1
+ // ============================================================
2
+ // V2 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
3
+ //
4
+ // Asserts the whole contract of the v2 `formalVerify` knob:
5
+ // · 'off' is a TRUE no-op: no Lean text in ANY prompt it builds, no formal record,
6
+ // no gate — while the three tools stay registered (registration is static)
7
+ // · 'encourage' injects the Lean section into BOTH the review and the debate prompt and the
8
+ // "顺手形式化" line into the solver/explorer prompts, and — the actual point of
9
+ // the feature — turns the review into a FIDELITY check once a Lean run passed
10
+ // · 'require' withholds a true/false verdict (未定论 + Formal/TODO.md + announcement) until
11
+ // the object is Lean-passed or carries an explicit, reasoned blocker; then the
12
+ // same verdict DOES write the card, and the card records the formal status
13
+ // · the three tools (run / archive / lib) write the right things to the right paths
14
+ //
15
+ // The Lean toolchain is mocked through the subprocess SERVICE (a fake Lean: exit 0 unless the
16
+ // file still contains `sorry` or the marker `-- FAIL`), so these tests exercise the REAL code
17
+ // path (resolveExecutable → spawn → collected stdout → exit code) without Lean installed.
18
+ //
19
+ // The plugin path honours V2_PLUGIN so a sensitivity probe can point this suite at a mutated
20
+ // copy of the plugin (a suite that ignored the override would make every probe vacuous —
21
+ // AUDIT-CHECKLIST §2.5).
22
+ //
23
+ // Run: node formal-verify-v2.test.mjs
24
+ // ============================================================
25
+ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
26
+ import { tmpdir } from 'node:os'
27
+ import { join, dirname, isAbsolute } from 'node:path'
28
+
29
+ const PLUGIN = process.env.V2_PLUGIN
30
+ ? new URL('file:///' + String(process.env.V2_PLUGIN).replace(/\\/g, '/'))
31
+ : new URL('./vibe-math-v2/vibe-math-v2.js', import.meta.url)
32
+
33
+ let passed = 0, failed = 0
34
+ const failures = []
35
+ const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
36
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
37
+ const section = (t) => console.log('\n[' + t + ']')
38
+
39
+ // ---------------------------------------------------------------
40
+ // the fake Lean toolchain (mocked at the SERVICE boundary)
41
+ // ---------------------------------------------------------------
42
+ let toolchainAvailable = true
43
+ const leanRuns = []
44
+ const subprocess = {
45
+ async resolveExecutable(cmd) {
46
+ if (!toolchainAvailable) throw new Error('spawn lean ENOENT')
47
+ if (String(cmd) !== 'lean') throw new Error('unknown executable ' + cmd)
48
+ return 'lean'
49
+ },
50
+ spawn(spec) {
51
+ const last = spec.argv[spec.argv.length - 1]
52
+ const script = String(last || '')
53
+ // v2 creates its directory tree through this SAME subprocess service
54
+ // (`powershell … New-Item -Force -ItemType Directory -Path 'a','b'`). Without honouring
55
+ // it, the Formal/ and Verified/Lean/ assertions would be vacuous: they would fail for the
56
+ // wrong reason (a mocked no-op) instead of a real defect.
57
+ if (/New-Item/.test(script)) {
58
+ const m = /-Path\s+(.+?)\s*(\||$)/.exec(script)
59
+ if (m) m[1].split(',').forEach((p) => { const q = p.trim().replace(/^'|'$/g, '').replace(/''/g, "'"); if (q) mkdirSync(q, { recursive: true }) })
60
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
61
+ }
62
+ if (/mkdir -p /.test(script)) {
63
+ const m = /mkdir -p (.+)$/.exec(script)
64
+ if (m) m[1].split(/\s+/).forEach((p) => { const q = p.trim().replace(/^'|'$/g, ''); if (q) mkdirSync(q, { recursive: true }) })
65
+ return { done: Promise.resolve({ exitCode: 0 }), collected: {}, terminate() {} }
66
+ }
67
+ const text = existsSync(last) ? readFileSync(last, 'utf8') : ''
68
+ const bad = /sorry|-- FAIL/.test(text)
69
+ leanRuns.push({ argv: spec.argv.slice(0, -1), file: last, cwd: spec.cwd, graceMs: spec.graceMs, stdio: spec.stdio })
70
+ const stdout = bad ? '' : 'ok\n'
71
+ const stderr = bad ? 'error: declaration uses sorry\n' : ''
72
+ return {
73
+ done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
74
+ collected: {
75
+ stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
76
+ stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
77
+ },
78
+ terminate() {},
79
+ }
80
+ },
81
+ }
82
+
83
+ // ---------------------------------------------------------------
84
+ // mock host (v2 standing-mount shape: one plugin instance, many sessions)
85
+ // ---------------------------------------------------------------
86
+ function makeHost(WS, opts = {}) {
87
+ const listeners = {}
88
+ const toolRegs = []
89
+ const cmdRegs = []
90
+ const spawns = []
91
+ const followups = []
92
+ const interrupts = []
93
+ const ctx = {
94
+ get(name) {
95
+ if (name === 'subprocess') return opts.noSubprocess ? undefined : subprocess
96
+ if (name === 'sandboxPolicy') return { workspaceRoot: WS, resolve: () => ({ workspaceRoot: WS }) }
97
+ return undefined
98
+ },
99
+ on(event, fn) { (listeners[event] = listeners[event] || []).push(fn) },
100
+ effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
101
+ logger: { info() {}, warn() {}, error() {} },
102
+ tools: { register(spec) { toolRegs.push(spec); return () => {} } },
103
+ commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
104
+ timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
105
+ subagents: {
106
+ list() { return ['spawn'] },
107
+ async startContinuable({ label, request }) {
108
+ const childId = 'child-' + (spawns.length + 1) + '-' + Math.random().toString(36).slice(2, 6)
109
+ spawns.push({ label, childId, rootId: (request && request.parent && request.parent.id) || 'root', prompt: request && request.prompt && request.prompt[0] && request.prompt[0].text })
110
+ return { childId }
111
+ },
112
+ async followup(parent, childId, blocks) { followups.push({ childId, prompt: (blocks && blocks[0] && blocks[0].text) || '' }) },
113
+ async sendMessage(parent, childId, blocks) { followups.push({ childId, prompt: (blocks && blocks[0] && blocks[0].text) || '' }) },
114
+ interrupt(childId) { interrupts.push(childId) },
115
+ },
116
+ agents: { roots() { return [] }, get() { return undefined } },
117
+ fs: {
118
+ async resolve(rel, o) {
119
+ const raw = String(rel)
120
+ const p = isAbsolute(raw) ? raw : join((o && o.cwd) || WS, ...raw.split('/'))
121
+ return p.replace(/\\/g, '/')
122
+ },
123
+ async stat(t) { if (!existsSync(t)) return undefined; return { type: statSync(t).isDirectory() ? 'directory' : 'file' } },
124
+ async readText(t) { return readFileSync(t, 'utf8') },
125
+ async writeText(t, c) { mkdirSync(dirname(t), { recursive: true }); writeFileSync(t, c, 'utf8') },
126
+ async listDir(t) { if (!existsSync(t)) return []; return readdirSync(t, { withFileTypes: true }).map((e) => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
127
+ },
128
+ }
129
+ const fireEnd = (info) => { for (const h of (listeners['subagent/end'] || [])) h(info) }
130
+ return { ctx, toolRegs, cmdRegs, spawns, followups, interrupts, fireEnd, WS }
131
+ }
132
+
133
+ function makeRoot(id, WS) {
134
+ return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, followup() {} }
135
+ }
136
+
137
+ const hosts = []
138
+ /**
139
+ * Build an INDEPENDENT plugin instance + workspace for one test case.
140
+ * AUDIT-CHECKLIST §2.3: cases must not share a session root/workspace, or a leftover
141
+ * heartbeat/verification from one case pollutes the next.
142
+ */
143
+ async function makeCase(label, opts = {}) {
144
+ const WS = mkdtempSync(join(tmpdir(), 'vibe-v2-lean-' + label + '-'))
145
+ const h = makeHost(WS, opts)
146
+ const mod = await import(PLUGIN.href + '?t=' + Date.now() + '-' + Math.random().toString(36).slice(2, 8))
147
+ ;(mod.default || mod).apply(h.ctx)
148
+ const root = makeRoot('sess-' + label, WS)
149
+ const call = async (name, args, agent) => {
150
+ const spec = h.toolRegs.find((s) => s.name === name)
151
+ if (!spec) throw new Error('no tool ' + name)
152
+ return JSON.parse(await spec.execute(args || {}, { agent: agent || root }))
153
+ }
154
+ await call('vibe_math_new_project', { name: 'proj' })
155
+ // 200ms is the sanitizer floor; verifierCount=2 (the sanitizer floor too) keeps each
156
+ // verification exactly two children, so a round settles predictably.
157
+ await call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2 })
158
+ const c = Object.assign(h, { WS, root, call, label })
159
+ hosts.push(c)
160
+ return c
161
+ }
162
+ // The scheduler only picks objects up while it is RUNNING (scheduleTick early-returns).
163
+ async function startScheduler(h) { await h.call('vibe_math_start', {}) }
164
+
165
+ const projRoot = (h) => join(h.WS, 'VibeMath', 'Projects', 'proj')
166
+ const vibeRoot = (h) => join(h.WS, 'VibeMath')
167
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
168
+ const formalStateOf = (h) => JSON.parse(readIf(join(projRoot(h), 'VibeMath_State', 'formal.json')) || '{}')
169
+ // One scheduler pass. v2's timer polls at 1s and ticks when tickDue() (200ms) — so one
170
+ // wall-clock second advances roughly one tick, exactly like the other v2 suites assume.
171
+ const tick = (ms = 1200) => sleep(ms)
172
+ async function waitFor(pred, tries = 40, ms = 120) {
173
+ for (let i = 0; i < tries; i++) { const v = pred(); if (v) return v; await sleep(ms) }
174
+ return undefined
175
+ }
176
+ const verifiersOf = (h, rKind, exclude) => h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind) && !(exclude || []).some((o) => o.childId === s.childId))
177
+ const fireVerdicts = (h, kids, vale) => {
178
+ for (let i = 0; i < kids.length; i++) {
179
+ h.fireEnd({ id: kids[i].childId, runId: 'v' + i, provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n' + JSON.stringify({ Result: vale, Reason: 'review ' + i }) + '\n```' }] })
180
+ }
181
+ }
182
+ /**
183
+ * Verify one object and DRIVE IT TO A SETTLEMENT.
184
+ *
185
+ * Important: v2's `processVerify` starts at most ONE verification and skips every candidate
186
+ * while a task is live, so leaving an object mid-debate would silently block the NEXT case's
187
+ * verification. The first `firstRounds` rounds are answered with `vale` (a non-unanimous value
188
+ * forces a real debate round, which is exactly what the debate-prompt assertions need), and any
189
+ * later round is answered with `settle` so the object actually closes.
190
+ */
191
+ async function verifyWithDebate(h, rKind, vale, firstRounds = 1, settle = 1) {
192
+ // v2 re-wakes the SAME verifier children for every debate round (it does not spawn new
193
+ // ones), so a new round is detected by counting the followups written to those children.
194
+ const kids = new Set()
195
+ for (let i = 0; i < 16; i++) {
196
+ // The scheduler may need a whole pass before it notices the object and another before it
197
+ // fills the verifier quota, so each wait must span several timer ticks.
198
+ const cand = await waitFor(() => {
199
+ const x = verifiersOf(h, rKind)
200
+ return x.length >= 2 ? x : undefined
201
+ }, 40, 250)
202
+ if (!cand) break
203
+ cand.forEach((s) => kids.add(s.childId))
204
+ const rounds = 1 + h.followups.filter((f) => kids.has(f.childId)).length
205
+ // `fireEnd` is a NO-OP for a child with no in-flight turn, so answering the whole current
206
+ // verifier set each pass is safe; the extra answers land before the next round's wakes.
207
+ fireVerdicts(h, cand, rounds <= firstRounds ? vale : settle)
208
+ await sleep(200)
209
+ await tick(1100)
210
+ }
211
+ const rounds = 1 + h.followups.filter((f) => kids.has(f.childId)).length
212
+ return { first: h.spawns.filter((s) => s.label.startsWith('verifier:' + rKind)), rounds: kids.size ? rounds : 0 }
213
+ }
214
+
215
+ // ===============================================================
216
+ console.log('-- V2 Lean formal verification --')
217
+
218
+ // ---------- 1. 'off' is a true no-op ----------
219
+ section("1 'off' (default) is a true no-op")
220
+ {
221
+ const h = await makeCase('off')
222
+ const st = await h.call('vibe_math_status', {})
223
+ assert(st.params.formalVerify === 'off', "the default mode is 'off' (got " + st.params.formalVerify + ')')
224
+ assert(st.formal.mode === 'off' && st.formal.required === false, 'status exposes the formal mode and gate flag')
225
+ assert(st.params.leanCommand === 'lean' && Array.isArray(st.params.leanArgs), 'the Lean knobs are readable in status.params')
226
+ assert(st.params.leanTimeoutMs === 120000, 'leanTimeoutMs defaults to 120000 (got ' + st.params.leanTimeoutMs + ')')
227
+ assert(!!h.toolRegs.find((t) => t.name === 'vibe_math_lean_run') && !!h.toolRegs.find((t) => t.name === 'vibe_math_lean_archive') && !!h.toolRegs.find((t) => t.name === 'vibe_math_lean_lib'),
228
+ 'the three Lean tools are registered in every mode (registration is static)')
229
+ assert(existsSync(join(vibeRoot(h), 'Formal', 'Lib')) && existsSync(join(vibeRoot(h), 'Formal', 'Proved')), 'the GLOBAL Formal/Lib + Formal/Proved dirs are created outside the project')
230
+ assert(existsSync(join(projRoot(h), 'Formal')) && existsSync(join(projRoot(h), 'Verified', 'Lean')), 'the project Formal/ and Verified/Lean/ dirs are created')
231
+ await h.call('vibe_math_add_problem', { id: 'q1', description: 'off 模式无操作测试' })
232
+ await startScheduler(h)
233
+ await tick(2000)
234
+ const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q1')), 60, 200)
235
+ assert(!!ex, 'the explorer was spawned (the scheduler really is running)')
236
+ assert(!!ex && !/Lean|形式化/.test(ex.prompt || ''), 'the explorer prompt contains no Lean/形式化 text in off mode')
237
+ if (ex) h.fireEnd({ id: ex.childId, runId: 'r1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"directions":[{"id":"d1","title":"D","method":"m","core_assumption":"c","feasibility":0.6}]}\n```' }] })
238
+ await sleep(300)
239
+ const so = await waitFor(() => h.spawns.find((s) => s.label.startsWith('solver:q1')), 40, 200)
240
+ assert(!!so, 'the solver was spawned after the explorer returned directions')
241
+ assert(!!so && !/Lean|形式化/.test(so.prompt || ''), 'the solver prompt contains no Lean/形式化 text in off mode')
242
+ if (so) h.fireEnd({ id: so.childId, runId: 'r2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"status":"success","solution":"complete solution","solution_probability":0.8,"lemmas":[],"routes":[],"lessons":[],"survival_probability":0.9,"dead_end_reason":null,"sub_questions":[]}\n```' }] })
243
+ const vs = await verifyWithDebate(h, 'r-q1-s0', 1)
244
+ assert(!!vs.first && vs.first.length >= 2, 'verifiers were spawned for the solution (' + (vs.first ? vs.first.length : 0) + ')')
245
+ const vp = vs.first ? (h.spawns.find((s) => s.label === 'verifier:r-q1-s0:0').prompt || '') : ''
246
+ assert(!/Lean|形式化/.test(vp), 'the review prompt contains no Lean/形式化 text in off mode')
247
+ const debateText = h.followups.map((f) => f.prompt || '').join('\n')
248
+ assert(!/Lean|形式化/.test(debateText), 'no debate/followup prompt mentions Lean in off mode')
249
+ const qs = JSON.parse(readIf(join(projRoot(h), 'qs', 'qs.json')) || '[]')
250
+ const q = qs.find((x) => x.id === 'q1')
251
+ assert(!!q && q.已解决 === true, "'off' still finalizes: the problem is marked solved with NO Lean artifact")
252
+ assert(!!q && q.解法列表 && q.解法列表[0].正确概率 === 1, 'the solution probability is 1 in off mode')
253
+ assert(existsSync(join(projRoot(h), 'Verified', '问题_Verified.json')), 'the Verified card was written in off mode')
254
+ assert(!/形式化/.test(readIf(join(projRoot(h), 'Verified', '问题_Verified.json'))), 'the card carries no formal line in off mode')
255
+ assert(!existsSync(join(projRoot(h), 'Formal', 'TODO.md')), 'no Formal/TODO.md is produced in off mode')
256
+ const fs0 = formalStateOf(h)
257
+ assert(!fs0.records || Object.keys(fs0.records).length === 0, 'off mode records no formal object state')
258
+ }
259
+
260
+ // ---------- 2. parameter validation + runtime switching ----------
261
+ section('2 parameter validation and runtime switching')
262
+ {
263
+ const h = await makeCase('params')
264
+ const bad = await h.call('vibe_math_set_params', { formalVerify: 'banana' })
265
+ assert(bad.params.formalVerify === 'off', "an unknown mode degrades to 'off', never to a stronger mode (got " + bad.params.formalVerify + ')')
266
+ const enc = await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
267
+ assert(enc.params.formalVerify === 'encourage', "'encourage' is accepted")
268
+ const req = await h.call('vibe_math_set_params', { formalVerify: 'require', leanTimeoutMs: -5, leanCommand: ' ' })
269
+ assert(req.params.formalVerify === 'require', "'require' is accepted")
270
+ assert(req.params.leanTimeoutMs === 120000, 'a non-positive leanTimeoutMs falls back to the default (' + req.params.leanTimeoutMs + ')')
271
+ assert(req.params.leanCommand === 'lean', 'a blank leanCommand falls back to "lean"')
272
+ const lake = await h.call('vibe_math_set_params', { leanCommand: 'lake', leanArgs: ['env', 'lean'] })
273
+ assert(lake.params.leanCommand === 'lake' && lake.params.leanArgs.join(' ') === 'env lean', 'leanCommand/leanArgs are settable (lake env lean)')
274
+ const st = await h.call('vibe_math_status', {})
275
+ assert(st.formal.leanCommand === 'lake' && st.formal.leanArgs.join(' ') === 'env lean', 'the Lean knobs round-trip through status')
276
+ const setup = await h.call('vibe_math_setup', {})
277
+ const names = setup.parameters.map((p) => p.name)
278
+ assert(['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs'].every((n) => names.indexOf(n) !== -1), 'vibe_math_setup schema lists all four Lean knobs')
279
+ const fv = setup.parameters.find((p) => p.name === 'formalVerify')
280
+ assert(!!fv && fv.current === 'require' && fv.default === 'off', 'the schema reports the current and default formalVerify')
281
+ const tpl = await h.call('vibe_math_template', { where: 'global' })
282
+ const tplText = readIf(tpl.path)
283
+ assert(/formalVerify/.test(tplText) && /leanCommand/.test(tplText) && /leanTimeoutMs/.test(tplText), 'the settings template documents the Lean knobs')
284
+ assert(/leanArgs/.test(tplText), 'the settings template also documents leanArgs')
285
+ assert(!/:\s*undefined/.test(tplText), 'the template has no bare undefined token (settings round-trip safety)')
286
+ const saved = await h.call('vibe_math_save_settings', {})
287
+ assert(saved.ok === true, 'save_settings still round-trips with the new knobs')
288
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
289
+ await h.call('vibe_math_add_problem', { id: 'q2', description: '模式切换测试' })
290
+ await startScheduler(h)
291
+ await tick(2000)
292
+ const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q2')), 60, 200)
293
+ assert(!!ex && /【顺手形式化(鼓励)】/.test(ex.prompt || ''), '★ switching to encourage changes the NEXT prompt immediately')
294
+ if (ex) h.fireEnd({ id: ex.childId, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: '```json\n{"directions":[{"id":"d1","title":"D","method":"m","core_assumption":"c","feasibility":0.5}]}\n```' }] })
295
+ await h.call('vibe_math_set_params', { formalVerify: 'off' })
296
+ await sleep(400)
297
+ const so = await waitFor(() => h.spawns.find((s) => s.label.startsWith('solver:q2')), 40, 200)
298
+ assert(!!so, 'the solver was spawned after the mode was switched back to off')
299
+ assert(!!so && !/形式化|Lean/.test(so.prompt || ''), '★ switching back to off removes the Lean text from the next prompt')
300
+ }
301
+
302
+ // ---------- 3. 'encourage' injection ----------
303
+ section("3 'encourage' injects the Lean section into review AND debate prompts")
304
+ {
305
+ const h = await makeCase('enc')
306
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
307
+ // Keep the scheduler alive: v2 STOPS itself when there is no unsolved problem, no agent and
308
+ // no task, and a stopped scheduler never starts a verification (nor spawns an explorer).
309
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
310
+ await startScheduler(h)
311
+ await h.call('vibe_math_add_proposition', { id: 'pEnc', 概述: '鼓励模式下的忠实性审查', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
312
+ const vs = await verifyWithDebate(h, 'r-pEnc', 0.5, 1) // 0.5 -> no consensus -> a REAL debate round
313
+ assert(!!vs.first, 'verifiers spawned for the bare proposition')
314
+ assert(vs.rounds >= 2, 'the object really went through a debate round (' + vs.rounds + ' rounds)')
315
+ const review = h.spawns.find((s) => s.label === 'verifier:r-pEnc:0')
316
+ assert(!!review, 'the first review prompt was captured')
317
+ const reviewText = review ? review.prompt : ''
318
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(reviewText), '★ the REVIEW prompt carries the Lean section')
319
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(reviewText), 'the review prompt states that a passing Lean run shrinks the question to fidelity')
320
+ assert(/实现难度/.test(reviewText), 'the review prompt asks for the implementation-difficulty judgement')
321
+ assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(reviewText), "'encourage' explicitly allows skipping (with a recorded judgement)")
322
+ assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库)/.test(reviewText), 'the review prompt names the three v2 tools')
323
+ const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
324
+ assert(/DEBATE/.test(debate), 'the debate round actually happened (a followup with the debate prompt was issued)')
325
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(debate), '★ the DEBATE prompt carries the Lean section too')
326
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(debate), 'the debate prompt states the fidelity shift')
327
+ await h.call('vibe_math_add_problem', { id: 'qW', description: '顺手形式化测试' })
328
+ await tick(2000)
329
+ const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:qW')), 60, 200)
330
+ assert(!!ex && /【顺手形式化(鼓励)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
331
+ assert(!!ex && /vibe_math_lean_archive kind='def'/.test(ex.prompt || ''), 'the work line points at the archive tool for reusable definitions')
332
+ assert(!!ex && /vibe_math_lean_lib 查重/.test(ex.prompt || ''), 'the work line tells members to check the reuse library first')
333
+ const offHost = await makeCase('enc-off')
334
+ await offHost.call('vibe_math_add_problem', { id: 'qN', description: 'x' })
335
+ await startScheduler(offHost)
336
+ await tick(2000)
337
+ const exN = await waitFor(() => offHost.spawns.find((s) => s.label.startsWith('explorer:qN')), 60, 200)
338
+ assert(!!exN && !/形式化|Lean/.test(exN.prompt || ''), 'the same prompt in off mode is free of Lean text (injection is mode-computed, not frozen)')
339
+ }
340
+
341
+ // ---------- 4. the run tool ----------
342
+ section('4 lean_run executes through the subprocess service and reports honestly')
343
+ {
344
+ const h = await makeCase('run')
345
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
346
+ const proj = projRoot(h)
347
+ mkdirSync(join(proj, 'Formal'), { recursive: true })
348
+ writeFileSync(join(proj, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
349
+ writeFileSync(join(proj, 'Formal', 'bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
350
+ const before = leanRuns.length
351
+ const runGood = await h.call('vibe_math_lean_run', { file: 'Formal/good.lean', target: 'pRun' })
352
+ assert(runGood.ok === true && runGood.exitCode === 0, 'a file with no sorry runs green (' + JSON.stringify({ ok: runGood.ok, exitCode: runGood.exitCode }) + ')')
353
+ assert(leanRuns.length > before, 'the run really went through the mocked subprocess service')
354
+ const spec = leanRuns[leanRuns.length - 1]
355
+ assert(spec.cwd.replace(/\\/g, '/') === proj.replace(/\\/g, '/'), 'the toolchain runs with the PROJECT root as cwd (got ' + spec.cwd + ')')
356
+ assert(!!spec.stdio && spec.stdio.stdin === 'ignore' && spec.stdio.stdout.maxBytes === 64 * 1024 && spec.stdio.stderr.maxBytes === 64 * 1024, 'stdio is stdin=ignore with a 64KB output cap')
357
+ assert(typeof spec.graceMs === 'number' && spec.graceMs >= 1000, 'the configured timeout is passed as graceMs (' + spec.graceMs + ')')
358
+ assert(typeof runGood.command === 'string' && runGood.command.indexOf('good.lean') !== -1, 'the result reports the exact command line')
359
+ const runBad = await h.call('vibe_math_lean_run', { file: 'Formal/bad.lean' })
360
+ assert(runBad.ok === false && runBad.exitCode === 1, 'a file that still uses sorry reports a red run')
361
+ assert(/sorry/.test(runBad.stderr), 'the compiler output is returned verbatim (' + JSON.stringify(runBad.stderr).slice(0, 60) + ')')
362
+ assert(/修复后重跑/.test(runBad.hint || ''), 'a red run tells the member to fix and rerun')
363
+ const runMissing = await h.call('vibe_math_lean_run', { file: 'Formal/nope.lean' })
364
+ assert(runMissing.ok === false && runMissing.code === 'V2_NOT_FOUND', 'a missing file is refused with a typed code (' + runMissing.code + ')')
365
+ const runNoFile = await h.call('vibe_math_lean_run', {})
366
+ assert(runNoFile.ok === false && runNoFile.code === 'V2_INVALID_ARGUMENT', 'a run without `file` is refused')
367
+ // The guard's boundary is the VibeMath ROOT, not the project: the global reuse library
368
+ // deliberately lives at <VibeMath>/Formal/{Lib,Proved}, so climbing out of the project but
369
+ // staying inside VibeMath is legal (it just fails as a missing file).
370
+ const runOutOfProject = await h.call('vibe_math_lean_run', { file: '../../Formal/Lib/x.lean' })
371
+ assert(runOutOfProject.ok === false && runOutOfProject.code === 'V2_NOT_FOUND',
372
+ 'climbing out of the project but staying inside the VibeMath root is ALLOWED (got ' + runOutOfProject.code + ')')
373
+ const runEscape = await h.call('vibe_math_lean_run', { file: '../../../../etc/evil.lean' })
374
+ assert(runEscape.ok === false && runEscape.code === 'V2_INVALID_ARGUMENT', '★ a traversal that climbs ABOVE the VibeMath root is refused')
375
+ const runEscape2 = await h.call('vibe_math_lean_run', { file: 'Formal/../../../../../evil.lean' })
376
+ assert(runEscape2.ok === false && runEscape2.code === 'V2_INVALID_ARGUMENT', 'a deeper traversal is refused too')
377
+ const runEscape3 = await h.call('vibe_math_lean_run', { file: '/etc/evil.lean' })
378
+ assert(runEscape3.ok === false && runEscape3.code === 'V2_INVALID_ARGUMENT', 'an unrelated absolute path is refused')
379
+ const runEscape4 = await h.call('vibe_math_lean_run', { file: 'C:/Windows/evil.lean' })
380
+ assert(runEscape4.ok === false && runEscape4.code === 'V2_INVALID_ARGUMENT', 'an unrelated Windows absolute path is refused')
381
+ const runNotLean = await h.call('vibe_math_lean_run', { file: 'Formal/good.txt' })
382
+ assert(runNotLean.ok === false && runNotLean.code === 'V2_INVALID_ARGUMENT', 'only .lean files can be executed')
383
+ assert(/VibeMath/.test(runEscape.message || ''), 'the refusal names the boundary it enforced')
384
+ mkdirSync(join(vibeRoot(h), 'Formal', 'Lib'), { recursive: true })
385
+ writeFileSync(join(vibeRoot(h), 'Formal', 'Lib', 'abs.lean'), 'def absTest := 1\n', 'utf8')
386
+ const runAbs = await h.call('vibe_math_lean_run', { file: join(vibeRoot(h), 'Formal', 'Lib', 'abs.lean').replace(/\\/g, '/') })
387
+ assert(runAbs.ok === true, 'an absolute path inside the VibeMath root is accepted')
388
+ const rec = formalStateOf(h)
389
+ assert(!!rec.records && !!rec.records.pRun && rec.records.pRun.status === 'attempted', 'a green lean_run records the object as attempted (stored, not promoted)')
390
+ assert(!!rec.records.pRun.run && rec.records.pRun.run.ok === true && rec.records.pRun.run.exitCode === 0, 'the run record keeps ok/exitCode')
391
+ assert(rec.records.pRun.run.stdoutTail !== undefined && rec.records.pRun.run.stderrTail !== undefined, 'the run record keeps truncated output tails')
392
+ toolchainAvailable = false
393
+ const runNoTc = await h.call('vibe_math_lean_run', { file: 'Formal/good.lean' })
394
+ assert(runNoTc.ok === false && runNoTc.code === 'LEAN_NOT_FOUND', 'a missing toolchain returns LEAN_NOT_FOUND instead of crashing')
395
+ assert(/仍可把形式化代码写下来归档/.test(runNoTc.message), 'the failure explains the graceful degradation')
396
+ toolchainAvailable = true
397
+ const noSub = await makeCase('nosub', { noSubprocess: true })
398
+ mkdirSync(join(projRoot(noSub), 'Formal'), { recursive: true })
399
+ writeFileSync(join(projRoot(noSub), 'Formal', 'x.lean'), 'theorem x : 1 = 1 := rfl\n', 'utf8')
400
+ const noSubRun = await noSub.call('vibe_math_lean_run', { file: 'Formal/x.lean' })
401
+ assert(noSubRun.ok === false && noSubRun.code === 'NO_SUBPROCESS', 'a host without the subprocess service returns NO_SUBPROCESS and does not crash (got ' + noSubRun.code + ')')
402
+ const noSubStatus = await noSub.call('vibe_math_status', {})
403
+ assert(noSubStatus.ok === true, 'the scheduler still answers status after that (nothing was thrown into the loop)')
404
+ }
405
+
406
+ // ---------- 5. archive: def / lemma / proof / blocked ----------
407
+ section('5 lean_archive writes the contract paths and indexes')
408
+ {
409
+ const h = await makeCase('arc')
410
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
411
+ const proj = projRoot(h)
412
+ const libPath = join(vibeRoot(h), 'Formal', 'Lib')
413
+ const provedPath = join(vibeRoot(h), 'Formal', 'Proved')
414
+ const defRes = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' })
415
+ assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
416
+ assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under <VibeMath>/Formal/Lib/ (cross-project, NOT inside the project)')
417
+ assert(!existsSync(join(proj, 'Formal', 'Lib', 'ZMod5.lean')), 'it is NOT duplicated inside the project tree')
418
+ assert(!!defRes.run && defRes.run.ok === true, 'the archived definition was executed (kind=def runs by default)')
419
+ const lemmaRes = await h.call('vibe_math_lean_archive', { kind: 'lemma', name: 'sq_odd', content: 'theorem sq_odd (n : Nat) : Odd (n*n) → Odd n := by omega\n' })
420
+ assert(lemmaRes.ok === true && lemmaRes.file === 'Formal/Proved/sq_odd.lean', 'a lemma is archived to the global Proved/ (' + lemmaRes.file + ')')
421
+ assert(existsSync(join(provedPath, 'sq_odd.lean')), 'the lemma exists under <VibeMath>/Formal/Proved/')
422
+ const defNoRun = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'quiet', content: 'def quiet := 2\n', run: false })
423
+ assert(defNoRun.ok === true && defNoRun.run === undefined, 'run:false archives without executing (still indexed)')
424
+ assert(/ZMod5/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the new definition')
425
+ assert(/quiet/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the run:false definition too')
426
+ assert(/sq_odd/.test(readIf(join(provedPath, 'Index.md'))), 'Proved/Index.md lists the new lemma')
427
+ writeFileSync(join(proj, 'Formal', 'src.lean'), 'def copied := 3\n', 'utf8')
428
+ const defFrom = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'copied', from: 'Formal/src.lean' })
429
+ assert(defFrom.ok === true && existsSync(join(libPath, 'copied.lean')), 'kind=def can archive from an existing .lean file')
430
+ const fromOutside = await h.call('vibe_math_lean_archive', { kind: 'def', name: 'escape', from: '../../../../etc/passwd' })
431
+ assert(fromOutside.ok === false && fromOutside.code === 'V2_INVALID_ARGUMENT', 'from=<path outside the VibeMath root> is refused')
432
+ const noName = await h.call('vibe_math_lean_archive', { kind: 'def', content: 'def x := 1\n' })
433
+ assert(noName.ok === false && noName.code === 'V2_INVALID_ARGUMENT', 'archiving a definition without a name is refused')
434
+ const noBody = await h.call('vibe_math_lean_archive', { kind: 'lemma', name: 'xb' })
435
+ assert(noBody.ok === false && noBody.code === 'V2_INVALID_ARGUMENT', 'archiving a lemma with neither content nor from is refused')
436
+ const badKind = await h.call('vibe_math_lean_archive', { kind: 'nonsense' })
437
+ assert(badKind.ok === false && badKind.code === 'V2_INVALID_ARGUMENT', 'an unknown archive kind is refused')
438
+ const noTarget = await h.call('vibe_math_lean_archive', { kind: 'proof', content: 'theorem x : 1 = 1 := rfl\n' })
439
+ assert(noTarget.ok === false && noTarget.code === 'V2_INVALID_ARGUMENT', 'kind=proof without a target is refused')
440
+ const proof = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pProof', content: 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' })
441
+ assert(proof.ok === true && proof.passed === true, 'a passing proof is archived (' + JSON.stringify({ ok: proof.ok, passed: proof.passed }) + ')')
442
+ assert(proof.file === 'Formal/pProof.lean', 'the working file is Formal/<target>.lean')
443
+ assert(proof.proof === 'Verified/Lean/pProof.lean', 'the archived proof path is Verified/Lean/<target>.lean')
444
+ assert(existsSync(join(proj, 'Formal', 'pProof.lean')), 'the working file exists on disk')
445
+ assert(existsSync(join(proj, 'Verified', 'Lean', 'pProof.lean')), '★ the proof is archived under Verified/Lean/')
446
+ assert(proof.status === 'passed', 'the object status becomes passed')
447
+ const proofBad = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pRed', content: 'theorem p_red : 1 = 2 := by sorry\n' })
448
+ assert(proofBad.ok === true && proofBad.passed === false && proofBad.status === 'attempted', 'a red proof is recorded as attempted (not passed)')
449
+ assert(!existsSync(join(proj, 'Verified', 'Lean', 'pRed.lean')), 'a red proof is NOT archived under Verified/Lean/')
450
+ assert(/sorry/.test((proofBad.run && proofBad.run.stderr) || ''), 'the red run keeps the compiler output for the member to fix')
451
+ const st = await h.call('vibe_math_status', {})
452
+ assert(st.formal.objects.some((o) => o.target === 'pProof' && o.status === 'passed'), 'status lists the passed object')
453
+ const idx = readIf(join(proj, 'Formal', 'Index.md'))
454
+ assert(/pProof/.test(idx) && /passed/.test(idx) && /Verified\/Lean\/pProof\.lean/.test(idx), 'Formal/Index.md indexes the object, its status and its archived proof')
455
+ assert(/pRed/.test(idx) && /attempted/.test(idx), 'Formal/Index.md also records the failed attempt')
456
+ const blkNoNote = await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'pBlk' })
457
+ assert(blkNoNote.ok === false && blkNoNote.code === 'V2_INVALID_ARGUMENT', 'blocked without a note is refused')
458
+ const blk = await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'pBlk', note: '需要外层解析数论框架,本轮工作量不可接受' })
459
+ assert(blk.ok === true && blk.status === 'blocked', 'a reasoned blocker is recorded')
460
+ assert(/需要外层解析数论框架/.test(readIf(join(proj, 'Formal', 'Index.md'))), 'the blocker reason reaches the index')
461
+ const stBlk = await h.call('vibe_math_status', {})
462
+ assert(stBlk.formal.objects.some((o) => o.target === 'pBlk' && o.status === 'blocked'), 'status lists the blocked object')
463
+ const lib = await h.call('vibe_math_lean_lib', {})
464
+ assert(lib.ok === true && lib.counts.lib >= 3 && lib.counts.proved >= 1, 'lean_lib reports the reuse library sizes (' + JSON.stringify(lib.counts) + ')')
465
+ assert(lib.objects.some((o) => o.target === 'pProof' && o.status === 'passed'), 'lean_lib lists the per-object formal status')
466
+ assert(/复用优先/.test(lib.hint || ''), 'lean_lib tells agents to reuse before redefining')
467
+ assert(lib.mode === 'encourage', 'lean_lib reports the active mode')
468
+ const noRefresh = await h.call('vibe_math_lean_lib', { refresh: false })
469
+ assert(noRefresh.rebuilt === false && noRefresh.counts.lib === null, 'refresh:false lists without rebuilding')
470
+ assert(existsSync(join(proj, 'Formal', 'Index.md')), 'the project Formal/Index.md exists')
471
+ assert(existsSync(join(libPath, 'Index.md')) && existsSync(join(provedPath, 'Index.md')), 'the two GLOBAL indexes exist')
472
+ }
473
+
474
+ // ---------- 6. a passing proof flips the review to FIDELITY ----------
475
+ section('6 a passing proof flips the review subject to fidelity')
476
+ {
477
+ const h = await makeCase('fid')
478
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
479
+ // v2's scheduler STOPS itself when there is no unsolved problem, no agent and no task; a
480
+ // case that only wants to verify a proposition therefore needs one live problem to keep
481
+ // the tick loop alive (otherwise no verification would ever be started).
482
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
483
+ await startScheduler(h)
484
+ await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'r-pFid', content: 'theorem p_fid : 2 + 2 = 4 := by decide\n' })
485
+ await h.call('vibe_math_add_proposition', { id: 'pFid', 概述: '2+2=4(已有 Lean 证明)', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
486
+ // 0.5 in round 1 forces a debate round whose prompt must ALSO carry the fidelity wording
487
+ const vs = await verifyWithDebate(h, 'r-pFid', 0.5, 1)
488
+ assert(!!vs.first, 'a verifier was spawned for the Lean-passed object')
489
+ const vp = h.spawns.find((s) => s.label === 'verifier:r-pFid:0')
490
+ const vpText = vp ? vp.prompt : ''
491
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(vpText), 'the review prompt announces the passing proof')
492
+ assert(/你不需要重新检查推导/.test(vpText), '★ it tells reviewers NOT to re-derive')
493
+ assert(/忠实性审查/.test(vpText), '★ it tells reviewers the review subject is now fidelity')
494
+ assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(vpText), 'it enumerates exactly what fidelity means')
495
+ assert(/Verified\/Lean\/r-pFid\.lean/.test(vpText), 'it points at the archived proof')
496
+ assert(/把 Result 用在\*\*忠实性\*\*上/.test(vpText), 'the verdict guidance switches to fidelity')
497
+ assert(!/请先判断该对象的\*\*实现难度\*\*/.test(vpText), 'the "judge the difficulty first" wording is gone when a proof already exists')
498
+ const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
499
+ assert(/你不需要重新检查推导/.test(debate), '★ the debate prompt for a Lean-passed object also asks for fidelity, not re-derivation')
500
+ await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlk2', note: '涉及未形式化的分析学前置' })
501
+ await h.call('vibe_math_add_proposition', { id: 'pBlk2', 概述: '已记录阻塞的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
502
+ const vs2 = await verifyWithDebate(h, 'r-pBlk2', 0.5, 1)
503
+ assert(!!vs2.first, 'verifiers spawned for the blocked object')
504
+ const vp2 = h.spawns.find((s) => s.label === 'verifier:r-pBlk2:0')
505
+ const vp2Text = vp2 ? vp2.prompt : ''
506
+ assert(/该对象已被记录为\*\*形式化阻塞\*\*/.test(vp2Text), 'a blocked object is announced as such')
507
+ assert(/涉及未形式化的分析学前置/.test(vp2Text), 'the blocker reason is shown to the reviewers')
508
+ assert(/这个阻塞判断是否成立/.test(vp2Text), 'the verdict guidance asks whether the blocker is justified')
509
+ }
510
+
511
+ // ---------- 7. the 'require' gate (proposition) ----------
512
+ section("7 'require' withholds a verdict until the formal record exists")
513
+ {
514
+ const h = await makeCase('gate')
515
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
516
+ // a live problem keeps the scheduler from stopping itself (see the comment in case 6)
517
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
518
+ await startScheduler(h)
519
+ const proj = projRoot(h)
520
+ await h.call('vibe_math_add_proposition', { id: 'pGate', 概述: '必须形式化的命题', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
521
+ const vs = await waitFor(() => { const x = verifiersOf(h, 'r-pGate'); return x.length >= 2 ? x : undefined }, 60, 250)
522
+ assert(!!vs, 'verifiers were spawned under require mode')
523
+ const vp = (h.spawns.find((s) => s.label === 'verifier:r-pGate:0') || {}).prompt || ''
524
+ assert(/【Lean 形式化验证(强制模式)】/.test(vp), 'the review prompt says 强制模式')
525
+ assert(/必须产出 Lean 形式化/.test(vp), "'require' states the formalization is mandatory")
526
+ assert(/本次裁定不会生效/.test(vp), 'the prompt warns that the verdict will not take effect without it')
527
+ assert(/formal-required/.test(vp), 'the prompt names the machine-readable reason')
528
+ fireVerdicts(h, vs, 1)
529
+ // The deferral writes Formal/TODO.md from INSIDE the settle path; waiting for that file is
530
+ // the observable proof that the round settled (the task is dropped right after).
531
+ const todoSettled = await waitFor(() => /pGate/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 40, 150)
532
+ assert(!!todoSettled, 'the deferred round settled (Formal/TODO.md recorded the object)')
533
+ await tick(600)
534
+ const propFile = join(proj, 'Propos', '数论_Propos.json')
535
+ const props = JSON.parse(readIf(propFile) || '[]')
536
+ const pGate = props.find((x) => x.id === 'pGate') || {}
537
+ assert(pGate.布尔估计 === 0.5, "★ the verdict did NOT take effect: the proposition's 布尔估计 is unchanged (got " + pGate.布尔估计 + ')')
538
+ assert(!(pGate.证明列表 || []).some((x) => x.正确概率 === 1), 'no probability-1 proof entry was recorded')
539
+ assert(pGate.优先级 !== 'never', 'the priority was not pinned to never')
540
+ assert(!existsSync(join(proj, 'Verified', '数论_Verified.json')), '★ no Verified card was written')
541
+ const todo = readIf(join(proj, 'Formal', 'TODO.md'))
542
+ assert(existsSync(join(proj, 'Formal', 'TODO.md')), 'Formal/TODO.md was created')
543
+ assert(/pGate/.test(todo) && /formal-required/.test(todo), '★ the object is on the formalization TODO with the machine-readable reason')
544
+ const recAfterGate = formalStateOf(h)
545
+ // record key = the verification object id (rId), which is what lean_archive target= also uses
546
+ assert(!!recAfterGate.records && !!recAfterGate.records['r-pGate'] && !!recAfterGate.records['r-pGate'].deferredAt, 'the deferral is persisted in the durable formal state (survives resume)')
547
+ assert(!!recAfterGate.records['r-pGate'] && recAfterGate.records['r-pGate'].status === 'none', 'the object stays at status none (nothing was formalized)')
548
+ const vlogs = existsSync(join(proj, 'Verification_logs')) ? readdirSync(join(proj, 'Verification_logs')) : []
549
+ assert(vlogs.some((f) => f.startsWith('r-pGate')), '★ the verification round DID complete (its debate log exists) — the gate withheld the conclusion, it did not stall the round')
550
+ const st1 = await h.call('vibe_math_status', {})
551
+ assert(/require 模式搁置/.test(JSON.stringify(st1.recentActivity)), 'the withholding is announced on v2-readable channel (activity log; v2 has no Shared/Chat/)')
552
+ assert(st1.formal.todo.some((t) => t.id === 'r-pGate'), 'status exposes the formalization TODO')
553
+ const rep = await h.call('vibe_math_report', {})
554
+ assert(!!rep.formal && rep.formal.required === true && rep.formal.mode === 'require', 'the report exposes the formal mode and gate flag')
555
+ assert(rep.recentActivity.some((a) => /formal-gate/.test(a.event)), 'the deferral also lands in the report log')
556
+ // The deferral leaves this project with no agent, task or solved problem, and v2's strict
557
+ // termination stops the scheduler there — so a case that wants to RE-verify the object after
558
+ // formalizing it must (re)start the scheduler, exactly as a human would.
559
+ await startScheduler(h)
560
+ const proofNow = await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pGate', content: 'theorem p_gate : 2 + 2 = 4 := by decide\n' })
561
+ assert(proofNow.ok === true && proofNow.passed === true, 'the object is now Lean-passed')
562
+ const spawned2 = await verifyWithDebate(h, 'r-pGate', 1, 0)
563
+ assert(!!spawned2.first, 'the object was re-verified after the proof was archived')
564
+ const props2 = JSON.parse(readIf(propFile) || '[]')
565
+ const pGateNow = props2.find((x) => x.id === 'pGate') || {}
566
+ assert(pGateNow.布尔估计 === 1, '★ with a passing Lean artifact the same unanimous verdict DOES take effect (布尔估计=1)')
567
+ const cardFile = join(proj, 'Verified', '数论_Verified.json')
568
+ const cards = JSON.parse(readIf(cardFile) || '[]')
569
+ const card = cards.find((c) => c.id === 'pGate')
570
+ assert(!!card, '★ the Verified card was written after the proof passed')
571
+ assert(!!card && /Lean 通过/.test(card['形式化'] || ''), '★ the card records how strong the result is (Lean 通过)')
572
+ assert(!!card && /Verified\/Lean\/pGate\.lean/.test(card['形式化'] || ''), 'the card points at the archived proof')
573
+ await h.call('vibe_math_add_proposition', { id: 'pBlkOk', 概述: '记录阻塞后可定论', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
574
+ const sp3 = await waitFor(() => { const x = verifiersOf(h, 'r-pBlkOk'); return x.length >= 2 ? x : undefined }, 60, 250)
575
+ assert(!!sp3, 'verifiers spawned for the blocker-escape case')
576
+ await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'r-pBlkOk', note: '命题涉及未形式化的分析学,本轮不做' })
577
+ fireVerdicts(h, sp3 || [], 1)
578
+ await waitFor(() => { const c = JSON.parse(readIf(cardFile) || '[]').find((x) => x.id === 'pBlkOk'); return c ? c : undefined }, 40, 150)
579
+ await tick(400)
580
+ const cards3 = JSON.parse(readIf(cardFile) || '[]')
581
+ const card3 = cards3.find((c) => c.id === 'pBlkOk')
582
+ assert(!!card3, '★ an explicit reasoned blocker also lets the verdict through (decide by difficulty, but decide out loud)')
583
+ assert(!!card3 && /阻塞(/.test(card3['形式化'] || ''), 'the card records the blocker')
584
+ assert(!!card3 && /未形式化的分析学/.test(card3['形式化'] || ''), 'the card records the blocker REASON, not just the fact')
585
+ }
586
+
587
+ // ---------- 8. the gate covers the qs (problem) choke point ----------
588
+ section('8 the gate also covers a problem-solution verdict')
589
+ {
590
+ const h = await makeCase('gq')
591
+ await h.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
592
+ // a live problem keeps the scheduler from stopping itself (see the comment in case 3)
593
+ await h.call('vibe_math_add_problem', { id: 'qStay', description: '保持调度器运行的占位问题', priority: 9 })
594
+ await startScheduler(h)
595
+ const proj = projRoot(h)
596
+ await h.call('vibe_math_add_problem', { id: 'qG', description: '带解法的 require 门禁问题', priority: 1 })
597
+ const qsFile = join(proj, 'qs', 'qs.json')
598
+ const qs0 = JSON.parse(readIf(qsFile) || '[]')
599
+ qs0.find((q) => q.id === 'qG').解法列表 = [{ 完整解法: 'closing argument', 正确概率: 0.8, 已验: false }]
600
+ writeFileSync(qsFile, JSON.stringify(qs0, null, 2), 'utf8')
601
+ await tick(2000)
602
+ const sp = await waitFor(() => { const x = verifiersOf(h, 'r-qG-s0'); return x.length >= 2 ? x : undefined }, 60, 250)
603
+ assert(!!sp, 'verifiers spawned for the problem solution')
604
+ fireVerdicts(h, sp || [], 1)
605
+ const todoG = await waitFor(() => /qG/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 40, 150)
606
+ assert(!!todoG, 'the deferred problem round settled (Formal/TODO.md recorded it)')
607
+ await tick(600)
608
+ const qs1 = JSON.parse(readIf(qsFile) || '[]')
609
+ const qG = qs1.find((q) => q.id === 'qG')
610
+ assert(!!qG && qG.已解决 !== true, '★ the problem was NOT marked solved (the require gate blocked the promotion)')
611
+ assert(!!qG && qG.解法列表[0].正确概率 !== 1, "the solution's probability was left as it was")
612
+ assert(!existsSync(join(proj, 'Verified', '问题_Verified.json')), 'no problem Verified card was written')
613
+ assert(/qG/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 'the problem is on the formalization TODO')
614
+ await h.call('vibe_math_lean_archive', { kind: 'blocked', target: 'qG', note: '问题的形式化超出本轮工作量' })
615
+ await startScheduler(h) // see case 7: the deferral may have stopped the scheduler
616
+ const sp2 = await verifyWithDebate(h, 'r-qG-s0', 1, 0)
617
+ assert(!!sp2.first, 'the problem solution was re-verified after the blocker was recorded')
618
+ const qs2 = JSON.parse(readIf(qsFile) || '[]')
619
+ const qG2 = qs2.find((q) => q.id === 'qG')
620
+ assert(!!qG2 && qG2.已解决 === true, 'with a reasoned blocker the problem DOES close')
621
+ assert(!!qG2 && qG2.解法列表[0].正确概率 === 1, 'the solution probability becomes 1 after the gate opened')
622
+ const cards = JSON.parse(readIf(join(proj, 'Verified', '问题_Verified.json')) || '[]')
623
+ const card = cards.find((c) => c.id === 'qG')
624
+ assert(!!card && /阻塞(/.test(card['形式化'] || ''), 'the problem card records the formal status')
625
+ assert(!existsSync(join(vibeRoot(h), 'Formal', 'Lib', 'qG.lean')), 'a blocked record does not fabricate a library file')
626
+ }
627
+
628
+ // ---------- 9. persistence across resume ----------
629
+ section('9 the formal state survives resume (v2 has no session projection)')
630
+ {
631
+ const h = await makeCase('persist')
632
+ await h.call('vibe_math_set_params', { formalVerify: 'require' })
633
+ await h.call('vibe_math_lean_archive', { kind: 'proof', target: 'pPersist', content: 'theorem p_persist : 1 + 1 = 2 := by decide\n' })
634
+ const before = await h.call('vibe_math_status', {})
635
+ assert(before.formal.objects.some((o) => o.target === 'pPersist' && o.status === 'passed'), 'the object is passed before the restart')
636
+ const proj = projRoot(h)
637
+ writeFileSync(join(proj, 'VibeMath_State', 'process_epoch.json'), JSON.stringify('OLD-PROCESS-EPOCH'), 'utf8')
638
+ const res = await h.call('vibe_math_resume', {})
639
+ assert(res.ok === true, 'resume succeeded')
640
+ const after = await h.call('vibe_math_status', {})
641
+ assert(after.formal.objects.some((o) => o.target === 'pPersist' && o.status === 'passed'),
642
+ '★ the Lean-passed object is still passed after resume (the gate does not forget)')
643
+ assert(/pPersist/.test(readIf(join(proj, 'Formal', 'Index.md'))), 'Formal/Index.md still indexes it (the files are the readable mirror)')
644
+ }
645
+
646
+ // ---------- 10. the gate is a no-op unless require ----------
647
+ section("10 'encourage' never gates (a verdict still lands with no Lean artifact)")
648
+ {
649
+ const h = await makeCase('nogate')
650
+ await h.call('vibe_math_set_params', { formalVerify: 'encourage', maxParallelThreshold: 8 })
651
+ await h.call('vibe_math_add_problem', { id: 'qKeep', description: '保持调度器运行的占位问题', priority: 9 })
652
+ await startScheduler(h)
653
+ const proj = projRoot(h)
654
+ await h.call('vibe_math_add_proposition', { id: 'pFree', 概述: '鼓励模式不设门禁', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
655
+ const vs = await verifyWithDebate(h, 'r-pFree', 1, 0)
656
+ assert(!!vs.first, 'verifiers spawned under encourage mode')
657
+ const props = JSON.parse(readIf(join(proj, 'Propos', '数论_Propos.json')) || '[]')
658
+ const p = props.find((x) => x.id === 'pFree') || {}
659
+ assert(p.布尔估计 === 1, "'encourage' finalizes normally with NO Lean artifact (布尔估计=1)")
660
+ const cards = JSON.parse(readIf(join(proj, 'Verified', '数论_Verified.json')) || '[]')
661
+ assert(!!cards.find((c) => c.id === 'pFree'), "'encourage' writes the Verified card")
662
+ assert(!existsSync(join(proj, 'Formal', 'TODO.md')) || !/pFree/.test(readIf(join(proj, 'Formal', 'TODO.md'))), 'no formalization TODO is created in encourage mode')
663
+ }
664
+
665
+ // cleanup
666
+ for (const h of hosts) { try { rmSync(h.WS, { recursive: true, force: true }) } catch (e) { /* ignore */ } }
667
+
668
+ console.log('')
669
+ console.log('passed=' + passed + ' failed=' + failed)
670
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
671
+ console.log('ALL GREEN')
672
+ process.exit(0)