dsh-vibe-math 2.6.0 → 2.7.1

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 (59) hide show
  1. package/README.en.md +161 -389
  2. package/README.md +161 -390
  3. package/cordis.patch.yml +62 -14
  4. package/docs/AUDIT-CHECKLIST.md +2 -2
  5. package/docs/final-paper.md +1 -0
  6. package/docs/formal-verification.md +184 -29
  7. package/docs/math-computation.md +163 -0
  8. package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
  9. package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
  10. package/docs/release-notes/RELEASE-NOTES-2.7.1.en.md +86 -0
  11. package/docs/release-notes/RELEASE-NOTES-2.7.1.md +86 -0
  12. package/docs/test-timing.md +24 -12
  13. package/installer.js +7 -4
  14. package/package.json +21 -3
  15. package/prompt-corpus-persona/persona-corpus.json +12 -12
  16. package/prompt-corpus-persona/persona-corpus.md +128 -32
  17. package/prompt-corpus-v2/formal-verify-v2.json +182 -92
  18. package/prompt-corpus-v2/formal-verify-v2.md +2379 -55
  19. package/prompt-corpus-v3/formal-verify-v3.json +215 -53
  20. package/prompt-corpus-v3/formal-verify-v3.md +2147 -22
  21. package/prompt-corpus-v4/formal-verify-v4.json +14 -14
  22. package/prompt-corpus-v4/formal-verify-v4.md +267 -14
  23. package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
  24. package/prompt-corpus-v5/prompt-corpus-v5.md +631 -3
  25. package/tests/audit-math-computation-contract.mjs +204 -0
  26. package/tests/audit-math-computation-parity.mjs +198 -0
  27. package/tests/audit-math-computation-sensitivity.mjs +231 -0
  28. package/tests/audit-persona-sensitivity.mjs +4 -4
  29. package/tests/audit-persona-surface.test.mjs +38 -10
  30. package/tests/audit-v5-integrity.mjs +14 -0
  31. package/tests/e2e-v5-round2.test.mjs +459 -2
  32. package/tests/formal-verify-v2.test.mjs +362 -8
  33. package/tests/formal-verify-v3.test.mjs +355 -6
  34. package/tests/formal-verify-v4.test.mjs +295 -6
  35. package/tests/formal-verify-v5.test.mjs +9 -1
  36. package/tests/helpers/math-computation-fake-seam.mjs +163 -0
  37. package/tests/math-computation-shared.test.mjs +806 -0
  38. package/tests/prompt-v5-integrity.test.mjs +5 -5
  39. package/vibe-math-v2/agent.cordis.yml +22 -8
  40. package/vibe-math-v2/math-computation.js +1279 -0
  41. package/vibe-math-v2/math-engines.js +197 -0
  42. package/vibe-math-v2/vibe-math-v2.js +853 -40
  43. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  44. package/vibe-math-v3/agent.cordis.yml +22 -8
  45. package/vibe-math-v3/math-computation.js +1279 -0
  46. package/vibe-math-v3/math-engines.js +197 -0
  47. package/vibe-math-v3/vibe-math-v3.js +844 -47
  48. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  49. package/vibe-math-v4/agent.cordis.yml +38 -6
  50. package/vibe-math-v4/math-computation.js +1279 -0
  51. package/vibe-math-v4/math-engines.js +197 -0
  52. package/vibe-math-v4/vibe-math-v4.js +816 -31
  53. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
  54. package/vibe-math-v5/agent.cordis.yml +42 -6
  55. package/vibe-math-v5/math-computation.js +1279 -0
  56. package/vibe-math-v5/math-engines.js +197 -0
  57. package/vibe-math-v5/vibe-math-v5.js +1132 -37
  58. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +83 -3
  59. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
@@ -31,11 +31,17 @@
31
31
  import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
32
32
  import { tmpdir } from 'node:os'
33
33
  import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
34
- import { fileURLToPath } from 'node:url'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
35
 
36
+ // V2_PLUGIN may be a REPO-RELATIVE path or an ABSOLUTE one (a mutant copy outside the repo, e.g. a
37
+ // temp dir). The previous `new URL('file:///' + path)` form only worked for drive-qualified Windows
38
+ // paths and made out-of-repo variants impossible; this is the same isAbsolute-aware resolution the
39
+ // `math-computation-v*` suites use. Default behaviour (env var unset) is unchanged.
36
40
  const PLUGIN = process.env.V2_PLUGIN
37
- ? new URL('file:///' + String(process.env.V2_PLUGIN).replace(/\\/g, '/'))
41
+ ? pathToFileURL(isAbsolute(process.env.V2_PLUGIN) ? process.env.V2_PLUGIN : join(dirname(fileURLToPath(import.meta.url)), '..', process.env.V2_PLUGIN))
38
42
  : new URL('../vibe-math-v2/vibe-math-v2.js', import.meta.url)
43
+ // 纯函数测试面(__testHelpers):参数归一化/搜索路径计划/哈希/指纹都从这里守卫。
44
+ const MOD = await import(PLUGIN.href)
39
45
  const HERE = dirname(fileURLToPath(import.meta.url))
40
46
  // Human-reviewable corpus (contract §10.10). A sensitivity probe runs THIS suite against a
41
47
  // MUTATED plugin copy: writing the repository corpus from such a run would replace the
