dsh-vibe-math 2.3.1 → 2.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AUDIT-CHECKLIST.md +59 -0
  2. package/README.md +16 -6
  3. package/RELEASE-NOTES-2.3.2.md +145 -0
  4. package/RELEASE-NOTES-2.3.3.md +115 -0
  5. package/audit-formal-sensitivity.mjs +11 -2
  6. package/audit-prompt-invariants.mjs +459 -0
  7. package/audit-spec-traceability.mjs +193 -0
  8. package/docs/formal-verification.md +37 -13
  9. package/docs/generate_framework_diagram_v5.mjs +2 -1
  10. package/docs/test-timing.md +32 -10
  11. package/formal-verify-v2.test.mjs +288 -2
  12. package/formal-verify-v3.test.mjs +230 -4
  13. package/formal-verify-v4.test.mjs +205 -5
  14. package/formal-verify-v5.test.mjs +114 -4
  15. package/installer.js +3 -1
  16. package/package.json +6 -2
  17. package/prompt-corpus-persona/persona-corpus.json +2 -2
  18. package/prompt-corpus-persona/persona-corpus.md +6 -2
  19. package/prompt-corpus-v2/formal-verify-v2.json +164 -44
  20. package/prompt-corpus-v2/formal-verify-v2.md +1374 -44
  21. package/prompt-corpus-v3/formal-verify-v3.json +236 -128
  22. package/prompt-corpus-v3/formal-verify-v3.md +1509 -400
  23. package/prompt-corpus-v4/formal-verify-v4.json +8 -3
  24. package/prompt-corpus-v4/formal-verify-v4.md +38 -10
  25. package/prompt-corpus-v5/prompt-corpus-v5.json +175 -246
  26. package/prompt-corpus-v5/prompt-corpus-v5.md +341 -781
  27. package/prompt-v5-integrity.test.mjs +136 -22
  28. package/run-tests.mjs +30 -11
  29. package/vibe-math-v2/vibe-math-v2.js +163 -38
  30. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +58 -6
  31. package/vibe-math-v3/vibe-math-v3.js +95 -25
  32. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +8 -7
  33. package/vibe-math-v4/vibe-math-v4.js +103 -24
  34. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +37 -14
  35. package/vibe-math-v5/agent.cordis.yml +6 -2
  36. package/vibe-math-v5/vibe-math-v5.js +56 -10
  37. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +45 -14
  38. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +16 -2
  39. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +6 -5
