dsh-vibe-math 2.7.3 → 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 (101) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -682
  3. package/README.md +683 -683
  4. package/docs/AUDIT-CHECKLIST.md +755 -744
  5. package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/audit-methodology.md +93 -0
  8. package/docs/final-paper.md +172 -172
  9. package/docs/formal-verification.md +613 -588
  10. package/docs/generate_framework_diagram_v2.py +114 -114
  11. package/docs/generate_framework_diagram_v3.py +127 -127
  12. package/docs/math-computation.md +195 -194
  13. package/docs/release-notes/README.en.md +1 -0
  14. package/docs/release-notes/README.md +1 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  16. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  17. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  18. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  19. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  20. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  21. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  22. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  23. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  24. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  25. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  26. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  27. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  28. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  29. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  30. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  31. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  32. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  33. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  34. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  35. package/docs/release-playbook.md +103 -0
  36. package/docs/slv-playbook.md +117 -0
  37. package/docs/test-timing.md +158 -157
  38. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  39. package/installer.js +792 -792
  40. package/package.json +13 -3
  41. package/prompt-corpus-v2/formal-verify-v2.json +2 -2
  42. package/prompt-corpus-v2/formal-verify-v2.md +4 -0
  43. package/prompt-corpus-v3/formal-verify-v3.json +7 -7
  44. package/prompt-corpus-v3/formal-verify-v3.md +14 -0
  45. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  46. package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
  47. package/scripts/clean-temp.mjs +234 -0
  48. package/tests/audit-engine-faces.mjs +298 -0
  49. package/tests/audit-formal-sensitivity.mjs +345 -342
  50. package/tests/audit-installer-compat.test.mjs +256 -256
  51. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  52. package/tests/audit-persona-sensitivity.mjs +323 -323
  53. package/tests/audit-persona-surface.test.mjs +431 -431
  54. package/tests/audit-prompt-duplication.mutants.mjs +3 -2
  55. package/tests/audit-prompt-invariants.mjs +589 -546
  56. package/tests/audit-registration.mjs +78 -78
  57. package/tests/audit-spec-traceability.mjs +193 -193
  58. package/tests/audit-status-report-fields.mjs +172 -172
  59. package/tests/audit-v5-integrity.mjs +795 -795
  60. package/tests/audit-v5-sensitivity.mjs +433 -432
  61. package/tests/e2e-v5-round2.test.mjs +2453 -2426
  62. package/tests/formal-verify-v2.test.mjs +1806 -1800
  63. package/tests/formal-verify-v3.test.mjs +1757 -1750
  64. package/tests/formal-verify-v4.test.mjs +2114 -2106
  65. package/tests/formal-verify-v5.test.mjs +774 -767
  66. package/tests/math-computation-discovery.mutants.mjs +3 -3
  67. package/tests/math-computation-shared.test.mjs +122 -5
  68. package/tests/math-computation-v2.test.mjs +14 -2
  69. package/tests/math-computation-v3.test.mjs +14 -2
  70. package/tests/math-computation-v4.test.mjs +739 -739
  71. package/tests/prompt-v5-integrity.test.mjs +1291 -1291
  72. package/tests/run-tests.mjs +393 -332
  73. package/tests/selfdrive-v5.mjs +560 -560
  74. package/tests/temp-hygiene.mutants.mjs +80 -0
  75. package/tests/v2-fix-probes.mutants.mjs +4 -0
  76. package/tests/v2-fix-probes.test.mjs +1034 -1033
  77. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  78. package/tests/v3-fix-probes.mutants.mjs +99 -95
  79. package/tests/v3-fix-probes.test.mjs +891 -890
  80. package/tests/v4-final-paper.mutants.mjs +108 -0
  81. package/tests/v5-institute-fixes.mutants.mjs +218 -190
  82. package/vibe-math-v2/agent.cordis.yml +376 -376
  83. package/vibe-math-v2/math-computation.js +17 -15
  84. package/vibe-math-v2/preset.yml +2 -2
  85. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  86. package/vibe-math-v2/vibe-math-v2.js +5078 -5011
  87. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
  88. package/vibe-math-v3/agent.cordis.yml +420 -420
  89. package/vibe-math-v3/math-computation.js +17 -15
  90. package/vibe-math-v3/preset.yml +2 -2
  91. package/vibe-math-v3/vibe-math-v3.js +6215 -6149
  92. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  93. package/vibe-math-v4/agent.cordis.yml +438 -438
  94. package/vibe-math-v4/math-computation.js +17 -15
  95. package/vibe-math-v4/preset.yml +2 -2
  96. package/vibe-math-v4/vibe-math-v4.js +4490 -4422
  97. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
  98. package/vibe-math-v5/math-computation.js +17 -15
  99. package/vibe-math-v5/vibe-math-v5.js +93 -11
  100. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
  101. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1,795 +1,795 @@