@@ -143,6 +149,7 @@ function makeHost(WS, opts = {}) {
143
149
  const spawns = []
144
150
  const followups = []
145
151
  const interrupts = []
152
+ const disposers = []
146
153
  const ctx = {
147
154
  get(name) {
148
155
  if (name === 'subprocess') return opts.noSubprocess ? undefined : subprocess
@@ -150,7 +157,7 @@ function makeHost(WS, opts = {}) {
150
157
  return undefined
151
158
  },
152
159
  on(event, fn) { (listeners[event] = listeners[event] || []).push(fn) },
153
- effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
160
+ effect(fn) { const d = fn(); const off = () => { if (typeof d === 'function') d() }; disposers.push(off); return off },
154
161
  logger: { info() {}, warn() {}, error() {} },
155
162
  tools: { register(spec) { toolRegs.push(spec); return () => {} } },
156
163
  commands: { register(spec) { cmdRegs.push(spec); return () => {} } },
@@ -180,7 +187,7 @@ function makeHost(WS, opts = {}) {
180
187
  },
181
188
  }
182
189
  const fireEnd = (info) => { for (const h of (listeners['subagent/end'] || [])) h(info) }
183
- return { ctx, toolRegs, cmdRegs, spawns, followups, interrupts, fireEnd, WS }
190
+ return { ctx, toolRegs, cmdRegs, spawns, followups, interrupts, disposers, unmount: () => { for (const d of disposers) { try { d() } catch (e) { /* ignore */ } } }, fireEnd, WS }
184
191
  }
185
192
 
186
193
  function makeRoot(id, WS) {
@@ -212,6 +219,10 @@ async function makeCase(label, opts = {}) {
212
219
  // 200ms is the sanitizer floor; verifierCount=2 (the sanitizer floor too) keeps each
213
220
  // verification exactly two children, so a round settles predictably.
214
221
  await call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2 })
222
+ // leanAsync defaults to TRUE (background queue). This suite's Lean sections guard the
223
+ // **synchronous** path verbatim, so every case pins leanAsync=false here; the async state
224
+ // machine (default档) is guarded by its own section, which flips it back on explicitly.
225
+ await call('vibe_math_set_params', { leanAsync: false })
215
226
  const c = Object.assign(h, { WS, root, call, label })
216
227
  hosts.push(c)
217
228
  return c
@@ -407,7 +418,7 @@ section('2 parameter validation and runtime switching')
407
418
  await startScheduler(h)
408
419
  await tick(2000)
409
420
  const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:q2')), 60, 200)
410
- assert(!!ex && /【顺手形式化(鼓励)】/.test(ex.prompt || ''), '★ switching to encourage changes the NEXT prompt immediately')
421
+ assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ switching to encourage changes the NEXT prompt immediately')
411
422
  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```' }] })
412
423
  await h.call('vibe_math_set_params', { formalVerify: 'off' })
413
424
  await sleep(400)
@@ -440,7 +451,7 @@ section("3 'encourage' injects the Lean section into review AND debate prompts")
440
451
  // really landing in the durable record — is asserted in section 11.
441
452
  assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(reviewText), "'encourage' explicitly allows skipping (with a recorded judgement)")
442
453
  assert(/"decision":"used\|blocked\|defect"/.test(reviewText), '★ the review contract lists the real decision enum (incl. defect)')
443
- assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库)/.test(reviewText), 'the review prompt names the three v2 tools')
454
+ assert(/vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库\/jobs)· vibe_math_lean_read(取回归档原文)/.test(reviewText), 'the review prompt names all four v2 tools (incl. the new read-only one)')
444
455
  assert(/归档可复用定义\/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run)/.test(reviewText), '★ the review prompt says a reusable artifact must run green BEFORE it is archived')
445
456
  assert(/LEAN_NOT_FOUND/.test(reviewText) && /宿主无 Lean 工具链/.test(reviewText), '★ the review prompt writes out the missing-toolchain escape hatch (a host without Lean must not deadlock the agent)')
446
457
  const debate = h.followups.map((f) => f.prompt || '').filter((p) => /DEBATE/.test(p)).join('\n')
@@ -450,7 +461,7 @@ section("3 'encourage' injects the Lean section into review AND debate prompts")
450
461
  await h.call('vibe_math_add_problem', { id: 'qW', description: '顺手形式化测试' })
451
462
  await tick(2000)
452
463
  const ex = await waitFor(() => h.spawns.find((s) => s.label.startsWith('explorer:qW')), 60, 200)
453
- assert(!!ex && /【顺手形式化(鼓励)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
464
+ assert(!!ex && /【顺手形式化(鼓励[^)]*)】/.test(ex.prompt || ''), '★ the explorer work prompt carries the 顺手形式化 line')
454
465
  assert(!!ex && /vibe_math_lean_archive kind='def'/.test(ex.prompt || ''), 'the work line points at the archive tool for reusable definitions')
455
466
  assert(!!ex && /vibe_math_lean_lib 查重/.test(ex.prompt || ''), 'the work line tells members to check the reuse library first')
456
467
  assert(!!ex && /归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(ex.prompt || ''), '★ the work line forbids archiving a definition that has not run green')
@@ -472,6 +483,10 @@ section('4 lean_run executes through the subprocess service and reports honestly
472
483
  {
473
484
  const h = await makeCase('run')
474
485
  await h.call('vibe_math_set_params', { formalVerify: 'encourage' })
486
+ // 这一节把 leanAsync 固定为 false:它逐字守卫**同步路径**(旧的 await 语义)。
487
+ // 异步(默认档)的接口与状态机由后面的「Lean async」小节守卫——两条路径都要有绿灯。
488
+ await h.call('vibe_math_set_params', { leanAsync: false })
489
+ assert((await h.call('vibe_math_status', {})).params.leanAsync === false, 'the sync section pins leanAsync=false explicitly')
475
490
  const proj = projRoot(h)
476
491
  mkdirSync(join(proj, 'Formal'), { recursive: true })
477
492
  writeFileSync(join(proj, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
@@ -1282,6 +1297,345 @@ section('15c the framework push report and the gate notice are captured')
1282
1297
  await g.call('vibe_math_pause', {})
1283
1298
  }
1284
1299
 
1300
+ section('15d Lean 增量 + 异步(spec §1–§4 + 修订 §1–§5):参数、argv、队列、去重、只读面、提示词')
1301
+ {
1302
+ const H = MOD.__testHelpers
1303
+ const slash = (s) => String(s == null ? '' : s).replace(/\\/g, '/') // 插件内部用 '/',测试用 path.join ⇒ 比较前统一
1304
+ const SAME = (a, b) => slash(a) === slash(b)
1305
+ // ── 参数:默认值 + 归一化(字符串 'false' 绝不能滑过去) ────────────────────────────
1306
+ const a = await makeCase('lean-params')
1307
+ const st0 = await a.call('vibe_math_status', {})
1308
+ assert(st0.params.leanAsync === false, 'the suite pins leanAsync=false by default (sync-path guard)')
1309
+ await a.call('vibe_math_set_params', { leanAsync: true })
1310
+ assert((await a.call('vibe_math_status', {})).params.leanAsync === true, 'leanAsync=true is accepted (the async档)')
1311
+ const bad = await a.call('vibe_math_set_params', { leanAsync: 'false' })
1312
+ assert(bad.ok === true, 'set_params accepts a (badly typed) leanAsync')
1313
+ assert((await a.call('vibe_math_status', {})).params.leanAsync === true, '★★★ leanAsync="false" (a STRING) does not slip through the `else out[k]=v` tail: it falls back to the boolean default (true) — a truthy string must never be read as a boolean')
1314
+ const defs = (await a.call('vibe_math_status', {})).params
1315
+ assert(defs.leanJobsMaxParallel === 1 && defs.leanInitiative === 'normal' && Array.isArray(defs.leanSearchPaths) && defs.leanSearchPaths.length === 0, 'the amendment defaults are leanJobsMaxParallel=1 / leanInitiative=normal / leanSearchPaths=[]')
1316
+ await a.call('vibe_math_set_params', { leanInitiative: 'banana', leanJobsMaxParallel: 0, leanSearchPaths: 'not-an-array' })
1317
+ const p1 = (await a.call('vibe_math_status', {})).params
1318
+ assert(p1.leanInitiative === 'normal', 'leanInitiative rejects an unknown mode back to normal')
1319
+ assert(p1.leanJobsMaxParallel === 1, 'leanJobsMaxParallel is floored at 1 (never 0/unbounded)')
1320
+ assert(Array.isArray(p1.leanSearchPaths) && p1.leanSearchPaths.length === 0, 'leanSearchPaths rejects a non-array back to []')
1321
+ await a.call('vibe_math_set_params', { leanSearchPaths: [' /opt/lean-lib ', '/opt/lean-lib', ''] })
1322
+ assert(JSON.stringify((await a.call('vibe_math_status', {})).params.leanSearchPaths) === JSON.stringify(['/opt/lean-lib']), 'leanSearchPaths trims, drops empties and de-duplicates')
1323
+ // 纯函数:搜索路径计划(修订 §2)
1324
+ const planDefault = H.leanSearchPathPlan([], [], '/root/vm')
1325
+ assert(JSON.stringify(planDefault.paths) === JSON.stringify(['/root/vm']) && planDefault.inject.length === 2, '★ default plan injects exactly the automatic VibeMath root')
1326
+ const planExtra = H.leanSearchPathPlan(['env', 'lean'], ['/a', '/b'], '/root/vm')
1327
+ assert(JSON.stringify(planExtra.paths) === JSON.stringify(['/a', '/b', '/root/vm']), '★★ user leanSearchPaths come FIRST, then the automatic root')
1328
+ assert(JSON.stringify(planExtra.inject) === JSON.stringify(['--search-path', '/a', '--search-path', '/b', '--search-path', '/root/vm']), 'and each path is injected with its own --search-path flag')
1329
+ assert(H.leanSearchPathPlan(['--search-path', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ an explicit --search-path in leanArgs suppresses all injection (user wins)')
1330
+ assert(H.leanSearchPathPlan(['-R', '/x'], ['/a'], '/root/vm').inject.length === 0 && H.leanSearchPathPlan(['--root', '/x'], ['/a'], '/root/vm').inject.length === 0, '★ -R / --root are honoured too')
1331
+
1332
+ // ── argv:注入位置在用户参数之后、文件名之前 ──────────────────────────────────────
1333
+ const b = await makeCase('lean-argv')
1334
+ await b.call('vibe_math_set_params', { leanAsync: false, leanArgs: ['env', 'lean'], leanCommand: 'lean' })
1335
+ const projB = projRoot(b)
1336
+ mkdirSync(join(projB, 'Formal'), { recursive: true })
1337
+ writeFileSync(join(projB, 'Formal', 'srch.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1338
+ const runsBefore = leanRuns.length
1339
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1340
+ const argv1 = leanRuns[leanRuns.length - 1].argv
1341
+ assert(leanRuns.length === runsBefore + 1, 'the sync run really reached the compiler')
1342
+ assert(JSON.stringify(argv1.slice(0, 2)) === JSON.stringify(['lean', 'env']), 'the user leanArgs are preserved in order')
1343
+ assert(argv1[argv1.length - 2] === '--search-path' && SAME(argv1[argv1.length - 1], vibeRoot(b)), '★★ --search-path + the VibeMath root are injected AFTER the user args and immediately BEFORE the file (argv=' + JSON.stringify(argv1) + ')')
1344
+ await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean', '--search-path', '/explicit'] })
1345
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1346
+ const argv2 = leanRuns[leanRuns.length - 1].argv
1347
+ assert(argv2.indexOf('--search-path') === 3 && argv2[4] === '/explicit' && argv2.filter((x) => x === '--search-path').length === 1, '★ an explicit --search-path is respected verbatim (no second injection) — got ' + JSON.stringify(argv2))
1348
+ await b.call('vibe_math_set_params', { leanArgs: ['env', 'lean'], leanSearchPaths: ['/extra1', '/extra2'] })
1349
+ await b.call('vibe_math_lean_run', { file: 'Formal/srch.lean' })
1350
+ const argv3 = leanRuns[leanRuns.length - 1].argv
1351
+ assert(JSON.stringify(argv3.slice(-6)) === JSON.stringify(['--search-path', '/extra1', '--search-path', '/extra2', '--search-path', slash(vibeRoot(b))]), '★★ leanSearchPaths are injected first, then the automatic root (got ' + JSON.stringify(argv3.slice(-6)) + ')')
1352
+
1353
+ // ── 异步状态机:入队立即返回 → 心跳排空 → 只有 settled(ok) 才是通过 ────────────────
1354
+ const c = await makeCase('lean-async')
1355
+ await c.call('vibe_math_set_params', { leanAsync: true, tickIntervalMs: 200 })
1356
+ const projC = projRoot(c)
1357
+ mkdirSync(join(projC, 'Formal'), { recursive: true })
1358
+ writeFileSync(join(projC, 'Formal', 'async-good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
1359
+ writeFileSync(join(projC, 'Formal', 'async-bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
1360
+ const nBefore = leanRuns.length
1361
+ const q1 = await c.call('vibe_math_lean_run', { file: 'Formal/async-good.lean', target: 'pAsync' })
1362
+ assert(q1.ok === true && !!q1.async && q1.async.state === 'queued' && typeof q1.async.jobId === 'string' && q1.async.jobId.length > 3, '★★ leanAsync=true returns IMMEDIATELY with {async:{jobId,state:queued}} (got ' + JSON.stringify(q1.async) + ')')
1363
+ assert(leanRuns.length === nBefore, '★★ the tool call itself did NOT block on the compiler (it is enqueued, not run)')
1364
+ assert(q1.async.jobId.indexOf('pAsync-') === 0, 'the jobId is <target>-<fingerprint[0:12]> (got ' + q1.async.jobId + ')')
1365
+ const jobFile = join(projC, 'Formal', 'Jobs', q1.async.jobId + '.json')
1366
+ assert(existsSync(jobFile), 'the job record is mirrored to <project>/Formal/Jobs/<jobId>.json')
1367
+ const rec0 = JSON.parse(readIf(jobFile))
1368
+ assert(rec0.state === 'queued' && typeof rec0.sha256 === 'string' && typeof rec0.buildSha256 === 'string' && rec0.buildContext.length > 0, '★ the job record carries state + content hash + build-context hash')
1369
+ assert((formalStateOf(c).records || {}).pAsync === undefined || formalStateOf(c).records.pAsync.status !== 'passed', '★★ a queued job never marks the object passed')
1370
+ const done1 = await waitFor(() => { const r = JSON.parse(readIf(jobFile) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1371
+ assert(!!done1, 'the apply-level heartbeat drained the queue and settled the job (got ' + JSON.parse(readIf(jobFile) || '{}').state + ')')
1372
+ assert(done1 && done1.exitCode === 0, 'the settled record keeps exitCode 0')
1373
+ const recAfter = formalStateOf(c).records.pAsync
1374
+ assert(!!recAfter && recAfter.status === 'attempted' && recAfter.run && recAfter.run.ok === true, 'a green RUN record lands as attempted (stored, not promoted) with run.ok=true')
1375
+ assert(!!recAfter.async && recAfter.async.state === 'settled' && recAfter.async.jobId === q1.async.jobId, 'the object record carries the async job pointer/state')
1376
+ // 红色作业:attempted + 编译器输出入库,绝不 passed
1377
+ const q2 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1378
+ const done2 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q2.async.jobId + '.json')) || '{}'); return r.state === 'failed' ? r : undefined }, 80, 60)
1379
+ assert(!!done2 && done2.exitCode === 1, '★ a red async run settles as failed with exitCode 1')
1380
+ const recBad = formalStateOf(c).records.pAsyncBad
1381
+ assert(!!recBad && recBad.status === 'attempted' && recBad.run.ok === false, '★ a red async run is recorded as attempted (never passed)')
1382
+ assert(/sorry/.test(String(recBad.run.stderrTail || '')), 'and the compiler output is kept in the record for the member to fix')
1383
+ // ── 归档证明(异步):落地前不是 passed;落地 ok 才写 Verified/Lean/ ────────────────
1384
+ await c.call('vibe_math_add_proposition', { id: 'pAsymProof', 概述: '异步归档证明', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1385
+ mkdirSync(join(projC, 'Formal'), { recursive: true })
1386
+ const q3 = await c.call('vibe_math_lean_archive', { kind: 'proof', target: 'pAsymProof', content: 'theorem pAsymProof : 1 = 1 := rfl\n' })
1387
+ assert(q3.ok === true && q3.async && q3.async.state === 'queued' && q3.status === 'attempted', '★★ kind=proof in async档 returns queued + attempted (NOT passed)')
1388
+ assert(!existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ the archived proof file does NOT exist while the job is still queued')
1389
+ assert((formalStateOf(c).records.pAsymProof || {}).status !== 'passed', 'and the record is not passed yet')
1390
+ const done3 = await waitFor(() => { const r = JSON.parse(readIf(join(projC, 'Formal', 'Jobs', q3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1391
+ assert(!!done3, 'the proof job settled green')
1392
+ assert(existsSync(join(projC, 'Verified', 'Lean', 'pAsymProof.lean')), '★★ only after settled(ok) is Verified/Lean/<id>.lean written')
1393
+ assert((formalStateOf(c).records.pAsymProof || {}).status === 'passed', '★★ and only then does the object become passed')
1394
+ assert(/pAsymProof/.test(readIf(join(projC, 'Formal', 'Index.md'))), 'the index reflects the passed object')
1395
+ // ── lean_job(修订 §3):清单 / 单查 / waitMs ─────────────────────────────────────
1396
+ const listJobs = await c.call('vibe_math_lean_job', {})
1397
+ assert(listJobs.ok === true && Array.isArray(listJobs.jobs) && listJobs.jobs.length >= 3, '★ lean_job without jobId returns the session job list')
1398
+ assert(listJobs.jobs.every((j) => !!j.paths && typeof j.paths.receipt === 'string'), 'every listed job carries its receipt path')
1399
+ const one = await c.call('vibe_math_lean_job', { jobId: q3.async.jobId })
1400
+ assert(one.ok === true && one.state === 'settled' && one.passed === true && one.paths.archive === 'Verified/Lean/pAsymProof.lean', '★ lean_job {jobId} returns state/exitCode/paths and the passed verdict')
1401
+ assert((await c.call('vibe_math_lean_job', { jobId: 'nope-000000000000' })).code === 'V2_NOT_FOUND', 'an unknown jobId is refused with a typed code')
1402
+ const q4 = await c.call('vibe_math_lean_run', { file: 'Formal/async-bad.lean', target: 'pAsyncBad' })
1403
+ const waited = await c.call('vibe_math_lean_job', { jobId: q4.async.jobId, waitMs: 4000 })
1404
+ assert(waited.ok === true && waited.state !== 'queued' && waited.stillRunning === false && waited.waitedMs > 0, '★★ lean_job waitMs>0 waits for the job to leave queued/running (got ' + waited.state + ')')
1405
+ // ── 超时:leanTimeoutMs 是每个作业的预算,主动 terminate,绝不 passed ─────────────
1406
+ const d = await makeCase('lean-timeout')
1407
+ await d.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 1000 })
1408
+ const projD = projRoot(d)
1409
+ mkdirSync(join(projD, 'Formal'), { recursive: true })
1410
+ writeFileSync(join(projD, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
1411
+ const termBefore = terminations.length
1412
+ const q5 = await d.call('vibe_math_lean_run', { file: 'Formal/hang.lean', target: 'pHang' })
1413
+ const done5 = await waitFor(() => { const r = JSON.parse(readIf(join(projD, 'Formal', 'Jobs', q5.async.jobId + '.json')) || '{}'); return r.state === 'timeout' ? r : undefined }, 120, 60)
1414
+ assert(!!done5 && done5.timedOut === true, '★★ a hanging job times out (state=timeout, timedOut=true) with leanTimeoutMs as its budget')
1415
+ assert(terminations.length > termBefore, '★★ the timeout ACTIVELY called handle.terminate()')
1416
+ assert((formalStateOf(d).records.pHang || {}).status !== 'passed', '★ a timed-out job never sets passed')
1417
+ // ── 并发参数(修订 §5):leanJobsMaxParallel>1 时两个作业同时进入 running ──────────
1418
+ const e = await makeCase('lean-parallel')
1419
+ await e.call('vibe_math_set_params', { leanAsync: true, leanJobsMaxParallel: 2 })
1420
+ const projE = projRoot(e)
1421
+ mkdirSync(join(projE, 'Formal'), { recursive: true })
1422
+ writeFileSync(join(projE, 'Formal', 'h1.lean'), '-- HANG\ntheorem h1 : 1 = 1 := rfl\n', 'utf8')
1423
+ writeFileSync(join(projE, 'Formal', 'h2.lean'), '-- HANG\ntheorem h2 : 1 = 1 := rfl\n', 'utf8')
1424
+ await e.call('vibe_math_set_params', { leanTimeoutMs: 3000 })
1425
+ const j1 = await e.call('vibe_math_lean_run', { file: 'Formal/h1.lean', target: 'pH1' })
1426
+ const j2 = await e.call('vibe_math_lean_run', { file: 'Formal/h2.lean', target: 'pH2' })
1427
+ // Both are HANG jobs: with concurrency 2 they must be in `running` AT THE SAME TIME. A serial
1428
+ // queue can never reach that state, so the probe discriminates the `leanJobsMaxParallel` parameter.
1429
+ const both = await waitFor(() => {
1430
+ const a = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j1.async.jobId + '.json')) || '{}')
1431
+ const b = JSON.parse(readIf(join(projE, 'Formal', 'Jobs', j2.async.jobId + '.json')) || '{}')
1432
+ return (a.state === 'running' && b.state === 'running') ? { a: a, b: b } : undefined
1433
+ }, 120, 50)
1434
+ assert(!!both, '★★ with leanJobsMaxParallel=2 BOTH queued jobs are in running at the same time (concurrency is a parameter, not a constant)')
1435
+ for (const k of [j1, j2]) { try { await e.call('vibe_math_lean_job', { jobId: k.async.jobId, waitMs: 3500 }) } catch (err) { /* ignore */ } }
1436
+ // ── 卸载(spec §2.5 / §7-6):terminate + interrupted,绝不 passed、绝不留孤儿 ────────
1437
+ const dz = await makeCase('lean-dispose')
1438
+ await dz.call('vibe_math_set_params', { leanAsync: true, leanTimeoutMs: 60000 })
1439
+ const projZ = projRoot(dz)
1440
+ mkdirSync(join(projZ, 'Formal'), { recursive: true })
1441
+ writeFileSync(join(projZ, 'Formal', 'orphan.lean'), '-- HANG\ntheorem orphan : 1 = 1 := rfl\n', 'utf8')
1442
+ const termZ = terminations.length
1443
+ const qz = await dz.call('vibe_math_lean_run', { file: 'Formal/orphan.lean', target: 'pOrphan' })
1444
+ const startedZ = await waitFor(() => { const r = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}'); return r.state === 'running' ? r : undefined }, 80, 60)
1445
+ assert(!!startedZ, 'the dispose case got a job into running')
1446
+ dz.unmount() // ctx.effect disposer ⇒ disposeLeanJobs()
1447
+ assert(terminations.length > termZ, '★★ unmounting the plugin terminated the in-flight compile (no orphan process)')
1448
+ assert((formalStateOf(dz).records.pOrphan || {}).status !== 'passed', '★ dispose never marks the object passed')
1449
+ const afterZ = JSON.parse(readIf(join(projZ, 'Formal', 'Jobs', qz.async.jobId + '.json')) || '{}')
1450
+ assert(afterZ.interrupted === true || afterZ.state === 'interrupted' || afterZ.state === 'running', '★ the disposed job is recorded as interrupted (or left running for the next recovery to mark)')
1451
+ // ── 去重(§4.3):同内容 + 同构建上下文 ⇒ 第二次不再编译 ──────────────────────────
1452
+ const f = await makeCase('lean-dedupe')
1453
+ await f.call('vibe_math_set_params', { leanAsync: true })
1454
+ const defBody = 'def reusableOne : Nat := 1\n'
1455
+ const r1 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1456
+ assert(r1.ok === true && r1.async && r1.deduped === false, 'the first archive of a definition is enqueued (not deduped)')
1457
+ await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r1.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1458
+ const runsAfter1 = leanRuns.length
1459
+ const r2 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1460
+ assert(r2.ok === true && r2.deduped === true && r2.async === undefined, '★★★ re-archiving identical content is DEDUPED (no job, no recompile) — got ' + JSON.stringify({ deduped: r2.deduped, async: r2.async }))
1461
+ assert(leanRuns.length === runsAfter1, '★★ the deduped archive did NOT recompile')
1462
+ // 构建上下文进指纹(修订 §4):同内容、不同 leanArgs ⇒ 不同 jobId / 不命中"已验证"
1463
+ await f.call('vibe_math_set_params', { leanArgs: ['--threads=2'] })
1464
+ const r5 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: defBody })
1465
+ assert(r5.ok === true && r5.deduped === false && !!r5.async && !!r1.async && r5.async.jobId !== r1.async.jobId, '★★★ the SAME content under a DIFFERENT build context (leanArgs) gets a different jobId — it is not treated as already-verified (got ' + JSON.stringify({ deduped: r5.deduped, async: r5.async }) + ')')
1466
+ if (r5.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r5.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1467
+ await f.call('vibe_math_set_params', { leanArgs: [] })
1468
+ const changed = defBody + '\n-- 内容变了\n'
1469
+ const r3 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1470
+ assert(r3.ok === true && r3.deduped === false && !!r3.async, 'a changed body is NOT deduped (content hash differs)')
1471
+ if (r3.async) await waitFor(() => { const r = JSON.parse(readIf(join(projRoot(f), 'Formal', 'Jobs', r3.async.jobId + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1472
+ const r6 = await f.call('vibe_math_lean_archive', { kind: 'def', name: 'reusableOne', content: changed })
1473
+ assert(r6.ok === true && r6.deduped === true, 'and the changed body dedupes on its own (content-based, not "first wins")')
1474
+ // ── 崩溃恢复(§2.6 + 修订 §4):绝不置 passed ─────────────────────────────────────
1475
+ const g = await makeCase('lean-recover')
1476
+ await g.call('vibe_math_set_params', { leanAsync: true })
1477
+ const projG = projRoot(g)
1478
+ mkdirSync(join(projG, 'Formal', 'Jobs'), { recursive: true })
1479
+ const goodBody = 'theorem recGood : 1 = 1 := rfl\n'
1480
+ writeFileSync(join(projG, 'Formal', 'recGood.lean'), goodBody, 'utf8')
1481
+ const ctxG = H.leanBuildContext('lean', [], [slash(vibeRoot(g))])
1482
+ const fpG = H.leanJobFingerprint(goodBody, ctxG)
1483
+ const jobIdG = H.leanJobId('pRecGood', fpG)
1484
+ writeFileSync(join(projG, 'Formal', 'Jobs', jobIdG + '.json'), JSON.stringify({
1485
+ jobId: jobIdG, kind: 'archive', state: 'running', attempts: 1, target: 'pRecGood', name: 'pRecGood',
1486
+ rel: 'Formal/recGood.lean', scope: 'project', sha256: H.leanContentSha(goodBody), buildSha256: fpG, buildContext: ctxG,
1487
+ engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1488
+ }), 'utf8')
1489
+ // 哈希不匹配的遗留作业:只标记,绝不重驱、绝不 passed
1490
+ const otherJob = H.leanJobId('pRecOther', H.leanJobFingerprint('theorem other : 1 = 1 := rfl\n', ctxG))
1491
+ writeFileSync(join(projG, 'Formal', 'Jobs', otherJob + '.json'), JSON.stringify({
1492
+ jobId: otherJob, kind: 'archive', state: 'running', attempts: 3, target: 'pRecOther', name: 'pRecOther',
1493
+ rel: 'Formal/recMissing.lean', scope: 'project', sha256: H.leanContentSha('theorem other : 1 = 1 := rfl\n'), buildSha256: 'ffffffffffff', buildContext: ctxG,
1494
+ engine: 'lean', argv: [], searchPaths: [slash(vibeRoot(g))], archiveKind: 'proof', enqueuedAt: 1, startedAt: 2, settledAt: 0, exitCode: null,
1495
+ }), 'utf8')
1496
+ await g.call('vibe_math_new_project', { name: 'proj' }) // 触发 setProject ⇒ recoverLeanJobs()
1497
+ const recG = formalStateOf(g).records.pRecGood || {}
1498
+ assert(recG.status === 'attempted', '★★ recovery marks a crashed running job as attempted (never passed)')
1499
+ const recOther = formalStateOf(g).records.pRecOther || {}
1500
+ assert(recOther.status === 'attempted', 'recovery marks the hash-mismatched job object as attempted too')
1501
+ assert(!existsSync(join(projG, 'Verified', 'Lean', 'pRecGood.lean')), '★ recovery never writes an archived proof for a crashed job')
1502
+ const recJobG = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}')
1503
+ assert(Number(recJobG.attempts) === 2 && ['queued', 'running', 'settled'].indexOf(recJobG.state) !== -1, '★★ the matching-hash job was re-enqueued with attempts+1 (got ' + JSON.stringify({ state: recJobG.state, attempts: recJobG.attempts }) + ')')
1504
+ const otherAfter = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', otherJob + '.json')) || '{}')
1505
+ assert(otherAfter.state === 'interrupted' && Number(otherAfter.attempts) === 3, '★★ the mismatched job is only marked interrupted (NOT re-driven, attempts unchanged)')
1506
+ const recDone = await waitFor(() => { const r = JSON.parse(readIf(join(projG, 'Formal', 'Jobs', jobIdG + '.json')) || '{}'); return r.state === 'settled' ? r : undefined }, 80, 60)
1507
+ assert(!!recDone, 'the re-enqueued job then settles normally on the heartbeat (the queue really resumed)')
1508
+ // ── lean_read(§1.2):只读、路径守卫、64KB 截断 ─────────────────────────────────
1509
+ mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1510
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'recGood.lean'), goodBody, 'utf8')
1511
+ const rd = await g.call('vibe_math_lean_read', { name: 'recGood' })
1512
+ assert(rd.ok === true && rd.kind === 'lib' && rd.sha256 === H.leanContentSha(goodBody) && rd.truncated === false && rd.text === goodBody, '★ lean_read returns the archived text + sha256 + bytes for a Lib file (got ' + JSON.stringify(rd).slice(0, 200) + ')')
1513
+ assert(typeof rd.bytes === 'number' && rd.bytes === Buffer.byteLength(goodBody, 'utf8'), 'lean_read reports the byte length')
1514
+ assert((await g.call('vibe_math_lean_read', { name: 'recGood', kind: 'proved' })).code === 'V2_NOT_FOUND', 'kind=proved looks only in Formal/Proved')
1515
+ for (const badName of ['../etc/passwd', 'a/b', 'C:\\x\\y', '..']) {
1516
+ const rbad = await g.call('vibe_math_lean_read', { name: badName })
1517
+ assert(rbad.ok === false && rbad.code === 'V2_INVALID_ARGUMENT', '★ lean_read rejects the path-escape attempt ' + JSON.stringify(badName))
1518
+ }
1519
+ const bigName = 'bigOne'
1520
+ mkdirSync(join(vibeRoot(g), 'Formal', 'Lib'), { recursive: true })
1521
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', bigName + '.lean'), '-- ' + 'x'.repeat(70 * 1024) + '\n', 'utf8')
1522
+ const rbig = await g.call('vibe_math_lean_read', { name: bigName })
1523
+ assert(rbig.ok === true && rbig.truncated === true && Buffer.byteLength(rbig.text, 'utf8') <= 64 * 1024, '★ lean_read truncates at 64KB and says so (truncated=true)')
1524
+ // ── 索引依赖列(§4.4)与 lean_lib 的 jobs/searchPath ─────────────────────────────
1525
+ writeFileSync(join(vibeRoot(g), 'Formal', 'Lib', 'depUser.lean'), 'import Formal.Lib.bigOne\n\ndef depUser := 1\n', 'utf8')
1526
+ const lib = await g.call('vibe_math_lean_lib', {})
1527
+ assert(!!lib.paths && SAME(lib.paths.searchPath, vibeRoot(g)), '★ lean_lib exposes paths.searchPath = the VibeMath root (got ' + JSON.stringify(lib.paths) + ')')
1528
+ assert(Array.isArray(lib.jobs) && lib.jobs.length >= 1, '★ lean_lib exposes the job list')
1529
+ assert(lib.async === true, 'lean_lib reports the current async mode')
1530
+ const libIdx = readIf(join(vibeRoot(g), 'Formal', 'Lib', 'Index.md'))
1531
+ assert(/依赖(import)/.test(libIdx) && /Formal\.Lib\.bigOne/.test(libIdx), '★★ the Lib index carries the dependency column scanned from `import` lines')
1532
+ // ── 主动性档位(修订 §1):提示词读 leanInitiative,不是 formalVerify ──────────────
1533
+ const wNormal = await makeCase('lean-init-normal')
1534
+ await wNormal.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'normal' })
1535
+ await wNormal.call('vibe_math_add_problem', { id: 'qInit', description: '主动性提示词 normal' })
1536
+ await startScheduler(wNormal)
1537
+ const exN = await waitFor(() => wNormal.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1538
+ const promptNormal = (exN && exN.prompt) || ''
1539
+ assert(/主动性 normal/.test(promptNormal) && /三条筛选判据/.test(promptNormal), '★ the work prompt names the initiative档位 and the three selection criteria')
1540
+ await wNormal.call('vibe_math_pause', {})
1541
+
1542
+ const wEager = await makeCase('lean-init-eager')
1543
+ await wEager.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'eager' })
1544
+ await wEager.call('vibe_math_add_problem', { id: 'qInitE', description: '主动性提示词 eager' })
1545
+ await startScheduler(wEager)
1546
+ const exE = await waitFor(() => wEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1547
+ assert(!!exE && /主动档(leanInitiative=eager)/.test(exE.prompt || ''), '★★ leanInitiative=eager adds the eager line (the prompt reads the initiative parameter)')
1548
+ await wEager.call('vibe_math_pause', {})
1549
+
1550
+ // v5 统一口径:主动性(leanInitiative)与验证要求强度(formalVerify)是两根独立的轴。
1551
+ // (a)leanInitiative:'off' ⇒ 连日线都不注入(真无操作)。
1552
+ const wOff = await makeCase('lean-init-off')
1553
+ await wOff.call('vibe_math_set_params', { formalVerify: 'encourage', leanInitiative: 'off' })
1554
+ await wOff.call('vibe_math_add_problem', { id: 'qInitO', description: '主动性提示词 off' })
1555
+ await startScheduler(wOff)
1556
+ const exO = await waitFor(() => wOff.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1557
+ assert(!!exO && !/顺手形式化/.test(exO.prompt || ''), '★★ leanInitiative=off drops the daily line entirely (initiative is a separate axis from formalVerify, which is still encourage here)')
1558
+ await wOff.call('vibe_math_pause', {})
1559
+
1560
+ // (b)formalVerify:'off' + leanInitiative:'eager' ⇒ 日线仍必须注入(eager 是独立轴,不许被 off 静默吞掉),
1561
+ // 而**验证阶段**的 Lean 文本仍然关闭。
1562
+ const wOffEager = await makeCase('lean-init-off-eager')
1563
+ await wOffEager.call('vibe_math_set_params', { formalVerify: 'off', leanInitiative: 'eager' })
1564
+ await wOffEager.call('vibe_math_add_problem', { id: 'qInitOE', description: 'off+eager 主动性' })
1565
+ await startScheduler(wOffEager)
1566
+ const exOE = await waitFor(() => wOffEager.spawns.find((s) => s.label.indexOf('explorer') === 0), 60, 60)
1567
+ const pOE = (exOE && exOE.prompt) || ''
1568
+ assert(/主动档(leanInitiative=eager)/.test(pOE) && /三条筛选判据/.test(pOE), '★★★ formalVerify:off + leanInitiative:eager STILL injects the full daily line (v5 rule: the initiative axis is independent)')
1569
+ assert(!/【Lean 形式化验证(/.test(pOE) && !wOffEager.spawns.some((s) => /【Lean 形式化验证(/.test(s.prompt || '')), '★★★ …while the verification-phase Lean block stays closed (formalVerify:off) in every captured prompt of that root')
1570
+ const stOE = await wOffEager.call('vibe_math_status', {})
1571
+ assert(stOE.params.formalVerify === 'off' && stOE.params.leanInitiative === 'eager', 'the off+eager case really ran with formalVerify:off + leanInitiative:eager')
1572
+ await wOffEager.call('vibe_math_pause', {})
1573
+ }
1574
+
1575
+ // ---------- 15e. round-2 lens-1 RUNTIME: the initiative axis is injected at EVERY site ----------
1576
+ // Three injection sites with three different shapes (explorer / rederive / solver). The static [sites]
1577
+ // check can only see that the producer call is not textually gated; THIS section proves the actual
1578
+ // prompt text of each site: (1) formalVerify:'off' + leanInitiative:'eager' must carry the daily line
1579
+ // at that site (and keep the verification block closed), (2) leanInitiative:'off' must drop it there
1580
+ // while the verification phase still follows formalVerify. Assertion names carry the site name, so a
1581
+ // single-site regression names itself instead of hiding behind a sibling site's coverage.
1582
+ const SITE_DAILY = /三条筛选判据/
1583
+ const SITE_VERIFY = /【Lean 形式化验证(/
1584
+ const FENCE = '`'.repeat(3)
1585
+ const jsonReply = (obj) => FENCE + 'json\n' + JSON.stringify(obj) + '\n' + FENCE
1586
+ /** Drive one problem through explorer -> solver(dead-end) -> re-derive and collect each site's prompt. */
1587
+ async function driveSites(h, opts, qid) {
1588
+ const q = qid || 'qSite'
1589
+ await h.call('vibe_math_set_params', Object.assign({ tickIntervalMs: 200, verifierCount: 2, leanAsync: false, maxExplorerRetries: 5, maxParallelThreshold: 4 }, opts))
1590
+ await h.call('vibe_math_add_problem', { id: q, description: '站点级运行时用例' })
1591
+ await startScheduler(h)
1592
+ const label = (n) => h.spawns.filter((s) => s.label.startsWith(n + ':' + q))
1593
+ const ex1 = await waitFor(() => label('explorer')[0], 80, 200)
1594
+ const out = { explorer: (ex1 && ex1.prompt) || '' }
1595
+ if (ex1) h.fireEnd({ id: ex1.childId, runId: 'rS1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1596
+ const so = await waitFor(() => label('solver')[0], 80, 200)
1597
+ out.solver = (so && so.prompt) || ''
1598
+ if (so) h.fireEnd({ id: so.childId, runId: 'rS2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'dead-end', dead_end_reason: '站点用例:本方向不可行', solution: '', solution_probability: 0.1, lemmas: [], routes: [], lessons: [], survival_probability: 0.2, sub_questions: [], new_inventions: [{ 类型: '工具', 标题: '站点工具', 内容描述: 'x', 是否已入库: false }] }) }] })
1599
+ // every direction is now dead-end ⇒ the NEXT explorer for this problem is built by rederivePrompt
1600
+ await waitFor(() => label('explorer').length >= 2 && label('explorer')[1], 80, 200)
1601
+ const exs = label('explorer')
1602
+ out.rederive = (exs[1] && exs[1].prompt) || ''
1603
+ out.distinct = !!out.explorer && !!out.rederive && out.explorer !== out.rederive
1604
+ await h.call('vibe_math_abort', {})
1605
+ return out
1606
+ }
1607
+ {
1608
+ // (1) eager 在 formalVerify:'off' 下也必须在**每一个**站点注入日线
1609
+ const he = await makeCase('site-eager-off')
1610
+ const A = await driveSites(he, { formalVerify: 'off', leanInitiative: 'eager' })
1611
+ assert(SITE_DAILY.test(A.explorer), '★ [explorer site] formalVerify:off + leanInitiative:eager injects the daily line')
1612
+ assert(SITE_DAILY.test(A.solver), '★ [solver site] formalVerify:off + leanInitiative:eager injects the daily line')
1613
+ assert(SITE_DAILY.test(A.rederive), '★ [rederive site] formalVerify:off + leanInitiative:eager injects the daily line')
1614
+ assert(A.distinct, '★ [rederive site] the re-explore prompt really is the rederive builder (differs from the first explorer wave)')
1615
+ assert(A.explorer.indexOf('【Lean 形式化验证(') === -1 && A.solver.indexOf('【Lean 形式化验证(') === -1 && A.rederive.indexOf('【Lean 形式化验证(') === -1, '★ [all sites] formalVerify:off keeps the verification-phase block closed while eager still injects the daily line')
1616
+
1617
+ // (2) leanInitiative:'off' ⇒ 每个站点都没有日线(同一驱动)
1618
+ const ho = await makeCase('site-init-off')
1619
+ const B = await driveSites(ho, { formalVerify: 'encourage', leanInitiative: 'off' }, 'qSiteOff')
1620
+ assert(!SITE_DAILY.test(B.explorer) && !/【顺手形式化/.test(B.explorer), '★ [explorer site] leanInitiative:off drops the daily line')
1621
+ assert(!SITE_DAILY.test(B.solver) && !/【顺手形式化/.test(B.solver), '★ [solver site] leanInitiative:off drops the daily line')
1622
+ assert(!SITE_DAILY.test(B.rederive) && !/【顺手形式化/.test(B.rederive), '★ [rederive site] leanInitiative:off drops the daily line')
1623
+
1624
+ // (2b) …而验证段仍按 formalVerify 出现(同一参数下的另一个 root,驱动到 verifier)
1625
+ const hv = await makeCase('site-init-off-verify')
1626
+ await hv.call('vibe_math_set_params', { tickIntervalMs: 200, verifierCount: 2, leanAsync: false, formalVerify: 'encourage', leanInitiative: 'off' })
1627
+ await hv.call('vibe_math_add_problem', { id: 'qV', description: '验证段仍在' })
1628
+ await startScheduler(hv)
1629
+ const exv = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('explorer:qV')), 80, 200)
1630
+ assert(!!exv && !SITE_DAILY.test(exv.prompt || ''), '★ [explorer site] 第二个 root:initiative off 时 explorer 仍无日线')
1631
+ if (exv) hv.fireEnd({ id: exv.childId, runId: 'rV1', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ directions: [{ id: 'd1', title: 'D', method: 'm', core_assumption: 'c', feasibility: 0.8 }] }) }] })
1632
+ const sov = await waitFor(() => hv.spawns.find((s) => s.label.startsWith('solver:qV')), 80, 200)
1633
+ if (sov) hv.fireEnd({ id: sov.childId, runId: 'rV2', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: jsonReply({ status: 'success', solution: 'complete solution', solution_probability: 0.9, lemmas: [], routes: [], lessons: [], survival_probability: 0.9, dead_end_reason: null, sub_questions: [] }) }] })
1634
+ const vv = await waitFor(() => hv.spawns.find((s) => /^verifier:/.test(s.label)), 100, 250)
1635
+ assert(!!vv && SITE_VERIFY.test(vv.prompt || ''), '★ leanInitiative:off 时验证段仍按 formalVerify 注入(verifier 提示词含【Lean 形式化验证()')
1636
+ await hv.call('vibe_math_abort', {})
1637
+ }
1638
+
1285
1639
  section('16 the captured prompt corpus is written for human review')
