dsh-vibe-math 2.0.22 → 2.2.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.
@@ -0,0 +1,343 @@
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 audit-v5-integrity.mjs (exit 1 on any finding)
11
+ // ============================================================
12
+ import { readFileSync, existsSync } from 'node:fs'
13
+
14
+ const FILE = new URL('./vibe-math-v5/vibe-math-v5.js', import.meta.url)
15
+ const raw = readFileSync(FILE, 'utf8')
16
+ // Strip comments and string literals before any identifier scan. Without this the
17
+ // heuristic matches English words inside comments that merely precede a '(' (e.g.
18
+ // "// per unit (" becomes a phantom call to unit()), drowning the real findings.
19
+ function stripNoise(s) {
20
+ let out = ''
21
+ let i = 0
22
+ const n = s.length
23
+ while (i < n) {
24
+ const c = s[i], c2 = s[i + 1]
25
+ if (c === '/' && c2 === '/') { while (i < n && s[i] !== '\n') i++; continue }
26
+ if (c === '/' && c2 === '*') { i += 2; while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++; i += 2; continue }
27
+ if (c === "'" || c === '"' || c === '`') {
28
+ const q = c
29
+ i++
30
+ while (i < n) {
31
+ if (s[i] === '\\') { i += 2; continue }
32
+ if (s[i] === q) { i++; break }
33
+ if (q !== '`' && s[i] === '\n') break
34
+ i++
35
+ }
36
+ out += q + q // keep a placeholder so `X: ''` shape survives
37
+ continue
38
+ }
39
+ out += c
40
+ i++
41
+ }
42
+ return out
43
+ }
44
+ const src = stripNoise(raw)
45
+ const findings = []
46
+ const notes = []
47
+
48
+ const lineOf = (idx) => src.slice(0, idx).split('\n').length
49
+
50
+ // ---- 1. called-but-undefined -------------------------------------------
51
+ const defined = new Set()
52
+ for (const m of src.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) defined.add(m[1])
53
+ for (const m of src.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) defined.add(m[1])
54
+ for (const m of src.matchAll(/(?:const|let|var)\s+\{([^}]+)\}\s*=/g)) {
55
+ for (const part of m[1].split(',')) { const n = part.split(':').pop().trim(); if (n) defined.add(n) }
56
+ }
57
+ for (const m of src.matchAll(/catch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) defined.add(m[1])
58
+ // Function/method PARAMETERS are locally bound identifiers too, not free calls.
59
+ for (const m of src.matchAll(/function\s*[A-Za-z_$]*\s*\(([^)]*)\)/g)) {
60
+ for (const raw of m[1].split(',')) {
61
+ const name = raw.trim().replace(/[={].*$/s, '').trim()
62
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
63
+ }
64
+ }
65
+ for (const m of src.matchAll(/\(([^()]*)\)\s*=>/g)) {
66
+ for (const raw of m[1].split(',')) {
67
+ const name = raw.trim().replace(/[={].*$/s, '').trim()
68
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) defined.add(name)
69
+ }
70
+ }
71
+ for (const m of src.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) defined.add(m[1])
72
+ // JS/DSH globals that are legitimately free
73
+ const GLOBALS = new Set([
74
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'function', 'new', 'await', 'delete', 'void', 'do', 'else', 'try',
75
+ 'Array', 'Object', 'JSON', 'Number', 'String', 'Boolean', 'Math', 'Date', 'Promise', 'Set', 'Map', 'WeakRef', 'WeakMap', 'Symbol', 'Error',
76
+ 'isFinite', 'isNaN', 'parseInt', 'parseFloat', 'encodeURIComponent', 'decodeURIComponent', 'structuredClone',
77
+ 'AbortSignal', 'console', 'require', 'import', 'super', 'this', 'of', 'in', 'instanceof',
78
+ 'RegExp', 'Proxy', 'Reflect', 'AggregateError', 'TextEncoder', 'TextDecoder', 'URL', 'BigInt', 'Intl', 'Buffer', 'process',
79
+ ])
80
+ const LOCAL_METHODS = new Set()
81
+ for (const m of src.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*[:(]/g)) LOCAL_METHODS.add(m[1])
82
+ for (const m of src.matchAll(/\.\s*([A-Za-z_$][\w$]*)\s*\(/g)) LOCAL_METHODS.add(m[1])
83
+
84
+ const called = new Map()
85
+ for (const m of src.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
86
+ const name = m[1]
87
+ if (GLOBALS.has(name) || LOCAL_METHODS.has(name)) continue
88
+ if (!called.has(name)) called.set(name, { n: 0, line: lineOf(m.index) })
89
+ called.get(name).n++
90
+ }
91
+ for (const [name, info] of called) {
92
+ if (!defined.has(name)) findings.push('line ' + info.line + ': called but never defined: ' + name + '() (' + info.n + ' call site(s))')
93
+ }
94
+
95
+ // ---- 2. params keys ----------------------------------------------------
96
+ const dpBlock = /const DEFAULT_PARAMS = \{([\s\S]*?)\n \}/.exec(src)
97
+ if (!dpBlock) findings.push('could not locate DEFAULT_PARAMS')
98
+ else {
99
+ const declared = new Set()
100
+ for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared.add(m[1])
101
+ const used = new Map()
102
+ for (const m of src.matchAll(/\bparams\.([A-Za-z_$][\w$]*)/g)) used.set(m[1], (used.get(m[1]) || 0) + 1)
103
+ for (const [k, n] of used) {
104
+ if (!declared.has(k)) findings.push('params.' + k + ' read but not declared in DEFAULT_PARAMS (' + n + ' use(s))')
105
+ }
106
+ notes.push('DEFAULT_PARAMS keys: ' + declared.size + '; params.* reads: ' + used.size)
107
+ }
108
+
109
+ // ---- 3. session-API surface used by tool handlers -----------------------
110
+ // The returned API object is the last `return { ... }` inside makeSession.
111
+ const apiStart = src.lastIndexOf(' return {\n sessionId,')
112
+ if (apiStart === -1) findings.push('could not locate the session API return object')
113
+ else {
114
+ const apiEnd = src.indexOf('\n }\n }', apiStart)
115
+ const apiText = src.slice(apiStart, apiEnd === -1 ? apiStart + 4000 : apiEnd)
116
+ const apiKeys = new Set()
117
+ for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*:/g)) apiKeys.add(m[1])
118
+ for (const m of apiText.matchAll(/(?:^|[\s{,])([A-Za-z_$][\w$]*)\s*,/g)) apiKeys.add(m[1])
119
+ const usedOnS = new Map()
120
+ for (const m of src.matchAll(/(?<![\w$.])s\.([A-Za-z_$][\w$]*)\s*\(/g)) {
121
+ if (!usedOnS.has(m[1])) usedOnS.set(m[1], lineOf(m.index))
122
+ }
123
+ for (const [k, line] of usedOnS) {
124
+ if (!apiKeys.has(k)) findings.push('line ' + line + ': tool handler calls s.' + k + '() but the session API does not export it')
125
+ }
126
+ notes.push('session API keys: ' + apiKeys.size + '; s.*() called: ' + usedOnS.size)
127
+ }
128
+
129
+ // ---- 4. error codes ----------------------------------------------------
130
+ // Scan the RAW source: these patterns live inside string literals, which `src` strips.
131
+ const raised = new Set()
132
+ for (const m of raw.matchAll(/v5err\(\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
133
+ for (const m of raw.matchAll(/code:\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
134
+ const docs = readFileSync(new URL('./vibe-math-v5/实现方案.md', import.meta.url), 'utf8')
135
+ const documented = new Set()
136
+ for (const m of docs.matchAll(/`(V5_[A-Z_]+)`/g)) documented.add(m[1])
137
+ const onlyDocs = new Set()
138
+ for (const m of docs.matchAll(/(V5_[A-Z_]+)/g)) onlyDocs.add(m[1])
139
+ const raisedNotDocumented = [...raised].filter(c => !onlyDocs.has(c))
140
+ const documentedNotRaised = [...documented].filter(c => !raised.has(c))
141
+ if (raisedNotDocumented.length) findings.push('error codes raised but absent from 实现方案.md: ' + raisedNotDocumented.join(', '))
142
+ if (documentedNotRaised.length) notes.push('documented but not raised in code (ok if advisory): ' + documentedNotRaised.join(', '))
143
+ notes.push('error codes raised: ' + raised.size)
144
+
145
+ // ---- 5. leftover scaffolding -------------------------------------------
146
+ for (const bad of ['@@V5_SECTION@@', 'TODO', 'FIXME', 'XXX', 'PLACEHOLDER']) {
147
+ if (src.includes(bad)) findings.push('leftover development marker: ' + bad)
148
+ }
149
+ // every event type declared in EV must be appended somewhere
150
+ const evBlock = /const EV = \{([\s\S]*?)\n\}/.exec(raw)
151
+ if (evBlock) {
152
+ const keys = [...evBlock[1].matchAll(/([A-Za-z]+):\s*'(vibe5\/[a-z]+)'/g)]
153
+ for (const [, prop, literal] of keys) {
154
+ const uses = (raw.match(new RegExp("EV\\." + prop + "\\b", 'g')) || []).length
155
+ if (uses <= 1) findings.push('event type EV.' + prop + ' (' + literal + ') is declared but never committed')
156
+ }
157
+ notes.push('event types declared: ' + keys.length)
158
+ }
159
+
160
+ // ---- 6. composition sanity --------------------------------------------
161
+ // The v5 preset is a composition, and a typo in a row `name` is a mounting failure that
162
+ // no unit test of the plugin can catch (the mock calls apply() directly and never goes
163
+ // through the loader). v4 is a proven-good composition mounted on the same hosts, so any
164
+ // package row v5 names that v4 does not is either a genuine new dependency or a typo —
165
+ // and a new dependency must be justified, so surface it for review.
166
+ function packageRows(yaml) {
167
+ const rows = []
168
+ const lines = yaml.split('\n')
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const idm = /^\s*-\s*id:\s*(\S+)\s*$/.exec(lines[i])
171
+ if (!idm) continue
172
+ const row = { id: idm[1], name: '', disabled: false, line: i + 1 }
173
+ for (let j = i + 1; j < lines.length && j < i + 12; j++) {
174
+ if (/^\s*-\s*id:\s*\S+\s*$/.test(lines[j])) break
175
+ const nm = /^\s*name:\s*'?([^'\n]+?)'?\s*$/.exec(lines[j])
176
+ if (nm && !row.name) row.name = nm[1].trim()
177
+ if (/^\s*disabled:\s*true/.test(lines[j])) row.disabled = true
178
+ }
179
+ if (row.name) rows.push(row)
180
+ }
181
+ return rows
182
+ }
183
+ const v5yaml = readFileSync(new URL('./vibe-math-v5/agent.cordis.yml', import.meta.url), 'utf8')
184
+ const v4yaml = readFileSync(new URL('./vibe-math-v4/agent.cordis.yml', import.meta.url), 'utf8')
185
+ const v5rows = packageRows(v5yaml)
186
+ const v4names = new Set(packageRows(v4yaml).map(r => r.name))
187
+ const RELATIVE_OK = new Set(['./vibe-math-v5.js'])
188
+ if (!v5rows.length) findings.push('composition: no rows parsed out of agent.cordis.yml (is the file still YAML?)')
189
+ for (const r of v5rows) {
190
+ if (r.name === 'cordis:group') continue
191
+ if (r.name.startsWith('./')) {
192
+ 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')
193
+ continue
194
+ }
195
+ 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')
196
+ }
197
+ // the preset must reference its own plugin row, and that file must exist
198
+ if (!v5rows.some(r => r.name === './vibe-math-v5.js')) findings.push('composition: the preset never mounts ./vibe-math-v5.js')
199
+ // prefix AND text are required on the persona row for the DSH schema and for back-compat
200
+ const personaBlock = /- id:\s*persona[\s\S]*?(?=\n-\s*id:)/.exec(v5yaml)
201
+ if (!personaBlock) findings.push('composition: no persona row found')
202
+ else {
203
+ if (!/^\s*prefix:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the required `prefix` key')
204
+ if (!/^\s*text:\s*\|/m.test(personaBlock[0])) findings.push('composition: persona row lacks the legacy `text` key')
205
+ }
206
+ 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)
207
+
208
+ // ---- 7. requirements traceability against the plan ---------------------
209
+ // "No missing logic" is only checkable mechanically if the SPEC is machine-readable.
210
+ // The plan's §15 names every tool and §16 every parameter, so compare those sets
211
+ // against the implementation: a name in the plan but not the code is an unimplemented
212
+ // requirement, and a name in the code but not the plan is undocumented surface.
213
+ {
214
+ const plan = readFileSync(new URL('./vibe-math-v5/实现方案.md', import.meta.url), 'utf8')
215
+ const planTools = new Set()
216
+ for (const m of plan.matchAll(/\bvibe_v5_[a-z_]+/g)) planTools.add(m[0])
217
+ const codeTools = new Set()
218
+ for (const m of raw.matchAll(/registerTool\(\s*'(vibe_v5_[a-z_]+)'/g)) codeTools.add(m[1])
219
+ // documented-but-wildcarded placeholders are not real tools
220
+ const IGNORE = new Set(['vibe_v5_', 'vibe_v5_record_'])
221
+ for (const t of planTools) {
222
+ if (IGNORE.has(t)) continue
223
+ if (!codeTools.has(t)) findings.push('plan names tool ' + t + ' but the plugin never registers it')
224
+ }
225
+ const undocumented = [...codeTools].filter(t => !planTools.has(t))
226
+ if (undocumented.length) notes.push('tools registered but not named in the plan: ' + undocumented.join(', '))
227
+ notes.push('plan tools: ' + planTools.size + '; registered tools: ' + codeTools.size)
228
+
229
+ // Parameters: only the §16 default table, NOT the §15 tool tables (whose rows also
230
+ // begin with a backticked name).
231
+ const declared2 = new Set()
232
+ if (dpBlock) for (const m of dpBlock[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)) declared2.add(m[1])
233
+ const sec16 = /##\s*16\.[\s\S]*?(?=\n##\s*17\.)/.exec(plan)
234
+ const paramRows = new Set()
235
+ if (sec16) {
236
+ for (const m of sec16[0].matchAll(/^\|\s*`([A-Za-z][\w]*)`(?:\s*\/\s*`([A-Za-z][\w]*)`)?\s*\|/gm)) {
237
+ paramRows.add(m[1])
238
+ if (m[2]) paramRows.add(m[2])
239
+ }
240
+ } else findings.push('could not locate the plan\'s §16 parameter table')
241
+ for (const p of paramRows) {
242
+ if (!declared2.has(p)) findings.push('plan documents parameter `' + p + '` but DEFAULT_PARAMS does not declare it')
243
+ }
244
+ notes.push('plan §16 parameter rows: ' + paramRows.size)
245
+
246
+ // The plan's philosophy is enforced by concrete gates; assert the load-bearing ones
247
+ // still exist so a future edit cannot quietly drop a guard.
248
+ const GATES = [
249
+ ['temp workers cannot vote', "if (m.kind === 'temp') return // no vote"],
250
+ ['temp workers cannot hire', "if (caller.kind === 'temp') return { ok: false, code: 'V5_NOT_VOTER'"],
251
+ ['only the academician assigns', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks'"],
252
+ ['only the academician sets priorities', "code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities'"],
253
+ ['permanent-staff changes need the office', "code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)'"],
254
+ ['assignment objections are broadcast', 'if (p.reject_assign && typeof p.reject_assign === \'object\' && params.memberMayRejectAssign)'],
255
+ ['the academician has no extra vote weight', 'const E = voters().map((m) => m.id)'],
256
+ ['members may reject an assignment', "memberMayRejectAssign: true,"],
257
+ ['the charter states the progress definition', "' · Progress/<你>/progress.md —— **你的研究日志**(叙述体,可追加)。'"],
258
+ ['the charter describes the academician as organizer', "' 【四、你的组织职责与边界(院士)】'"],
259
+ ['the framework never assigns on its own', "agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?'"],
260
+
261
+ // ── PROMPT / INTERACTION CORRECTNESS GATES ─────────────────────────────
262
+ // The 2026-09 field test shipped a framework whose every member brief named the WRONG
263
+ // member. These gates keep the structural fixes in place, and the companion
264
+ // prompt-v5-integrity.test.mjs asserts the TEXT those fixes produce.
265
+ ['the status block takes the member it describes', 'function briefBlock(member) {'],
266
+ ['an unknown identity fails loudly instead of being guessed', "throw v5err('V5_INTERNAL', 'briefBlock: a member is required"],
267
+ ['the status block is built from that member, never a global', 'function stateBlock(member) {\n return briefBlock(member)\n }'],
268
+ ['the joiner is committed to the roster BEFORE its brief is built', "member.phase = 'active'\n member.childId = ''\n await putMember(member)"],
269
+ ['the charter is frozen at hire and reused on resume', 'const persona = member.persona || memberPersona(member)'],
270
+ ['a rebuilt session is framed as a rebuild', "const resume = mode === 'resume'"],
271
+ ['leadership text follows the live roster', 'function academicianId() {'],
272
+ ['no charter invents a leader when there is none', "本所当前**没有在册院士**"],
273
+ ['the office resolves as the office, never as a guessed member', "try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }"],
274
+ ['the mailbox is acked BEFORE the round prompt is built', 'if (pending.length) await ackPending(pending)\n const base = typeof baseFn === \'function\' ? baseFn() : baseFn'],
275
+ ['framework feedback has its own sender (never a self-message)', "return await say('framework', { to: memberId, kind: 'notice', text: String(text) })"],
276
+ ['an assignment is framed by its true origin', "if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text"],
277
+ ['a nudge is framed as supervision, not as an assignment', "to, kind: 'nudge',"],
278
+ ['a voters-only broadcast is framed as such', "if (m.kind === 'voters') return '【研究所·致全体表决者 from ' + m.from + '】' + m.text"],
279
+ ['relayed messages carry their true sender', "await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })"],
280
+ ['a member that failed to provision stays visible', "b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))"],
281
+ ['the objection channel is documented in the reply spec', '"reject_assign": {"task_id":"t-3"'],
282
+ ['the task-done channel is documented in the reply spec', '"task_done": "t-3",'],
283
+ ['the meeting input channel is documented in the reply spec', '"input": "本轮会议/辩论的发言正文'],
284
+ ['the work push is paced, not an unbounded loop', 'if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue'],
285
+ ]
286
+ for (const [label, needle] of GATES) {
287
+ if (!raw.includes(needle)) findings.push('philosophy gate missing from the implementation: ' + label)
288
+ }
289
+ notes.push('philosophy gates checked: ' + GATES.length)
290
+
291
+ // The prompt corpus is a SHIPPED deliverable, not a build artifact: a human must be able
292
+ // to read the exact text every member receives without decoding session logs.
293
+ const REPO_ROOT = new URL('.', import.meta.url)
294
+ const needFiles = [
295
+ ['the prompt-integrity suite is shipped', 'prompt-v5-integrity.test.mjs'],
296
+ ['the machine-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.json'],
297
+ ['the human-readable prompt corpus is shipped', 'prompt-corpus-v5/prompt-corpus-v5.md'],
298
+ ]
299
+ for (const [label, rel] of needFiles) {
300
+ let ok = false
301
+ try { ok = existsSync(new URL(rel, REPO_ROOT)) } catch (e) { ok = false }
302
+ if (!ok) findings.push('missing shipped prompt-correctness artifact: ' + label + ' (' + rel + ')')
303
+ }
304
+ notes.push('prompt-correctness artifacts checked: ' + needFiles.length)
305
+
306
+ // The corpus must actually contain the interactions a reviewer needs to see, and must
307
+ // never contain a wrong-identity brief.
308
+ try {
309
+ const corpusPath = new URL('prompt-corpus-v5/prompt-corpus-v5.json', REPO_ROOT)
310
+ const c = JSON.parse(readFileSync(corpusPath, 'utf8'))
311
+ const kinds = new Set((c.prompts || []).map(p => p.kind))
312
+ const need = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
313
+ 'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
314
+ 'inbox-office', 'inbox-assign', 'inbox-nudge', 'inbox-office-assign', 'inbox-office-nudge',
315
+ 'notice', 'notice-claim', 'after-failure']
316
+ for (const k of need) if (!kinds.has(k)) findings.push('the prompt corpus is missing a ' + k + ' prompt')
317
+ const all = (c.prompts || []).map(p => p.prompt + '\n' + (p.charter || '')).join('\n')
318
+ if (/你是 \?/.test(all)) findings.push('the prompt corpus contains a wrong-identity "你是 ?" brief')
319
+ if (/\[状态\][^\n]*你是\s+(\S+?)[^\n]*\n/.test(all)) {
320
+ // every [状态] line must name a real member id, never a placeholder
321
+ for (const m of all.match(/\[状态\][^\n]*/g) || []) {
322
+ const id = (/\[状态\]\s*你是\s+(\S+?)(/.exec(m) || [])[1]
323
+ if (!id || id === '?' || id === 'undefined') findings.push('the prompt corpus has a bad identity line: ' + m.slice(0, 60))
324
+ }
325
+ }
326
+ notes.push('prompt corpus: ' + (c.total || 0) + ' prompts, ' + kinds.size + ' kinds')
327
+ } catch (e) {
328
+ findings.push('the prompt corpus could not be read/parsed: ' + String((e && e.message) || e))
329
+ }
330
+ }
331
+
332
+ // ---- report ------------------------------------------------------------
333
+ console.log('-- V5 integrity audit --')
334
+ for (const n of notes) console.log(' note: ' + n)
335
+ console.log('')
336
+ if (findings.length) {
337
+ for (const f of findings) console.error(' FINDING: ' + f)
338
+ console.error('')
339
+ console.error(findings.length + ' finding(s)')
340
+ process.exit(1)
341
+ }
342
+ console.log('clean: no undefined calls, no undeclared params keys, no missing session API, no leftover markers')
343
+ process.exit(0)
@@ -0,0 +1,306 @@
1
+ // ============================================================
2
+ // V5 SENSITIVITY PROBES — prove the suites are not vacuous.
3
+ //
4
+ // Each probe copies vibe-math-v5.js, applies ONE targeted mutation that breaks a
5
+ // specific guarantee, and runs the referencing suite against the mutated copy. A probe
6
+ // PASSES when the suite goes RED (non-zero exit) — i.e. the assertions really do detect
7
+ // that break. A probe that stays green means the suite has a blind spot.
8
+ //
9
+ // The prompt-integrity block below exists because the 2026-09 field test found a bug
10
+ // that 123 tool-level assertions could not see: every member's brief named the WRONG
11
+ // member. Any guarantee about the TEXT a member reads must have a probe that proves
12
+ // prompt-v5-integrity.test.mjs detects its violation.
13
+ //
14
+ // Run: node audit-v5-sensitivity.mjs
15
+ // ============================================================
16
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { spawnSync } from 'node:child_process'
20
+ import { fileURLToPath } from 'node:url'
21
+
22
+ const SRC = new URL('./vibe-math-v5/vibe-math-v5.js', import.meta.url)
23
+ const TESTS = {
24
+ selfdrive: fileURLToPath(new URL('./selfdrive-v5.mjs', import.meta.url)),
25
+ round2: fileURLToPath(new URL('./e2e-v5-round2.test.mjs', import.meta.url)),
26
+ prompt: fileURLToPath(new URL('./prompt-v5-integrity.test.mjs', import.meta.url)),
27
+ }
28
+ const original = readFileSync(SRC, 'utf8')
29
+ const REPO = fileURLToPath(new URL('.', import.meta.url))
30
+ const dir = mkdtempSync(join(tmpdir(), 'v5-sens-'))
31
+
32
+ // Each probe: { name, ref, guarantee, from, to }
33
+ // `from` must occur exactly once, so a mutation can never quietly hit the wrong site.
34
+ const probes = [
35
+ {
36
+ // The m floor is enforced by THREE cooperating checks (an early `bTrue + bFalse < m`
37
+ // return, the conflict check, and a final `bTrue >= m`), so a single weakened check is
38
+ // masked by the others and is a SEMANTICALLY INERT mutation — it must never be used as
39
+ // a probe (it would look like a "blind spot"). Forcing the quorum itself to 1 breaks
40
+ // the rule on every path at once, which is exactly the guarantee under test.
41
+ name: 'quorum-forced-to-one',
42
+ ref: 'selfdrive-v5.mjs',
43
+ guarantee: '④ only >= m boolean votes may verify (m must be min(quorumCap, |voters|))',
44
+ from: " return Math.max(1, Math.min(cap, voterCount()))",
45
+ to: " return 1",
46
+ },
47
+ {
48
+ name: 'abstention-counts-as-true',
49
+ ref: 'selfdrive-v5.mjs',
50
+ guarantee: '⑤ an abstention must NOT count toward the quorum',
51
+ from: " else if (p === 0) bFalse += 1\n else abstain += 1",
52
+ to: " else if (p === 0) bFalse += 1\n else { abstain += 1; bTrue += 1 }",
53
+ },
54
+ {
55
+ // `judgeVerdict` checks the m floor, then the conflict, then the floor again, and each
56
+ // guard masks the next — so deleting a guard is inert. The behaviour-changing break is
57
+ // letting a CONFLICT pass as a verdict, which is what this mutation does.
58
+ name: 'conflict-allowed',
59
+ ref: 'selfdrive-v5.mjs',
60
+ guarantee: '④ a conflicting 1 vs 0 must BLOCK the verdict',
61
+ from: " return Object.assign(base, { outcome: 'undecided', reason: 'conflicting assertions (true=' + bTrue + ', false=' + bFalse + ')' })",
62
+ to: " return Object.assign(base, { outcome: 'true', reason: 'conflicting assertions (true=' + bTrue + ', false=' + bFalse + ')' })",
63
+ },
64
+ {
65
+ name: 'temp-worker-can-vote',
66
+ ref: 'selfdrive-v5.mjs',
67
+ guarantee: '⑦ a temp worker must NOT be able to vote',
68
+ from: " if (member.kind === 'temp') {\n // Temp workers have no vote",
69
+ to: " if (false) {\n // Temp workers have no vote",
70
+ },
71
+ {
72
+ name: 'academician-gate-removed',
73
+ ref: 'selfdrive-v5.mjs',
74
+ guarantee: '⑬ only the academician may assign tasks',
75
+ from: " return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks' }",
76
+ to: " return { ok: false, code: 'NO_GATE', message: 'x' }",
77
+ },
78
+ {
79
+ name: 'spawn-not-registered-inflight',
80
+ ref: 'selfdrive-v5.mjs',
81
+ guarantee: '⑯ the founding turn must be registered in-flight or its end is dropped',
82
+ from: " inflight.set(started.childId, shortId())",
83
+ to: "",
84
+ },
85
+ {
86
+ name: 'group-chat-not-fanned-out',
87
+ ref: 'selfdrive-v5.mjs',
88
+ guarantee: '③ group chat must reach every other member',
89
+ from: " if (to === 'all' || to === '') targets = activeMembers().filter((m) => m.id !== from)",
90
+ to: " if (to === 'all' || to === '') targets = activeMembers().filter((m) => m.id !== from).slice(0, 1)",
91
+ },
92
+ {
93
+ name: 'verify-round-advances-anyway',
94
+ ref: 'selfdrive-v5.mjs',
95
+ guarantee: '④ a round must not advance without every voter answering',
96
+ from: " const missing = need.filter((id) => !vs.votes[id])\n if (missing.length) {",
97
+ to: " const missing = need.filter((id) => !vs.votes[id])\n if (false) {",
98
+ },
99
+ {
100
+ name: 'fire-does-not-release',
101
+ ref: 'selfdrive-v5.mjs',
102
+ guarantee: '⑨ firing must really release the resident child',
103
+ from: " if (typeof subagents.drainContinuableChildren === 'function') await subagents.drainContinuableChildren(rootAgent, [target.childId])",
104
+ to: " if (false) await subagents.drainContinuableChildren(rootAgent, [target.childId])",
105
+ },
106
+ {
107
+ name: 'solve-vote-not-unanimous',
108
+ ref: 'selfdrive-v5.mjs',
109
+ guarantee: '⑥ the institute must not stop unless EVERY voter agrees',
110
+ from: " if (!vs.every((id) => solveVotes.get(id) === true)) return false",
111
+ to: " if (false) return false",
112
+ },
113
+ // ── probes added after the round-2 audit found these defects ──────────────
114
+ {
115
+ name: 'fallback-state-never-loaded',
116
+ ref: 'e2e-v5-round2.test.mjs',
117
+ guarantee: '⑰ the file fallback must LOAD persisted state before any read (restart safety)',
118
+ from: " if (backend.kind === 'file' && typeof backend.load === 'function') await backend.load()",
119
+ to: " if (false) await backend.load()",
120
+ },
121
+ {
122
+ name: 'solve-vote-never-re-evaluated',
123
+ ref: 'prompt-v5-integrity.test.mjs',
124
+ guarantee: '⑥ a solve vote landing OUTSIDE a meeting must still stop the institute',
125
+ // The anchor must include the CALL. An `if (false)` inserted before the comment would
126
+ // leave the real `await checkSolved()` below it untouched — an inert mutation that
127
+ // would masquerade as a blind spot.
128
+ from: " solveVotes.set(memberId, val === true)\n // Evaluate the stop condition on EVERY solve vote, not only when a meeting\n // finalizes. A vote that lands after the meeting closed — a late reply, or an\n // ordinary round carrying vote_solved — would otherwise be recorded and never\n // read, leaving a unanimously-concluded institute running forever.\n await checkSolved()",
129
+ to: " solveVotes.set(memberId, val === true)",
130
+ },
131
+ {
132
+ name: 'proposal-only-kicks-scheduler',
133
+ ref: 'selfdrive-v5.mjs',
134
+ guarantee: '④ a proposal must actually START, not just ask the scheduler to try',
135
+ from: " await armNextVerify()\n if (!hasVerifyInFlight()) await scheduleNext()",
136
+ to: " await scheduleNext()",
137
+ },
138
+ {
139
+ name: 'begin-not-exclusive',
140
+ ref: 'prompt-v5-integrity.test.mjs',
141
+ guarantee: '⑱ a second proposal must QUEUE, never start a concurrent ballot',
142
+ from: " if (beginLock) return\n if (currentVerify()) return\n beginLock = true",
143
+ to: " beginLock = true",
144
+ },
145
+ // NOTE — a probe for `continueMeetingRound`'s `if (finalizeLock) { armHeartbeat(); return }`
146
+ // re-arm was REMOVED, not because the re-arm is unnecessary but because the state it
147
+ // guards is UNREACHABLE, so no black-box probe can detect its removal:
148
+ // · `schedulePass` handles a live meeting BEFORE it looks at a verification, and
149
+ // · `startMeeting` parks any meeting while `hasVerifyInFlight()`,
150
+ // so a meeting and a running verify settlement can never overlap. The re-arm stays in
151
+ // the code as cheap insurance against a future ordering change; leaving a probe that can
152
+ // never go red would be a false "detection" and is worse than no probe.
153
+ {
154
+ name: 'meeting-never-finalized',
155
+ ref: 'prompt-v5-integrity.test.mjs',
156
+ guarantee: '⑲ a meeting that collected every input must actually finalize and write its minutes',
157
+ from: " finalizeLock = 'meeting'\n try {\n await finalizeMeeting(meeting)",
158
+ to: " finalizeLock = 'meeting'\n try {\n meeting = null",
159
+ },
160
+
161
+ // ── PROMPT / INTERACTION INTEGRITY PROBES ────────────────────────────────
162
+ // These are the probes that would have caught the 2026-09 field-test bug. Every one
163
+ // of them breaks something a member READS (identity, roster, framing, persona), and
164
+ // prompt-v5-integrity.test.mjs must go RED for each.
165
+ {
166
+ name: 'brief-names-the-last-woken-member',
167
+ ref: 'prompt-v5-integrity.test.mjs',
168
+ guarantee: '⑳ a prompt\'s [状态] block must name the member it is sent to (the exact field bug: briefs named the previous member)',
169
+ from: " function stateBlock(member) {\n return briefBlock(member)\n }",
170
+ to: " function stateBlock(member) {\n return briefBlock(memberById(currentMember) || activeMembers()[0] || member)\n }",
171
+ },
172
+ {
173
+ name: 'joiner-absent-from-own-roster',
174
+ ref: 'prompt-v5-integrity.test.mjs',
175
+ guarantee: '⑳ a founding brief must show the roster INCLUDING its reader (roster committed before the prompt is built)',
176
+ from: " member.phase = 'active'\n member.childId = ''\n await putMember(member)",
177
+ to: " member.childId = ''\n await putMember(member)",
178
+ },
179
+ {
180
+ name: 'inbox-message-delivered-twice',
181
+ ref: 'prompt-v5-integrity.test.mjs',
182
+ guarantee: '㉑ one prompt must not deliver the same message twice',
183
+ from: " if (pending.length) await ackPending(pending)\n const base = typeof baseFn === 'function' ? baseFn() : baseFn",
184
+ to: " const base = typeof baseFn === 'function' ? baseFn() : baseFn\n if (pending.length) await ackPending(pending)",
185
+ },
186
+ {
187
+ name: 'charter-rewritten-on-resume',
188
+ ref: 'prompt-v5-integrity.test.mjs',
189
+ guarantee: '㉒ the charter is frozen at hire; a resume must not rewrite the induction snapshot',
190
+ from: " const persona = member.persona || memberPersona(member)",
191
+ to: " const persona = memberPersona(member)",
192
+ },
193
+ {
194
+ name: 'resume-framed-as-induction',
195
+ ref: 'prompt-v5-integrity.test.mjs',
196
+ guarantee: '㉓ a rebuilt session must not be told it just joined the institute',
197
+ from: " const resume = mode === 'resume'",
198
+ to: " const resume = false",
199
+ },
200
+ {
201
+ name: 'meeting-proposal-misattributed',
202
+ ref: 'prompt-v5-integrity.test.mjs',
203
+ guarantee: '㉔ a relayed message must carry its TRUE sender, never the last-woken member',
204
+ from: " await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })",
205
+ to: " await say(academicianId() || callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })",
206
+ },
207
+ {
208
+ name: 'office-impersonates-a-member',
209
+ ref: 'prompt-v5-integrity.test.mjs',
210
+ guarantee: '㉕ the office/host caller must resolve to the office, not to a guessed member',
211
+ // The FAITHFUL regression mutation: restore the old "answer with whoever this session
212
+ // woke last" fallback, which made the office's own assignments resolve to a random
213
+ // researcher and be refused as V5_NOT_ACADEMICIAN.
214
+ from: " try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }",
215
+ to: " if (currentMember && memberById(currentMember)) return currentMember",
216
+ },
217
+ {
218
+ name: 'office-assignment-framed-as-academician',
219
+ ref: 'prompt-v5-integrity.test.mjs',
220
+ guarantee: '㉖ an assignment must be framed by its true origin (office vs academician)',
221
+ from: " if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text",
222
+ to: " if (m.kind === 'assign') return '【院士分派】' + m.text",
223
+ },
224
+ {
225
+ name: 'nudge-mislabelled-as-assignment',
226
+ ref: 'prompt-v5-integrity.test.mjs',
227
+ guarantee: '㉗ a nudge is supervision, not an assignment, and must say so',
228
+ from: " to, kind: 'nudge',",
229
+ to: " to, kind: 'assign',",
230
+ },
231
+ {
232
+ name: 'framework-notice-sent-as-self-message',
233
+ ref: 'prompt-v5-integrity.test.mjs',
234
+ guarantee: '㉘ framework feedback must actually reach the member (not be refused as a self-message)',
235
+ from: " return await say('framework', { to: memberId, kind: 'notice', text: String(text) })",
236
+ to: " return await say(memberId, { to: memberId, kind: 'notice', text: String(text) })",
237
+ },
238
+ {
239
+ name: 'failed-member-hidden',
240
+ ref: 'prompt-v5-integrity.test.mjs',
241
+ guarantee: '㉙ a member that failed to provision must be visible in [未就位]',
242
+ from: " if (absent.length) b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))",
243
+ to: " if (false) b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))",
244
+ },
245
+ {
246
+ name: 'leaderless-charter-invents-a-leader',
247
+ ref: 'prompt-v5-integrity.test.mjs',
248
+ guarantee: '㉚ with academician:false no charter may name a leader who does not exist',
249
+ from: " const a = academicianId()\n const L = [",
250
+ to: " const a = 'acad'\n const L = [",
251
+ },
252
+ {
253
+ name: 'reply-spec-hides-the-objection-channel',
254
+ ref: 'prompt-v5-integrity.test.mjs',
255
+ guarantee: '㉛ every field the framework honours must be documented in the reply spec',
256
+ from: " L.push(' \"reject_assign\": {\"task_id\":\"t-3\",\"why\":\"你对这项分派的异议理由\"}",
257
+ to: " if (false) L.push(' \"reject_assign\": {\"task_id\":\"t-3\",\"why\":\"你对这项分派的异议理由\"}",
258
+ },
259
+ {
260
+ name: 'task-owner-rewoken-unpaced',
261
+ ref: 'prompt-v5-integrity.test.mjs',
262
+ guarantee: '㉜ a task owner must be pushed on a paced cadence, not in an unbounded tight loop',
263
+ from: " if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue",
264
+ to: " if (false) continue",
265
+ },
266
+ ]
267
+
268
+ let probesPassed = 0
269
+ let probesFailed = 0
270
+ console.log('-- V5 sensitivity probes --')
271
+ console.log('(a probe passes when breaking the guarantee turns the suite RED)')
272
+ console.log('')
273
+
274
+ for (const p of probes) {
275
+ const occurrences = original.split(p.from).length - 1
276
+ if (occurrences !== 1) {
277
+ console.error(' SETUP-FAIL - ' + p.name + ': anchor matched ' + occurrences + ' times (need exactly 1)')
278
+ probesFailed++
279
+ continue
280
+ }
281
+ const mutated = original.replace(p.from, p.to)
282
+ const file = join(dir, p.name + '.js')
283
+ writeFileSync(file, mutated, 'utf8')
284
+ const testPath = TESTS[p.ref === 'e2e-v5-round2.test.mjs' ? 'round2'
285
+ : p.ref === 'prompt-v5-integrity.test.mjs' ? 'prompt' : 'selfdrive']
286
+ const r = spawnSync(process.execPath, [testPath], {
287
+ env: Object.assign({}, process.env, { V5_PLUGIN: file }),
288
+ encoding: 'utf8',
289
+ cwd: REPO,
290
+ })
291
+ const red = r.status !== 0
292
+ if (red) {
293
+ probesPassed++
294
+ console.log(' ok - ' + p.name + ' [' + p.ref + '] => suite went RED as required [' + p.guarantee + ']')
295
+ } else {
296
+ probesFailed++
297
+ console.error(' BLIND SPOT - ' + p.name + ' [' + p.ref + '] => suite stayed GREEN, so it does NOT detect: ' + p.guarantee)
298
+ }
299
+ }
300
+
301
+ rmSync(dir, { recursive: true, force: true })
302
+ console.log('')
303
+ console.log('sensitivity: ' + probesPassed + ' probes detected the break, ' + probesFailed + ' blind spots')
304
+ if (probesFailed) process.exit(1)
305
+ console.log('ALL PROBES RED AS REQUIRED')
306
+ process.exit(0)