1
- // ============================================================
2
- // V5 INTEGRITY AUDIT — static self-check of vibe-math-v5.js against the ways this
3
- // kind of single-file plugin actually breaks:
4
- // 1. a function CALLED but never defined (ReferenceError at runtime only)
5
- // 2. a `params.X` read for a key that DEFAULT_PARAMS never declares (silent undefined)
6
- // 3. a tool handler calling `s.NAME(...)` on the session API object that the session
7
- // never returns (TypeError only when that tool is used)
8
- // 4. documented error codes that are never raised, and raised codes never documented
9
- // 5. leftover development markers / TODO scaffolding
10
- // Run: node tests/audit-v5-integrity.mjs (exit 1 on any finding)
11
- // ============================================================
12
- import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, readdirSync } from 'node:fs'
13
- import { spawnSync } from 'node:child_process'
14
- import { tmpdir } from 'node:os'
15
- import { join } from 'node:path'
16
- import { fileURLToPath } from 'node:url'
17
-
18
- const HERE = new URL('../', import.meta.url)
19
- /**
20
- * `V5_INTEGRITY_MUTATE` carries a JSON `[rel, from, to]` triple: that ONE repo-relative file is mutated
21
- * IN MEMORY for one child run. Combined with `--self-probe` this makes every guard below reproducible
22
- * from OUTSIDE — a verifier no longer has to copy this audit and rewrite its URLs to point it at a
23
- * mutated copy (R16's request). Same shape as `audit-prompt-invariants.mjs` and the
24
- * `math-computation-shared --self-probe` pattern.
25
- *
26
- * SEAM COVERAGE (R17 measured, both directions): the seam reaches a **plugin** path (a plugin mutation
27
- * produces 1 finding) AND the **corpus** path (a corpus mutation produces 2 findings) — i.e. it is not
28
- * limited to the plugin file, and a corpus-only drift is observable from outside too. The corpus is read
29
- * through the same `readRaw()`, so any new corpus check inherits the seam.
30
- */
31
- const MUT = process.env.V5_INTEGRITY_MUTATE
32
- function readRaw(rel) {
33
- let text = readFileSync(new URL(rel, HERE), 'utf8')
34
- if (MUT) {
35
- try {
36
- const [r, from, to] = JSON.parse(MUT)
37
- if (r === rel && from) text = text.replace(from, to)
38
- } catch (e) { /* a malformed mutation is a harness error, not an audit failure */ }
39
- }
40
- return text
41
- }
42
-
43
- /**
44
- * Each mutation is a REAL defect shape for one of this audit's guards. `expect` is a substring that MUST
45
- * appear in the failing run; `control: true` marks the unmutated run, which must stay green.
46
- */
47
- const SELF_PROBE_MUTATIONS = [
48
- { name: 'control (no mutation)', rel: '', from: '', to: '', expect: '', control: true },
49
- {
50
- name: 'F3: a status() top-level key is renamed (the frozen field surface must redden)',
51
- rel: 'vibe-math-v5/vibe-math-v5.js',
52
- from: 'leanNoticesScope: ',
53
- to: 'leanNoticesScopeRenamed: ',
54
- expect: 'status() field surface changed',
55
- },
56
- {
57
- name: 'F1: the README resume claim loses its in-instance case (the pairing gate must redden)',
58
- rel: 'README.md',
59
- from: '**同实例重建**(宿主丢了子会话、插件实例还在)**继续**原编号',
60
- to: '**同实例重建**从 1 重新开始',
61
- expect: 'README resume-claim side missing',
62
- },
63
- {
64
- name: 'F1: a code anchor the README cites is removed (its philosophy gate must redden)',
65
- rel: 'vibe-math-v5/vibe-math-v5.js',
66
- from: 'rounds.set(member.id, startRound)',
67
- to: 'void 0 /* anchor removed (self-probe) */',
68
- expect: 'philosophy gate missing from the implementation: the founding round is applied only AFTER a successful start',
69
- },
70
- {
71
- name: 'F2/G1: the diagram is mutated back to the pre-G1 inbox order (the pairing gate must redden)',
72
- rel: 'vibe-math-v5/架构图.md',
73
- from: '唯一的推进驱动',
74
- to: '唯一的推进驱动(先 ack 再构造)',
75
- expect: '架构图 still states the pre-G1 order',
76
- },
77
- {
78
- name: 'F2/G1: the plan is mutated back to the explicit pre-G1 inbox phrasing (the pairing gate must redden)',
79
- rel: 'vibe-math-v5/实现方案.md',
80
- from: '**未 ack ⇒ 仍可重投**',
81
- to: '**先 ack 邮件、再构造**;**未 ack ⇒ 仍可重投**',
82
- expect: '实现方案.md still states the pre-G1 order',
83
- },
84
- {
85
- name: 'F2/G1: a docs file is mutated back to the named pre-G1 phrasing (the docs-wide gate must redden)',
86
- rel: 'docs/AUDIT-CHECKLIST.md',
87
- from: '身份一律显式传递,绝不猜测。',
88
- to: '身份一律显式传递,绝不猜测。先 ack 再构造',
89
- expect: 'still states the pre-G1 order',
90
- },
91
- {
92
- name: 'F2/G1: the plan is mutated to re-introduce the pre-G1 re-send contract (the named check must redden)',
93
- rel: 'vibe-math-v5/实现方案.md',
94
- from: '`makeFileBackend`',
95
- to: '`makeFileBackend`(queued 永不重发)',
96
- expect: 'still states the pre-G1 re-send contract',
97
- },
98
- ]
99
-
100
- if (process.argv.includes('--self-probe')) {
101
- const bad = []
102
- for (const mut of SELF_PROBE_MUTATIONS) {
103
- const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--json'], {
104
- cwd: fileURLToPath(HERE),
105
- env: Object.assign({}, process.env, mut.control ? {} : { V5_INTEGRITY_MUTATE: JSON.stringify([mut.rel, mut.from, mut.to]) }),
106
- encoding: 'utf8',
107
- })
108
- let parsed = null
109
- try { parsed = JSON.parse(r.stdout) } catch (e) { /* fall through to the diagnostic below */ }
110
- const text = (r.stdout || '') + (r.stderr || '')
111
- if (mut.control) {
112
- const ok = r.status === 0 && parsed && parsed.failed === 0
113
- console.log((ok ? 'PASS ' : 'FAIL ') + 'control: the unmutated run stays green (' + (parsed ? parsed.failed + ' findings, ' + parsed.gates + ' gates' : 'unparsable output') + ')')
114
- if (!ok) bad.push('control run was not green')
115
- continue
116
- }
117
- const hit = text.includes(mut.expect)
118
- const red = r.status === 1 && parsed && parsed.failed > 0
119
- const ok = red && hit
120
- console.log((ok ? 'PASS ' : 'FAIL ') + mut.name + (ok ? '' : ' → exit=' + r.status + ' hit=' + hit + ' failed=' + (parsed && parsed.failed)))
121
- if (!ok) bad.push(mut.name + ' (exit ' + r.status + ', expected ' + JSON.stringify(mut.expect) + ')')
122
- }
123
- console.log('')
124
- console.log('V5 INTEGRITY SELF-PROBE: ' + (SELF_PROBE_MUTATIONS.length - bad.length) + '/' + SELF_PROBE_MUTATIONS.length + ' as required')
125
- for (const b of bad) console.error(' FAIL ' + b)
126
- process.exit(bad.length === 0 ? 0 : 1)
127
- }
128
-
129
- const FILE_REL = 'vibe-math-v5/vibe-math-v5.js'
130
- const raw = readRaw(FILE_REL)
131
- // Strip comments, string literals AND regex literals before any identifier scan. Without this the
132
- // heuristic matches English words inside comments that merely precede a '(' (e.g.
133
- // "// per unit (" becomes a phantom call to unit()), drowning the real findings.
134
- //
135
- // REGEX LITERALS ARE NOT OPTIONAL HERE: v5 contains `/[\\/:*?"<>|\u0000-\u001f]+/` — a character class
136
- // holding a DOUBLE QUOTE. A scanner without regex support reads that quote as a string start and
137
- // mangles the rest of the line. Measured on this very file (old vs new, line by line): 30 of 4365
138
- // lines differed and the stripped output did NOT parse. No identifier this audit checks
139
- // (call names, `params.*` reads, `s.*()` methods) happened to be missed in the current source, so this
140
- // was latent rather than exploited — but a single call appearing only on such a line would have been
141
- // invisible. The self-check at the bottom of this file keeps the scanner honest.
142
- // The keyword rule matters for the same reason as in audit-prompt-invariants.mjs (`return /…/`).
143
- function stripNoise(s) {
144
- let out = ''
145
- let i = 0
146
- const n = s.length
147
- let prev = '' // last significant token (single char, or a whole word such as `return`)
148
- let word = ''
149
- const REGEX_AFTER_KEYWORD = /^(?:return|typeof|case|delete|void|instanceof|in|of|yield|await|new|do|else)$/
150
- const regexAllowed = () => prev === '' || REGEX_AFTER_KEYWORD.test(prev) || /[(,=:[!&|?{};+\-*%~^<>]/.test(prev)
151
- while (i < n) {
152
- const c = s[i], c2 = s[i + 1]
153
- if (c === '/' && c2 === '/') { while (i < n && s[i] !== '\n') i++; continue }
154
- if (c === '/' && c2 === '*') { i += 2; while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++; i += 2; continue }
155
- if (c === '/' && regexAllowed()) {
156
- i++
157
- let inClass = false
158
- while (i < n) {
159
- if (s[i] === '\\') { i += 2; continue }
160
- if (s[i] === '[') inClass = true
161
- else if (s[i] === ']') inClass = false
162
- else if (s[i] === '/' && !inClass) { i++; break }
163
- else if (s[i] === '\n') break // a regex literal cannot span lines
164
- i++
165
- }
166
- while (i < n && /[a-z]/i.test(s[i])) i++ // flags
167
- out += "''" // a value placeholder, like strings: keeps `X: <value>` shapes but no phantom calls
168
- prev = ')'
169
- continue
170
- }
171
- if (c === "'" || c === '"' || c === '`') {
172
- const q = c
173
- i++
174
- while (i < n) {
175
- if (s[i] === '\\') { i += 2; continue }
176
- if (s[i] === q) { i++; break }
177
- if (q !== '`' && s[i] === '\n') break
178
- i++
179
- }
180
- out += q + q // keep a placeholder so `X: ''` shape survives
181
- prev = ')'
182
- continue
183
- }
184
- out += c
185
- if (/[A-Za-z0-9_$]/.test(c)) word += c
186
- else { if (word) { prev = word; word = '' } if (!/\s/.test(c)) prev = c }
187
- i++
188
- }
189
- return out
190
- }
191
- const src = stripNoise(raw)
192
- const findings = []
193
- const notes = []
194
- // How many philosophy gates the run checked (module scope so `--json` can report it).
195
- let gateCount = 0
196
-
197
- const lineOf = (idx) => src.slice(0, idx).split('\n').length
198
-
199
- // ---- 1. called-but-undefined -------------------------------------------
200
- const defined = new Set()
201
- for (const m of src.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) defined.add(m[1])
202
- for (const m of src.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) defined.add(m[1])
203
- for (const m of src.matchAll(/(?:const|let|var)\s+\{([^}]+)\}\s*=/g)) {
204
- for (const part of m[1].split(',')) { const n = part.split(':').pop().trim(); if (n) defined.add(n) }
205
- }
206
- for (const m of src.matchAll(/catch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) defined.add(m[1])
207
- // ESM imports are DEFINITIONS too: the preset imports the shared `math_computation` module
208
- // (INTERFACE-FREEZE §5.1), so calling an imported function is not a ReferenceError. Without
209
- // this every imported symbol was reported as "called but never defined"; a call to a name that
210
- // is neither declared nor imported is still flagged.
211
- for (const m of src.matchAll(/import\s*(?:\*\s*as\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\}\s*)?from\s*['"][^'"]*['"]/g)) {
212
- if (m[1]) defined.add(m[1])
213
- if (m[2]) defined.add(m[2])
214
- if (m[3]) {
215
- for (const part of m[3].split(',')) {
216
- const name = part.split(/\s+as\s+/).pop().trim()
217
- if (name) defined.add(name)
218
- }
219
- }
220
- }
221
- // Function/method PARAMETERS are locally bound identifiers too, not free calls.
222
- for (const m of src.matchAll(/function\s*[A-Za-z_$]*\s*\(([^)]*)\)/g)) {
223
- for (const raw of m[1].split(',')) {
224
- const name = raw.trim().replace(/[={].*$/s, '').trim()
225
- if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
226
- }
227
- }
228
- for (const m of src.matchAll(/\(([^()]*)\)\s*=>/g)) {
229
- for (const raw of m[1].split(',')) {
230
- const name = raw.trim().replace(/[={].*$/s, '').trim()
231
- if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
232
- }
233
- }
234
- for (const m of src.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) defined.add(m[1])
235
- // JS/DSH globals that are legitimately free
236
- const GLOBALS = new Set([
237
- 'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'function', 'new', 'await', 'delete', 'void', 'do', 'else', 'try',
238
- 'Array', 'Object', 'JSON', 'Number', 'String', 'Boolean', 'Math', 'Date', 'Promise', 'Set', 'Map', 'WeakRef', 'WeakMap', 'Symbol', 'Error',
239
- 'isFinite', 'isNaN', 'parseInt', 'parseFloat', 'encodeURIComponent', 'decodeURIComponent', 'structuredClone',
240
- 'AbortSignal', 'console', 'require', 'import', 'super', 'this', 'of', 'in', 'instanceof',
241
- 'RegExp', 'Proxy', 'Reflect', 'AggregateError', 'TextEncoder', 'TextDecoder', 'URL', 'BigInt', 'Intl', 'Buffer', 'process',
242
- ])
243
- const LOCAL_METHODS = new Set()
244
- for (const m of src.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*[:(]/g)) LOCAL_METHODS.add(m[1])
245
- for (const m of src.matchAll(/\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) LOCAL_METHODS.add(m[1])
246
-
247
- const called = new Map()
248
- for (const m of src.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
249
- const name = m[1]
250
- if (GLOBALS.has(name) || LOCAL_METHODS.has(name)) continue
251
- if (!called.has(name)) called.set(name, { n: 0, line: lineOf(m.index) })
252
- called.get(name).n++
253
- }
254
- for (const [name, info] of called) {
255
- if (!defined.has(name)) findings.push('line ' + info.line + ': called but never defined: ' + name + '() (' + info.n + ' call site(s))')
256
- }
257
-
258
- // ---- 2. params keys ----------------------------------------------------
259
- const dpBlock = /const DEFAULT_PARAMS = \{([\s\S]*?)\n \}/.exec(src)
260
- if (!dpBlock) findings.push('could not locate DEFAULT_PARAMS')
261
- else {
262
- const declared = new Set()
263
- for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared.add(m[1])
264
- const used = new Map()
265
- for (const m of src.matchAll(/\bparams\.([A-Za-z_$][\w$]*)/g)) used.set(m[1], (used.get(m[1]) || 0) + 1)
266
- for (const [k, n] of used) {
267
- if (!declared.has(k)) findings.push('params.' + k + ' read but not declared in DEFAULT_PARAMS (' + n + ' use(s))')
268
- }
269
- notes.push('DEFAULT_PARAMS keys: ' + declared.size + '; params.* reads: ' + used.size)
270
- }
271
-
272
- // ---- 3. session-API surface used by tool handlers -----------------------
273
- // `s` is not always the session API object. The toolFilter sanitiser added for the v2/v3/v4
274
- // parity guard contains `.map(function(s){ return s.trim() })` — there `s` is a LOCAL string
275
- // parameter, and a flat `s.NAME(` scan reported the phantom finding "calls s.trim() but the
276
- // session API does not export it". A false FINDING is as corrosive as a blind spot (it trains
277
- // the reader to ignore the audit), so the scan now respects the ONE shadowing form the plugin
278
- // actually uses: a plain `function (...)` whose parameter list binds `s`.
279
- //
280
- // This is scope analysis, not an allowlist: any `s.X()` inside such a body CANNOT be a call on
281
- // the enclosing session object — the parameter shadows it. The tool handlers themselves are
282
- // ARROW functions (`(s, a, x) => s.…)`), so their `s` IS the session API and stays scanned; the
283
- // self-check at the bottom of this file proves the skip does not blind those.
284
- function localSParamBodySpans(text) {
285
- const spans = []
286
- const re = /function\s*(?:[A-Za-z_$][\w$]*)?\s*\(([^)]*)\)\s*\{/g
287
- let m
288
- while ((m = re.exec(text)) !== null) {
289
- const params = m[1].split(',').map((p) => p.trim().replace(/[={].*$/s, '').trim())
290
- if (params.indexOf('s') === -1) continue
291
- let i = m.index + m[0].length - 1 // sitting on the opening '{'
292
- let depth = 0
293
- for (; i < text.length; i++) {
294
- if (text[i] === '{') depth++
295
- else if (text[i] === '}') { depth--; if (depth === 0) break }
296
- }
297
- spans.push([m.index, i])
298
- }
299
- return spans
300
- }
301
- // The returned API object is the last `return { ... }` inside makeSession.
302
- const apiStart = src.lastIndexOf(' return {\n sessionId,')
303
- if (apiStart === -1) findings.push('could not locate the session API return object')
304
- else {
305
- const apiEnd = src.indexOf('\n }\n }', apiStart)
306
- const apiText = src.slice(apiStart, apiEnd === -1 ? apiStart + 4000 : apiEnd)
307
- const apiKeys = new Set()
308
- for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*:/g)) apiKeys.add(m[1])
309
- for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*,/g)) apiKeys.add(m[1])
310
- const localScopes = localSParamBodySpans(src)
311
- const inLocalScope = (idx) => localScopes.some(([a, b]) => idx >= a && idx <= b)
312
- const usedOnS = new Map()
313
- let shadowed = 0
314
- for (const m of src.matchAll(/(?<![\w$.])s\.([A-Za-z_$][\w$]*)\s*\(/g)) {
315
- if (inLocalScope(m.index)) { shadowed++; continue }
316
- if (!usedOnS.has(m[1])) usedOnS.set(m[1], lineOf(m.index))
317
- }
318
- for (const [k, line] of usedOnS) {
319
- if (!apiKeys.has(k)) findings.push('line ' + line + ': tool handler calls s.' + k + '() but the session API does not export it')
320
- }
321
- notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size +
322
- (shadowed ? '; skipped ' + shadowed + ' inside a local `function(s)` scope (a shadowing parameter, not the session API)' : ''))
323
- }
324
-
325
- // ---- 4. error codes ----------------------------------------------------
326
- // Scan the RAW source: these patterns live inside string literals, which `src` strips.
327
- const raised = new Set()
328
- for (const m of raw.matchAll(/v5err\(\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
329
- for (const m of raw.matchAll(/code:\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
330
- const docs = readRaw('vibe-math-v5/实现方案.md')
331
- const documented = new Set()
332
- for (const m of docs.matchAll(/`(V5_[A-Z_]+)`/g)) documented.add(m[1])
333
- const onlyDocs = new Set()
334
- for (const m of docs.matchAll(/(V5_[A-Z_]+)/g)) onlyDocs.add(m[1])
335
- const raisedNotDocumented = [...raised].filter(c => !onlyDocs.has(c))
336
- const documentedNotRaised = [...documented].filter(c => !raised.has(c))
337
- if (raisedNotDocumented.length) findings.push('error codes raised but absent from 实现方案.md: ' + raisedNotDocumented.join(', '))
338
- if (documentedNotRaised.length) notes.push('documented but not raised in code (ok if advisory): ' + documentedNotRaised.join(', '))
339
- notes.push('error codes raised: ' + raised.size)
340
-
341
- // ---- 5. leftover scaffolding -------------------------------------------
342
- for (const bad of ['@@V5_SECTION@@', 'TODO', 'FIXME', 'XXX', 'PLACEHOLDER']) {
343
- if (src.includes(bad)) findings.push('leftover development marker: ' + bad)
344
- }
345
- // every event type declared in EV must be appended somewhere
346
- const evBlock = /const EV = \{([\s\S]*?)\n\}/.exec(raw)
347
- if (evBlock) {
348
- const keys = [...evBlock[1].matchAll(/([A-Za-z]+):\s*'(vibe5\/[a-z]+)'/g)]
349
- for (const [, prop, literal] of keys) {
350
- const uses = (raw.match(new RegExp("EV\\." + prop + "\\b", 'g')) || []).length
351
- if (uses <= 1) findings.push('event type EV.' + prop + ' (' + literal + ') is declared but never committed')
352
- }
353
- notes.push('event types declared: ' + keys.length)
354
- }
355
-
356
- // ---- 6. composition sanity --------------------------------------------
357
- // The v5 preset is a composition, and a typo in a row `name` is a mounting failure that
358
- // no unit test of the plugin can catch (the mock calls apply() directly and never goes
359
- // through the loader). v4 is a proven-good composition mounted on the same hosts, so any
360
- // package row v5 names that v4 does not is either a genuine new dependency or a typo —
361
- // and a new dependency must be justified, so surface it for review.
362
- function packageRows(yaml) {
363
- const rows = []
364
- const lines = yaml.split('\n')
365
- for (let i = 0; i < lines.length; i++) {
366
- const idm = /^\s*-\s*id:\s*(\S+)\s*$/.exec(lines[i])
367
- if (!idm) continue
368
- const row = { id: idm[1], name: '', disabled: false, line: i + 1 }
369
- for (let j = i + 1; j < lines.length && j < i + 12; j++) {
370
- if (/^\s*-\s*id:\s*\S+\s*$/.test(lines[j])) break
371
- const nm = /^\s*name:\s*'?([^'\n]+?)'?\s*$/.exec(lines[j])
372
- if (nm && !row.name) row.name = nm[1].trim()
373
- if (/^\s*disabled:\s*true/.test(lines[j])) row.disabled = true
374
- }
375
- if (row.name) rows.push(row)
376
- }
377
- return rows
378
- }
379
- const v5yaml = readRaw('vibe-math-v5/agent.cordis.yml')
380
- const v4yaml = readRaw('vibe-math-v4/agent.cordis.yml')
381
- const v5rows = packageRows(v5yaml)
382
- const v4names = new Set(packageRows(v4yaml).map(r => r.name))
383
- const RELATIVE_OK = new Set(['./vibe-math-v5.js'])
384
- if (!v5rows.length) findings.push('composition: no rows parsed out of agent.cordis.yml (is the file still YAML?)')
385
- for (const r of v5rows) {
386
- if (r.name === 'cordis:group') continue
387
- if (r.name.startsWith('./')) {
388
- if (!RELATIVE_OK.has(r.name)) findings.push('composition line ' + r.line + ': relative row name "' + r.name + '" has no matching file in the preset directory')
389
- continue
390
- }
391
- if (!v4names.has(r.name)) findings.push('composition line ' + r.line + ': row "' + r.id + '" names "' + r.name + '", which v4 does not — verify the package/name is correct')
392
- }
393
- // the preset must reference its own plugin row, and that file must exist
394
- if (!v5rows.some(r => r.name === './vibe-math-v5.js')) findings.push('composition: the preset never mounts ./vibe-math-v5.js')
395
- // prefix AND text are required on the persona row for the DSH schema and for back-compat
396
- const personaBlock = /- id:\s*persona[\s\S]*?(?=\n-\s*id:)/.exec(v5yaml)
397
- if (!personaBlock) findings.push('composition: no persona row found')
398
- else {
399
- if (!/^\s*prefix:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the required `prefix` key')
400
- if (!/^\s*text:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the legacy `text` key')
401
- }
402
- notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5rows.filter(r => r.name !== 'cordis:group' && !r.name.startsWith('./') && !v4names.has(r.name)).length)
403
-
404
- // ---- 7. requirements traceability against the plan ---------------------
405
- // "No missing logic" is only checkable mechanically if the SPEC is machine-readable.
406
- // The plan's §15 names every tool and §16 every parameter, so compare those sets
407
- // against the implementation: a name in the plan but not the code is an unimplemented
408
- // requirement, and a name in the code but not the plan is undocumented surface.
409
- {
410
- const plan = readRaw('vibe-math-v5/实现方案.md')
411
- const planTools = new Set()
412
- for (const m of plan.matchAll(/\bvibe_v5_[a-z_]+/g)) planTools.add(m[0])
413
- const codeTools = new Set()
414
- for (const m of raw.matchAll(/registerTool\(\s*'(vibe_v5_[a-z_]+)'/g)) codeTools.add(m[1])
415
- // documented-but-wildcarded placeholders are not real tools
416
- const IGNORE = new Set(['vibe_v5_', 'vibe_v5_record_', 'vibe_v5_lean_', 'vibe_v5_task_'])
417
- for (const t of planTools) {
418
- if (IGNORE.has(t)) continue
419
- if (!codeTools.has(t)) findings.push('plan names tool ' + t + ' but the plugin never registers it')
420
- }
421
- const undocumented = [...codeTools].filter(t => !planTools.has(t))
422
- if (undocumented.length) notes.push('tools registered but not named in the plan: ' + undocumented.join(', '))
423
- notes.push('plan tools: ' + planTools.size + '; registered tools: ' + codeTools.size)
424
-
425
- // Parameters: only the §16 default table, NOT the §15 tool tables (whose rows also
426
- // begin with a backticked name).
427
- const declared2 = new Set()
428
- if (dpBlock) for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared2.add(m[1])
429
- const sec16 = /##\s*16\.[\s\S]*?(?=\n##\s*17\.)/.exec(plan)
430
- const paramRows = new Set()
431
- if (sec16) {
432
- for (const m of sec16[0].matchAll(/^\|\s*`([A-Za-z][\w]*)`(?:\s*\/\s*`([A-Za-z][\w]*)`)?\s*\|/gm)) {
433
- paramRows.add(m[1])
434
- if (m[2]) paramRows.add(m[2])
435
- }
436
- } else findings.push('could not locate the plan\'s §16 parameter table')
437
- for (const p of paramRows) {
438
- if (!declared2.has(p)) findings.push('plan documents parameter `' + p + '` but DEFAULT_PARAMS does not declare it')
439
- }
440
- notes.push('plan §16 parameter rows: ' + paramRows.size)
441
-
442
- // F3 (docs-vs-behaviour sweep): `status()`'s machine surface. OWNERSHIP (agreed division, so the two
443
- // guards never both claim the same job):
444
- // • FIELD-LEVEL reconciliation (field ↔ doc row, BOTH directions) is owned by
445
- // `tests/audit-status-report-fields.mjs`, which carries the table and prints the
446
- // `frozen / doc-rows / exceptions` counts;
447
- // • THIS audit keeps ONLY this frozen-key drift check: the extracted 43 keys below, the printed
448
- // `captured: N`, and the `<30` anti-vacuity floor. Any addition/removal of a top-level key must be
449
- // an explicit decision here (and in the owning guard's reconciliation).
450
- // No "doc half" is promised or planned here — the note printed at the end points at the owner instead.
451
- const stIdx = raw.indexOf('function status() {')
452
- const statusKeys = []
453
- if (stIdx === -1) findings.push('could not locate status() — the machine-surface extraction has no anchor')
454
- else {
455
- const open = raw.indexOf('return {', stIdx)
456
- const close = raw.indexOf('\n }', open)
457
- // Tokenise the returned literal: every key directly inside it — including SHORTHAND keys that share a
458
- // line (`institute: instituteName, project, key, phase,`) — while SKIPPING each value (so an
459
- // identifier used as a value can never be mistaken for a key).
460
- const body = open === -1 ? '' : raw.slice(open + 'return {'.length, close === -1 ? open + 9000 : close)
461
- // Comments must be skipped IN PLACE: the payload's comments contain prose with apostrophes, parens and
462
- // commas (`…delivered ONCE…`, `phase:'failed'`, `(installer review)`), which otherwise corrupt both the
463
- // depth tracking and the value skipping.
464
- const skipComment = (s, at) => {
465
- if (s[at + 1] === '/') { let j = at + 2; while (j < s.length && s[j] !== '\n') j += 1; return j }
466
- if (s[at + 1] === '*') { const j = s.indexOf('*/', at + 2); return j === -1 ? s.length : j + 2 }
467
- return -1
468
- }
469
- let i = 0, depth = 0
470
- while (i < body.length) {
471
- const ch = body[i]
472
- if (ch === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
473
- if (ch === '"' || ch === "'") { const q = ch; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
474
- if (ch === '{' || ch === '[' || ch === '(') { depth += 1; i += 1; continue }
475
- if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) break; depth -= 1; i += 1; continue }
476
- if (depth === 0 && /[A-Za-z_$]/.test(ch)) {
477
- const m = /^[A-Za-z_$][\w$]*/.exec(body.slice(i))
478
- const after = body.slice(i + m[0].length).replace(/^[\s]+/, '')
479
- if (after[0] === ':') {
480
- statusKeys.push(m[0])
481
- i += m[0].length
482
- let d = 0
483
- while (i < body.length) { // skip the VALUE up to the next top-level comma
484
- const c = body[i]
485
- if (c === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
486
- if (c === '"' || c === "'") { const q = c; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
487
- if (c === '{' || c === '[' || c === '(') { d += 1; i += 1; continue }
488
- if (c === '}' || c === ']' || c === ')') { if (d === 0) break; d -= 1; i += 1; continue }
489
- if (c === ',' && d === 0) { i += 1; break }
490
- i += 1
491
- }
492
- continue
493
- }
494
- if (after[0] === ',') { statusKeys.push(m[0]); i += m[0].length; continue }
495
- i += m[0].length; continue
496
- }
497
- i += 1
498
- }
499
- }
500
- notes.push('status() top-level keys captured: ' + statusKeys.length)
501
- if (statusKeys.length < 30) findings.push('status() key extraction captured only ' + statusKeys.length + ' keys (anchor or payload broke — this check must never pass vacuously)')
502
- for (const must of ['phase', 'members', 'quorum', 'chat', 'persistence']) {
503
- if (statusKeys.indexOf(must) === -1) findings.push('status() no longer exposes `' + must + '` (either the extraction anchor or the payload broke)')
504
- }
505
- const EXPECTED_STATUS_KEYS = ['ok', 'institute', 'project', 'key', 'phase', 'running', 'autoDone', 'runId', 'leanNotices', 'leanNoticesScope', 'fieldScopes', 'backend', 'diagnostics', 'debug', 'quorum', 'members', 'tasks', 'failedMembers', 'failedMembersNote', 'pendingSpawns', 'pendingSpawnsNote', 'chat', 'chatScope', 'officeRequests', 'officeRequestsShown', 'officeRequestsCap', 'officeRequestsDropped', 'officeRequestsTruncated', 'persistence', 'meeting', 'parkedMeeting', 'verify', 'verifyQueue', 'verified', 'verifiedTrue', 'concludedFalse', 'verifiedNote', 'undecided', 'solveVotes', 'formal', 'paper', 'lastProgressAt', 'params']
506
- const statusMissing = EXPECTED_STATUS_KEYS.filter((k) => statusKeys.indexOf(k) === -1)
507
- const statusAdded = statusKeys.filter((k) => EXPECTED_STATUS_KEYS.indexOf(k) === -1)
508
- if (statusMissing.length || statusAdded.length) {
509
- findings.push('status() field surface changed — missing=' + JSON.stringify(statusMissing) + ' added=' + JSON.stringify(statusAdded) +
510
- ': record the decision in this freeze AND let the field-level owner reconcile the documents (tests/audit-status-report-fields.mjs)')
511
- }
512
- notes.push('status() field-surface drift: frozen=' + statusKeys.length + ' keys (this audit owns ONLY this freeze + the <30 anti-vacuity floor); ' +
513
- 'field-level reconciliation (field ↔ doc row, both directions) is OWNED BY tests/audit-status-report-fields.mjs, which prints frozen / doc-rows / exceptions')
514
-
515
- // F1 (docs-vs-behaviour): the README's TWO-CASE `resume` claim must stay paired with the behavioural
516
- // anchor that pins the in-instance case (the two CODE anchors are gates in the GATES table below). Only
517
- // the prose side is checked here, and every side reports how much it matched, so this cannot pass vacuously.
518
- {
519
- const readme = readRaw('README.md')
520
- const suite = readRaw('tests/e2e-v5-round2.test.mjs')
521
- const readmeClaims = [
522
- ['the two-case framing', /`resume`\s*后轮次计数的两种情形/],
523
- ['case 1 = a cross-process reload counts from 1', /跨进程重载[^\n]*从\s*\*\*1\*\*\s*重新计/],
524
- ['case 2 = an in-instance rebuild CONTINUES the numbering (轮次 2)', /同实例重建[^\n]*继续[^\n]*轮次 2/],
525
- ['a failed/invalid start no longer erases the existing count', /失败\/无效的启动\*\*不再抹掉\*\*已有计数/],
526
- ['the claim is pinned to a revision', /口径复核于\s*`?[0-9a-f]{7,40}`?/],
527
- ]
528
- let readmeHits = 0
529
- for (const [label, re] of readmeClaims) {
530
- if (re.test(readme)) readmeHits += 1
531
- else findings.push('README resume-claim side missing: ' + label)
532
- }
533
- const suiteClaims = [
534
- ['the in-instance rebuild is asserted as 轮次 2', /a FAILED resume must not move the round number backwards/],
535
- ['the founding round is asserted as 轮次 1', /the founding prompt starts the member at 轮次 1/],
536
- ]
537
- let suiteHits = 0
538
- for (const [label, re] of suiteClaims) {
539
- if (re.test(suite)) suiteHits += 1
540
- else findings.push('README resume-claim has no behavioural anchor: ' + label)
541
- }
542
- // The README cites exact code/test lines PINNED to a revision ("口径复核于 <sha>"). Those drift by
543
- // design, so the drift is a measured NOTE carrying the current lines — never a failure.
544
- const lineOf = (needle) => { const at = raw.indexOf(needle); return at === -1 ? -1 : raw.slice(0, at).split('\n').length }
545
- const startLine = lineOf('const startRound = (rounds.get(member.id) || 0) + 1')
546
- const applyLine = lineOf('rounds.set(member.id, startRound)')
547
- if (startLine === -1 || applyLine === -1) findings.push('the spawnMember round-count anchors are gone (startRound=' + startLine + ', applied=' + applyLine + ')')
548
- notes.push('F1 resume-claim pairing: README ' + readmeHits + '/' + readmeClaims.length + ' claims, behavioural anchors ' + suiteHits + '/' + suiteClaims.length + '; cited-line drift (README pins a revision): startRound now :' + startLine + ', applied now :' + applyLine)
549
- }
550
-
551
- // F2/G1 pairing (prose ↔ code): the v5 diagram must not state the PRE-G1 order, and must describe the
552
- // implemented one. Going through `readRaw` gives this block the same `V5_INTEGRITY_MUTATE` seam as every
553
- // other file, which is what the `--self-probe` case below relies on.
554
- {
555
- const diagram = readRaw('vibe-math-v5/架构图.md')
556
- const FORBID = /先\s*ack[^\n]{0,8}再构造/
557
- const CLAUSES = /构造[^\n]{0,12}(后|再)[^\n]{0,12}ack|ack[^\n]{0,12}(仅|只)[^\n]{0,12}(成功|送达)/
558
- if (FORBID.test(diagram)) findings.push('架构图 still states the pre-G1 order: ack before the prompt is built (F2/G1)')
559
- if (!CLAUSES.test(diagram)) findings.push('架构图 does not describe the implemented inbox order (construct first / ack only on success)')
560
- if (!raw.includes('if (ok && prompt.pending.length) await ackPending(')) findings.push('the ack is no longer gated by a successful wake (F2/G1 anchor missing)')
561
- notes.push('架构图 inbox pairing: forbid-hits=' + (FORBID.test(diagram) ? 1 : 0) + ', clauses=' + (CLAUSES.test(diagram) ? 1 : 0))
562
- // The PLAN uses the explicit pre-G1 phrases only: a sentence that legitimately QUOTES or FORBIDS the old
563
- // order must not trip this, so the pattern names the phrases instead of the bare `先 ack`.
564
- const planDoc = readRaw('vibe-math-v5/实现方案.md')
565
- const PLAN_FORBID = /先\s*ack\s*邮件[、,,]?\s*再构造|先\s*ack[、,,]?\s*后构造/
566
- if (PLAN_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 order: ack the mail before building the prompt (F2/G1)')
567
- notes.push('实现方案.md inbox pairing: forbid-hits=' + (PLAN_FORBID.test(planDoc) ? 1 : 0))
568
- // The SAME class produced five instances, the last one inside the guard checklist itself, so the scan
569
- // covers every top-level docs/*.md. The phrase is NAMED (never the bare `先 ack`) so a sentence that
570
- // legitimately QUOTES or FORBIDS the old order cannot trip it. NOTE (applier): `HERE` is the REPO ROOT
571
- // (see `new URL(rel, HERE)` above), so the docs directory is `new URL('docs/', HERE)` — NOT `../docs/`.
572
- const DOC_FORBID = /先\s*ack[^\n]{0,8}再构造/
573
- let docHits = 0, docFiles = 0
574
- for (const f of readdirSync(new URL('docs/', HERE)).filter((n) => n.endsWith('.md'))) {
575
- docFiles += 1
576
- if (DOC_FORBID.test(readRaw('docs/' + f))) { docHits += 1; findings.push('docs/' + f + ' still states the pre-G1 order: ack the mail before building the prompt (F2/G1)') }
577
- }
578
- // The scan covers LIVE docs only. `docs/release-notes/**` is a FROZEN published record: the pre-G1
579
- // wording there is history, not a stale claim, so it must stay OUT of the scan — and the exemption is
580
- // ASSERTED (a missing/empty directory fails) so a future change to recursive scanning cannot silently
581
- // start scanning frozen history. Same convention as audit-readme-counts.mjs's frozen TOTAL-65 notes.
582
- let frozenNotes = []
583
- try { frozenNotes = readdirSync(new URL('docs/release-notes/', HERE)).filter((n) => n.endsWith('.md')) } catch (e) { frozenNotes = [] }
584
- if (!frozenNotes.length) findings.push('the frozen release-notes exemption is EMPTY or missing (docs/release-notes/*.md) — re-decide the scan scope explicitly, never widen it silently')
585
- notes.push('docs/** inbox pairing: files=' + docFiles + ', forbid-hits=' + docHits + '; frozen docs/release-notes/*.md exempted=' + frozenNotes.length)
586
- // The plan ALSO carried the pre-G1 re-send contract ("queued 不重发/永不重发") in two sketch lines while
587
- // its own M2 row already said "未 ack ⇒ 仍可重投". NAMED phrase only, scoped to the plan file, so a
588
- // sentence that legitimately quotes or forbids the old wording cannot trip it.
589
- const QUEUED_FORBID = /queued\s*(永不|不)重发/
590
- if (QUEUED_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 re-send contract (queued 不重发/永不重发): unacked mail is re-deliverable (F2/G1)')
591
- notes.push('实现方案.md queued-no-resend phrasing: hits=' + (QUEUED_FORBID.test(planDoc) ? 1 : 0))
592
- }
593
-
594
- // The plan's philosophy is enforced by concrete gates; assert the load-bearing ones
595
- // still exist so a future edit cannot quietly drop a guard.
596
- const GATES = [
597
- ['temp workers cannot vote', "if (m.kind === 'temp') return // no vote"],
598
- ['temp workers cannot hire', "if (caller.kind === 'temp') return { ok: false, code: 'V5_NOT_VOTER'"],
599
- ['only the academician assigns', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks'"],
600
- ['only the academician sets priorities', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities'"],
601
- ['permanent-staff changes need the office', "code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)'"],
602
- ['assignment objections are broadcast', 'if (p.reject_assign && typeof p.reject_assign === \'object\' && params.memberMayRejectAssign)'],
603
- ['the academician has no extra vote weight', 'const E = voters().map((m) => m.id)'],
604
- ['members may reject an assignment', "memberMayRejectAssign: true,"],
605
- ['the charter states the progress definition', "' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。'"],
606
- ['the charter describes the academician as organizer', "' 【四、你的组织职责与边界(院士)】'"],
607
- ['the framework never assigns on its own', "agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?'"],
608
-
609
- // ── PROMPT / INTERACTION CORRECTNESS GATES ─────────────────────────────
610
- // F1 (README two-case resume claim): the two code anchors the prose cites.
611
- ['the founding round is computed from the stored count (never reset)', 'const startRound = (rounds.get(member.id) || 0) + 1'],
612
- ['the founding round is applied only AFTER a successful start', 'rounds.set(member.id, startRound)'],
613
- // The 2026-09 field test shipped a framework whose every member brief named the WRONG
614
- // member. These gates keep the structural fixes in place, and the companion
615
- // prompt-v5-integrity.test.mjs asserts the TEXT those fixes produce.
616
- ['the status block takes the member it describes', 'function briefBlock(member, roundNo) {'],
617
- ['an unknown identity fails loudly instead of being guessed', "throw v5err('V5_INTERNAL', 'briefBlock: a member is required"],
618
- ['the status block is built from that member, never a global', 'function stateBlock(member, roundNo) {\n return briefBlock(member, roundNo)\n }'],
619
- ['the founding prompt is told the round it is starting (never a stored 0)', 'const prompt = initialPrompt(member, initialTask, mode, startRound)'],
620
- ['the joiner is committed to the roster BEFORE its brief is built', "member.phase = 'active'\n member.childId = ''\n await putMember(member)"],
621
- ['the charter is frozen at hire and reused on resume', 'const persona = member.persona || memberPersona(member)'],
622
- ['a rebuilt session is framed as a rebuild', "const resume = mode === 'resume'"],
623
- ['leadership text follows the live roster', 'function academicianId() {'],
624
- ['no charter invents a leader when there is none', "本所当前**没有在册院士**"],
625
- ['the office resolves as the office, never as a guessed member', "try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }"],
626
- ['the mailbox is acked only AFTER the wake actually succeeded', "if (ok && prompt.pending.length) await ackPending(prompt.pending)"],
627
- // F2: the three REAL mailbox invariants, each at its own source anchor. `inFlightMessages` is a DSH
628
- // mailbox internal that v5 does NOT have (dedupe is `delivered` + the per-round injection marks), so
629
- // no gate may reference it.
630
- ['the acknowledgement is gated by the wake result AND a non-empty pending list', 'const ok = await wakeMember(member, prompt.text, kind)'],
631
- ['the delivered ledger is CAPPED (oldest evicted, never unbounded)', 'const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered'],
632
- ['a prompt prepends only messages not already prepended in this round', 'const fresh = pending.filter((p) => !seen.has(p.id))'],
633
- ['the per-round injection mark is cleared with every concluded attempt', 'inboxInjected.delete(member.id)'],
634
- ['a prepended inbox suppresses the base block (exactly one inbox section per prompt)', "const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)"],
635
- ['framework feedback has its own sender (never a self-message)', "return await say('framework', { to: memberId, kind: 'notice', text: String(text) })"],
636
- ['an assignment is framed by its true origin', "if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text"],
637
- ['a nudge is framed as supervision, not as an assignment', "to, kind: 'nudge',"],
638
- ['a voters-only broadcast is framed as such', "if (m.kind === 'voters') return '【研究所·致全体表决者 from ' + m.from + '】' + m.text"],
639
- ['relayed messages carry their true sender', "await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })"],
640
- ['a member that failed to provision stays visible', "b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))"],
641
- ['the objection channel is documented in the reply spec', '"reject_assign": {"task_id":"t-3"'],
642
- ['the task-done channel is documented in the reply spec', '"task_done": "t-3",'],
643
- ['the meeting input channel is documented in the reply spec', '"input": "本轮会议/辩论的发言正文'],
644
- ['the work push is paced, not an unbounded loop', 'if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue'],
645
-
646
- // ── LEAN FORMAL VERIFICATION GATES (docs/formal-verification.md) ─────────
647
- ['the formal-verification mode defaults to off', "formalVerify: 'off',"],
648
- ['an unknown mode degrades to off, never to a stronger mode', "indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'"],
649
- ['the mode is read at prompt-build time (never frozen into the charter)', 'const formalOn = () => formalMode()'],
650
- ['a passing run — not merely an archived file — is what makes an object passed', 'const formalGateOk = (rec) => !!rec && (rec.status === \'passed\' || rec.status === \'blocked\')'],
651
- ['a passing Lean run switches the review subject to fidelity', '你的任务是**忠实性审查**'],
652
- ['the voting prompt tells voters not to re-derive once a proof exists', '**你不需要重新检查推导**'],
653
- ['require mode withholds a verdict without a formal record', 'if (formalMode() === \'require\' && !formalGateOk(rec)) {'],
654
- ['a withheld verdict is recorded as undecided with a machine-readable reason', 'formal-required:尚未取得 Lean 形式化通过'],
655
- ['the withheld object goes on a formalization TODO', "'Formal/TODO.md'"],
656
- ['a blocker record must carry a reason', "if (!note) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '阻塞记录必须写明原因(note)"],
657
- ['the proof of an object is archived under Verified/Lean/', "await writeTextRel('Verified/Lean/' + target + '.lean', body)"],
658
- ['reusable definitions live in the GLOBAL cross-project library', 'const okWrite = await writeTextAbs(instRootless(rel), body)'],
659
- ['the Lean path guard normalises .. (no string-prefix traversal hole)', 'const norm = normalizeAbsPath(abs)'],
660
- ['a missing toolchain degrades to a readable result, not a crash', "code: 'LEAN_NOT_FOUND'"],
661
- ['the formal record is durable state (folded into State/<institute>.v5state.json)', "formal: 'vibe5/formal',"],
662
- ['the state block advertises the formalization counts', "b.push('[形式化] ' + (formalMode()"],
663
- ['the reply contract carries the difficulty judgement', "L.push(' \"formal\": {"],
664
- ]
665
- for (const [label, needle] of GATES) {
666
- if (!raw.includes(needle)) findings.push('philosophy gate missing from the implementation: ' + label)
667
- }
668
- notes.push('philosophy gates checked: ' + GATES.length)
669
- gateCount = GATES.length
670
-
671
- // The prompt corpus is a SHIPPED deliverable, not a build artifact: a human must be able
672
- // to read the exact text every member receives without decoding session logs.
673
- const REPO_ROOT = new URL('../', import.meta.url)
674
- const needFiles = [
675
- ['the prompt-integrity suite is shipped', 'tests/prompt-v5-integrity.test.mjs'],
676
- ['the machine-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.json'],
677
- ['the human-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.md'],
678
- ['the persona-surface suite is shipped', 'tests/audit-persona-surface.test.mjs'],
679
- ['the four-preset persona corpus is shipped (machine-readable)', 'prompt-corpus-persona/persona-corpus.json'],
680
- ['the four-preset persona corpus is shipped (human-readable)', 'prompt-corpus-persona/persona-corpus.md'],
681
- ]
682
- for (const [label, rel] of needFiles) {
683
- let ok = false
684
- try { ok = existsSync(new URL(rel, REPO_ROOT)) } catch (e) { ok = false }
685
- if (!ok) findings.push('missing shipped prompt-correctness artifact: ' + label + ' (' + rel + ')')
686
- }
687
- notes.push('prompt-correctness artifacts checked: ' + needFiles.length)
688
-
689
- // The corpus must actually contain the interactions a reviewer needs to see, and must
690
- // never contain a wrong-identity brief.
691
- try {
692
- const corpusPath = new URL('prompt-corpus-v5/prompt-corpus-v5.json', REPO_ROOT)
693
- const c = JSON.parse(readRaw('prompt-corpus-v5/prompt-corpus-v5.json'))
694
- const kinds = new Set((c.prompts || []).map(p => p.kind))
695
- const need = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
696
- 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
697
- 'inbox-office', 'inbox-assign', 'inbox-nudge', 'inbox-office-assign', 'inbox-office-nudge',
698
- 'notice', 'notice-claim', 'after-failure',
699
- // the Lean formal-verification interaction must be reviewable by a human too — including
700
- // the `require` gate wording and the state a member sees AFTER a fidelity defect withdrew
701
- // a proof (both were missing from the first corpus, so nobody could read them).
702
- 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect']
703
- for (const k of need) if (!kinds.has(k)) findings.push('the prompt corpus is missing a ' + k + ' prompt')
704
- const all = (c.prompts || []).map(p => p.prompt + '\n' + (p.charter || '')).join('\n')
705
- if (/你是 \?/.test(all)) findings.push('the prompt corpus contains a wrong-identity "你是 ?" brief')
706
- if (/\[状态\][^\n]*你是\s+(\S+?)[^\n]*\n/.test(all)) {
707
- // every [状态] line must name a real member id, never a placeholder
708
- for (const m of all.match(/\[状态\][^\n]*/g) || []) {
709
- const id = (/\[状态\]\s*你是\s+(\S+?)(/.exec(m) || [])[1]
710
- if (!id || id === '?' || id === 'undefined') findings.push('the prompt corpus has a bad identity line: ' + m.slice(0, 60))
711
- }
712
- }
713
- notes.push('prompt corpus: ' + (c.total || 0) + ' prompts, ' + kinds.size + ' kinds')
714
- } catch (e) {
715
- findings.push('the prompt corpus could not be read/parsed: ' + String((e && e.message) || e))
716
- }
717
-
718
- // The persona surface of ALL FOUR presets is a shipped prompt-correctness artifact too:
719
- // it is the only prompt the MAIN agent receives, and no e2e suite loads the YAML, so a
720
- // human reviewer needs the corpus on disk (generated by audit-persona-surface.test.mjs).
721
- try {
722
- const pc = JSON.parse(readRaw('prompt-corpus-persona/persona-corpus.json'))
723
- for (const d of ['vibe-math-v2', 'vibe-math-v3', 'vibe-math-v4', 'vibe-math-v5']) {
724
- if (!(pc.presets || []).some((p) => p.preset === d)) findings.push('the persona corpus is missing preset ' + d)
725
- }
726
- const emptyBlocks = (pc.presets || []).filter((p) => !p.prefix || !p.text).map((p) => p.preset)
727
- if (emptyBlocks.length) findings.push('the persona corpus has an empty persona block: ' + emptyBlocks.join(', '))
728
- notes.push('persona corpus: ' + (pc.presets || []).length + ' presets')
729
- } catch (e) {
730
- findings.push('the persona corpus could not be read/parsed: ' + String((e && e.message) || e))
731
- }
732
- }
733
-
734
- // ---- scanner self-check -------------------------------------------------
735
- // The whole audit rests on stripNoise(); a scanner that mis-lexes silently BLINDS it (that is how the
736
- // regex-with-a-quote bug lived here). Two checks, both cheap and both measured to be sensitive:
737
- // · the stripped source must still PARSE (the pre-fix scanner produced a SyntaxError);
738
- // · a fixture with a quoted character class + a following comment must survive intact.
739
- {
740
- const tmp = mkdtempSync(join(tmpdir(), 'v5-integrity-strip-'))
741
- try {
742
- const f = join(tmp, 'v5.mjs')
743
- writeFileSync(f, src)
744
- const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' })
745
- if (r.status !== 0) {
746
- findings.push('stripNoise() corrupted the source it scans (the stripped text does not parse) — every identifier check below is unreliable: ' +
747
- String(r.stderr || '').split('\n').filter((l) => l.trim()).slice(-2).join(' ').slice(0, 160))
748
- }
749
- const fixture = [
750
- 'function f(s) { return s.replace(/["\']/g, "-") }',
751
- '// phantom_call( must not survive as a call site',
752
- 'const div = 6 / 2',
753
- ].join('\n')
754
- const stripped = stripNoise(fixture)
755
- if (stripped.includes('phantom_call')) findings.push('stripNoise() left a comment in the code stream (a phantom call site would be reported)')
756
- if (!/'/.test(stripped)) findings.push('stripNoise() dropped a value placeholder')
757
- // The session-API scan must ignore a SHADOWING `function(s)` parameter while still seeing the
758
- // real arrow handlers. Without this, either the false `s.trim()` finding comes back or the
759
- // scope skip silently blinds every tool-handler call — both are failures worth failing on.
760
- const scopeFixture = [
761
- 'const names = m[1].split(",").map(function(s){ return s.trim() })', // local string param
762
- 'registerTool("t", "d", {}, (s, a) => s.resume())', // the session API
763
- ].join('\n')
764
- const spans = localSParamBodySpans(scopeFixture)
765
- const idxShadow = scopeFixture.indexOf('s.trim()')
766
- const idxApi = scopeFixture.indexOf('s.resume()')
767
- if (!spans.some(([a, b]) => idxShadow >= a && idxShadow <= b)) findings.push('the session-API scan would report a PHANTOM finding for a shadowing `function(s)` parameter (s.trim())')
768
- if (spans.some(([a, b]) => idxApi >= a && idxApi <= b)) findings.push('the session-API scan would SKIP a real arrow tool handler (s.resume() was classified as shadowed)')
769
- notes.push('scanner self-check: stripped output parses=' + (r.status === 0) + '; quoted-class fixture=' + (!stripped.includes('phantom_call')) +
770
- '; shadowed-param skip=' + spans.length)
771
- } finally {
772
- rmSync(tmp, { recursive: true, force: true })
773
- }
774
- }
775
-
776
- // ---- report ------------------------------------------------------------
777
- // `--json` is the machine-readable contract the `--self-probe` children parse (same shape as
778
- // audit-prompt-invariants.mjs): { gates, failed, findings, notes }. It is emitted BEFORE the human
779
- // output so a child run's stdout is parseable in one piece.
780
- if (process.argv.includes('--json')) {
781
- console.log(JSON.stringify({ gates: gateCount, failed: findings.length, findings, notes }))
782
- for (const f of findings) console.error(' FINDING: ' + f)
783
- process.exit(findings.length ? 1 : 0)
784
- }
785
- console.log('-- V5 integrity audit --')
786
- for (const n of notes) console.log(' note: ' + n)
787
- console.log('')
788
- if (findings.length) {
789
- for (const f of findings) console.error(' FINDING: ' + f)
790
- console.error('')
791
- console.error(findings.length + ' finding(s)')
792
- process.exit(1)
793
- }
794
- console.log('clean: no undefined calls, no undeclared params keys, no missing session API, no leftover markers')
795
- process.exit(0)
1
+ // ============================================================
2
+ // V5 INTEGRITY AUDIT — static self-check of vibe-math-v5.js against the ways this
3
+ // kind of single-file plugin actually breaks:
4
+ // 1. a function CALLED but never defined (ReferenceError at runtime only)
5
+ // 2. a `params.X` read for a key that DEFAULT_PARAMS never declares (silent undefined)
6
+ // 3. a tool handler calling `s.NAME(...)` on the session API object that the session
7
+ // never returns (TypeError only when that tool is used)
8
+ // 4. documented error codes that are never raised, and raised codes never documented
9
+ // 5. leftover development markers / TODO scaffolding
10
+ // Run: node tests/audit-v5-integrity.mjs (exit 1 on any finding)
11
+ // ============================================================
12
+ import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, readdirSync } from 'node:fs'
13
+ import { spawnSync } from 'node:child_process'
14
+ import { tmpdir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const HERE = new URL('../', import.meta.url)
19
+ /**
20
+ * `V5_INTEGRITY_MUTATE` carries a JSON `[rel, from, to]` triple: that ONE repo-relative file is mutated
21
+ * IN MEMORY for one child run. Combined with `--self-probe` this makes every guard below reproducible
22
+ * from OUTSIDE — a verifier no longer has to copy this audit and rewrite its URLs to point it at a
23
+ * mutated copy (R16's request). Same shape as `audit-prompt-invariants.mjs` and the
24
+ * `math-computation-shared --self-probe` pattern.
25
+ *
26
+ * SEAM COVERAGE (R17 measured, both directions): the seam reaches a **plugin** path (a plugin mutation
27
+ * produces 1 finding) AND the **corpus** path (a corpus mutation produces 2 findings) — i.e. it is not
28
+ * limited to the plugin file, and a corpus-only drift is observable from outside too. The corpus is read
29
+ * through the same `readRaw()`, so any new corpus check inherits the seam.
30
+ */
31
+ const MUT = process.env.V5_INTEGRITY_MUTATE
32
+ function readRaw(rel) {
33
+ let text = readFileSync(new URL(rel, HERE), 'utf8').replace(/\r\n?/g, '\n')
34
+ if (MUT) {
35
+ try {
36
+ const [r, from, to] = JSON.parse(MUT)
37
+ if (r === rel && from) text = text.replace(from, to)
38
+ } catch (e) { /* a malformed mutation is a harness error, not an audit failure */ }
39
+ }
40
+ return text
41
+ }
42
+
43
+ /**
44
+ * Each mutation is a REAL defect shape for one of this audit's guards. `expect` is a substring that MUST
45
+ * appear in the failing run; `control: true` marks the unmutated run, which must stay green.
46
+ */
47
+ const SELF_PROBE_MUTATIONS = [
48
+ { name: 'control (no mutation)', rel: '', from: '', to: '', expect: '', control: true },
49
+ {
50
+ name: 'F3: a status() top-level key is renamed (the frozen field surface must redden)',
51
+ rel: 'vibe-math-v5/vibe-math-v5.js',
52
+ from: 'leanNoticesScope: ',
53
+ to: 'leanNoticesScopeRenamed: ',
54
+ expect: 'status() field surface changed',
55
+ },
56
+ {
57
+ name: 'F1: the README resume claim loses its in-instance case (the pairing gate must redden)',
58
+ rel: 'README.md',
59
+ from: '**同实例重建**(宿主丢了子会话、插件实例还在)**继续**原编号',
60
+ to: '**同实例重建**从 1 重新开始',
61
+ expect: 'README resume-claim side missing',
62
+ },
63
+ {
64
+ name: 'F1: a code anchor the README cites is removed (its philosophy gate must redden)',
65
+ rel: 'vibe-math-v5/vibe-math-v5.js',
66
+ from: 'rounds.set(member.id, startRound)',
67
+ to: 'void 0 /* anchor removed (self-probe) */',
68
+ expect: 'philosophy gate missing from the implementation: the founding round is applied only AFTER a successful start',
69
+ },
70
+ {
71
+ name: 'F2/G1: the diagram is mutated back to the pre-G1 inbox order (the pairing gate must redden)',
72
+ rel: 'vibe-math-v5/架构图.md',
73
+ from: '唯一的推进驱动',
74
+ to: '唯一的推进驱动(先 ack 再构造)',
75
+ expect: '架构图 still states the pre-G1 order',
76
+ },
77
+ {
78
+ name: 'F2/G1: the plan is mutated back to the explicit pre-G1 inbox phrasing (the pairing gate must redden)',
79
+ rel: 'vibe-math-v5/实现方案.md',
80
+ from: '**未 ack ⇒ 仍可重投**',
81
+ to: '**先 ack 邮件、再构造**;**未 ack ⇒ 仍可重投**',
82
+ expect: '实现方案.md still states the pre-G1 order',
83
+ },
84
+ {
85
+ name: 'F2/G1: a docs file is mutated back to the named pre-G1 phrasing (the docs-wide gate must redden)',
86
+ rel: 'docs/AUDIT-CHECKLIST.md',
87
+ from: '身份一律显式传递,绝不猜测。',
88
+ to: '身份一律显式传递,绝不猜测。先 ack 再构造',
89
+ expect: 'still states the pre-G1 order',
90
+ },
91
+ {
92
+ name: 'F2/G1: the plan is mutated to re-introduce the pre-G1 re-send contract (the named check must redden)',
93
+ rel: 'vibe-math-v5/实现方案.md',
94
+ from: '`makeFileBackend`',
95
+ to: '`makeFileBackend`(queued 永不重发)',
96
+ expect: 'still states the pre-G1 re-send contract',
97
+ },
98
+ ]
99
+
100
+ if (process.argv.includes('--self-probe')) {
101
+ const bad = []
102
+ for (const mut of SELF_PROBE_MUTATIONS) {
103
+ const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--json'], {
104
+ cwd: fileURLToPath(HERE),
105
+ env: Object.assign({}, process.env, mut.control ? {} : { V5_INTEGRITY_MUTATE: JSON.stringify([mut.rel, mut.from, mut.to]) }),
106
+ encoding: 'utf8',
107
+ })
108
+ let parsed = null
109
+ try { parsed = JSON.parse(r.stdout) } catch (e) { /* fall through to the diagnostic below */ }
110
+ const text = (r.stdout || '') + (r.stderr || '')
111
+ if (mut.control) {
112
+ const ok = r.status === 0 && parsed && parsed.failed === 0
113
+ console.log((ok ? 'PASS ' : 'FAIL ') + 'control: the unmutated run stays green (' + (parsed ? parsed.failed + ' findings, ' + parsed.gates + ' gates' : 'unparsable output') + ')')
114
+ if (!ok) bad.push('control run was not green')
115
+ continue
116
+ }
117
+ const hit = text.includes(mut.expect)
118
+ const red = r.status === 1 && parsed && parsed.failed > 0
119
+ const ok = red && hit
120
+ console.log((ok ? 'PASS ' : 'FAIL ') + mut.name + (ok ? '' : ' → exit=' + r.status + ' hit=' + hit + ' failed=' + (parsed && parsed.failed)))
121
+ if (!ok) bad.push(mut.name + ' (exit ' + r.status + ', expected ' + JSON.stringify(mut.expect) + ')')
122
+ }
123
+ console.log('')
124
+ console.log('V5 INTEGRITY SELF-PROBE: ' + (SELF_PROBE_MUTATIONS.length - bad.length) + '/' + SELF_PROBE_MUTATIONS.length + ' as required')
125
+ for (const b of bad) console.error(' FAIL ' + b)
126
+ process.exit(bad.length === 0 ? 0 : 1)
127
+ }
128
+
129
+ const FILE_REL = 'vibe-math-v5/vibe-math-v5.js'
130
+ const raw = readRaw(FILE_REL)
131
+ // Strip comments, string literals AND regex literals before any identifier scan. Without this the
132
+ // heuristic matches English words inside comments that merely precede a '(' (e.g.
133
+ // "// per unit (" becomes a phantom call to unit()), drowning the real findings.
134
+ //
135
+ // REGEX LITERALS ARE NOT OPTIONAL HERE: v5 contains `/[\\/:*?"<>|\u0000-\u001f]+/` — a character class
136
+ // holding a DOUBLE QUOTE. A scanner without regex support reads that quote as a string start and
137
+ // mangles the rest of the line. Measured on this very file (old vs new, line by line): 30 of 4365
138
+ // lines differed and the stripped output did NOT parse. No identifier this audit checks
139
+ // (call names, `params.*` reads, `s.*()` methods) happened to be missed in the current source, so this
140
+ // was latent rather than exploited — but a single call appearing only on such a line would have been
141
+ // invisible. The self-check at the bottom of this file keeps the scanner honest.
142
+ // The keyword rule matters for the same reason as in audit-prompt-invariants.mjs (`return /…/`).
143
+ function stripNoise(s) {
144
+ let out = ''
145
+ let i = 0
146
+ const n = s.length
147
+ let prev = '' // last significant token (single char, or a whole word such as `return`)
148
+ let word = ''
149
+ const REGEX_AFTER_KEYWORD = /^(?:return|typeof|case|delete|void|instanceof|in|of|yield|await|new|do|else)$/
150
+ const regexAllowed = () => prev === '' || REGEX_AFTER_KEYWORD.test(prev) || /[(,=:[!&|?{};+\-*%~^<>]/.test(prev)
151
+ while (i < n) {
152
+ const c = s[i], c2 = s[i + 1]
153
+ if (c === '/' && c2 === '/') { while (i < n && s[i] !== '\n') i++; continue }
154
+ if (c === '/' && c2 === '*') { i += 2; while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++; i += 2; continue }
155
+ if (c === '/' && regexAllowed()) {
156
+ i++
157
+ let inClass = false
158
+ while (i < n) {
159
+ if (s[i] === '\\') { i += 2; continue }
160
+ if (s[i] === '[') inClass = true
161
+ else if (s[i] === ']') inClass = false
162
+ else if (s[i] === '/' && !inClass) { i++; break }
163
+ else if (s[i] === '\n') break // a regex literal cannot span lines
164
+ i++
165
+ }
166
+ while (i < n && /[a-z]/i.test(s[i])) i++ // flags
167
+ out += "''" // a value placeholder, like strings: keeps `X: <value>` shapes but no phantom calls
168
+ prev = ')'
169
+ continue
170
+ }
171
+ if (c === "'" || c === '"' || c === '`') {
172
+ const q = c
173
+ i++
174
+ while (i < n) {
175
+ if (s[i] === '\\') { i += 2; continue }
176
+ if (s[i] === q) { i++; break }
177
+ if (q !== '`' && s[i] === '\n') break
178
+ i++
179
+ }
180
+ out += q + q // keep a placeholder so `X: ''` shape survives
181
+ prev = ')'
182
+ continue
183
+ }
184
+ out += c
185
+ if (/[A-Za-z0-9_$]/.test(c)) word += c
186
+ else { if (word) { prev = word; word = '' } if (!/\s/.test(c)) prev = c }
187
+ i++
188
+ }
189
+ return out
190
+ }
191
+ const src = stripNoise(raw)
192
+ const findings = []
193
+ const notes = []
194
+ // How many philosophy gates the run checked (module scope so `--json` can report it).
195
+ let gateCount = 0
196
+
197
+ const lineOf = (idx) => src.slice(0, idx).split('\n').length
198
+
199
+ // ---- 1. called-but-undefined -------------------------------------------
200
+ const defined = new Set()
201
+ for (const m of src.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) defined.add(m[1])
202
+ for (const m of src.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) defined.add(m[1])
203
+ for (const m of src.matchAll(/(?:const|let|var)\s+\{([^}]+)\}\s*=/g)) {
204
+ for (const part of m[1].split(',')) { const n = part.split(':').pop().trim(); if (n) defined.add(n) }
205
+ }
206
+ for (const m of src.matchAll(/catch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) defined.add(m[1])
207
+ // ESM imports are DEFINITIONS too: the preset imports the shared `math_computation` module
208
+ // (INTERFACE-FREEZE §5.1), so calling an imported function is not a ReferenceError. Without
209
+ // this every imported symbol was reported as "called but never defined"; a call to a name that
210
+ // is neither declared nor imported is still flagged.
211
+ for (const m of src.matchAll(/import\s*(?:\*\s*as\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\}\s*)?from\s*['"][^'"]*['"]/g)) {
212
+ if (m[1]) defined.add(m[1])
213
+ if (m[2]) defined.add(m[2])
214
+ if (m[3]) {
215
+ for (const part of m[3].split(',')) {
216
+ const name = part.split(/\s+as\s+/).pop().trim()
217
+ if (name) defined.add(name)
218
+ }
219
+ }
220
+ }
221
+ // Function/method PARAMETERS are locally bound identifiers too, not free calls.
222
+ for (const m of src.matchAll(/function\s*[A-Za-z_$]*\s*\(([^)]*)\)/g)) {
223
+ for (const raw of m[1].split(',')) {
224
+ const name = raw.trim().replace(/[={].*$/s, '').trim()
225
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
226
+ }
227
+ }
228
+ for (const m of src.matchAll(/\(([^()]*)\)\s*=>/g)) {
229
+ for (const raw of m[1].split(',')) {
230
+ const name = raw.trim().replace(/[={].*$/s, '').trim()
231
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
232
+ }
233
+ }
234
+ for (const m of src.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) defined.add(m[1])
235
+ // JS/DSH globals that are legitimately free
236
+ const GLOBALS = new Set([
237
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'function', 'new', 'await', 'delete', 'void', 'do', 'else', 'try',
238
+ 'Array', 'Object', 'JSON', 'Number', 'String', 'Boolean', 'Math', 'Date', 'Promise', 'Set', 'Map', 'WeakRef', 'WeakMap', 'Symbol', 'Error',
239
+ 'isFinite', 'isNaN', 'parseInt', 'parseFloat', 'encodeURIComponent', 'decodeURIComponent', 'structuredClone',
240
+ 'AbortSignal', 'console', 'require', 'import', 'super', 'this', 'of', 'in', 'instanceof',
241
+ 'RegExp', 'Proxy', 'Reflect', 'AggregateError', 'TextEncoder', 'TextDecoder', 'URL', 'BigInt', 'Intl', 'Buffer', 'process',
242
+ ])
243
+ const LOCAL_METHODS = new Set()
244
+ for (const m of src.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*[:(]/g)) LOCAL_METHODS.add(m[1])
245
+ for (const m of src.matchAll(/\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) LOCAL_METHODS.add(m[1])
246
+
247
+ const called = new Map()
248
+ for (const m of src.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
249
+ const name = m[1]
250
+ if (GLOBALS.has(name) || LOCAL_METHODS.has(name)) continue
251
+ if (!called.has(name)) called.set(name, { n: 0, line: lineOf(m.index) })
252
+ called.get(name).n++
253
+ }
254
+ for (const [name, info] of called) {
255
+ if (!defined.has(name)) findings.push('line ' + info.line + ': called but never defined: ' + name + '() (' + info.n + ' call site(s))')
256
+ }
257
+
258
+ // ---- 2. params keys ----------------------------------------------------
259
+ const dpBlock = /const DEFAULT_PARAMS = \{([\s\S]*?)\n \}/.exec(src)
260
+ if (!dpBlock) findings.push('could not locate DEFAULT_PARAMS')
261
+ else {
262
+ const declared = new Set()
263
+ for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared.add(m[1])
264
+ const used = new Map()
265
+ for (const m of src.matchAll(/\bparams\.([A-Za-z_$][\w$]*)/g)) used.set(m[1], (used.get(m[1]) || 0) + 1)
266
+ for (const [k, n] of used) {
267
+ if (!declared.has(k)) findings.push('params.' + k + ' read but not declared in DEFAULT_PARAMS (' + n + ' use(s))')
268
+ }
269
+ notes.push('DEFAULT_PARAMS keys: ' + declared.size + '; params.* reads: ' + used.size)
270
+ }
271
+
272
+ // ---- 3. session-API surface used by tool handlers -----------------------
273
+ // `s` is not always the session API object. The toolFilter sanitiser added for the v2/v3/v4
274
+ // parity guard contains `.map(function(s){ return s.trim() })` — there `s` is a LOCAL string
275
+ // parameter, and a flat `s.NAME(` scan reported the phantom finding "calls s.trim() but the
276
+ // session API does not export it". A false FINDING is as corrosive as a blind spot (it trains
277
+ // the reader to ignore the audit), so the scan now respects the ONE shadowing form the plugin
278
+ // actually uses: a plain `function (...)` whose parameter list binds `s`.
279
+ //
280
+ // This is scope analysis, not an allowlist: any `s.X()` inside such a body CANNOT be a call on
281
+ // the enclosing session object — the parameter shadows it. The tool handlers themselves are
282
+ // ARROW functions (`(s, a, x) => s.…)`), so their `s` IS the session API and stays scanned; the
283
+ // self-check at the bottom of this file proves the skip does not blind those.
284
+ function localSParamBodySpans(text) {
285
+ const spans = []
286
+ const re = /function\s*(?:[A-Za-z_$][\w$]*)?\s*\(([^)]*)\)\s*\{/g
287
+ let m
288
+ while ((m = re.exec(text)) !== null) {
289
+ const params = m[1].split(',').map((p) => p.trim().replace(/[={].*$/s, '').trim())
290
+ if (params.indexOf('s') === -1) continue
291
+ let i = m.index + m[0].length - 1 // sitting on the opening '{'
292
+ let depth = 0
293
+ for (; i < text.length; i++) {
294
+ if (text[i] === '{') depth++
295
+ else if (text[i] === '}') { depth--; if (depth === 0) break }
296
+ }
297
+ spans.push([m.index, i])
298
+ }
299
+ return spans
300
+ }
301
+ // The returned API object is the last `return { ... }` inside makeSession.
302
+ const apiStart = src.lastIndexOf(' return {\n sessionId,')
303
+ if (apiStart === -1) findings.push('could not locate the session API return object')
304
+ else {
305
+ const apiEnd = src.indexOf('\n }\n }', apiStart)
306
+ const apiText = src.slice(apiStart, apiEnd === -1 ? apiStart + 4000 : apiEnd)
307
+ const apiKeys = new Set()
308
+ for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*:/g)) apiKeys.add(m[1])
309
+ for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*,/g)) apiKeys.add(m[1])
310
+ const localScopes = localSParamBodySpans(src)
311
+ const inLocalScope = (idx) => localScopes.some(([a, b]) => idx >= a && idx <= b)
312
+ const usedOnS = new Map()
313
+ let shadowed = 0
314
+ for (const m of src.matchAll(/(?<![\w$.])s\.([A-Za-z_$][\w$]*)\s*\(/g)) {
315
+ if (inLocalScope(m.index)) { shadowed++; continue }
316
+ if (!usedOnS.has(m[1])) usedOnS.set(m[1], lineOf(m.index))
317
+ }
318
+ for (const [k, line] of usedOnS) {
319
+ if (!apiKeys.has(k)) findings.push('line ' + line + ': tool handler calls s.' + k + '() but the session API does not export it')
320
+ }
321
+ notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size +
322
+ (shadowed ? '; skipped ' + shadowed + ' inside a local `function(s)` scope (a shadowing parameter, not the session API)' : ''))
323
+ }
324
+
325
+ // ---- 4. error codes ----------------------------------------------------
326
+ // Scan the RAW source: these patterns live inside string literals, which `src` strips.
327
+ const raised = new Set()
328
+ for (const m of raw.matchAll(/v5err\(\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
329
+ for (const m of raw.matchAll(/code:\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
330
+ const docs = readRaw('vibe-math-v5/实现方案.md')
331
+ const documented = new Set()
332
+ for (const m of docs.matchAll(/`(V5_[A-Z_]+)`/g)) documented.add(m[1])
333
+ const onlyDocs = new Set()
334
+ for (const m of docs.matchAll(/(V5_[A-Z_]+)/g)) onlyDocs.add(m[1])
335
+ const raisedNotDocumented = [...raised].filter(c => !onlyDocs.has(c))
336
+ const documentedNotRaised = [...documented].filter(c => !raised.has(c))
337
+ if (raisedNotDocumented.length) findings.push('error codes raised but absent from 实现方案.md: ' + raisedNotDocumented.join(', '))
338
+ if (documentedNotRaised.length) notes.push('documented but not raised in code (ok if advisory): ' + documentedNotRaised.join(', '))
339
+ notes.push('error codes raised: ' + raised.size)
340
+
341
+ // ---- 5. leftover scaffolding -------------------------------------------
342
+ for (const bad of ['@@V5_SECTION@@', 'TODO', 'FIXME', 'XXX', 'PLACEHOLDER']) {
343
+ if (src.includes(bad)) findings.push('leftover development marker: ' + bad)
344
+ }
345
+ // every event type declared in EV must be appended somewhere
346
+ const evBlock = /const EV = \{([\s\S]*?)\n\}/.exec(raw)
347
+ if (evBlock) {
348
+ const keys = [...evBlock[1].matchAll(/([A-Za-z]+):\s*'(vibe5\/[a-z]+)'/g)]
349
+ for (const [, prop, literal] of keys) {
350
+ const uses = (raw.match(new RegExp("EV\\." + prop + "\\b", 'g')) || []).length
351
+ if (uses <= 1) findings.push('event type EV.' + prop + ' (' + literal + ') is declared but never committed')
352
+ }
353
+ notes.push('event types declared: ' + keys.length)
354
+ }
355
+
356
+ // ---- 6. composition sanity --------------------------------------------
357
+ // The v5 preset is a composition, and a typo in a row `name` is a mounting failure that
358
+ // no unit test of the plugin can catch (the mock calls apply() directly and never goes
359
+ // through the loader). v4 is a proven-good composition mounted on the same hosts, so any
360
+ // package row v5 names that v4 does not is either a genuine new dependency or a typo —
361
+ // and a new dependency must be justified, so surface it for review.
362
+ function packageRows(yaml) {
363
+ const rows = []
364
+ const lines = yaml.split('\n')
365
+ for (let i = 0; i < lines.length; i++) {
366
+ const idm = /^\s*-\s*id:\s*(\S+)\s*$/.exec(lines[i])
367
+ if (!idm) continue
368
+ const row = { id: idm[1], name: '', disabled: false, line: i + 1 }
369
+ for (let j = i + 1; j < lines.length && j < i + 12; j++) {
370
+ if (/^\s*-\s*id:\s*\S+\s*$/.test(lines[j])) break
371
+ const nm = /^\s*name:\s*'?([^'\n]+?)'?\s*$/.exec(lines[j])
372
+ if (nm && !row.name) row.name = nm[1].trim()
373
+ if (/^\s*disabled:\s*true/.test(lines[j])) row.disabled = true
374
+ }
375
+ if (row.name) rows.push(row)
376
+ }
377
+ return rows
378
+ }
379
+ const v5yaml = readRaw('vibe-math-v5/agent.cordis.yml')
380
+ const v4yaml = readRaw('vibe-math-v4/agent.cordis.yml')
381
+ const v5rows = packageRows(v5yaml)
382
+ const v4names = new Set(packageRows(v4yaml).map(r => r.name))
383
+ const RELATIVE_OK = new Set(['./vibe-math-v5.js'])
384
+ if (!v5rows.length) findings.push('composition: no rows parsed out of agent.cordis.yml (is the file still YAML?)')
385
+ for (const r of v5rows) {
386
+ if (r.name === 'cordis:group') continue
387
+ if (r.name.startsWith('./')) {
388
+ if (!RELATIVE_OK.has(r.name)) findings.push('composition line ' + r.line + ': relative row name "' + r.name + '" has no matching file in the preset directory')
389
+ continue
390
+ }
391
+ if (!v4names.has(r.name)) findings.push('composition line ' + r.line + ': row "' + r.id + '" names "' + r.name + '", which v4 does not — verify the package/name is correct')
392
+ }
393
+ // the preset must reference its own plugin row, and that file must exist
394
+ if (!v5rows.some(r => r.name === './vibe-math-v5.js')) findings.push('composition: the preset never mounts ./vibe-math-v5.js')
395
+ // prefix AND text are required on the persona row for the DSH schema and for back-compat
396
+ const personaBlock = /- id:\s*persona[\s\S]*?(?=\n-\s*id:)/.exec(v5yaml)
397
+ if (!personaBlock) findings.push('composition: no persona row found')
398
+ else {
399
+ if (!/^\s*prefix:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the required `prefix` key')
400
+ if (!/^\s*text:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the legacy `text` key')
401
+ }
402
+ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5rows.filter(r => r.name !== 'cordis:group' && !r.name.startsWith('./') && !v4names.has(r.name)).length)
403
+
404
+ // ---- 7. requirements traceability against the plan ---------------------
405
+ // "No missing logic" is only checkable mechanically if the SPEC is machine-readable.
406
+ // The plan's §15 names every tool and §16 every parameter, so compare those sets
407
+ // against the implementation: a name in the plan but not the code is an unimplemented
408
+ // requirement, and a name in the code but not the plan is undocumented surface.
409
+ {
410
+ const plan = readRaw('vibe-math-v5/实现方案.md')
411
+ const planTools = new Set()
412
+ for (const m of plan.matchAll(/\bvibe_v5_[a-z_]+/g)) planTools.add(m[0])
413
+ const codeTools = new Set()
414
+ for (const m of raw.matchAll(/registerTool\(\s*'(vibe_v5_[a-z_]+)'/g)) codeTools.add(m[1])
415
+ // documented-but-wildcarded placeholders are not real tools
416
+ const IGNORE = new Set(['vibe_v5_', 'vibe_v5_record_', 'vibe_v5_lean_', 'vibe_v5_task_'])
417
+ for (const t of planTools) {
418
+ if (IGNORE.has(t)) continue
419
+ if (!codeTools.has(t)) findings.push('plan names tool ' + t + ' but the plugin never registers it')
420
+ }
421
+ const undocumented = [...codeTools].filter(t => !planTools.has(t))
422
+ if (undocumented.length) notes.push('tools registered but not named in the plan: ' + undocumented.join(', '))
423
+ notes.push('plan tools: ' + planTools.size + '; registered tools: ' + codeTools.size)
424
+
425
+ // Parameters: only the §16 default table, NOT the §15 tool tables (whose rows also
426
+ // begin with a backticked name).
427
+ const declared2 = new Set()
428
+ if (dpBlock) for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared2.add(m[1])
429
+ const sec16 = /##\s*16\.[\s\S]*?(?=\n##\s*17\.)/.exec(plan)
430
+ const paramRows = new Set()
431
+ if (sec16) {
432
+ for (const m of sec16[0].matchAll(/^\|\s*`([A-Za-z][\w]*)`(?:\s*\/\s*`([A-Za-z][\w]*)`)?\s*\|/gm)) {
433
+ paramRows.add(m[1])
434
+ if (m[2]) paramRows.add(m[2])
435
+ }
436
+ } else findings.push('could not locate the plan\'s §16 parameter table')
437
+ for (const p of paramRows) {
438
+ if (!declared2.has(p)) findings.push('plan documents parameter `' + p + '` but DEFAULT_PARAMS does not declare it')
439
+ }
440
+ notes.push('plan §16 parameter rows: ' + paramRows.size)
441
+
442
+ // F3 (docs-vs-behaviour sweep): `status()`'s machine surface. OWNERSHIP (agreed division, so the two
443
+ // guards never both claim the same job):
444
+ // • FIELD-LEVEL reconciliation (field ↔ doc row, BOTH directions) is owned by
445
+ // `tests/audit-status-report-fields.mjs`, which carries the table and prints the
446
+ // `frozen / doc-rows / exceptions` counts;
447
+ // • THIS audit keeps ONLY this frozen-key drift check: the extracted 43 keys below, the printed
448
+ // `captured: N`, and the `<30` anti-vacuity floor. Any addition/removal of a top-level key must be
449
+ // an explicit decision here (and in the owning guard's reconciliation).
450
+ // No "doc half" is promised or planned here — the note printed at the end points at the owner instead.
451
+ const stIdx = raw.indexOf('function status() {')
452
+ const statusKeys = []
453
+ if (stIdx === -1) findings.push('could not locate status() — the machine-surface extraction has no anchor')
454
+ else {
455
+ const open = raw.indexOf('return {', stIdx)
456
+ const close = raw.indexOf('\n }', open)
457
+ // Tokenise the returned literal: every key directly inside it — including SHORTHAND keys that share a
458
+ // line (`institute: instituteName, project, key, phase,`) — while SKIPPING each value (so an
459
+ // identifier used as a value can never be mistaken for a key).
460
+ const body = open === -1 ? '' : raw.slice(open + 'return {'.length, close === -1 ? open + 9000 : close)
461
+ // Comments must be skipped IN PLACE: the payload's comments contain prose with apostrophes, parens and
462
+ // commas (`…delivered ONCE…`, `phase:'failed'`, `(installer review)`), which otherwise corrupt both the
463
+ // depth tracking and the value skipping.
464
+ const skipComment = (s, at) => {
465
+ if (s[at + 1] === '/') { let j = at + 2; while (j < s.length && s[j] !== '\n') j += 1; return j }
466
+ if (s[at + 1] === '*') { const j = s.indexOf('*/', at + 2); return j === -1 ? s.length : j + 2 }
467
+ return -1
468
+ }
469
+ let i = 0, depth = 0
470
+ while (i < body.length) {
471
+ const ch = body[i]
472
+ if (ch === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
473
+ if (ch === '"' || ch === "'") { const q = ch; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
474
+ if (ch === '{' || ch === '[' || ch === '(') { depth += 1; i += 1; continue }
475
+ if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) break; depth -= 1; i += 1; continue }
476
+ if (depth === 0 && /[A-Za-z_$]/.test(ch)) {
477
+ const m = /^[A-Za-z_$][\w$]*/.exec(body.slice(i))
478
+ const after = body.slice(i + m[0].length).replace(/^[\s]+/, '')
479
+ if (after[0] === ':') {
480
+ statusKeys.push(m[0])
481
+ i += m[0].length
482
+ let d = 0
483
+ while (i < body.length) { // skip the VALUE up to the next top-level comma
484
+ const c = body[i]
485
+ if (c === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
486
+ if (c === '"' || c === "'") { const q = c; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
487
+ if (c === '{' || c === '[' || c === '(') { d += 1; i += 1; continue }
488
+ if (c === '}' || c === ']' || c === ')') { if (d === 0) break; d -= 1; i += 1; continue }
489
+ if (c === ',' && d === 0) { i += 1; break }
490
+ i += 1
491
+ }
492
+ continue
493
+ }
494
+ if (after[0] === ',') { statusKeys.push(m[0]); i += m[0].length; continue }
495
+ i += m[0].length; continue
496
+ }
497
+ i += 1
498
+ }
499
+ }
500
+ notes.push('status() top-level keys captured: ' + statusKeys.length)
501
+ if (statusKeys.length < 30) findings.push('status() key extraction captured only ' + statusKeys.length + ' keys (anchor or payload broke — this check must never pass vacuously)')
502
+ for (const must of ['phase', 'members', 'quorum', 'chat', 'persistence']) {
503
+ if (statusKeys.indexOf(must) === -1) findings.push('status() no longer exposes `' + must + '` (either the extraction anchor or the payload broke)')
504
+ }
505
+ const EXPECTED_STATUS_KEYS = ['ok', 'institute', 'project', 'key', 'phase', 'running', 'autoDone', 'runId', 'leanNotices', 'leanNoticesScope', 'fieldScopes', 'backend', 'diagnostics', 'debug', 'quorum', 'members', 'tasks', 'failedMembers', 'failedMembersNote', 'pendingSpawns', 'pendingSpawnsNote', 'chat', 'chatScope', 'officeRequests', 'officeRequestsShown', 'officeRequestsCap', 'officeRequestsDropped', 'officeRequestsTruncated', 'persistence', 'meeting', 'parkedMeeting', 'verify', 'verifyQueue', 'verified', 'verifiedTrue', 'concludedFalse', 'verifiedNote', 'undecided', 'solveVotes', 'formal', 'paper', 'lastProgressAt', 'params']
506
+ const statusMissing = EXPECTED_STATUS_KEYS.filter((k) => statusKeys.indexOf(k) === -1)
507
+ const statusAdded = statusKeys.filter((k) => EXPECTED_STATUS_KEYS.indexOf(k) === -1)
508
+ if (statusMissing.length || statusAdded.length) {
509
+ findings.push('status() field surface changed — missing=' + JSON.stringify(statusMissing) + ' added=' + JSON.stringify(statusAdded) +
510
+ ': record the decision in this freeze AND let the field-level owner reconcile the documents (tests/audit-status-report-fields.mjs)')
511
+ }
512
+ notes.push('status() field-surface drift: frozen=' + statusKeys.length + ' keys (this audit owns ONLY this freeze + the <30 anti-vacuity floor); ' +
513
+ 'field-level reconciliation (field ↔ doc row, both directions) is OWNED BY tests/audit-status-report-fields.mjs, which prints frozen / doc-rows / exceptions')
514
+
515
+ // F1 (docs-vs-behaviour): the README's TWO-CASE `resume` claim must stay paired with the behavioural
516
+ // anchor that pins the in-instance case (the two CODE anchors are gates in the GATES table below). Only
517
+ // the prose side is checked here, and every side reports how much it matched, so this cannot pass vacuously.
518
+ {
519
+ const readme = readRaw('README.md')
520
+ const suite = readRaw('tests/e2e-v5-round2.test.mjs')
521
+ const readmeClaims = [
522
+ ['the two-case framing', /`resume`\s*后轮次计数的两种情形/],
523
+ ['case 1 = a cross-process reload counts from 1', /跨进程重载[^\n]*从\s*\*\*1\*\*\s*重新计/],
524
+ ['case 2 = an in-instance rebuild CONTINUES the numbering (轮次 2)', /同实例重建[^\n]*继续[^\n]*轮次 2/],
525
+ ['a failed/invalid start no longer erases the existing count', /失败\/无效的启动\*\*不再抹掉\*\*已有计数/],
526
+ ['the claim is pinned to a revision', /口径复核于\s*`?[0-9a-f]{7,40}`?/],
527
+ ]
528
+ let readmeHits = 0
529
+ for (const [label, re] of readmeClaims) {
530
+ if (re.test(readme)) readmeHits += 1
531
+ else findings.push('README resume-claim side missing: ' + label)
532
+ }
533
+ const suiteClaims = [
534
+ ['the in-instance rebuild is asserted as 轮次 2', /a FAILED resume must not move the round number backwards/],
535
+ ['the founding round is asserted as 轮次 1', /the founding prompt starts the member at 轮次 1/],
536
+ ]
537
+ let suiteHits = 0
538
+ for (const [label, re] of suiteClaims) {
539
+ if (re.test(suite)) suiteHits += 1
540
+ else findings.push('README resume-claim has no behavioural anchor: ' + label)
541
+ }
542
+ // The README cites exact code/test lines PINNED to a revision ("口径复核于 <sha>"). Those drift by
543
+ // design, so the drift is a measured NOTE carrying the current lines — never a failure.
544
+ const lineOf = (needle) => { const at = raw.indexOf(needle); return at === -1 ? -1 : raw.slice(0, at).split('\n').length }
545
+ const startLine = lineOf('const startRound = (rounds.get(member.id) || 0) + 1')
546
+ const applyLine = lineOf('rounds.set(member.id, startRound)')
547
+ if (startLine === -1 || applyLine === -1) findings.push('the spawnMember round-count anchors are gone (startRound=' + startLine + ', applied=' + applyLine + ')')
548
+ notes.push('F1 resume-claim pairing: README ' + readmeHits + '/' + readmeClaims.length + ' claims, behavioural anchors ' + suiteHits + '/' + suiteClaims.length + '; cited-line drift (README pins a revision): startRound now :' + startLine + ', applied now :' + applyLine)
549
+ }
550
+
551
+ // F2/G1 pairing (prose ↔ code): the v5 diagram must not state the PRE-G1 order, and must describe the
552
+ // implemented one. Going through `readRaw` gives this block the same `V5_INTEGRITY_MUTATE` seam as every
553
+ // other file, which is what the `--self-probe` case below relies on.
554
+ {
555
+ const diagram = readRaw('vibe-math-v5/架构图.md')
556
+ const FORBID = /先\s*ack[^\n]{0,8}再构造/
557
+ const CLAUSES = /构造[^\n]{0,12}(后|再)[^\n]{0,12}ack|ack[^\n]{0,12}(仅|只)[^\n]{0,12}(成功|送达)/
558
+ if (FORBID.test(diagram)) findings.push('架构图 still states the pre-G1 order: ack before the prompt is built (F2/G1)')
559
+ if (!CLAUSES.test(diagram)) findings.push('架构图 does not describe the implemented inbox order (construct first / ack only on success)')
560
+ if (!raw.includes('if (ok && prompt.pending.length) await ackPending(')) findings.push('the ack is no longer gated by a successful wake (F2/G1 anchor missing)')
561
+ notes.push('架构图 inbox pairing: forbid-hits=' + (FORBID.test(diagram) ? 1 : 0) + ', clauses=' + (CLAUSES.test(diagram) ? 1 : 0))
562
+ // The PLAN uses the explicit pre-G1 phrases only: a sentence that legitimately QUOTES or FORBIDS the old
563
+ // order must not trip this, so the pattern names the phrases instead of the bare `先 ack`.
564
+ const planDoc = readRaw('vibe-math-v5/实现方案.md')
565
+ const PLAN_FORBID = /先\s*ack\s*邮件[、,,]?\s*再构造|先\s*ack[、,,]?\s*后构造/
566
+ if (PLAN_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 order: ack the mail before building the prompt (F2/G1)')
567
+ notes.push('实现方案.md inbox pairing: forbid-hits=' + (PLAN_FORBID.test(planDoc) ? 1 : 0))
568
+ // The SAME class produced five instances, the last one inside the guard checklist itself, so the scan
569
+ // covers every top-level docs/*.md. The phrase is NAMED (never the bare `先 ack`) so a sentence that
570
+ // legitimately QUOTES or FORBIDS the old order cannot trip it. NOTE (applier): `HERE` is the REPO ROOT
571
+ // (see `new URL(rel, HERE)` above), so the docs directory is `new URL('docs/', HERE)` — NOT `../docs/`.
572
+ const DOC_FORBID = /先\s*ack[^\n]{0,8}再构造/
573
+ let docHits = 0, docFiles = 0
574
+ for (const f of readdirSync(new URL('docs/', HERE)).filter((n) => n.endsWith('.md'))) {
575
+ docFiles += 1
576
+ if (DOC_FORBID.test(readRaw('docs/' + f))) { docHits += 1; findings.push('docs/' + f + ' still states the pre-G1 order: ack the mail before building the prompt (F2/G1)') }
577
+ }
578
+ // The scan covers LIVE docs only. `docs/release-notes/**` is a FROZEN published record: the pre-G1
579
+ // wording there is history, not a stale claim, so it must stay OUT of the scan — and the exemption is
580
+ // ASSERTED (a missing/empty directory fails) so a future change to recursive scanning cannot silently
581
+ // start scanning frozen history. Same convention as audit-readme-counts.mjs's frozen TOTAL-65 notes.
582
+ let frozenNotes = []
583
+ try { frozenNotes = readdirSync(new URL('docs/release-notes/', HERE)).filter((n) => n.endsWith('.md')) } catch (e) { frozenNotes = [] }
584
+ if (!frozenNotes.length) findings.push('the frozen release-notes exemption is EMPTY or missing (docs/release-notes/*.md) — re-decide the scan scope explicitly, never widen it silently')
585
+ notes.push('docs/** inbox pairing: files=' + docFiles + ', forbid-hits=' + docHits + '; frozen docs/release-notes/*.md exempted=' + frozenNotes.length)
586
+ // The plan ALSO carried the pre-G1 re-send contract ("queued 不重发/永不重发") in two sketch lines while
587
+ // its own M2 row already said "未 ack ⇒ 仍可重投". NAMED phrase only, scoped to the plan file, so a
588
+ // sentence that legitimately quotes or forbids the old wording cannot trip it.
589
+ const QUEUED_FORBID = /queued\s*(永不|不)重发/
590
+ if (QUEUED_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 re-send contract (queued 不重发/永不重发): unacked mail is re-deliverable (F2/G1)')
591
+ notes.push('实现方案.md queued-no-resend phrasing: hits=' + (QUEUED_FORBID.test(planDoc) ? 1 : 0))
592
+ }
593
+
594
+ // The plan's philosophy is enforced by concrete gates; assert the load-bearing ones
595
+ // still exist so a future edit cannot quietly drop a guard.
596
+ const GATES = [
597
+ ['temp workers cannot vote', "if (m.kind === 'temp') return // no vote"],
598
+ ['temp workers cannot hire', "if (caller.kind === 'temp') return { ok: false, code: 'V5_NOT_VOTER'"],
599
+ ['only the academician assigns', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks'"],
600
+ ['only the academician sets priorities', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities'"],
601
+ ['permanent-staff changes need the office', "code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)'"],
602
+ ['assignment objections are broadcast', 'if (p.reject_assign && typeof p.reject_assign === \'object\' && params.memberMayRejectAssign)'],
603
+ ['the academician has no extra vote weight', 'const E = voters().map((m) => m.id)'],
604
+ ['members may reject an assignment', "memberMayRejectAssign: true,"],
605
+ ['the charter states the progress definition', "' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。'"],
606
+ ['the charter describes the academician as organizer', "' 【四、你的组织职责与边界(院士)】'"],
607
+ ['the framework never assigns on its own', "agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?'"],
608
+
609
+ // ── PROMPT / INTERACTION CORRECTNESS GATES ─────────────────────────────
610
+ // F1 (README two-case resume claim): the two code anchors the prose cites.
611
+ ['the founding round is computed from the stored count (never reset)', 'const startRound = (rounds.get(member.id) || 0) + 1'],
612
+ ['the founding round is applied only AFTER a successful start', 'rounds.set(member.id, startRound)'],
613
+ // The 2026-09 field test shipped a framework whose every member brief named the WRONG
614
+ // member. These gates keep the structural fixes in place, and the companion
615
+ // prompt-v5-integrity.test.mjs asserts the TEXT those fixes produce.
616
+ ['the status block takes the member it describes', 'function briefBlock(member, roundNo) {'],
617
+ ['an unknown identity fails loudly instead of being guessed', "throw v5err('V5_INTERNAL', 'briefBlock: a member is required"],
618
+ ['the status block is built from that member, never a global', 'function stateBlock(member, roundNo) {\n return briefBlock(member, roundNo)\n }'],
619
+ ['the founding prompt is told the round it is starting (never a stored 0)', 'const prompt = initialPrompt(member, initialTask, mode, startRound)'],
620
+ ['the joiner is committed to the roster BEFORE its brief is built', "member.phase = 'active'\n member.childId = ''\n await putMember(member)"],
621
+ ['the charter is frozen at hire and reused on resume', 'const persona = member.persona || memberPersona(member)'],
622
+ ['a rebuilt session is framed as a rebuild', "const resume = mode === 'resume'"],
623
+ ['leadership text follows the live roster', 'function academicianId() {'],
624
+ ['no charter invents a leader when there is none', "本所当前**没有在册院士**"],
625
+ ['the office resolves as the office, never as a guessed member', "try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }"],
626
+ ['the mailbox is acked only AFTER the wake actually succeeded', "if (ok && prompt.pending.length) await ackPending(prompt.pending)"],
627
+ // F2: the three REAL mailbox invariants, each at its own source anchor. `inFlightMessages` is a DSH
628
+ // mailbox internal that v5 does NOT have (dedupe is `delivered` + the per-round injection marks), so
629
+ // no gate may reference it.
630
+ ['the acknowledgement is gated by the wake result AND a non-empty pending list', 'const ok = await wakeMember(member, prompt.text, kind)'],
631
+ ['the delivered ledger is CAPPED (oldest evicted, never unbounded)', 'const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered'],
632
+ ['a prompt prepends only messages not already prepended in this round', 'const fresh = pending.filter((p) => !seen.has(p.id))'],
633
+ ['the per-round injection mark is cleared with every concluded attempt', 'inboxInjected.delete(member.id)'],
634
+ ['a prepended inbox suppresses the base block (exactly one inbox section per prompt)', "const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)"],
635
+ ['framework feedback has its own sender (never a self-message)', "return await say('framework', { to: memberId, kind: 'notice', text: String(text) })"],
636
+ ['an assignment is framed by its true origin', "if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text"],
637
+ ['a nudge is framed as supervision, not as an assignment', "to, kind: 'nudge',"],
638
+ ['a voters-only broadcast is framed as such', "if (m.kind === 'voters') return '【研究所·致全体表决者 from ' + m.from + '】' + m.text"],
639
+ ['relayed messages carry their true sender', "await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })"],
640
+ ['a member that failed to provision stays visible', "b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))"],
641
+ ['the objection channel is documented in the reply spec', '"reject_assign": {"task_id":"t-3"'],
642
+ ['the task-done channel is documented in the reply spec', '"task_done": "t-3",'],
643
+ ['the meeting input channel is documented in the reply spec', '"input": "本轮会议/辩论的发言正文'],
644
+ ['the work push is paced, not an unbounded loop', 'if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue'],
645
+
646
+ // ── LEAN FORMAL VERIFICATION GATES (docs/formal-verification.md) ─────────
647
+ ['the formal-verification mode defaults to off', "formalVerify: 'off',"],
648
+ ['an unknown mode degrades to off, never to a stronger mode', "indexOf(out.formalVerify) !== -1 ? out.formalVerify : 'off'"],
649
+ ['the mode is read at prompt-build time (never frozen into the charter)', 'const formalOn = () => formalMode()'],
650
+ ['a passing run — not merely an archived file — is what makes an object passed', 'const formalGateOk = (rec) => !!rec && (rec.status === \'passed\' || rec.status === \'blocked\')'],
651
+ ['a passing Lean run switches the review subject to fidelity', '你的任务是**忠实性审查**'],
652
+ ['the voting prompt tells voters not to re-derive once a proof exists', '**你不需要重新检查推导**'],
653
+ ['require mode withholds a verdict without a formal record', 'if (formalMode() === \'require\' && !formalGateOk(rec)) {'],
654
+ ['a withheld verdict is recorded as undecided with a machine-readable reason', 'formal-required:尚未取得 Lean 形式化通过'],
655
+ ['the withheld object goes on a formalization TODO', "'Formal/TODO.md'"],
656
+ ['a blocker record must carry a reason', "if (!note) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '阻塞记录必须写明原因(note)"],
657
+ ['the proof of an object is archived under Verified/Lean/', "await writeTextRel('Verified/Lean/' + target + '.lean', body)"],
658
+ ['reusable definitions live in the GLOBAL cross-project library', 'const okWrite = await writeTextAbs(instRootless(rel), body)'],
659
+ ['the Lean path guard normalises .. (no string-prefix traversal hole)', 'const norm = normalizeAbsPath(abs)'],
660
+ ['a missing toolchain degrades to a readable result, not a crash', "code: 'LEAN_NOT_FOUND'"],
661
+ ['the formal record is durable state (folded into State/<institute>.v5state.json)', "formal: 'vibe5/formal',"],
662
+ ['the state block advertises the formalization counts', "b.push('[形式化] ' + (formalMode()"],
663
+ ['the reply contract carries the difficulty judgement', "L.push(' \"formal\": {"],
664
+ ]
665
+ for (const [label, needle] of GATES) {
666
+ if (!raw.includes(needle)) findings.push('philosophy gate missing from the implementation: ' + label)
667
+ }
668
+ notes.push('philosophy gates checked: ' + GATES.length)
669
+ gateCount = GATES.length
670
+
671
+ // The prompt corpus is a SHIPPED deliverable, not a build artifact: a human must be able
672
+ // to read the exact text every member receives without decoding session logs.
673
+ const REPO_ROOT = new URL('../', import.meta.url)
674
+ const needFiles = [
675
+ ['the prompt-integrity suite is shipped', 'tests/prompt-v5-integrity.test.mjs'],
676
+ ['the machine-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.json'],
677
+ ['the human-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.md'],
678
+ ['the persona-surface suite is shipped', 'tests/audit-persona-surface.test.mjs'],
679
+ ['the four-preset persona corpus is shipped (machine-readable)', 'prompt-corpus-persona/persona-corpus.json'],
680
+ ['the four-preset persona corpus is shipped (human-readable)', 'prompt-corpus-persona/persona-corpus.md'],
681
+ ]
682
+ for (const [label, rel] of needFiles) {
683
+ let ok = false
684
+ try { ok = existsSync(new URL(rel, REPO_ROOT)) } catch (e) { ok = false }
685
+ if (!ok) findings.push('missing shipped prompt-correctness artifact: ' + label + ' (' + rel + ')')
686
+ }
687
+ notes.push('prompt-correctness artifacts checked: ' + needFiles.length)
688
+
689
+ // The corpus must actually contain the interactions a reviewer needs to see, and must
690
+ // never contain a wrong-identity brief.
691
+ try {
692
+ const corpusPath = new URL('prompt-corpus-v5/prompt-corpus-v5.json', REPO_ROOT)
693
+ const c = JSON.parse(readRaw('prompt-corpus-v5/prompt-corpus-v5.json'))
694
+ const kinds = new Set((c.prompts || []).map(p => p.kind))
695
+ const need = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
696
+ 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
697
+ 'inbox-office', 'inbox-assign', 'inbox-nudge', 'inbox-office-assign', 'inbox-office-nudge',
698
+ 'notice', 'notice-claim', 'after-failure',
699
+ // the Lean formal-verification interaction must be reviewable by a human too — including
700
+ // the `require` gate wording and the state a member sees AFTER a fidelity defect withdrew
701
+ // a proof (both were missing from the first corpus, so nobody could read them).
702
+ 'lean-work', 'lean-verify', 'lean-fidelity', 'lean-require', 'lean-after-defect']
703
+ for (const k of need) if (!kinds.has(k)) findings.push('the prompt corpus is missing a ' + k + ' prompt')
704
+ const all = (c.prompts || []).map(p => p.prompt + '\n' + (p.charter || '')).join('\n')
705
+ if (/你是 \?/.test(all)) findings.push('the prompt corpus contains a wrong-identity "你是 ?" brief')
706
+ if (/\[状态\][^\n]*你是\s+(\S+?)[^\n]*\n/.test(all)) {
707
+ // every [状态] line must name a real member id, never a placeholder
708
+ for (const m of all.match(/\[状态\][^\n]*/g) || []) {
709
+ const id = (/\[状态\]\s*你是\s+(\S+?)(/.exec(m) || [])[1]
710
+ if (!id || id === '?' || id === 'undefined') findings.push('the prompt corpus has a bad identity line: ' + m.slice(0, 60))
711
+ }
712
+ }
713
+ notes.push('prompt corpus: ' + (c.total || 0) + ' prompts, ' + kinds.size + ' kinds')
714
+ } catch (e) {
715
+ findings.push('the prompt corpus could not be read/parsed: ' + String((e && e.message) || e))
716
+ }
717
+
718
+ // The persona surface of ALL FOUR presets is a shipped prompt-correctness artifact too:
719
+ // it is the only prompt the MAIN agent receives, and no e2e suite loads the YAML, so a
720
+ // human reviewer needs the corpus on disk (generated by audit-persona-surface.test.mjs).
721
+ try {
722
+ const pc = JSON.parse(readRaw('prompt-corpus-persona/persona-corpus.json'))
723
+ for (const d of ['vibe-math-v2', 'vibe-math-v3', 'vibe-math-v4', 'vibe-math-v5']) {
724
+ if (!(pc.presets || []).some((p) => p.preset === d)) findings.push('the persona corpus is missing preset ' + d)
725
+ }
726
+ const emptyBlocks = (pc.presets || []).filter((p) => !p.prefix || !p.text).map((p) => p.preset)
727
+ if (emptyBlocks.length) findings.push('the persona corpus has an empty persona block: ' + emptyBlocks.join(', '))
728
+ notes.push('persona corpus: ' + (pc.presets || []).length + ' presets')
729
+ } catch (e) {
730
+ findings.push('the persona corpus could not be read/parsed: ' + String((e && e.message) || e))
731
+ }
732
+ }
733
+
734
+ // ---- scanner self-check -------------------------------------------------
735
+ // The whole audit rests on stripNoise(); a scanner that mis-lexes silently BLINDS it (that is how the
736
+ // regex-with-a-quote bug lived here). Two checks, both cheap and both measured to be sensitive:
737
+ // · the stripped source must still PARSE (the pre-fix scanner produced a SyntaxError);
738
+ // · a fixture with a quoted character class + a following comment must survive intact.
739
+ {
740
+ const tmp = mkdtempSync(join(tmpdir(), 'v5-integrity-strip-'))
741
+ try {
742
+ const f = join(tmp, 'v5.mjs')
743
+ writeFileSync(f, src)
744
+ const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' })
745
+ if (r.status !== 0) {
746
+ findings.push('stripNoise() corrupted the source it scans (the stripped text does not parse) — every identifier check below is unreliable: ' +
747
+ String(r.stderr || '').split('\n').filter((l) => l.trim()).slice(-2).join(' ').slice(0, 160))
748
+ }
749
+ const fixture = [
750
+ 'function f(s) { return s.replace(/["\']/g, "-") }',
751
+ '// phantom_call( must not survive as a call site',
752
+ 'const div = 6 / 2',
753
+ ].join('\n')
754
+ const stripped = stripNoise(fixture)
755
+ if (stripped.includes('phantom_call')) findings.push('stripNoise() left a comment in the code stream (a phantom call site would be reported)')
756
+ if (!/'/.test(stripped)) findings.push('stripNoise() dropped a value placeholder')
757
+ // The session-API scan must ignore a SHADOWING `function(s)` parameter while still seeing the
758
+ // real arrow handlers. Without this, either the false `s.trim()` finding comes back or the
759
+ // scope skip silently blinds every tool-handler call — both are failures worth failing on.
760
+ const scopeFixture = [
761
+ 'const names = m[1].split(",").map(function(s){ return s.trim() })', // local string param
762
+ 'registerTool("t", "d", {}, (s, a) => s.resume())', // the session API
763
+ ].join('\n')
764
+ const spans = localSParamBodySpans(scopeFixture)
765
+ const idxShadow = scopeFixture.indexOf('s.trim()')
766
+ const idxApi = scopeFixture.indexOf('s.resume()')
767
+ if (!spans.some(([a, b]) => idxShadow >= a && idxShadow <= b)) findings.push('the session-API scan would report a PHANTOM finding for a shadowing `function(s)` parameter (s.trim())')
768
+ if (spans.some(([a, b]) => idxApi >= a && idxApi <= b)) findings.push('the session-API scan would SKIP a real arrow tool handler (s.resume() was classified as shadowed)')
769
+ notes.push('scanner self-check: stripped output parses=' + (r.status === 0) + '; quoted-class fixture=' + (!stripped.includes('phantom_call')) +
770
+ '; shadowed-param skip=' + spans.length)
771
+ } finally {
772
+ rmSync(tmp, { recursive: true, force: true })
773
+ }
774
+ }
775
+
776
+ // ---- report ------------------------------------------------------------
777
+ // `--json` is the machine-readable contract the `--self-probe` children parse (same shape as
778
+ // audit-prompt-invariants.mjs): { gates, failed, findings, notes }. It is emitted BEFORE the human
779
+ // output so a child run's stdout is parseable in one piece.
780
+ if (process.argv.includes('--json')) {
781
+ console.log(JSON.stringify({ gates: gateCount, failed: findings.length, findings, notes }))
782
+ for (const f of findings) console.error(' FINDING: ' + f)
783
+ process.exit(findings.length ? 1 : 0)
784
+ }
785
+ console.log('-- V5 integrity audit --')
786
+ for (const n of notes) console.log(' note: ' + n)
787
+ console.log('')
788
+ if (findings.length) {
789
+ for (const f of findings) console.error(' FINDING: ' + f)
790
+ console.error('')
791
+ console.error(findings.length + ' finding(s)')
792
+ process.exit(1)
793
+ }
794
+ console.log('clean: no undefined calls, no undeclared params keys, no missing session API, no leftover markers')
795
+ process.exit(0)