@@ -298,7 +298,6 @@ export function apply(ctx) {
298
298
  { name: 'plannerEnabled', type: 'boolean', description: 'false = 完全走内置启发式调度(规划代理禁用)', suggestion: true },
299
299
  { name: 'plannerProvider', type: 'string', description: '规划代理模型 provider(空 = 继承根代理)', suggestion: '' },
300
300
  { name: 'plannerModel', type: 'string', description: '规划代理模型 id(空 = 继承根代理)', suggestion: '' },
301
- { name: 'plannerPersona', type: 'string', description: '注入规划代理提示词开头的人格/要求', suggestion: '' },
302
301
  { name: 'planMinIntervalMs', type: 'integer', description: '两次规划调用的最小间隔(毫秒);系统空闲且有工作时忽略', suggestion: 30000 },
303
302
  { name: 'plannerMaxFails', type: 'integer', description: '规划代理连续失败达此值 → 自动降级启发式', suggestion: 3 },
304
303
  { name: 'methodKeepIntervalMs', type: 'integer', description: 'Method Keeper 定时整理间隔(0 = 事件驱动)', suggestion: 0 },
@@ -1577,9 +1576,9 @@ export function apply(ctx) {
1577
1576
  }
1578
1577
  async function writeVerifiedPropositionCardIfNeeded(p) {
1579
1578
  if (p.概率 !== 1 && p.概率 !== 0) return false
1580
- // 写 Verified 卡片是"宣告定论"的收口点:require 模式下没过门就绝不写(防御性双保险,
1581
- // 正常路径已在 settleVerdict / processStatusUpdates 拦住)。
1582
- if (formalBlocksConclusion(p.id)) return false
1579
+ // 门禁只在**唯一的收口点**(writeVerifiedCardIfChanged)判定:那里的语义是"新卡要过门、
1580
+ // 已存在的卡只做刷新(可能要把被 defect 撤回的形式化状态如实改掉)"。在这里提前 return false
1581
+ // 会把两种情形一起挡掉,于是 require 档下一张已存在的卡片会永久宣称「形式化: Lean 通过」。
1583
1582
  const proofs1 = (p.proofs || []).filter(function (x) { return x.prob === 1 })
1584
1583
  const refutes1 = (p.refutes || []).filter(function (x) { return x.prob === 1 })
1585
1584
  const parts = []
@@ -1590,7 +1589,7 @@ export function apply(ctx) {
1590
1589
  }
1591
1590
  async function writeVerifiedProblemCardIfNeeded(q) {
1592
1591
  if (q.状态 !== '已解决') return false
1593
- if (formalBlocksConclusion(q.id)) return false
1592
+ // 同 writeVerifiedPropositionCardIfNeeded:门禁统一在 writeVerifiedCardIfChanged 里判定。
1594
1593
  const sols1 = (q.solutions || []).filter(function (s) { return s.prob === 1 })
1595
1594
  const parts = []
1596
1595
  for (let i = 0; i < sols1.length; i++) parts.push('【解法 #' + (i + 1) + '】' + (sols1[i].text || ''))
@@ -1603,8 +1602,13 @@ export function apply(ctx) {
1603
1602
  const existing = await readText(rel)
1604
1603
  // 最后一道闸门:require 模式下没有 passed/blocked 记录就不允许**新写** Verified 卡片。
1605
1604
  // 卡片已经存在(切到 require 之前就已定论)只做刷新,不算"新的定论",因此不记待办。
1606
- if (formalBlocksConclusion(card.id)) {
1607
- if (existing === undefined) await deferForFormal(card.id, formalRequiredWhy(card.id), card.结论 === true)
1605
+ //
1606
+ // 但"只做刷新"必须**真的刷新**:`defect` 会撤回形式化(降级 attempted、proof 清空、归档证明
1607
+ // 删除/覆盖),若在这里连刷新也一起 return false,那张已存在的卡片会永久宣称
1608
+ // 「形式化: Lean 通过(Verified/Lean/<id>.lean)」——指向一份已经不存在的证明。门禁管的是
1609
+ // "能不能宣告新结论",不是"能不能说实话"。所以:不存在 → 记待办并拒绝新写;已存在 → 照常刷新。
1610
+ if (formalBlocksConclusion(card.id) && existing === undefined) {
1611
+ await deferForFormal(card.id, formalRequiredWhy(card.id), card.结论 === true)
1608
1612
  return false
1609
1613
  }
1610
1614
  const md = composeVerifiedMd(card)
@@ -2621,7 +2625,12 @@ export function apply(ctx) {
2621
2625
  L.push(' ▸ **发现任何偏差,不要投 0**:偏差只说明**形式化不合格**,不代表命题为假。此时请:')
2622
2626
  L.push(' ① Result 给一个严格介于 0 与 1 之间的值(记为弃权),并在 Reason 里写清偏差;')
2623
2627
  L.push(" ② 用回执 formal:{decision:'defect', note:'<具体偏差>'} 记录它。框架会撤回这条证明的")
2624
- L.push(' 「已通过」状态(降级为 attempted、删除归档证明、写入形式化待办),本次裁定**不定论**;')
2628
+ // §4.1 第 3 条:只有 `require` 真的有门禁。`encourage` 档框架**仍然**撤回证明并记入待办,
2629
+ // 但**不得**承诺一个它无法强制的"不定论"——那里靠表决者自己的弃权票使表决无法得出一致结论。
2630
+ L.push(' 「已通过」状态(降级为 attempted、删除或就地覆盖归档证明、写入形式化待办)'
2631
+ + (mode === 'require'
2632
+ ? ',本次裁定**不定论**;'
2633
+ : '。**本档没有门禁**:请务必给弃权值,以保证本轮无法得出一致结论;'))
2625
2634
  L.push(' 修正形式化并重新跑通后再投票。')
2626
2635
  L.push(' ▸ 只有当你**独立于这份 Lean 代码**也能确定命题为假时,才投 0,并在 Reason 里写清独立理由。')
2627
2636
  } else if (rec.status === 'blocked') {
@@ -2633,13 +2642,15 @@ export function apply(ctx) {
2633
2642
  L.push(' · 工作目录:Formal/(相对项目根);可复用定义放 ' + (vibeRoot() + '/Formal/Lib/').replace(/\\/g, '/')
2634
2643
  + ',已证引理放 ' + (vibeRoot() + '/Formal/Proved/').replace(/\\/g, '/') + ';写之前先 vibe_math_lean_lib 查重。')
2635
2644
  L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义/对象/条件/假设/结论是否与命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
2645
+ // 把"这么做的收益"说出来:本轮通过,下一轮的审查对象就整体换掉了(不是再加一道苦役)。
2646
+ L.push(' ▸ 若你在本轮把它形式化并跑通(vibe_math_lean_archive kind=\'proof\'),后续轮次的审查对象就会从"推导是否正确"变成"Lean 代码是否忠实于命题"。')
2636
2647
  if (mode === 'require') {
2637
2648
  L.push(' · **本模式要求**:必须产出 Lean 形式化,或**必须**给出显式的阻塞原因(vibe_math_lean_archive kind=\'blocked\' note=… 或回执 formal.note)。若两者都没有,本次裁定不会生效,会被记为未定论(原因 formal-required)并进入「形式化待办」。')
2638
2649
  } else {
2639
2650
  L.push(' · 若你判断不值得或无法形式化,可以不做,但请在回执的 formal 字段写明难度判断(decision=\'blocked\' 时必须写明 note)。')
2640
2651
  }
2641
2652
  L.push(' · 归档可复用定义/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run);跑不通不要入库。')
2642
- L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"——这算显式阻塞原因,定论门禁可以据此放行。')
2653
+ L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或宿主不提供 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"——这算显式阻塞原因,定论门禁可以据此放行。')
2643
2654
  }
2644
2655
  return L.join('\n')
2645
2656
  }
@@ -2762,7 +2773,10 @@ export function apply(ctx) {
2762
2773
  }
2763
2774
  L.push('')
2764
2775
  if (formalTodo().length) {
2765
- L.push('## 形式化待办(require 模式:定论被搁置)')
2776
+ // §4.1 第 3 条:只有 `require` 真的搁置定论;`encourage` 档的待办只是"这份形式化要重做"的记录。
2777
+ L.push(formalMode() === 'require'
2778
+ ? '## 形式化待办(require 模式:定论被搁置)'
2779
+ : '## 形式化待办(encourage 档:框架不搁置定论,靠表决者弃权)')
2766
2780
  for (const t of formalTodo()) L.push('- ' + t.id + ' —— ' + (t.why || 'formal-required') + '(' + fmtTime(t.at) + ')')
2767
2781
  L.push('')
2768
2782
  }
@@ -2771,7 +2785,10 @@ export function apply(ctx) {
2771
2785
  async function writeFormalTodo() {
2772
2786
  const list = formalTodo()
2773
2787
  const L = ['# 形式化待办|' + currentProject + '|' + fmtTime(), '',
2774
- '> 这些对象在 `require` 模式下尚不具备「Lean 已通过」或「显式阻塞记录」,因此**定论被搁置**。',
2788
+ // 说清这一档**实际**会发生什么:只有 require 有门禁会把定论记为未定论。
2789
+ formalMode() === 'require'
2790
+ ? '> 这些对象在 `require` 模式下尚不具备「Lean 已通过」或「显式阻塞记录」,因此**定论被搁置**。'
2791
+ : '> 这些对象尚未取得「Lean 已通过」或「显式阻塞记录」。本档(encourage)**没有定论门禁**,框架不会搁置裁定——请在投票时给出严格介于 0 与 1 之间的弃权值,并尽快修正形式化。',
2775
2792
  '> 完成形式化(vibe_math_lean_archive kind=\'proof\')或记录阻塞原因(kind=\'blocked\')后,重新提议验证即可。', '']
2776
2793
  if (!list.length) L.push('(暂无)')
2777
2794
  for (const t of list) L.push('- ' + t.id + '|' + (t.why || 'formal-required') + '|' + fmtTime(t.at))
@@ -2868,10 +2885,16 @@ export function apply(ctx) {
2868
2885
  await writeFormalIndex()
2869
2886
  }
2870
2887
  if (run.ok) await formalAnnounce('【形式化】' + (memberId || 'scheduler') + ' 运行 Lean 通过:' + run.file + '(' + ((run.ms || 0) / 1000).toFixed(1) + 's)' + (target ? '|对象 ' + target : ''))
2888
+ // 失败提示必须与失败原因一致:工具链缺失 / 宿主没有 subprocess 服务时**没有任何编译器输出**
2889
+ // 可以"按它修复",把它当成普通编译错误会让代理反复重试而不是走"写下代码 + 记录显式阻塞"
2890
+ // 这条出路(契约 §6 硬要求 4)。
2891
+ const noHost = run.code === 'LEAN_NOT_FOUND' || run.code === 'NO_SUBPROCESS' || run.code === 'LEAN_SPAWN_FAILED'
2871
2892
  return Object.assign({ ok: !!run.ok }, run, {
2872
2893
  hint: run.ok
2873
2894
  ? '通过。若是某个对象的证明,请用 vibe_math_lean_archive kind=\'proof\' 归档(会写入 Verified/Lean/ 并把审查对象变成忠实性);若是可复用定义/引理,用 kind=\'def\'/\'lemma\' 归档到全局库。'
2874
- : '未通过。请按上面的编译器输出修复后重跑;若判断无法完成,用 vibe_math_lean_archive kind=\'blocked\' 记录原因。',
2895
+ : (noHost
2896
+ ? '本宿主无法执行 Lean(' + run.code + '),没有编译器输出可以修:把形式化代码写下来并用 vibe_math_lean_archive 归档,并在回执的 note 里写明原因——这算显式阻塞原因,定论门禁可以据此放行。'
2897
+ : '未通过。请按上面的编译器输出修复后重跑;若判断无法完成,用 vibe_math_lean_archive kind=\'blocked\' 记录原因。'),
2875
2898
  })
2876
2899
  }
2877
2900
  async function leanArchive(memberId, o) {
@@ -2914,20 +2937,28 @@ export function apply(ctx) {
2914
2937
  if (body === undefined && from) { const r = await readFrom(); if (r.err) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: r.err }; body = r.body }
2915
2938
  if (body === undefined) return { ok: false, code: 'V3_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
2916
2939
  const workRel = 'Formal/' + target + '.lean'
2917
- if (!await writeText(workRel, body)) return { ok: false, code: 'V3_WRITE_FAILED', message: 'could not write ' + workRel }
2940
+ // 用绝对路径写:`writeText` 写失败时只会抛(由工具包装层变成通用 error),
2941
+ // 这一行的 V3_WRITE_FAILED 分支就永远不可能触发;writeTextAbs 会如实返回 false。
2942
+ if (!await writeTextAbs(frameworkRoot() + '/' + workRel, body)) return { ok: false, code: 'V3_WRITE_FAILED', message: 'could not write ' + workRel }
2918
2943
  const run = await leanRunFile(workRel)
2919
2944
  const prev = formalOf(target)
2920
2945
  const passed = !!run.ok
2946
+ // A RED re-archive invalidates the previous proof: the work file it proved was just
2947
+ // overwritten by code that does not compile. Keeping the pointer (or the archived file)
2948
+ // would produce "attempted + 归档证明 X.lean" in the index and let the fidelity prompt print
2949
+ // a proof path for code that no longer exists — `proof` is for `passed` only (contract §4).
2950
+ const stalePrev = passed ? '' : String(prev.proof || ('Verified/Lean/' + target + '.lean'))
2921
2951
  const rec = Object.assign({}, prev, {
2922
2952
  status: passed ? 'passed' : 'attempted',
2923
2953
  file: workRel,
2924
- proof: passed ? 'Verified/Lean/' + target + '.lean' : (prev.proof || ''),
2954
+ proof: passed ? 'Verified/Lean/' + target + '.lean' : '',
2925
2955
  decision: 'used',
2926
2956
  note: String(args.note || prev.note || ''),
2927
2957
  run: { at: now(), ok: !!run.ok, exitCode: run.exitCode === undefined ? null : run.exitCode, ms: run.ms || 0, stdoutTail: formalTail(run.stdout, 800), stderrTail: formalTail(run.stderr, 800) },
2928
2958
  updatedAt: now(),
2929
2959
  })
2930
2960
  if (passed) await writeText('Verified/Lean/' + target + '.lean', body)
2961
+ else if (stalePrev) { try { await withdrawArchivedProof(stalePrev) } catch (e) { /* 撤回失败已在公告里如实说明 */ } }
2931
2962
  await putFormal(target, rec)
2932
2963
  await upsertFormalAnchor(target)
2933
2964
  await rebuildLeanLibIndexes()
@@ -2951,15 +2982,38 @@ export function apply(ctx) {
2951
2982
  }
2952
2983
  return { ok: false, code: 'V3_INVALID_ARGUMENT', message: "kind must be 'def' | 'lemma' | 'proof' | 'blocked'" }
2953
2984
  }
2985
+ /**
2986
+ * 撤回归档证明:**删除**既不是唯一手段,也不是想当然就能成功的手段。
2987
+ *
2988
+ * fs 服务没有 unlink,`subprocess` 服务是**可选**的(`runShell` 在没有它时只返回 no-subprocess),
2989
+ * 删除命令本身也可能静默失败(桩宿主、权限、宿主不提供 shell)。而归档证明就躺在
2990
+ * `Verified/Lean/<id>.lean`——所有人都去那个路径找"这条结论的证明"——所以"删掉了"必须被
2991
+ * **回读验证**:删不掉就用撤回声明**就地覆盖**,使它不可能再被读成一份通过的证明。
2992
+ * 返回:'deleted'(确认已不在)| 'overwritten'(已覆盖为撤回声明)| 'failed'(两者都没成功)。
2993
+ */
2994
+ async function withdrawArchivedProof(rel) {
2995
+ const abs = leanAbsPath(rel)
2996
+ if (abs === null) return 'failed'
2997
+ const sub = subprocessOf()
2998
+ if (sub !== undefined && typeof sub.spawn === 'function') {
2999
+ try { await removeFile(rel) } catch (e) { /* 落到覆盖兜底 */ }
3000
+ // 退出码 0 不等于文件真的没了(桩宿主 / 权限怪癖 / 删除被静默忽略):必须回读确认。
3001
+ if (await readTextAbs(abs) === undefined) return 'deleted'
3002
+ }
3003
+ const notice = '-- 已撤回(' + fmtTime() + '):该形式化被认定与命题原文不一致。\n'
3004
+ + '-- 原代码保留在工作文件 Formal/' + String(rel).split('/').pop() + ';修正并重新跑通后重新归档。\n'
3005
+ return (await writeTextAbs(abs, notice)) !== false ? 'overwritten' : 'failed'
3006
+ }
2954
3007
  /**
2955
3008
  * §4.1 的落地点:表决者认定这条**已通过的** Lean 形式化不忠实于命题原文。
2956
3009
  *
2957
3010
  * 偏差不是"命题为假",而恰好是"这次形式化不合格",因此这里的动作与 blocked 不同:
2958
3011
  * ① 无论此前是 `passed` 还是 `blocked`,一律**降级**为 `attempted`(都让位于"需重做");
2959
- * ② 清空 `proof` 并**删除** `Verified/Lean/<id>.lean`(工作文件 `Formal/<id>.lean` 保留,代码不丢);
3012
+ * ② 清空 `proof` 并**撤下** `Verified/Lean/<id>.lean`(工作文件 `Formal/<id>.lean` 保留,代码不丢);
2960
3013
  * ③ 把具体偏差写进记录与 `Formal/TODO.md`,并公告。
2961
3014
  * 之后 `formalGateOk` 为假:require 档本次裁定**不定论**,对象进入「形式化待办」——修正形式化
2962
- * 并重新跑通后再投票。绝不把这条路径写成 `0`(那会让框架记下"命题为假")。
3015
+ * 并重新跑通后再投票。encourage 档没有门禁,公告里**不得**声称框架搁置了裁定(§4.1 第 3 条)。
3016
+ * 绝不把这条路径写成 `0`(那会让框架记下"命题为假")。
2963
3017
  */
2964
3018
  async function formalRecordDefect(target, note, who) {
2965
3019
  const id = formalId(target)
@@ -2974,7 +3028,7 @@ export function apply(ctx) {
2974
3028
  updatedAt: now(),
2975
3029
  }))
2976
3030
  // 归档证明必须消失,否则 Verified/Lean/ 里会留下一份"看起来已通过"的不忠实代码。
2977
- await removeFile(archived)
3031
+ const withdrawn = await withdrawArchivedProof(archived)
2978
3032
  // 待办条目按 id 去重、就地刷新:note 进入 TODO.md 的 why 列(require 档据此搁置定论)。
2979
3033
  const list = formalTodo()
2980
3034
  const i = list.findIndex(function (x) { return x && x.id === id })
@@ -2985,10 +3039,17 @@ export function apply(ctx) {
2985
3039
  await upsertFormalAnchor(id)
2986
3040
  await rebuildLeanLibIndexes()
2987
3041
  await saveAll()
3042
+ // 如实说出**哪一种**撤回发生了:删除成功 / 就地覆盖 / 两者都失败(后者必须让人手动处理,
3043
+ // 否则一份不忠实的代码会静静留在"证明"的路径上而无人知晓)。
2988
3044
  await formalAnnounce('【形式化】' + who + ' 认定 ' + id + ' 的 Lean 形式化存在**忠实性缺陷**:' + note
2989
3045
  + '。这不是"命题为假",而是**形式化不合格**:已撤回其「已通过」状态(降级为 attempted)、'
2990
- + '删除归档证明 ' + archived + '、写入 Formal/TODO.md;本次裁定**不定论**,修正形式化并重新'
2991
- + '跑通(vibe_math_lean_archive kind=\'proof\')后再投票。')
3046
+ + (withdrawn === 'deleted' ? '删除归档证明 ' + archived
3047
+ : withdrawn === 'overwritten' ? '归档证明 ' + archived + ' 无法删除(宿主不支持删除),已**就地覆盖为撤回声明**'
3048
+ : '归档证明 ' + archived + ' **未能撤回**(宿主删除与覆盖均失败,请手动删除,不要把它当作该对象的证明)')
3049
+ + '、写入 Formal/TODO.md;'
3050
+ + (formalMode() === 'require'
3051
+ ? '本次裁定**不定论**,修正形式化并重新跑通(vibe_math_lean_archive kind=\'proof\')后再投票。'
3052
+ : '**本档没有门禁**:框架不会替你搁置裁定,请给弃权值以避免得出真/假一致结论;修正形式化并重新跑通(vibe_math_lean_archive kind=\'proof\')后再投票。'))
2992
3053
  return { ok: true, kind: 'defect', target: id, status: 'attempted', proof: '', note: note, archived: archived }
2993
3054
  }
2994
3055
  /**
@@ -3024,10 +3085,15 @@ export function apply(ctx) {
3024
3085
  if (decision === 'used') {
3025
3086
  const prev = formalOf(target)
3026
3087
  const file = String(f.file || ('Formal/' + target + '.lean'))
3027
- await putFormal(target, Object.assign({}, prev, { status: prev.status === 'passed' ? 'passed' : 'attempted', decision: 'used', file: file, updatedAt: now() }))
3088
+ // 契约 §4:`used` 只说"这一轮碰了形式化",**不得**撤销已成立的证明,也不得把一条显式阻塞
3089
+ // (blocked,本身就是放行门禁的记录)打回 attempted 而重新关门。只有 `defect` 能撤销证明。
3090
+ // 只保留 `passed` 是不够的:`blocked` 同样是"门禁已放行"的状态(v2/v4/v5 两者都保留)。
3091
+ const keepStatus = (prev.status === 'passed' || prev.status === 'blocked') ? prev.status : 'attempted'
3092
+ await putFormal(target, Object.assign({}, prev, { status: keepStatus, decision: 'used', file: file, updatedAt: now() }))
3028
3093
  await upsertFormalAnchor(target)
3029
3094
  await rebuildLeanLibIndexes()
3030
- await formalAnnounce('【形式化】' + who + ' 通过回执记录 ' + target + ' 形式化草稿:' + file)
3095
+ await formalAnnounce('【形式化】' + who + ' 通过回执记录 ' + target + ' 形式化草稿:' + file
3096
+ + (keepStatus === 'attempted' ? '' : '(保留已有的 ' + keepStatus + ' 状态:一次 used 回执不撤销已成立的证明/已记录的阻塞)'))
3031
3097
  return
3032
3098
  }
3033
3099
  await formalAnnounce('【形式化】' + who + ' 的 formal.decision 只能是 \'used\'、\'blocked\' 或 \'defect\'(收到 ' + String(f.decision) + '),已忽略。')
@@ -3391,7 +3457,7 @@ export function apply(ctx) {
3391
3457
  registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), async function () { await refreshParams(); return await getStatus() })
3392
3458
  registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json + Logs/报告.md.', objParams({}), async function () { await refreshParams(); await maybeWriteReport(true); return await buildReport() })
3393
3459
  registerTool('vibe_math_set_mode', 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.', objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }, ['mode']), async function (args) { params.mode = args.mode; await saveAll(); await saveSettings(); if (params.mode === 'auto') await autoResolvePending(); return { ok: true, mode: params.mode } })
3394
- registerTool('vibe_math_set_params', 'Update scheduler parameters (partial).', objParams({ maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' } }), async function (args) { params = Object.assign({}, params, sanitizeParams(args)); await saveAll(); await saveSettings(); return { ok: true, params: params } })
3460
+ registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象未达到 Lean 已通过或已记录显式阻塞原因之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式。', objParams({ maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' } }), async function (args) { params = Object.assign({}, params, sanitizeParams(args)); await saveAll(); await saveSettings(); return { ok: true, params: params } })
3395
3461
  registerTool('vibe_math_setup', 'Return the interactive parameter schema for guided configuration.', objParams({}), async function () { await refreshParams(); const list = PARAM_SCHEMA.map(function (p) { const out = Object.assign({}, p); out.current = params[p.name]; out.default = DEFAULT_PARAMS[p.name]; return out }); return { ok: true, parameters: list, saveTo: frameworkRoot() + '/vibe_math_setting.json' } })
3396
3462
  registerTool('vibe_math_save_settings', 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.', objParams({}), async function () { return await saveSettings() })
3397
3463
  registerTool('vibe_math_template', 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.', objParams({ where: { type: 'string', enum: ['global', 'project'] } }), async function (args) { return await createTemplate((args && args.where) || 'global') })
@@ -3631,8 +3697,12 @@ export function apply(ctx) {
3631
3697
  // ================= session surface =================
3632
3698
  return {
3633
3699
  sessionId: sessionId,
3634
- scheduler: scheduler,
3635
- tickInFlight: tickInFlight,
3700
+ // 这两个必须是**取值器**而不是快照:`scheduler` 会在 loadState/setProject 里被整体重新赋值,
3701
+ // 而 `tickInFlight` 每次 tick 都会翻转。快照会让 apply 级的定时器守卫(见文件末尾
3702
+ // `!s.tickInFlight && s.scheduler.gate === null`)永远读到最初的值——那个守卫就再也拦不住
3703
+ // 任何东西(tick() 内部还有一道实时守卫,所以此前没有可观测后果,但那是巧合而非设计)。
3704
+ get scheduler() { return scheduler },
3705
+ get tickInFlight() { return tickInFlight },
3636
3706
  scheduleTick: scheduleTick,
3637
3707
  onChildEnd: onChildEnd,
3638
3708
  dispatchVibeCommand: dispatchVibeCommand,
@@ -3666,7 +3736,7 @@ export function apply(ctx) {
3666
3736
  registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), 'vibe_math_status')
3667
3737
  registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json + Logs/报告.md.', objParams({}), 'vibe_math_report')
3668
3738
  registerTool('vibe_math_set_mode', 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.', objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }, ['mode']), 'vibe_math_set_mode')
3669
- registerTool('vibe_math_set_params', 'Update scheduler parameters (partial).', objParams({ maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' } }), 'vibe_math_set_params')
3739
+ registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象未达到 Lean 已通过或已记录显式阻塞原因之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式。', objParams({ maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, plannerPersona: { type: 'string' }, methodKeeperPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { type: 'boolean' }, verifierAllowNetwork: { type: 'boolean' }, solverAllowScripts: { type: 'boolean' }, verifierAllowScripts: { type: 'boolean' }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, planningHorizon: { type: 'integer' }, plannerEnabled: { type: 'boolean' }, plannerProvider: { type: 'string' }, plannerModel: { type: 'string' }, planMinIntervalMs: { type: 'integer' }, plannerMaxFails: { type: 'integer' }, methodKeepIntervalMs: { type: 'integer' }, methodKeepEvery: { type: 'integer' }, methodAutoPromote: { type: 'boolean' }, indexAutoRebuild: { type: 'boolean' }, projectLockTimeoutMs: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' } }), 'vibe_math_set_params')
3670
3740
  registerTool('vibe_math_setup', 'Return the interactive parameter schema for guided configuration.', objParams({}), 'vibe_math_setup')
3671
3741
  registerTool('vibe_math_save_settings', 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.', objParams({}), 'vibe_math_save_settings')
3672
3742
  registerTool('vibe_math_template', 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.', objParams({ where: { type: 'string', enum: ['global', 'project'] } }), 'vibe_math_template')
@@ -498,7 +498,7 @@ VibeMath/ # 工作区级
498
498
  | `vibe_math_decide` | 扩展支持 `node: 'plan'`(计划审批)与 `node: 'method-promote'` |
499
499
  | `vibe_math_lean_run` | `{file, target?, timeout_ms?}`:在一个 `.lean` 文件上执行 Lean 工具链并**如实**回报 `{ok, exitCode, signal, ms, command, stdout, stderr, file}`;给了 `target` 就把这次运行记到该对象上。**绝不抛异常到调度循环**:宿主没有 subprocess 服务 → `NO_SUBPROCESS`,工具链解析失败 → `LEAN_NOT_FOUND`,超时 → `LEAN_TIMEOUT`(并调用 `handle.terminate()`) |
500
500
  | `vibe_math_lean_archive` | `{kind, ...}` 一个工具覆盖四种归档:`kind='def'`/`'lemma'` → 全局 `<VibeMath 根>/Formal/{Lib,Proved}/<name>.lean` 并重建对应索引(`content` 或 `from` 二选一;`run:false` 跳过执行);`kind='proof'` → 写 `Formal/<target>.lean`,该文件最近一次运行 ok 时**同时**写 `Verified/Lean/<target>.lean` 并把对象标为 `passed`;`kind='blocked'` → 记录显式难度判断/阻塞原因(`note` 必填,空则拒绝) |
501
- | `vibe_math_lean_lib` | `{refresh?}`:扫描并重建三处索引(项目 `Formal/Index.md`、全局 `Lib/Index.md`、`Proved/Index.md`),返回可复用库清单(供写新定义前查重、直接复用);`refresh:false` 只读不重建 |
501
+ | `vibe_math_lean_lib` | `{refresh?}`:扫描并重建三处索引(项目 `Formal/Index.md`、全局 `Lib/Index.md`、`Proved/Index.md`),返回可复用库清单(供写新定义前查重、直接复用);`refresh:false` 只读不重建。另有 `objects`(每个对象的形式化状态)与 `verify`(当前在飞验证任务的标识,无则 `null`)两个字段,与 v5 的同名输出一致 |
502
502
 
503
503
  ### 11.1 Lean 形式化验证(v3 实现要点)
504
504
 
@@ -530,12 +530,12 @@ VibeMath/ # 工作区级
530
530
 
531
531
  **卡片锚点**:对象 md 卡(`Problems/`、`Propos/`、`Verified/`)在非 off 模式且该对象**有形式化记录**时,头部锚点块里多一行 `- 形式化: <状态>`(`Lean 通过(…)` / `阻塞(…)` / `已尝试未通过`),位置紧随 `- 概率:`(无概率锚点时紧随 `- 状态:`),不影响调度器只读头部锚点的既有解析。锚点用**文本级插入/替换**维护(不整卡重写),因此代理按"直接写 md"协议写进卡片的正文不会被抹掉;off 模式或状态为 `none` 时不写这一行。
532
532
 
533
- **回执通道**(契约 §6.3):非 `off` 模式下,验证回执的 JSON 契约里带 `"formal":{...}`(由 `handleVerifier` 处理),solver / explorer / 重派生提示词的**回执说明**里也给出同一字段(`handleSolver` / `handleExplorer` 通过 `absorbFormalFromReply` 接受顶层 `formal` 与 `meta.formal` 两种写法)。因此代理即使一次 Lean 工具都没调用,也能留下显式、可审计的难度判断;`decision='blocked'` 而 `note` 为空一律**拒绝记录**并公告,`decision='used'` 只记 `attempted`(不足以打开 require 门禁)。
533
+ **回执通道**(契约 §6.3):非 `off` 模式下,验证回执的 JSON 契约里带 `"formal":{...}`(由 `handleVerifier` 处理),solver / explorer / 重派生提示词的**回执说明**里也给出同一字段(`handleSolver` / `handleExplorer` 通过 `absorbFormalFromReply` 接受顶层 `formal` 与 `meta.formal` 两种写法)。因此代理即使一次 Lean 工具都没调用,也能留下显式、可审计的难度判断;`decision='blocked'` 而 `note` 为空一律**拒绝记录**并公告,`decision='used'` 只记 `attempted`(不足以打开 require 门禁)——但**若该对象已是 `passed`/`blocked` 则保持原状**:一次"这一轮碰了形式化"的 `used` 回执不得撤销已成立的证明(撤销只有 `defect` 一条路,契约 §4)。
534
534
 
535
535
  **门禁落点**(契约 §8 要求唯一收口点,不散落):
536
536
  - 判定入口:`settleVerdict`(辩论/评审裁定,在**改对象之前**拦下)与 `processStatusUpdates`(自报概率 1 的第二条"宣告定论"入口);
537
- - 写卡收口点:`writeVerifiedPropositionCardIfNeeded` / `writeVerifiedProblemCardIfNeeded` → `writeVerifiedCardIfChanged`(require 模式下没有 `passed`/`blocked` 就**不写** Verified 卡片,作为防御性双保险);
538
- - 不通过时统一走 `deferForFormal`:记未定论(`formal-required`)+ 写 `Formal/TODO.md` + 公告(活动日志 + `Logs/形式化.md`),**不改变对象的既有权重/概率字段**,绝不卡死调度——对象留在原库,完成形式化或记录阻塞后重新提议验证即可;达到 `passed`/`blocked` 时自动把该对象从待办中移除(待办的含义就是"还不满足门禁")。
537
+ - 写卡收口点:`writeVerifiedPropositionCardIfNeeded` / `writeVerifiedProblemCardIfNeeded` → `writeVerifiedCardIfChanged`。门禁**只在这唯一的收口点**判定:卡片**不存在**时 require 模式下没有 `passed`/`blocked` 就**不写**(并记待办);卡片**已存在**时只做刷新——包括把被 `defect` 撤回的形式化状态如实改成 `已尝试未通过`(否则一张定论卡片会永久宣称「形式化: Lean 通过(Verified/Lean/<id>.lean)」而那份证明已被撤回。门禁管的是"能不能宣告新结论",不是"能不能说实话")。
538
+ - 不通过时统一走 `deferForFormal`:记未定论(`formal-required`)+ 写 `Formal/TODO.md` + 公告(活动日志 + `Logs/形式化.md`),**不改变对象的既有权重/概率字段**,绝不卡死调度——对象留在原库,完成形式化或记录阻塞后重新提议验证即可;达到 `passed`/`blocked` 时自动把该对象从待办中移除(待办的含义就是"还不满足门禁")。`Formal/TODO.md` 与 `Formal/Index.md` 的待办段按**当前档位**措辞:`require` 说"定论被搁置",`encourage` 说明本档没有门禁、框架不会搁置裁定(靠表决者弃权),不承诺它无法强制的行为(契约 §4.1 第 3 条)。
539
539
  - **防空转**:已经被记为「形式化待办」而形式化又没补齐的对象,`createVerifyTask` 不再为它建验证任务(否则同一个 `rId` 每个 tick 都会被重建、每轮再派一批验证器,而结果每轮都被同一道门拦下——纯烧验证预算)。一旦 `passed`/`blocked` 落库,门禁条件满足,候选自然重新出现并继续验证,无需人工干预。回退启发式在跳过这类候选时**不占用本轮名额**(`created` 才 `return`),否则排在待办对象后面的候选会被永久饿死。
540
540
 
541
541
  **错误码**(一律作为结果返回,不抛进调度循环):
@@ -558,8 +558,9 @@ VibeMath/ # 工作区级
558
558
  **`defect` 是独立的一档,不是"命题为假"**(契约 §4.1)。`passed` 只保证"这段 Lean 代码通过了内核检查",**不保证它说的就是命题想说的**。表决者逐条核对后发现写窄了 / 写宽了 / 换了对象 / 漏了条件时,那是**形式化不合格**:若把它记成 0,框架记下的会是"该命题为假"——用来求真更严格的机制,反而伪造出一个错误的否定结论;若只记成 `blocked`,门禁又会**放行**(`blocked` 本就允许定论),等于带着一个坏形式化去定论。
559
559
 
560
560
  - **回执**:`formal:{target, decision:'defect', note:'<具体偏差>'}`。`note` **必填**,为空则整条记录被**拒绝**(返回 `V3_INVALID_ARGUMENT`),形式化记录与归档证明**保持不变**。顶层 `formal` 与 `meta.formal` 两种写法都接受:验证回执走 `handleVerifier`,solver / explorer / 重派生等工作轮回执走 `absorbFormalFromReply`。
561
- - **落库**(`formalRecordDefect`,契约 §4.1):无论此前是 `passed` 还是 `blocked`,一律**降级**为 `attempted`(都让位于"形式化不合格,需重做");清空 `proof`;**删除**归档证明 `Verified/Lean/<id>.lean`(工作文件 `Formal/<id>.lean` 保留,代码不丢);`note` 写入形式化记录与 `Formal/TODO.md`(按 id 去重、就地刷新);对象卡锚点刷新为 `已尝试未通过`;公告到活动日志与 `Logs/形式化.md`。
562
- - **`require` 档**:降级后 `formalGateOk` 为假,本次裁定**不定论**——不改对象的既有权重/概率字段、不写 `Verified/` 卡片,对象进入「形式化待办」;修正形式化并重新跑通(`vibe_math_lean_archive kind='proof'`)后再投票。
561
+ - **落库**(`formalRecordDefect`,契约 §4.1):无论此前是 `passed` 还是 `blocked`,一律**降级**为 `attempted`(都让位于"形式化不合格,需重做");清空 `proof`;**撤下**归档证明 `Verified/Lean/<id>.lean`(工作文件 `Formal/<id>.lean` 保留,代码不丢);`note` 写入形式化记录与 `Formal/TODO.md`(按 id 去重、就地刷新);对象卡锚点刷新为 `已尝试未通过`;公告到活动日志与 `Logs/形式化.md`。
562
+ - **撤回归档证明不是"尽力删除"**(`withdrawArchivedProof`):`fs` 服务没有 unlink,`subprocess` 又是可选服务,删除命令也可能静默失败(桩宿主 / 权限 / 宿主不提供 shell),而 `Verified/Lean/<id>.lean` 正是所有人找"这条结论的证明"的路径。因此删除后**必须回读确认**;确认不了就用撤回声明**就地覆盖**该文件(`-- 已撤回(<时间>):该形式化被认定与命题原文不一致。` + 指向保留的工作文件),使它不可能再被读成一份通过的证明;两种兜底都失败时公告里明确写出"**未能撤回**,请手动删除"。公告如实说明发生的是**哪一种**(删除 / 就地覆盖 / 失败)。
563
+ - **`require` 档**:降级后 `formalGateOk` 为假,本次裁定**不定论**——不改对象的既有权重/概率字段、不写 `Verified/` 卡片,对象进入「形式化待办」;修正形式化并重新跑通(`vibe_math_lean_archive kind='proof'`)后再投票。**`encourage` 档没有门禁**:框架仍然撤回证明并写入待办,但注入文本与公告都**不得**声称框架搁置了裁定(那是它无法强制的行为,契约 §4.1 第 3 条)——只有表决者自己的弃权值能阻止一致性结论。
563
564
  - **唯一可以投 0 的情形**:表决者**独立于这份 Lean 代码**也能确定命题为假,且 `Reason` 写清独立理由。
564
565
 
565
566
  **提示词硬要求**(契约 §6 顶部的五条,v3 全部落地,并由套件对**每条捕获的提示词**做正则扫描,而不是只看一两处):
@@ -567,7 +568,7 @@ VibeMath/ # 工作区级
567
568
  1. **工具名一律全称**:注入文本(含工具自己返回的 `hint` 字段)里只允许 `vibe_math_lean_run` / `vibe_math_lean_archive` / `vibe_math_lean_lib`。`lean_run` / `lean_archive` / `lean_lib` 这类缩写**不是注册名**,代理照抄会去调用一个不存在的工具;套件用 `/(^|[^a-z_])lean_(run|archive|lib)/` 扫描全部语料,命中即红。
568
569
  2. **回执字段名必须与真实契约一致**:v3 的表决值字段是 **`Result`**(v4/v5 才是 `verdict`)。写错字段名 = 那一票被静默丢弃(退回默认 0.5)。忠实性分支因此写的是 `一致 → Result = 1`;套件断言每条表决提示词含 `Result` 且不含 `verdict`。
569
570
  3. **归档可复用定义/引理前必须先跑通**:`vibe_math_lean_archive` 支持 `run:true`,或先 `vibe_math_lean_run`;**跑不通的代码不得进入 `Formal/Lib` / `Formal/Proved`**,否则"可复用库"会被不编译的定义污染。验证提示词与「顺手形式化」一行都写明了这条纪律(`归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。`)。
570
- 4. **工具链缺失时的出路必须写出来**:`LEAN_NOT_FOUND` 时把代码写下来归档,并在回执的 `note` 里写明"宿主无 Lean 工具链"——这算**显式阻塞原因**,`require` 档可以据此放行,代理不会因为装不了 Lean 而卡死。
571
+ 4. **工具链缺失时的出路必须写出来**:`LEAN_NOT_FOUND` 时把代码写下来归档,并在回执的 `note` 里写明"宿主无 Lean 工具链"——这算**显式阻塞原因**,`require` 档可以据此放行,代理不会因为装不了 Lean 而卡死。v3 的注入文本**同时点名 `NO_SUBPROCESS`**(宿主不提供 subprocess 服务、因此根本无法执行 Lean 的另一种失败形态),否则代理不认识这个码,会把它当成未知故障反复重试而不是记录阻塞。`vibe_math_lean_run` 返回的失败 `hint` 也按错误码区分:工具链缺失 / 无 subprocess / spawn 失败时给出"没有编译器输出可修,写下代码并归档 + 记录阻塞原因",只有真正的编译失败才说"按上面的编译器输出修复后重跑"。
571
572
  5. **忠实性缺陷不得用 0 表达**:只有独立于 Lean 代码也能确定命题为假时才投 0(§4.1 第 4 条)。套件断言注入文本**不含** `偏离 → 0` 这类"偏差即假"的指令,且含"不要投 0 / 记为形式化不合格 / 走待办"的要求。
572
573
 
573
574
  ---
@@ -65,7 +65,7 @@ export function apply(ctx) {
65
65
  // is persisted through v4's OWN durable State/*.json mechanism (State/formal.json) and must
66
66
  // survive `resume` — otherwise a require-mode object would lose the very record that decides
67
67
  // whether its verdict may take effect.
68
- let formal = {}, formalTodos = []
68
+ let formal = {}, formalTodos = [], formalPersisted = false
69
69
  let meetingState = null, verifyState = null, pendingVerify = [], pendingMeeting = null // pendingVerify: FIFO queue (several residents may independently propose different objects before any verify runs — a single slot silently DROPPED all but the last proposal)
70
70
  let busy = new Set(), wakeKind = new Map(), currentResident = ''
71
71
  let finalizeLock = null // 'meeting'|'verify' while a consensus finalize is running (reentry guard)
@@ -294,7 +294,13 @@ export function apply(ctx) {
294
294
  await saveFormal()
295
295
  return true
296
296
  }
297
- function saveFormal(){ return writeJson('State/formal.json',{records:formal,todo:formalTodos}) }
297
+ // Once formal state has been persisted (or restored from disk) the file must KEEP being
298
+ // written even after the mode is switched back to `off` — otherwise `start()`'s clean slate
299
+ // would never reach disk and a later `resume` would restore a stale `passed` record for a
300
+ // reused object id (the exact hazard that reset exists to prevent). A session that never
301
+ // touched the feature writes NO State/formal.json at all: `off` is a TRUE no-op (v3 guards
302
+ // its own State/formal.json the same way).
303
+ function saveFormal(){ formalPersisted=true; return writeJson('State/formal.json',{records:formal,todo:formalTodos}) }
298
304
  // `passed` requires a GREEN RUN, not merely an archived file: a proof file that has never
299
305
  // been executed proves nothing, so a hand-written file cannot buy its way past the gate.
300
306
  const formalGateOk=(rec)=>!!rec&&(rec.status==='passed'||rec.status==='blocked')
@@ -374,16 +380,44 @@ export function apply(ctx) {
374
380
  try {
375
381
  handle=sub.spawn({ argv, cwd:frameworkRoot(), stdio:{stdin:'ignore',stdout:{maxBytes:64*1024},stderr:{maxBytes:64*1024}}, graceMs:cap })
376
382
  } catch(e){ return {ok:false,code:'LEAN_SPAWN_FAILED',message:String((e&&e.message)||e),file:rel,ms:now()-started} }
383
+ // A timeout must be ACTIVELY enforced. `graceMs` is only the host's own grace window; the
384
+ // contract (§7) additionally requires `handle.terminate()` on timeout, and a host that
385
+ // ignores/exceeds graceMs would otherwise leave the Lean process running while the framework
386
+ // reports LEAN_TIMEOUT. Race `done` against a cap-ms timer that terminates the handle and
387
+ // resolves a synthetic outcome. Note: the plugin runtime has no global setTimeout — the
388
+ // `timer` service (ctx.timeout) is the only timer, and it returns the disposer we clear
389
+ // below when `done` wins the race.
390
+ let killedByUs=false, timerDispose=null
377
391
  let outcome
378
- try { outcome=await handle.done }
379
- catch(e){ return {ok:false,code:'LEAN_RUN_FAILED',message:String((e&&e.message)||e),file:rel,ms:now()-started} }
392
+ try {
393
+ outcome=await Promise.race([
394
+ handle.done,
395
+ new Promise(function(resolve){
396
+ if(typeof ctx.timeout!=='function') return // no timer service: the host's graceMs is all we have
397
+ timerDispose=ctx.timeout(function(){
398
+ killedByUs=true
399
+ try { if(typeof handle.terminate==='function') handle.terminate() } catch(e){ /* best effort */ }
400
+ resolve({exitCode:null,signal:'SIGTERM'})
401
+ }, cap)
402
+ }),
403
+ ])
404
+ } catch(e){
405
+ if(timerDispose){ try { timerDispose() } catch(_e){} }
406
+ return {ok:false,code:'LEAN_RUN_FAILED',message:String((e&&e.message)||e),file:rel,ms:now()-started}
407
+ }
408
+ if(timerDispose){ try { timerDispose() } catch(e){ /* already fired */ } }
380
409
  let out='',err=''