1286
1640
  {
1287
1641
  // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
@@ -1337,7 +1691,7 @@ section('16 the captured prompt corpus is written for human review')
1337
1691
  assert(entries.some((e) => /【Lean 形式化验证(鼓励模式)】/.test(e.prompt)), 'the corpus contains the encourage verify prompt')
1338
1692
  assert(entries.some((e) => /【Lean 形式化验证(强制模式)】/.test(e.prompt)), '★ the corpus contains the REQUIRE verify prompt')
1339
1693
  assert(entries.some((e) => /你不需要重新检查推导/.test(e.prompt) && /一致 → Result = 1/.test(e.prompt)), 'the corpus contains the passed/fidelity prompt')
1340
- assert(entries.some((e) => /【顺手形式化(鼓励)】/.test(e.prompt)), 'the corpus contains the work-round 顺手形式化 prompt')
1694
+ assert(entries.some((e) => /【顺手形式化(鼓励[^)]*)】/.test(e.prompt)), 'the corpus contains the work-round 顺手形式化 prompt')
1341
1695
  assert(entries.some((e) => /【顺手形式化/.test(e.prompt) && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(e.prompt)), 'the corpus contains the formal reply contract line')
1342
1696
  assert(entries.some((e) => !/Lean|形式化/.test(e.prompt)), 'the corpus contains off-mode prompts with no Lean text at all')
1343
1697
  assert(entries.some((e) => e.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')