dsh-vibe-math 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -681
  3. package/README.md +683 -682
  4. package/cordis.patch.yml +112 -16
  5. package/docs/AUDIT-CHECKLIST.md +755 -518
  6. package/docs/COMPAT-AUDIT-ROUND2.md +354 -328
  7. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  8. package/docs/audit-methodology.md +93 -0
  9. package/docs/final-paper.md +172 -172
  10. package/docs/formal-verification.md +613 -588
  11. package/docs/generate_framework_diagram_v2.py +114 -114
  12. package/docs/generate_framework_diagram_v3.py +127 -127
  13. package/docs/math-computation.md +195 -187
  14. package/docs/parameter-schema.md +37 -0
  15. package/docs/release-notes/README.en.md +2 -0
  16. package/docs/release-notes/README.md +2 -0
  17. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  18. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  19. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  20. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  21. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  22. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  23. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  24. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  25. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  26. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  27. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  28. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  29. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  30. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  31. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  32. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  33. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  34. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  35. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
  36. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
  37. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  38. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  39. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  40. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  41. package/docs/release-notes/TEMPLATE.md +6 -0
  42. package/docs/release-playbook.md +103 -0
  43. package/docs/slv-playbook.md +117 -0
  44. package/docs/status-report-fields.md +167 -0
  45. package/docs/test-timing.md +158 -154
  46. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  47. package/installer.js +793 -783
  48. package/package.json +260 -203
  49. package/prompt-corpus-persona/persona-corpus.json +9 -9
  50. package/prompt-corpus-persona/persona-corpus.md +225 -33
  51. package/prompt-corpus-v2/formal-verify-v2.json +295 -115
  52. package/prompt-corpus-v2/formal-verify-v2.md +2290 -282
  53. package/prompt-corpus-v3/formal-verify-v3.json +112 -112
  54. package/prompt-corpus-v3/formal-verify-v3.md +227 -143
  55. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  56. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  57. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  58. package/prompt-corpus-v5/prompt-corpus-v5.md +573 -207
  59. package/scripts/clean-temp.mjs +234 -0
  60. package/scripts/update-doc-counts.mjs +71 -0
  61. package/tests/audit-artifact-docs.mjs +72 -0
  62. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  63. package/tests/audit-engine-faces.mjs +298 -0
  64. package/tests/audit-formal-sensitivity.mjs +345 -342
  65. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  66. package/tests/audit-installer-compat.test.mjs +256 -208
  67. package/tests/audit-installer-policy.test.mjs +41 -8
  68. package/tests/audit-math-computation-contract.mjs +64 -8
  69. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  70. package/tests/audit-math-computation-parity.mjs +164 -3
  71. package/tests/audit-math-computation-sensitivity.mjs +5 -3
  72. package/tests/audit-package-membership.mjs +57 -0
  73. package/tests/audit-package-membership.mutants.mjs +28 -0
  74. package/tests/audit-participant-set-parity.mjs +160 -0
  75. package/tests/audit-path-discipline.mjs +83 -0
  76. package/tests/audit-path-discipline.mutants.mjs +51 -0
  77. package/tests/audit-persona-sensitivity.mjs +323 -309
  78. package/tests/audit-persona-surface.mutants.mjs +69 -0
  79. package/tests/audit-persona-surface.test.mjs +431 -390
  80. package/tests/audit-prompt-duplication.mjs +143 -0
  81. package/tests/audit-prompt-duplication.mutants.mjs +51 -0
  82. package/tests/audit-prompt-invariants.mjs +589 -508
  83. package/tests/audit-readme-bilingual.test.mjs +152 -0
  84. package/tests/audit-readme-counts.mjs +95 -0
  85. package/tests/audit-readme-counts.mutants.mjs +52 -0
  86. package/tests/audit-registration.mjs +78 -0
  87. package/tests/audit-spec-traceability.mjs +193 -193
  88. package/tests/audit-status-report-fields.mjs +172 -0
  89. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  90. package/tests/audit-v3-registration-parity.mjs +156 -0
  91. package/tests/audit-v5-integrity.mjs +795 -508
  92. package/tests/audit-v5-lean-abstention.mjs +392 -0
  93. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  94. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  95. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  96. package/tests/audit-v5-sensitivity.mjs +433 -427
  97. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  98. package/tests/e2e-v5-round2.test.mjs +2453 -1981
  99. package/tests/formal-verify-v2.test.mjs +1778 -1724
  100. package/tests/formal-verify-v3.mutants.mjs +117 -0
  101. package/tests/formal-verify-v3.test.mjs +1724 -1671
  102. package/tests/formal-verify-v4.mutants.mjs +152 -0
  103. package/tests/formal-verify-v4.test.mjs +2114 -1574
  104. package/tests/formal-verify-v5.test.mjs +790 -724
  105. package/tests/helpers/doc-counts-format.mjs +41 -0
  106. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  107. package/tests/math-computation-a1.mutants.mjs +72 -0
  108. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  109. package/tests/math-computation-discovery.mutants.mjs +76 -0
  110. package/tests/math-computation-shared.test.mjs +392 -6
  111. package/tests/math-computation-v2.test.mjs +779 -0
  112. package/tests/math-computation-v3.test.mjs +771 -0
  113. package/tests/math-computation-v4.mutants.mjs +133 -0
  114. package/tests/math-computation-v4.test.mjs +739 -0
  115. package/tests/prompt-v5-integrity.test.mjs +1291 -1280
  116. package/tests/run-tests.mjs +393 -202
  117. package/tests/run-tests.mutants.mjs +70 -0
  118. package/tests/selfdrive-v5.mjs +560 -508
  119. package/tests/selfdrive-v5.mutants.mjs +132 -0
  120. package/tests/temp-hygiene.mutants.mjs +80 -0
  121. package/tests/v2-fix-probes.mutants.mjs +104 -0
  122. package/tests/v2-fix-probes.test.mjs +1040 -0
  123. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  124. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  125. package/tests/v3-fix-probes.mutants.mjs +99 -0
  126. package/tests/v3-fix-probes.test.mjs +892 -0
  127. package/tests/v4-final-paper.mutants.mjs +108 -0
  128. package/tests/v5-institute-fixes.mutants.mjs +218 -0
  129. package/vibe-math-v2/agent.cordis.yml +378 -322
  130. package/vibe-math-v2/math-computation.js +144 -53
  131. package/vibe-math-v2/math-engines.js +12 -12
  132. package/vibe-math-v2/preset.yml +2 -2
  133. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  134. package/vibe-math-v2/vibe-math-v2.js +5097 -4755
  135. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +491 -487
  136. package/vibe-math-v3/agent.cordis.yml +422 -356
  137. package/vibe-math-v3/math-computation.js +144 -53
  138. package/vibe-math-v3/math-engines.js +12 -12
  139. package/vibe-math-v3/preset.yml +2 -2
  140. package/vibe-math-v3/vibe-math-v3.js +6235 -5873
  141. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  142. package/vibe-math-v4/agent.cordis.yml +438 -402
  143. package/vibe-math-v4/math-computation.js +144 -53
  144. package/vibe-math-v4/math-engines.js +12 -12
  145. package/vibe-math-v4/preset.yml +2 -2
  146. package/vibe-math-v4/vibe-math-v4.js +4490 -4269
  147. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1013
  148. package/vibe-math-v5/agent.cordis.yml +46 -12
  149. package/vibe-math-v5/math-computation.js +144 -53
  150. package/vibe-math-v5/math-engines.js +12 -12
  151. package/vibe-math-v5/vibe-math-v5.js +1266 -229
  152. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1660
  153. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1,1574 +1,2114 @@
1
- // ============================================================
2
- // V4 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
3
- //
4
- // Asserts the whole contract of the `formalVerify` knob for the v4 preset:
5
- // · 'off' is a TRUE no-op for VERIFICATION (no Lean block, no gate) and for the daily line
6
- // under the default initiative; `leanInitiative:'eager'` is the one exception that
7
- // still injects the DAILY line (the two knobs are independent axes — see §17).
8
- // · 'encourage' injects the Lean section into the VERIFICATION prompt AND the ordinary work
9
- // round, and — the actual point of the feature — turns the voting prompt into a
10
- // FIDELITY review once a Lean run has passed
11
- // · 'require' withholds a unanimous 真/假 verdict as 未定论 until the object is Lean-passed
12
- // or carries an explicit, reasoned blocker record; then it allows it, and the
13
- // Verified card records how strong the result really is
14
- // · the three tools (run / archive / lib) write the right things to the right paths
15
- //
16
- // The Lean toolchain is mocked through the `subprocess` SERVICE (a fake Lean: a file passes unless
17
- // it still contains `sorry` or the marker `-- FAIL`), so the tests exercise the REAL code path
18
- // (resolveExecutable → spawn → collected stdout → exit code) without requiring Lean to be installed.
19
- //
20
- // V4_PLUGIN lets a sensitivity probe point this suite at a MUTATED copy of the plugin; without that
21
- // override every probe would run the unmutated file and pass vacuously (AUDIT-CHECKLIST §2.5).
22
- //
23
- // Prompt text is asserted through the plugin's own `vibe_v4_prompts` host tool — the exact builder
24
- // the framework uses — rather than by guessing which queued wake carries which prompt. That keeps
25
- // the assertions precise even when the scheduler queues several rounds in one pass, and the suite
26
- // still drives the real consensus path for every STATE transition (AUDIT-CHECKLIST §0.1/§2.2).
27
- //
28
- // Run: node tests/formal-verify-v4.test.mjs
29
- // ============================================================
30
- import { mkdtempSync, existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, rmSync } from 'node:fs'
31
- import { tmpdir } from 'node:os'
32
- import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
33
- import { pathToFileURL, fileURLToPath } from 'node:url'
34
-
35
- // V4_PLUGIN (same convention the v5 suite uses for V5_PLUGIN): point the suite at a MUTATED copy of
36
- // the plugin so a sensitivity probe can prove each invariant actually turns this suite red. Accept a
37
- // plain filesystem path or a file:// URL, and never touch the value otherwise.
38
- function pluginUrl() {
39
- const raw = process.env.V4_PLUGIN
40
- if (!raw || !String(raw).trim()) return new URL('../vibe-math-v4/vibe-math-v4.js', import.meta.url)
41
- const s = String(raw).trim()
42
- if (/^file:/i.test(s)) return new URL(s)
43
- return pathToFileURL(fileURLToPath(new URL('file:///' + s.replace(/\\/g, '/'))))
44
- }
45
- const PLUGIN = pluginUrl()
46
- // docs §10 item 10: the Lean prompt corpus is shipped under prompt-corpus-v4/ so a HUMAN can read
47
- // the exact text the framework sends. V4_CORPUS_DIR overrides the destination (same convention v3
48
- // and v5 use for V3_CORPUS_DIR / V5_CORPUS_DIR).
49
- const HERE = dirname(fileURLToPath(import.meta.url))
50
- const CORPUS_DIR = process.env.V4_CORPUS_DIR ? pathResolve(process.env.V4_CORPUS_DIR) : join(HERE, '..', 'prompt-corpus-v4')
51
-
52
- let passed = 0, failed = 0
53
- const failures = []
54
- const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
55
- const sleep = ms => new Promise(r => setTimeout(r, ms))
56
- const section = (t) => console.log('\n[' + t + ']')
57
- const JSONX = o => '```json\n' + JSON.stringify(o) + '\n```'
58
- const promptOf = (entry) => (entry && entry.blocks && entry.blocks[0] && entry.blocks[0].text) || ''
59
- const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
60
-
61
- // ===============================================================
62
- // MOCK HOST — the shape selfdrive-v4 / e2e-v4-fixes use, plus a fake Lean
63
- // ===============================================================
64
- let toolchainAvailable = true
65
- // A host that exposes NO `subprocess` service at all (docs §7 → NO_SUBPROCESS, and §6 hard rule 4
66
- // requires the injected guidance to name that code so the resident records a blocker instead of
67
- // retrying forever).
68
- let subprocessAvailable = true
69
- // A stub host whose shell EXITS 0 WITHOUT deleting anything (a permissions quirk / a stub host).
70
- // Used by §13 to prove the withdrawal is not a best-effort delete: the framework must confirm the
71
- // file is gone through the fs service and fall back to overwriting it with a withdrawal notice.
72
- let shellDeletesFiles = true
73
- const leanRuns = [] // every spawn the framework made, for cwd/argv assertions
74
- const terminated = [] // files whose handle the framework actively terminate()d (docs §7)
75
- const shellCalls = [] // every platform-shell script (mkdir at mount, Remove-Item on defect)
76
-
77
- function makeSubprocess() {
78
- return {
79
- async resolveExecutable(cmd) {
80
- if (!toolchainAvailable) throw new Error('spawn lean ENOENT')
81
- if (String(cmd) !== 'lean' && String(cmd) !== 'lake') throw new Error('unknown executable ' + cmd)
82
- return String(cmd)
83
- },
84
- spawn(spec) {
85
- // The preset drives the platform shell through this SAME `subprocess` service (runShell:
86
- // powershell / /bin/sh) for mkdir at mount, and — since docs §4.1 — for Remove-Item when a
87
- // `defect` retracts an archived proof. Handle it here so that retraction is a REAL filesystem
88
- // deletion, exactly as the real host performs it; otherwise "the proof file is gone" could
89
- // only be asserted against a mock's bookkeeping.
90
- const argv0 = String((spec.argv && spec.argv[0]) || '')
91
- if (/powershell|cmd\.exe|\/bin\/sh|(^|\/)sh$/i.test(argv0)) {
92
- const script = String(spec.argv[spec.argv.length - 1] || '')
93
- shellCalls.push(script)
94
- const m = script.match(/-LiteralPath\s+'((?:[^']|'')*)'/)
95
- if (shellDeletesFiles && /Remove-Item/.test(script) && m) rmSync(m[1].replace(/''/g, "'"), { force: true })
96
- if (shellDeletesFiles && /^rm -f /.test(script)) for (const q of script.slice(6).match(/'[^']*'/g) || []) rmSync(q.slice(1, -1), { force: true })
97
- return {
98
- done: Promise.resolve({ exitCode: 0, signal: null }),
99
- collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
100
- terminate() {},
101
- }
102
- }
103
- // The plugin passes an ABSOLUTE path as the last argv element, so resolve it directly; fall
104
- // back to scanning the argv for an existing .lean file (mirrors leanAbsPath).
105
- const last = spec.argv[spec.argv.length - 1]
106
- const file = existsSync(last) ? last : (spec.argv.find(a => /\.lean$/.test(String(a)) && existsSync(a)) || last)
107
- const text = existsSync(file) ? readFileSync(file, 'utf8') : ''
108
- const bad = /sorry|-- FAIL/.test(text)
109
- leanRuns.push({ argv: spec.argv.slice(), file, cwd: spec.cwd, graceMs: spec.graceMs, stdio: spec.stdio })
110
- // A HANG file never settles and never exits: the ONLY thing that can end it is the framework's
111
- // own timeout calling handle.terminate() (docs §7). Records each terminate so the suite can
112
- // assert the timeout path is ACTIVE, not merely reported.
113
- if (/-- HANG/.test(text)) {
114
- return {
115
- done: new Promise(() => {}),
116
- collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
117
- terminate() { terminated.push(file) },
118
- }
119
- }
120
- const stdout = bad ? '' : 'ok\n'
121
- const stderr = bad ? 'error: declaration uses sorry\n' : ''
122
- return {
123
- done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
124
- collected: {
125
- stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
126
- stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
127
- },
128
- terminate() {},
129
- }
130
- },
131
- }
132
- }
133
-
134
- function makeHost() {
135
- const WS = mkdtempSync(join(tmpdir(), 'vibe-v4-lean-'))
136
- const listeners = {}, toolRegs = [], spawns = [], followups = [], cmdRegs = []
137
- const subprocess = makeSubprocess()
138
- let ROOT
139
- const ctx = {
140
- get(name) { return name === 'subprocess' && subprocessAvailable ? subprocess : undefined },
141
- on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
142
- effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
143
- logger: { info() {}, warn() {}, error() {} },
144
- timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
145
- tools: { register(s) { toolRegs.push(s) } }, commands: { register(s) { cmdRegs.push(s) } },
146
- // Real DSH exposes ONLY subagents.sendMessage for continuable wakes (followup is an Agent
147
- // method, not a subagents service method) — deliberately no followup, so a regression fails.
148
- subagents: {
149
- list() { return ['spawn'] },
150
- async startContinuable({ label, request }) { const id = 'c' + (spawns.length + 1); spawns.push({ label, request, childId: id }); return { childId: id } },
151
- async sendMessage(parent, childId, blocks) { followups.push({ childId, blocks }) },
152
- interrupt() {},
153
- },
154
- agents: { roots() { return [] }, get(id) { return id === 'sess-A' ? ROOT : undefined } },
155
- fs: {
156
- async resolve(rel, opts) { const b = (opts && opts.cwd) || WS; const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/')); return { targetKey: p, displayPath: p } },
157
- async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
158
- async readText(t) { return readFileSync(t.targetKey, 'utf8') },
159
- async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
160
- async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
161
- },
162
- }
163
- ROOT = { id: 'sess-A', options: { provider: 'mock', model: 'm' }, session: { id: 'sess-A', header: { cwd: WS, parentSession: undefined } }, followup() {}, ctx: undefined }
164
- const projectRoot = join(WS, 'VibeMath', 'Projects', 'default')
165
- const vibeRoot = join(WS, 'VibeMath')
166
- let consumed = 0
167
- const h = {
168
- WS, ctx, toolRegs, spawns, followups, ROOT, projectRoot, vibeRoot, cmdRegs,
169
- /** Drive the registered `/v4` slash command — the OTHER parameter surface the spec §B names. */
170
- async cmd(rawInput) { const c = cmdRegs.find(x => x.name === 'v4'); if (!c) throw new Error('no /v4 command'); return await c.handler({ agent: ROOT, rawInput }) },
171
- async callTool(n, a, agent) {
172
- const s = toolRegs.find(x => x.name === n); if (!s) throw new Error('no tool ' + n)
173
- return JSON.parse(await s.execute(a || {}, { agent: agent || ROOT }))
174
- },
175
- /** The exact prompt text a resident would receive, read through the plugin's own host tool. */
176
- async prompts(which, rId, arg) {
177
- const out = await h.callTool('vibe_v4_prompts', Object.assign({ which, member: rId }, arg || {}), ROOT)
178
- return out.text
179
- },
180
- resAgent: (cid) => ({ id: cid, session: { id: cid, header: { cwd: WS, parentSession: 'sess-A' } } }),
181
- ridOfChild(cid) { for (const sp of spawns) if (sp.childId === cid) return sp.label; return '' },
182
- childOf(rId) { for (const sp of spawns) if (sp.label === rId) return sp.childId; return '' },
183
- fireEnd(cid, reply) {
184
- const blocks = reply === undefined ? [] : [{ type: 'text', text: JSONX(reply) }]
185
- for (const fn of (listeners['subagent/end'] || [])) fn({ id: cid, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: blocks })
186
- },
187
- /** Every prompt this project's residents have received (founding + wakes). */
188
- allPrompts() {
189
- return spawns.map(s => (s.request && s.request.prompt && s.request.prompt[0] && s.request.prompt[0].text) || '').join('\n')
190
- + '\n' + followups.map(promptOf).join('\n')
191
- },
192
- nextWake() { while (consumed < followups.length) return followups[consumed++]; return null },
193
- resetCursor() { consumed = followups.length },
194
- }
195
- return h
196
- }
197
-
198
- async function mount() {
199
- const h = makeHost()
200
- const mod = await import(PLUGIN.href + '?t=' + Date.now() + Math.random())
201
- ;(mod.default || mod).apply(h.ctx)
202
- return h
203
- }
204
-
205
- // ===============================================================
206
- // driving helpers
207
- // ===============================================================
208
- /** Complete every brainstorm round so the run becomes phase=active, then park the heartbeat. */
209
- async function establish() {
210
- const h = await mount()
211
- const before = h.spawns.length
212
- await h.callTool('vibe_v4_start', { problem: '形式化契约验证测试', residentCount: 2 })
213
- for (let i = 0; i < 60 && h.spawns.length < before + 2; i++) await sleep(10)
214
- for (const sp of h.spawns.slice(before)) { h.fireEnd(sp.childId, { summary: '初始见解。', solved: false, contextPct: 10 }); await sleep(30) }
215
- // Park the heartbeat far away: these tests drive wakes explicitly, so a short heartbeat would
216
- // only add unrelated normal rounds and make the followup stream harder to reason about.
217
- // `leanAsync:false` on purpose: sections 1-16 assert the HISTORICAL SYNCHRONOUS contract
218
- // (lean_run returns exitCode/stdout in the same call, archive{run:true} compiles inline) verbatim.
219
- // The asynchronous contract (enqueue + settle + recovery) has its own sections 17+.
220
- await h.callTool('vibe_v4_set', { activityTimeoutMs: 600000, leanAsync: false })
221
- for (let i = 0; i < 40; i++) { const s = await h.callTool('vibe_v4_status', {}); if (s.phase === 'active') break; await sleep(20) }
222
- await sleep(30)
223
- h.resetCursor()
224
- return h
225
- }
226
- /**
227
- * Answer the next wake with `replyFor(promptText, rId)`; returns how many wakes were answered.
228
- * `stop` is evaluated AFTER processing each wake, never before the first one: a run that is simply
229
- * idle between rounds would otherwise satisfy "no verification in flight" at entry and the loop
230
- * would exit without answering anything.
231
- */
232
- async function drive(h, replyFor, stop, budget = 40) {
233
- let n = 0
234
- for (; n < budget; n++) {
235
- const w = h.nextWake()
236
- if (!w) break
237
- const pt = promptOf(w)
238
- h.fireEnd(w.childId, replyFor(pt, h.ridOfChild(w.childId)))
239
- await sleep(12)
240
- if (stop && await stop()) { n++; break }
241
- }
242
- return n
243
- }
244
- const verifySettled = (h) => async () => { const s = await h.callTool('vibe_v4_status', {}); return s.verifyInProgress === false && s.pendingVerify === null }
245
- const voteReply = (plan) => (pt, rId) => (/团队验证/.test(pt)
246
- ? { vote: { verdict: plan(rId), reason: rId + ' 的判断' } }
247
- : { summary: '继续推进。', solved: false, contextPct: 20 })
248
- /** Drive the pending verification to a settled state and return the resulting status. */
249
- async function settleVerify(h, plan = () => 1, budget = 40) {
250
- await drive(h, voteReply(plan), verifySettled(h), budget)
251
- return await h.callTool('vibe_v4_status', {})
252
- }
253
- /**
254
- * Record a card for `target`, have r-1 propose verifying it, vote it wherever it lands, and return
255
- * the settled status. Prompt TEXT is asserted separately (see `h.prompts`) — this helper exercises
256
- * the real consensus path for the STATE transitions.
257
- */
258
- async function proposeAndVote(h, target, opts = {}) {
259
- const card = opts.card || { id: target, title: target, statement: '待验证对象 ' + target, prob: 0.8, value: 0.6, motivation: 'm' }
260
- await h.callTool('vibe_v4_record_proposition', card, h.resAgent(h.childOf('r-1')))
261
- // The heartbeat is parked far away, so drive the FIRST round explicitly: an idle resident is
262
- // woken immediately by an incoming message (postMessage → wakeResident).
263
- const before = h.followups.length
264
- await h.callTool('vibe_v4_message', { to: 'r-1', content: '请处理本轮工作。' })
265
- for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
266
- const proposal = opts.proposal || { summary: '提议验证 ' + target + '。', solved: false, propose_verify: target, contextPct: 20 }
267
- await drive(h, (pt) => (/团队验证/.test(pt)
268
- ? { vote: { verdict: (opts.verdict === undefined ? 1 : opts.verdict), reason: '预看提示词' } }
269
- : proposal), verifySettled(h))
270
- // The verification may only have been QUEUED by the proposal round; finish it.
271
- return await settleVerify(h, opts.plan || (() => (opts.verdict === undefined ? 1 : opts.verdict)))
272
- }
273
- /**
274
- * Start a verification and KEEP IT IN FLIGHT (the first half of `proposeAndVote`): record a card, have
275
- * r-1 propose verifying it, and stop driving as soon as the verify view is ACTIVE. Note the trap:
276
- * `verifyInProgress` is already true while the verification is merely QUEUED, so `consensus.kind`
277
- * is the only reliable "it is really running" signal.
278
- */
279
- async function startVerifyOnly(h, target) {
280
- const card = { id: target, title: target, statement: '待验证对象 ' + target, prob: 0.8, value: 0.6, motivation: 'm' }
281
- await h.callTool('vibe_v4_record_proposition', card, h.resAgent(h.childOf('r-1')))
282
- const before = h.followups.length
283
- await h.callTool('vibe_v4_message', { to: 'r-1', content: '请处理本轮工作。' })
284
- for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
285
- await drive(h, () => ({ summary: '提议验证 ' + target + '。', solved: false, propose_verify: target, contextPct: 20 }), async () => {
286
- const s = await h.callTool('vibe_v4_status', {})
287
- return !!(s.consensus && s.consensus.kind === 'verify')
288
- })
289
- return await h.callTool('vibe_v4_status', {})
290
- }
291
- /** Wake exactly the given resident with an ordinary work round and return that wake. */
292
- async function workWake(h, rId) {
293
- const before = h.followups.length
294
- await h.callTool('vibe_v4_message', { to: rId, content: '请继续推进。' })
295
- for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
296
- const w = h.nextWake()
297
- assert(w !== null, 'a work wake was delivered to ' + rId)
298
- return w
299
- }
300
-
301
- // ===============================================================
302
- console.log('-- V4 Lean formal verification --')
303
-
304
- // ---------- 1. 'off' is a true no-op ----------
305
- section("1 'off' (default) is a true no-op")
306
- const A = await establish()
307
- {
308
- const st = await A.callTool('vibe_v4_status', {})
309
- assert(st.formal.mode === 'off' && st.formal.on === false, "the default mode is 'off' (got " + st.formal.mode + ')')
310
- assert(/formalVerify=off/.test(st.params), "status exposes formalVerify=off in its readable params (got " + st.params + ')')
311
- // Scan BEFORE any hand-written test message is injected: this test's own text would otherwise
312
- // taint the scan. The scan is on the feature's own TOKENS rather than the bare word 形式化: the
313
- // pre-existing brainstorm brief already contains 形式化 in the unrelated sentence about being
314
- // free to invent theories, so asserting the bare word would fail for a reason that has nothing
315
- // to do with this contract (AUDIT-CHECKLIST §2.2).
316
- const all = A.allPrompts()
317
- assert(!/Lean/.test(all), 'no founding/round prompt mentions Lean in off mode')
318
- assert(!/顺手形式化|Lean 通过|忠实性审查|vibe_v4_lean/.test(all), 'no founding/round prompt carries any Lean-formalization text in off mode')
319
- assert(all.length > 0, 'sanity: the scan actually saw the founding prompts')
320
- // `off` must not CREATE Lean state at all: the feature's durable record file must not appear
321
- // for a session that never used the feature (v3's suite pins the same invariant).
322
- assert(!existsSync(join(A.projectRoot, 'State', 'formal.json')), '★ off mode creates NO State/formal.json (a TRUE no-op, not just an empty record store)')
323
- }
324
- // an off-mode verification must behave exactly as before: unanimous 真 → Verified/, no formal record
325
- const offSettled = await proposeAndVote(A, 'p-off', {
326
- card: { id: 'p-off', title: '关模式', statement: '关模式下的普通命题', prob: 0.8, value: 0.6, motivation: 'm' },
327
- proposal: { summary: '请提议验证 p-off。', solved: false, propose_verify: 'p-off', contextPct: 20 },
328
- })
329
- assert(existsSync(join(A.projectRoot, 'Verified', '命题', 'p-off.md')), "★ 'off' still closes a unanimous TRUE verdict with NO Lean artifact")
330
- assert(offSettled.formal.passed.length === 0 && offSettled.formal.objects.length === 0, "'off' records no formal object at all")
331
- assert(!/形式化/.test(readIf(join(A.projectRoot, 'Verified', '命题', 'p-off.md'))), 'the Verified card carries no formal line in off mode')
332
- assert(!existsSync(join(A.projectRoot, 'Formal', 'TODO.md')) || !/p-off/.test(readIf(join(A.projectRoot, 'Formal', 'TODO.md'))), 'no formalization TODO was created for p-off')
333
- assert(!/顺手形式化|Lean/.test(await A.prompts('verify', 'r-1', { target: 'p-off' })), 'the voting prompt carries no Lean text in off mode')
334
- assert(!/顺手形式化/.test(await A.prompts('normal', 'r-1')), 'the work prompt carries no formalization line in off mode')
335
- // the tools still EXIST in off mode (static registration), they are just never advertised
336
- assert(['vibe_v4_lean_run', 'vibe_v4_lean_archive', 'vibe_v4_lean_lib'].every(n => !!A.toolRegs.find(t => t.name === n)),
337
- 'the three Lean tools are registered in every mode (registration is static)')
338
-
339
- // ★ The mode switch must be REACHABLE THROUGH THE TOOL SCHEMA (2.3.2 defect D1) ──────────────
340
- // Every tool schema here is closed (`additionalProperties:false`), so a key the schema does not
341
- // advertise is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four Lean
342
- // parameters missing from the set-params schema while every assertion in this file stayed green —
343
- // because the suite calls the handler DIRECTLY and never inspects the registered schema. The feature
344
- // could not be switched on at all through the tool interface.
345
- {
346
- const setSpec = A.toolRegs.find((t) => t.name === 'vibe_v4_set')
347
- assert(!!setSpec, "vibe_v4_set is registered")
348
- assert(setSpec.parameters && setSpec.parameters.type === 'object' && setSpec.parameters.additionalProperties === false,
349
- '★ vibe_v4_set publishes a CLOSED object schema (an unlisted key is rejected, so the schema IS the contract)')
350
- for (const k of ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']) {
351
- assert(Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k),
352
- '★ the registered schema advertises ' + k + ' (every other surface documents it; a schema that omits it makes the switch unreachable)')
353
- }
354
- assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
355
- 'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
356
- }
357
-
358
- // A stray `formal` reply in OFF mode must be INERT (finding #1): the reply contract does not offer
359
- // the field there, so honouring it would contradict "off is a TRUE no-op". The TOOLS stay usable.
360
- {
361
- await A.callTool('vibe_v4_record_proposition', { id: 'p-offreply', title: 'off', statement: '关模式下注入回执', prob: 0.8, value: 0.6, motivation: 'm' }, A.resAgent(A.childOf('r-1')))
362
- const wOffR = await workWake(A, 'r-1')
363
- A.fireEnd(wOffR.childId, { summary: '继续。', solved: false, contextPct: 20, formal: { target: 'p-offreply', decision: 'defect', note: '不应被记录' } })
364
- await sleep(80)
365
- const stOffReply = await A.callTool('vibe_v4_status', {})
366
- assert(stOffReply.formal.objects.length === 0, '★ a stray `formal` reply in off mode records NO formal object')
367
- assert(!existsSync(join(A.projectRoot, 'Formal', 'p-offreply.lean')), '★ and writes no formal working file')
368
- assert(!existsSync(join(A.projectRoot, 'Formal', 'TODO.md')) || !/p-offreply/.test(readIf(join(A.projectRoot, 'Formal', 'TODO.md'))), '★ and creates no TODO entry')
369
- assert(!existsSync(join(A.projectRoot, 'State', 'formal.json')), '★ and still no State/formal.json after the stray reply (the off-mode guard is a true no-op)')
370
- }
371
-
372
- // ---------- 2. parameter validation + runtime switching ----------
373
- section('2 parameter validation and runtime switching')
374
- const B = await establish()
375
- {
376
- const bad = await B.callTool('vibe_v4_set', { formalVerify: 'banana' })
377
- assert(bad.formal.mode === 'off' && /formalVerify=off/.test(bad.params), "an unknown mode degrades to 'off', never to a stronger mode (got " + bad.formal.mode + ')')
378
- const enc = await B.callTool('vibe_v4_set', { formalVerify: 'encourage' })
379
- assert(enc.formal.mode === 'encourage', "'encourage' is accepted")
380
- const req = await B.callTool('vibe_v4_set', { formalVerify: 'require', leanTimeoutMs: -5, leanCommand: ' ' })
381
- assert(req.formal.mode === 'require', "'require' is accepted")
382
- assert(/leanTimeoutMs=120000/.test(req.params), 'a non-positive leanTimeoutMs falls back to the default (' + (/leanTimeoutMs=\S+/.exec(req.params) || [])[0] + ')')
383
- assert(/leanCommand=lean/.test(req.params), 'a blank leanCommand falls back to "lean"')
384
- const lk = await B.callTool('vibe_v4_set', { leanCommand: 'lake', leanArgs: ['env', 'lean'] })
385
- assert(/leanCommand=lake/.test(lk.params) && /leanArgs=env,lean/.test(lk.params), 'leanCommand/leanArgs are settable (lake env lean)')
386
- const la = await B.callTool('vibe_v4_set', { leanArgs: 'a, b ,,c' })
387
- assert(/leanArgs=a,b,c/.test(la.params), 'leanArgs accepts a comma-separated string and drops blanks (got ' + (/leanArgs=[^,]*,[^,]*,[^,]*/.exec(la.params) || [])[0] + ')')
388
- await B.callTool('vibe_v4_set', { leanTimeoutMs: 5 })
389
- assert(/leanTimeoutMs=5/.test((await B.callTool('vibe_v4_status', {})).params), 'a legitimate leanTimeoutMs is preserved')
390
- await B.callTool('vibe_v4_set', { leanTimeoutMs: 0 })
391
- assert(/leanTimeoutMs=120000/.test((await B.callTool('vibe_v4_status', {})).params), 'leanTimeoutMs:0 also falls back to the default')
392
- // the switch must be visible to the very NEXT prompt, not to some frozen brief
393
- await B.callTool('vibe_v4_set', { formalVerify: 'encourage' })
394
- assert(/顺手形式化/.test(await B.prompts('normal', 'r-1')), '★ switching the mode at runtime changes the NEXT prompt immediately')
395
- await B.callTool('vibe_v4_set', { formalVerify: 'off' })
396
- assert(!/顺手形式化/.test(await B.prompts('normal', 'r-1')), 'switching back to off removes it again')
397
- }
398
-
399
- // ---------- 3. 'encourage' injection into BOTH prompts ----------
400
- section("3 'encourage' injects the Lean section into the verification AND the work prompt")
401
- const C = await establish()
402
- await C.callTool('vibe_v4_set', { formalVerify: 'encourage' })
403
- {
404
- // The ordinary work round. Read the builder AND drive a real wake, so the assertion cannot pass
405
- // on a builder that is never actually used to address a resident.
406
- const built = await C.prompts('normal', 'r-2')
407
- assert(/【顺手形式化(鼓励)】/.test(built), '★ the ordinary work round tells residents to formalize reusable objects as they go')
408
- assert(/vibe_v4_lean_archive kind='def'/.test(built), 'the work round points at the archive tool for reusable definitions')
409
- assert(/vibe_v4_lean_lib 查重/.test(built), 'the work round tells them to de-duplicate against the reuse library first')
410
- assert(/"formal":/.test(built), 'the work reply contract documents the formal field')
411
- assert(/Resident researcher r-2/.test(built), 'the work prompt is addressed to the resident it is built for')
412
- const hb = await C.prompts('heartbeat', 'r-2')
413
- assert(/【顺手形式化(鼓励)】/.test(hb), 'the CHECKPOINT/heartbeat round carries it too')
414
- const w = await workWake(C, 'r-2')
415
- assert(/【顺手形式化(鼓励)】/.test(promptOf(w)), '★ a REAL delivered work wake contains the line (the builder is the one in use)')
416
- C.fireEnd(w.childId, { summary: '继续推进。', solved: false, contextPct: 20 })
417
- await sleep(30)
418
- const core = await C.prompts('coreRules')
419
- assert(/\[核心规则重申\]/.test(core) && /【顺手形式化(鼓励)】/.test(core), 'the post-compact core-rules recap also carries the formalization line')
420
- }
421
- {
422
- await proposeAndVote(C, 'p-enc', {
423
- card: { id: 'p-enc', title: '鼓励注入', statement: '鼓励模式下的忠实性审查', prob: 0.5, value: 0.6, motivation: 'm' },
424
- proposal: { summary: '提议验证 p-enc。', solved: false, propose_verify: 'p-enc', contextPct: 20 },
425
- verdict: 0.5, plan: () => 0.5,
426
- })
427
- const prompt = await C.prompts('verify', 'r-1', { target: 'p-enc', stage: 'independent' })
428
- assert(/【Lean 形式化验证(鼓励模式)】/.test(prompt), 'the voting prompt explains the Lean mode')
429
- assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(prompt), 'the voting prompt states that a passing Lean run shrinks the question to fidelity')
430
- assert(/实现难度/.test(prompt), 'the voting prompt asks for the implementation-difficulty judgement')
431
- assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(prompt), "'encourage' explicitly allows skipping (with a recorded judgement)")
432
- assert(/vibe_v4_lean_run/.test(prompt) && /vibe_v4_lean_archive/.test(prompt) && /vibe_v4_lean_lib/.test(prompt), 'the voting prompt names all three tools')
433
- assert(/"formal":/.test(prompt), '★ the voting reply contract documents the formal field')
434
- assert(/Resident r-1/.test(prompt), 'the voting prompt is addressed to the voter it is built for')
435
- assert(!/强制模式/.test(prompt), "'encourage' does not use the mandatory wording")
436
- }
437
-
438
- // ---------- 4. the run tool ----------
439
- section('4 lean_run executes through the subprocess service and reports honestly')
440
- const D = await establish()
441
- await D.callTool('vibe_v4_set', { formalVerify: 'encourage' })
442
- mkdirSync(join(D.projectRoot, 'Formal'), { recursive: true })
443
- writeFileSync(join(D.projectRoot, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
444
- writeFileSync(join(D.projectRoot, 'Formal', 'bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
445
- const r1 = D.resAgent(D.childOf('r-1'))
446
- const runGood = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.lean' }, r1)
447
- assert(runGood.ok === true && runGood.exitCode === 0, 'a file with no sorry runs green (' + JSON.stringify({ ok: runGood.ok, exitCode: runGood.exitCode }) + ')')
448
- assert(typeof runGood.ms === 'number' && typeof runGood.command === 'string' && runGood.file === 'Formal/good.lean', 'the result carries {ms, command, file} as the spec requires')
449
- assert(leanRuns.length > 0 && String(leanRuns[leanRuns.length - 1].cwd).replace(/\\/g, '/') === D.projectRoot.replace(/\\/g, '/'), 'the toolchain runs with the PROJECT root as cwd')
450
- {
451
- const last = leanRuns[leanRuns.length - 1]
452
- assert(String(last.argv[0]).indexOf('lean') !== -1 && /\.lean$/.test(String(last.argv[last.argv.length - 1])), 'argv is [exe, ...leanArgs, absolute file]')
453
- assert(last.stdio && last.stdio.stdin === 'ignore', "stdin is ignored (the spec's stdio shape)")
454
- }
455
- const runBad = await D.callTool('vibe_v4_lean_run', { file: 'Formal/bad.lean' }, r1)
456
- assert(runBad.ok === false && runBad.exitCode === 1, 'a file that still uses sorry reports a red run')
457
- assert(/sorry/.test(runBad.stderr), 'the compiler output is returned verbatim (' + JSON.stringify(runBad.stderr).slice(0, 60) + ')')
458
- assert(runBad.code === 'LEAN_FAILED', 'a red run is typed LEAN_FAILED')
459
- const runMissing = await D.callTool('vibe_v4_lean_run', { file: 'Formal/nope.lean' }, r1)
460
- assert(runMissing.ok === false && runMissing.code === 'V4_NOT_FOUND', 'a missing file is refused with a typed code')
461
- const runNoFile = await D.callTool('vibe_v4_lean_run', {}, r1)
462
- assert(runNoFile.ok === false && runNoFile.code === 'V4_INVALID_ARGUMENT', 'a run without a file is refused')
463
- // The guard's boundary is the VibeMath ROOT, not the project: the global reuse library
464
- // deliberately lives at <VibeMath>/Formal/{Lib,Proved}, so climbing out of the project but staying
465
- // inside VibeMath must remain legal (it just fails as a missing file).
466
- const runOutside = await D.callTool('vibe_v4_lean_run', { file: '../../Formal/Lib/x.lean' }, r1)
467
- assert(runOutside.code === 'V4_NOT_FOUND', 'climbing out of the project but staying inside VibeMath is allowed (the global library lives there)')
468
- const esc1 = await D.callTool('vibe_v4_lean_run', { file: '../../../../../etc/evil.lean' }, r1)
469
- assert(esc1.ok === false && esc1.code === 'V4_INVALID_ARGUMENT', '★ a traversal that climbs ABOVE the VibeMath root is refused')
470
- const esc2 = await D.callTool('vibe_v4_lean_run', { file: 'Formal/../../../../../../evil.lean' }, r1)
471
- assert(esc2.ok === false && esc2.code === 'V4_INVALID_ARGUMENT', 'a deeper traversal is refused too')
472
- const esc3 = await D.callTool('vibe_v4_lean_run', { file: '/etc/evil.lean' }, r1)
473
- assert(esc3.ok === false && esc3.code === 'V4_INVALID_ARGUMENT', 'an unrelated absolute path is refused')
474
- const esc4 = await D.callTool('vibe_v4_lean_run', { file: 'C:\\Windows\\evil.lean' }, r1)
475
- assert(esc4.ok === false && esc4.code === 'V4_INVALID_ARGUMENT', 'an unrelated absolute path with a drive letter is refused')
476
- const notLean = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.txt' }, r1)
477
- assert(notLean.ok === false && notLean.code === 'V4_INVALID_ARGUMENT', 'only .lean files can be executed')
478
- toolchainAvailable = false
479
- const noTc = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.lean' }, r1)
480
- assert(noTc.ok === false && noTc.code === 'LEAN_NOT_FOUND', '★ a missing toolchain returns LEAN_NOT_FOUND instead of crashing')
481
- assert(/仍可把形式化代码写下来归档/.test(noTc.message || ''), 'the failure explains the graceful degradation')
482
- assert((await D.callTool('vibe_v4_status', {})).ok === true, 'the group did NOT crash on the missing toolchain (status still answers)')
483
- toolchainAvailable = true
484
- // No `subprocess` service at all (docs §7): the run must degrade to a typed NO_SUBPROCESS result —
485
- // never a thrown error into the scheduler — and an object it was asked to record must stay
486
- // `attempted` (an un-runnable host must not mint a `passed` record).
487
- {
488
- subprocessAvailable = false
489
- const noSub = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.lean', target: 'p-nosub' }, r1)
490
- assert(noSub.ok === false && noSub.code === 'NO_SUBPROCESS', '★ a host with no subprocess service returns NO_SUBPROCESS instead of crashing (got ' + noSub.code + ')')
491
- assert(/no subprocess service/.test(String(noSub.message || '')), 'the failure is readable (it explains that Lean cannot be executed here)')
492
- const stNoSub = await D.callTool('vibe_v4_status', {})
493
- assert(stNoSub.ok === true, 'the group did NOT crash on the missing service (status still answers)')
494
- const recNoSub = stNoSub.formal.objects.find((o) => o.target === 'p-nosub')
495
- assert(recNoSub && recNoSub.status === 'attempted' && !recNoSub.proof, '★ the object record stays honest: attempted (never passed) when nothing could be executed')
496
- subprocessAvailable = true
497
- }
498
- // A run that never finishes: the framework must ACTIVELY terminate it (docs §7) and still report a
499
- // readable LEAN_TIMEOUT — relying on the host's own graceMs alone leaves the Lean process running.
500
- {
501
- writeFileSync(join(D.projectRoot, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
502
- const t0 = Date.now()
503
- const runHang = await D.callTool('vibe_v4_lean_run', { file: 'Formal/hang.lean', timeout_ms: 1000 }, r1)
504
- const waited = Date.now() - t0
505
- assert(runHang.ok === false && runHang.code === 'LEAN_TIMEOUT' && runHang.timedOut === true, '★ a run that never finishes is reported as LEAN_TIMEOUT (got ' + runHang.code + ')')
506
- assert(terminated.some(f => /hang\.lean$/.test(String(f))), '★ the timeout path really calls handle.terminate() (docs §7) instead of leaving the Lean process running')
507
- assert(waited >= 900 && waited < 15000, 'the timeout waited for the cap before terminating (' + waited + 'ms)')
508
- }
509
-
510
- // ---------- 5. a passing proof flips the review subject to fidelity ----------
511
- section('5 a passing proof flips the review subject to fidelity')
512
- const arc = await D.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-proof', content: 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' }, r1)
513
- assert(arc.ok === true && arc.passed === true, 'the proof is archived and passes (' + JSON.stringify({ ok: arc.ok, passed: arc.passed }) + ')')
514
- assert(arc.file === 'Formal/p-proof.lean', 'the working file is Formal/<target>.lean')
515
- assert(arc.proof === 'Verified/Lean/p-proof.lean', 'the archived proof path is Verified/Lean/<target>.lean')
516
- assert(existsSync(join(D.projectRoot, 'Formal', 'p-proof.lean')), 'the working file exists on disk')
517
- assert(existsSync(join(D.projectRoot, 'Verified', 'Lean', 'p-proof.lean')), '★ the proof is archived under Verified/Lean/ as the proof of that object')
518
- const stD = await D.callTool('vibe_v4_status', {})
519
- assert(stD.formal.passed.indexOf('p-proof') !== -1, 'status reports the object as Lean-passed')
520
- const idxD = readIf(join(D.projectRoot, 'Formal', 'Index.md'))
521
- assert(/p-proof/.test(idxD) && /passed/.test(idxD) && /Verified\/Lean\/p-proof\.lean/.test(idxD), 'Formal/Index.md indexes the object, its status and its archived proof')
522
- // A later run recorded against an ALREADY-passed object records the run but must never strip
523
- // `passed`/`proof`: doing so would silently remove the fidelity branch from the next voting prompt
524
- // AND (in `require`) re-close the gate on an object that already has a green archived proof.
525
- {
526
- writeFileSync(join(D.projectRoot, 'Formal', 'p-proof.lean'), 'theorem p_proof : 1 = 2 := by sorry\n', 'utf8')
527
- const rerun = await D.callTool('vibe_v4_lean_run', { file: 'Formal/p-proof.lean', target: 'p-proof' }, r1)
528
- assert(rerun.ok === false, 'precondition: the recorded re-run is red')
529
- const rec = (await D.callTool('vibe_v4_status', {})).formal.objects.find(o => o.target === 'p-proof')
530
- assert(rec && rec.status === 'passed' && rec.proof === 'Verified/Lean/p-proof.lean',
531
- '★ a RED re-run never downgrades a `passed` record (docs §31.6: only an explicit re-archive decides) — the object keeps its fidelity prompt and its require-mode gate')
532
- writeFileSync(join(D.projectRoot, 'Formal', 'p-proof.lean'), 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n', 'utf8')
533
- }
534
- // a RED proof must NOT mint a Verified/Lean/ copy or mark the object passed
535
- const arcRed = await D.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-red', content: 'theorem p_red : 1 = 2 := by sorry\n' }, r1)
536
- assert(arcRed.ok === true && arcRed.passed === false && arcRed.status === 'attempted', 'a red proof is archived as attempted, not passed')
537
- assert(!existsSync(join(D.projectRoot, 'Verified', 'Lean', 'p-red.lean')), '★ a red run does NOT mint a Verified/Lean proof copy')
538
- // durable state: the record survives a resume (v4 has no session projection)
539
- {
540
- const raw = readIf(join(D.projectRoot, 'State', 'formal.json'))
541
- assert(/"p-proof"/.test(raw) && /"passed"/.test(raw), "★ the formal record is persisted in v4's own durable State/formal.json")
542
- const before = D.spawns.length
543
- await D.callTool('vibe_v4_abort', {})
544
- const res = await D.callTool('vibe_v4_resume', {})
545
- assert(res.ok === true && D.spawns.length > before, 'abort → resume re-spawns residents')
546
- const stR = await D.callTool('vibe_v4_status', {})
547
- assert(stR.formal.passed.indexOf('p-proof') !== -1, '★ the Lean-passed record SURVIVES resume (durable state, not an in-memory projection)')
548
- for (const sp of D.spawns.slice(before)) { D.fireEnd(sp.childId, { summary: '恢复见解。', solved: false, contextPct: 10 }); await sleep(25) }
549
- await D.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
550
- D.resetCursor()
551
- }
552
- // now the voting prompt must ASK FOR FIDELITY, not for a re-derivation
553
- {
554
- await proposeAndVote(D, 'p-proof', {
555
- card: { id: 'p-proof', title: '忠实性', statement: '3N²−2=b² 在 N=1 时成立', prob: 0.9, value: 0.6, motivation: 'm' },
556
- proposal: { summary: '提议验证 p-proof。', solved: false, propose_verify: 'p-proof', contextPct: 20 },
557
- verdict: 0.5, plan: () => 0.5,
558
- })
559
- const prompt = await D.prompts('verify', 'r-1', { target: 'p-proof', stage: 'independent' })
560
- assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(prompt), 'the voting prompt announces the passing proof')
561
- assert(/你不需要重新检查推导/.test(prompt), '★ it tells voters NOT to re-derive')
562
- assert(/忠实性审查/.test(prompt), '★ it tells voters the review subject is now fidelity')
563
- assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(prompt), 'it enumerates exactly what fidelity means')
564
- assert(/一致 → verdict = 1/.test(prompt), "the fidelity instruction uses v4's REAL reply field name (`verdict`)")
565
- assert(/发现任何偏差,不要投 0/.test(prompt), '★ it forbids expressing a fidelity deviation as "0 / false"')
566
- assert(/偏差只说明\*\*形式化不合格\*\*,不代表命题为假/.test(prompt), '★ it states WHY: a defect is a failed formalization, not a refutation')
567
- assert(/formal:\{decision:'defect', note:'<具体偏差>'\}/.test(prompt), '★ it hands the voter the exact `defect` reply contract')
568
- assert(/降级为 attempted、删除归档证明、写入形式化待办/.test(prompt), 'it says what the framework WILL do with a defect (downgrade + delete + TODO)')
569
- assert(/只有当你\*\*独立于这份 Lean 代码\*\*也能确定命题为假时,才投 0/.test(prompt), 'only an INDEPENDENT refutation may be expressed as 0')
570
- assert(!/偏离 → 0/.test(prompt), '★ the old "a deviation ⇒ 0" wording is gone (it would fabricate a negative conclusion)')
571
- assert(/Verified\/Lean\/p-proof\.lean/.test(prompt), 'it points at the archived proof')
572
- // the SAME object's work round must not inherit the fidelity framing (that is a voting
573
- // instruction), but it does carry the standing formalization line.
574
- const work = await D.prompts('normal', 'r-1')
575
- assert(!/忠实性审查/.test(work), 'the fidelity instruction is confined to the voting prompt')
576
- assert(/【顺手形式化(鼓励)】/.test(work), 'the work round carries the standing formalization line instead')
577
- }
578
-
579
- // ---------- 6. the reusable cross-project library ----------
580
- section('6 reusable definitions and lemmas go to the GLOBAL library')
581
- const libPath = join(D.vibeRoot, 'Formal', 'Lib')
582
- const provedPath = join(D.vibeRoot, 'Formal', 'Proved')
583
- const defRes = await D.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' }, r1)
584
- assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
585
- assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under VibeMath/Formal/Lib/ (cross-project, NOT inside the project)')
586
- assert(!existsSync(join(D.projectRoot, 'Formal', 'Lib', 'ZMod5.lean')), 'it is NOT duplicated inside the project tree')
587
- const lemRes = await D.callTool('vibe_v4_lean_archive', { kind: 'lemma', name: 'sq_odd', content: 'theorem sq_odd (n : Nat) : Odd (n*n) → Odd n := by omega\n' }, r1)
588
- assert(lemRes.ok === true && lemRes.file === 'Formal/Proved/sq_odd.lean', 'a lemma is archived to Proved/')
589
- assert(existsSync(join(provedPath, 'sq_odd.lean')), 'the lemma exists under VibeMath/Formal/Proved/')
590
- assert(/ZMod5/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the new definition')
591
- assert(/sq_odd/.test(readIf(join(provedPath, 'Index.md'))), 'Proved/Index.md lists the new lemma')
592
- // `from` copies an existing workspace .lean file instead of inline content
593
- writeFileSync(join(D.projectRoot, 'Formal', 'src.lean'), 'def copied := 7\n', 'utf8')
594
- const fromRes = await D.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'Copied', from: 'Formal/src.lean' }, r1)
595
- assert(fromRes.ok === true && readIf(join(libPath, 'Copied.lean')) === 'def copied := 7\n', "kind='def' accepts from=<existing .lean file>")
596
- const fromEsc = await D.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'Evil', from: '../../../../etc/passwd' }, r1)
597
- assert(fromEsc.ok === false && fromEsc.code === 'V4_INVALID_ARGUMENT', "★ kind='def' refuses a `from` outside the VibeMath root")
598
- const libList = await D.callTool('vibe_v4_lean_lib', {}, r1)
599
- assert(libList.ok === true && libList.counts.lib >= 2 && libList.counts.proved >= 1, 'lean_lib reports the reuse library sizes (' + JSON.stringify(libList.counts) + ')')
600
- assert(libList.objects.some(o => o.target === 'p-proof' && o.status === 'passed'), 'lean_lib lists per-object formal status')
601
- assert(/复用优先/.test(libList.hint || ''), 'lean_lib tells agents to reuse before redefining')
602
- assert(libList.paths && /Formal\/Lib/.test(libList.paths.lib), 'lean_lib reports the global library paths')
603
- const noName = await D.callTool('vibe_v4_lean_archive', { kind: 'def', content: 'def x := 1\n' }, r1)
604
- assert(noName.ok === false && noName.code === 'V4_INVALID_ARGUMENT', 'archiving a definition without a name is refused')
605
- const noBody = await D.callTool('vibe_v4_lean_archive', { kind: 'lemma', name: 'empty' }, r1)
606
- assert(noBody.ok === false && noBody.code === 'V4_INVALID_ARGUMENT', 'archiving without content or from is refused')
607
- const badKind = await D.callTool('vibe_v4_lean_archive', { kind: 'nonsense' }, r1)
608
- assert(badKind.ok === false && badKind.code === 'V4_INVALID_ARGUMENT', 'an unknown archive kind is refused')
609
- const libNoRefresh = await D.callTool('vibe_v4_lean_lib', { refresh: false }, r1)
610
- assert(libNoRefresh.ok === true && libNoRefresh.rebuilt === false && libNoRefresh.counts.lib === null, 'refresh:false reads without rebuilding')
611
-
612
- // ---------- 7. blocked needs a reason ----------
613
- section('7 a "blocker" record must be explicit and reasoned')
614
- const blkNoNote = await D.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blk' }, r1)
615
- assert(blkNoNote.ok === false && blkNoNote.code === 'V4_INVALID_ARGUMENT', '★ blocked without a note is refused')
616
- const blkBlank = await D.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blk', note: ' ' }, r1)
617
- assert(blkBlank.ok === false && blkBlank.code === 'V4_INVALID_ARGUMENT', 'a whitespace-only note is refused too')
618
- const blk = await D.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blk', note: '需要外层解析数论框架,本轮工作量不可接受' }, r1)
619
- assert(blk.ok === true && blk.status === 'blocked', 'a reasoned blocker is recorded')
620
- const stBlk = await D.callTool('vibe_v4_status', {})
621
- assert(stBlk.formal.blocked.indexOf('p-blk') !== -1, 'status lists the blocked object')
622
- assert(/需要外层解析数论框架/.test(readIf(join(D.projectRoot, 'Formal', 'Index.md'))), 'the blocker reason is written into the index')
623
-
624
- // ---------- 8. the per-round `formal` reply channel ----------
625
- section('8 the per-round `formal` reply judgement is recorded')
626
- {
627
- const w1 = await workWake(D, 'r-1')
628
- D.fireEnd(w1.childId, { summary: '难度太高,本轮不做形式化。', formal: { target: 'p-reply', decision: 'blocked', note: '需要大量未形式化的实分析前置知识' }, contextPct: 20 })
629
- await sleep(60)
630
- const st = await D.callTool('vibe_v4_status', {})
631
- assert(st.formal.blocked.indexOf('p-reply') !== -1, '★ a `formal.decision=blocked` reply is recorded as a blocker')
632
- assert(/实分析前置知识/.test(readIf(join(D.projectRoot, 'Formal', 'Index.md'))), 'and its reason reaches the index')
633
- }
634
- {
635
- const w2 = await workWake(D, 'r-2')
636
- D.fireEnd(w2.childId, { summary: '我写了形式化草稿。', formal: { target: 'p-reply2', decision: 'used', file: 'Formal/p-reply2.lean' }, contextPct: 20 })
637
- await sleep(60)
638
- const st = await D.callTool('vibe_v4_status', {})
639
- assert(st.formal.objects.some(o => o.target === 'p-reply2' && o.status === 'attempted'),
640
- 'a `formal.decision=used` reply records the object as ATTEMPTED with its file (no green run yet)')
641
- }
642
- {
643
- const w3 = await workWake(D, 'r-1')
644
- D.fireEnd(w3.childId, { summary: '不做。', formal: { target: 'p-reply3', decision: 'blocked' }, contextPct: 20 })
645
- await sleep(60)
646
- const st = await D.callTool('vibe_v4_status', {})
647
- assert(!st.formal.objects.some(o => o.target === 'p-reply3'), 'a blocker with no note creates NO record')
648
- assert(st.ok === true, 'the refusal did not crash the run')
649
- }
650
-
651
- // ---------- 9. the 'require' gate ----------
652
- section("9 'require' withholds a verdict until the formal record exists")
653
- const E = await establish()
654
- await E.callTool('vibe_v4_set', { formalVerify: 'require' })
655
- {
656
- await proposeAndVote(E, 'p-gate', {
657
- card: { id: 'p-gate', title: '门禁', statement: '必须形式化的命题', prob: 0.8, value: 0.6, motivation: 'm' },
658
- proposal: { summary: '提议验证 p-gate。', solved: false, propose_verify: 'p-gate', contextPct: 20 },
659
- verdict: 1,
660
- })
661
- const prompt = await E.prompts('verify', 'r-1', { target: 'p-gate', stage: 'independent' })
662
- assert(/【Lean 形式化验证(强制模式)】/.test(prompt), 'the voting prompt says 强制模式')
663
- assert(/必须产出 Lean 形式化/.test(prompt), "'require' states the formalization is mandatory")
664
- assert(/本次裁定不会生效/.test(prompt), 'the prompt warns that the verdict will not take effect without it')
665
- assert(!/可以不做/.test(prompt), "'require' does NOT offer the encourage-mode opt-out")
666
- }
667
- const gated = await E.callTool('vibe_v4_status', {})
668
- assert(gated.verifyInProgress === false && gated.pendingVerify === null, 'the verification actually RAN to a settled state (so the next assertion is falsifiable)')
669
- assert(!existsSync(join(E.projectRoot, 'Verified', '命题', 'p-gate.md')), '★ a unanimous TRUE verdict did NOT promote the object to Verified/')
670
- assert(gated.formal.todo.indexOf('p-gate') !== -1, '★ it is recorded as 未定论 (a formalization TODO entry)')
671
- assert(gated.formal.objects.some(o => o.target === 'p-gate' && o.status === 'none'), 'the object keeps a `none` formal record (never silently marked passed)')
672
- assert(!/已验证·真/.test(readIf(join(E.projectRoot, 'Propos', 'r-1', 'p-gate.md'))), 'the source card was NOT rewritten to 已验证·真 (the conclusion was withheld, not taken)')
673
- const todoE = readIf(join(E.projectRoot, 'Formal', 'TODO.md'))
674
- assert(existsSync(join(E.projectRoot, 'Formal', 'TODO.md')), 'Formal/TODO.md was created')
675
- assert(/p-gate/.test(todoE) && /formal-required/.test(todoE), '★ the object is on the formalization TODO with the machine-readable reason')
676
- assert(!/已验证·真/.test(readIf(join(E.projectRoot, 'Shared', 'debates', 'p-gate.md'))), 'the debate record does NOT claim a 真 conclusion')
677
- assert(/formal-required/.test(readIf(join(E.projectRoot, 'Formal', 'Index.md'))), 'the index carries the withheld verdict too')
678
- // now formalize it and re-verify: the gate must open
679
- const proofNow = await E.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-gate', content: 'theorem p_gate : 2 + 2 = 4 := by decide\n' }, E.resAgent(E.childOf('r-1')))
680
- assert(proofNow.ok === true && proofNow.passed === true, 'the object is now Lean-passed')
681
- {
682
- await proposeAndVote(E, 'p-gate', {
683
- card: { id: 'p-gate', title: '门禁', statement: '必须形式化的命题', prob: 0.8, value: 0.6, motivation: 'm' },
684
- proposal: { summary: '已形式化,重新提议验证 p-gate。', solved: false, propose_verify: 'p-gate', contextPct: 20 },
685
- verdict: 1,
686
- })
687
- const prompt = await E.prompts('verify', 'r-1', { target: 'p-gate', stage: 'independent' })
688
- assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(prompt), 'the re-verify prompt now points at the passing proof')
689
- }
690
- const gated2 = await E.callTool('vibe_v4_status', {})
691
- assert(existsSync(join(E.projectRoot, 'Verified', '命题', 'p-gate.md')), '★ with a passing Lean artifact the same verdict DOES promote it')
692
- const card = readIf(join(E.projectRoot, 'Verified', '命题', 'p-gate.md'))
693
- assert(/- 形式化: Lean 通过/.test(card), '★ the Verified card records how strong the result is (Lean 通过)')
694
- assert(/Verified\/Lean\/p-gate\.lean/.test(card), 'the card points at the archived proof')
695
- assert(gated2.formal.todo.indexOf('p-gate') === -1, 'the satisfied TODO entry was cleared')
696
- // the blocker escape hatch must also open the gate
697
- await E.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blocked-ok', note: '命题涉及未形式化的分析学,本轮不做' }, E.resAgent(E.childOf('r-1')))
698
- await proposeAndVote(E, 'p-blocked-ok', {
699
- card: { id: 'p-blocked-ok', title: '阻塞放行', statement: '记录阻塞后可定论', prob: 0.8, value: 0.6, motivation: 'm' },
700
- proposal: { summary: '提议验证 p-blocked-ok。', solved: false, propose_verify: 'p-blocked-ok', contextPct: 20 },
701
- verdict: 1,
702
- })
703
- assert(existsSync(join(E.projectRoot, 'Verified', '命题', 'p-blocked-ok.md')), '★ an explicit reasoned blocker also lets the verdict through (decide by difficulty, but decide out loud)')
704
- assert(/- 形式化: 阻塞(/.test(readIf(join(E.projectRoot, 'Verified', '命题', 'p-blocked-ok.md'))), 'the card records the blocker')
705
- assert((await E.callTool('vibe_v4_status', {})).formal.blocked.indexOf('p-blocked-ok') !== -1, 'status lists it as blocked')
706
- // a `require`-mode FALSE verdict must be gated exactly the same way (the gate sits on the choke
707
- // point, not on the 真 branch): assert the tally was recorded BEFORE asserting it was withheld.
708
- await proposeAndVote(E, 'p-gate-false', {
709
- card: { id: 'p-gate-false', title: '假也要门禁', statement: '一个会被一致判假的命题', prob: 0.1, value: 0.6, motivation: 'm' },
710
- proposal: { summary: '提议验证 p-gate-false。', solved: false, propose_verify: 'p-gate-false', contextPct: 20 },
711
- verdict: 0,
712
- })
713
- const gatedF = await E.callTool('vibe_v4_status', {})
714
- assert(!existsSync(join(E.projectRoot, 'Verified', '命题', 'p-gate-false.md')), '★ a unanimous FALSE verdict is gated too')
715
- assert(gatedF.formal.todo.indexOf('p-gate-false') !== -1, 'the false verdict is recorded as 未定论 as well')
716
- assert(!/已验证·假/.test(readIf(join(E.projectRoot, 'Propos', 'r-1', 'p-gate-false.md'))), 'the source card was not rewritten to 已验证·假')
717
-
718
- // ---------- 10. 'encourage' does NOT gate ----------
719
- section("10 'encourage' has no gate (the verdict still closes)")
720
- const F = await establish()
721
- await F.callTool('vibe_v4_set', { formalVerify: 'encourage' })
722
- await proposeAndVote(F, 'p-enc-ok', {
723
- card: { id: 'p-enc-ok', title: '鼓励不门禁', statement: '鼓励模式下无形式化产物也能定论', prob: 0.8, value: 0.6, motivation: 'm' },
724
- proposal: { summary: '提议验证 p-enc-ok。', solved: false, propose_verify: 'p-enc-ok', contextPct: 20 },
725
- verdict: 1,
726
- })
727
- assert(existsSync(join(F.projectRoot, 'Verified', '命题', 'p-enc-ok.md')), "★ 'encourage' imposes NO gate: the verdict closes with no Lean artifact")
728
- assert(/- 形式化: 未尝试/.test(readIf(join(F.projectRoot, 'Verified', '命题', 'p-enc-ok.md'))), 'the card still states the object was never formalized')
729
-
730
- // ---------- 11. reporting ----------
731
- section('11 the host can audit formal strength')
732
- const repF = await F.callTool('vibe_v4_formal_report', {}, F.ROOT)
733
- assert(/## Lean 形式化/.test(repF.formalReport), 'the formal report has a Lean section')
734
- assert(/模式:encourage/.test(repF.formalReport), 'it states the mode')
735
- const repOff = await A.callTool('vibe_v4_formal_report', {}, A.ROOT)
736
- assert(/未启用/.test(repOff.formalReport), 'in off mode the report says the feature is not enabled')
737
- const repE = await E.callTool('vibe_v4_report', {}, E.ROOT)
738
- assert(repE.formal && repE.formal.passed.indexOf('p-gate') !== -1, 'the structured report exposes the Lean-passed objects')
739
- assert(repE.formal.blocked.indexOf('p-blocked-ok') !== -1, 'and the blocked objects')
740
- const reportE = await E.callTool('vibe_v4_formal_report', {}, E.ROOT)
741
- assert(/已通过:.*p-gate/.test(reportE.formalReport), 'the human report lists Lean-passed objects')
742
- assert(/已记录阻塞:.*p-blocked-ok/.test(reportE.formalReport), 'the human report lists blocked objects')
743
- assert(/形式化待办:.*p-gate-false/.test(reportE.formalReport), 'the human report lists the formalization TODO')
744
-
745
- // ===============================================================
746
- // 12. the fidelity rule + the prompt hard requirements (docs §6, §10 items 9/11)
747
- // The v2 lesson (docs §10 item 8) is why every claim here is paired with a BEHAVIOURAL
748
- // assertion in §13/§14: wording alone guards nothing.
749
- // ===============================================================
750
- section('12 the injected text states the fidelity rule and never abbreviates a tool name')
751
- const G = await establish()
752
- await G.callTool('vibe_v4_set', { formalVerify: 'encourage' })
753
- {
754
- const enc = await G.prompts('verify', 'r-1', { target: 'p-text', stage: 'independent' })
755
- assert(/【Lean 形式化验证(鼓励模式)】/.test(enc), 'the encourage voting prompt keeps its header')
756
- assert(/实现难度/.test(enc), 'it still asks for the implementation-difficulty judgement')
757
- assert(/工具:vibe_v4_lean_run(执行\/入队)· vibe_v4_lean_archive(归档)· vibe_v4_lean_lib(查已有可复用库与 jobs)· vibe_v4_lean_read(读归档原文逐字复用)/.test(enc), 'it names all FOUR tools in FULL (the new read-only tool included)')
758
- assert(/归档可复用定义\/引理前先跑通(vibe_v4_lean_archive run=true 或先 vibe_v4_lean_run);跑不通不要入库。/.test(enc), '★ a reusable definition/lemma must be RUN GREEN before it is archived')
759
- assert(/宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"/.test(enc), '★ both unavailable-toolchain paths are written out (missing Lean OR no subprocess service) as explicit blocker reasons')
760
- assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(enc), 'a green Lean run still shrinks the open question to fidelity')
761
- assert(/decision='blocked' 时必须写明 note/.test(enc), 'the encourage opt-out documents the mandatory note')
762
- assert(!/偏离 → 0/.test(enc) && !/发现任何偏离/.test(enc), '★ no "deviation ⇒ 0" instruction anywhere in the encourage block')
763
- await G.callTool('vibe_v4_set', { formalVerify: 'require' })
764
- const req = await G.prompts('verify', 'r-1', { target: 'p-text', stage: 'independent' })
765
- assert(/【Lean 形式化验证(强制模式)】/.test(req), 'the require voting prompt says 强制模式')
766
- assert(/必须产出 Lean 形式化/.test(req) && /必须\*\*给出显式的阻塞原因/.test(req), "'require' states the formalization OR an explicit blocker is mandatory")
767
- assert(/本次裁定不会生效/.test(req) && /进入「形式化待办」/.test(req), 'it warns the verdict is withheld as 未定论 (formal-required)')
768
- assert(!/可以不做/.test(req), "'require' does NOT offer the encourage-mode opt-out")
769
- assert(/归档可复用定义\/引理前先跑通/.test(req) && /宿主没有 Lean 工具链/.test(req), 'the run-before-archive and toolchain rules are in the require text as well')
770
- assert(/LEAN_NOT_FOUND/.test(req) && /NO_SUBPROCESS/.test(req), '★ the require text names BOTH unavailable-toolchain codes (the escape route must be documented in every mode)')
771
- // The sentence is written ONCE and reused by both modes: a second copy would drift (one branch
772
- // kept up to date, the other stale). The anchor `(LEAN_NOT_FOUND)` therefore occurs exactly once
773
- // in the plugin, which is also what the sensitivity probe table assumes.
774
- {
775
- const src = readFileSync(fileURLToPath(PLUGIN), 'utf8')
776
- assert(src.split('(LEAN_NOT_FOUND)').length - 1 === 1, '★ the unavailable-toolchain guidance is written once (not duplicated per mode/branch)')
777
- assert(src.split('(NO_SUBPROCESS)').length - 1 === 1, 'the same single sentence names NO_SUBPROCESS (no per-branch copy)')
778
- }
779
- assert(/\*\*本模式要求\*\*/.test(req), 'the require bullet replaces the encourage opt-out in place')
780
- // normal / heartbeat / post-compact recap all carry the standing work line; the two that ARE a
781
- // reply contract also document the `defect` decision (coreRules is a recap prefix, not a contract)
782
- for (const [label, text] of [['normal', await G.prompts('normal', 'r-1')], ['heartbeat', await G.prompts('heartbeat', 'r-1')], ['coreRules', await G.prompts('coreRules', 'r-1')]]) {
783
- assert(/【顺手形式化(强制)】/.test(text), label + ': the standing formalization line uses the require wording')
784
- assert(/归档前先跑通(vibe_v4_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(text), label + ': ★ it requires a green run before archiving a reusable definition')
785
- if (label !== 'coreRules') assert(/"decision":"used\|blocked\|defect"/.test(text), label + ': the reply contract documents the `defect` decision')
786
- }
787
- assert(/"decision":"used\|blocked\|defect"/.test(req) && /具体偏差/.test(req), '★ the voting reply contract documents decision=defect and its note')
788
- }
789
- {
790
- // ---- the sweep: EVERY agent-facing string must spell the three tools in full (docs §6-1) ----
791
- const scanned = []
792
- const addText = (label, t) => { if (typeof t === 'string' && t) scanned.push({ label, text: t }) }
793
- addText('captured run prompts', G.allPrompts())
794
- for (const which of ['brainstorm', 'normal', 'heartbeat', 'coreRules']) addText(which, await G.prompts(which, 'r-1'))
795
- for (const t of ['p-text', 'p-corpus']) for (const st of ['independent', 'debate']) addText('verify:' + t + ':' + st, await G.prompts('verify', 'r-1', { target: t, stage: st }))
796
- // tool hints are injected text too (docs §6 hard requirement 1 names them explicitly)
797
- const r1G = G.resAgent(G.childOf('r-1'))
798
- const hintGreen = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-hint', content: 'theorem p_hint : 1 = 1 := rfl\n' }, r1G)
799
- assert(hintGreen.ok === true && hintGreen.passed === true, 'precondition: a green file exists for the hint sweep')
800
- const hintRed = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-hint-red', content: 'theorem p_hint_red : 1 = 2 := by sorry\n' }, r1G)
801
- assert(hintRed.ok === true && hintRed.passed === false, 'precondition: a red file exists for the hint sweep')
802
- addText('lean_run hint (green)', (await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-hint.lean' }, r1G)).hint)
803
- addText('lean_run hint (red)', (await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-hint-red.lean' }, r1G)).hint)
804
- addText('lean_lib hint', (await G.callTool('vibe_v4_lean_lib', {}, r1G)).hint)
805
- toolchainAvailable = false
806
- addText('LEAN_NOT_FOUND message', (await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-hint.lean' }, r1G)).message)
807
- toolchainAvailable = true
808
- for (const t of G.toolRegs.filter((x) => /lean/.test(x.name))) addText('tool description ' + t.name, t.description)
809
- // The activity log is agent/host-readable text too (vibe_v4_report exposes `recentActivity`, and
810
- // a host reads it out to the group): a bare abbreviation there names a tool that does not exist,
811
- // for exactly the same reason as in a prompt.
812
- try {
813
- const log = JSON.parse(readIf(join(G.projectRoot, 'State', 'session.json')) || '{}').activityLog || []
814
- for (const e of log) addText('activity log: ' + e.event, String((e && e.detail) || ''))
815
- } catch (e) { /* the log is best-effort */ }
816
- const bare = [/(^|[^a-z_])lean_run/, /(^|[^a-z_])lean_archive/, /(^|[^a-z_])lean_lib/]
817
- const offenders = []
818
- for (const s of scanned) for (const re of bare) if (re.test(s.text)) offenders.push(s.label + ' :: ' + re.source)
819
- assert(offenders.length === 0, '★ no injected text (prompt, tool hint or tool description) uses a bare tool abbreviation (' + offenders.slice(0, 3).join(' | ') + ')')
820
- assert(scanned.length >= 15, 'the sweep really covered the injected-text surface (' + scanned.length + ' texts)')
821
- }
822
- {
823
- // docs §4.1-3 / §6.1: the fidelity branch's "the framework withholds the verdict" clause may only
824
- // appear in `require` (the only mode with a gate). In `encourage` the framework still withdraws
825
- // the proof and records the TODO, but it CANNOT hold the ballot — so the text must not promise
826
- // that, and must instead point at the voter's own abstention.
827
- await G.callTool('vibe_v4_set', { formalVerify: 'require' })
828
- const reqFid = await G.prompts('verify', 'r-1', { target: 'p-hint', stage: 'independent' })
829
- assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(reqFid), 'precondition: p-hint is the passed/fidelity case in this prompt')
830
- assert(/本次裁定\*\*不定论\*\*/.test(reqFid), "'require' fidelity text states the verdict will be withheld (it has a gate)")
831
- await G.callTool('vibe_v4_set', { formalVerify: 'encourage' })
832
- const encFid = await G.prompts('verify', 'r-1', { target: 'p-hint', stage: 'independent' })
833
- assert(!/本次裁定\*\*不定论\*\*/.test(encFid), "★ 'encourage' does NOT promise a hold it cannot enforce (docs §4.1-3)")
834
- assert(/本档没有门禁/.test(encFid) && /弃权值/.test(encFid), "★ instead it says the voter's own abstention is what keeps the ballot from concluding")
835
- assert(/降级为 attempted、删除归档证明、写入形式化待办/.test(encFid), 'the withdrawal the framework CAN enforce is still stated in both modes')
836
- assert(/发现任何偏差,不要投 0/.test(encFid), 'the fidelity rule itself is mode-independent')
837
- await G.callTool('vibe_v4_set', { formalVerify: 'require' })
838
- }
839
-
840
- // ===============================================================
841
- // 13. the `defect` reply channel (docs §4.1 / §10 item 8) — BEHAVIOURAL, not wording
842
- // ===============================================================
843
- section('13 the `defect` reply withdraws a passing proof (spec §4.1)')
844
- await G.callTool('vibe_v4_set', { formalVerify: 'encourage' })
845
- const activityOf = (h) => { try { return JSON.parse(readIf(join(h.projectRoot, 'State', 'session.json')) || '{}').activityLog || [] } catch (e) { return [] } }
846
- const g1 = G.resAgent(G.childOf('r-1'))
847
- {
848
- const arc = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect', content: 'theorem p_defect : 2 + 2 = 4 := by decide\n' }, g1)
849
- assert(arc.passed === true && existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect.lean')), 'precondition: p-defect has a green archived proof')
850
- const w = await workWake(G, 'r-1')
851
- G.fireEnd(w.childId, { summary: '我逐条核对了 Lean 代码,发现偏差。', formal: { target: 'p-defect', decision: 'defect', note: 'Lean 里的条件比命题弱:只证了 n ≥ 1 的情形' }, contextPct: 20 })
852
- await sleep(90)
853
- const st = await G.callTool('vibe_v4_status', {})
854
- const rec = st.formal.objects.find((o) => o.target === 'p-defect')
855
- assert(rec && rec.status === 'attempted', '★ a `formal.decision=defect` reply downgrades the object to attempted (never a refutation)')
856
- assert(rec && rec.proof === '', '★ the archived proof is cleared from the record')
857
- assert(rec && rec.note === 'Lean 里的条件比命题弱:只证了 n ≥ 1 的情形', 'the concrete deviation is stored as the record note')
858
- assert(!existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect.lean')), '★ the archived proof file is DELETED (the formalization is 不合格 — the proposition is NOT false)')
859
- assert(existsSync(join(G.projectRoot, 'Formal', 'p-defect.lean')), 'the WORKING file is kept — the code is not lost, only its "passed" claim')
860
- assert(st.formal.passed.indexOf('p-defect') === -1, 'the object is no longer reported as Lean-passed')
861
- assert(shellCalls.some((c) => /Remove-Item|^rm -f/.test(c) && /p-defect\.lean/.test(c)), 'the withdrawal really went through the platform shell (the fs service has no delete)')
862
- const todo = readIf(join(G.projectRoot, 'Formal', 'TODO.md'))
863
- assert(/p-defect/.test(todo) && /formal-defect/.test(todo), '★ Formal/TODO.md lists the object with the DEFECT reason')
864
- assert(/只证了 n ≥ 1 的情形/.test(todo), '★ the concrete deviation reaches the human-readable TODO')
865
- const idx = readIf(join(G.projectRoot, 'Formal', 'Index.md'))
866
- assert(/\| p-defect \| attempted \|/.test(idx), 'Formal/Index.md downgrades the object to attempted')
867
- assert(/只证了 n ≥ 1 的情形/.test(idx), 'the deviation is the record note in the index')
868
- assert(activityOf(G).some((e) => /忠实性缺陷/.test(e.detail) && /p-defect/.test(e.detail)), '★ the retraction is announced in the activity log')
869
- // the log line is agent/host-readable text, so it is bound by the same §4.1-3 rule as the prompt:
870
- // in `encourage` it may not claim a hold the mode does not have.
871
- {
872
- const line = activityOf(G).filter((e) => /忠实性缺陷/.test(e.detail) && /p-defect[^-]/.test(e.detail)).pop() || { detail: '' }
873
- assert(/本档没有门禁/.test(line.detail) && !/本次裁定\*\*不定论\*\*/.test(line.detail), "★ the encourage-mode activity line does not promise the 不定论 hold that only `require` enforces")
874
- }
875
- }
876
- {
877
- // §4.1 degraded withdrawal: the record must never be the ONLY thing withdrawn. A host whose shell
878
- // "succeeds" without deleting (a stub host / a permissions quirk) must be caught by re-reading the
879
- // file through the fs service, and the archive must then be OVERWRITTEN with a withdrawal notice —
880
- // otherwise the retracted proof keeps sitting at the exact path everyone looks for the proof.
881
- const arcD = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect-degraded', content: 'theorem p_defect_degraded : 5 = 5 := rfl\n' }, g1)
882
- assert(arcD.passed === true && existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect-degraded.lean')), 'precondition: the degraded-path object has a green archived proof')
883
- shellDeletesFiles = false
884
- const wD = await workWake(G, 'r-1')
885
- G.fireEnd(wD.childId, { summary: '核对后发现偏差。', formal: { target: 'p-defect-degraded', decision: 'defect', note: '结论方向相反' }, contextPct: 20 })
886
- await sleep(90)
887
- shellDeletesFiles = true
888
- const body = readIf(join(G.projectRoot, 'Verified', 'Lean', 'p-defect-degraded.lean'))
889
- assert(!/p_defect_degraded/.test(body), '★ a shell that exits 0 without deleting does NOT leave the retracted proof readable — its original text is gone')
890
- assert(/已撤回/.test(body) && /原代码保留在工作文件/.test(body), '★ the archived file was OVERWRITTEN with an explicit withdrawal notice instead (delete → confirm → overwrite)')
891
- const recD = (await G.callTool('vibe_v4_status', {})).formal.objects.find((o) => o.target === 'p-defect-degraded')
892
- assert(recD && recD.status === 'attempted' && recD.proof === '', 'the record is downgraded on the degraded path too')
893
- assert(activityOf(G).some((e) => /覆盖归档证明/.test(e.detail) && /p-defect-degraded/.test(e.detail)), '★ the activity log says WHICH withdrawal path was taken (overwritten, not silently reported as deleted)')
894
- }
895
- {
896
- const arc2 = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect2', content: 'theorem p_defect2 : 3 + 3 = 6 := by decide\n' }, g1)
897
- assert(arc2.passed === true, 'precondition: p-defect2 is Lean-passed')
898
- const w2 = await workWake(G, 'r-2')
899
- G.fireEnd(w2.childId, { summary: '有偏差,但没写清是什么。', formal: { target: 'p-defect2', decision: 'defect' }, contextPct: 20 })
900
- await sleep(90)
901
- const st2 = await G.callTool('vibe_v4_status', {})
902
- const rec2 = st2.formal.objects.find((o) => o.target === 'p-defect2')
903
- assert(rec2 && rec2.status === 'passed' && rec2.proof === 'Verified/Lean/p-defect2.lean', '★ a `defect` reply WITHOUT a note is REJECTED — the record is untouched')
904
- assert(existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect2.lean')), '★ and the archived proof is NOT withdrawn')
905
- assert(activityOf(G).some((e) => /defect/.test(e.detail) && /V4_INVALID_ARGUMENT/.test(e.detail)), '★ the rejection carries the preset error code (V4_INVALID_ARGUMENT), not a silent drop')
906
- assert((await G.callTool('vibe_v4_status', {})).ok === true, 'the refusal did not crash the run')
907
- }
908
- {
909
- const blk = await G.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-defect3', note: '前置知识未形式化,本轮不做' }, g1)
910
- assert(blk.ok === true && blk.status === 'blocked', 'precondition: p-defect3 carries a reasoned blocker record')
911
- const w3 = await workWake(G, 'r-1')
912
- G.fireEnd(w3.childId, { summary: '复核后发现归档的形式化换了对象。', formal: { target: 'p-defect3', decision: 'defect', note: '归档的形式化证的是特例,换了对象' }, contextPct: 20 })
913
- await sleep(90)
914
- const st3 = await G.callTool('vibe_v4_status', {})
915
- const rec3 = st3.formal.objects.find((o) => o.target === 'p-defect3')
916
- assert(rec3 && rec3.status === 'attempted', '★ `defect` downgrades even a `blocked` record (spec §4.1: ALWAYS downgrade)')
917
- assert(st3.formal.blocked.indexOf('p-defect3') === -1, '★ a bad formalization may not stay in the gate-passing `blocked` state')
918
- }
919
-
920
- // ===============================================================
921
- // 14. `require` after a defect: the retraction closes the gate (docs §4.1-3 / §10 item 9)
922
- // ===============================================================
923
- // 13b. a plain `used` judgement must NOT withdraw an ESTABLISHED proof (contract §4): only a
924
- // fidelity defect retracts one. v2 shipped the unconditional downgrade and silently re-closed the gate.
925
- section('13b a \`used\` reply must NOT downgrade an already-passed object')
926
- {
927
- const arcU = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-usedkeep', content: 'theorem p_usedkeep : 2 + 2 = 4 := by decide\n' }, g1)
928
- assert(arcU.passed === true && existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-usedkeep.lean')), 'used-keep: the object starts with a green archived proof')
929
- const wU = await workWake(G, 'r-1')
930
- G.fireEnd(wU.childId, { summary: '这一轮只是又写了一遍草稿。', formal: { target: 'p-usedkeep', decision: 'used', file: 'Formal/p-usedkeep.lean' }, contextPct: 20 })
931
- await sleep(90)
932
- const stU = await G.callTool('vibe_v4_status', {})
933
- const recU = stU.formal.objects.find((o) => o.target === 'p-usedkeep')
934
- assert(!!recU && recU.status === 'passed', '★ a `used` reply does NOT downgrade an already-passed object (got ' + JSON.stringify(recU) + ')')
935
- assert(!!recU && recU.proof === 'Verified/Lean/p-usedkeep.lean', '★ and the proof pointer survives the reply')
936
- assert(existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-usedkeep.lean')), '★ and the archived proof is still on disk')
937
- assert(stU.formal.passed.indexOf('p-usedkeep') !== -1, '★ status still reports it as Lean-passed')
938
- const rerunU = await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-usedkeep.lean', target: 'p-usedkeep' }, g1)
939
- assert(rerunU.ok === true, 'used-keep: the work file runs green')
940
- const recU2 = (await G.callTool('vibe_v4_status', {})).formal.objects.find((o) => o.target === 'p-usedkeep')
941
- assert(!!recU2 && recU2.status === 'passed', '★ a plain re-run does not downgrade a passed record (contract §4)')
942
- }
943
-
944
- section("14 'require' withholds the verdict after a defect, even on a unanimous 1")
945
- const H = await establish()
946
- await H.callTool('vibe_v4_set', { formalVerify: 'require' })
947
- {
948
- const h1 = H.resAgent(H.childOf('r-1'))
949
- const arc = await H.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect-req', content: 'theorem p_defect_req : 4 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' }, h1)
950
- assert(arc.passed === true, 'precondition: p-defect-req is Lean-passed')
951
- const before = await H.prompts('verify', 'r-1', { target: 'p-defect-req', stage: 'independent' })
952
- assert(/不要投 0/.test(before) && /formal:\{decision:'defect'/.test(before), 'the voter is told (before voting) how to report a fidelity defect')
953
- await H.callTool('vibe_v4_record_proposition', { id: 'p-defect-req', title: '缺陷不是证伪', statement: '形式化写窄了不代表命题为假', prob: 0.9, value: 0.6, motivation: 'm' }, h1)
954
- const n0 = H.followups.length
955
- await H.callTool('vibe_v4_message', { to: 'r-1', content: '请处理本轮工作。' })
956
- for (let i = 0; i < 300 && H.followups.length === n0; i++) await sleep(10)
957
- // r-1 reports the defect AND votes 1 in the SAME reply; r-2 votes 1. The framework must NOT take
958
- // the unanimous 1: the formalization was just retracted, so the verdict has to be deferred.
959
- const replyFor = (pt, rId) => (/团队验证/.test(pt)
960
- ? (rId === 'r-1'
961
- ? { vote: { verdict: 1, reason: '我发现形式化写窄了,但独立看命题仍为真' }, formal: { target: 'p-defect-req', decision: 'defect', note: 'Lean 只证了 x=1 的特例,命题要求所有整数 x' } }
962
- : { vote: { verdict: 1, reason: '独立复核为真' } })
963
- : { summary: '提议验证 p-defect-req。', solved: false, propose_verify: 'p-defect-req', contextPct: 20 })
964
- for (let i = 0; i < 4; i++) { await drive(H, replyFor, verifySettled(H)); if (await verifySettled(H)()) break }
965
- const st = await H.callTool('vibe_v4_status', {})
966
- assert(st.verifyInProgress === false && st.pendingVerify === null, 'the verification actually settled (so the next assertion is falsifiable)')
967
- assert(!existsSync(join(H.projectRoot, 'Verified', '命题', 'p-defect-req.md')), '★ a defect in `require` mode writes NO Verified card, even with a unanimous 1')
968
- assert(st.formal.todo.indexOf('p-defect-req') !== -1, '★ the object stays 未定论 on the formalization TODO')
969
- const rec = st.formal.objects.find((o) => o.target === 'p-defect-req')
970
- assert(rec && rec.status === 'attempted' && rec.proof === '', 'the formal record is the retracted one (attempted, no proof)')
971
- assert(rec && /只证了 x=1 的特例/.test(rec.note || ''), "the record's note is the reported deviation")
972
- assert(!existsSync(join(H.projectRoot, 'Verified', 'Lean', 'p-defect-req.lean')), '★ the archived proof was withdrawn as part of the retraction')
973
- assert(!/已验证·真/.test(readIf(join(H.projectRoot, 'Propos', 'r-1', 'p-defect-req.md'))), 'the source card was NOT rewritten to 已验证·真 (the conclusion was withheld, not taken)')
974
- assert(!/已验证·真/.test(readIf(join(H.projectRoot, 'Shared', 'debates', 'p-defect-req.md'))), 'the debate record does not claim a 真 conclusion')
975
- const todo = readIf(join(H.projectRoot, 'Formal', 'TODO.md'))
976
- assert(/p-defect-req/.test(todo) && /formal-defect/.test(todo), '★ the TODO keeps the DEFECT reason, not merely formal-required')
977
- assert(/只证了 x=1 的特例/.test(todo), 'the concrete deviation is what a human reads in the TODO')
978
- }
979
-
980
- // ===============================================================
981
- // 15. the shipped Lean prompt corpus (docs §10 item 10) — a HUMAN must be able to re-read the
982
- // exact text, not just the assertions about it.
983
- // ===============================================================
984
- section('15 the captured Lean prompt corpus is written for human review')
985
- const K = await establish()
986
- {
987
- const vibe = K.vibeRoot
988
- // Normalise BOTH slash forms, and the VibeMath root FIRST: it sits INSIDE the workspace, so
989
- // replacing the workspace first would leave `<WS>/VibeMath` instead of `<VIBEMATH>`.
990
- const scrub = (s) => String(s == null ? '' : s)
991
- .split(vibe).join('<VIBEMATH>').split(vibe.replace(/\\/g, '/')).join('<VIBEMATH>')
992
- .split(K.WS).join('<WS>').split(K.WS.replace(/\\/g, '/')).join('<WS>')
993
- // `propose_task` mints `t-<8 hex>` from Math.random (vibe-math-v4.js:1317 + shortId :2582).
994
- // The task-frame entry embeds that id, so without this the SHIPPED corpus changed on every
995
- // run (AUDIT-CHECKLIST §2.4: two consecutive runs must hash identically) and `git status`
996
- // always showed prompt-corpus-v4/* as modified. Normalise it like the machine paths.
997
- .replace(/\bt-[0-9a-f]{8}\b/g, '<TASKID>')
998
- const corpus = []
999
- const add = (kind, label, prompt) => corpus.push({ kind, label, prompt: scrub(prompt) })
1000
- // (a) off: a TRUE no-op must be visible in the corpus, not merely asserted
1001
- add('verify', 'off/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'independent' }))
1002
- add('work', 'off/normal', await K.prompts('normal', 'r-1'))
1003
- // (b) encourage
1004
- await K.callTool('vibe_v4_set', { formalVerify: 'encourage' })
1005
- add('verify', 'encourage/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'independent' }))
1006
- const encNormal = await K.prompts('normal', 'r-1')
1007
- add('work', 'encourage/normal', encNormal)
1008
- add('work', 'encourage/heartbeat', await K.prompts('heartbeat', 'r-1'))
1009
- add('work', 'encourage/coreRules', await K.prompts('coreRules', 'r-1'))
1010
- // (c) require
1011
- await K.callTool('vibe_v4_set', { formalVerify: 'require' })
1012
- add('verify', 'require/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'independent' }))
1013
- add('verify', 'require/verify/debate', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'debate' }))
1014
- add('work', 'require/normal', await K.prompts('normal', 'r-1'))
1015
- // (d) a PASSED object: the review subject has changed to fidelity
1016
- const k1 = K.resAgent(K.childOf('r-1'))
1017
- await K.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-corpus-passed', content: 'theorem p_corpus_passed : 1 + 1 = 2 := by decide\n' }, k1)
1018
- const fid = await K.prompts('verify', 'r-1', { target: 'p-corpus-passed', stage: 'independent' })
1019
- add('verify', 'passed/fidelity', fid)
1020
- // (d2) the SAME passed object in `encourage`: the promise must shrink to what that mode enforces
1021
- // (docs §4.1-3: no gate there, so no claim that the framework withholds the verdict).
1022
- await K.callTool('vibe_v4_set', { formalVerify: 'encourage' })
1023
- const fidEnc = await K.prompts('verify', 'r-1', { target: 'p-corpus-passed', stage: 'independent' })
1024
- add('verify', 'passed/fidelity (encourage)', fidEnc)
1025
- await K.callTool('vibe_v4_set', { formalVerify: 'require' })
1026
- // (e) a BLOCKED object
1027
- await K.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-corpus-blocked', note: '需要未形式化的解析数论框架' }, k1)
1028
- add('verify', 'blocked/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus-blocked', stage: 'independent' }))
1029
- // (f) the `formal` reply contract line itself (the field whose parsing §13/§14 prove)
1030
- const contractLine = (t) => { const m = String(t).match(/"formal":\{[^\n]*\}\}/); return m ? m[0] : '' }
1031
- add('contract', 'formal reply contract (voting prompt)', contractLine(fid))
1032
- add('contract', 'formal reply contract (work prompt)', contractLine(encNormal))
1033
- // (g) a REAL delivered work wake (proves the builder is the one actually used to address a resident)
1034
- const w = await workWake(K, 'r-2')
1035
- add('work', 'require/real work wake', promptOf(w))
1036
- K.fireEnd(w.childId, { summary: '继续推进。', solved: false, contextPct: 20 })
1037
- await sleep(40)
1038
- // (h) the tool hints are injected text too (docs §6 hard requirement 1)
1039
- add('hint', 'lean_run hint (green)', (await K.callTool('vibe_v4_lean_run', { file: 'Formal/p-corpus-passed.lean' }, k1)).hint)
1040
- add('hint', 'lean_lib hint', (await K.callTool('vibe_v4_lean_lib', {}, k1)).hint)
1041
- // (i) ── THE NON-FORMAL SURFACES (audit M6/F15) ─────────────────────────────────────────────
1042
- // The corpus used to cover ONLY Lean/formal variants, so the meeting prompt — this preset's ONLY
1043
- // group-chat / task-allocation / stop-vote carrier — and every message frame had NO reviewable
1044
- // artefact at all: a defect in `meetingPrompt` (e.g. its stop-vote contract) could not be read by
1045
- // a human reviewer. These entries close that gap. `st` is passed explicitly so the capture does
1046
- // not depend on the scheduler's state, and both the meeting's two perspectives (first speaker with
1047
- // no prior speech / a later speaker seeing a relayed contribution) are captured.
1048
- {
1049
- const st = {
1050
- id: 'mt-corpus', agenda: '分工与是否需要验证', type: 'general', targetId: null, round: 0, asked: [], order: ['r-1', 'r-2'],
1051
- inputs: { 'r-1': { input: '我建议先验证 p-corpus,并认领引理 A 的整理。', voteSolved: false, propose_verify: 'p-corpus', propose_task: '整理引理 A', task_desc: '把已有引理写成可复用形式', claim_task: null } },
1052
- transcript: [], at: Date.now(), lastInputAt: Date.now(),
1053
- }
1054
- add('meeting', 'meeting/first speaker (no prior speech)', await K.prompts('meeting', 'r-2', { meeting: { agenda: st.agenda, type: st.type, inputs: {}, order: st.order } }))
1055
- add('meeting', 'meeting/later speaker (others relayed)', await K.prompts('meeting', 'r-2', { meeting: st }))
1056
- }
1057
- // (i) ── the private-message frame ──────────────────────────────────────────────────────────────
1058
- // A resident reads a message in TWO shapes, and both must be reviewable:
1059
- // · `[MESSAGE from <sender>] <content>` — the frame `deliverNextMailbox` prepends when it wakes an
1060
- // idle recipient with a QUEUED message (captured here from a REAL delivery, not re-typed);
1061
- // · `[<sender>] <content>` — the inbox line every prompt's "New items:" block renders.
1062
- // r-1 is put in flight first (a message to an IDLE resident is delivered immediately as its own
1063
- // wake instead of queueing), so the second message is queued and the prompt shows the inbox line.
1064
- {
1065
- const before = K.followups.length
1066
- await K.callTool('vibe_v4_message', { to: 'r-1', content: '先做一轮常规工作。' })
1067
- for (let i = 0; i < 200 && K.followups.length === before; i++) await sleep(10)
1068
- const inFlight = K.followups[K.followups.length - 1]
1069
- assert(!!inFlight, 'corpus: r-1 is in flight so the next message is QUEUED (mailbox frame prerequisite)')
1070
- await K.callTool('vibe_v4_message', { to: 'r-1', content: '请优先核对引理 B 的假设条件。' })
1071
- add('work', 'normal + queued inbox line ([<sender>] content)', await K.prompts('normal', 'r-1'))
1072
- K.fireEnd(inFlight.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1073
- await sleep(60)
1074
- }
1075
- // the REAL delivery frames. A message that arrives while its recipient is idle is delivered as a
1076
- // wake whose body is `<ordinary round>\n\n[MESSAGE from <sender>]\n<content>`, and a message that
1077
- // was relayed by a teammate (`relayToGroup` posts it with the sender's own id and the `[群聊]`
1078
- // prefix — the exact same `postMessage` path the human messenger uses) renders as `[群聊] …`.
1079
- // Each frame is captured from a REAL delivery on its own host, so the corpus text is generated by
1080
- // the code under review rather than re-typed here.
1081
- const deliveredFrame = async (content, sender) => {
1082
- const L = await establish()
1083
- await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 }) // keep the heartbeat out of the way
1084
- const before = L.followups.length
1085
- await (sender === 'facilitator'
1086
- ? L.callTool('vibe_v4_message', { to: 'r-1', content })
1087
- : L.callTool('vibe_v4_send_message', { to: 'r-1', content }, L.resAgent(L.childOf(sender))))
1088
- let hit = null
1089
- for (let i = 0; i < 300 && !hit; i++) { hit = L.followups.slice(before).find((f) => /\[(NEW )?MESSAGE from /.test(promptOf(f))) || null; if (!hit) await sleep(10) }
1090
- assert(!!hit, 'corpus: a real delivery wake carrying "' + content.slice(0, 16) + '…" was produced (saw ' + L.followups.slice(before).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1091
- if (hit) L.fireEnd(hit.childId, { summary: '收到。', solved: false, contextPct: 20 })
1092
- await sleep(30)
1093
- return hit ? promptOf(hit) : ''
1094
- }
1095
- add('work', 'normal + delivered message frame ([NEW MESSAGE from ...])', await deliveredFrame('请优先核对引理 B 的假设条件。', 'facilitator'))
1096
- // the group-chat frame: the recipient's inbox line for a message RELAYED by a teammate
1097
- // (`relayToGroup` pushes `[群聊] <text>` into every other resident's mailbox with the speaker's own
1098
- // id, and the next delivery renders `[<sender>] [群聊] <text>` — the same `postMessage` path, run
1099
- // with the sender the resident actually sees). Deterministic on the same host: the speaker's own
1100
- // end handler is what triggers the relay, and reading the recipient's next prompt shows it.
1101
- {
1102
- const L = await establish()
1103
- await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
1104
- const before = L.followups.length
1105
- await L.callTool('vibe_v4_message', { to: 'r-2', content: '先做一轮常规工作。' })
1106
- for (let i = 0; i < 300 && L.followups.length === before; i++) await sleep(10)
1107
- const speaker = L.followups[L.followups.length - 1]
1108
- assert(!!speaker, 'corpus: the relaying resident is in flight')
1109
- const mark = L.followups.length
1110
- L.fireEnd(speaker.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1111
- let relay = null
1112
- for (let i = 0; i < 300 && !relay; i++) { relay = L.followups.slice(mark).find((f) => /群聊/.test(promptOf(f))) || null; if (!relay) await sleep(10) }
1113
- assert(!!relay, 'corpus: the relayed group-chat message was really delivered (saw ' + L.followups.slice(mark).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1114
- add('work', 'normal + group-chat frame ([群聊])', relay ? promptOf(relay) : '')
1115
- if (relay) L.fireEnd(relay.childId, { summary: '收到。', solved: false, contextPct: 20 })
1116
- await sleep(30)
1117
- }
1118
- // the task-claim frame. The task is claimed while its claimer is idle, so the claim wake carries
1119
- // `[YOU CLAIMED TASK …]`; the corpus entry is the ordinary round plus that exact frame.
1120
- {
1121
- const pt = await K.callTool('vibe_v4_propose_task', { title: '核对引理 B 的假设', description: '确认 n≥1 是否必要' })
1122
- assert(pt.ok === true && !!pt.id, 'corpus: a task was proposed for the task-frame entry')
1123
- const claimed = await K.callTool('vibe_v4_claim_task', { id: pt.id }, k1)
1124
- add('work', 'task frame ([YOU CLAIMED TASK ...])', claimed.ok === true ? (await K.prompts('normal', 'r-1')) + '\n\n[YOU CLAIMED TASK ' + pt.id + '] 核对引理 B 的假设 — 确认 n≥1 是否必要' : '')
1125
- }
1126
-
1127
- // The corpus is REGENERATED on every run. Dumping it while V4_PLUGIN points at a MUTATED copy
1128
- // would let a sensitivity probe overwrite the SHIPPED corpus with mutated text (the probe's job
1129
- // is to run this suite against a broken plugin), so the dump is skipped then — unless the probe
1130
- // explicitly redirects it with V4_CORPUS_DIR. The IN-MEMORY corpus assertions below still run, so
1131
- // nothing is weakened.
1132
- const writeCorpus = !process.env.V4_PLUGIN || !!process.env.V4_CORPUS_DIR
1133
- if (writeCorpus) {
1134
- mkdirSync(CORPUS_DIR, { recursive: true })
1135
- writeFileSync(join(CORPUS_DIR, 'formal-verify-v4.json'), JSON.stringify({ entries: corpus }, null, 2), 'utf8')
1136
- const md = ['# V4 交互语料(prompt corpus)', '',
1137
- '> 由 `formal-verify-v4.test.mjs` 落盘:常驻**真正会读到**的提示词原文',
1138
- '> (`vibe_v4_prompts` 的只读回显 + 一条真实投递的工作轮 + 工具 `hint`)。',
1139
- '> 工作区路径归一化为 `<WS>`,VibeMath 根归一化为 `<VIBEMATH>`,随机任务号归一化为',
1140
- '> `<TASKID>`(`propose_task` 的 `t-<8 位 hex>` 来自 `Math.random`):确定、可 diff、不含任何本机路径或随机数。', '',
1141
- '> 覆盖:`off`(无 Lean 文本)、`encourage`、**`require`**、对象 `passed` 后的**忠实性分支**',
1142
- '> (`encourage` / `require` 两种措辞各一份:只有 `require` 会声称"不定论")、',
1143
- '> `blocked` 分支、平时工作轮的「顺手形式化」,以及回执契约里的 `formal` 字段;',
1144
- '> **以及非形式化的交互面**:会议提示(首位发言者 / 已看到他人发言者各一份,含停止表决契约)、',
1145
- '> 私信框头 `[MESSAGE from …]`、群聊框头 `[群聊]`、认领任务框头 `[YOU CLAIMED TASK …]`。', '']
1146
- for (let i = 0; i < corpus.length; i++) {
1147
- const c = corpus[i]
1148
- md.push('## [' + i + '] ' + c.kind + ' · ' + c.label)
1149
- md.push('')
1150
- md.push('```text')
1151
- md.push(c.prompt)
1152
- md.push('```')
1153
- md.push('')
1154
- }
1155
- writeFileSync(join(CORPUS_DIR, 'formal-verify-v4.md'), md.join('\n'), 'utf8')
1156
- assert(existsSync(join(CORPUS_DIR, 'formal-verify-v4.json')) && existsSync(join(CORPUS_DIR, 'formal-verify-v4.md')), 'the prompt corpus was written (JSON + Markdown)')
1157
- }
1158
- assert(corpus.length >= 14, 'the corpus covers the whole Lean prompt surface (' + corpus.length + ' prompts)')
1159
- const byLabel = (l) => corpus.find((c) => c.label === l)
1160
- assert(!!byLabel('off/verify') && !/Lean/.test(byLabel('off/verify').prompt) && !/Lean/.test(byLabel('off/normal').prompt), '★ the corpus keeps the off-mode entries and they contain NO Lean text')
1161
- assert(/【Lean 形式化验证(鼓励模式)】/.test(byLabel('encourage/verify').prompt), 'the corpus carries the encourage voting prompt')
1162
- assert(/【Lean 形式化验证(强制模式)】/.test(byLabel('require/verify').prompt), '★ the corpus carries the REQUIRE voting prompt')
1163
- assert(/不要投 0/.test(byLabel('passed/fidelity').prompt), '★ the corpus carries the passed/fidelity branch')
1164
- assert(/本次裁定\*\*不定论\*\*/.test(byLabel('passed/fidelity').prompt), 'the require fidelity entry keeps the hold it really enforces')
1165
- assert(!!byLabel('passed/fidelity (encourage)') && !/本次裁定\*\*不定论\*\*/.test(byLabel('passed/fidelity (encourage)').prompt)
1166
- && /本档没有门禁/.test(byLabel('passed/fidelity (encourage)').prompt), '★ the encourage fidelity entry does NOT promise the hold only require has (docs §4.1-3)')
1167
- assert(/【顺手形式化(强制)】/.test(byLabel('require/normal').prompt), 'the corpus carries the ordinary work-round line')
1168
- assert(/"decision":"used\|blocked\|defect"/.test(byLabel('formal reply contract (voting prompt)').prompt), '★ the corpus carries the `formal` reply contract line with decision=defect')
1169
- assert(/【顺手形式化/.test(byLabel('require/real work wake').prompt), 'the corpus also keeps a prompt the framework REALLY delivered')
1170
- // ---- the non-formal interaction surfaces (audit M6 / F15) --------------------------------
1171
- // The corpus used to be Lean-only, so `meetingPrompt` (the ONLY group-chat / allocation /
1172
- // stop-vote carrier this preset has) had no reviewable artefact. These assertions pin both the
1173
- // coverage and the CONTENT of the stop-vote contract that `finalizeMeeting` now enforces.
1174
- {
1175
- const first = byLabel('meeting/first speaker (no prior speech)')
1176
- const later = byLabel('meeting/later speaker (others relayed)')
1177
- assert(!!first && !!later, '★ the corpus carries the MEETING prompt (two perspectives: no prior speech / others already relayed)')
1178
- assert(/meeting is in progress/.test(first.prompt) && /meeting is in progress/.test(later.prompt), 'the meeting entries are built by the real meeting prompt builder')
1179
- assert(/目前还没人发言/.test(first.prompt), 'the first-speaker entry shows the "nobody has spoken yet" branch')
1180
- assert(/已有发言(他人 input,已转发给你)/.test(later.prompt) && /我建议先验证 p-corpus/.test(later.prompt), '★ the later-speaker entry carries a teammate contribution VERBATIM (the group-chat relay is reviewable)')
1181
- assert(/\[r-1\]/.test(later.prompt) && !/\[r-2\]/.test(later.prompt), 'the relayed-speech list excludes the reader and names the real sender id')
1182
- assert(/voteSolved/.test(first.prompt) && /停止表决必须是绝对票/.test(first.prompt), '★ the meeting prompt states the stop-vote contract the code enforces (every speaker must answer true)')
1183
- assert(!!byLabel('normal + queued inbox line ([<sender>] content)') && /\[facilitator(框架\/人类信使/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt) && /请优先核对引理 B 的假设条件。/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the corpus carries the inbox line that names the real sender of a queued message')
1184
- assert(/\[(NEW )?MESSAGE from facilitator\]/.test(byLabel('normal + delivered message frame ([NEW MESSAGE from ...])').prompt), '★ the corpus carries the [NEW MESSAGE from …] delivery frame, captured from a REAL delivery')
1185
- assert(/\[群聊\] 我对引理 B 的假设有异议/.test(byLabel('normal + group-chat frame ([群聊])').prompt), '★ the corpus carries the group-chat frame relayed by a teammate (and the recipient is not the speaker)')
1186
- assert(/facilitator(框架\/人类信使,不是常驻成员,不要向它回信/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the frame DEFINES `facilitator` on the very wake that shows it, so it is not an unaddressable stranger')
1187
- }
1188
- const joined = corpus.map((c) => c.prompt).join('\n')
1189
- assert(joined.indexOf(K.WS) === -1 && joined.indexOf(K.WS.replace(/\\/g, '/')) === -1 && joined.indexOf(vibe) === -1 && joined.indexOf(vibe.replace(/\\/g, '/')) === -1, '★ every captured prompt normalises <WS> and <VIBEMATH> (diffable, no machine paths)')
1190
- // The random `t-<hex8>` must be normalised too, and NON-vacuously: the task-frame entry has to
1191
- // CARRY the normalised token (a scrub that quietly blanked the whole frame would also pass the
1192
- // absence check below).
1193
- assert(/\[YOU CLAIMED TASK <TASKID>\]/.test(byLabel('task frame ([YOU CLAIMED TASK ...])').prompt), '★ the task-frame corpus entry really carries the normalised `<TASKID>` token')
1194
- assert(!/\bt-[0-9a-f]{8}\b/.test(joined), '★ no captured prompt leaks a random `t-<hex8>` id (the corpus is byte-stable across runs)')
1195
- assert(!/\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(joined), 'no captured prompt contains placeholder garbage')
1196
- assert(corpus.every((c) => c.prompt && c.prompt.length > 20), 'every corpus entry carries real prompt text')
1197
- }
1198
-
1199
- // ===============================================================
1200
- // 16. a FRESH run must not inherit the previous run's formal records (docs §31.6): object ids are
1201
- // reused (p-*, r-1..), so the in-memory reset in `start()` has to REACH DISK even when the mode
1202
- // has been switched back to `off` — otherwise a later `resume` restores a stale `passed` and
1203
- // re-opens the require gate for an object of the new run. This is the exact hazard that makes
1204
- // the `off`-mode persistence guard ("do not create State/formal.json" ) non-trivial.
1205
- // ===============================================================
1206
- section('16 a fresh run clears the persisted formal records (even in off mode)')
1207
- {
1208
- const stale = readIf(join(D.projectRoot, 'State', 'formal.json'))
1209
- assert(/"p-proof"/.test(stale), 'precondition: the previous run left formal records on disk')
1210
- await D.callTool('vibe_v4_set', { formalVerify: 'off' })
1211
- const beforeSpawns = D.spawns.length
1212
- const st = await D.callTool('vibe_v4_start', { problem: '新一轮:形式化清白起点', residentCount: 1 })
1213
- assert(st.ok === true && D.spawns.length > beforeSpawns, 'the fresh run actually started (so the clean-slate assertion is falsifiable)')
1214
- const now = readIf(join(D.projectRoot, 'State', 'formal.json'))
1215
- assert(!/"p-proof"/.test(now) && !/"passed"/.test(now), '★ a fresh run clears the persisted formal records on disk, even in off mode (no stale `passed` can open the new run’s gate)')
1216
- }
1217
-
1218
- // ===============================================================
1219
- // 17. leanAsync (default true): the tool ENQUEUES and returns immediately; ONLY a settled, green
1220
- // job whose file still matches the enqueued content may set `passed` / mint Verified/Lean/.
1221
- // ===============================================================
1222
- const settleJob = async (h, jobId, tries = 80) => {
1223
- for (let i = 0; i < tries; i++) {
1224
- const r = await h.callTool('vibe_v4_lean_job', { jobId, waitMs: 200 })
1225
- if (['settled', 'failed', 'timeout', 'interrupted'].indexOf(r.state) !== -1) return r
1226
- await sleep(30)
1227
- }
1228
- return await h.callTool('vibe_v4_lean_job', { jobId })
1229
- }
1230
- /** The mock's `ensureDirs` records a shell mkdir but does not materialise directories, so a test
1231
- * that seeds a project file must create its parent itself. */
1232
- const writeIn = (h, rel, body) => { const p = join(h.WS, 'VibeMath', ...rel.split('/')); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, body); return p }
1233
- section('17 leanAsync=true enqueues immediately; passed only after a settled green job')
1234
- {
1235
- const N = await establish()
1236
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 60 })
1237
- writeIn(N, 'Projects/default/Formal/a-async.lean', 'theorem a_async : 1 + 1 = 2 := by decide\n')
1238
- const runsBefore = leanRuns.length
1239
- const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async', content: 'theorem p_async : 1 + 1 = 2 := by decide\n' }, N.resAgent(N.childOf('r-1')))
1240
- assert(arc.ok === true && arc.async && arc.async.state === 'queued' && /^p-async-[0-9a-f]{12}$/.test(arc.jobId), '★ lean_archive kind=proof ENQUEUES and returns {async:{jobId,state:queued}} immediately (jobId=' + arc.jobId + ')')
1241
- assert(arc.passed === undefined && !existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async.lean')), '★ NO proof is minted at enqueue time (nothing may become passed before the compile lands)')
1242
- assert(leanRuns.length === runsBefore, '★ the compiler has NOT been spawned yet by the enqueueing call (it is genuinely non-blocking)')
1243
- const st0 = await N.callTool('vibe_v4_status', {})
1244
- assert(!st0.formal.passed.includes('p-async'), 'and the object is not reported passed while queued')
1245
- const done = await settleJob(N, arc.jobId)
1246
- assert(done.state === 'settled' && done.exitCode === 0 && done.archive === 'Verified/Lean/p-async.lean', '★ the job settles green and reports its archive path (' + done.state + '/' + done.exitCode + ')')
1247
- assert(existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async.lean')), '★ the archived proof exists only AFTER the settle')
1248
- const st1 = await N.callTool('vibe_v4_status', {})
1249
- assert(st1.formal.passed.includes('p-async'), '★ the object is now passed')
1250
- const list = await N.callTool('vibe_v4_lean_job', {})
1251
- assert(list.ok === true && list.jobs.some(j => j.jobId === arc.jobId && j.receipt === 'Formal/Jobs/' + arc.jobId + '.json' && j.archive === 'Verified/Lean/p-async.lean'), '★ lean_job (no jobId) lists the job with its receipt + archive paths')
1252
- const nextWake = await N.callTool('vibe_v4_message', { to: 'r-1', content: '继续。' })
1253
- const promptsText = N.followups.map(promptOf).join('\n')
1254
- assert(/【形式化结果】/.test(promptsText), '★ the settled result is announced once in the next prompt (【形式化结果】)')
1255
- }
1256
-
1257
- // ===============================================================
1258
- // 18. The failure + timeout branches stay `attempted`.
1259
- // ===============================================================
1260
- section('18 async failure and timeout never set passed')
1261
- {
1262
- const N = await establish()
1263
- await N.callTool('vibe_v4_set', { formalVerify: 'require', leanAsync: true, activityTimeoutMs: 60 })
1264
- const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async-fail', content: 'theorem p_async_fail : False := by sorry\n' })
1265
- const done = await settleJob(N, arc.jobId)
1266
- assert(done.state === 'failed' && done.exitCode === 1, '★ a red compile settles as failed (not passed)')
1267
- assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async-fail.lean')), '★ no proof is archived for a red run')
1268
- const st = await N.callTool('vibe_v4_status', {})
1269
- const rec = (st.formal.objects || []).find(o => o.target === 'p-async-fail') || {}
1270
- assert(!st.formal.passed.includes('p-async-fail') && rec.status === 'attempted', '★ a failed async proof stays attempted and never satisfies the require gate (record status=' + rec.status + ')')
1271
- // a HANG file: only the framework's own timeout can end it
1272
- await N.callTool('vibe_v4_set', { leanTimeoutMs: 1000 })
1273
- writeIn(N, 'Projects/default/Formal/a-hang.lean', '-- HANG\ntheorem a_hang : True := by trivial\n')
1274
- const hang = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async-hang', content: '-- HANG\ntheorem p_async_hang : True := by trivial\n' })
1275
- const hd = await settleJob(N, hang.jobId, 120)
1276
- assert(hd.state === 'timeout' && hd.timedOut === true, '★ a hanging compile is timed out by the job budget (leanTimeoutMs), not left running')
1277
- assert(terminated.length >= 1, '★ the timeout path really called handle.terminate() (an active kill, not just a report)')
1278
- assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async-hang.lean')), 'a timed-out proof is never archived')
1279
- }
1280
-
1281
- // ===============================================================
1282
- // 19. dispose/abort terminates the in-flight compile and marks it interrupted.
1283
- // ===============================================================
1284
- section('19 dispose marks the in-flight job interrupted (never passed)')
1285
- {
1286
- const N = await establish()
1287
- // The heartbeat is the queue driver, so it must be SHORT before the enqueue arms it (a long
1288
- // heartbeat armed at enqueue time would leave the job queued forever — which is the point of §2.2).
1289
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 40, leanTimeoutMs: 600000 })
1290
- const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-dispose', content: '-- HANG\ntheorem p_dispose : True := by trivial\n' })
1291
- for (let i = 0; i < 100; i++) { const r = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId }); if (r.state === 'running') break; await sleep(25) }
1292
- const before = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId })
1293
- assert(before.state === 'running', 'precondition: the job is genuinely in flight (state=' + before.state + ')')
1294
- await N.callTool('vibe_v4_abort', {})
1295
- const after = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId })
1296
- assert(after.state === 'interrupted' && after.interrupted === true, '★ abort/dispose marks the in-flight job interrupted (state=' + after.state + ')')
1297
- assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-dispose.lean')), '★ an interrupted job never mints a proof')
1298
- const st = await N.callTool('vibe_v4_status', {})
1299
- assert(!st.formal.passed.includes('p-dispose'), 'and the object is not passed')
1300
- }
1301
-
1302
- // ===============================================================
1303
- // 20. crash/restart recovery reads Formal/Jobs/*.json and never silently verifies.
1304
- // ===============================================================
1305
- section('20 crash recovery: queued re-driven, changed content never verified, settled completed')
1306
- {
1307
- const N = await establish()
1308
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 600000 })
1309
- const jobsDir = join(N.projectRoot, 'Formal', 'Jobs')
1310
- mkdirSync(jobsDir, { recursive: true })
1311
- const body = 'theorem p_rec : 1 + 1 = 2 := by decide\n'
1312
- const file = writeIn(N, 'Projects/default/Formal/p-rec.lean', body)
1313
- const crypto = await import('node:crypto')
1314
- const sha = crypto.createHash('sha256').update(body.replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '').replace(/\n+$/, '') + '\n', 'utf8').digest('hex')
1315
- // (a) `running` with UNCHANGED content → re-queued (never verified)
1316
- writeFileSync(join(jobsDir, 'p-rec-aaaaaaaaaaaa.json'), JSON.stringify({ jobId: 'p-rec-aaaaaaaaaaaa', kind: 'proof', key: 'p-rec', target: 'p-rec', rel: 'Formal/p-rec.lean', abs: file, sha256: sha, content: body, state: 'running', attempts: 1, enqueuedAt: 1, startedAt: 2 }))
1317
- // (b) `running` with CHANGED content → interrupted + attempted, NO auto re-drive
1318
- const stalePath = writeIn(N, 'Projects/default/Formal/p-stale.lean', 'theorem p_stale : True := by trivial\n')
1319
- writeFileSync(join(jobsDir, 'p-stale-bbbbbbbbbbbb.json'), JSON.stringify({ jobId: 'p-stale-bbbbbbbbbbbb', kind: 'proof', key: 'p-stale', target: 'p-stale', rel: 'Formal/p-stale.lean', abs: stalePath, sha256: sha, content: body, state: 'running', attempts: 1, enqueuedAt: 1, startedAt: 2 }))
1320
- // A FRESH run (start) performs the safe half of the recovery table: `queued` jobs are re-driven,
1321
- // `running` jobs are re-driven only while the content still matches, and a stale `settled` record
1322
- // is NEVER adopted (that is exactly what the clean formal slate exists to prevent).
1323
- writeFileSync(join(jobsDir, 'p-done-cccccccccccc.json'), JSON.stringify({ jobId: 'p-done-cccccccccccc', kind: 'proof', key: 'p-done', target: 'p-done', rel: 'Formal/p-done.lean', abs: file, sha256: sha, content: body, state: 'settled', exitCode: 0, verifiedRel: 'Verified/Lean/p-done.lean', attempts: 1, enqueuedAt: 1, startedAt: 2, settledAt: 3 }))
1324
- await N.callTool('vibe_v4_start', { problem: '恢复扫描', residentCount: 1 })
1325
- const a = await N.callTool('vibe_v4_lean_job', { jobId: 'p-rec-aaaaaaaaaaaa' })
1326
- const b = await N.callTool('vibe_v4_lean_job', { jobId: 'p-stale-bbbbbbbbbbbb' })
1327
- assert(a.state === 'queued' || a.state === 'running' || a.state === 'settled', '★ recovery re-drives a `running` job whose content still matches (state=' + a.state + ')')
1328
- assert(b.state === 'interrupted', '★ a `running` job whose file CHANGED is interrupted, not verified (state=' + b.state + ')')
1329
- const st = await N.callTool('vibe_v4_status', {})
1330
- assert(!st.formal.passed.includes('p-stale'), '★ the changed-content job never reaches `passed`')
1331
- assert(!st.formal.passed.includes('p-done') && !existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-done.lean')), '★ a fresh run does NOT adopt a previous run’s `settled` record (no stale passed, no minted proof)')
1332
- assert(N.followups.map(promptOf).join('\n').length >= 0, 'recovery ran without throwing')
1333
- }
1334
-
1335
- // ===============================================================
1336
- // 21. lean_read: verbatim library access, confined to Formal/{Lib,Proved}, 64KB cap.
1337
- // ===============================================================
1338
- section('21 lean_read returns the archived text verbatim and refuses escapes')
1339
- {
1340
- const N = await establish()
1341
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false }) // sync: the file must be on disk for the read
1342
- const defBody = 'def ZMod9 := Fin 9\n\ntheorem zmod9_card : Fintype.card ZMod9 = 9 := by decide\n'
1343
- const arch = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'ZMod9', content: defBody }, N.resAgent(N.childOf('r-1')))
1344
- assert(arch.ok === true, 'precondition: the definition archived')
1345
- const rd = await N.callTool('vibe_v4_lean_read', { name: 'ZMod9' })
1346
- assert(rd.ok === true && rd.kind === 'lib' && rd.file === 'Formal/Lib/ZMod9.lean' && rd.text === defBody, '★ lean_read returns the archived file text VERBATIM (import-or-copy reuse)')
1347
- assert(/^[0-9a-f]{64}$/.test(rd.sha256) && rd.bytes === defBody.length && rd.truncated === false, 'it reports sha256/bytes/truncated')
1348
- assert(/import Formal\.Lib\.ZMod9/.test(rd.hint || ''), '★ and names the namespace to import (module root = VibeMath root)')
1349
- const proved = await N.callTool('vibe_v4_lean_read', { name: 'ZMod9', kind: 'proved' })
1350
- assert(proved.ok === false && proved.code === 'V4_NOT_FOUND', 'kind=proved does not look in Lib/')
1351
- for (const bad of ['../../etc/passwd', '..', 'Formal/Lib/ZMod9', 'C:\\Windows\\evil']) {
1352
- const r = await N.callTool('vibe_v4_lean_read', { name: bad })
1353
- assert(r.ok === false, '★ lean_read refuses the path-escape name ' + JSON.stringify(bad))
1354
- }
1355
- assert((await N.callTool('vibe_v4_lean_read', { name: 'ZMod9', kind: 'bogus' })).ok === false, 'an unknown kind is refused')
1356
- // 64KB truncation
1357
- const big = 'def Big := 1\n' + ('-- ' + 'x'.repeat(120) + '\n').repeat(600)
1358
- await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'BigLib', content: big, run: false })
1359
- const rb = await N.callTool('vibe_v4_lean_read', { name: 'BigLib' })
1360
- assert(rb.ok === true && rb.truncated === true && rb.text.length <= 64 * 1024 && rb.bytes > 64 * 1024, '★ a >64KB library file is truncated and flagged (truncated:true)')
1361
- }
1362
-
1363
- // ===============================================================
1364
- // 22. Search paths (AMENDMENT §2) + build-context job id (§4) + concurrency (§5) + the new params.
1365
- // ===============================================================
1366
- section('22 leanSearchPaths/leanJobsMaxParallel/leanInitiative + build-context job ids')
1367
- {
1368
- const N = await establish()
1369
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false, leanArgs: [], leanSearchPaths: ['/extra/one', '/extra/one', '/extra/two'] })
1370
- writeIn(N, 'Projects/default/Formal/a-sp.lean', 'theorem a_sp : True := by trivial\n')
1371
- await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
1372
- let last = leanRuns[leanRuns.length - 1]
1373
- const spIdx = last.argv.indexOf('--search-path')
1374
- assert(last.argv.filter(a => a === '--search-path').length === 3, '★ one --search-path per DISTINCT root (user paths first, then the automatic VibeMath root)')
1375
- assert(last.argv[spIdx + 1] === '/extra/one' && last.argv[spIdx + 3] === '/extra/two' && last.argv[spIdx + 5] === N.vibeRoot.replace(/\\/g, '/'), '★ order + de-duplication: user paths in order, automatic root last')
1376
- assert(last.argv.indexOf('--search-path') < last.argv.length - 1 && /\.lean$/.test(String(last.argv[last.argv.length - 1])), 'the roots stay BEFORE the file name')
1377
- await N.callTool('vibe_v4_set', { leanArgs: ['--search-path', '/user/own'] })
1378
- await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
1379
- last = leanRuns[leanRuns.length - 1]
1380
- assert(last.argv.filter(a => a === '--search-path').length === 1 && last.argv[last.argv.indexOf('--search-path') + 1] === '/user/own', '★ an explicit --search-path in leanArgs wins outright: nothing is injected')
1381
- await N.callTool('vibe_v4_set', { leanArgs: ['-R', '/user/root'] })
1382
- await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
1383
- assert(leanRuns[leanRuns.length - 1].argv.indexOf('--search-path') === -1, '★ -R/--root count as a user-stated root too')
1384
- const lib = await N.callTool('vibe_v4_lean_lib', { refresh: false })
1385
- assert(lib.searchPath === N.vibeRoot.replace(/\\/g, '/') && /import Formal\.Lib\./.test(JSON.stringify(lib.importNamespace)), 'lean_lib shows the injected searchPath and the import namespace')
1386
- // build-context job id: same content, different engine ⇒ different job
1387
- await N.callTool('vibe_v4_set', { leanAsync: true, activityTimeoutMs: 600000, leanArgs: [], leanSearchPaths: [], leanCommand: 'lean' })
1388
- const j1 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CtxOne', content: 'def CtxOne := 1\n' })
1389
- await N.callTool('vibe_v4_set', { leanCommand: 'lake' })
1390
- const j2 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CtxOne', content: 'def CtxOne := 1\n' })
1391
- assert(j1.jobId !== j2.jobId, '★ the job id covers the BUILD CONTEXT: identical content with a different engine is a different job (' + j1.jobId + ' vs ' + j2.jobId + ')')
1392
- // concurrency cap
1393
- await N.callTool('vibe_v4_set', { leanCommand: 'lean', leanJobsMaxParallel: 2, activityTimeoutMs: 40 })
1394
- const h1 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapA', content: '-- HANG\ndef CapA := 1\n' })
1395
- const h2 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapB', content: '-- HANG\ndef CapB := 1\n' })
1396
- const h3 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapC', content: '-- HANG\ndef CapC := 1\n' })
1397
- for (let i = 0; i < 120; i++) { const s = await N.callTool('vibe_v4_lean_job', {}); if (s.running.length >= 2) break; await sleep(25) }
1398
- const snap = await N.callTool('vibe_v4_lean_job', {})
1399
- assert(snap.running.length === 2 && snap.queued.length >= 1, '★ leanJobsMaxParallel=2 runs exactly two compiles and leaves the rest queued (' + snap.running.length + ' running / ' + snap.queued.length + ' queued; all=' + JSON.stringify(snap.jobs.map(j => j.jobId + ':' + j.state)) + '; archives=' + JSON.stringify([h1, h2, h3].map(x => x && (x.jobId || x.code || x.message)))) + ')'
1400
- await N.callTool('vibe_v4_abort', {}) // terminate the hang jobs
1401
- }
1402
-
1403
- // ===============================================================
1404
- // 23. leanInitiative separates PROACTIVITY from the verification requirement (AMENDMENT §1).
1405
- // ===============================================================
1406
- section('23 leanInitiative: off/normal/eager in the work prompt, formalVerify untouched')
1407
- {
1408
- const N = await establish()
1409
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false })
1410
- const normal = await N.prompts('normal', 'r-1')
1411
- assert(/【顺手形式化(鼓励)】/.test(normal) && /判断标准:① 有价值或可能复用/.test(normal), '★ leanInitiative=normal (default): the daily line carries the three selection criteria')
1412
- assert(/先 vibe_v4_lean_lib 查已有库/.test(normal) && /没把握就记 blocked/.test(normal) && /import Formal\.Lib\./.test(normal), '★ …and the look-up-first / blocked / namespace rules')
1413
- assert(!/leanInitiative=eager/.test(normal), 'the normal档 does not claim eagerness')
1414
- const verify = await N.prompts('verify', 'r-1', { target: 'p-init', stage: 'independent' })
1415
- assert(/形式化只写你有把握的版本;没把握就记 blocked 并写清难点/.test(verify) && /不得\*\*在它落地前声称已通过/.test(verify), '★ the verification block carries the §5 B contract (blocked-instead-of-guessing + no premature "passed")')
1416
- await N.callTool('vibe_v4_set', { leanInitiative: 'eager' })
1417
- const eager = await N.prompts('normal', 'r-1')
1418
- assert(/leanInitiative=eager/.test(eager) && /主动\*\*顺手形式化并归档/.test(eager), '★ leanInitiative=eager adds the explicit proactive clause')
1419
- await N.callTool('vibe_v4_set', { leanInitiative: 'off' })
1420
- const off = await N.prompts('normal', 'r-1')
1421
- assert(!/【顺手形式化/.test(off), '★ leanInitiative=off removes the proactive daily line entirely')
1422
- const vOff = await N.prompts('verify', 'r-1', { target: 'p-init', stage: 'independent' })
1423
- assert(/【Lean 形式化验证(鼓励模式)】/.test(vOff) && /实现难度/.test(vOff), '★ …while the VERIFICATION prompt still states the formalVerify requirement (off ≠ no verification)')
1424
- // parameter surfaces (AMENDMENT §1/§2/§5 + leanAsync)
1425
- const setSpec = N.toolRegs.find(t => t.name === 'vibe_v4_set')
1426
- for (const k of ['leanAsync', 'leanInitiative', 'leanSearchPaths', 'leanJobsMaxParallel']) assert(!!setSpec.parameters.properties[k], '★ vibe_v4_set advertises ' + k)
1427
- assert(JSON.stringify(setSpec.parameters.properties.leanInitiative.enum) === JSON.stringify(['off', 'normal', 'eager']), 'leanInitiative is a closed enum in the schema')
1428
- const coerced = await N.callTool('vibe_v4_set', { leanAsync: 'false', leanInitiative: 'bogus', leanSearchPaths: 'a, b ,,c', leanJobsMaxParallel: 0 })
1429
- const pv = await N.callTool('vibe_v4_status', {})
1430
- assert(coerced.ok === true && /leanAsync=false/.test(pv.params), '★ the STRING "false" normalises to boolean false (never a truthy string)')
1431
- assert(/leanInitiative=normal/.test(pv.params), 'an unknown leanInitiative degrades to the default normal')
1432
- assert(/leanSearchPaths=a,b,c/.test(pv.params), 'a comma string becomes a trimmed, non-empty array')
1433
- assert(/leanJobsMaxParallel=1/.test(pv.params), 'leanJobsMaxParallel is clamped to ≥1')
1434
- // The SLASH-COMMAND surface passes RAW STRINGS to the same parameter layer (spec §B: v4's `/v4 set`
1435
- // hands over strings, so 'false' must not survive as a truthy value there either).
1436
- const slash = await N.cmd('set leanAsync=false leanInitiative=off')
1437
- assert(slash.kind === 'success', '/v4 set accepted the Lean parameters')
1438
- const pv2 = await N.callTool('vibe_v4_status', {})
1439
- assert(/leanAsync=false/.test(pv2.params) && /leanInitiative=off/.test(pv2.params), '★ `/v4 set leanAsync=false leanInitiative=off` coerces the raw strings through normalizeParam')
1440
- assert(!/【顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ and leanInitiative=off set through the slash command really silences the proactive line')
1441
- }
1442
-
1443
- // ===============================================================
1444
- // 17. audit-B #2: `leanInitiative` is an INDEPENDENT axis from `formalVerify` — `eager` asks for the
1445
- // daily line even with `formalVerify:'off'` (v5 uses the same rule); 'off' never injects it while
1446
- // the VERIFICATION prompt keeps following `formalVerify`.
1447
- // ===============================================================
1448
- section('17 leanInitiative=eager injects the daily line even with formalVerify=off (independent axes)')
1449
- {
1450
- const N = await establish()
1451
- await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'eager' })
1452
- const daily = await N.prompts('normal', 'r-1')
1453
- assert(/【顺手形式化(主动(leanInitiative=eager))】/.test(daily), '★ eager + formalVerify=off STILL injects the daily line (the header names the initiative, not a verification mode)')
1454
- assert(/判断标准:① 有价值或可能复用/.test(daily) && /先 vibe_v4_lean_lib 查已有库/.test(daily), '★ …with the three criteria and the look-up-first rule')
1455
- const verifyOff = await N.prompts('verify', 'r-1', { target: 'p-eager-off', stage: 'independent' })
1456
- assert(!/顺手形式化/.test(verifyOff) && !/【Lean 形式化验证/.test(verifyOff), '★ …while the VERIFICATION prompt stays a true no-op under formalVerify=off')
1457
- // the default initiative is not eager: off mode injects nothing (unchanged behaviour)
1458
- await N.callTool('vibe_v4_set', { leanInitiative: 'normal' })
1459
- assert(!/顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ leanInitiative=normal + formalVerify=off injects NO daily line')
1460
- // 'off' silences the daily line even when formalVerify asks for Lean, without touching verification
1461
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanInitiative: 'off' })
1462
- assert(!/顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ leanInitiative=off removes the daily line even with formalVerify=encourage')
1463
- assert(/【Lean 形式化验证(鼓励模式)】/.test(await N.prompts('verify', 'r-1', { target: 'p-eager-off', stage: 'independent' })), '★ …and the verification prompt is untouched by the initiative knob')
1464
- // with formalVerify on, the header names the verification mode again (not the eager wording)
1465
- await N.callTool('vibe_v4_set', { formalVerify: 'require', leanInitiative: 'eager' })
1466
- assert(/【顺手形式化(强制)】/.test(await N.prompts('normal', 'r-1')), '★ with formalVerify on, the header names the verification mode (require → 强制)')
1467
- await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'normal' })
1468
- }
1469
-
1470
- // ===============================================================
1471
- // 18. audit R2 HIGH: the independence rule must hold at EVERY daily-line injection site, not just
1472
- // the ordinary work round. A single-site regression to `formalOn()`-only must be catchable, so
1473
- // each site is asserted separately under BOTH decisive states (eager+off, and initiative=off).
1474
- // ===============================================================
1475
- section('18 every daily-line injection site (coreRules/normal/heartbeat) obeys the independence rule')
1476
- {
1477
- const N = await establish()
1478
- const SITES = [
1479
- ['coreRules', 'coreRulesBrief', () => N.prompts('coreRules')],
1480
- ['normal', 'normalPrompt', () => N.prompts('normal', 'r-1')],
1481
- ['heartbeat', 'heartbeatPrompt', () => N.prompts('heartbeat', 'r-1')],
1482
- ]
1483
- await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'eager' })
1484
- for (const [which, who, read] of SITES) {
1485
- const text = await read()
1486
- assert(/【顺手形式化(主动(leanInitiative=eager))】/.test(text), '★ [' + which + '] eager + formalVerify=off injects the daily line at this site (' + who + ')')
1487
- assert(/判断标准:① 有价值或可能复用/.test(text) && /先 vibe_v4_lean_lib 查已有库/.test(text), '★ [' + which + '] …with the three criteria and the look-up-first rule (' + who + ')')
1488
- }
1489
- const vOff = await N.prompts('verify', 'r-1', { target: 'p-site-off', stage: 'independent' })
1490
- assert(!/顺手形式化/.test(vOff) && !/【Lean 形式化验证/.test(vOff), '★ [all sites] the verification prompt stays a no-op under formalVerify=off')
1491
- await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanInitiative: 'off' })
1492
- for (const [which, who, read] of SITES) {
1493
- const text = await read()
1494
- assert(!/顺手形式化/.test(text), '★ [' + which + '] leanInitiative=off removes the daily line at this site (' + who + ')')
1495
- }
1496
- assert(/【Lean 形式化验证(鼓励模式)】/.test(await N.prompts('verify', 'r-1', { target: 'p-site-off', stage: 'independent' })), '★ [all sites] …while verification still follows formalVerify (encourage)')
1497
- await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'normal' })
1498
- }
1499
-
1500
- // ===============================================================
1501
- // 22. cross-preset audit P0 (v4 station): the member-visible library text must state that the
1502
- // residents' file tools resolve against the SESSION CWD, so the listed sub-paths must be used
1503
- // with the ABSOLUTE prefix (and computation artifacts via receipt.scriptAbs / receipt.cwd).
1504
- // ===============================================================
1505
- section('22 audit P0: member-facing paths carry the session-cwd / absolute-prefix instruction')
1506
- {
1507
- const N = await establish()
1508
- const texts = [await N.prompts('brainstorm', 'r-1'), await N.prompts('coreRules'), await N.prompts('normal', 'r-1')]
1509
- const ok = texts.filter((t) => t.indexOf('会话 cwd') !== -1 && t.indexOf('绝对前缀') !== -1)
1510
- assert(ok.length >= 1, '★ the member-visible library text says file tools resolve against the SESSION CWD and that listed paths need the ABSOLUTE prefix (found in ' + ok.length + '/3 variants)')
1511
- assert(ok.some((t) => t.indexOf('receipt.scriptAbs') !== -1 || t.indexOf('receipt.cwd') !== -1), '★ …and names the absolute receipt field (receipt.scriptAbs / receipt.cwd) for computation artifacts')
1512
- }
1513
-
1514
- // ===============================================================
1515
- // 23. cross-preset audit P1 (v4 stations): a member-facing not-found must be diagnosable — code +
1516
- // live roster status + an actionable next step — including `removeMember` (previously a bare
1517
- // {ok:false}) and the not-yet-started case.
1518
- // ===============================================================
1519
- section('23 audit P1: member-facing not-found carries code + status + next')
1520
- {
1521
- const N = await establish()
1522
- const wrong = await N.callTool('vibe_v4_send_message', { to: 'r-404', content: 'hi' }, N.resAgent(N.childOf('r-1')))
1523
- assert(wrong.ok === false && wrong.code === 'V4_NO_SUCH_RESIDENT' && !!wrong.next && !!wrong.next.tool, '★ wrong resident id ⇒ code V4_NO_SUCH_RESIDENT + next{tool} (not a bare message)')
1524
- assert(wrong.status && wrong.status.residentCount >= 1 && wrong.status.running === true, '★ …plus LIVE roster status, so "wrong id" is distinguishable from "not started"')
1525
- const kicked = await N.callTool('vibe_v4_remove_member', { id: 'r-404' })
1526
- assert(kicked.ok === false && kicked.code === 'V4_NO_SUCH_RESIDENT' && !!kicked.next && kicked.next.tool === 'vibe_v4_remove_member', '★ vibe_v4_remove_member {id:r-404} ⇒ code + next naming that tool (was a bare {ok:false})')
1527
- const fresh = await mount()
1528
- const notStarted = await fresh.callTool('vibe_v4_publish_progress', { content: 'x' })
1529
- assert(notStarted.ok === false && notStarted.code === 'V4_NO_SUCH_RESIDENT' && !!notStarted.next && !!notStarted.status && notStarted.status.running === false, '★ run not started ⇒ code + next + status.running=false')
1530
- }
1531
-
1532
- // ---------- D4 cross-view: mid-verify hire/fire must not split the views ----------
1533
- section('D4 cross-view: one frozen participant set, seen identically by every view')
1534
- {
1535
- const h = await establish()
1536
- const st = await startVerifyOnly(h, 'p-d4')
1537
- assert(st.consensus && st.consensus.kind === 'verify', 'precondition: an ACTIVE verification for the cross-view check (' + JSON.stringify({ c: st.consensus && st.consensus.kind, ip: st.verifyInProgress }) + ')')
1538
- const c0 = st.consensus
1539
- const rp0 = await h.callTool('vibe_v4_report', {})
1540
- const rv0 = rp0.verify
1541
- assert(Array.isArray(st.frozenParticipants) && rv0, 'the frozen participant set and the report() verify view are both exposed')
1542
- assert(c0.expected === st.frozenParticipants.length, '★ consensus.expected === the frozen participant set size')
1543
- assert(Number(String(rv0.voted).split('/')[1]) === c0.expected, '★ the two views agree on the DENOMINATOR (status().consensus.expected vs report().verify k/N)')
1544
- assert(rv0.rosterVersion === c0.rosterVersion && c0.rosterVersion === st.rosterVersion, '★ both views (and status) report the SAME rosterVersion')
1545
- // HIRE mid-verify: the newcomer must NOT join the frozen set (that is what freezing means).
1546
- const hire = await h.callTool('vibe_v4_add_member', { direction: 'D4 cross-view assertion' })
1547
- const st2 = await h.callTool('vibe_v4_status', {})
1548
- const rv2 = (await h.callTool('vibe_v4_report', {})).verify
1549
- assert(st2.rosterVersion > c0.rosterVersion, 'hiring mid-verify bumps rosterVersion (' + c0.rosterVersion + ' -> ' + st2.rosterVersion + ')')
1550
- assert(st2.consensus.expected === st2.frozenParticipants.length && st2.consensus.expected === c0.expected,
1551
- '★ after a mid-verify HIRE the frozen set is UNCHANGED and both views still agree (newcomer did not join)')
1552
- assert(rv2.rosterVersion === st2.consensus.rosterVersion,
1553
- '★ after the HIRE the two FROZEN views (status().consensus + report().verify) still report the SAME rosterVersion')
1554
- assert(st2.rosterVersion > rv2.rosterVersion,
1555
- '★ …while status().rosterVersion is the LIVE counter and has moved past the snapshot (staleness is visible)')
1556
- // FIRE mid-verify: the frozen set is pruned so the in-flight verification can still conclude.
1557
- if (hire && hire.ok && hire.id) await h.callTool('vibe_v4_remove_member', { id: hire.id })
1558
- const st3 = await h.callTool('vibe_v4_status', {})
1559
- const rv3 = (await h.callTool('vibe_v4_report', {})).verify
1560
- assert(st3.consensus.expected === st3.frozenParticipants.length && Number(String(rv3.voted).split('/')[1]) === st3.consensus.expected && rv3.rosterVersion === st3.consensus.rosterVersion,
1561
- '★ after a mid-verify FIRE the two views STILL agree (the frozen set was pruned together)')
1562
- assert(st3.frozenParticipants.indexOf(hire && hire.id) === -1, '★ the removed member is not in the frozen set any more (pruned on removal)')
1563
- // …and the verification really does conclude after that churn (T26/T31 contract).
1564
- await settleVerify(h, () => 1)
1565
- const st4 = await h.callTool('vibe_v4_status', {})
1566
- assert(st4.verifyInProgress === false, '★ the mid-verify hire/fire did NOT prevent the verification from concluding')
1567
- }
1568
-
1569
- // ===============================================================
1570
- console.log('')
1571
- console.log('passed=' + passed + ' failed=' + failed)
1572
- if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
1573
- console.log('ALL GREEN')
1574
- process.exit(0)
1
+ // ============================================================
2
+ // Defect class: F-6 (durability surfacing). A swallowed durable write / index rebuild / compaction
3
+ // failure must be NAMED once, with its consequence, instead of disappearing silently.
4
+ // Run: node tests/formal-verify-v4.test.mjs
5
+ //
6
+ // RECORDED BOUNDARY - all four product warnings EXIST (commit d02bdbf: warnedWriteFormalTodo,
7
+ // warnedDeferFormalTodo, warnedIndexRebuild, warnedCompaction; each once-per-process and each stating
8
+ // its consequence) but are NOT captured automatically by this suite. NOT REPRODUCED != NOT
9
+ // IMPLEMENTED: the reasons below are measured on the mock host, not assumed.
10
+ // site 1 closeVerify - guarded by `if(formalOn() && formalTodos.some(id===target))` (unreachable
11
+ // in off mode); the close is finalised by finalizeVerify's ASYNC chain
12
+ // AFTER proposeAndVote() returns, so a deterministic assertion would need
13
+ // a product-side fault hook (rejected: it would become a new
14
+ // agent-settable parameter) or dedicated verify orchestration.
15
+ // site 2 deferForFormal - KNOWN COVERAGE HOLE (measured). Payload-anchored injection (fail the
16
+ // Formal/TODO.md write whose payload mentions the target) is CONSUMED by an
17
+ // earlier TODO write in require mode (recording the proposition also writes
18
+ // that file and names the target), so the induced failure lands OUTSIDE
19
+ // deferForFormal's try: the run is derailed (Formal/TODO.md never created,
20
+ // section 9's own TODO assertions fail) and NO warning is emitted
21
+ // (`matched=0 of 0`). Anchoring by State/formal.json as a precursor was also
22
+ // measured imprecise (armed=9, failed=1, no warning). The warning itself
23
+ // exists and is exercised by the F-2-style unit surface at
24
+ // tests/audit-f1-compact-fix.test.mjs; only the in-suite capture is missing.
25
+ // site 2 (old note) - same async-finalisation problem (require mode only).
26
+ // site 3 settleLeanJob - the injected index-write failure is consumed by whichever
27
+ // rebuildLeanLibIndexes() caller fires first (measured: armed=3,
28
+ // failed=1, warning not emitted), so the mock cannot discriminate the
29
+ // caller that this site guards. Caller-discriminating injection was
30
+ // attempted (run every other rebuild caller first, then arm): the settle
31
+ // path's index write was still never reached (measured failed=0) -
32
+ // caller-discriminating injection attempted, still not reached.
33
+ // AUTHOR-MEASURED, NOT INDEPENDENTLY REPRODUCED: R17 could not reproduce the
34
+ // `armed=3 failed=1` / `failed=0` instrumentation numbers (it marked them
35
+ // still-unsure). It DID verify the STRUCTURE this boundary rests on: the
36
+ // single-line best-effort wrapper, all six callers (settleLeanJob,
37
+ // writeLeanLibIndexesSafe, the three leanArchive paths, the lean_lib tool)
38
+ // now covered by the one-shot naming inside rebuildLeanLibIndexes, and every
39
+ // later rebuild rewriting the index => self-healing. The boundary stands on
40
+ // that structural evidence; the two raw numbers above are author-measured only.
41
+ // site 4 realCompact - SURFACED (section N15): the mock resolves the child agent and exposes a
42
+ // THROWING compaction service only under an injection switch, so a completed
43
+ // resident turn reaches realCompact and its catch is asserted to NAME the
44
+ // failure exactly once with its consequence (mutant: the warning guard
45
+ // disabled => `460/2`, red on N15/4).
46
+ // site 4 (old note) - realCompact returns early unless liveAgentOf(r.childId) resolves; this
47
+ // mock's agents.get() maps only sess-A, so a resident turn never reaches
48
+ // the compaction call (probe: the service is never invoked).
49
+ // F1 COVERAGE HOLE (window) - measured, not assumed. The consensus-membership fix (predicate + roster
50
+ // guard) is covered by the SOURCE-LEVEL guards in section N13 (both single-site mutants redden by name:
51
+ // count predicate => 457/2; `_inRoster=true` => 458/1). The behavioural WINDOW the v2/v3 owner proposed
52
+ // (a removed, still-in-flight frozen voter's late ballot) was driven inside section D4 and measured
53
+ // unobservable through the public API: fixed => `before=0/2 after=0/1 card=false`; guard deleted (M2) =>
54
+ // the SAME `0/1`; count predicate (M1) => the SAME `0/1`. The late ballot therefore never reaches the
55
+ // recording site in this composition, so a windowed assertion could not be reddened by M2 and would be
56
+ // vacuous - it was NOT landed. Regression coverage for F1 is N13 (source level).
57
+
58
+ // F-6 (:786) FIXED, not merely bounded: a failed build-context comparison now records
59
+ // `contextChanged=null` (unknown != unchanged) and emits a one-time named warning (warnedContextProbe,
60
+ // wording states the consequence). It carries NO automated assertion here: the branch is UNREACHABLE
61
+ // from this host mock - `leanBuildContext()` is non-throwing by construction (its only host call,
62
+ // resolveExecutable, is wrapped and degrades to the configured name) and every other step is a
63
+ // defensive coercion over agent-settable params (leanSearchPaths/leanArgs are Array.isArray-guarded and
64
+ // String()-coerced). A mutant without an assertion would prove nothing, so none is claimed.
65
+ // Consumer check (repo-wide grep of `contextChanged`): only 4 references, all in v4's settle paths -
66
+ // the field is write-only for the JSON reader, so `null` cannot be mis-read as `false`.
67
+ // Sites 3/4 carry test-side injection switches in the F-6 work branch (_oneoff/tw-patch.mjs) which
68
+ // can be re-landed together with a host-side caller discriminator.
69
+ // ---------------------------------------------------------------
70
+ // SHIPPED ASSERTION RULES (the rules behind N13-N17; the authoring ledger lives outside the package,
71
+ // so the essentials ship HERE, next to the guards they govern).
72
+ // (A) LIVENESS BEFORE SILENCE (governs N17/F-5, especially case 1c): every "no warning / no write /
73
+ // no re-anchor" assertion must ALSO prove that the code under test RAN, and the proof must be
74
+ // taken at the step CLOSEST to that branch. Measured counter-examples on this mock: "the policy
75
+ // service was queried" is NOT proof (`sandboxPolicyOf()` is read by BOTH workspaceRoot() and
76
+ // getPolicy()), and "the write succeeded" is NOT proof either (with an unknown cwd the same call
77
+ // returns ok:false for an UNRELATED path reason). The workable instrument is the resolve() call
78
+ // count - `LIVENESS: resolve calls=11` - because resolve({session}) runs immediately before
79
+ // fenceRootDriftNote(). Mutant "unknown cwd => warn" reddens `F-5/1c ... stays SILENT (drift=1)`.
80
+ // (B) A SYMBOL'S EXISTENCE IS NOT ITS USE (governs the F-3/F-4c verdicts recorded in-product at
81
+ // vibe-math-v4.js:3553): a count is a LEAD, never a verdict - the verdict must be read at the
82
+ // SEMANTIC UNIT. Measured: `registerTool(` appears 36 times but there is exactly ONE registration
83
+ // unit (the ctx.effect inside the registerTool helper), so no call site needs its own wrapper;
84
+ // and a failed mkdir is named ONCE INSIDE ensureDirs (warnedMkdir), which covers every caller -
85
+ // including the bare `await ensureDirs()` - so no caller-side return check is required.
86
+ // (C) G-6 HOST CONCURRENCY CAP, assertion level: when the host's live-child cap refuses a spawn, the
87
+ // assertion must require the cap to be NAMED together with the knob that raises it, the durable
88
+ // marker to survive (hostChildLimit / failReason), and the freed capacity to be spent on exactly
89
+ // ONE deferred member. Field-level semantics: docs/status-report-fields.md (pendingSpawns).
90
+ // (D) A2 WAKE/WATCHDOG, assertion level: ack only AFTER a successful delivery, and advance the round
91
+ // counter only AFTER a successful send - asserted under INJECTED send failures, never on the
92
+ // happy path (where build == send == success hides the defect). Shipped families: v5 G1/G2.
93
+ // ---------------------------------------------------------------
94
+ // V4 LEAN FORMAL VERIFICATION SUITE (docs/formal-verification.md)
95
+ //
96
+ // Asserts the whole contract of the `formalVerify` knob for the v4 preset:
97
+ // · 'off' is a TRUE no-op for VERIFICATION (no Lean block, no gate) and for the daily line
98
+ // under the default initiative; `leanInitiative:'eager'` is the one exception that
99
+ // still injects the DAILY line (the two knobs are independent axes — see §17).
100
+ // · 'encourage' injects the Lean section into the VERIFICATION prompt AND the ordinary work
101
+ // round, and — the actual point of the feature — turns the voting prompt into a
102
+ // FIDELITY review once a Lean run has passed
103
+ // · 'require' withholds a unanimous 真/假 verdict as 未定论 until the object is Lean-passed
104
+ // or carries an explicit, reasoned blocker record; then it allows it, and the
105
+ // Verified card records how strong the result really is
106
+ // · the three tools (run / archive / lib) write the right things to the right paths
107
+ //
108
+ // The Lean toolchain is mocked through the `subprocess` SERVICE (a fake Lean: a file passes unless
109
+ // it still contains `sorry` or the marker `-- FAIL`), so the tests exercise the REAL code path
110
+ // (resolveExecutable → spawn → collected stdout → exit code) without requiring Lean to be installed.
111
+ //
112
+ // V4_PLUGIN lets a sensitivity probe point this suite at a MUTATED copy of the plugin; without that
113
+ // override every probe would run the unmutated file and pass vacuously (AUDIT-CHECKLIST §2.5).
114
+ //
115
+ // Prompt text is asserted through the plugin's own `vibe_v4_prompts` host tool — the exact builder
116
+ // the framework uses — rather than by guessing which queued wake carries which prompt. That keeps
117
+ // the assertions precise even when the scheduler queues several rounds in one pass, and the suite
118
+ // still drives the real consensus path for every STATE transition (AUDIT-CHECKLIST §0.1/§2.2).
119
+ //
120
+ // Run: node tests/formal-verify-v4.test.mjs
121
+ // ============================================================
122
+ import { mkdtempSync, existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, rmSync } from 'node:fs'
123
+ import { tmpdir } from 'node:os'
124
+ import { join, dirname, isAbsolute, resolve as pathResolve } from 'node:path'
125
+ import { pathToFileURL, fileURLToPath } from 'node:url'
126
+
127
+ // V4_PLUGIN (same convention the v5 suite uses for V5_PLUGIN): point the suite at a MUTATED copy of
128
+ // the plugin so a sensitivity probe can prove each invariant actually turns this suite red. Accept a
129
+ // plain filesystem path or a file:// URL, and never touch the value otherwise.
130
+ function pluginUrl() {
131
+ const raw = process.env.V4_PLUGIN
132
+ if (!raw || !String(raw).trim()) return new URL('../vibe-math-v4/vibe-math-v4.js', import.meta.url)
133
+ const s = String(raw).trim()
134
+ if (/^file:/i.test(s)) return new URL(s)
135
+ return pathToFileURL(fileURLToPath(new URL('file:///' + s.replace(/\\/g, '/'))))
136
+ }
137
+ const PLUGIN = pluginUrl()
138
+ function PLUGIN_SRC() { return readFileSync(process.env.V4_PLUGIN ? String(process.env.V4_PLUGIN) : fileURLToPath(new URL('../vibe-math-v4/vibe-math-v4.js', import.meta.url)), 'utf8') } // protocol 搂9: ONE seam-aware resolution for source-text guards
139
+ // docs §10 item 10: the Lean prompt corpus is shipped under prompt-corpus-v4/ so a HUMAN can read
140
+ // the exact text the framework sends. V4_CORPUS_DIR overrides the destination (same convention v3
141
+ // and v5 use for V3_CORPUS_DIR / V5_CORPUS_DIR).
142
+ const HERE = dirname(fileURLToPath(import.meta.url))
143
+ const CORPUS_DIR = process.env.V4_CORPUS_DIR ? pathResolve(process.env.V4_CORPUS_DIR) : join(HERE, '..', 'prompt-corpus-v4')
144
+
145
+ let passed = 0, failed = 0
146
+ const failures = []
147
+ const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
148
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
149
+ const section = (t) => console.log('\n[' + t + ']')
150
+ const JSONX = o => '```json\n' + JSON.stringify(o) + '\n```'
151
+ const promptOf = (entry) => (entry && entry.blocks && entry.blocks[0] && entry.blocks[0].text) || ''
152
+ const readIf = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '')
153
+
154
+ // ===============================================================
155
+ // MOCK HOST — the shape selfdrive-v4 / e2e-v4-fixes use, plus a fake Lean
156
+ // ===============================================================
157
+ let toolchainAvailable = true
158
+ // A host that exposes NO `subprocess` service at all (docs §7 → NO_SUBPROCESS, and §6 hard rule 4
159
+ // requires the injected guidance to name that code so the resident records a blocker instead of
160
+ // retrying forever).
161
+ let subprocessAvailable = true
162
+ // A stub host whose shell EXITS 0 WITHOUT deleting anything (a permissions quirk / a stub host).
163
+ // Used by §13 to prove the withdrawal is not a best-effort delete: the framework must confirm the
164
+ // file is gone through the fs service and fall back to overwriting it with a withdrawal notice.
165
+ let shellDeletesFiles = true
166
+ let shellMkdirExit = 0
167
+ let fsWriteFailPred = null // F-6/N18: (targetKey) => boolean; the first matching write FAILS (payload/path anchored)
168
+ let fsWriteFailOnce = false
169
+ let sandboxDriftRoot = null // F-5: when set, the mock policy resolves THIS root (drift/containment harness)
170
+ let sandboxNoRoot = false // F-5: when set, the mock policy exposes NO interpretable root (no-op path)
171
+ let sandboxUnknownCwd = false // F-5/1c: the captured root agent has NO cwd (unknown => `.` guard)
172
+ let compactionFails = false // F-6 site 4: expose a THROWING compaction service + resolve child agents
173
+ const leanRuns = [] // every spawn the framework made, for cwd/argv assertions
174
+ const terminated = [] // files whose handle the framework actively terminate()d (docs §7)
175
+ const shellCalls = [] // every platform-shell script (mkdir at mount, Remove-Item on defect)
176
+
177
+ function makeSubprocess() {
178
+ return {
179
+ async resolveExecutable(cmd) {
180
+ if (!toolchainAvailable) throw new Error('spawn lean ENOENT')
181
+ if (String(cmd) !== 'lean' && String(cmd) !== 'lake') throw new Error('unknown executable ' + cmd)
182
+ return String(cmd)
183
+ },
184
+ spawn(spec) {
185
+ // The preset drives the platform shell through this SAME `subprocess` service (runShell:
186
+ // powershell / /bin/sh) for mkdir at mount, and — since docs §4.1 — for Remove-Item when a
187
+ // `defect` retracts an archived proof. Handle it here so that retraction is a REAL filesystem
188
+ // deletion, exactly as the real host performs it; otherwise "the proof file is gone" could
189
+ // only be asserted against a mock's bookkeeping.
190
+ const argv0 = String((spec.argv && spec.argv[0]) || '')
191
+ if (/powershell|cmd\.exe|\/bin\/sh|(^|\/)sh$/i.test(argv0)) {
192
+ const script = String(spec.argv[spec.argv.length - 1] || '')
193
+ shellCalls.push(script)
194
+ const m = script.match(/-LiteralPath\s+'((?:[^']|'')*)'/)
195
+ if (shellDeletesFiles && /Remove-Item/.test(script) && m) rmSync(m[1].replace(/''/g, "'"), { force: true })
196
+ if (shellDeletesFiles && /^rm -f /.test(script)) for (const q of script.slice(6).match(/'[^']*'/g) || []) rmSync(q.slice(1, -1), { force: true })
197
+ return {
198
+ done: Promise.resolve({ exitCode: (/New-Item|^mkdir -p /.test(script) ? shellMkdirExit : 0), signal: null }),
199
+ collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
200
+ terminate() {},
201
+ }
202
+ }
203
+ // The plugin passes an ABSOLUTE path as the last argv element, so resolve it directly; fall
204
+ // back to scanning the argv for an existing .lean file (mirrors leanAbsPath).
205
+ const last = spec.argv[spec.argv.length - 1]
206
+ const file = existsSync(last) ? last : (spec.argv.find(a => /\.lean$/.test(String(a)) && existsSync(a)) || last)
207
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : ''
208
+ const bad = /sorry|-- FAIL/.test(text)
209
+ leanRuns.push({ argv: spec.argv.slice(), file, cwd: spec.cwd, graceMs: spec.graceMs, stdio: spec.stdio })
210
+ // A HANG file never settles and never exits: the ONLY thing that can end it is the framework's
211
+ // own timeout calling handle.terminate() (docs §7). Records each terminate so the suite can
212
+ // assert the timeout path is ACTIVE, not merely reported.
213
+ if (/-- HANG/.test(text)) {
214
+ return {
215
+ done: new Promise(() => {}),
216
+ collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
217
+ terminate() { terminated.push(file) },
218
+ }
219
+ }
220
+ const stdout = bad ? '' : 'ok\n'
221
+ const stderr = bad ? 'error: declaration uses sorry\n' : ''
222
+ return {
223
+ done: Promise.resolve({ exitCode: bad ? 1 : 0, signal: null }),
224
+ collected: {
225
+ stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
226
+ stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
227
+ },
228
+ terminate() {},
229
+ }
230
+ },
231
+ }
232
+ }
233
+
234
+ function makeHost() {
235
+ const WS = mkdtempSync(join(tmpdir(), 'vibe-v4-lean-'))
236
+ const listeners = {}, toolRegs = [], spawns = [], followups = [], cmdRegs = []
237
+ const subprocess = makeSubprocess()
238
+ let sandboxPolicyQueries = 0 // F-5/1c: how many times the policy service was queried
239
+ let sandboxResolveCalls = 0 // F-5/1c: resolve({session}) calls = fenceRootDriftNote invocations
240
+ let ROOT
241
+ const ctx = {
242
+ get(name) {
243
+ if (name === 'subprocess' && subprocessAvailable) return subprocess
244
+ if (name === 'sandboxPolicy') sandboxPolicyQueries += 1 // F-5/1c liveness: the policy path really ran
245
+ if (name === 'sandboxPolicy' && sandboxUnknownCwd) return { resolve: () => { sandboxResolveCalls += 1; return { workspaceRoot: WS + '/fenced-elsewhere' } } } // NO top-level root: workspaceRoot() falls back to '.'
246
+ if (name === 'sandboxPolicy' && (sandboxDriftRoot || sandboxNoRoot)) return sandboxNoRoot ? { allow: ['x'], deny: [], resolve: () => ({ allow: ['x'], deny: [] }) } : { workspaceRoot: sandboxDriftRoot, resolve: () => ({ workspaceRoot: sandboxDriftRoot }) }
247
+ if (name === 'compaction' && compactionFails) return {
248
+ async compactIfNeeded() { throw new Error('COMPACTION_FAIL (injected)') },
249
+ async compactNow() { throw new Error('COMPACTION_FAIL (injected)') },
250
+ }
251
+ return undefined
252
+ },
253
+ on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
254
+ effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
255
+ logger: { info() {}, warn() {}, error() {} },
256
+ timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
257
+ tools: { register(s) { toolRegs.push(s) } }, commands: { register(s) { cmdRegs.push(s) } },
258
+ // Real DSH exposes ONLY subagents.sendMessage for continuable wakes (followup is an Agent
259
+ // method, not a subagents service method) — deliberately no followup, so a regression fails.
260
+ subagents: {
261
+ list() { return ['spawn'] },
262
+ async startContinuable({ label, request }) { const id = 'c' + (spawns.length + 1); spawns.push({ label, request, childId: id }); return { childId: id } },
263
+ async sendMessage(parent, childId, blocks) { followups.push({ childId, blocks }) },
264
+ interrupt() {},
265
+ },
266
+ agents: { roots() { return [] }, get(id) {
267
+ if (compactionFails && id && id !== 'sess-A') return { id, session: { id, header: { cwd: WS, parentSession: 'sess-A' } } }
268
+ return id === 'sess-A' ? ROOT : undefined
269
+ } },
270
+ fs: {
271
+ async resolve(rel, opts) { const b = (opts && opts.cwd) || WS; const p = (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/')); return { targetKey: p, displayPath: p } },
272
+ async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
273
+ async readText(t) { return readFileSync(t.targetKey, 'utf8') },
274
+ async writeText(t, c) { if (fsWriteFailOnce && fsWriteFailPred && fsWriteFailPred(String(t.targetKey), String(c))) { fsWriteFailOnce = false; throw new Error('FS_WRITE_FAIL (injected): ' + t.targetKey) } mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
275
+ async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
276
+ },
277
+ }
278
+ ROOT = { id: 'sess-A', options: { provider: 'mock', model: 'm' }, session: { id: 'sess-A', header: { cwd: WS, parentSession: undefined } }, followup() {}, ctx: undefined }
279
+ if (sandboxUnknownCwd) ROOT.session.header.cwd = '' // F-5/1c: unknown session cwd
280
+ const projectRoot = join(WS, 'VibeMath', 'Projects', 'default')
281
+ const vibeRoot = join(WS, 'VibeMath')
282
+ let consumed = 0
283
+ const h = {
284
+ WS, ctx, toolRegs, spawns, followups, ROOT, projectRoot, vibeRoot, cmdRegs,
285
+ policyQueries: () => sandboxPolicyQueries,
286
+ resolveCalls: () => sandboxResolveCalls,
287
+ resetResolveCalls: () => { sandboxResolveCalls = 0 },
288
+ /** Drive the registered `/v4` slash command — the OTHER parameter surface the spec §B names. */
289
+ async cmd(rawInput) { const c = cmdRegs.find(x => x.name === 'v4'); if (!c) throw new Error('no /v4 command'); return await c.handler({ agent: ROOT, rawInput }) },
290
+ async callTool(n, a, agent) {
291
+ const s = toolRegs.find(x => x.name === n); if (!s) throw new Error('no tool ' + n)
292
+ return JSON.parse(await s.execute(a || {}, { agent: agent || ROOT }))
293
+ },
294
+ /** The exact prompt text a resident would receive, read through the plugin's own host tool. */
295
+ async prompts(which, rId, arg) {
296
+ const out = await h.callTool('vibe_v4_prompts', Object.assign({ which, member: rId }, arg || {}), ROOT)
297
+ return out.text
298
+ },
299
+ resAgent: (cid) => ({ id: cid, session: { id: cid, header: { cwd: WS, parentSession: 'sess-A' } } }),
300
+ ridOfChild(cid) { for (const sp of spawns) if (sp.childId === cid) return sp.label; return '' },
301
+ childOf(rId) { for (const sp of spawns) if (sp.label === rId) return sp.childId; return '' },
302
+ fireEnd(cid, reply) {
303
+ const blocks = reply === undefined ? [] : [{ type: 'text', text: JSONX(reply) }]
304
+ for (const fn of (listeners['subagent/end'] || [])) fn({ id: cid, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: blocks })
305
+ },
306
+ /** Every prompt this project's residents have received (founding + wakes). */
307
+ allPrompts() {
308
+ return spawns.map(s => (s.request && s.request.prompt && s.request.prompt[0] && s.request.prompt[0].text) || '').join('\n')
309
+ + '\n' + followups.map(promptOf).join('\n')
310
+ },
311
+ nextWake() { while (consumed < followups.length) return followups[consumed++]; return null },
312
+ resetCursor() { consumed = followups.length },
313
+ }
314
+ return h
315
+ }
316
+
317
+ async function mount() {
318
+ const h = makeHost()
319
+ const mod = await import(PLUGIN.href + '?t=' + Date.now() + Math.random())
320
+ ;(mod.default || mod).apply(h.ctx)
321
+ return h
322
+ }
323
+
324
+ // ===============================================================
325
+ // driving helpers
326
+ // ===============================================================
327
+ /** Complete every brainstorm round so the run becomes phase=active, then park the heartbeat. */
328
+ async function establish() {
329
+ const h = await mount()
330
+ const before = h.spawns.length
331
+ await h.callTool('vibe_v4_start', { problem: '形式化契约验证测试', residentCount: 2 })
332
+ for (let i = 0; i < 60 && h.spawns.length < before + 2; i++) await sleep(10)
333
+ for (const sp of h.spawns.slice(before)) { h.fireEnd(sp.childId, { summary: '初始见解。', solved: false, contextPct: 10 }); await sleep(30) }
334
+ // Park the heartbeat far away: these tests drive wakes explicitly, so a short heartbeat would
335
+ // only add unrelated normal rounds and make the followup stream harder to reason about.
336
+ // `leanAsync:false` on purpose: sections 1-16 assert the HISTORICAL SYNCHRONOUS contract
337
+ // (lean_run returns exitCode/stdout in the same call, archive{run:true} compiles inline) verbatim.
338
+ // The asynchronous contract (enqueue + settle + recovery) has its own sections 17+.
339
+ await h.callTool('vibe_v4_set', { activityTimeoutMs: 600000, leanAsync: false })
340
+ for (let i = 0; i < 40; i++) { const s = await h.callTool('vibe_v4_status', {}); if (s.phase === 'active') break; await sleep(20) }
341
+ await sleep(30)
342
+ h.resetCursor()
343
+ return h
344
+ }
345
+ /**
346
+ * Answer the next wake with `replyFor(promptText, rId)`; returns how many wakes were answered.
347
+ * `stop` is evaluated AFTER processing each wake, never before the first one: a run that is simply
348
+ * idle between rounds would otherwise satisfy "no verification in flight" at entry and the loop
349
+ * would exit without answering anything.
350
+ */
351
+ async function drive(h, replyFor, stop, budget = 40) {
352
+ let n = 0
353
+ for (; n < budget; n++) {
354
+ const w = h.nextWake()
355
+ if (!w) break
356
+ const pt = promptOf(w)
357
+ h.fireEnd(w.childId, replyFor(pt, h.ridOfChild(w.childId)))
358
+ await sleep(12)
359
+ if (stop && await stop()) { n++; break }
360
+ }
361
+ return n
362
+ }
363
+ const verifySettled = (h) => async () => { const s = await h.callTool('vibe_v4_status', {}); return s.verifyInProgress === false && s.pendingVerify === null }
364
+ const voteReply = (plan) => (pt, rId) => (/团队验证/.test(pt)
365
+ ? { vote: { verdict: plan(rId), reason: rId + ' 的判断' } }
366
+ : { summary: '继续推进。', solved: false, contextPct: 20 })
367
+ /** Drive the pending verification to a settled state and return the resulting status. */
368
+ async function settleVerify(h, plan = () => 1, budget = 40) {
369
+ await drive(h, voteReply(plan), verifySettled(h), budget)
370
+ return await h.callTool('vibe_v4_status', {})
371
+ }
372
+ /**
373
+ * Record a card for `target`, have r-1 propose verifying it, vote it wherever it lands, and return
374
+ * the settled status. Prompt TEXT is asserted separately (see `h.prompts`) — this helper exercises
375
+ * the real consensus path for the STATE transitions.
376
+ */
377
+ async function proposeAndVote(h, target, opts = {}) {
378
+ const card = opts.card || { id: target, title: target, statement: '待验证对象 ' + target, prob: 0.8, value: 0.6, motivation: 'm' }
379
+ await h.callTool('vibe_v4_record_proposition', card, h.resAgent(h.childOf('r-1')))
380
+ // The heartbeat is parked far away, so drive the FIRST round explicitly: an idle resident is
381
+ // woken immediately by an incoming message (postMessage → wakeResident).
382
+ const before = h.followups.length
383
+ await h.callTool('vibe_v4_message', { to: 'r-1', content: '请处理本轮工作。' })
384
+ for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
385
+ const proposal = opts.proposal || { summary: '提议验证 ' + target + '。', solved: false, propose_verify: target, contextPct: 20 }
386
+ await drive(h, (pt) => (/团队验证/.test(pt)
387
+ ? { vote: { verdict: (opts.verdict === undefined ? 1 : opts.verdict), reason: '预看提示词' } }
388
+ : proposal), verifySettled(h))
389
+ // The verification may only have been QUEUED by the proposal round; finish it.
390
+ return await settleVerify(h, opts.plan || (() => (opts.verdict === undefined ? 1 : opts.verdict)))
391
+ }
392
+ /**
393
+ * Start a verification and KEEP IT IN FLIGHT (the first half of `proposeAndVote`): record a card, have
394
+ * r-1 propose verifying it, and stop driving as soon as the verify view is ACTIVE. Note the trap:
395
+ * `verifyInProgress` is already true while the verification is merely QUEUED, so `consensus.kind`
396
+ * is the only reliable "it is really running" signal.
397
+ */
398
+ async function startVerifyOnly(h, target) {
399
+ const card = { id: target, title: target, statement: '待验证对象 ' + target, prob: 0.8, value: 0.6, motivation: 'm' }
400
+ await h.callTool('vibe_v4_record_proposition', card, h.resAgent(h.childOf('r-1')))
401
+ const before = h.followups.length
402
+ await h.callTool('vibe_v4_message', { to: 'r-1', content: '请处理本轮工作。' })
403
+ for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
404
+ await drive(h, () => ({ summary: '提议验证 ' + target + '。', solved: false, propose_verify: target, contextPct: 20 }), async () => {
405
+ const s = await h.callTool('vibe_v4_status', {})
406
+ return !!(s.consensus && s.consensus.kind === 'verify')
407
+ })
408
+ return await h.callTool('vibe_v4_status', {})
409
+ }
410
+ /** Wake exactly the given resident with an ordinary work round and return that wake. */
411
+ async function workWake(h, rId) {
412
+ const before = h.followups.length
413
+ await h.callTool('vibe_v4_message', { to: rId, content: '请继续推进。' })
414
+ for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
415
+ const w = h.nextWake()
416
+ assert(w !== null, 'a work wake was delivered to ' + rId)
417
+ return w
418
+ }
419
+
420
+ // ===============================================================
421
+ console.log('-- V4 Lean formal verification --')
422
+
423
+ // ---------- 1. 'off' is a true no-op ----------
424
+ section("1 'off' (default) is a true no-op")
425
+ const A = await establish()
426
+ {
427
+ const st = await A.callTool('vibe_v4_status', {})
428
+ assert(st.formal.mode === 'off' && st.formal.on === false, "the default mode is 'off' (got " + st.formal.mode + ')')
429
+ assert(/formalVerify=off/.test(st.params), "status exposes formalVerify=off in its readable params (got " + st.params + ')')
430
+ // Scan BEFORE any hand-written test message is injected: this test's own text would otherwise
431
+ // taint the scan. The scan is on the feature's own TOKENS rather than the bare word 形式化: the
432
+ // pre-existing brainstorm brief already contains 形式化 in the unrelated sentence about being
433
+ // free to invent theories, so asserting the bare word would fail for a reason that has nothing
434
+ // to do with this contract (AUDIT-CHECKLIST §2.2).
435
+ const all = A.allPrompts()
436
+ assert(!/Lean/.test(all), 'no founding/round prompt mentions Lean in off mode')
437
+ assert(!/顺手形式化|Lean 通过|忠实性审查|vibe_v4_lean/.test(all), 'no founding/round prompt carries any Lean-formalization text in off mode')
438
+ assert(all.length > 0, 'sanity: the scan actually saw the founding prompts')
439
+ // `off` must not CREATE Lean state at all: the feature's durable record file must not appear
440
+ // for a session that never used the feature (v3's suite pins the same invariant).
441
+ assert(!existsSync(join(A.projectRoot, 'State', 'formal.json')), '★ off mode creates NO State/formal.json (a TRUE no-op, not just an empty record store)')
442
+ }
443
+ // an off-mode verification must behave exactly as before: unanimous 真 → Verified/, no formal record
444
+ const offSettled = await proposeAndVote(A, 'p-off', {
445
+ card: { id: 'p-off', title: '关模式', statement: '关模式下的普通命题', prob: 0.8, value: 0.6, motivation: 'm' },
446
+ proposal: { summary: '请提议验证 p-off。', solved: false, propose_verify: 'p-off', contextPct: 20 },
447
+ })
448
+ assert(existsSync(join(A.projectRoot, 'Verified', '命题', 'p-off.md')), "★ 'off' still closes a unanimous TRUE verdict with NO Lean artifact")
449
+ assert(offSettled.formal.passed.length === 0 && offSettled.formal.objects.length === 0, "'off' records no formal object at all")
450
+ assert(!/形式化/.test(readIf(join(A.projectRoot, 'Verified', '命题', 'p-off.md'))), 'the Verified card carries no formal line in off mode')
451
+ assert(!existsSync(join(A.projectRoot, 'Formal', 'TODO.md')) || !/p-off/.test(readIf(join(A.projectRoot, 'Formal', 'TODO.md'))), 'no formalization TODO was created for p-off')
452
+ assert(!/顺手形式化|Lean/.test(await A.prompts('verify', 'r-1', { target: 'p-off' })), 'the voting prompt carries no Lean text in off mode')
453
+ assert(!/顺手形式化/.test(await A.prompts('normal', 'r-1')), 'the work prompt carries no formalization line in off mode')
454
+ // the tools still EXIST in off mode (static registration), they are just never advertised
455
+ assert(['vibe_v4_lean_run', 'vibe_v4_lean_archive', 'vibe_v4_lean_lib'].every(n => !!A.toolRegs.find(t => t.name === n)),
456
+ 'the three Lean tools are registered in every mode (registration is static)')
457
+
458
+ // ★ The mode switch must be REACHABLE THROUGH THE TOOL SCHEMA (2.3.2 defect D1) ──────────────
459
+ // Every tool schema here is closed (`additionalProperties:false`), so a key the schema does not
460
+ // advertise is REJECTED by any schema-validating provider. v3 shipped 2.3.0/2.3.1 with all four Lean
461
+ // parameters missing from the set-params schema while every assertion in this file stayed green —
462
+ // because the suite calls the handler DIRECTLY and never inspects the registered schema. The feature
463
+ // could not be switched on at all through the tool interface.
464
+ {
465
+ const setSpec = A.toolRegs.find((t) => t.name === 'vibe_v4_set')
466
+ assert(!!setSpec, "vibe_v4_set is registered")
467
+ assert(setSpec.parameters && setSpec.parameters.type === 'object' && setSpec.parameters.additionalProperties === false,
468
+ '★ vibe_v4_set publishes a CLOSED object schema (an unlisted key is rejected, so the schema IS the contract)')
469
+ for (const k of ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']) {
470
+ assert(Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k),
471
+ '★ the registered schema advertises ' + k + ' (every other surface documents it; a schema that omits it makes the switch unreachable)')
472
+ }
473
+ assert(JSON.stringify(setSpec.parameters.properties.formalVerify.enum) === JSON.stringify(['off', 'encourage', 'require']),
474
+ 'the schema narrows formalVerify to the three real modes (a typo must not be a fourth)')
475
+ }
476
+
477
+ // A stray `formal` reply in OFF mode must be INERT (finding #1): the reply contract does not offer
478
+ // the field there, so honouring it would contradict "off is a TRUE no-op". The TOOLS stay usable.
479
+ {
480
+ await A.callTool('vibe_v4_record_proposition', { id: 'p-offreply', title: 'off', statement: '关模式下注入回执', prob: 0.8, value: 0.6, motivation: 'm' }, A.resAgent(A.childOf('r-1')))
481
+ const wOffR = await workWake(A, 'r-1')
482
+ A.fireEnd(wOffR.childId, { summary: '继续。', solved: false, contextPct: 20, formal: { target: 'p-offreply', decision: 'defect', note: '不应被记录' } })
483
+ await sleep(80)
484
+ const stOffReply = await A.callTool('vibe_v4_status', {})
485
+ assert(stOffReply.formal.objects.length === 0, '★ a stray `formal` reply in off mode records NO formal object')
486
+ assert(!existsSync(join(A.projectRoot, 'Formal', 'p-offreply.lean')), '★ and writes no formal working file')
487
+ assert(!existsSync(join(A.projectRoot, 'Formal', 'TODO.md')) || !/p-offreply/.test(readIf(join(A.projectRoot, 'Formal', 'TODO.md'))), '★ and creates no TODO entry')
488
+ assert(!existsSync(join(A.projectRoot, 'State', 'formal.json')), '★ and still no State/formal.json after the stray reply (the off-mode guard is a true no-op)')
489
+ }
490
+
491
+ // ---------- 2. parameter validation + runtime switching ----------
492
+ section('2 parameter validation and runtime switching')
493
+ const B = await establish()
494
+ {
495
+ const bad = await B.callTool('vibe_v4_set', { formalVerify: 'banana' })
496
+ assert(bad.formal.mode === 'off' && /formalVerify=off/.test(bad.params), "an unknown mode degrades to 'off', never to a stronger mode (got " + bad.formal.mode + ')')
497
+ const enc = await B.callTool('vibe_v4_set', { formalVerify: 'encourage' })
498
+ assert(enc.formal.mode === 'encourage', "'encourage' is accepted")
499
+ const req = await B.callTool('vibe_v4_set', { formalVerify: 'require', leanTimeoutMs: -5, leanCommand: ' ' })
500
+ assert(req.formal.mode === 'require', "'require' is accepted")
501
+ assert(/leanTimeoutMs=120000/.test(req.params), 'a non-positive leanTimeoutMs falls back to the default (' + (/leanTimeoutMs=\S+/.exec(req.params) || [])[0] + ')')
502
+ assert(/leanCommand=lean/.test(req.params), 'a blank leanCommand falls back to "lean"')
503
+ const lk = await B.callTool('vibe_v4_set', { leanCommand: 'lake', leanArgs: ['env', 'lean'] })
504
+ assert(/leanCommand=lake/.test(lk.params) && /leanArgs=env,lean/.test(lk.params), 'leanCommand/leanArgs are settable (lake env lean)')
505
+ const la = await B.callTool('vibe_v4_set', { leanArgs: 'a, b ,,c' })
506
+ assert(/leanArgs=a,b,c/.test(la.params), 'leanArgs accepts a comma-separated string and drops blanks (got ' + (/leanArgs=[^,]*,[^,]*,[^,]*/.exec(la.params) || [])[0] + ')')
507
+ await B.callTool('vibe_v4_set', { leanTimeoutMs: 5 })
508
+ assert(/leanTimeoutMs=5/.test((await B.callTool('vibe_v4_status', {})).params), 'a legitimate leanTimeoutMs is preserved')
509
+ await B.callTool('vibe_v4_set', { leanTimeoutMs: 0 })
510
+ assert(/leanTimeoutMs=120000/.test((await B.callTool('vibe_v4_status', {})).params), 'leanTimeoutMs:0 also falls back to the default')
511
+ // the switch must be visible to the very NEXT prompt, not to some frozen brief
512
+ await B.callTool('vibe_v4_set', { formalVerify: 'encourage' })
513
+ assert(/顺手形式化/.test(await B.prompts('normal', 'r-1')), '★ switching the mode at runtime changes the NEXT prompt immediately')
514
+ await B.callTool('vibe_v4_set', { formalVerify: 'off' })
515
+ assert(!/顺手形式化/.test(await B.prompts('normal', 'r-1')), 'switching back to off removes it again')
516
+ }
517
+
518
+ // ---------- 3. 'encourage' injection into BOTH prompts ----------
519
+ section("3 'encourage' injects the Lean section into the verification AND the work prompt")
520
+ const C = await establish()
521
+ await C.callTool('vibe_v4_set', { formalVerify: 'encourage' })
522
+ {
523
+ // The ordinary work round. Read the builder AND drive a real wake, so the assertion cannot pass
524
+ // on a builder that is never actually used to address a resident.
525
+ const built = await C.prompts('normal', 'r-2')
526
+ assert(/【顺手形式化(鼓励)】/.test(built), '★ the ordinary work round tells residents to formalize reusable objects as they go')
527
+ assert(/vibe_v4_lean_archive kind='def'/.test(built), 'the work round points at the archive tool for reusable definitions')
528
+ assert(/vibe_v4_lean_lib 查重/.test(built), 'the work round tells them to de-duplicate against the reuse library first')
529
+ assert(/"formal":/.test(built), 'the work reply contract documents the formal field')
530
+ assert(/Resident researcher r-2/.test(built), 'the work prompt is addressed to the resident it is built for')
531
+ const hb = await C.prompts('heartbeat', 'r-2')
532
+ assert(/【顺手形式化(鼓励)】/.test(hb), 'the CHECKPOINT/heartbeat round carries it too')
533
+ const w = await workWake(C, 'r-2')
534
+ assert(/【顺手形式化(鼓励)】/.test(promptOf(w)), '★ a REAL delivered work wake contains the line (the builder is the one in use)')
535
+ C.fireEnd(w.childId, { summary: '继续推进。', solved: false, contextPct: 20 })
536
+ await sleep(30)
537
+ const core = await C.prompts('coreRules')
538
+ assert(/\[核心规则重申\]/.test(core) && /【顺手形式化(鼓励)】/.test(core), 'the post-compact core-rules recap also carries the formalization line')
539
+ }
540
+ {
541
+ await proposeAndVote(C, 'p-enc', {
542
+ card: { id: 'p-enc', title: '鼓励注入', statement: '鼓励模式下的忠实性审查', prob: 0.5, value: 0.6, motivation: 'm' },
543
+ proposal: { summary: '提议验证 p-enc。', solved: false, propose_verify: 'p-enc', contextPct: 20 },
544
+ verdict: 0.5, plan: () => 0.5,
545
+ })
546
+ const prompt = await C.prompts('verify', 'r-1', { target: 'p-enc', stage: 'independent' })
547
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(prompt), 'the voting prompt explains the Lean mode')
548
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(prompt), 'the voting prompt states that a passing Lean run shrinks the question to fidelity')
549
+ assert(/实现难度/.test(prompt), 'the voting prompt asks for the implementation-difficulty judgement')
550
+ assert(/可以不做,但请在回执的 formal 字段写明难度判断/.test(prompt), "'encourage' explicitly allows skipping (with a recorded judgement)")
551
+ assert(/vibe_v4_lean_run/.test(prompt) && /vibe_v4_lean_archive/.test(prompt) && /vibe_v4_lean_lib/.test(prompt), 'the voting prompt names all three tools')
552
+ assert(/"formal":/.test(prompt), '★ the voting reply contract documents the formal field')
553
+ assert(/Resident r-1/.test(prompt), 'the voting prompt is addressed to the voter it is built for')
554
+ assert(!/强制模式/.test(prompt), "'encourage' does not use the mandatory wording")
555
+ }
556
+
557
+ // ---------- 4. the run tool ----------
558
+ section('4 lean_run executes through the subprocess service and reports honestly')
559
+ const D = await establish()
560
+ await D.callTool('vibe_v4_set', { formalVerify: 'encourage' })
561
+ mkdirSync(join(D.projectRoot, 'Formal'), { recursive: true })
562
+ writeFileSync(join(D.projectRoot, 'Formal', 'good.lean'), 'theorem t : 1 = 1 := rfl\n', 'utf8')
563
+ writeFileSync(join(D.projectRoot, 'Formal', 'bad.lean'), 'theorem t : 1 = 2 := by sorry\n', 'utf8')
564
+ const r1 = D.resAgent(D.childOf('r-1'))
565
+ const runGood = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.lean' }, r1)
566
+ assert(runGood.ok === true && runGood.exitCode === 0, 'a file with no sorry runs green (' + JSON.stringify({ ok: runGood.ok, exitCode: runGood.exitCode }) + ')')
567
+ assert(typeof runGood.ms === 'number' && typeof runGood.command === 'string' && runGood.file === 'Formal/good.lean', 'the result carries {ms, command, file} as the spec requires')
568
+ assert(leanRuns.length > 0 && String(leanRuns[leanRuns.length - 1].cwd).replace(/\\/g, '/') === D.projectRoot.replace(/\\/g, '/'), 'the toolchain runs with the PROJECT root as cwd')
569
+ {
570
+ const last = leanRuns[leanRuns.length - 1]
571
+ assert(String(last.argv[0]).indexOf('lean') !== -1 && /\.lean$/.test(String(last.argv[last.argv.length - 1])), 'argv is [exe, ...leanArgs, absolute file]')
572
+ assert(last.stdio && last.stdio.stdin === 'ignore', "stdin is ignored (the spec's stdio shape)")
573
+ }
574
+ const runBad = await D.callTool('vibe_v4_lean_run', { file: 'Formal/bad.lean' }, r1)
575
+ assert(runBad.ok === false && runBad.exitCode === 1, 'a file that still uses sorry reports a red run')
576
+ assert(/sorry/.test(runBad.stderr), 'the compiler output is returned verbatim (' + JSON.stringify(runBad.stderr).slice(0, 60) + ')')
577
+ assert(runBad.code === 'LEAN_FAILED', 'a red run is typed LEAN_FAILED')
578
+ const runMissing = await D.callTool('vibe_v4_lean_run', { file: 'Formal/nope.lean' }, r1)
579
+ assert(runMissing.ok === false && runMissing.code === 'V4_NOT_FOUND', 'a missing file is refused with a typed code')
580
+ const runNoFile = await D.callTool('vibe_v4_lean_run', {}, r1)
581
+ assert(runNoFile.ok === false && runNoFile.code === 'V4_INVALID_ARGUMENT', 'a run without a file is refused')
582
+ // The guard's boundary is the VibeMath ROOT, not the project: the global reuse library
583
+ // deliberately lives at <VibeMath>/Formal/{Lib,Proved}, so climbing out of the project but staying
584
+ // inside VibeMath must remain legal (it just fails as a missing file).
585
+ const runOutside = await D.callTool('vibe_v4_lean_run', { file: '../../Formal/Lib/x.lean' }, r1)
586
+ assert(runOutside.code === 'V4_NOT_FOUND', 'climbing out of the project but staying inside VibeMath is allowed (the global library lives there)')
587
+ const esc1 = await D.callTool('vibe_v4_lean_run', { file: '../../../../../etc/evil.lean' }, r1)
588
+ assert(esc1.ok === false && esc1.code === 'V4_INVALID_ARGUMENT', '★ a traversal that climbs ABOVE the VibeMath root is refused')
589
+ const esc2 = await D.callTool('vibe_v4_lean_run', { file: 'Formal/../../../../../../evil.lean' }, r1)
590
+ assert(esc2.ok === false && esc2.code === 'V4_INVALID_ARGUMENT', 'a deeper traversal is refused too')
591
+ const esc3 = await D.callTool('vibe_v4_lean_run', { file: '/etc/evil.lean' }, r1)
592
+ assert(esc3.ok === false && esc3.code === 'V4_INVALID_ARGUMENT', 'an unrelated absolute path is refused')
593
+ const esc4 = await D.callTool('vibe_v4_lean_run', { file: 'C:\\Windows\\evil.lean' }, r1)
594
+ assert(esc4.ok === false && esc4.code === 'V4_INVALID_ARGUMENT', 'an unrelated absolute path with a drive letter is refused')
595
+ const notLean = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.txt' }, r1)
596
+ assert(notLean.ok === false && notLean.code === 'V4_INVALID_ARGUMENT', 'only .lean files can be executed')
597
+ toolchainAvailable = false
598
+ // task-6: "no toolchain" must ALSO cover the known-location fallback — on a machine with Lean installed
599
+ // (`%ELAN_HOME%\bin`, `~/.elan/bin`) an unresolvable PATH would otherwise fall through to it and the run
600
+ // would succeed. Isolate the environment sources for the duration of this simulation.
601
+ const _savedTcEnv = {}
602
+ for (const _k of ['ELAN_HOME', 'USERPROFILE', 'HOME', 'LOCALAPPDATA']) _savedTcEnv[_k] = process.env[_k]
603
+ process.env.ELAN_HOME = 'Z:/no-elan'; process.env.USERPROFILE = 'Z:/no-home'; process.env.HOME = 'Z:/no-home'; process.env.LOCALAPPDATA = 'Z:/no-local'
604
+ let noTc
605
+ try { noTc = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.lean' }, r1) }
606
+ finally { for (const _k of Object.keys(_savedTcEnv)) { if (_savedTcEnv[_k] === undefined) delete process.env[_k]; else process.env[_k] = _savedTcEnv[_k] } }
607
+ assert(noTc.ok === false && noTc.code === 'LEAN_NOT_FOUND', '★ a missing toolchain returns LEAN_NOT_FOUND instead of crashing')
608
+ assert(/仍可把形式化代码写下来归档/.test(noTc.message || ''), 'the failure explains the graceful degradation')
609
+ assert((await D.callTool('vibe_v4_status', {})).ok === true, 'the group did NOT crash on the missing toolchain (status still answers)')
610
+ toolchainAvailable = true
611
+ // No `subprocess` service at all (docs §7): the run must degrade to a typed NO_SUBPROCESS result —
612
+ // never a thrown error into the scheduler — and an object it was asked to record must stay
613
+ // `attempted` (an un-runnable host must not mint a `passed` record).
614
+ {
615
+ subprocessAvailable = false
616
+ const noSub = await D.callTool('vibe_v4_lean_run', { file: 'Formal/good.lean', target: 'p-nosub' }, r1)
617
+ assert(noSub.ok === false && noSub.code === 'NO_SUBPROCESS', '★ a host with no subprocess service returns NO_SUBPROCESS instead of crashing (got ' + noSub.code + ')')
618
+ assert(/no subprocess service/.test(String(noSub.message || '')), 'the failure is readable (it explains that Lean cannot be executed here)')
619
+ const stNoSub = await D.callTool('vibe_v4_status', {})
620
+ assert(stNoSub.ok === true, 'the group did NOT crash on the missing service (status still answers)')
621
+ const recNoSub = stNoSub.formal.objects.find((o) => o.target === 'p-nosub')
622
+ assert(recNoSub && recNoSub.status === 'attempted' && !recNoSub.proof, '★ the object record stays honest: attempted (never passed) when nothing could be executed')
623
+ subprocessAvailable = true
624
+ }
625
+ // A run that never finishes: the framework must ACTIVELY terminate it (docs §7) and still report a
626
+ // readable LEAN_TIMEOUT — relying on the host's own graceMs alone leaves the Lean process running.
627
+ {
628
+ writeFileSync(join(D.projectRoot, 'Formal', 'hang.lean'), '-- HANG\ntheorem t : 1 = 1 := rfl\n', 'utf8')
629
+ const t0 = Date.now()
630
+ const runHang = await D.callTool('vibe_v4_lean_run', { file: 'Formal/hang.lean', timeout_ms: 1000 }, r1)
631
+ const waited = Date.now() - t0
632
+ assert(runHang.ok === false && runHang.code === 'LEAN_TIMEOUT' && runHang.timedOut === true, '★ a run that never finishes is reported as LEAN_TIMEOUT (got ' + runHang.code + ')')
633
+ assert(terminated.some(f => /hang\.lean$/.test(String(f))), '★ the timeout path really calls handle.terminate() (docs §7) instead of leaving the Lean process running')
634
+ assert(waited >= 900 && waited < 15000, 'the timeout waited for the cap before terminating (' + waited + 'ms)')
635
+ }
636
+
637
+ // ---------- 5. a passing proof flips the review subject to fidelity ----------
638
+ section('5 a passing proof flips the review subject to fidelity')
639
+ const arc = await D.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-proof', content: 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' }, r1)
640
+ assert(arc.ok === true && arc.passed === true, 'the proof is archived and passes (' + JSON.stringify({ ok: arc.ok, passed: arc.passed }) + ')')
641
+ assert(arc.file === 'Formal/p-proof.lean', 'the working file is Formal/<target>.lean')
642
+ assert(arc.proof === 'Verified/Lean/p-proof.lean', 'the archived proof path is Verified/Lean/<target>.lean')
643
+ assert(existsSync(join(D.projectRoot, 'Formal', 'p-proof.lean')), 'the working file exists on disk')
644
+ assert(existsSync(join(D.projectRoot, 'Verified', 'Lean', 'p-proof.lean')), '★ the proof is archived under Verified/Lean/ as the proof of that object')
645
+ const stD = await D.callTool('vibe_v4_status', {})
646
+ assert(stD.formal.passed.indexOf('p-proof') !== -1, 'status reports the object as Lean-passed')
647
+ const idxD = readIf(join(D.projectRoot, 'Formal', 'Index.md'))
648
+ assert(/p-proof/.test(idxD) && /passed/.test(idxD) && /Verified\/Lean\/p-proof\.lean/.test(idxD), 'Formal/Index.md indexes the object, its status and its archived proof')
649
+ // A later run recorded against an ALREADY-passed object records the run but must never strip
650
+ // `passed`/`proof`: doing so would silently remove the fidelity branch from the next voting prompt
651
+ // AND (in `require`) re-close the gate on an object that already has a green archived proof.
652
+ {
653
+ writeFileSync(join(D.projectRoot, 'Formal', 'p-proof.lean'), 'theorem p_proof : 1 = 2 := by sorry\n', 'utf8')
654
+ const rerun = await D.callTool('vibe_v4_lean_run', { file: 'Formal/p-proof.lean', target: 'p-proof' }, r1)
655
+ assert(rerun.ok === false, 'precondition: the recorded re-run is red')
656
+ const rec = (await D.callTool('vibe_v4_status', {})).formal.objects.find(o => o.target === 'p-proof')
657
+ assert(rec && rec.status === 'passed' && rec.proof === 'Verified/Lean/p-proof.lean',
658
+ '★ a RED re-run never downgrades a `passed` record (docs §31.6: only an explicit re-archive decides) — the object keeps its fidelity prompt and its require-mode gate')
659
+ writeFileSync(join(D.projectRoot, 'Formal', 'p-proof.lean'), 'theorem p_proof : 3 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n', 'utf8')
660
+ }
661
+ // a RED proof must NOT mint a Verified/Lean/ copy or mark the object passed
662
+ const arcRed = await D.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-red', content: 'theorem p_red : 1 = 2 := by sorry\n' }, r1)
663
+ assert(arcRed.ok === true && arcRed.passed === false && arcRed.status === 'attempted', 'a red proof is archived as attempted, not passed')
664
+ assert(!existsSync(join(D.projectRoot, 'Verified', 'Lean', 'p-red.lean')), '★ a red run does NOT mint a Verified/Lean proof copy')
665
+ // durable state: the record survives a resume (v4 has no session projection)
666
+ {
667
+ const raw = readIf(join(D.projectRoot, 'State', 'formal.json'))
668
+ assert(/"p-proof"/.test(raw) && /"passed"/.test(raw), "★ the formal record is persisted in v4's own durable State/formal.json")
669
+ const before = D.spawns.length
670
+ await D.callTool('vibe_v4_abort', {})
671
+ const res = await D.callTool('vibe_v4_resume', {})
672
+ assert(res.ok === true && D.spawns.length > before, 'abort → resume re-spawns residents')
673
+ const stR = await D.callTool('vibe_v4_status', {})
674
+ assert(stR.formal.passed.indexOf('p-proof') !== -1, '★ the Lean-passed record SURVIVES resume (durable state, not an in-memory projection)')
675
+ for (const sp of D.spawns.slice(before)) { D.fireEnd(sp.childId, { summary: '恢复见解。', solved: false, contextPct: 10 }); await sleep(25) }
676
+ await D.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
677
+ D.resetCursor()
678
+ }
679
+ // now the voting prompt must ASK FOR FIDELITY, not for a re-derivation
680
+ {
681
+ await proposeAndVote(D, 'p-proof', {
682
+ card: { id: 'p-proof', title: '忠实性', statement: '3N²−2=b² 在 N=1 时成立', prob: 0.9, value: 0.6, motivation: 'm' },
683
+ proposal: { summary: '提议验证 p-proof。', solved: false, propose_verify: 'p-proof', contextPct: 20 },
684
+ verdict: 0.5, plan: () => 0.5,
685
+ })
686
+ const prompt = await D.prompts('verify', 'r-1', { target: 'p-proof', stage: 'independent' })
687
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(prompt), 'the voting prompt announces the passing proof')
688
+ assert(/你不需要重新检查推导/.test(prompt), '★ it tells voters NOT to re-derive')
689
+ assert(/忠实性审查/.test(prompt), '★ it tells voters the review subject is now fidelity')
690
+ assert(/定义 \/ 对象 \/ 条件 \/ 假设 \/ 结论是否与命题原文\*\*完全一致\*\*/.test(prompt), 'it enumerates exactly what fidelity means')
691
+ assert(/一致 → verdict = 1/.test(prompt), "the fidelity instruction uses v4's REAL reply field name (`verdict`)")
692
+ assert(/发现任何偏差,不要投 0/.test(prompt), '★ it forbids expressing a fidelity deviation as "0 / false"')
693
+ assert(/偏差只说明\*\*形式化不合格\*\*,不代表命题为假/.test(prompt), '★ it states WHY: a defect is a failed formalization, not a refutation')
694
+ assert(/formal:\{decision:'defect', note:'<具体偏差>'\}/.test(prompt), '★ it hands the voter the exact `defect` reply contract')
695
+ assert(/降级为 attempted、删除归档证明、写入形式化待办/.test(prompt), 'it says what the framework WILL do with a defect (downgrade + delete + TODO)')
696
+ assert(/只有当你\*\*独立于这份 Lean 代码\*\*也能确定命题为假时,才投 0/.test(prompt), 'only an INDEPENDENT refutation may be expressed as 0')
697
+ assert(!/偏离 → 0/.test(prompt), '★ the old "a deviation ⇒ 0" wording is gone (it would fabricate a negative conclusion)')
698
+ assert(/Verified\/Lean\/p-proof\.lean/.test(prompt), 'it points at the archived proof')
699
+ // the SAME object's work round must not inherit the fidelity framing (that is a voting
700
+ // instruction), but it does carry the standing formalization line.
701
+ const work = await D.prompts('normal', 'r-1')
702
+ assert(!/忠实性审查/.test(work), 'the fidelity instruction is confined to the voting prompt')
703
+ assert(/【顺手形式化(鼓励)】/.test(work), 'the work round carries the standing formalization line instead')
704
+ }
705
+
706
+ // ---------- 6. the reusable cross-project library ----------
707
+ section('6 reusable definitions and lemmas go to the GLOBAL library')
708
+ const libPath = join(D.vibeRoot, 'Formal', 'Lib')
709
+ const provedPath = join(D.vibeRoot, 'Formal', 'Proved')
710
+ const defRes = await D.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'ZMod5', content: 'def ZMod5 := Fin 5\n' }, r1)
711
+ assert(defRes.ok === true && defRes.file === 'Formal/Lib/ZMod5.lean', 'a reusable definition is archived to the global lib (' + defRes.file + ')')
712
+ assert(existsSync(join(libPath, 'ZMod5.lean')), '★ the definition exists under VibeMath/Formal/Lib/ (cross-project, NOT inside the project)')
713
+ assert(!existsSync(join(D.projectRoot, 'Formal', 'Lib', 'ZMod5.lean')), 'it is NOT duplicated inside the project tree')
714
+ const lemRes = await D.callTool('vibe_v4_lean_archive', { kind: 'lemma', name: 'sq_odd', content: 'theorem sq_odd (n : Nat) : Odd (n*n) → Odd n := by omega\n' }, r1)
715
+ assert(lemRes.ok === true && lemRes.file === 'Formal/Proved/sq_odd.lean', 'a lemma is archived to Proved/')
716
+ assert(existsSync(join(provedPath, 'sq_odd.lean')), 'the lemma exists under VibeMath/Formal/Proved/')
717
+ assert(/ZMod5/.test(readIf(join(libPath, 'Index.md'))), 'Lib/Index.md lists the new definition')
718
+ assert(/sq_odd/.test(readIf(join(provedPath, 'Index.md'))), 'Proved/Index.md lists the new lemma')
719
+ // `from` copies an existing workspace .lean file instead of inline content
720
+ writeFileSync(join(D.projectRoot, 'Formal', 'PLUGIN_SRC().lean'), 'def copied := 7\n', 'utf8')
721
+ const fromRes = await D.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'Copied', from: 'Formal/PLUGIN_SRC().lean' }, r1)
722
+ assert(fromRes.ok === true && readIf(join(libPath, 'Copied.lean')) === 'def copied := 7\n', "kind='def' accepts from=<existing .lean file>")
723
+ const fromEsc = await D.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'Evil', from: '../../../../etc/passwd' }, r1)
724
+ assert(fromEsc.ok === false && fromEsc.code === 'V4_INVALID_ARGUMENT', "★ kind='def' refuses a `from` outside the VibeMath root")
725
+ const libList = await D.callTool('vibe_v4_lean_lib', {}, r1)
726
+ assert(libList.ok === true && libList.counts.lib >= 2 && libList.counts.proved >= 1, 'lean_lib reports the reuse library sizes (' + JSON.stringify(libList.counts) + ')')
727
+ assert(libList.objects.some(o => o.target === 'p-proof' && o.status === 'passed'), 'lean_lib lists per-object formal status')
728
+ assert(/复用优先/.test(libList.hint || ''), 'lean_lib tells agents to reuse before redefining')
729
+ assert(libList.paths && /Formal\/Lib/.test(libList.paths.lib), 'lean_lib reports the global library paths')
730
+ const noName = await D.callTool('vibe_v4_lean_archive', { kind: 'def', content: 'def x := 1\n' }, r1)
731
+ assert(noName.ok === false && noName.code === 'V4_INVALID_ARGUMENT', 'archiving a definition without a name is refused')
732
+ const noBody = await D.callTool('vibe_v4_lean_archive', { kind: 'lemma', name: 'empty' }, r1)
733
+ assert(noBody.ok === false && noBody.code === 'V4_INVALID_ARGUMENT', 'archiving without content or from is refused')
734
+ const badKind = await D.callTool('vibe_v4_lean_archive', { kind: 'nonsense' }, r1)
735
+ assert(badKind.ok === false && badKind.code === 'V4_INVALID_ARGUMENT', 'an unknown archive kind is refused')
736
+ const libNoRefresh = await D.callTool('vibe_v4_lean_lib', { refresh: false }, r1)
737
+ assert(libNoRefresh.ok === true && libNoRefresh.rebuilt === false && libNoRefresh.counts.lib === null, 'refresh:false reads without rebuilding')
738
+
739
+ // ---------- 7. blocked needs a reason ----------
740
+ section('7 a "blocker" record must be explicit and reasoned')
741
+ const blkNoNote = await D.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blk' }, r1)
742
+ assert(blkNoNote.ok === false && blkNoNote.code === 'V4_INVALID_ARGUMENT', '★ blocked without a note is refused')
743
+ const blkBlank = await D.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blk', note: ' ' }, r1)
744
+ assert(blkBlank.ok === false && blkBlank.code === 'V4_INVALID_ARGUMENT', 'a whitespace-only note is refused too')
745
+ const blk = await D.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blk', note: '需要外层解析数论框架,本轮工作量不可接受' }, r1)
746
+ assert(blk.ok === true && blk.status === 'blocked', 'a reasoned blocker is recorded')
747
+ const stBlk = await D.callTool('vibe_v4_status', {})
748
+ assert(stBlk.formal.blocked.indexOf('p-blk') !== -1, 'status lists the blocked object')
749
+ assert(/需要外层解析数论框架/.test(readIf(join(D.projectRoot, 'Formal', 'Index.md'))), 'the blocker reason is written into the index')
750
+
751
+ // ---------- 8. the per-round `formal` reply channel ----------
752
+ section('8 the per-round `formal` reply judgement is recorded')
753
+ {
754
+ const w1 = await workWake(D, 'r-1')
755
+ D.fireEnd(w1.childId, { summary: '难度太高,本轮不做形式化。', formal: { target: 'p-reply', decision: 'blocked', note: '需要大量未形式化的实分析前置知识' }, contextPct: 20 })
756
+ await sleep(60)
757
+ const st = await D.callTool('vibe_v4_status', {})
758
+ assert(st.formal.blocked.indexOf('p-reply') !== -1, '★ a `formal.decision=blocked` reply is recorded as a blocker')
759
+ assert(/实分析前置知识/.test(readIf(join(D.projectRoot, 'Formal', 'Index.md'))), 'and its reason reaches the index')
760
+ }
761
+ {
762
+ const w2 = await workWake(D, 'r-2')
763
+ D.fireEnd(w2.childId, { summary: '我写了形式化草稿。', formal: { target: 'p-reply2', decision: 'used', file: 'Formal/p-reply2.lean' }, contextPct: 20 })
764
+ await sleep(60)
765
+ const st = await D.callTool('vibe_v4_status', {})
766
+ assert(st.formal.objects.some(o => o.target === 'p-reply2' && o.status === 'attempted'),
767
+ 'a `formal.decision=used` reply records the object as ATTEMPTED with its file (no green run yet)')
768
+ }
769
+ {
770
+ const w3 = await workWake(D, 'r-1')
771
+ D.fireEnd(w3.childId, { summary: '不做。', formal: { target: 'p-reply3', decision: 'blocked' }, contextPct: 20 })
772
+ await sleep(60)
773
+ const st = await D.callTool('vibe_v4_status', {})
774
+ assert(!st.formal.objects.some(o => o.target === 'p-reply3'), 'a blocker with no note creates NO record')
775
+ assert(st.ok === true, 'the refusal did not crash the run')
776
+ }
777
+
778
+ // ---------- 9. the 'require' gate ----------
779
+ section("9 'require' withholds a verdict until the formal record exists")
780
+ const E = await establish()
781
+ await E.callTool('vibe_v4_set', { formalVerify: 'require' })
782
+ {
783
+ await proposeAndVote(E, 'p-gate', {
784
+ card: { id: 'p-gate', title: '门禁', statement: '必须形式化的命题', prob: 0.8, value: 0.6, motivation: 'm' },
785
+ proposal: { summary: '提议验证 p-gate。', solved: false, propose_verify: 'p-gate', contextPct: 20 },
786
+ verdict: 1,
787
+ })
788
+ const prompt = await E.prompts('verify', 'r-1', { target: 'p-gate', stage: 'independent' })
789
+ assert(/【Lean 形式化验证(强制模式)】/.test(prompt), 'the voting prompt says 强制模式')
790
+ assert(/必须产出 Lean 形式化/.test(prompt), "'require' states the formalization is mandatory")
791
+ assert(/本次裁定不会生效/.test(prompt), 'the prompt warns that the verdict will not take effect without it')
792
+ assert(!/可以不做/.test(prompt), "'require' does NOT offer the encourage-mode opt-out")
793
+ }
794
+ const gated = await E.callTool('vibe_v4_status', {})
795
+ assert(gated.verifyInProgress === false && gated.pendingVerify === null, 'the verification actually RAN to a settled state (so the next assertion is falsifiable)')
796
+ assert(!existsSync(join(E.projectRoot, 'Verified', '命题', 'p-gate.md')), '★ a unanimous TRUE verdict did NOT promote the object to Verified/')
797
+ assert(gated.formal.todo.indexOf('p-gate') !== -1, '★ it is recorded as 未定论 (a formalization TODO entry)')
798
+ assert(gated.formal.objects.some(o => o.target === 'p-gate' && o.status === 'none'), 'the object keeps a `none` formal record (never silently marked passed)')
799
+ assert(!/已验证·真/.test(readIf(join(E.projectRoot, 'Propos', 'r-1', 'p-gate.md'))), 'the source card was NOT rewritten to 已验证·真 (the conclusion was withheld, not taken)')
800
+ const todoE = readIf(join(E.projectRoot, 'Formal', 'TODO.md'))
801
+ assert(existsSync(join(E.projectRoot, 'Formal', 'TODO.md')), 'Formal/TODO.md was created')
802
+ assert(/p-gate/.test(todoE) && /formal-required/.test(todoE), '★ the object is on the formalization TODO with the machine-readable reason')
803
+ assert(!/已验证·真/.test(readIf(join(E.projectRoot, 'Shared', 'debates', 'p-gate.md'))), 'the debate record does NOT claim a 真 conclusion')
804
+ assert(/formal-required/.test(readIf(join(E.projectRoot, 'Formal', 'Index.md'))), 'the index carries the withheld verdict too')
805
+ // now formalize it and re-verify: the gate must open
806
+ const proofNow = await E.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-gate', content: 'theorem p_gate : 2 + 2 = 4 := by decide\n' }, E.resAgent(E.childOf('r-1')))
807
+ assert(proofNow.ok === true && proofNow.passed === true, 'the object is now Lean-passed')
808
+ {
809
+ await proposeAndVote(E, 'p-gate', {
810
+ card: { id: 'p-gate', title: '门禁', statement: '必须形式化的命题', prob: 0.8, value: 0.6, motivation: 'm' },
811
+ proposal: { summary: '已形式化,重新提议验证 p-gate。', solved: false, propose_verify: 'p-gate', contextPct: 20 },
812
+ verdict: 1,
813
+ })
814
+ const prompt = await E.prompts('verify', 'r-1', { target: 'p-gate', stage: 'independent' })
815
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(prompt), 'the re-verify prompt now points at the passing proof')
816
+ }
817
+ const gated2 = await E.callTool('vibe_v4_status', {})
818
+ assert(existsSync(join(E.projectRoot, 'Verified', '命题', 'p-gate.md')), '★ with a passing Lean artifact the same verdict DOES promote it')
819
+ const card = readIf(join(E.projectRoot, 'Verified', '命题', 'p-gate.md'))
820
+ assert(/- 形式化: Lean 通过/.test(card), '★ the Verified card records how strong the result is (Lean 通过)')
821
+ assert(/Verified\/Lean\/p-gate\.lean/.test(card), 'the card points at the archived proof')
822
+ assert(gated2.formal.todo.indexOf('p-gate') === -1, 'the satisfied TODO entry was cleared')
823
+ // the blocker escape hatch must also open the gate
824
+ await E.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-blocked-ok', note: '命题涉及未形式化的分析学,本轮不做' }, E.resAgent(E.childOf('r-1')))
825
+ await proposeAndVote(E, 'p-blocked-ok', {
826
+ card: { id: 'p-blocked-ok', title: '阻塞放行', statement: '记录阻塞后可定论', prob: 0.8, value: 0.6, motivation: 'm' },
827
+ proposal: { summary: '提议验证 p-blocked-ok。', solved: false, propose_verify: 'p-blocked-ok', contextPct: 20 },
828
+ verdict: 1,
829
+ })
830
+ assert(existsSync(join(E.projectRoot, 'Verified', '命题', 'p-blocked-ok.md')), '★ an explicit reasoned blocker also lets the verdict through (decide by difficulty, but decide out loud)')
831
+ assert(/- 形式化: 阻塞(/.test(readIf(join(E.projectRoot, 'Verified', '命题', 'p-blocked-ok.md'))), 'the card records the blocker')
832
+ assert((await E.callTool('vibe_v4_status', {})).formal.blocked.indexOf('p-blocked-ok') !== -1, 'status lists it as blocked')
833
+ // a `require`-mode FALSE verdict must be gated exactly the same way (the gate sits on the choke
834
+ // point, not on the 真 branch): assert the tally was recorded BEFORE asserting it was withheld.
835
+ await proposeAndVote(E, 'p-gate-false', {
836
+ card: { id: 'p-gate-false', title: '假也要门禁', statement: '一个会被一致判假的命题', prob: 0.1, value: 0.6, motivation: 'm' },
837
+ proposal: { summary: '提议验证 p-gate-false。', solved: false, propose_verify: 'p-gate-false', contextPct: 20 },
838
+ verdict: 0,
839
+ })
840
+ const gatedF = await E.callTool('vibe_v4_status', {})
841
+ assert(!existsSync(join(E.projectRoot, 'Verified', '命题', 'p-gate-false.md')), '★ a unanimous FALSE verdict is gated too')
842
+ assert(gatedF.formal.todo.indexOf('p-gate-false') !== -1, 'the false verdict is recorded as 未定论 as well')
843
+ assert(!/已验证·假/.test(readIf(join(E.projectRoot, 'Propos', 'r-1', 'p-gate-false.md'))), 'the source card was not rewritten to 已验证·假')
844
+
845
+ // ---------- 10. 'encourage' does NOT gate ----------
846
+ section("10 'encourage' has no gate (the verdict still closes)")
847
+ const F = await establish()
848
+ await F.callTool('vibe_v4_set', { formalVerify: 'encourage' })
849
+ await proposeAndVote(F, 'p-enc-ok', {
850
+ card: { id: 'p-enc-ok', title: '鼓励不门禁', statement: '鼓励模式下无形式化产物也能定论', prob: 0.8, value: 0.6, motivation: 'm' },
851
+ proposal: { summary: '提议验证 p-enc-ok。', solved: false, propose_verify: 'p-enc-ok', contextPct: 20 },
852
+ verdict: 1,
853
+ })
854
+ assert(existsSync(join(F.projectRoot, 'Verified', '命题', 'p-enc-ok.md')), "★ 'encourage' imposes NO gate: the verdict closes with no Lean artifact")
855
+ assert(/- 形式化: 未尝试/.test(readIf(join(F.projectRoot, 'Verified', '命题', 'p-enc-ok.md'))), 'the card still states the object was never formalized')
856
+
857
+ // ---------- 11. reporting ----------
858
+ section('11 the host can audit formal strength')
859
+ const repF = await F.callTool('vibe_v4_formal_report', {}, F.ROOT)
860
+ assert(/## Lean 形式化/.test(repF.formalReport), 'the formal report has a Lean section')
861
+ assert(/模式:encourage/.test(repF.formalReport), 'it states the mode')
862
+ const repOff = await A.callTool('vibe_v4_formal_report', {}, A.ROOT)
863
+ assert(/未启用/.test(repOff.formalReport), 'in off mode the report says the feature is not enabled')
864
+ const repE = await E.callTool('vibe_v4_report', {}, E.ROOT)
865
+ assert(repE.formal && repE.formal.passed.indexOf('p-gate') !== -1, 'the structured report exposes the Lean-passed objects')
866
+ assert(repE.formal.blocked.indexOf('p-blocked-ok') !== -1, 'and the blocked objects')
867
+ const reportE = await E.callTool('vibe_v4_formal_report', {}, E.ROOT)
868
+ assert(/已通过:.*p-gate/.test(reportE.formalReport), 'the human report lists Lean-passed objects')
869
+ assert(/已记录阻塞:.*p-blocked-ok/.test(reportE.formalReport), 'the human report lists blocked objects')
870
+ assert(/形式化待办:.*p-gate-false/.test(reportE.formalReport), 'the human report lists the formalization TODO')
871
+
872
+ // ===============================================================
873
+ // 12. the fidelity rule + the prompt hard requirements (docs §6, §10 items 9/11)
874
+ // The v2 lesson (docs §10 item 8) is why every claim here is paired with a BEHAVIOURAL
875
+ // assertion in §13/§14: wording alone guards nothing.
876
+ // ===============================================================
877
+ section('12 the injected text states the fidelity rule and never abbreviates a tool name')
878
+ const G = await establish()
879
+ await G.callTool('vibe_v4_set', { formalVerify: 'encourage' })
880
+ {
881
+ const enc = await G.prompts('verify', 'r-1', { target: 'p-text', stage: 'independent' })
882
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(enc), 'the encourage voting prompt keeps its header')
883
+ assert(/实现难度/.test(enc), 'it still asks for the implementation-difficulty judgement')
884
+ assert(/工具:vibe_v4_lean_run(执行\/入队)· vibe_v4_lean_archive(归档)· vibe_v4_lean_lib(查已有可复用库与 jobs)· vibe_v4_lean_read(读归档原文逐字复用)/.test(enc), 'it names all FOUR tools in FULL (the new read-only tool included)')
885
+ assert(/归档可复用定义\/引理前先跑通(vibe_v4_lean_archive run=true 或先 vibe_v4_lean_run);跑不通不要入库。/.test(enc), '★ a reusable definition/lemma must be RUN GREEN before it is archived')
886
+ assert(/宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"/.test(enc), '★ both unavailable-toolchain paths are written out (missing Lean OR no subprocess service) as explicit blocker reasons')
887
+ assert(/一旦 Lean 通过,你唯一需要确认的就是忠实性/.test(enc), 'a green Lean run still shrinks the open question to fidelity')
888
+ assert(/decision='blocked' 时必须写明 note/.test(enc), 'the encourage opt-out documents the mandatory note')
889
+ assert(!/偏离 → 0/.test(enc) && !/发现任何偏离/.test(enc), '★ no "deviation ⇒ 0" instruction anywhere in the encourage block')
890
+ await G.callTool('vibe_v4_set', { formalVerify: 'require' })
891
+ const req = await G.prompts('verify', 'r-1', { target: 'p-text', stage: 'independent' })
892
+ assert(/【Lean 形式化验证(强制模式)】/.test(req), 'the require voting prompt says 强制模式')
893
+ assert(/必须产出 Lean 形式化/.test(req) && /必须\*\*给出显式的阻塞原因/.test(req), "'require' states the formalization OR an explicit blocker is mandatory")
894
+ assert(/本次裁定不会生效/.test(req) && /进入「形式化待办」/.test(req), 'it warns the verdict is withheld as 未定论 (formal-required)')
895
+ assert(!/可以不做/.test(req), "'require' does NOT offer the encourage-mode opt-out")
896
+ assert(/归档可复用定义\/引理前先跑通/.test(req) && /宿主没有 Lean 工具链/.test(req), 'the run-before-archive and toolchain rules are in the require text as well')
897
+ assert(/LEAN_NOT_FOUND/.test(req) && /NO_SUBPROCESS/.test(req), '★ the require text names BOTH unavailable-toolchain codes (the escape route must be documented in every mode)')
898
+ // The sentence is written ONCE and reused by both modes: a second copy would drift (one branch
899
+ // kept up to date, the other stale). The anchor `(LEAN_NOT_FOUND)` therefore occurs exactly once
900
+ // in the plugin, which is also what the sensitivity probe table assumes.
901
+ {
902
+ const src = readFileSync(fileURLToPath(PLUGIN), 'utf8')
903
+ assert(src.split('(LEAN_NOT_FOUND)').length - 1 === 1, '★ the unavailable-toolchain guidance is written once (not duplicated per mode/branch)')
904
+ assert(src.split('(NO_SUBPROCESS)').length - 1 === 1, 'the same single sentence names NO_SUBPROCESS (no per-branch copy)')
905
+ }
906
+ assert(/\*\*本模式要求\*\*/.test(req), 'the require bullet replaces the encourage opt-out in place')
907
+ // normal / heartbeat / post-compact recap all carry the standing work line; the two that ARE a
908
+ // reply contract also document the `defect` decision (coreRules is a recap prefix, not a contract)
909
+ for (const [label, text] of [['normal', await G.prompts('normal', 'r-1')], ['heartbeat', await G.prompts('heartbeat', 'r-1')], ['coreRules', await G.prompts('coreRules', 'r-1')]]) {
910
+ assert(/【顺手形式化(强制)】/.test(text), label + ': the standing formalization line uses the require wording')
911
+ assert(/归档前先跑通(vibe_v4_lean_run 或 run=true);跑不通的定义不要进可复用库。/.test(text), label + ': ★ it requires a green run before archiving a reusable definition')
912
+ if (label !== 'coreRules') assert(/"decision":"used\|blocked\|defect"/.test(text), label + ': the reply contract documents the `defect` decision')
913
+ }
914
+ assert(/"decision":"used\|blocked\|defect"/.test(req) && /具体偏差/.test(req), '★ the voting reply contract documents decision=defect and its note')
915
+ }
916
+ {
917
+ // ---- the sweep: EVERY agent-facing string must spell the three tools in full (docs §6-1) ----
918
+ const scanned = []
919
+ const addText = (label, t) => { if (typeof t === 'string' && t) scanned.push({ label, text: t }) }
920
+ addText('captured run prompts', G.allPrompts())
921
+ for (const which of ['brainstorm', 'normal', 'heartbeat', 'coreRules']) addText(which, await G.prompts(which, 'r-1'))
922
+ for (const t of ['p-text', 'p-corpus']) for (const st of ['independent', 'debate']) addText('verify:' + t + ':' + st, await G.prompts('verify', 'r-1', { target: t, stage: st }))
923
+ // tool hints are injected text too (docs §6 hard requirement 1 names them explicitly)
924
+ const r1G = G.resAgent(G.childOf('r-1'))
925
+ const hintGreen = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-hint', content: 'theorem p_hint : 1 = 1 := rfl\n' }, r1G)
926
+ assert(hintGreen.ok === true && hintGreen.passed === true, 'precondition: a green file exists for the hint sweep')
927
+ const hintRed = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-hint-red', content: 'theorem p_hint_red : 1 = 2 := by sorry\n' }, r1G)
928
+ assert(hintRed.ok === true && hintRed.passed === false, 'precondition: a red file exists for the hint sweep')
929
+ addText('lean_run hint (green)', (await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-hint.lean' }, r1G)).hint)
930
+ addText('lean_run hint (red)', (await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-hint-red.lean' }, r1G)).hint)
931
+ addText('lean_lib hint', (await G.callTool('vibe_v4_lean_lib', {}, r1G)).hint)
932
+ toolchainAvailable = false
933
+ addText('LEAN_NOT_FOUND message', (await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-hint.lean' }, r1G)).message)
934
+ toolchainAvailable = true
935
+ for (const t of G.toolRegs.filter((x) => /lean/.test(x.name))) addText('tool description ' + t.name, t.description)
936
+ // The activity log is agent/host-readable text too (vibe_v4_report exposes `recentActivity`, and
937
+ // a host reads it out to the group): a bare abbreviation there names a tool that does not exist,
938
+ // for exactly the same reason as in a prompt.
939
+ try {
940
+ const log = JSON.parse(readIf(join(G.projectRoot, 'State', 'session.json')) || '{}').activityLog || []
941
+ for (const e of log) addText('activity log: ' + e.event, String((e && e.detail) || ''))
942
+ } catch (e) { /* the log is best-effort */ }
943
+ const bare = [/(^|[^a-z_])lean_run/, /(^|[^a-z_])lean_archive/, /(^|[^a-z_])lean_lib/]
944
+ const offenders = []
945
+ for (const s of scanned) for (const re of bare) if (re.test(s.text)) offenders.push(s.label + ' :: ' + re.source)
946
+ assert(offenders.length === 0, '★ no injected text (prompt, tool hint or tool description) uses a bare tool abbreviation (' + offenders.slice(0, 3).join(' | ') + ')')
947
+ assert(scanned.length >= 15, 'the sweep really covered the injected-text surface (' + scanned.length + ' texts)')
948
+ }
949
+ {
950
+ // docs §4.1-3 / §6.1: the fidelity branch's "the framework withholds the verdict" clause may only
951
+ // appear in `require` (the only mode with a gate). In `encourage` the framework still withdraws
952
+ // the proof and records the TODO, but it CANNOT hold the ballot — so the text must not promise
953
+ // that, and must instead point at the voter's own abstention.
954
+ await G.callTool('vibe_v4_set', { formalVerify: 'require' })
955
+ const reqFid = await G.prompts('verify', 'r-1', { target: 'p-hint', stage: 'independent' })
956
+ assert(/该对象已有\*\*通过的 Lean 形式化证明\*\*/.test(reqFid), 'precondition: p-hint is the passed/fidelity case in this prompt')
957
+ assert(/本次裁定\*\*不定论\*\*/.test(reqFid), "'require' fidelity text states the verdict will be withheld (it has a gate)")
958
+ await G.callTool('vibe_v4_set', { formalVerify: 'encourage' })
959
+ const encFid = await G.prompts('verify', 'r-1', { target: 'p-hint', stage: 'independent' })
960
+ assert(!/本次裁定\*\*不定论\*\*/.test(encFid), "★ 'encourage' does NOT promise a hold it cannot enforce (docs §4.1-3)")
961
+ assert(/本档没有门禁/.test(encFid) && /弃权值/.test(encFid), "★ instead it says the voter's own abstention is what keeps the ballot from concluding")
962
+ assert(/降级为 attempted、删除归档证明、写入形式化待办/.test(encFid), 'the withdrawal the framework CAN enforce is still stated in both modes')
963
+ assert(/发现任何偏差,不要投 0/.test(encFid), 'the fidelity rule itself is mode-independent')
964
+ await G.callTool('vibe_v4_set', { formalVerify: 'require' })
965
+ }
966
+
967
+ // ===============================================================
968
+ // 13. the `defect` reply channel (docs §4.1 / §10 item 8) — BEHAVIOURAL, not wording
969
+ // ===============================================================
970
+ section('13 the `defect` reply withdraws a passing proof (spec §4.1)')
971
+ await G.callTool('vibe_v4_set', { formalVerify: 'encourage' })
972
+ const activityOf = (h) => { try { return JSON.parse(readIf(join(h.projectRoot, 'State', 'session.json')) || '{}').activityLog || [] } catch (e) { return [] } }
973
+ const g1 = G.resAgent(G.childOf('r-1'))
974
+ {
975
+ const arc = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect', content: 'theorem p_defect : 2 + 2 = 4 := by decide\n' }, g1)
976
+ assert(arc.passed === true && existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect.lean')), 'precondition: p-defect has a green archived proof')
977
+ const w = await workWake(G, 'r-1')
978
+ G.fireEnd(w.childId, { summary: '我逐条核对了 Lean 代码,发现偏差。', formal: { target: 'p-defect', decision: 'defect', note: 'Lean 里的条件比命题弱:只证了 n ≥ 1 的情形' }, contextPct: 20 })
979
+ await sleep(90)
980
+ const st = await G.callTool('vibe_v4_status', {})
981
+ const rec = st.formal.objects.find((o) => o.target === 'p-defect')
982
+ assert(rec && rec.status === 'attempted', '★ a `formal.decision=defect` reply downgrades the object to attempted (never a refutation)')
983
+ assert(rec && rec.proof === '', '★ the archived proof is cleared from the record')
984
+ assert(rec && rec.note === 'Lean 里的条件比命题弱:只证了 n ≥ 1 的情形', 'the concrete deviation is stored as the record note')
985
+ assert(!existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect.lean')), '★ the archived proof file is DELETED (the formalization is 不合格 — the proposition is NOT false)')
986
+ assert(existsSync(join(G.projectRoot, 'Formal', 'p-defect.lean')), 'the WORKING file is kept — the code is not lost, only its "passed" claim')
987
+ assert(st.formal.passed.indexOf('p-defect') === -1, 'the object is no longer reported as Lean-passed')
988
+ assert(shellCalls.some((c) => /Remove-Item|^rm -f/.test(c) && /p-defect\.lean/.test(c)), 'the withdrawal really went through the platform shell (the fs service has no delete)')
989
+ const todo = readIf(join(G.projectRoot, 'Formal', 'TODO.md'))
990
+ assert(/p-defect/.test(todo) && /formal-defect/.test(todo), '★ Formal/TODO.md lists the object with the DEFECT reason')
991
+ assert(/只证了 n ≥ 1 的情形/.test(todo), '★ the concrete deviation reaches the human-readable TODO')
992
+ const idx = readIf(join(G.projectRoot, 'Formal', 'Index.md'))
993
+ assert(/\| p-defect \| attempted \|/.test(idx), 'Formal/Index.md downgrades the object to attempted')
994
+ assert(/只证了 n ≥ 1 的情形/.test(idx), 'the deviation is the record note in the index')
995
+ assert(activityOf(G).some((e) => /忠实性缺陷/.test(e.detail) && /p-defect/.test(e.detail)), '★ the retraction is announced in the activity log')
996
+ // the log line is agent/host-readable text, so it is bound by the same §4.1-3 rule as the prompt:
997
+ // in `encourage` it may not claim a hold the mode does not have.
998
+ {
999
+ const line = activityOf(G).filter((e) => /忠实性缺陷/.test(e.detail) && /p-defect[^-]/.test(e.detail)).pop() || { detail: '' }
1000
+ assert(/本档没有门禁/.test(line.detail) && !/本次裁定\*\*不定论\*\*/.test(line.detail), "★ the encourage-mode activity line does not promise the 不定论 hold that only `require` enforces")
1001
+ }
1002
+ }
1003
+ {
1004
+ // §4.1 degraded withdrawal: the record must never be the ONLY thing withdrawn. A host whose shell
1005
+ // "succeeds" without deleting (a stub host / a permissions quirk) must be caught by re-reading the
1006
+ // file through the fs service, and the archive must then be OVERWRITTEN with a withdrawal notice —
1007
+ // otherwise the retracted proof keeps sitting at the exact path everyone looks for the proof.
1008
+ const arcD = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect-degraded', content: 'theorem p_defect_degraded : 5 = 5 := rfl\n' }, g1)
1009
+ assert(arcD.passed === true && existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect-degraded.lean')), 'precondition: the degraded-path object has a green archived proof')
1010
+ shellDeletesFiles = false
1011
+ const wD = await workWake(G, 'r-1')
1012
+ G.fireEnd(wD.childId, { summary: '核对后发现偏差。', formal: { target: 'p-defect-degraded', decision: 'defect', note: '结论方向相反' }, contextPct: 20 })
1013
+ await sleep(90)
1014
+ shellDeletesFiles = true
1015
+ const body = readIf(join(G.projectRoot, 'Verified', 'Lean', 'p-defect-degraded.lean'))
1016
+ assert(!/p_defect_degraded/.test(body), '★ a shell that exits 0 without deleting does NOT leave the retracted proof readable — its original text is gone')
1017
+ assert(/已撤回/.test(body) && /原代码保留在工作文件/.test(body), '★ the archived file was OVERWRITTEN with an explicit withdrawal notice instead (delete → confirm → overwrite)')
1018
+ const recD = (await G.callTool('vibe_v4_status', {})).formal.objects.find((o) => o.target === 'p-defect-degraded')
1019
+ assert(recD && recD.status === 'attempted' && recD.proof === '', 'the record is downgraded on the degraded path too')
1020
+ assert(activityOf(G).some((e) => /覆盖归档证明/.test(e.detail) && /p-defect-degraded/.test(e.detail)), '★ the activity log says WHICH withdrawal path was taken (overwritten, not silently reported as deleted)')
1021
+ }
1022
+ {
1023
+ const arc2 = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect2', content: 'theorem p_defect2 : 3 + 3 = 6 := by decide\n' }, g1)
1024
+ assert(arc2.passed === true, 'precondition: p-defect2 is Lean-passed')
1025
+ const w2 = await workWake(G, 'r-2')
1026
+ G.fireEnd(w2.childId, { summary: '有偏差,但没写清是什么。', formal: { target: 'p-defect2', decision: 'defect' }, contextPct: 20 })
1027
+ await sleep(90)
1028
+ const st2 = await G.callTool('vibe_v4_status', {})
1029
+ const rec2 = st2.formal.objects.find((o) => o.target === 'p-defect2')
1030
+ assert(rec2 && rec2.status === 'passed' && rec2.proof === 'Verified/Lean/p-defect2.lean', '★ a `defect` reply WITHOUT a note is REJECTED — the record is untouched')
1031
+ assert(existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-defect2.lean')), '★ and the archived proof is NOT withdrawn')
1032
+ assert(activityOf(G).some((e) => /defect/.test(e.detail) && /V4_INVALID_ARGUMENT/.test(e.detail)), '★ the rejection carries the preset error code (V4_INVALID_ARGUMENT), not a silent drop')
1033
+ assert((await G.callTool('vibe_v4_status', {})).ok === true, 'the refusal did not crash the run')
1034
+ }
1035
+ {
1036
+ const blk = await G.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-defect3', note: '前置知识未形式化,本轮不做' }, g1)
1037
+ assert(blk.ok === true && blk.status === 'blocked', 'precondition: p-defect3 carries a reasoned blocker record')
1038
+ const w3 = await workWake(G, 'r-1')
1039
+ G.fireEnd(w3.childId, { summary: '复核后发现归档的形式化换了对象。', formal: { target: 'p-defect3', decision: 'defect', note: '归档的形式化证的是特例,换了对象' }, contextPct: 20 })
1040
+ await sleep(90)
1041
+ const st3 = await G.callTool('vibe_v4_status', {})
1042
+ const rec3 = st3.formal.objects.find((o) => o.target === 'p-defect3')
1043
+ assert(rec3 && rec3.status === 'attempted', '★ `defect` downgrades even a `blocked` record (spec §4.1: ALWAYS downgrade)')
1044
+ assert(st3.formal.blocked.indexOf('p-defect3') === -1, '★ a bad formalization may not stay in the gate-passing `blocked` state')
1045
+ }
1046
+
1047
+ // ===============================================================
1048
+ // 14. `require` after a defect: the retraction closes the gate (docs §4.1-3 / §10 item 9)
1049
+ // ===============================================================
1050
+ // 13b. a plain `used` judgement must NOT withdraw an ESTABLISHED proof (contract §4): only a
1051
+ // fidelity defect retracts one. v2 shipped the unconditional downgrade and silently re-closed the gate.
1052
+ section('13b a \`used\` reply must NOT downgrade an already-passed object')
1053
+ {
1054
+ const arcU = await G.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-usedkeep', content: 'theorem p_usedkeep : 2 + 2 = 4 := by decide\n' }, g1)
1055
+ assert(arcU.passed === true && existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-usedkeep.lean')), 'used-keep: the object starts with a green archived proof')
1056
+ const wU = await workWake(G, 'r-1')
1057
+ G.fireEnd(wU.childId, { summary: '这一轮只是又写了一遍草稿。', formal: { target: 'p-usedkeep', decision: 'used', file: 'Formal/p-usedkeep.lean' }, contextPct: 20 })
1058
+ await sleep(90)
1059
+ const stU = await G.callTool('vibe_v4_status', {})
1060
+ const recU = stU.formal.objects.find((o) => o.target === 'p-usedkeep')
1061
+ assert(!!recU && recU.status === 'passed', '★ a `used` reply does NOT downgrade an already-passed object (got ' + JSON.stringify(recU) + ')')
1062
+ assert(!!recU && recU.proof === 'Verified/Lean/p-usedkeep.lean', '★ and the proof pointer survives the reply')
1063
+ assert(existsSync(join(G.projectRoot, 'Verified', 'Lean', 'p-usedkeep.lean')), '★ and the archived proof is still on disk')
1064
+ assert(stU.formal.passed.indexOf('p-usedkeep') !== -1, '★ status still reports it as Lean-passed')
1065
+ const rerunU = await G.callTool('vibe_v4_lean_run', { file: 'Formal/p-usedkeep.lean', target: 'p-usedkeep' }, g1)
1066
+ assert(rerunU.ok === true, 'used-keep: the work file runs green')
1067
+ const recU2 = (await G.callTool('vibe_v4_status', {})).formal.objects.find((o) => o.target === 'p-usedkeep')
1068
+ assert(!!recU2 && recU2.status === 'passed', '★ a plain re-run does not downgrade a passed record (contract §4)')
1069
+ }
1070
+
1071
+ section("14 'require' withholds the verdict after a defect, even on a unanimous 1")
1072
+ const H = await establish()
1073
+ await H.callTool('vibe_v4_set', { formalVerify: 'require' })
1074
+ {
1075
+ const h1 = H.resAgent(H.childOf('r-1'))
1076
+ const arc = await H.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-defect-req', content: 'theorem p_defect_req : 4 * 1 ^ 2 - 2 = (1:Nat) ^ 2 := by decide\n' }, h1)
1077
+ assert(arc.passed === true, 'precondition: p-defect-req is Lean-passed')
1078
+ const before = await H.prompts('verify', 'r-1', { target: 'p-defect-req', stage: 'independent' })
1079
+ assert(/不要投 0/.test(before) && /formal:\{decision:'defect'/.test(before), 'the voter is told (before voting) how to report a fidelity defect')
1080
+ await H.callTool('vibe_v4_record_proposition', { id: 'p-defect-req', title: '缺陷不是证伪', statement: '形式化写窄了不代表命题为假', prob: 0.9, value: 0.6, motivation: 'm' }, h1)
1081
+ const n0 = H.followups.length
1082
+ await H.callTool('vibe_v4_message', { to: 'r-1', content: '请处理本轮工作。' })
1083
+ for (let i = 0; i < 300 && H.followups.length === n0; i++) await sleep(10)
1084
+ // r-1 reports the defect AND votes 1 in the SAME reply; r-2 votes 1. The framework must NOT take
1085
+ // the unanimous 1: the formalization was just retracted, so the verdict has to be deferred.
1086
+ const replyFor = (pt, rId) => (/团队验证/.test(pt)
1087
+ ? (rId === 'r-1'
1088
+ ? { vote: { verdict: 1, reason: '我发现形式化写窄了,但独立看命题仍为真' }, formal: { target: 'p-defect-req', decision: 'defect', note: 'Lean 只证了 x=1 的特例,命题要求所有整数 x' } }
1089
+ : { vote: { verdict: 1, reason: '独立复核为真' } })
1090
+ : { summary: '提议验证 p-defect-req。', solved: false, propose_verify: 'p-defect-req', contextPct: 20 })
1091
+ for (let i = 0; i < 4; i++) { await drive(H, replyFor, verifySettled(H)); if (await verifySettled(H)()) break }
1092
+ const st = await H.callTool('vibe_v4_status', {})
1093
+ assert(st.verifyInProgress === false && st.pendingVerify === null, 'the verification actually settled (so the next assertion is falsifiable)')
1094
+ assert(!existsSync(join(H.projectRoot, 'Verified', '命题', 'p-defect-req.md')), '★ a defect in `require` mode writes NO Verified card, even with a unanimous 1')
1095
+ assert(st.formal.todo.indexOf('p-defect-req') !== -1, '★ the object stays 未定论 on the formalization TODO')
1096
+ const rec = st.formal.objects.find((o) => o.target === 'p-defect-req')
1097
+ assert(rec && rec.status === 'attempted' && rec.proof === '', 'the formal record is the retracted one (attempted, no proof)')
1098
+ assert(rec && /只证了 x=1 的特例/.test(rec.note || ''), "the record's note is the reported deviation")
1099
+ assert(!existsSync(join(H.projectRoot, 'Verified', 'Lean', 'p-defect-req.lean')), '★ the archived proof was withdrawn as part of the retraction')
1100
+ assert(!/已验证·真/.test(readIf(join(H.projectRoot, 'Propos', 'r-1', 'p-defect-req.md'))), 'the source card was NOT rewritten to 已验证·真 (the conclusion was withheld, not taken)')
1101
+ assert(!/已验证·真/.test(readIf(join(H.projectRoot, 'Shared', 'debates', 'p-defect-req.md'))), 'the debate record does not claim a 真 conclusion')
1102
+ const todo = readIf(join(H.projectRoot, 'Formal', 'TODO.md'))
1103
+ assert(/p-defect-req/.test(todo) && /formal-defect/.test(todo), '★ the TODO keeps the DEFECT reason, not merely formal-required')
1104
+ assert(/只证了 x=1 的特例/.test(todo), 'the concrete deviation is what a human reads in the TODO')
1105
+ }
1106
+
1107
+ // ===============================================================
1108
+ // 15. the shipped Lean prompt corpus (docs §10 item 10) — a HUMAN must be able to re-read the
1109
+ // exact text, not just the assertions about it.
1110
+ // ===============================================================
1111
+ section('15 the captured Lean prompt corpus is written for human review')
1112
+ const K = await establish()
1113
+ {
1114
+ const vibe = K.vibeRoot
1115
+ // Normalise BOTH slash forms, and the VibeMath root FIRST: it sits INSIDE the workspace, so
1116
+ // replacing the workspace first would leave `<WS>/VibeMath` instead of `<VIBEMATH>`.
1117
+ const scrub = (s) => String(s == null ? '' : s)
1118
+ .split(vibe).join('<VIBEMATH>').split(vibe.replace(/\\/g, '/')).join('<VIBEMATH>')
1119
+ .split(K.WS).join('<WS>').split(K.WS.replace(/\\/g, '/')).join('<WS>')
1120
+ // `propose_task` mints `t-<8 hex>` from Math.random (vibe-math-v4.js:1317 + shortId :2582).
1121
+ // The task-frame entry embeds that id, so without this the SHIPPED corpus changed on every
1122
+ // run (AUDIT-CHECKLIST §2.4: two consecutive runs must hash identically) and `git status`
1123
+ // always showed prompt-corpus-v4/* as modified. Normalise it like the machine paths.
1124
+ .replace(/\bt-[0-9a-f]{8}\b/g, '<TASKID>')
1125
+ const corpus = []
1126
+ const add = (kind, label, prompt) => corpus.push({ kind, label, prompt: scrub(prompt) })
1127
+ // (a) off: a TRUE no-op must be visible in the corpus, not merely asserted
1128
+ add('verify', 'off/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'independent' }))
1129
+ add('work', 'off/normal', await K.prompts('normal', 'r-1'))
1130
+ // (b) encourage
1131
+ await K.callTool('vibe_v4_set', { formalVerify: 'encourage' })
1132
+ add('verify', 'encourage/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'independent' }))
1133
+ const encNormal = await K.prompts('normal', 'r-1')
1134
+ add('work', 'encourage/normal', encNormal)
1135
+ add('work', 'encourage/heartbeat', await K.prompts('heartbeat', 'r-1'))
1136
+ add('work', 'encourage/coreRules', await K.prompts('coreRules', 'r-1'))
1137
+ // (c) require
1138
+ await K.callTool('vibe_v4_set', { formalVerify: 'require' })
1139
+ add('verify', 'require/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'independent' }))
1140
+ add('verify', 'require/verify/debate', await K.prompts('verify', 'r-1', { target: 'p-corpus', stage: 'debate' }))
1141
+ add('work', 'require/normal', await K.prompts('normal', 'r-1'))
1142
+ // (d) a PASSED object: the review subject has changed to fidelity
1143
+ const k1 = K.resAgent(K.childOf('r-1'))
1144
+ await K.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-corpus-passed', content: 'theorem p_corpus_passed : 1 + 1 = 2 := by decide\n' }, k1)
1145
+ const fid = await K.prompts('verify', 'r-1', { target: 'p-corpus-passed', stage: 'independent' })
1146
+ add('verify', 'passed/fidelity', fid)
1147
+ // (d2) the SAME passed object in `encourage`: the promise must shrink to what that mode enforces
1148
+ // (docs §4.1-3: no gate there, so no claim that the framework withholds the verdict).
1149
+ await K.callTool('vibe_v4_set', { formalVerify: 'encourage' })
1150
+ const fidEnc = await K.prompts('verify', 'r-1', { target: 'p-corpus-passed', stage: 'independent' })
1151
+ add('verify', 'passed/fidelity (encourage)', fidEnc)
1152
+ await K.callTool('vibe_v4_set', { formalVerify: 'require' })
1153
+ // (e) a BLOCKED object
1154
+ await K.callTool('vibe_v4_lean_archive', { kind: 'blocked', target: 'p-corpus-blocked', note: '需要未形式化的解析数论框架' }, k1)
1155
+ add('verify', 'blocked/verify', await K.prompts('verify', 'r-1', { target: 'p-corpus-blocked', stage: 'independent' }))
1156
+ // (f) the `formal` reply contract line itself (the field whose parsing §13/§14 prove)
1157
+ const contractLine = (t) => { const m = String(t).match(/"formal":\{[^\n]*\}\}/); return m ? m[0] : '' }
1158
+ add('contract', 'formal reply contract (voting prompt)', contractLine(fid))
1159
+ add('contract', 'formal reply contract (work prompt)', contractLine(encNormal))
1160
+ // (g) a REAL delivered work wake (proves the builder is the one actually used to address a resident)
1161
+ const w = await workWake(K, 'r-2')
1162
+ add('work', 'require/real work wake', promptOf(w))
1163
+ K.fireEnd(w.childId, { summary: '继续推进。', solved: false, contextPct: 20 })
1164
+ await sleep(40)
1165
+ // (h) the tool hints are injected text too (docs §6 hard requirement 1)
1166
+ add('hint', 'lean_run hint (green)', (await K.callTool('vibe_v4_lean_run', { file: 'Formal/p-corpus-passed.lean' }, k1)).hint)
1167
+ add('hint', 'lean_lib hint', (await K.callTool('vibe_v4_lean_lib', {}, k1)).hint)
1168
+ // (i) ── THE NON-FORMAL SURFACES (audit M6/F15) ─────────────────────────────────────────────
1169
+ // The corpus used to cover ONLY Lean/formal variants, so the meeting prompt — this preset's ONLY
1170
+ // group-chat / task-allocation / stop-vote carrier — and every message frame had NO reviewable
1171
+ // artefact at all: a defect in `meetingPrompt` (e.g. its stop-vote contract) could not be read by
1172
+ // a human reviewer. These entries close that gap. `st` is passed explicitly so the capture does
1173
+ // not depend on the scheduler's state, and both the meeting's two perspectives (first speaker with
1174
+ // no prior speech / a later speaker seeing a relayed contribution) are captured.
1175
+ {
1176
+ const st = {
1177
+ id: 'mt-corpus', agenda: '分工与是否需要验证', type: 'general', targetId: null, round: 0, asked: [], order: ['r-1', 'r-2'],
1178
+ inputs: { 'r-1': { input: '我建议先验证 p-corpus,并认领引理 A 的整理。', voteSolved: false, propose_verify: 'p-corpus', propose_task: '整理引理 A', task_desc: '把已有引理写成可复用形式', claim_task: null } },
1179
+ transcript: [], at: Date.now(), lastInputAt: Date.now(),
1180
+ }
1181
+ add('meeting', 'meeting/first speaker (no prior speech)', await K.prompts('meeting', 'r-2', { meeting: { agenda: st.agenda, type: st.type, inputs: {}, order: st.order } }))
1182
+ add('meeting', 'meeting/later speaker (others relayed)', await K.prompts('meeting', 'r-2', { meeting: st }))
1183
+ }
1184
+ // (i) ── the private-message frame ──────────────────────────────────────────────────────────────
1185
+ // A resident reads a message in TWO shapes, and both must be reviewable:
1186
+ // · `[MESSAGE from <sender>] <content>` — the frame `deliverNextMailbox` prepends when it wakes an
1187
+ // idle recipient with a QUEUED message (captured here from a REAL delivery, not re-typed);
1188
+ // · `[<sender>] <content>` — the inbox line every prompt's "New items:" block renders.
1189
+ // r-1 is put in flight first (a message to an IDLE resident is delivered immediately as its own
1190
+ // wake instead of queueing), so the second message is queued and the prompt shows the inbox line.
1191
+ {
1192
+ const before = K.followups.length
1193
+ await K.callTool('vibe_v4_message', { to: 'r-1', content: '先做一轮常规工作。' })
1194
+ for (let i = 0; i < 200 && K.followups.length === before; i++) await sleep(10)
1195
+ const inFlight = K.followups[K.followups.length - 1]
1196
+ assert(!!inFlight, 'corpus: r-1 is in flight so the next message is QUEUED (mailbox frame prerequisite)')
1197
+ await K.callTool('vibe_v4_message', { to: 'r-1', content: '请优先核对引理 B 的假设条件。' })
1198
+ add('work', 'normal + queued inbox line ([<sender>] content)', await K.prompts('normal', 'r-1'))
1199
+ K.fireEnd(inFlight.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1200
+ await sleep(60)
1201
+ }
1202
+ // the REAL delivery frames. A message that arrives while its recipient is idle is delivered as a
1203
+ // wake whose body is `<ordinary round>\n\n[MESSAGE from <sender>]\n<content>`, and a message that
1204
+ // was relayed by a teammate (`relayToGroup` posts it with the sender's own id and the `[群聊]`
1205
+ // prefix — the exact same `postMessage` path the human messenger uses) renders as `[群聊] …`.
1206
+ // Each frame is captured from a REAL delivery on its own host, so the corpus text is generated by
1207
+ // the code under review rather than re-typed here.
1208
+ const deliveredFrame = async (content, sender) => {
1209
+ const L = await establish()
1210
+ await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 }) // keep the heartbeat out of the way
1211
+ const before = L.followups.length
1212
+ await (sender === 'facilitator'
1213
+ ? L.callTool('vibe_v4_message', { to: 'r-1', content })
1214
+ : L.callTool('vibe_v4_send_message', { to: 'r-1', content }, L.resAgent(L.childOf(sender))))
1215
+ let hit = null
1216
+ for (let i = 0; i < 300 && !hit; i++) { hit = L.followups.slice(before).find((f) => /\[(NEW )?MESSAGE from /.test(promptOf(f))) || null; if (!hit) await sleep(10) }
1217
+ assert(!!hit, 'corpus: a real delivery wake carrying "' + content.slice(0, 16) + '…" was produced (saw ' + L.followups.slice(before).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1218
+ if (hit) L.fireEnd(hit.childId, { summary: '收到。', solved: false, contextPct: 20 })
1219
+ await sleep(30)
1220
+ return hit ? promptOf(hit) : ''
1221
+ }
1222
+ add('work', 'normal + delivered message frame ([NEW MESSAGE from ...])', await deliveredFrame('请优先核对引理 B 的假设条件。', 'facilitator'))
1223
+ // the group-chat frame: the recipient's inbox line for a message RELAYED by a teammate
1224
+ // (`relayToGroup` pushes `[群聊] <text>` into every other resident's mailbox with the speaker's own
1225
+ // id, and the next delivery renders `[<sender>] [群聊] <text>` — the same `postMessage` path, run
1226
+ // with the sender the resident actually sees). Deterministic on the same host: the speaker's own
1227
+ // end handler is what triggers the relay, and reading the recipient's next prompt shows it.
1228
+ {
1229
+ const L = await establish()
1230
+ await L.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
1231
+ const before = L.followups.length
1232
+ await L.callTool('vibe_v4_message', { to: 'r-2', content: '先做一轮常规工作。' })
1233
+ for (let i = 0; i < 300 && L.followups.length === before; i++) await sleep(10)
1234
+ const speaker = L.followups[L.followups.length - 1]
1235
+ assert(!!speaker, 'corpus: the relaying resident is in flight')
1236
+ const mark = L.followups.length
1237
+ L.fireEnd(speaker.childId, { summary: '推进。', solved: false, input: '我对引理 B 的假设有异议:需要 n≥1。', contextPct: 20 })
1238
+ let relay = null
1239
+ for (let i = 0; i < 300 && !relay; i++) { relay = L.followups.slice(mark).find((f) => /群聊/.test(promptOf(f))) || null; if (!relay) await sleep(10) }
1240
+ assert(!!relay, 'corpus: the relayed group-chat message was really delivered (saw ' + L.followups.slice(mark).map((f) => promptOf(f).slice(-90).replace(/\n/g, ' ')).join(' | ') + ')')
1241
+ add('work', 'normal + group-chat frame ([群聊])', relay ? promptOf(relay) : '')
1242
+ if (relay) L.fireEnd(relay.childId, { summary: '收到。', solved: false, contextPct: 20 })
1243
+ await sleep(30)
1244
+ }
1245
+ // the task-claim frame. The task is claimed while its claimer is idle, so the claim wake carries
1246
+ // `[YOU CLAIMED TASK …]`; the corpus entry is the ordinary round plus that exact frame.
1247
+ {
1248
+ const pt = await K.callTool('vibe_v4_propose_task', { title: '核对引理 B 的假设', description: '确认 n≥1 是否必要' })
1249
+ assert(pt.ok === true && !!pt.id, 'corpus: a task was proposed for the task-frame entry')
1250
+ const claimed = await K.callTool('vibe_v4_claim_task', { id: pt.id }, k1)
1251
+ add('work', 'task frame ([YOU CLAIMED TASK ...])', claimed.ok === true ? (await K.prompts('normal', 'r-1')) + '\n\n[YOU CLAIMED TASK ' + pt.id + '] 核对引理 B 的假设 — 确认 n≥1 是否必要' : '')
1252
+ }
1253
+
1254
+ // The corpus is REGENERATED on every run. Dumping it while V4_PLUGIN points at a MUTATED copy
1255
+ // would let a sensitivity probe overwrite the SHIPPED corpus with mutated text (the probe's job
1256
+ // is to run this suite against a broken plugin), so the dump is skipped then — unless the probe
1257
+ // explicitly redirects it with V4_CORPUS_DIR. The IN-MEMORY corpus assertions below still run, so
1258
+ // nothing is weakened.
1259
+ const writeCorpus = !process.env.V4_PLUGIN || !!process.env.V4_CORPUS_DIR
1260
+ if (writeCorpus) {
1261
+ mkdirSync(CORPUS_DIR, { recursive: true })
1262
+ writeFileSync(join(CORPUS_DIR, 'formal-verify-v4.json'), JSON.stringify({ entries: corpus }, null, 2), 'utf8')
1263
+ const md = ['# V4 交互语料(prompt corpus)', '',
1264
+ '> 由 `formal-verify-v4.test.mjs` 落盘:常驻**真正会读到**的提示词原文',
1265
+ '> (`vibe_v4_prompts` 的只读回显 + 一条真实投递的工作轮 + 工具 `hint`)。',
1266
+ '> 工作区路径归一化为 `<WS>`,VibeMath 根归一化为 `<VIBEMATH>`,随机任务号归一化为',
1267
+ '> `<TASKID>`(`propose_task` 的 `t-<8 位 hex>` 来自 `Math.random`):确定、可 diff、不含任何本机路径或随机数。', '',
1268
+ '> 覆盖:`off`(无 Lean 文本)、`encourage`、**`require`**、对象 `passed` 后的**忠实性分支**',
1269
+ '> (`encourage` / `require` 两种措辞各一份:只有 `require` 会声称"不定论")、',
1270
+ '> `blocked` 分支、平时工作轮的「顺手形式化」,以及回执契约里的 `formal` 字段;',
1271
+ '> **以及非形式化的交互面**:会议提示(首位发言者 / 已看到他人发言者各一份,含停止表决契约)、',
1272
+ '> 私信框头 `[MESSAGE from …]`、群聊框头 `[群聊]`、认领任务框头 `[YOU CLAIMED TASK …]`。', '']
1273
+ for (let i = 0; i < corpus.length; i++) {
1274
+ const c = corpus[i]
1275
+ md.push('## [' + i + '] ' + c.kind + ' · ' + c.label)
1276
+ md.push('')
1277
+ md.push('```text')
1278
+ md.push(c.prompt)
1279
+ md.push('```')
1280
+ md.push('')
1281
+ }
1282
+ writeFileSync(join(CORPUS_DIR, 'formal-verify-v4.md'), md.join('\n'), 'utf8')
1283
+ assert(existsSync(join(CORPUS_DIR, 'formal-verify-v4.json')) && existsSync(join(CORPUS_DIR, 'formal-verify-v4.md')), 'the prompt corpus was written (JSON + Markdown)')
1284
+ }
1285
+ assert(corpus.length >= 14, 'the corpus covers the whole Lean prompt surface (' + corpus.length + ' prompts)')
1286
+ const byLabel = (l) => corpus.find((c) => c.label === l)
1287
+ assert(!!byLabel('off/verify') && !/Lean/.test(byLabel('off/verify').prompt) && !/Lean/.test(byLabel('off/normal').prompt), '★ the corpus keeps the off-mode entries and they contain NO Lean text')
1288
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(byLabel('encourage/verify').prompt), 'the corpus carries the encourage voting prompt')
1289
+ assert(/【Lean 形式化验证(强制模式)】/.test(byLabel('require/verify').prompt), '★ the corpus carries the REQUIRE voting prompt')
1290
+ assert(/不要投 0/.test(byLabel('passed/fidelity').prompt), '★ the corpus carries the passed/fidelity branch')
1291
+ assert(/本次裁定\*\*不定论\*\*/.test(byLabel('passed/fidelity').prompt), 'the require fidelity entry keeps the hold it really enforces')
1292
+ assert(!!byLabel('passed/fidelity (encourage)') && !/本次裁定\*\*不定论\*\*/.test(byLabel('passed/fidelity (encourage)').prompt)
1293
+ && /本档没有门禁/.test(byLabel('passed/fidelity (encourage)').prompt), '★ the encourage fidelity entry does NOT promise the hold only require has (docs §4.1-3)')
1294
+ assert(/【顺手形式化(强制)】/.test(byLabel('require/normal').prompt), 'the corpus carries the ordinary work-round line')
1295
+ assert(/"decision":"used\|blocked\|defect"/.test(byLabel('formal reply contract (voting prompt)').prompt), '★ the corpus carries the `formal` reply contract line with decision=defect')
1296
+ assert(/【顺手形式化/.test(byLabel('require/real work wake').prompt), 'the corpus also keeps a prompt the framework REALLY delivered')
1297
+ // ---- the non-formal interaction surfaces (audit M6 / F15) --------------------------------
1298
+ // The corpus used to be Lean-only, so `meetingPrompt` (the ONLY group-chat / allocation /
1299
+ // stop-vote carrier this preset has) had no reviewable artefact. These assertions pin both the
1300
+ // coverage and the CONTENT of the stop-vote contract that `finalizeMeeting` now enforces.
1301
+ {
1302
+ const first = byLabel('meeting/first speaker (no prior speech)')
1303
+ const later = byLabel('meeting/later speaker (others relayed)')
1304
+ assert(!!first && !!later, '★ the corpus carries the MEETING prompt (two perspectives: no prior speech / others already relayed)')
1305
+ assert(/meeting is in progress/.test(first.prompt) && /meeting is in progress/.test(later.prompt), 'the meeting entries are built by the real meeting prompt builder')
1306
+ assert(/并行独立发言/.test(first.prompt), 'the first-speaker entry states the parallel-statements semantics (nobody has spoken yet)')
1307
+ assert(/已有发言(他人 input,已转发给你)/.test(later.prompt) && /我建议先验证 p-corpus/.test(later.prompt), '★ the later-speaker entry carries a teammate contribution VERBATIM (the group-chat relay is reviewable)')
1308
+ assert(/\[r-1\]/.test(later.prompt) && !/\[r-2\]/.test(later.prompt), 'the relayed-speech list excludes the reader and names the real sender id')
1309
+ assert(/voteSolved/.test(first.prompt) && /停止表决必须是绝对票/.test(first.prompt), '★ the meeting prompt states the stop-vote contract the code enforces (every speaker must answer true)')
1310
+ assert(!!byLabel('normal + queued inbox line ([<sender>] content)') && /\[facilitator(框架\/人类信使/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt) && /请优先核对引理 B 的假设条件。/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the corpus carries the inbox line that names the real sender of a queued message')
1311
+ assert(/\[(NEW )?MESSAGE from facilitator\]/.test(byLabel('normal + delivered message frame ([NEW MESSAGE from ...])').prompt), '★ the corpus carries the [NEW MESSAGE from …] delivery frame, captured from a REAL delivery')
1312
+ assert(/\[群聊\] 我对引理 B 的假设有异议/.test(byLabel('normal + group-chat frame ([群聊])').prompt), '★ the corpus carries the group-chat frame relayed by a teammate (and the recipient is not the speaker)')
1313
+ assert(/facilitator(框架\/人类信使,不是常驻成员,不要向它回信/.test(byLabel('normal + queued inbox line ([<sender>] content)').prompt), '★ the frame DEFINES `facilitator` on the very wake that shows it, so it is not an unaddressable stranger')
1314
+ }
1315
+ const joined = corpus.map((c) => c.prompt).join('\n')
1316
+ assert(joined.indexOf(K.WS) === -1 && joined.indexOf(K.WS.replace(/\\/g, '/')) === -1 && joined.indexOf(vibe) === -1 && joined.indexOf(vibe.replace(/\\/g, '/')) === -1, '★ every captured prompt normalises <WS> and <VIBEMATH> (diffable, no machine paths)')
1317
+ // The random `t-<hex8>` must be normalised too, and NON-vacuously: the task-frame entry has to
1318
+ // CARRY the normalised token (a scrub that quietly blanked the whole frame would also pass the
1319
+ // absence check below).
1320
+ assert(/\[YOU CLAIMED TASK <TASKID>\]/.test(byLabel('task frame ([YOU CLAIMED TASK ...])').prompt), '★ the task-frame corpus entry really carries the normalised `<TASKID>` token')
1321
+ assert(!/\bt-[0-9a-f]{8}\b/.test(joined), '★ no captured prompt leaks a random `t-<hex8>` id (the corpus is byte-stable across runs)')
1322
+ assert(!/\[object Object\]|\bNaN\b|:\s*undefined|["']undefined["']|undefined\s*[,}\]]/.test(joined), 'no captured prompt contains placeholder garbage')
1323
+ assert(corpus.every((c) => c.prompt && c.prompt.length > 20), 'every corpus entry carries real prompt text')
1324
+ }
1325
+
1326
+ // ===============================================================
1327
+ // 16. a FRESH run must not inherit the previous run's formal records (docs §31.6): object ids are
1328
+ // reused (p-*, r-1..), so the in-memory reset in `start()` has to REACH DISK even when the mode
1329
+ // has been switched back to `off` — otherwise a later `resume` restores a stale `passed` and
1330
+ // re-opens the require gate for an object of the new run. This is the exact hazard that makes
1331
+ // the `off`-mode persistence guard ("do not create State/formal.json" ) non-trivial.
1332
+ // ===============================================================
1333
+ section('16 a fresh run clears the persisted formal records (even in off mode)')
1334
+ {
1335
+ const stale = readIf(join(D.projectRoot, 'State', 'formal.json'))
1336
+ assert(/"p-proof"/.test(stale), 'precondition: the previous run left formal records on disk')
1337
+ await D.callTool('vibe_v4_set', { formalVerify: 'off' })
1338
+ const beforeSpawns = D.spawns.length
1339
+ const st = await D.callTool('vibe_v4_start', { problem: '新一轮:形式化清白起点', residentCount: 1 })
1340
+ assert(st.ok === true && D.spawns.length > beforeSpawns, 'the fresh run actually started (so the clean-slate assertion is falsifiable)')
1341
+ const now = readIf(join(D.projectRoot, 'State', 'formal.json'))
1342
+ assert(!/"p-proof"/.test(now) && !/"passed"/.test(now), '★ a fresh run clears the persisted formal records on disk, even in off mode (no stale `passed` can open the new run’s gate)')
1343
+ }
1344
+
1345
+ // ===============================================================
1346
+ // 17. leanAsync (default true): the tool ENQUEUES and returns immediately; ONLY a settled, green
1347
+ // job whose file still matches the enqueued content may set `passed` / mint Verified/Lean/.
1348
+ // ===============================================================
1349
+ const settleJob = async (h, jobId, tries = 80) => {
1350
+ for (let i = 0; i < tries; i++) {
1351
+ const r = await h.callTool('vibe_v4_lean_job', { jobId, waitMs: 200 })
1352
+ if (['settled', 'failed', 'timeout', 'interrupted'].indexOf(r.state) !== -1) return r
1353
+ await sleep(30)
1354
+ }
1355
+ return await h.callTool('vibe_v4_lean_job', { jobId })
1356
+ }
1357
+ /** The mock's `ensureDirs` records a shell mkdir but does not materialise directories, so a test
1358
+ * that seeds a project file must create its parent itself. */
1359
+ const writeIn = (h, rel, body) => { const p = join(h.WS, 'VibeMath', ...rel.split('/')); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, body); return p }
1360
+ section('17 leanAsync=true enqueues immediately; passed only after a settled green job')
1361
+ {
1362
+ const N = await establish()
1363
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 60 })
1364
+ writeIn(N, 'Projects/default/Formal/a-async.lean', 'theorem a_async : 1 + 1 = 2 := by decide\n')
1365
+ const runsBefore = leanRuns.length
1366
+ const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async', content: 'theorem p_async : 1 + 1 = 2 := by decide\n' }, N.resAgent(N.childOf('r-1')))
1367
+ assert(arc.ok === true && arc.async && arc.async.state === 'queued' && /^p-async-[0-9a-f]{12}$/.test(arc.jobId), '★ lean_archive kind=proof ENQUEUES and returns {async:{jobId,state:queued}} immediately (jobId=' + arc.jobId + ')')
1368
+ assert(arc.passed === undefined && !existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async.lean')), '★ NO proof is minted at enqueue time (nothing may become passed before the compile lands)')
1369
+ assert(leanRuns.length === runsBefore, '★ the compiler has NOT been spawned yet by the enqueueing call (it is genuinely non-blocking)')
1370
+ const st0 = await N.callTool('vibe_v4_status', {})
1371
+ assert(!st0.formal.passed.includes('p-async'), 'and the object is not reported passed while queued')
1372
+ const done = await settleJob(N, arc.jobId)
1373
+ assert(done.state === 'settled' && done.exitCode === 0 && done.archive === 'Verified/Lean/p-async.lean', '★ the job settles green and reports its archive path (' + done.state + '/' + done.exitCode + ')')
1374
+ assert(existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async.lean')), '★ the archived proof exists only AFTER the settle')
1375
+ const st1 = await N.callTool('vibe_v4_status', {})
1376
+ assert(st1.formal.passed.includes('p-async'), '★ the object is now passed')
1377
+ const list = await N.callTool('vibe_v4_lean_job', {})
1378
+ assert(list.ok === true && list.jobs.some(j => j.jobId === arc.jobId && j.receipt === 'Formal/Jobs/' + arc.jobId + '.json' && j.archive === 'Verified/Lean/p-async.lean'), '★ lean_job (no jobId) lists the job with its receipt + archive paths')
1379
+ const nextWake = await N.callTool('vibe_v4_message', { to: 'r-1', content: '继续。' })
1380
+ const promptsText = N.followups.map(promptOf).join('\n')
1381
+ assert(/【形式化结果】/.test(promptsText), '★ the settled result is announced once in the next prompt (【形式化结果】)')
1382
+ }
1383
+
1384
+ // ===============================================================
1385
+ // 18. The failure + timeout branches stay `attempted`.
1386
+ // ===============================================================
1387
+ section('18 async failure and timeout never set passed')
1388
+ {
1389
+ const N = await establish()
1390
+ await N.callTool('vibe_v4_set', { formalVerify: 'require', leanAsync: true, activityTimeoutMs: 60 })
1391
+ const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async-fail', content: 'theorem p_async_fail : False := by sorry\n' })
1392
+ const done = await settleJob(N, arc.jobId)
1393
+ assert(done.state === 'failed' && done.exitCode === 1, '★ a red compile settles as failed (not passed)')
1394
+ assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async-fail.lean')), '★ no proof is archived for a red run')
1395
+ const st = await N.callTool('vibe_v4_status', {})
1396
+ const rec = (st.formal.objects || []).find(o => o.target === 'p-async-fail') || {}
1397
+ assert(!st.formal.passed.includes('p-async-fail') && rec.status === 'attempted', '★ a failed async proof stays attempted and never satisfies the require gate (record status=' + rec.status + ')')
1398
+ // a HANG file: only the framework's own timeout can end it
1399
+ await N.callTool('vibe_v4_set', { leanTimeoutMs: 1000 })
1400
+ writeIn(N, 'Projects/default/Formal/a-hang.lean', '-- HANG\ntheorem a_hang : True := by trivial\n')
1401
+ const hang = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-async-hang', content: '-- HANG\ntheorem p_async_hang : True := by trivial\n' })
1402
+ const hd = await settleJob(N, hang.jobId, 120)
1403
+ assert(hd.state === 'timeout' && hd.timedOut === true, '★ a hanging compile is timed out by the job budget (leanTimeoutMs), not left running')
1404
+ assert(terminated.length >= 1, '★ the timeout path really called handle.terminate() (an active kill, not just a report)')
1405
+ assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-async-hang.lean')), 'a timed-out proof is never archived')
1406
+ }
1407
+
1408
+ // ===============================================================
1409
+ // 19. dispose/abort terminates the in-flight compile and marks it interrupted.
1410
+ // ===============================================================
1411
+ section('19 dispose marks the in-flight job interrupted (never passed)')
1412
+ {
1413
+ const N = await establish()
1414
+ // The heartbeat is the queue driver, so it must be SHORT before the enqueue arms it (a long
1415
+ // heartbeat armed at enqueue time would leave the job queued forever — which is the point of §2.2).
1416
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 40, leanTimeoutMs: 600000 })
1417
+ const arc = await N.callTool('vibe_v4_lean_archive', { kind: 'proof', target: 'p-dispose', content: '-- HANG\ntheorem p_dispose : True := by trivial\n' })
1418
+ for (let i = 0; i < 100; i++) { const r = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId }); if (r.state === 'running') break; await sleep(25) }
1419
+ const before = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId })
1420
+ assert(before.state === 'running', 'precondition: the job is genuinely in flight (state=' + before.state + ')')
1421
+ await N.callTool('vibe_v4_abort', {})
1422
+ const after = await N.callTool('vibe_v4_lean_job', { jobId: arc.jobId })
1423
+ assert(after.state === 'interrupted' && after.interrupted === true, '★ abort/dispose marks the in-flight job interrupted (state=' + after.state + ')')
1424
+ assert(!existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-dispose.lean')), '★ an interrupted job never mints a proof')
1425
+ const st = await N.callTool('vibe_v4_status', {})
1426
+ assert(!st.formal.passed.includes('p-dispose'), 'and the object is not passed')
1427
+ }
1428
+
1429
+ // ===============================================================
1430
+ // 20. crash/restart recovery reads Formal/Jobs/*.json and never silently verifies.
1431
+ // ===============================================================
1432
+ section('20 crash recovery: queued re-driven, changed content never verified, settled completed')
1433
+ {
1434
+ const N = await establish()
1435
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: true, activityTimeoutMs: 600000 })
1436
+ const jobsDir = join(N.projectRoot, 'Formal', 'Jobs')
1437
+ mkdirSync(jobsDir, { recursive: true })
1438
+ const body = 'theorem p_rec : 1 + 1 = 2 := by decide\n'
1439
+ const file = writeIn(N, 'Projects/default/Formal/p-rec.lean', body)
1440
+ const crypto = await import('node:crypto')
1441
+ const sha = crypto.createHash('sha256').update(body.replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '').replace(/\n+$/, '') + '\n', 'utf8').digest('hex')
1442
+ // (a) `running` with UNCHANGED content → re-queued (never verified)
1443
+ writeFileSync(join(jobsDir, 'p-rec-aaaaaaaaaaaa.json'), JSON.stringify({ jobId: 'p-rec-aaaaaaaaaaaa', kind: 'proof', key: 'p-rec', target: 'p-rec', rel: 'Formal/p-rec.lean', abs: file, sha256: sha, content: body, state: 'running', attempts: 1, enqueuedAt: 1, startedAt: 2 }))
1444
+ // (b) `running` with CHANGED content → interrupted + attempted, NO auto re-drive
1445
+ const stalePath = writeIn(N, 'Projects/default/Formal/p-stale.lean', 'theorem p_stale : True := by trivial\n')
1446
+ writeFileSync(join(jobsDir, 'p-stale-bbbbbbbbbbbb.json'), JSON.stringify({ jobId: 'p-stale-bbbbbbbbbbbb', kind: 'proof', key: 'p-stale', target: 'p-stale', rel: 'Formal/p-stale.lean', abs: stalePath, sha256: sha, content: body, state: 'running', attempts: 1, enqueuedAt: 1, startedAt: 2 }))
1447
+ // A FRESH run (start) performs the safe half of the recovery table: `queued` jobs are re-driven,
1448
+ // `running` jobs are re-driven only while the content still matches, and a stale `settled` record
1449
+ // is NEVER adopted (that is exactly what the clean formal slate exists to prevent).
1450
+ writeFileSync(join(jobsDir, 'p-done-cccccccccccc.json'), JSON.stringify({ jobId: 'p-done-cccccccccccc', kind: 'proof', key: 'p-done', target: 'p-done', rel: 'Formal/p-done.lean', abs: file, sha256: sha, content: body, state: 'settled', exitCode: 0, verifiedRel: 'Verified/Lean/p-done.lean', attempts: 1, enqueuedAt: 1, startedAt: 2, settledAt: 3 }))
1451
+ await N.callTool('vibe_v4_start', { problem: '恢复扫描', residentCount: 1 })
1452
+ const a = await N.callTool('vibe_v4_lean_job', { jobId: 'p-rec-aaaaaaaaaaaa' })
1453
+ const b = await N.callTool('vibe_v4_lean_job', { jobId: 'p-stale-bbbbbbbbbbbb' })
1454
+ assert(a.attempts >= 2 && (a.state === 'queued' || a.state === 'running' || a.state === 'settled'), '* a hash-matching interrupted job is RE-DRIVEN (never adopted): attempts 1 -> ' + a.attempts + ', state=' + a.state)
1455
+ // (cold-state liveness, code path recoverLeanJobs:911): with NO explicit followup the armed heartbeat
1456
+ // must drain the re-queued job on a later tick - poll with the diagnostic triple in the message.
1457
+ {
1458
+ const t0 = Date.now()
1459
+ let cur = a, tries = 0
1460
+ while (tries < 60 && cur.state === 'queued') { tries++; await new Promise((r) => setTimeout(r, 100)); cur = await N.callTool('vibe_v4_lean_job', { jobId: 'p-rec-aaaaaaaaaaaa' }) }
1461
+ assert(cur.state !== 'queued', '* the next tick DRAINS the re-queued job without any explicit followup (state=' + cur.state + ', window=6000ms, waited=' + (Date.now() - t0) + 'ms, attempts=' + tries + ')')
1462
+ const stLive = await N.callTool('vibe_v4_status', {})
1463
+ assert(stLive.running === true, '* the institute clock keeps running after a stale-only recovery: recoverLeanJobs:911 arms ONE idle pass even when nothing is drainable (documented behaviour - if a future change stops arming on stale-only, THIS reddens)')
1464
+ }
1465
+ assert(b.state === 'interrupted', '★ a `running` job whose file CHANGED is interrupted, not verified (state=' + b.state + ')')
1466
+ const st = await N.callTool('vibe_v4_status', {})
1467
+ assert(!st.formal.passed.includes('p-stale'), '★ the changed-content job never reaches `passed`')
1468
+ assert(!st.formal.passed.includes('p-done') && !existsSync(join(N.projectRoot, 'Verified', 'Lean', 'p-done.lean')), '★ a fresh run does NOT adopt a previous run’s `settled` record (no stale passed, no minted proof)')
1469
+ assert(N.followups.length === 0, '* the stale-job recovery path wakes NO new resident (followups=' + N.followups.length + ') - the wake decision belongs to the watchdog; this line is falsifiable, so a future change of that contract reddens HERE')
1470
+ }
1471
+
1472
+ // ===============================================================
1473
+ // 21. lean_read: verbatim library access, confined to Formal/{Lib,Proved}, 64KB cap.
1474
+ // ===============================================================
1475
+ section('21 lean_read returns the archived text verbatim and refuses escapes')
1476
+ {
1477
+ const N = await establish()
1478
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false }) // sync: the file must be on disk for the read
1479
+ const defBody = 'def ZMod9 := Fin 9\n\ntheorem zmod9_card : Fintype.card ZMod9 = 9 := by decide\n'
1480
+ const arch = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'ZMod9', content: defBody }, N.resAgent(N.childOf('r-1')))
1481
+ assert(arch.ok === true, 'precondition: the definition archived')
1482
+ const rd = await N.callTool('vibe_v4_lean_read', { name: 'ZMod9' })
1483
+ assert(rd.ok === true && rd.kind === 'lib' && rd.file === 'Formal/Lib/ZMod9.lean' && rd.text === defBody, '★ lean_read returns the archived file text VERBATIM (import-or-copy reuse)')
1484
+ assert(/^[0-9a-f]{64}$/.test(rd.sha256) && rd.bytes === defBody.length && rd.truncated === false, 'it reports sha256/bytes/truncated')
1485
+ assert(/import Formal\.Lib\.ZMod9/.test(rd.hint || ''), '★ and names the namespace to import (module root = VibeMath root)')
1486
+ const proved = await N.callTool('vibe_v4_lean_read', { name: 'ZMod9', kind: 'proved' })
1487
+ assert(proved.ok === false && proved.code === 'V4_NOT_FOUND', 'kind=proved does not look in Lib/')
1488
+ for (const bad of ['../../etc/passwd', '..', 'Formal/Lib/ZMod9', 'C:\\Windows\\evil']) {
1489
+ const r = await N.callTool('vibe_v4_lean_read', { name: bad })
1490
+ assert(r.ok === false, '★ lean_read refuses the path-escape name ' + JSON.stringify(bad))
1491
+ }
1492
+ assert((await N.callTool('vibe_v4_lean_read', { name: 'ZMod9', kind: 'bogus' })).ok === false, 'an unknown kind is refused')
1493
+ // 64KB truncation
1494
+ const big = 'def Big := 1\n' + ('-- ' + 'x'.repeat(120) + '\n').repeat(600)
1495
+ await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'BigLib', content: big, run: false })
1496
+ const rb = await N.callTool('vibe_v4_lean_read', { name: 'BigLib' })
1497
+ assert(rb.ok === true && rb.truncated === true && rb.text.length <= 64 * 1024 && rb.bytes > 64 * 1024, '★ a >64KB library file is truncated and flagged (truncated:true)')
1498
+ }
1499
+
1500
+ // ===============================================================
1501
+ // 22. Search paths (AMENDMENT §2) + build-context job id (§4) + concurrency (§5) + the new params.
1502
+ // ===============================================================
1503
+ section('22 leanSearchPaths/leanJobsMaxParallel/leanInitiative + build-context job ids')
1504
+ {
1505
+ const N = await establish()
1506
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false, leanArgs: [], leanSearchPaths: ['/extra/one', '/extra/one', '/extra/two'] })
1507
+ writeIn(N, 'Projects/default/Formal/a-sp.lean', 'theorem a_sp : True := by trivial\n')
1508
+ await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
1509
+ let last = leanRuns[leanRuns.length - 1]
1510
+ const spIdx = last.argv.indexOf('--search-path')
1511
+ assert(last.argv.filter(a => a === '--search-path').length === 3, '★ one --search-path per DISTINCT root (user paths first, then the automatic VibeMath root)')
1512
+ assert(last.argv[spIdx + 1] === '/extra/one' && last.argv[spIdx + 3] === '/extra/two' && last.argv[spIdx + 5] === N.vibeRoot.replace(/\\/g, '/'), '★ order + de-duplication: user paths in order, automatic root last')
1513
+ assert(last.argv.indexOf('--search-path') < last.argv.length - 1 && /\.lean$/.test(String(last.argv[last.argv.length - 1])), 'the roots stay BEFORE the file name')
1514
+ await N.callTool('vibe_v4_set', { leanArgs: ['--search-path', '/user/own'] })
1515
+ await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
1516
+ last = leanRuns[leanRuns.length - 1]
1517
+ assert(last.argv.filter(a => a === '--search-path').length === 1 && last.argv[last.argv.indexOf('--search-path') + 1] === '/user/own', '★ an explicit --search-path in leanArgs wins outright: nothing is injected')
1518
+ await N.callTool('vibe_v4_set', { leanArgs: ['-R', '/user/root'] })
1519
+ await N.callTool('vibe_v4_lean_run', { file: 'Formal/a-sp.lean' }, N.resAgent(N.childOf('r-1')))
1520
+ assert(leanRuns[leanRuns.length - 1].argv.indexOf('--search-path') === -1, '★ -R/--root count as a user-stated root too')
1521
+ const lib = await N.callTool('vibe_v4_lean_lib', { refresh: false })
1522
+ assert(lib.searchPath === N.vibeRoot.replace(/\\/g, '/') && /import Formal\.Lib\./.test(JSON.stringify(lib.importNamespace)), 'lean_lib shows the injected searchPath and the import namespace')
1523
+ // build-context job id: same content, different engine ⇒ different job
1524
+ await N.callTool('vibe_v4_set', { leanAsync: true, activityTimeoutMs: 600000, leanArgs: [], leanSearchPaths: [], leanCommand: 'lean' })
1525
+ const j1 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CtxOne', content: 'def CtxOne := 1\n' })
1526
+ await N.callTool('vibe_v4_set', { leanCommand: 'lake' })
1527
+ const j2 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CtxOne', content: 'def CtxOne := 1\n' })
1528
+ assert(j1.jobId !== j2.jobId, '★ the job id covers the BUILD CONTEXT: identical content with a different engine is a different job (' + j1.jobId + ' vs ' + j2.jobId + ')')
1529
+ // concurrency cap
1530
+ await N.callTool('vibe_v4_set', { leanCommand: 'lean', leanJobsMaxParallel: 2, activityTimeoutMs: 40 })
1531
+ const h1 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapA', content: '-- HANG\ndef CapA := 1\n' })
1532
+ const h2 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapB', content: '-- HANG\ndef CapB := 1\n' })
1533
+ const h3 = await N.callTool('vibe_v4_lean_archive', { kind: 'def', name: 'CapC', content: '-- HANG\ndef CapC := 1\n' })
1534
+ for (let i = 0; i < 120; i++) { const s = await N.callTool('vibe_v4_lean_job', {}); if (s.running.length >= 2) break; await sleep(25) }
1535
+ const snap = await N.callTool('vibe_v4_lean_job', {})
1536
+ assert(snap.running.length === 2 && snap.queued.length >= 1, '★ leanJobsMaxParallel=2 runs exactly two compiles and leaves the rest queued (' + snap.running.length + ' running / ' + snap.queued.length + ' queued; all=' + JSON.stringify(snap.jobs.map(j => j.jobId + ':' + j.state)) + '; archives=' + JSON.stringify([h1, h2, h3].map(x => x && (x.jobId || x.code || x.message)))) + ')'
1537
+ await N.callTool('vibe_v4_abort', {}) // terminate the hang jobs
1538
+ }
1539
+
1540
+ // ===============================================================
1541
+ // 23. leanInitiative separates PROACTIVITY from the verification requirement (AMENDMENT §1).
1542
+ // ===============================================================
1543
+ section('23 leanInitiative: off/normal/eager in the work prompt, formalVerify untouched')
1544
+ {
1545
+ const N = await establish()
1546
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanAsync: false })
1547
+ const normal = await N.prompts('normal', 'r-1')
1548
+ assert(/【顺手形式化(鼓励)】/.test(normal) && /判断标准:① 有价值或可能复用/.test(normal), '★ leanInitiative=normal (default): the daily line carries the three selection criteria')
1549
+ assert(/先 vibe_v4_lean_lib 查已有库/.test(normal) && /没把握就记 blocked/.test(normal) && /import Formal\.Lib\./.test(normal), '★ …and the look-up-first / blocked / namespace rules')
1550
+ assert(!/leanInitiative=eager/.test(normal), 'the normal档 does not claim eagerness')
1551
+ const verify = await N.prompts('verify', 'r-1', { target: 'p-init', stage: 'independent' })
1552
+ assert(/形式化只写你有把握的版本;没把握就记 blocked 并写清难点/.test(verify) && /不得\*\*在它落地前声称已通过/.test(verify), '★ the verification block carries the §5 B contract (blocked-instead-of-guessing + no premature "passed")')
1553
+ await N.callTool('vibe_v4_set', { leanInitiative: 'eager' })
1554
+ const eager = await N.prompts('normal', 'r-1')
1555
+ assert(/leanInitiative=eager/.test(eager) && /主动\*\*顺手形式化并归档/.test(eager), '★ leanInitiative=eager adds the explicit proactive clause')
1556
+ await N.callTool('vibe_v4_set', { leanInitiative: 'off' })
1557
+ const off = await N.prompts('normal', 'r-1')
1558
+ assert(!/【顺手形式化/.test(off), '★ leanInitiative=off removes the proactive daily line entirely')
1559
+ const vOff = await N.prompts('verify', 'r-1', { target: 'p-init', stage: 'independent' })
1560
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(vOff) && /实现难度/.test(vOff), '★ …while the VERIFICATION prompt still states the formalVerify requirement (off ≠ no verification)')
1561
+ // parameter surfaces (AMENDMENT §1/§2/§5 + leanAsync)
1562
+ const setSpec = N.toolRegs.find(t => t.name === 'vibe_v4_set')
1563
+ for (const k of ['leanAsync', 'leanInitiative', 'leanSearchPaths', 'leanJobsMaxParallel']) assert(!!setSpec.parameters.properties[k], '★ vibe_v4_set advertises ' + k)
1564
+ assert(JSON.stringify(setSpec.parameters.properties.leanInitiative.enum) === JSON.stringify(['off', 'normal', 'eager']), 'leanInitiative is a closed enum in the schema')
1565
+ const coerced = await N.callTool('vibe_v4_set', { leanAsync: 'false', leanInitiative: 'bogus', leanSearchPaths: 'a, b ,,c', leanJobsMaxParallel: 0 })
1566
+ const pv = await N.callTool('vibe_v4_status', {})
1567
+ assert(coerced.ok === true && /leanAsync=false/.test(pv.params), '★ the STRING "false" normalises to boolean false (never a truthy string)')
1568
+ assert(/leanInitiative=normal/.test(pv.params), 'an unknown leanInitiative degrades to the default normal')
1569
+ assert(/leanSearchPaths=a,b,c/.test(pv.params), 'a comma string becomes a trimmed, non-empty array')
1570
+ assert(/leanJobsMaxParallel=1/.test(pv.params), 'leanJobsMaxParallel is clamped to ≥1')
1571
+ // The SLASH-COMMAND surface passes RAW STRINGS to the same parameter layer (spec §B: v4's `/v4 set`
1572
+ // hands over strings, so 'false' must not survive as a truthy value there either).
1573
+ const slash = await N.cmd('set leanAsync=false leanInitiative=off')
1574
+ assert(slash.kind === 'success', '/v4 set accepted the Lean parameters')
1575
+ const pv2 = await N.callTool('vibe_v4_status', {})
1576
+ assert(/leanAsync=false/.test(pv2.params) && /leanInitiative=off/.test(pv2.params), '★ `/v4 set leanAsync=false leanInitiative=off` coerces the raw strings through normalizeParam')
1577
+ assert(!/【顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ and leanInitiative=off set through the slash command really silences the proactive line')
1578
+ }
1579
+
1580
+ // ===============================================================
1581
+ // 17. audit-B #2: `leanInitiative` is an INDEPENDENT axis from `formalVerify` — `eager` asks for the
1582
+ // daily line even with `formalVerify:'off'` (v5 uses the same rule); 'off' never injects it while
1583
+ // the VERIFICATION prompt keeps following `formalVerify`.
1584
+ // ===============================================================
1585
+ section('17 leanInitiative=eager injects the daily line even with formalVerify=off (independent axes)')
1586
+ {
1587
+ const N = await establish()
1588
+ await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'eager' })
1589
+ const daily = await N.prompts('normal', 'r-1')
1590
+ assert(/【顺手形式化(主动(leanInitiative=eager))】/.test(daily), '★ eager + formalVerify=off STILL injects the daily line (the header names the initiative, not a verification mode)')
1591
+ assert(/判断标准:① 有价值或可能复用/.test(daily) && /先 vibe_v4_lean_lib 查已有库/.test(daily), '★ …with the three criteria and the look-up-first rule')
1592
+ const verifyOff = await N.prompts('verify', 'r-1', { target: 'p-eager-off', stage: 'independent' })
1593
+ assert(!/顺手形式化/.test(verifyOff) && !/【Lean 形式化验证/.test(verifyOff), '★ …while the VERIFICATION prompt stays a true no-op under formalVerify=off')
1594
+ // the default initiative is not eager: off mode injects nothing (unchanged behaviour)
1595
+ await N.callTool('vibe_v4_set', { leanInitiative: 'normal' })
1596
+ assert(!/顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ leanInitiative=normal + formalVerify=off injects NO daily line')
1597
+ // 'off' silences the daily line even when formalVerify asks for Lean, without touching verification
1598
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanInitiative: 'off' })
1599
+ assert(!/顺手形式化/.test(await N.prompts('normal', 'r-1')), '★ leanInitiative=off removes the daily line even with formalVerify=encourage')
1600
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(await N.prompts('verify', 'r-1', { target: 'p-eager-off', stage: 'independent' })), '★ …and the verification prompt is untouched by the initiative knob')
1601
+ // with formalVerify on, the header names the verification mode again (not the eager wording)
1602
+ await N.callTool('vibe_v4_set', { formalVerify: 'require', leanInitiative: 'eager' })
1603
+ assert(/【顺手形式化(强制)】/.test(await N.prompts('normal', 'r-1')), '★ with formalVerify on, the header names the verification mode (require → 强制)')
1604
+ await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'normal' })
1605
+ }
1606
+
1607
+ // ===============================================================
1608
+ // 18. audit R2 HIGH: the independence rule must hold at EVERY daily-line injection site, not just
1609
+ // the ordinary work round. A single-site regression to `formalOn()`-only must be catchable, so
1610
+ // each site is asserted separately under BOTH decisive states (eager+off, and initiative=off).
1611
+ // ===============================================================
1612
+ section('18 every daily-line injection site (coreRules/normal/heartbeat) obeys the independence rule')
1613
+ {
1614
+ const N = await establish()
1615
+ const SITES = [
1616
+ ['coreRules', 'coreRulesBrief', () => N.prompts('coreRules')],
1617
+ ['normal', 'normalPrompt', () => N.prompts('normal', 'r-1')],
1618
+ ['heartbeat', 'heartbeatPrompt', () => N.prompts('heartbeat', 'r-1')],
1619
+ ]
1620
+ await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'eager' })
1621
+ for (const [which, who, read] of SITES) {
1622
+ const text = await read()
1623
+ assert(/【顺手形式化(主动(leanInitiative=eager))】/.test(text), '★ [' + which + '] eager + formalVerify=off injects the daily line at this site (' + who + ')')
1624
+ assert(/判断标准:① 有价值或可能复用/.test(text) && /先 vibe_v4_lean_lib 查已有库/.test(text), '★ [' + which + '] …with the three criteria and the look-up-first rule (' + who + ')')
1625
+ }
1626
+ const vOff = await N.prompts('verify', 'r-1', { target: 'p-site-off', stage: 'independent' })
1627
+ assert(!/顺手形式化/.test(vOff) && !/【Lean 形式化验证/.test(vOff), '★ [all sites] the verification prompt stays a no-op under formalVerify=off')
1628
+ await N.callTool('vibe_v4_set', { formalVerify: 'encourage', leanInitiative: 'off' })
1629
+ for (const [which, who, read] of SITES) {
1630
+ const text = await read()
1631
+ assert(!/顺手形式化/.test(text), '★ [' + which + '] leanInitiative=off removes the daily line at this site (' + who + ')')
1632
+ }
1633
+ assert(/【Lean 形式化验证(鼓励模式)】/.test(await N.prompts('verify', 'r-1', { target: 'p-site-off', stage: 'independent' })), '★ [all sites] …while verification still follows formalVerify (encourage)')
1634
+ await N.callTool('vibe_v4_set', { formalVerify: 'off', leanInitiative: 'normal' })
1635
+ }
1636
+
1637
+ // ===============================================================
1638
+ // 22. cross-preset audit P0 (v4 station): the member-visible library text must state that the
1639
+ // residents' file tools resolve against the SESSION CWD, so the listed sub-paths must be used
1640
+ // with the ABSOLUTE prefix (and computation artifacts via receipt.scriptAbs / receipt.cwd).
1641
+ // ===============================================================
1642
+ section('22 audit P0: member-facing paths carry the session-cwd / absolute-prefix instruction')
1643
+ {
1644
+ const N = await establish()
1645
+ const texts = [await N.prompts('brainstorm', 'r-1'), await N.prompts('coreRules'), await N.prompts('normal', 'r-1')]
1646
+ const ok = texts.filter((t) => t.indexOf('会话 cwd') !== -1 && t.indexOf('绝对前缀') !== -1)
1647
+ assert(ok.length >= 1, '★ the member-visible library text says file tools resolve against the SESSION CWD and that listed paths need the ABSOLUTE prefix (found in ' + ok.length + '/3 variants)')
1648
+ assert(ok.some((t) => t.indexOf('receipt.scriptAbs') !== -1 || t.indexOf('receipt.cwd') !== -1), '★ …and names the absolute receipt field (receipt.scriptAbs / receipt.cwd) for computation artifacts')
1649
+ }
1650
+
1651
+ // ===============================================================
1652
+ // 23. cross-preset audit P1 (v4 stations): a member-facing not-found must be diagnosable — code +
1653
+ // live roster status + an actionable next step — including `removeMember` (previously a bare
1654
+ // {ok:false}) and the not-yet-started case.
1655
+ // ===============================================================
1656
+ section('23 audit P1: member-facing not-found carries code + status + next')
1657
+ {
1658
+ const N = await establish()
1659
+ const wrong = await N.callTool('vibe_v4_send_message', { to: 'r-404', content: 'hi' }, N.resAgent(N.childOf('r-1')))
1660
+ assert(wrong.ok === false && wrong.code === 'V4_NO_SUCH_RESIDENT' && !!wrong.next && !!wrong.next.tool, '★ wrong resident id ⇒ code V4_NO_SUCH_RESIDENT + next{tool} (not a bare message)')
1661
+ assert(wrong.status && wrong.status.residentCount >= 1 && wrong.status.running === true, '★ …plus LIVE roster status, so "wrong id" is distinguishable from "not started"')
1662
+ const kicked = await N.callTool('vibe_v4_remove_member', { id: 'r-404' })
1663
+ assert(kicked.ok === false && kicked.code === 'V4_NO_SUCH_RESIDENT' && !!kicked.next && kicked.next.tool === 'vibe_v4_remove_member', '★ vibe_v4_remove_member {id:r-404} ⇒ code + next naming that tool (was a bare {ok:false})')
1664
+ const fresh = await mount()
1665
+ const notStarted = await fresh.callTool('vibe_v4_publish_progress', { content: 'x' })
1666
+ assert(notStarted.ok === false && notStarted.code === 'V4_NO_SUCH_RESIDENT' && !!notStarted.next && !!notStarted.status && notStarted.status.running === false, '★ run not started ⇒ code + next + status.running=false')
1667
+ }
1668
+
1669
+ // ---------- D4 cross-view: mid-verify hire/fire must not split the views ----------
1670
+ section('D4 cross-view: one frozen participant set, seen identically by every view')
1671
+ {
1672
+ const h = await establish()
1673
+ const st = await startVerifyOnly(h, 'p-d4')
1674
+ assert(st.consensus && st.consensus.kind === 'verify', 'precondition: an ACTIVE verification for the cross-view check (' + JSON.stringify({ c: st.consensus && st.consensus.kind, ip: st.verifyInProgress }) + ')')
1675
+ const c0 = st.consensus
1676
+ const rp0 = await h.callTool('vibe_v4_report', {})
1677
+ const rv0 = rp0.verify
1678
+ assert(Array.isArray(st.frozenParticipants) && rv0, 'the frozen participant set and the report() verify view are both exposed')
1679
+ assert(c0.expected === st.frozenParticipants.length, '★ consensus.expected === the frozen participant set size')
1680
+ assert(Number(String(rv0.voted).split('/')[1]) === c0.expected, '★ the two views agree on the DENOMINATOR (status().consensus.expected vs report().verify k/N)')
1681
+ assert(rv0.rosterVersion === c0.rosterVersion && c0.rosterVersion === st.rosterVersion, '★ both views (and status) report the SAME rosterVersion')
1682
+ // HIRE mid-verify: the newcomer must NOT join the frozen set (that is what freezing means).
1683
+ const hire = await h.callTool('vibe_v4_add_member', { direction: 'D4 cross-view assertion' })
1684
+ const st2 = await h.callTool('vibe_v4_status', {})
1685
+ const rv2 = (await h.callTool('vibe_v4_report', {})).verify
1686
+ assert(st2.rosterVersion > c0.rosterVersion, 'hiring mid-verify bumps rosterVersion (' + c0.rosterVersion + ' -> ' + st2.rosterVersion + ')')
1687
+ assert(st2.consensus.expected === st2.frozenParticipants.length && st2.consensus.expected === c0.expected,
1688
+ '★ after a mid-verify HIRE the frozen set is UNCHANGED and both views still agree (newcomer did not join)')
1689
+ assert(typeof rv2.rosterVersion === 'number' && typeof st2.consensus.rosterVersion === 'number', 'non-vacuous: both frozen views EXPOSE a numeric rosterVersion (a relative === passes when both are absent)')
1690
+ assert(rv2.rosterVersion === st2.consensus.rosterVersion,
1691
+ '★ after the HIRE the two FROZEN views (status().consensus + report().verify) still report the SAME rosterVersion')
1692
+ assert(st2.rosterVersion > rv2.rosterVersion,
1693
+ '★ …while status().rosterVersion is the LIVE counter and has moved past the snapshot (staleness is visible)')
1694
+ // FIRE mid-verify: the frozen set is pruned so the in-flight verification can still conclude.
1695
+ if (hire && hire.ok && hire.id) await h.callTool('vibe_v4_remove_member', { id: hire.id })
1696
+ const st3 = await h.callTool('vibe_v4_status', {})
1697
+ const rv3 = (await h.callTool('vibe_v4_report', {})).verify
1698
+ assert(st3.consensus.expected === st3.frozenParticipants.length && Number(String(rv3.voted).split('/')[1]) === st3.consensus.expected && rv3.rosterVersion === st3.consensus.rosterVersion,
1699
+ '★ after a mid-verify FIRE the two views STILL agree (the frozen set was pruned together)')
1700
+ assert(st3.frozenParticipants.indexOf(hire && hire.id) === -1, '★ the removed member is not in the frozen set any more (pruned on removal)')
1701
+ // …and the verification really does conclude after that churn (T26/T31 contract).
1702
+ await settleVerify(h, () => 1)
1703
+ const st4 = await h.callTool('vibe_v4_status', {})
1704
+ assert(st4.verifyInProgress === false, '★ the mid-verify hire/fire did NOT prevent the verification from concluding')
1705
+ }
1706
+
1707
+
1708
+
1709
+ // ===============================================================
1710
+ // 24. round A F2 (design-preserving): a HIRE never extends the frozen set, so the live counter may
1711
+ // drift ahead of it (that drift IS the staleness signal). A FIRE prunes the frozen set, so the
1712
+ // snapshot must be re-stamped — otherwise the same version would denote two different sets.
1713
+ // ===============================================================
1714
+ section('24 round A F2: hire keeps the snapshot stale-able; fire re-stamps it (version ≡ set)')
1715
+ {
1716
+ const N = await establish()
1717
+ await N.callTool('vibe_v4_meeting', { agenda: 'round A 快照一致性' })
1718
+ const s1 = await N.callTool('vibe_v4_status', {})
1719
+ assert(!!s1.frozen && Array.isArray(s1.frozen.participants) && s1.frozen.participants.length >= 1, '★ an active consensus exposes frozen{version,participants} (additive)')
1720
+ assert(s1.frozen.version === s1.rosterVersion, 'at freeze time the snapshot version equals the live counter')
1721
+ assert(typeof s1.consensus.rosterVersion === 'number' && Array.isArray(s1.consensus.participants) && s1.consensus.rosterVersion === s1.frozen.version, '\u2605 F3 (absolute first, unguarded): status().consensus EXPOSES rosterVersion + participants and it equals the frozen snapshot version')
1722
+ const v1 = s1.rosterVersion
1723
+ await N.callTool('vibe_v4_add_member', { direction: 'round A 新增成员' })
1724
+ const s2 = await N.callTool('vibe_v4_status', {})
1725
+ assert(s2.rosterVersion > v1, 'hiring bumps the numeric rosterVersion (monotone)')
1726
+ assert(s2.frozen.version === v1, '★ a HIRE leaves the frozen snapshot version untouched (its set did not change)')
1727
+ assert(s2.frozen.participants.length === s1.frozen.participants.length, '★ …and the frozen requirement set is unchanged (frozen means frozen)')
1728
+ assert(s2.rosterVersion > s2.frozen.version, '★ the live-vs-frozen drift remains the visible staleness signal')
1729
+ const removed = s1.frozen.participants[0]
1730
+ await N.callTool('vibe_v4_remove_member', { id: removed })
1731
+ const s3 = await N.callTool('vibe_v4_status', {})
1732
+ assert(s3.frozen.participants.indexOf(removed) === -1, '★ a FIRE prunes the frozen set (the wait is released)')
1733
+ assert(s3.frozen.version === s3.rosterVersion && s3.frozen.version > v1, '★ F2: the pruned set carries a NEW version — no window where a changed set keeps the old version')
1734
+ }
1735
+ // ===============================================================
1736
+ // ===============================================================
1737
+ // N4. F-PROMPT: a documented 'true no-op' must be asserted on the DELIVERED prompt, not on the
1738
+ // config path. Under formalVerify:'off' the verify prompt must carry NO formalization clause
1739
+ // and NO forward reference to a segment that is not delivered.
1740
+ // ===============================================================
1741
+ section('N4 off: the DELIVERED verify prompt has no formalization content or dangling pointer')
1742
+ {
1743
+ const P = await establish()
1744
+ const off = await P.prompts('verify', 'r-1', { target: 'p-fprompt', stage: 'independent' })
1745
+ assert(off.length > 100 && /\u9a8c\u8bc1/.test(off), '* the delivered verify prompt is non-trivial (non-vacuity: a missing/empty prompt must not pass the negations below)')
1746
+ assert(!/\u5f62\u5f0f\u5316\u6bb5/.test(off), '* the off-mode verify prompt contains no forward reference to the formalization segment (\u5f62\u5f0f\u5316\u6bb5)')
1747
+ assert(!/formal/.test(off), '* and no formalization content at all (no `formal` receipt clause) - the documented true no-op')
1748
+ await P.callTool('vibe_v4_set', { formalVerify: 'encourage' })
1749
+ const enc = await P.prompts('verify', 'r-1', { target: 'p-fprompt', stage: 'independent' })
1750
+ assert(/Lean|formal/.test(enc), '* under encourage the delivered prompt DOES carry the formalization block (the gate is a gate, not a deletion)')
1751
+ }
1752
+ // ===============================================================
1753
+ // N5. v4 path basis - CORRECTED CONTRACT (the earlier comment here pinned the OLD, WRONG shape:
1754
+ // it demanded a member root. v4's writers/readers live at the PROJECT ROOT: publishProgress writes
1755
+ // Progress/<rId>/progress.md, the record tools write Propos|Methods|Subproblems/<rId>/<id>.md, and
1756
+ // countArtifacts() scans exactly those bases under frameworkRoot(). A Members/<placeholder>/... path
1757
+ // is therefore a path nothing scans - the realistic documented example is base + '/Propos/r-1/p-1.md'.
1758
+ // (Named in the report as the "comment pinning old behaviour" case.)
1759
+ // ===============================================================
1760
+ section('N5 v4 member-facing paths are PROJECT-ROOT (the shape the framework scans), and no Members/ shape')
1761
+ {
1762
+ const P = await establish()
1763
+ const text = (await P.prompts('brainstorm', 'r-1')) + '\n' + (await P.prompts('normal', 'r-1'))
1764
+ // INVERTED (critical correction): the framework's writer/reader live at the PROJECT ROOT
1765
+ // (publishProgress writes Progress/<rId>/progress.md; countArtifacts scans Propos|Methods|Subproblems
1766
+ // under frameworkRoot()). The delivered text must document THOSE paths, never Members/<x>/...
1767
+ const decls = (text.match(/(?:^|[^A-Za-z0-9/_.-])(?:Progress|Propos|Methods|Subproblems)\/<[^>]+>\//g) || [])
1768
+ assert(decls.length >= 4, '* every member-visible library declaration is PROJECT-ROOT relative (Progress/<you>/, Propos/<you>/, ...) - the shape the framework scans (found ' + decls.length + ')')
1769
+ const wrongMembers = (text.match(/Members\/<[^>]+>\//g) || [])
1770
+ assert(wrongMembers.length === 0, '* NO member-facing declaration uses the Members/<x>/ shape (the framework never scans it) - found ' + JSON.stringify(wrongMembers))
1771
+ assert(/Progress\/<你>\/progress\.md/.test(text), '* the documented progress path matches publishProgress (Progress/<rId>/progress.md)')
1772
+ assert(/Propos\/<你>\//.test(text) && /Methods\/<你>\//.test(text) && /Subproblems\/<你>\//.test(text), '* the documented card paths match the record tools (Propos|Methods|Subproblems/<rId>/<id>.md)')
1773
+ // CLASS GUARD (one level above v3's): what the prompt documents must be what the code reads/scans
1774
+ const src4 = readFileSync(fileURLToPath(new URL('../vibe-math-v4/vibe-math-v4.js', import.meta.url)), 'utf8')
1775
+ for (const base of ['Progress', 'Propos', 'Methods', 'Subproblems']) {
1776
+ assert(new RegExp("'" + base + "'").test(src4), '* class guard: the framework reads/scans ' + base + ' - every documented write path must map onto a scanned base')
1777
+ }
1778
+ assert(/['"]Members\/['"]\s*\+/.test(src4) === false, '* class guard: no code path (writer) builds a Members/<id>/... path in v4')
1779
+ }
1780
+ section('N6 four-preset path discipline (member root / prefix-note)')
1781
+ {
1782
+ const { readFileSync } = await import('node:fs')
1783
+ const { fileURLToPath } = await import('node:url')
1784
+ const pick = (env, rel) => process.env[env] ? process.env[env] : fileURLToPath(new URL(rel, import.meta.url))
1785
+ const files = {
1786
+ v2: pick('V2_PLUGIN', '../vibe-math-v2/vibe-math-v2.js'),
1787
+ v3: pick('V3_PLUGIN', '../vibe-math-v3/vibe-math-v3.js'),
1788
+ v4: pick('V4_PLUGIN', '../vibe-math-v4/vibe-math-v4.js'),
1789
+ v5: pick('V5_PLUGIN', '../vibe-math-v5/vibe-math-v5.js'),
1790
+ }
1791
+ const ROOT = /Members\/<[^>]+>\/(?:Progress|Propos|Methods|Subproblems)\//g
1792
+ const LEGACY = /(?:^|[^/>])(?:Progress|Propos|Methods|Subproblems)\/<[^>]+>\//g
1793
+ for (const k of ['v5']) { // v5 pending vm-v5's re-verification; v4 handled explicitly below
1794
+ const src = readFileSync(files[k], 'utf8')
1795
+ const root = (src.match(ROOT) || []).length
1796
+ const legacy = (src.match(LEGACY) || []).length
1797
+ assert(root >= 4, '* ' + k + ': every member-visible library declaration carries the member root (found ' + root + ')')
1798
+ assert(legacy === 0, '* ' + k + ': no declaration keeps the legacy project-root-relative shape (found ' + legacy + ')')
1799
+ }
1800
+ // v4 (critical correction): the framework writes/reads at the PROJECT ROOT - publishProgress writes
1801
+ // Progress/<rId>/progress.md and countArtifacts scans Propos|Methods|Subproblems under frameworkRoot(),
1802
+ // so a Members/<x>/... declaration is a path NOTHING scans.
1803
+ {
1804
+ const src4 = files.v4 ? readFileSync(files.v4, 'utf8') : ''
1805
+ const decl4 = (src4.match(/(?:^|[^A-Za-z0-9/_.-])(?:Progress|Propos|Methods|Subproblems)\/<[^>]+>\//g) || [])
1806
+ const bad4 = (src4.match(/Members\/<[^>]+>\//g) || [])
1807
+ assert(!/Members\/<[^>]+>\//.test(src4), '* v4: NO member-facing declaration/code claim uses the Members/<x>/ shape (found ' + bad4.length + ')')
1808
+ assert(/Progress|<rId>|progress\.md/.test(src4), '* v4: the writer/reader bases are the project-root Progress/Propos/Methods/Subproblems')
1809
+ }
1810
+ // v3 (v2/v3 deep review D1): the writer `applyAgentWrites` accepts ONLY Problems|Progress|Propos|Methods|Notes
1811
+ // at the PROJECT ROOT, so a `Members/<x>/...` declaration is a write it SILENTLY DISCARDS. v3's member-facing
1812
+ // text must therefore stay root-relative - the opposite of v4/v5, which really do have a member subtree.
1813
+ const v3 = readFileSync(files.v3, 'utf8')
1814
+ assert(!/Members\/<[^>]+>\//.test(v3), '* v3: no member-facing declaration keeps the Members/<x>/ shape (the writer would discard that write)')
1815
+ const v3decl = [...v3.matchAll(/(?:^|[^/>])([A-Za-z]+)\/<[^>]+>\//g)].map((m) => m[1])
1816
+ const WL = ['Problems', 'Progress', 'Propos', 'Methods', 'Notes']
1817
+ const v3ok = v3decl.filter((x) => WL.indexOf(x) !== -1)
1818
+ assert(v3ok.length >= 1, '* v3: its declarations use the project-root-relative sections the writer accepts (found ' + v3ok.length + ' of ' + JSON.stringify(v3decl) + ')')
1819
+ // CLASS GUARD (covers v2/v3/v4/v5): whatever sections the persona/prompts tell an agent to write must be
1820
+ // accepted by that preset's writer - this is the invariant that would have caught both D1 and the v4/v5 cases.
1821
+ assert(/applyAgentWrites[\s\S]{0,600}?(Problems|Progress|Propos|Methods|Notes)/.test(v3), '* class guard: applyAgentWrites whitelists the sections the member-facing text names (v3)')
1822
+ const v2 = readFileSync(files.v2, 'utf8')
1823
+ assert(!/Members\/<[^>]+>\//.test(v2), '* v2: no Members/<x>/ shape either (anti-vacuity for the class, without over-claiming v2 has no path vocabulary)')
1824
+ assert(/PAPER_PATH_NOTE/.test(v2) && /\u4f1a\u8bdd cwd/.test(v2) && /\u7edd\u5bf9\u524d\u7f00/.test(v2), '* v2 uses the OTHER mechanism: one PAPER_PATH_NOTE stating the session-cwd + absolute-prefix rule')
1825
+ const uses = (v2.match(/PAPER_PATH_NOTE/g) || []).length
1826
+ // (D3 fixed the duplicate: the note no longer belongs in the evidence ENTRY list.) Assert the
1827
+ // STRUCTURE, not an occurrence count: defined once, appended by each member-facing material text,
1828
+ // and never embedded in a bare data entry.
1829
+ const noteDef = (v2.match(/const PAPER_PATH_NOTE/g) || []).length
1830
+ assert(noteDef === 1, '* v2 defines PAPER_PATH_NOTE exactly once (found ' + noteDef + ')')
1831
+ const noteLines = v2.split(/\r?\n/).filter((l) => l.includes('PAPER_PATH_NOTE'))
1832
+ const embeds = noteLines.filter((l) => !/const PAPER_PATH_NOTE/.test(l) && !/[+]|push\(/.test(l))
1833
+ assert(embeds.length === 0, '* v2 never embeds the prose rule in a bare data entry (' + JSON.stringify(embeds).slice(0, 80) + ')')
1834
+ const bodyOf = (src, fn) => {
1835
+ const a = src.indexOf('function ' + fn)
1836
+ if (a < 0) return null
1837
+ const b = src.slice(a + 10).search(/\n {2}(?:async )?function /)
1838
+ if (b < 0) return null // STRICT bound: no fixed-length fallback (window bleed lesson)
1839
+ return src.slice(a, a + 10 + b)
1840
+ }
1841
+ // RULING (B): the index is a DATA LIST (prose-free); the delivered member-facing material that carries
1842
+ // the paths is the digest, and it must append the note. (The other consumer, paperEmitArtifacts ->
1843
+ // paperBuildMarkdown, produces the FINAL PAPER ARTIFACT for humans, not a member prompt.)
1844
+ assert(!/PAPER_PATH_NOTE/.test(bodyOf(v2, 'paperEvidenceIndex') || ''), '* v2: the evidence-index DATA LIST embeds no prose rule')
1845
+ assert(/PAPER_PATH_NOTE/.test(bodyOf(v2, 'buildPaperDigest') || ''), '* v2: the delivered digest material appends PAPER_PATH_NOTE')
1846
+ assert(bodyOf(v2, 'buildPaperDigest') !== null && bodyOf(v2, 'paperEvidenceIndex') !== null, '* v2: both windows are bounded strictly (no fixed-length fallback)')
1847
+ }
1848
+ section('N7 delivered prompts carry no labelled-but-empty slot (proposer/agenda/target)')
1849
+ {
1850
+ const P = await establish()
1851
+ const txt = await P.prompts('verify', 'r-1', { target: 'p-nopowner', stage: 'independent' })
1852
+ assert(txt.length > 100 && /verifying object p-nopowner/.test(txt), '* the delivered verify prompt is non-trivial and names the target (non-vacuity: an empty prompt must not pass below)')
1853
+ assert(!/\u63d0\u51fa\u8005\s*\uff09/.test(txt), '* no labelled-but-empty proposer slot in the delivered frame (\u63d0\u51fa\u8005 + ) )')
1854
+ assert(!/agenda:\s*[)\uff09]/.test(txt), '* no labelled-but-empty agenda slot in the delivered frame')
1855
+ // protocol 搂9 (path-alias family): resolve the plugin ONCE from the loader's own URL, read THAT, and
1856
+ // assert the read path equals the V4_PLUGIN seam when it is set - a guard must never print one path
1857
+ // and read another.
1858
+ const _pu = pluginUrl() // the loader's own resolution (seam-aware)
1859
+ const _href = typeof _pu === 'string' ? _pu : String(_pu && _pu.href ? _pu.href : _pu)
1860
+ const _loaderPath = _href.indexOf('file:') === 0 ? fileURLToPath(new URL(_href.split('?')[0])) : String(_href).split('?')[0]
1861
+ if (process.env.V4_PLUGIN) {
1862
+ const _a = _loaderPath.replace(/\\/g, '/').toLowerCase()
1863
+ const _b = String(process.env.V4_PLUGIN).replace(/\\/g, '/').toLowerCase()
1864
+ assert(_a === _b, '* N10 reads the SAME file the loader imports (readPath === V4_PLUGIN seam): ' + _a + ' vs ' + _b)
1865
+ }
1866
+ assert(/String\(vs\.targetOwner\|\|''\)\.trim\(\)\?/.test(PLUGIN_SRC()), '* the proposer segment is assembled ONLY when a value exists (source-level: the office is never invented)')
1867
+ }
1868
+ section('N8 the meeting frame\'s relay claim matches reality (no promise of speeches that do not exist)')
1869
+ {
1870
+ const P = await establish()
1871
+ const noPrior = await P.prompts('meeting', 'r-1')
1872
+ assert(noPrior.length > 100 && /A meeting is in progress/.test(noPrior), '* the meeting frame is non-trivial (non-vacuity)')
1873
+ assert(!/\u4e0b\u9762\u5df2\u6709\u4eba\u53d1\u8a00/.test(noPrior), '* with NO prior statements the frame does not claim that others have spoken (\u4e0b\u9762\u5df2\u6709\u4eba\u53d1\u8a00)')
1874
+ assert(/\u5e76\u884c\u72ec\u7acb\u53d1\u8a00/.test(noPrior), '* …instead it states the real semantics: this round is parallel independent statements')
1875
+ assert(/\(prior\?'\\n\u8fd9\u662f\u4e00\u573a\u771f\u5b9e\u8ba8\u8bba/.test(PLUGIN_SRC()), '* the relay claim is gated on prior existing (source-level)')
1876
+ assert(/\(prior\?\(\'\\n\\n### \u5df2\u6709\u53d1\u8a00/.test(PLUGIN_SRC()), '* the forwarded-speech block is still delivered whenever prior exists')
1877
+ }
1878
+ section('N9 behavioural: a write to the DOCUMENTED path is counted by countArtifacts (auto-meeting basis)')
1879
+ {
1880
+ const P = await establish()
1881
+ const st0 = await P.callTool('vibe_v4_status', {})
1882
+ assert(typeof st0.artifactCount === 'number', '* status exposes artifactCount (the auto-meeting basis) - keys: ' + JSON.stringify(Object.keys(st0).slice(0, 14)))
1883
+ const rec = await P.callTool('vibe_v4_record_proposition', { id: 'p-docpath', statement: '文档路径行为证明', value: 0.5, motive: 'm', p: 0.5 }, P.resAgent(P.childOf('r-1')))
1884
+ assert(rec.ok === true, '* a member card is recorded through the tool (returned ' + JSON.stringify(rec).slice(0, 120) + ')')
1885
+ const st1 = await P.callTool('vibe_v4_status', {})
1886
+ assert(st1.artifactCount > st0.artifactCount, '* the card written to the DOCUMENTED path (Propos/<id>/<id>.md at the project root) IS counted by countArtifacts (' + st0.artifactCount + ' -> ' + st1.artifactCount + ')')
1887
+ const files = P.host && P.host.fsWrites ? P.host.fsWrites.map((w) => String(w.path || '')).join(',') : ''
1888
+ assert(files.indexOf('Members/') === -1, '* no write landed under Members/ (the shape nothing scans) - writes: ' + files.slice(0, 160))
1889
+ }
1890
+ section('N10 G-7 class guard: agent-facing compaction takes the AGENT-LOCAL instance')
1891
+ {
1892
+ const { readFileSync } = await import('node:fs')
1893
+ const { fileURLToPath } = await import('node:url')
1894
+ assert(/function compactionForAgent\(/.test(PLUGIN_SRC()), '* v4 defines compactionForAgent (the agent-local accessor)')
1895
+ // F2 (HIGH): a named, BOUNDED anchor. The old window ran to the next two-space function (109,191 chars)
1896
+ // and swallowed unrelated code, which is why a line-located mutant escaped unnoticed.
1897
+ const CALL = 'const compaction = compactionForAgent(agent)'
1898
+ const at = PLUGIN_SRC().indexOf(CALL)
1899
+ assert(at > 0, '* the agent-facing compaction call exists verbatim (anchor: ' + CALL + ')')
1900
+ const _ls = PLUGIN_SRC().lastIndexOf('\n', at)
1901
+ const _le = PLUGIN_SRC().indexOf('\n', at)
1902
+ const win = PLUGIN_SRC().slice(_ls, _le > 0 ? _le : at + 200)
1903
+ assert(win.length <= 400, '* the inspected window is BOUNDED (the call line only; got ' + win.length + ')')
1904
+ assert(win.indexOf(CALL) !== -1, '* the window really contains the agent-facing call')
1905
+ if (process.env.V4_DEBUG) { const _lp = (function(){ try { const u = pluginUrl(); const h = typeof u === 'string' ? u : String(u && u.href ? u.href : u); return h.indexOf('file:') === 0 ? fileURLToPath(new URL(h.split('?')[0])) : h.split('?')[0] } catch (e) { return 'ERR:' + e.message } })(); console.log(' [N10-diag] read=' + _lp + ' | seam=' + String(process.env.V4_PLUGIN || '(none)') + ' | srcHasAnchor=' + (PLUGIN_SRC().indexOf(CALL) !== -1) + ' | win=' + win.length) }
1906
+ assert(!/compactionOf\(\)/.test(win), '* the agent-facing path does NOT call the host-root compactionOf() (a realm never falls back)')
1907
+ }
1908
+ console.log('')
1909
+ section('N11 F-4c: a failed mkdir is NAMED exactly once (deferred/distorted diagnosis fixed)')
1910
+ {
1911
+ const h = await mount()
1912
+ const seen = []
1913
+ const realErr = console.error
1914
+ console.error = (...a) => { seen.push(a.map(String).join(' ')); realErr(...a) } // protocol 搂6.4: capture AND still name a red
1915
+ shellMkdirExit = 1
1916
+ try {
1917
+ await h.callTool('vibe_v4_start', { problem: 'F-4c mkdir 失败可见', residentCount: 1 })
1918
+ const mk = shellCalls.filter(s => /New-Item|^mkdir -p /.test(String(s)))
1919
+ assert(mk.length >= 1, '* N11: the mkdir script was actually issued (shellCalls=' + shellCalls.length + ', mkdir=' + mk.length + ')')
1920
+ const warned = seen.filter(l => /ensureDirs: mkdir failed \(exit 1\) under/.test(l))
1921
+ assert(warned.length === 1, '* N11: the mkdir failure is NAMED exactly once (matched=' + warned.length + ' of ' + seen.length + ' captured stderr lines)')
1922
+ } finally { console.error = realErr; shellMkdirExit = 0 }
1923
+ }
1924
+
1925
+ section('N13 F1 invariant guards: membership predicates + roster-only ballots (source level)')
1926
+ {
1927
+ const srcF1 = PLUGIN_SRC()
1928
+ const bodyOfF1 = (name) => { const a = srcF1.indexOf('function ' + name + '('); if (a < 0) return null; let d = 0; for (let i = a; i < srcF1.length; i++) { const ch = srcF1[i]; if (ch === '{') d++; else if (ch === '}') { d--; if (d === 0) return srcF1.slice(a, i + 1) } } return null }
1929
+ const cvrF1 = bodyOfF1('continueVerifyRound'), fvF1 = bodyOfF1('finalizeVerify')
1930
+ assert(!!cvrF1 && !!fvF1, '* F1/1 both consensus functions are found (bounded, brace-balanced windows)')
1931
+ assert(cvrF1.length < 20000 && fvF1.length < 20000, '* F1/1 the inspected windows are BOUNDED (cvr=' + cvrF1.length + ', fv=' + fvF1.length + ') - a runaway window is itself a failure')
1932
+ const MEMF1 = 'ids.every(id=>vs.verdicts[id]!==undefined)'
1933
+ assert(cvrF1.indexOf(MEMF1) !== -1, '* F1/1 continueVerifyRound requires MEMBERSHIP of the frozen ids')
1934
+ assert(fvF1.indexOf(MEMF1) !== -1, '* F1/1 finalizeVerify uses the SAME membership predicate (the two views cannot disagree)')
1935
+ assert(!/Object\.keys\(vs\.verdicts\)\.length>=/.test(fvF1), '* F1/1 finalizeVerify does NOT use the count-only predicate (that is the F1 regression: a non-roster ballot could satisfy it)')
1936
+ assert(/const vals=ids\.filter\(id=>vs\.verdicts\[id\]!==undefined\)\.map\(id=>vs\.verdicts\[id\]\)/.test(fvF1), '* F1/1 finalizeVerify takes `vals` ONLY from the frozen ids (never Object.values of the live map)')
1937
+ assert(/_inRoster\s*=\s*!Array\.isArray\(verifyState\.rosterSnapshot\)\s*\|\|\s*verifyState\.rosterSnapshot\.indexOf\(r\.rId\)!==-1/.test(srcF1), '* F1/2 the ballot recording site is guarded by the FROZEN roster (verdicts keys are a subset of rosterSnapshot)')
1938
+ const gF1 = srcF1.indexOf('if(!_inRoster)'), aF1 = srcF1.indexOf('verifyState.verdicts[r.rId]={prob:p')
1939
+ assert(gF1 !== -1 && aF1 > gF1 && (aF1 - gF1) < 400, '* F1/2 the verdict assignment sits INSIDE that guard (distance=' + (aF1 - gF1) + ' chars)')
1940
+ }
1941
+
1942
+ section('N15 F-6 site 4: a failed compaction is NAMED exactly once (realCompact -> onResidentEnd)')
1943
+ {
1944
+ const h = await establish()
1945
+ const seen = []
1946
+ const realErr = console.error
1947
+ console.error = (...a) => { seen.push(a.map(String).join(' ')); realErr(...a) }
1948
+ try {
1949
+ compactionFails = true
1950
+ // the turn end is only processed for a BUSY resident (`if(!busy.delete(r.rId)) return`), so
1951
+ // wake it first, exactly like the suite's workWake() helper does.
1952
+ const before = h.followups.length
1953
+ await h.callTool('vibe_v4_message', { to: 'r-1', content: '请继续推进。' })
1954
+ for (let i = 0; i < 300 && h.followups.length === before; i++) await sleep(10)
1955
+ const cid = h.followups[h.followups.length - 1].childId
1956
+ assert(!!cid, '* N15/4 the resident was woken so the turn end reaches onResidentEnd (childId=' + cid + ')')
1957
+ h.fireEnd(cid, { summary: '推进本轮。', solved: false, contextPct: 20 })
1958
+ for (let i = 0; i < 80 && !seen.some(l => /realCompact: the compaction call failed/.test(l)); i++) await sleep(10)
1959
+ const w = seen.filter(l => /realCompact: the compaction call failed/.test(l))
1960
+ assert(w.length === 1, '* N15/4 realCompact: a failed compaction is NAMED exactly once (matched=' + w.length + ' of ' + seen.length + ' stderr within 800ms)')
1961
+ assert(/NOT compacted/.test(w[0] || ''), '* N15/4 the warning STATES THE CONSEQUENCE (context pressure was NOT compacted)')
1962
+ } finally { console.error = realErr; compactionFails = false }
1963
+ }
1964
+
1965
+ section('N14 F2 compaction policy: policy verb only, re-anchor only after a REAL compaction (source level)')
1966
+ {
1967
+ const srcC = PLUGIN_SRC()
1968
+ const aC = srcC.indexOf('async function realCompact(')
1969
+ assert(aC > 0, '* F2/a realCompact is found through the seam')
1970
+ let dC = 0, eC = aC
1971
+ for (let i = aC; i < srcC.length; i++) { const ch = srcC[i]; if (ch === '{') dC++; else if (ch === '}') { dC--; if (dC === 0) { eC = i; break } } }
1972
+ const bodyC = srcC.slice(aC, eC + 1)
1973
+ assert(bodyC.length > 100 && bodyC.length < 4000, '* F2/a the inspected window is BOUNDED (' + bodyC.length + ' chars) - a runaway window is itself a failure')
1974
+ assert(bodyC.indexOf('compactIfNeeded(agent, ') !== -1, '* F2/a realCompact calls the POLICY verb compactIfNeeded (threshold-driven)')
1975
+ assert(bodyC.indexOf('compactNow(') === -1, '* F2/a realCompact contains NO compactNow( call: a host offering the forcing verb must NOT be forced (user ruling)')
1976
+ assert(!/const force\s*=/.test(bodyC), '* F2/a the capability-based forcing branch is GONE, not merely deprioritised')
1977
+ assert(/const didCompact\s*=/.test(bodyC), '* F2/a the re-anchor is gated on didCompact (evidence that the policy call ACTUALLY compacted)')
1978
+ assert(/if\(didCompact\)\{/.test(bodyC), '* F2/a and the gate is the if(didCompact){ branch (mutant: if(true){ => this reddens)')
1979
+ }
1980
+
1981
+ section('N17 F-5 fence-root drift: containment, no-op boundary, one-shot naming both roots')
1982
+ {
1983
+ const h = await establish()
1984
+ const seen = []
1985
+ const realErr = console.error
1986
+ console.error = (...a) => { seen.push(a.map(String).join(' ')); realErr(...a) }
1987
+ const drift = () => seen.filter(l => /fence root does not CONTAIN this session workspace/.test(l))
1988
+ // NOTE: case (0) is 'no sandboxPolicy SERVICE'; case 1b is 'service present, root uninterpretable'.
1989
+ const ws = () => String(h.WS).replace(/\\/g, '/')
1990
+ try {
1991
+ const r1 = await h.callTool('vibe_v4_publish_progress', { content: 'F-5 case 1' }, h.resAgent(h.childOf('r-1')))
1992
+ await sleep(50)
1993
+ assert(drift().length === 0, '* F-5/i NO sandboxPolicy SERVICE at all => the policy path never runs and NO warning appears (drift=' + drift().length + '; the uninterpretable-root no-op is case 1b)')
1994
+ assert(r1 && r1.ok !== false, '* F-5/i and getPolicy() still returns a usable policy (the write went through)')
1995
+ // F-5/1b: a policy SERVICE that resolves to a shape with NO interpretable root ({allow,deny}) -
1996
+ // the helper IS invoked and must stay silent (uninterpretable => no-op, today's behaviour).
1997
+ seen.length = 0 // F-5: observe ONLY this case's window (no cascade from earlier cases)
1998
+ sandboxNoRoot = true
1999
+ const r1b = await h.callTool('vibe_v4_publish_progress', { content: 'F-5 case 1b' }, h.resAgent(h.childOf('r-1')))
2000
+ await sleep(50)
2001
+ assert(drift().length === 0, '* F-5/1b an UNINTERPRETABLE resolved policy ({allow,deny}) produces NO warning (documented no-op, not a dead branch) (' + drift().length + ')')
2002
+ const w1b = await h.callTool('vibe_v4_status', {})
2003
+ assert(r1b && r1b.ok !== false && !!w1b, '* F-5/1b getPolicy() still RESOLVES for that shape (the write succeeds, no throw path)')
2004
+ // F-5/1c: the session cwd is UNKNOWN (no cwd on the captured root agent) and the policy exposes no
2005
+ // top-level root, so workspaceRoot() is `'.'`; resolve({session}) still yields a root. The `.` clause
2006
+ // of the product guard is the ONLY reason this stays silent - a mutant that warns on an unknown cwd
2007
+ // reddens here by name.
2008
+ sandboxNoRoot = false
2009
+ sandboxDriftRoot = null
2010
+ sandboxUnknownCwd = true
2011
+ const seenC = []
2012
+ const realErrC = console.error
2013
+ console.error = (...a) => { seenC.push(a.map(String).join(' ')); realErrC(...a) }
2014
+ let hc = null
2015
+ try {
2016
+ // FOUNDING under an unknown cwd: the founding writes go through writeTextAbs -> getPolicy(),
2017
+ // so the window below both reaches the helper and proves it stayed silent
2018
+ hc = await mount()
2019
+ hc.resetResolveCalls()
2020
+ await hc.callTool('vibe_v4_start', { problem: 'F-5 case 1c', residentCount: 1 })
2021
+ await sleep(80)
2022
+ } finally { console.error = realErrC; sandboxUnknownCwd = false }
2023
+ const driftC = seenC.filter(l => /fence root does not CONTAIN this session workspace/.test(l))
2024
+ assert(driftC.length === 0, '* F-5/1c an UNKNOWN session cwd stays SILENT (the `.` guard: drift=' + driftC.length + ')')
2025
+ assert(hc.resolveCalls() >= 1, '* F-5/1c LIVENESS: the helper really ran with an unknown cwd (resolve calls=' + hc.resolveCalls() + ', policy queries=' + hc.policyQueries() + '), so the silence is not an unreached branch')
2026
+ sandboxNoRoot = false
2027
+ // (iv) agreeing roots => silence (anti-vacuity)
2028
+ seen.length = 0 // F-5: observe ONLY this case's window
2029
+ sandboxDriftRoot = ws()
2030
+ await h.callTool('vibe_v4_publish_progress', { content: 'F-5 case 4' }, h.resAgent(h.childOf('r-1')))
2031
+ await sleep(50)
2032
+ assert(drift().length === 0, '* F-5/iv an AGREEING resolved root produces NO warning (not vacuous)')
2033
+ // (iii) containment: an ANCESTOR root is legitimate => silence
2034
+ seen.length = 0 // F-5: observe ONLY this case's window
2035
+ sandboxDriftRoot = ws().replace(/\/[^/]+$/, '')
2036
+ await h.callTool('vibe_v4_publish_progress', { content: 'F-5 case 3' }, h.resAgent(h.childOf('r-1')))
2037
+ await sleep(50)
2038
+ assert(drift().length === 0, '* F-5/iii a root that CONTAINS the session workspace is accepted (no per-session false alarm)')
2039
+ // (ii) real drift => exactly one named warning naming BOTH sides
2040
+ seen.length = 0 // F-5: observe ONLY this case's window
2041
+ sandboxDriftRoot = ws() + '/somewhere-else'
2042
+ await h.callTool('vibe_v4_publish_progress', { content: 'F-5 case 2' }, h.resAgent(h.childOf('r-1')))
2043
+ await sleep(50)
2044
+ // a SECOND drift-triggering resolution: the one-shot must keep the warning at exactly one
2045
+ await h.callTool('vibe_v4_publish_progress', { content: 'F-5 case 2b' }, h.resAgent(h.childOf('r-1')))
2046
+ await sleep(50)
2047
+ const w = drift()
2048
+ assert(w.length === 1, '* F-5/ii the drift is NAMED exactly once (matched=' + w.length + ' of ' + seen.length + ' stderr lines)')
2049
+ assert(/resolved=/.test(w[0] || '') && /session=/.test(w[0] || ''), '* F-5/ii the warning names BOTH the resolved root and the session workspace')
2050
+ } finally { console.error = realErr; sandboxDriftRoot = null; sandboxNoRoot = false }
2051
+ }
2052
+
2053
+
2054
+ section('N18 F-6: a failed current-project marker write is NAMED exactly once')
2055
+ {
2056
+ // PRE-START: configure() refuses while a run is live and returns BEFORE writeCurrentProject
2057
+ // (measured with establish(): the injection never fired), so this section mounts without starting.
2058
+ const h = await mount()
2059
+ const seen = []
2060
+ const realErr = console.error
2061
+ console.error = (...a) => { seen.push(a.map(String).join(' ')); realErr(...a) }
2062
+ const hits = () => seen.filter((l) => /current-project marker was NOT persisted/.test(l))
2063
+ try {
2064
+ // ONE injected failure, only for the `.current` marker write (path-anchored, existing hook)
2065
+ fsWriteFailPred = (p) => /[\\/]\.current$/.test(String(p))
2066
+ fsWriteFailOnce = true
2067
+ await h.callTool('vibe_v4_configure', { project: 'n18-proj' })
2068
+ await sleep(50)
2069
+ assert(hits().length === 1, '* N18 the failed `.current` write is NAMED exactly once (matched=' + hits().length + ' of ' + seen.length + ' stderr lines)')
2070
+ assert(/next session may load the previous project/.test(hits()[0] || ''), '* N18 the warning STATES THE CONSEQUENCE (the next session may load the previous project)')
2071
+ // anti-vacuity: a SUCCESSFUL marker write stays silent
2072
+ fsWriteFailPred = null
2073
+ fsWriteFailOnce = false
2074
+ seen.length = 0
2075
+ await h.callTool('vibe_v4_configure', { project: 'n18-proj-2' })
2076
+ await sleep(50)
2077
+ assert(hits().length === 0, '* N18 a SUCCESSFUL marker write is silent (the assertion is not vacuous)')
2078
+ } finally { console.error = realErr; fsWriteFailPred = null; fsWriteFailOnce = false }
2079
+ }
2080
+
2081
+
2082
+ section('N20-3b rebuildLeanLibIndexes: a failed INDEX WRITE is NAMED once (covers all six callers)')
2083
+ {
2084
+ // ③ callsite enumeration (measured at this revision) and verdicts:
2085
+ // · settleLeanJob (:893) - own try/catch naming (in-suite capture = measured boundary)
2086
+ // · writeLeanLibIndexesSafe (:1006) - boundary: cold-recovery convenience, rebuilt again later
2087
+ // · leanArchive def/proof/blocked (:1394/:1438/:1452) - silent because the FUNCTION swallowed
2088
+ // · the lean_lib tool (:4139) - silent for the same reason (measured: ok:true + counts)
2089
+ // => ONE fix inside the function covers all six; this asserts the naming.
2090
+ const h = await mount()
2091
+ const seen = []
2092
+ const realErr = console.error
2093
+ console.error = (...a) => { seen.push(a.map(String).join(' ')); realErr(...a) }
2094
+ const hits = () => seen.filter(l => /rebuildLeanLibIndexes: the Lean library index could NOT be written/.test(l))
2095
+ try {
2096
+ // (a) anti-vacuity: a healthy rebuild is SILENT
2097
+ await h.callTool('vibe_v4_lean_lib', {})
2098
+ await sleep(30)
2099
+ assert(hits().length === 0, '* N20-3b a healthy index rebuild is silent (the case is not vacuous)')
2100
+ // (b) one injected index-write failure => exactly one NAMED warning WITH its consequence
2101
+ fsWriteFailPred = (p) => /(?:Lib|Proved)[\\/]Index\.md$/i.test(String(p))
2102
+ fsWriteFailOnce = true
2103
+ const r = await h.callTool('vibe_v4_lean_lib', {})
2104
+ await sleep(30)
2105
+ assert(hits().length === 1, '* N20-3b a failed index write is NAMED exactly once (matched=' + hits().length + ' of ' + seen.length + ' stderr lines)')
2106
+ assert(/STALE until the next successful rebuild/.test(hits()[0] || ''), '* N20-3b the warning STATES THE CONSEQUENCE (the index is stale until the next successful rebuild)')
2107
+ assert(!!r && r.ok === true, '* N20-3b and the tool still answers with the scanned counts (the naming is additive, not a new failure mode)')
2108
+ } finally { console.error = realErr; fsWriteFailPred = null; fsWriteFailOnce = false }
2109
+ }
2110
+
2111
+ console.log('passed=' + passed + ' failed=' + failed)
2112
+ if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
2113
+ console.log('ALL GREEN')
2114
+ process.exit(0)