381
410
  try { if(handle.collected&&handle.collected.stdout) out=handle.collected.stdout.readFrom(0).text } catch(e){ /* best effort */ }
382
411
  try { if(handle.collected&&handle.collected.stderr) err=handle.collected.stderr.readFrom(0).text } catch(e){ /* best effort */ }
383
412
  const exitCode=outcome?outcome.exitCode:null
384
413
  const ms=now()-started
385
414
  const ok=exitCode===0
386
- const timedOut=!ok&&ms>=cap
415
+ // Two ways a timeout is observed: OUR timer won (we terminated the handle), or the HOST's own
416
+ // grace window killed the process first and its `done` beat our timer to the race — the latter
417
+ // is recognised by "non-zero exit after at least cap ms", which is what the spec's
418
+ // LEAN_TIMEOUT row describes. Keeping both means a real hang is never reported as a plain
419
+ // compile failure just because the host's kill resolved first.
420
+ const timedOut=killedByUs||(!ok&&ms>=cap)
387
421
  return {
388
422
  ok, exitCode, signal:(outcome&&outcome.signal)||null, ms,
389
423
  command:argv.join(' '), file:rel,
@@ -393,11 +427,17 @@ export function apply(ctx) {
393
427
  }
394
428
  // Record one run against an object. `passed`/`blocked` are NEVER downgraded by a later red
395
429
  // run (only an explicit re-archive decides those); everything else becomes `attempted`.
430
+ // The comment used to describe a transition the body did not implement: `status:'attempted'`
431
+ // was hardcoded, so ANY `lean_run {target}` on a passed object silently stripped `passed`
432
+ // (and kept the now-stale `proof` pointer). That is not a cosmetic slip: it removes the
433
+ // fidelity branch from the next voting prompt AND, in `require` mode, re-closes the gate on an
434
+ // object that already has a green archived proof. docs/formal-verification.md §4 only maps
435
+ // `none`/`attempted` → `attempted`, and v2/v5 preserve passed/blocked here.
396
436
  async function formalSetRun(target,run){
397
437
  const key=formalKey(target); if(!key) return
398
438
  const prev=formalOf(key)
399
439
  await putFormal(key,Object.assign({},prev,{
400
- status:'attempted',
440
+ status:prev.status==='passed'?'passed':(prev.status==='blocked'?'blocked':'attempted'),
401
441
  file:run.file||prev.file||'',
402
442
  decision:prev.decision||'used',
403
443
  run:{at:now(),ok:!!run.ok,exitCode:run.exitCode===undefined?null:run.exitCode,ms:run.ms||0,stdoutTail:tail(run.stdout,800),stderrTail:tail(run.stderr,800)},
@@ -424,7 +464,14 @@ export function apply(ctx) {
424
464
  L.push(' ▸ **发现任何偏差,不要投 0**:偏差只说明**形式化不合格**,不代表命题为假。此时请:')
425
465
  L.push(" ① verdict 给一个严格介于 0 与 1 之间的值(记为弃权),并在 reason 里写清偏差;")
426
466
  L.push(" ② 用回执 formal:{decision:'defect', note:'<具体偏差>'} 记录它。框架会撤回这条证明的")
427
- L.push(' 「已通过」状态(降级为 attempted、删除归档证明、写入形式化待办),本次裁定**不定论**;')
467
+ // Only `require` actually GATES the conclusion. In `encourage` the framework still
468
+ // withdraws the proof (and records the defect + TODO), but it CANNOT hold the ballot —
469
+ // promising "本次裁定不定论" there would promise behaviour the framework does not have
470
+ // (docs §4.1-3/§6.1); the voter's own abstention is what keeps the ballot from concluding.
471
+ L.push(' 「已通过」状态(降级为 attempted、删除归档证明、写入形式化待办)'
472
+ +(formalMode()==='require'
473
+ ?',本次裁定**不定论**;'
474
+ :';本档没有门禁:请务必给弃权值,以保证本轮无法得出一致结论;'))
428
475
  L.push(' 修正形式化并重新跑通后再投票。')
429
476
  L.push(' ▸ 只有当你**独立于这份 Lean 代码**也能确定命题为假时,才投 0,并在 reason 里写清独立理由。')
430
477
  } else if(rec.status==='blocked'){
@@ -442,7 +489,7 @@ export function apply(ctx) {
442
489
  L.push(" · 若你判断不值得或无法形式化,可以不做,但请在回执的 formal 字段写明难度判断(decision='blocked' 时必须写明 note)。")
443
490
  }
444
491
  L.push(' · 归档可复用定义/引理前先跑通(vibe_v4_lean_archive run=true 或先 vibe_v4_lean_run);跑不通不要入库。')
445
- L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"——这算显式阻塞原因,定论门禁可以据此放行。')
492
+ L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"——这两种都算显式阻塞原因,定论门禁可以据此放行。')
446
493
  if(rec.status==='attempted'){
447
494
  L.push(' ▸ 该对象已有形式化尝试但尚未通过(最近一次 '+(rec.run?(rec.run.ok?'通过':'未通过'):'无运行记录')+')。')
448
495
  L.push(" 请修复后重跑(vibe_v4_lean_run),跑通后用 kind='proof' 归档。")
@@ -553,7 +600,10 @@ export function apply(ctx) {
553
600
  await writeFormalIndex()
554
601
  }
555
602
  } catch(e){ /* the index is best-effort; a run result must always come back */ }
556
- logActivity('formal',(memberId||'host')+' lean_run '+(run.file||String(args.file||''))+' → '+(run.ok?'通过':(run.code||'未通过')))
603
+ // The activity log is part of the injected-text surface (a host reads it out of
604
+ // vibe_v4_report, and residents may be quoted it): use the REGISTERED tool name, never the
605
+ // bare `lean_run` abbreviation (docs §6 hard requirement 1).
606
+ logActivity('formal',(memberId||'host')+' vibe_v4_lean_run '+(run.file||String(args.file||''))+' → '+(run.ok?'通过':(run.code||'未通过')))
557
607
  return Object.assign({ok:!!run.ok},run,{
558
608
  mode:formalMode(),
559
609
  hint: run.ok
@@ -641,24 +691,38 @@ export function apply(ctx) {
641
691
  return {ok:false,code:'V4_INVALID_ARGUMENT',message:"kind must be 'def' | 'lemma' | 'proof' | 'blocked'"}
642
692
  }
643
693
  /**
644
- * Withdraw an archived proof (spec §4.1). Best-effort by contract: the RECORD is the
645
- * authoritative state, so a host without a subprocess service (or without Lean) still gets a
646
- * correct `attempted` record — the stale file is then reported in the activity log instead of
647
- * silently kept. Every path goes through `leanAbsPath` so this can only ever remove a `.lean`
648
- * file inside the VibeMath root (a hand-edited State/formal.json must not become an
649
- * arbitrary-file delete).
694
+ * Withdraw an archived proof (spec §4.1). The RECORD is authoritative, but the FILE must not
695
+ * survive at the exact path everyone looks for "this object's proof": a host whose shell
696
+ * cannot delete (no `subprocess`, a stub shell that exits 0 without removing anything, a
697
+ * permission quirk) would otherwise leave the retracted proof readable as the object's proof
698
+ * while the record already says `attempted`. So the withdrawal is: delete → CONFIRM through
699
+ * the fs service that it is really gone → if it still exists, OVERWRITE it with an explicit
700
+ * withdrawal notice. Returns WHICH path it took ({ok,how:'deleted'|'overwritten'|'failed'})
701
+ * so the caller/activity log can say so out loud instead of reporting a best-effort delete as
702
+ * done. Every path goes through `leanAbsPath`, so this can only ever touch a `.lean` file
703
+ * inside the VibeMath root (a hand-edited State/formal.json must not become an arbitrary-file
704
+ * delete).
650
705
  */
651
706
  async function withdrawProof(rel){
652
707
  const raw=String(rel==null?'':rel)
653
- if(!raw) return {ok:false,skipped:true}
708
+ if(!raw) return {ok:false,how:'failed',skipped:true}
654
709
  const abs=leanAbsPath(raw)
655
710
  if(abs===null||!/\.lean$/.test(abs)){
656
711
  logActivity('formal','拒绝删除越界的归档证明路径:'+raw)
657
- return {ok:false,skipped:true}
712
+ return {ok:false,how:'failed',skipped:true}
713
+ }
714
+ const sub=subprocessOf()
715
+ if(sub!==undefined&&typeof sub.spawn==='function'){
716
+ const r=await runShell(rmCmd([abs]),vibeRoot())
717
+ // Exit 0 is NOT proof of deletion: confirm through the fs service before believing it.
718
+ if(r&&r.ok&&await readTextAbs(abs)===undefined) return {ok:true,how:'deleted',abs}
658
719
  }
659
- const r=await runShell(rmCmd([abs]),vibeRoot())
660
- if(!r||!r.ok) return {ok:false,error:(r&&r.error)||('exit '+String(r&&r.exitCode))}
661
- return {ok:true,abs}
720
+ // Fallback: make the file impossible to read as this object's proof any more. The withdrawn
721
+ // code itself is NOT lost — it stays in the working file Formal/<id>.lean.
722
+ const notice='-- 已撤回('+fmtTime()+'):该形式化被认定与命题原文不一致。\n'
723
+ +'-- 原代码保留在工作文件 Formal/'+String(raw).split('/').pop()+';修正并重新跑通后重新归档。\n'
724
+ if(await writeTextAbs(abs,notice)) return {ok:true,how:'overwritten',abs}
725
+ return {ok:false,how:'failed',abs}
662
726
  }
663
727
  /**
664
728
  * §4.1 `defect`: a voter checked the Lean code against the proposition and found a FIDELITY
@@ -687,11 +751,18 @@ export function apply(ctx) {
687
751
  // leaves an orphaned file with a truthful record, never a record still claiming `passed`.
688
752
  await putFormal(key,rec,todo)
689
753
  const del=await withdrawProof(proofRel)
754
+ const how=del&&del.how==='deleted'?'删除归档证明 '+proofRel
755
+ :del&&del.how==='overwritten'?'覆盖归档证明 '+proofRel+'(宿主无法删除,已写入撤回说明,原证明内容不再可读)'
756
+ :'⚠ 归档证明 '+proofRel+' 未能撤回(删除与覆盖均失败)——它仍停留在"该对象的证明"的位置,请不要把它当作该对象的证明'
690
757
  try { await writeFormalTodo(); await writeFormalIndex() } catch(e){ /* best-effort */ }
758
+ // The activity log is agent/host-readable, so the same §4.1-3 rule applies here as in the
759
+ // prompt: only `require` actually GATES the verdict, so only there may this say 不定论.
691
760
  logActivity('formal',(rId||'host')+' 报告 '+key+' 存在**忠实性缺陷**(formal.decision=defect):'+note
692
- +' ——已撤回「已通过」状态(→ attempted)、'+(del&&del.ok?'删除归档证明 '+proofRel:'删除归档证明失败('+String((del&&del.error)||'no-subprocess')+',记录已降级)')
693
- +'、写入 Formal/TODO.md;本次裁定**不定论**,修正形式化并重新跑通后再投票')
694
- return {ok:true,target:key,status:'attempted',proof:'',decision:'defect',removedProof:!!(del&&del.ok)}
761
+ +' ——已撤回「已通过」状态(→ attempted)、'+how
762
+ +'、写入 Formal/TODO.md;'+(formalMode()==='require'
763
+ ?'require 门禁使本次裁定**不定论**,修正形式化并重新跑通后再投票'
764
+ :'本档没有门禁:本轮能否定论取决于表决者是否给出弃权值,修正形式化并重新跑通后再投票'))
765
+ return {ok:true,target:key,status:'attempted',proof:'',decision:'defect',removedProof:!!(del&&del.ok),withdrawal:del?del.how:'failed'}
695
766
  }
696
767
  /**
697
768
  * The per-round `formal` reply channel. This is the path that fires IN PRACTICE: a resident
@@ -702,6 +773,11 @@ export function apply(ctx) {
702
773
  */
703
774
  async function applyFormalReply(rId,formalReply){
704
775
  try {
776
+ // `off` is a TRUE no-op: the reply contract only offers the `formal` field in non-off modes,
777
+ // so a stray / stale / hallucinated one must NOT create Lean state (v2/v3 guard here too;
778
+ // without this, off mode could still be made to write Formal/ records, TODO entries and
779
+ // announcements). The TOOLS stay usable in off mode on purpose — a tool call is deliberate.
780
+ if(!formalOn()) return {ok:false,ignored:true}
705
781
  const key=formalKey(String(formalReply.target||''))
706
782
  if(!key){
707
783
  logActivity('formal',(rId||'host')+' 的 formal 回执缺少 target(对象 id)——本次未记录(V4_INVALID_ARGUMENT)')
@@ -764,7 +840,9 @@ export function apply(ctx) {
764
840
  await writeJson('State/mailboxes.json', Object.fromEntries(mailboxes))
765
841
  await writeJson('State/taskboard.json', taskboard)
766
842
  await writeJson('State/decisions.json', decisions)
767
- await writeJson('State/formal.json', {records:formal,todo:formalTodos})
843
+ // `off` mode must not CREATE Lean state: a run that never used the feature leaves no
844
+ // State/formal.json behind. Seeded/live records (or a mode that is on) still persist.
845
+ if(formalOn()||formalPersisted||Object.keys(formal).length||formalTodos.length) await writeJson('State/formal.json', {records:formal,todo:formalTodos})
768
846
  await writeJson('State/session.json', {running,autoDone,phase,problemId,problemText,runId,meetings,reports,lastActivityAt,lastProgressAt,activityLog,processEpoch,artifactCount})
769
847
  }
770
848
  async function loadAll(){
@@ -779,6 +857,7 @@ export function apply(ctx) {
779
857
  if(fm&&typeof fm==='object'){
780
858
  formal=(fm.records&&typeof fm.records==='object')?fm.records:{}
781
859
  formalTodos=Array.isArray(fm.todo)?fm.todo:[]
860
+ if(Object.keys(formal).length||formalTodos.length) formalPersisted=true
782
861
  }
783
862
  }
784
863