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,431 +1,431 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PERSONA SURFACE — prompt/allocation consistency for the four presets.
4
- *
5
- * DEFECT CLASS (why this guard exists; it belongs to the dual-persona-block parity family of §3)
6
- * "capability advertised but never granted, or granted but never advertised": the persona block is
7
- * the main agent view of the tool surface, so a registered-but-unnamed tool is invisible and a
8
- * named-but-unregistered tool is a trap. Every other suite calls apply(ctx) directly and never
9
- * parses the YAML, so this class cannot fail anywhere else.
10
- *
11
- * Run: node tests/audit-persona-surface.test.mjs
12
- *
13
- * WHY THIS EXISTS
14
- * The persona row of each `agent.cordis.yml` is the prompt the MAIN agent actually
15
- * receives, and it is the only place the main agent learns which `vibe_*` tools exist
16
- * and which parameters it may tune. Two failure modes are invisible to every other
17
- * suite, because those suites call `apply(ctx)` directly and never load the YAML:
18
- *
19
- * 1. a tool is registered but never named in the persona → the agent does not know
20
- * the capability exists (it cannot even guess the exact name);
21
- * 2. the persona names a tool that is NOT registered → the agent calls a tool
22
- * that will fail, and (in the worst case) is told to rely on it.
23
- *
24
- * The Lean formal-verification feature shipped with exactly this bug in three of four
25
- * presets: the three `*_lean_*` tools were registered unconditionally but the v2/v3/v4
26
- * personas never listed them (v5's did), and v4's `vibe_v4_set` parameter list omitted
27
- * `formalVerify`/`leanCommand`/`leanArgs`/`leanTimeoutMs`, so the coordinator could not
28
- * discover the switch at all. Nothing in the build noticed. Hence this guard.
29
- *
30
- * INVARIANTS (per preset)
31
- * A. the persona row carries both `prefix` and `text` literal blocks, and both parse
32
- * into a non-empty body (both keys are required for DSH schema/back-compat);
33
- * B. the two blocks are IDENTICAL apart from their first line — an old host that uses
34
- * `text` must not receive a different tool surface from a new host that uses `prefix`;
35
- * C. mentions ⊆ registered: every `vibe_*` token in the persona is a registered tool
36
- * (a `name*` mention is allowed when some registered tool carries that prefix);
37
- * D. every registered tool is either MENTIONED in the persona or consciously ALLOW-LISTED, and the
38
- * allow-list is itself validated (real tools only, empty by default) — so adding a tool forcing a
39
- * decision: name it, or add it to the list with a reason. (Earlier this was a snapshot equality,
40
- * which went red whenever a legitimately newly-named tool emptied the "undocumented" set.)
41
- * without documenting it (or deleting a tool the persona still advertises) fails
42
- * loudly and forces the author to make a decision;
43
- * E. the parameter surface of the Lean feature (the four names) and its semantics
44
- * (three modes, the fidelity switch, the project/global paths) appear in BOTH blocks.
45
- *
46
- * The snapshots below are the reviewed answer to "is this tool intentionally absent from
47
- * the coordinator's persona?" — virtually all of them are `(member)` / `(academician)` /
48
- * `(resident)` tools that only subagents call.
49
- */
50
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs'
51
- import { join, resolve } from 'node:path'
52
- import { fileURLToPath } from 'node:url'
53
-
54
- const ROOT = fileURLToPath(new URL('../', import.meta.url))
55
- /**
56
- * `PERSONA_ROOT` points the preset lookup at a prepared copy. It exists so that
57
- * `audit-persona-sensitivity.mjs` can prove this suite is not vacuous: each probe mutates a
58
- * copy of one persona (or one plugin) and requires this suite to go RED. Without the
59
- * override the mutation would never be loaded and every probe would be a false green.
60
- * `docs/formal-verification.md` is always read from the repository root, because the
61
- * shared contract is not part of a preset copy.
62
- */
63
- const BASE = process.env.PERSONA_ROOT ? resolve(process.env.PERSONA_ROOT) : ROOT
64
-
65
- let passed = 0
66
- let failed = 0
67
- const failures = []
68
- function ok(cond, msg) {
69
- if (cond) { passed++; return true }
70
- failed++
71
- failures.push(msg)
72
- return false
73
- }
74
- function eq(actual, expected, msg) {
75
- const a = JSON.stringify(actual)
76
- const e = JSON.stringify(expected)
77
- return ok(a === e, `${msg}\n expected: ${e}\n actual: ${a}`)
78
- }
79
-
80
- /**
81
- * Extract a YAML literal block scalar (`prefix: |-`) by indentation. Deliberately does
82
- * not depend on a YAML library: this file must run inside the published package, where
83
- * only the DSH host (not this repo) has one. The block's own indentation is stripped so
84
- * the `prefix` and `text` bodies can be compared line by line.
85
- */
86
- function literalBlocks(yml) {
87
- const out = {}
88
- const lines = yml.split(/\r?\n/)
89
- for (let i = 0; i < lines.length; i++) {
90
- const m = /^(\s*)([A-Za-z_][\w-]*):\s*\|-?\s*$/.exec(lines[i])
91
- if (!m) continue
92
- const ind = m[1].length
93
- const body = []
94
- for (let j = i + 1; j < lines.length; j++) {
95
- const L = lines[j]
96
- if (L.trim() === '') { body.push(''); continue }
97
- const li = L.length - L.trimStart().length
98
- if (li <= ind) break
99
- body.push(L.slice(ind + 2))
100
- }
101
- out[m[2]] = body
102
- }
103
- return out
104
- }
105
-
106
- /**
107
- * The slice of a plugin that describes and implements its `/vN` slash command: the
108
- * `commands.register({...})` block, plus — for v2/v3, which dispatch through a named function —
109
- * the `dispatchVibeCommand` body (that is where their branch list and usage string live).
110
- * The anchor includes the `{` on purpose: the files also mention `commands.register()` in a
111
- * prose comment *before* the real call, and anchoring on the call's object literal skips it.
112
- */
113
- function commandRegion(src) {
114
- const i = src.indexOf('commands.register({')
115
- if (i < 0) return null
116
- const end = src.indexOf('\n }))', i)
117
- let region = src.slice(i, end > i ? end : Math.min(src.length, i + 8000))
118
- if (!/cmd\s*===\s*'/.test(region)) {
119
- const d = src.indexOf('dispatchVibeCommand(cmd, args)')
120
- if (d >= 0) {
121
- const dEnd = src.indexOf('\n }', d)
122
- region += '\n' + src.slice(d, dEnd > d ? dEnd : d + 8000)
123
- }
124
- }
125
- return region
126
- }
127
-
128
- /** Split a `a|b <x|y>|c` advertised list into bare subcommand names. */
129
- function cmdsFrom(list) {
130
- const out = []
131
- const bare = String(list).replace(/\[[^\]]*\]/g, ' ').replace(/<[^>]*>/g, ' ')
132
- for (const tok of bare.split('|')) {
133
- const t = tok.trim()
134
- if (!t || t === '...') continue
135
- const name = t.split(/\s+/)[0]
136
- if (/^[a-z][a-z0-9-]*$/.test(name)) out.push(name)
137
- }
138
- return out
139
- }
140
-
141
- const PRESETS = [
142
- {
143
- dir: 'vibe-math-v2',
144
- js: 'vibe-math-v2.js',
145
- prefix: 'vibe_math_',
146
- tools: 28,
147
- // D. registered \ mentioned ⊆ allow-list: EVERY registered tool must be mentioned in the persona,
148
- // unless its name is listed here as a conscious exception (the allow-list is empty by default, so a
149
- // newly registered tool cannot slip in unnamed). An entry must be a REAL registered tool, and a
150
- // preset may also pin names that must be mentioned (never allow-listed).
151
- allowList: [],
152
- required: [],
153
- lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib', 'vibe_math_lean_read', 'vibe_math_lean_job'], extra: [] },
154
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
155
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
156
- math: { tools: ['math_computation'] },
157
- },
158
- {
159
- dir: 'vibe-math-v3',
160
- js: 'vibe-math-v3.js',
161
- prefix: 'vibe_math_',
162
- tools: 36,
163
- // `vibe_math_sync_meta` is named by the promotion-contract line, and the write-lock pair is now
164
- // named too (the lock is described in the persona, so leaving the two tools unmentioned was the
165
- // last hole). Both stay REQUIRED: they must be mentioned, never allow-listed.
166
- allowList: [],
167
- required: ['vibe_math_claim_write', 'vibe_math_release_write'],
168
- lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib', 'vibe_math_lean_read', 'vibe_math_lean_job'], extra: [] },
169
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
170
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
171
- math: { tools: ['math_computation'] },
172
- },
173
- {
174
- dir: 'vibe-math-v4',
175
- js: 'vibe-math-v4.js',
176
- prefix: 'vibe_v4_',
177
- tools: 35,
178
- // Member-facing tools (mail, library cards, task board, write lock) that the v4 persona describes
179
- // GENERICALLY rather than naming one by one; the coordinator drives residents through
180
- // vibe_v4_message / _meeting / _add_member. Allow-listed ON PURPOSE, so a NEWLY registered tool
181
- // still has to be named (or added here consciously).
182
- allowList: [
183
- 'vibe_v4_send_message', 'vibe_v4_publish_progress', 'vibe_v4_record_proposition',
184
- 'vibe_v4_record_method', 'vibe_v4_record_subproblem', 'vibe_v4_read_progress',
185
- 'vibe_v4_list_residents', 'vibe_v4_propose_task', 'vibe_v4_claim_task',
186
- 'vibe_v4_task_done', 'vibe_v4_list_tasks', 'vibe_v4_report_context',
187
- 'vibe_v4_claim_write', 'vibe_v4_release_write',
188
- ],
189
- required: [],
190
- lean: { tools: ['vibe_v4_lean_run', 'vibe_v4_lean_archive', 'vibe_v4_lean_lib', 'vibe_v4_lean_read', 'vibe_v4_lean_job'], extra: ['vibe_v4_formal_report'] },
191
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
192
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
193
- math: { tools: ['math_computation'] },
194
- },
195
- {
196
- dir: 'vibe-math-v5',
197
- js: 'vibe-math-v5.js',
198
- prefix: 'vibe_v5_',
199
- tools: 40,
200
- // Member- and academician-facing tools that the v5 persona describes GENERICALLY; the office (main
201
- // agent) holds only the institute-level controls plus the hiring authority. The final-paper pair is
202
- // an OFFICE control and IS named in the persona (vibe_v5_paper / vibe_v5_finalize_paper), so it must
203
- // NOT be allow-listed here. Allow-listed ON PURPOSE: a NEWLY registered tool still has to be named.
204
- allowList: [
205
- 'vibe_v5_wait', 'vibe_v5_record_progress', 'vibe_v5_record_proposition',
206
- 'vibe_v5_record_method', 'vibe_v5_record_subproblem', 'vibe_v5_read_library',
207
- 'vibe_v5_propose_verify', 'vibe_v5_verdict', 'vibe_v5_task_create',
208
- 'vibe_v5_task_list', 'vibe_v5_task_get', 'vibe_v5_task_update',
209
- 'vibe_v5_overview', 'vibe_v5_assign', 'vibe_v5_prioritize', 'vibe_v5_nudge',
210
- ],
211
- required: [],
212
- lean: { tools: ['vibe_v5_lean_run', 'vibe_v5_lean_archive', 'vibe_v5_lean_lib', 'vibe_v5_lean_read', 'vibe_v5_lean_job'], extra: [] },
213
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
214
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
215
- math: { tools: ['math_computation'] },
216
- },
217
- ]
218
-
219
- const LEAN_PARAMS = ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs', 'leanAsync', 'leanJobsMaxParallel', 'leanInitiative', 'leanSearchPaths']
220
-
221
- for (const P of PRESETS) {
222
- const ymlPath = join(BASE, P.dir, 'agent.cordis.yml')
223
- const jsPath = join(BASE, P.dir, P.js)
224
- const yml = readFileSync(ymlPath, 'utf8')
225
- const src = readFileSync(jsPath, 'utf8')
226
-
227
- // ---- registered tools -------------------------------------------------
228
- // Two registration shapes exist now:
229
- // 1. a literal `registerTool('<name>', …)` (most tools, including the shared math tool in
230
- // v2/v3, which register it by its frozen prefix-less name);
231
- // 2. the shared math tool in v4/v5, where the plugin only wires a host callback and the module
232
- // calls `host.register(MATH_TOOL_NAME, …)`. The module's name is FROZEN
233
- // (MATH_TOOL_NAME = 'math_computation', INTERFACE-FREEZE.md §3), so a source that wires
234
- // `registerMathComputation(` contributes exactly that one tool. Without this the shared tool
235
- // would be invisible to this guard on v4/v5 - a silent coverage hole.
236
- const registered = new Set()
237
- for (const m of src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)) registered.add(m[1])
238
- if (/registerMathComputation\s*\(/.test(src)) registered.add('math_computation')
239
- eq(registered.size, P.tools, `${P.dir}: registered tool count changed (update this snapshot deliberately)`)
240
- for (const tool of (P.math ? P.math.tools : [])) ok(registered.has(tool), `${P.dir}: the shared math tool ${tool} is not registered`)
241
-
242
- // ---- A. both literal blocks exist and are non-empty -------------------
243
- const blocks = literalBlocks(yml)
244
- const hasPersona = /^\s*-\s*id:\s*persona\s*$/m.test(yml)
245
- ok(hasPersona, `${P.dir}: no persona row in agent.cordis.yml`)
246
- for (const key of ['prefix', 'text']) {
247
- ok(Array.isArray(blocks[key]) && blocks[key].length > 0, `${P.dir}: persona.config.${key} is missing or not a literal block`)
248
- }
249
- if (!blocks.prefix || !blocks.text) { ok(false, `${P.dir}: cannot continue without both blocks`); continue }
250
-
251
- // ---- B. the two blocks must not drift --------------------------------
252
- const trimTail = (a) => { const b = a.slice(); while (b.length && b[b.length - 1] === '') b.pop(); return b }
253
- const p = trimTail(blocks.prefix)
254
- const t = trimTail(blocks.text)
255
- eq(p.length, t.length, `${P.dir}: prefix and text have different line counts`)
256
- const drift = []
257
- for (let i = 1; i < Math.max(p.length, t.length); i++) if (p[i] !== t[i]) drift.push(i)
258
- ok(drift.length === 0, `${P.dir}: prefix and text differ on line(s) ${drift.slice(0, 5).join(', ')} (only line 0 may differ)`)
259
- ok(/\{\{model\}\}/.test(p[0]) || /\{\{model\}\}/.test(t[0]), `${P.dir}: line 0 does not interpolate {{model}}`)
260
-
261
- // ---- C/D. mention <-> registry ----------------------------------------
262
- for (const key of ['prefix', 'text']) {
263
- const body = blocks[key].join('\n')
264
- const mentioned = new Set()
265
- const wildcards = new Set()
266
- // Uppercase is part of the token class on purpose: a drifted name such as
267
- // `vibe_v4_formal_reportX` must be captured whole and judged unregistered, instead of
268
- // being read as a mention of the registered `vibe_v4_formal_report`.
269
- // Names that do NOT carry this preset's prefix (the shared `math_computation`) are added to the
270
- // alternation from the registry, so "every registered tool must be mentioned" stays true for
271
- // them too instead of forcing them into the `undocumented` snapshot.
272
- const prefixRoot = P.prefix.replace(/_$/, '')
273
- const prefixless = [...registered].filter((n) => !new RegExp('^' + prefixRoot + '_').test(n))
274
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
275
- const re = new RegExp('\\b' + prefixRoot + '_[A-Za-z0-9_]+' + (prefixless.length ? '|\\b' + prefixless.map(escapeRe).join('\\b|\\b') + '\\b' : ''), 'g')
276
- for (const m of body.matchAll(re)) {
277
- if (body[m.index + m[0].length] === '*') { wildcards.add(m[0]); continue }
278
- mentioned.add(m[0])
279
- }
280
- const stale = [...mentioned].filter((n) => !registered.has(n)).sort()
281
- eq(stale, [], `${P.dir} [${key}]: persona advertises tool(s) that are not registered`)
282
- for (const w of wildcards) {
283
- ok([...registered].some((n) => n.startsWith(w)), `${P.dir} [${key}]: wildcard mention ${w}* matches no registered tool`)
284
- }
285
- const unmentioned = [...registered].filter((n) => !mentioned.has(n)).sort()
286
- // ALLOW-LIST design (replaces the old "equals a reviewed snapshot" equality): a registered tool may
287
- // be absent from the persona ONLY if its name is consciously allow-listed for this preset. The
288
- // allow-list is empty by default (v2/v3), so a newly registered tool turns this red until it is
289
- // either mentioned in the persona or deliberately added to the list with a reason.
290
- const allowed = new Set(P.allowList || [])
291
- const illegal = unmentioned.filter((n) => !allowed.has(n))
292
- ok(illegal.length === 0, `${P.dir} [${key}]: every registered tool is mentioned in the persona, or explicitly allow-listed`,
293
- 'unmentioned and not allow-listed: ' + JSON.stringify(illegal))
294
- const staleAllowed = [...allowed].filter((n) => !registered.has(n)).sort()
295
- ok(staleAllowed.length === 0, `${P.dir} [${key}]: every allow-list entry is a real registered tool`,
296
- 'allow-listed but not registered: ' + JSON.stringify(staleAllowed))
297
- for (const must of (P.required || [])) {
298
- ok(mentioned.has(must) && !allowed.has(must), `${P.dir} [${key}]: ${must} must be MENTIONED in the persona (never allow-listed)`)
299
- }
300
- }
301
-
302
- // ---- F. the slash-command surface -------------------------------------
303
- // The persona enumerates the `/vN` subcommands; the plugin advertises them twice more
304
- // (the command `hint` the user sees while typing, and the `usage` string returned by the
305
- // unknown-subcommand path) and implements them in a third place. A `/v4` usage string once
306
- // advertised a `message` subcommand that no branch implemented; the v5 persona and plan
307
- // omitted `add|remove` although the handler implements them. All three surfaces must agree.
308
- const region = commandRegion(src)
309
- if (!ok(region !== null, `${P.dir}: no commands.register(...) block found`)) continue
310
- const hinted = cmdsFrom(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
311
- // The LAST `usage:` is the unknown-subcommand advertisement; earlier ones are inline
312
- // per-branch usage hints (e.g. `/v4 message <to|all> <content>`).
313
- const usageMatches = [...region.matchAll(/usage:\s*'([^']*)'/g)]
314
- const used = cmdsFrom(usageMatches.length ? usageMatches[usageMatches.length - 1][1] : '')
315
- const implemented = [...new Set([...region.matchAll(/cmd\s*===\s*'([a-z0-9_-]+)'/g)].map((m) => m[1]))].sort()
316
- const hintOpen = /\.\.\./.test(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
317
- ok(hinted.length > 0 && implemented.length > 0, `${P.dir}: could not extract the slash-command surface (hint ${hinted.length}, branches ${implemented.length})`)
318
- eq(hinted.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the slash hint advertises subcommand(s) that no branch implements`)
319
- if (!hintOpen) eq(implemented.filter((c) => !hinted.includes(c)).sort(), [], `${P.dir}: implemented slash subcommand(s) are missing from the hint`)
320
- eq(used.sort(), implemented, `${P.dir}: the usage string and the implemented slash subcommands disagree`)
321
- const personaEnum = /slash command mirrors[^(]*\(([\s\S]*?)\)/.exec(blocks.prefix.join('\n') + '\n' + blocks.text.join('\n'))
322
- // An ellipsis must NOT switch this check off (that is how a dropped `paper` entry survived here),
323
- // and `...` inside an argument placeholder (`set <k=v>...`) is not an abbreviation at all. So the
324
- // persona is compared against the BRANCHES (the ground truth), and every implemented subcommand
325
- // must be named EXPLICITLY. Only a HINT that is itself abbreviated makes the reverse direction
326
- // (persona ⊆ hint) unprovable, so only that direction is relaxed then.
327
- if (!personaEnum) {
328
- ok(false, `${P.dir}: the persona never enumerates its /vN subcommands (list them so a human can see every one)`)
329
- } else {
330
- const personaCmds = cmdsFrom(personaEnum[1].replace(/^\s*\/[a-z0-9]+\s+/, ''))
331
- ok(personaCmds.length > 0, `${P.dir}: no slash subcommand could be parsed from the persona's /vN list`)
332
- eq(personaCmds.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the persona enumerates slash subcommand(s) the handler does not implement`)
333
- eq(implemented.filter((c) => !personaCmds.includes(c)).sort(), [], `${P.dir}: the persona's /vN list omits subcommand(s) the handler implements — list them explicitly, do not hide behind "..."`)
334
- if (!hintOpen) eq(personaCmds.slice().sort(), implemented.slice().sort(), `${P.dir}: the persona's /vN list and the command hint disagree`)
335
- }
336
-
337
- // ---- E. the Lean feature's prompt surface ----------------------------
338
- const names = (s) => new RegExp('\\b' + s + '\\b')
339
- for (const key of ['prefix', 'text']) {
340
- const body = blocks[key].join('\n')
341
- for (const tool of P.lean.tools) ok(names(tool).test(body), `${P.dir} [${key}]: Lean tool ${tool} is never named`)
342
- for (const tool of P.lean.extra) ok(names(tool).test(body), `${P.dir} [${key}]: ${tool} is never named`)
343
- for (const prm of LEAN_PARAMS) ok(names(prm).test(body), `${P.dir} [${key}]: Lean parameter ${prm} is never named`)
344
- for (const mode of ['off', 'encourage', 'require']) ok(body.includes(`'${mode}'`), `${P.dir} [${key}]: Lean mode ${mode} is never spelled out`)
345
- ok(/FIDELITY|忠实性/.test(body), `${P.dir} [${key}]: the fidelity switch of a passing Lean run is not stated`)
346
- ok(body.includes('Formal/Lib') && body.includes('Formal/Proved'), `${P.dir} [${key}]: the cross-project Lean library paths are not stated`)
347
- ok(body.includes('Verified/Lean'), `${P.dir} [${key}]: the archived-proof path Verified/Lean is not stated`)
348
- }
349
- }
350
-
351
- // ---- the shared contract must stay in step with the personas -----------
352
- try {
353
- const contract = readFileSync(join(ROOT, 'docs', 'formal-verification.md'), 'utf8')
354
- for (const prm of LEAN_PARAMS) ok(contract.includes(prm), `docs/formal-verification.md: parameter ${prm} is not documented`)
355
- for (const sec of ['off', 'encourage', 'require']) ok(new RegExp('`' + sec + '`').test(contract), `docs/formal-verification.md: mode ${sec} is not documented`)
356
- ok(contract.includes('Verified/Lean'), 'docs/formal-verification.md: the archived-proof path is not documented')
357
- ok(contract.includes('Formal/Lib') && contract.includes('Formal/Proved'), 'docs/formal-verification.md: the global library paths are not documented')
358
- } catch (e) {
359
- ok(false, `docs/formal-verification.md: cannot read the shared contract (${e.message})`)
360
- }
361
-
362
- // ---- G. the human-reviewable persona corpus (shipped) -------------------
363
- // Assertions are not enough: the reviewer must be able to READ what the main agent receives
364
- // without digging through YAML (see AUDIT-CHECKLIST §2.4). This writes the exact persona text
365
- // of all four presets to `prompt-corpus-persona/` and is skipped when running against a copy
366
- // (PERSONA_ROOT), so a mutation probe can never overwrite the shipped corpus.
367
- {
368
- const rows = PRESETS.map((P) => {
369
- const yml = readFileSync(join(BASE, P.dir, 'agent.cordis.yml'), 'utf8')
370
- const b = literalBlocks(yml)
371
- const src = readFileSync(join(BASE, P.dir, P.js), 'utf8')
372
- const registered = [...new Set([...src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)].map((m) => m[1]))].sort()
373
- const region = commandRegion(src) || ''
374
- return {
375
- preset: P.dir,
376
- tools: registered.length,
377
- prefix: (b.prefix || []).join('\n'),
378
- text: (b.text || []).join('\n'),
379
- slashHint: (region.match(/hint:\s*'\[(.*)\]'/) || [])[1] || '',
380
- }
381
- })
382
- eq(rows.length, PRESETS.length, 'persona corpus: not every preset was captured')
383
- ok(rows.every((r) => r.prefix.length > 0 && r.text.length > 0), 'persona corpus: a preset has an empty persona block')
384
- const corpusDir = join(ROOT, 'prompt-corpus-persona')
385
- if (!process.env.PERSONA_ROOT) {
386
- const md = ['# 四个预设的 persona 原文(主代理实际收到的提示词)', '',
387
- '> 本文件由 `audit-persona-surface.test.mjs` 生成,供人工复核:四个预设的主代理分别被告知了',
388
- '> 哪些工具、哪些参数、哪些斜杠子命令。`prefix` 与 `text` 两个块**只允许第 0 行不同**。', '']
389
- for (const r of rows) {
390
- md.push(`## ${r.preset}`, '', `- 注册工具数:**${r.tools}**`, `- 斜杠命令 hint:\`${r.slashHint}\``, '',
391
- '### config.prefix', '', '```text', r.prefix, '```', '', '### config.text', '', '```text', r.text, '```', '')
392
- }
393
- mkdirSync(corpusDir, { recursive: true })
394
- // CONCURRENCY FIX: `audit-persona-sensitivity` spawns this suite with PERSONA_ROOT, and those child
395
- // runs READ these two shipped files while a parallel `run-tests` job may be WRITING them. A plain
396
- // writeFileSync let a reader observe a half-written JSON (the flake: sensitivity red only under
397
- // concurrency 4, SETUP-FAIL in its control run). Write a unique temp file in the same directory and
398
- // rename it into place, so a reader sees either the old or the new file, never a partial one.
399
- const writeAtomic = (target, data) => {
400
- const tmp = target + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2)
401
- writeFileSync(tmp, data, 'utf8')
402
- renameSync(tmp, target)
403
- }
404
- writeAtomic(join(corpusDir, 'persona-corpus.md'), md.join('\n'))
405
- writeAtomic(join(corpusDir, 'persona-corpus.json'), JSON.stringify({ presets: rows }, null, 2) + '\n')
406
- }
407
- // Round-trip check: the shipped corpus must name all four presets and carry line 0 of each block.
408
- // Skipped under PERSONA_ROOT: there `rows` describe the mutated copy while the corpus on disk
409
- // describes the real presets, so comparing them would be meaningless.
410
- if (process.env.PERSONA_ROOT) {
411
- ok(existsSync(join(corpusDir, 'persona-corpus.md')) && existsSync(join(corpusDir, 'persona-corpus.json')),
412
- 'persona corpus: the shipped corpus files are missing from the repository')
413
- } else {
414
- try {
415
- const back = readFileSync(join(corpusDir, 'persona-corpus.md'), 'utf8')
416
- for (const r of rows) {
417
- ok(back.includes(`## ${r.preset}`), `persona corpus: ${r.preset} is missing from the shipped corpus`)
418
- ok(back.includes(r.prefix.split('\n')[0]), `persona corpus: ${r.preset} prefix line 0 is missing from the shipped corpus`)
419
- }
420
- const backJson = JSON.parse(readFileSync(join(corpusDir, 'persona-corpus.json'), 'utf8'))
421
- eq(backJson.presets.length, PRESETS.length, 'persona corpus (json): not every preset was captured')
422
- } catch (e) {
423
- ok(false, `persona corpus: cannot read back the shipped corpus (${e.message})`)
424
- }
425
- }
426
- }
427
-
428
- console.log('')
429
- for (const f of failures) console.log(' FAIL ' + f)
430
- console.log(`\n=== PERSONA SURFACE RESULT: ${passed} passed, ${failed} failed ===`)
431
- process.exit(failed === 0 ? 0 : 1)
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PERSONA SURFACE — prompt/allocation consistency for the four presets.
4
+ *
5
+ * DEFECT CLASS (why this guard exists; it belongs to the dual-persona-block parity family of §3)
6
+ * "capability advertised but never granted, or granted but never advertised": the persona block is
7
+ * the main agent view of the tool surface, so a registered-but-unnamed tool is invisible and a
8
+ * named-but-unregistered tool is a trap. Every other suite calls apply(ctx) directly and never
9
+ * parses the YAML, so this class cannot fail anywhere else.
10
+ *
11
+ * Run: node tests/audit-persona-surface.test.mjs
12
+ *
13
+ * WHY THIS EXISTS
14
+ * The persona row of each `agent.cordis.yml` is the prompt the MAIN agent actually
15
+ * receives, and it is the only place the main agent learns which `vibe_*` tools exist
16
+ * and which parameters it may tune. Two failure modes are invisible to every other
17
+ * suite, because those suites call `apply(ctx)` directly and never load the YAML:
18
+ *
19
+ * 1. a tool is registered but never named in the persona → the agent does not know
20
+ * the capability exists (it cannot even guess the exact name);
21
+ * 2. the persona names a tool that is NOT registered → the agent calls a tool
22
+ * that will fail, and (in the worst case) is told to rely on it.
23
+ *
24
+ * The Lean formal-verification feature shipped with exactly this bug in three of four
25
+ * presets: the three `*_lean_*` tools were registered unconditionally but the v2/v3/v4
26
+ * personas never listed them (v5's did), and v4's `vibe_v4_set` parameter list omitted
27
+ * `formalVerify`/`leanCommand`/`leanArgs`/`leanTimeoutMs`, so the coordinator could not
28
+ * discover the switch at all. Nothing in the build noticed. Hence this guard.
29
+ *
30
+ * INVARIANTS (per preset)
31
+ * A. the persona row carries both `prefix` and `text` literal blocks, and both parse
32
+ * into a non-empty body (both keys are required for DSH schema/back-compat);
33
+ * B. the two blocks are IDENTICAL apart from their first line — an old host that uses
34
+ * `text` must not receive a different tool surface from a new host that uses `prefix`;
35
+ * C. mentions ⊆ registered: every `vibe_*` token in the persona is a registered tool
36
+ * (a `name*` mention is allowed when some registered tool carries that prefix);
37
+ * D. every registered tool is either MENTIONED in the persona or consciously ALLOW-LISTED, and the
38
+ * allow-list is itself validated (real tools only, empty by default) — so adding a tool forcing a
39
+ * decision: name it, or add it to the list with a reason. (Earlier this was a snapshot equality,
40
+ * which went red whenever a legitimately newly-named tool emptied the "undocumented" set.)
41
+ * without documenting it (or deleting a tool the persona still advertises) fails
42
+ * loudly and forces the author to make a decision;
43
+ * E. the parameter surface of the Lean feature (the four names) and its semantics
44
+ * (three modes, the fidelity switch, the project/global paths) appear in BOTH blocks.
45
+ *
46
+ * The snapshots below are the reviewed answer to "is this tool intentionally absent from
47
+ * the coordinator's persona?" — virtually all of them are `(member)` / `(academician)` /
48
+ * `(resident)` tools that only subagents call.
49
+ */
50
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs'
51
+ import { join, resolve } from 'node:path'
52
+ import { fileURLToPath } from 'node:url'
53
+
54
+ const ROOT = fileURLToPath(new URL('../', import.meta.url))
55
+ /**
56
+ * `PERSONA_ROOT` points the preset lookup at a prepared copy. It exists so that
57
+ * `audit-persona-sensitivity.mjs` can prove this suite is not vacuous: each probe mutates a
58
+ * copy of one persona (or one plugin) and requires this suite to go RED. Without the
59
+ * override the mutation would never be loaded and every probe would be a false green.
60
+ * `docs/formal-verification.md` is always read from the repository root, because the
61
+ * shared contract is not part of a preset copy.
62
+ */
63
+ const BASE = process.env.PERSONA_ROOT ? resolve(process.env.PERSONA_ROOT) : ROOT
64
+
65
+ let passed = 0
66
+ let failed = 0
67
+ const failures = []
68
+ function ok(cond, msg) {
69
+ if (cond) { passed++; return true }
70
+ failed++
71
+ failures.push(msg)
72
+ return false
73
+ }
74
+ function eq(actual, expected, msg) {
75
+ const a = JSON.stringify(actual)
76
+ const e = JSON.stringify(expected)
77
+ return ok(a === e, `${msg}\n expected: ${e}\n actual: ${a}`)
78
+ }
79
+
80
+ /**
81
+ * Extract a YAML literal block scalar (`prefix: |-`) by indentation. Deliberately does
82
+ * not depend on a YAML library: this file must run inside the published package, where
83
+ * only the DSH host (not this repo) has one. The block's own indentation is stripped so
84
+ * the `prefix` and `text` bodies can be compared line by line.
85
+ */
86
+ function literalBlocks(yml) {
87
+ const out = {}
88
+ const lines = yml.split(/\r?\n/)
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const m = /^(\s*)([A-Za-z_][\w-]*):\s*\|-?\s*$/.exec(lines[i])
91
+ if (!m) continue
92
+ const ind = m[1].length
93
+ const body = []
94
+ for (let j = i + 1; j < lines.length; j++) {
95
+ const L = lines[j]
96
+ if (L.trim() === '') { body.push(''); continue }
97
+ const li = L.length - L.trimStart().length
98
+ if (li <= ind) break
99
+ body.push(L.slice(ind + 2))
100
+ }
101
+ out[m[2]] = body
102
+ }
103
+ return out
104
+ }
105
+
106
+ /**
107
+ * The slice of a plugin that describes and implements its `/vN` slash command: the
108
+ * `commands.register({...})` block, plus — for v2/v3, which dispatch through a named function —
109
+ * the `dispatchVibeCommand` body (that is where their branch list and usage string live).
110
+ * The anchor includes the `{` on purpose: the files also mention `commands.register()` in a
111
+ * prose comment *before* the real call, and anchoring on the call's object literal skips it.
112
+ */
113
+ function commandRegion(src) {
114
+ const i = src.indexOf('commands.register({')
115
+ if (i < 0) return null
116
+ const end = src.indexOf('\n }))', i)
117
+ let region = src.slice(i, end > i ? end : Math.min(src.length, i + 8000))
118
+ if (!/cmd\s*===\s*'/.test(region)) {
119
+ const d = src.indexOf('dispatchVibeCommand(cmd, args)')
120
+ if (d >= 0) {
121
+ const dEnd = src.indexOf('\n }', d)
122
+ region += '\n' + src.slice(d, dEnd > d ? dEnd : d + 8000)
123
+ }
124
+ }
125
+ return region
126
+ }
127
+
128
+ /** Split a `a|b <x|y>|c` advertised list into bare subcommand names. */
129
+ function cmdsFrom(list) {
130
+ const out = []
131
+ const bare = String(list).replace(/\[[^\]]*\]/g, ' ').replace(/<[^>]*>/g, ' ')
132
+ for (const tok of bare.split('|')) {
133
+ const t = tok.trim()
134
+ if (!t || t === '...') continue
135
+ const name = t.split(/\s+/)[0]
136
+ if (/^[a-z][a-z0-9-]*$/.test(name)) out.push(name)
137
+ }
138
+ return out
139
+ }
140
+
141
+ const PRESETS = [
142
+ {
143
+ dir: 'vibe-math-v2',
144
+ js: 'vibe-math-v2.js',
145
+ prefix: 'vibe_math_',
146
+ tools: 28,
147
+ // D. registered \ mentioned ⊆ allow-list: EVERY registered tool must be mentioned in the persona,
148
+ // unless its name is listed here as a conscious exception (the allow-list is empty by default, so a
149
+ // newly registered tool cannot slip in unnamed). An entry must be a REAL registered tool, and a
150
+ // preset may also pin names that must be mentioned (never allow-listed).
151
+ allowList: [],
152
+ required: [],
153
+ lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib', 'vibe_math_lean_read', 'vibe_math_lean_job'], extra: [] },
154
+ // the shared math-computation tool (docs/math-computation.md) is prefix-less by
155
+ // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
156
+ math: { tools: ['math_computation'] },
157
+ },
158
+ {
159
+ dir: 'vibe-math-v3',
160
+ js: 'vibe-math-v3.js',
161
+ prefix: 'vibe_math_',
162
+ tools: 36,
163
+ // `vibe_math_sync_meta` is named by the promotion-contract line, and the write-lock pair is now
164
+ // named too (the lock is described in the persona, so leaving the two tools unmentioned was the
165
+ // last hole). Both stay REQUIRED: they must be mentioned, never allow-listed.
166
+ allowList: [],
167
+ required: ['vibe_math_claim_write', 'vibe_math_release_write'],
168
+ lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib', 'vibe_math_lean_read', 'vibe_math_lean_job'], extra: [] },
169
+ // the shared math-computation tool (docs/math-computation.md) is prefix-less by
170
+ // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
171
+ math: { tools: ['math_computation'] },
172
+ },
173
+ {
174
+ dir: 'vibe-math-v4',
175
+ js: 'vibe-math-v4.js',
176
+ prefix: 'vibe_v4_',
177
+ tools: 35,
178
+ // Member-facing tools (mail, library cards, task board, write lock) that the v4 persona describes
179
+ // GENERICALLY rather than naming one by one; the coordinator drives residents through
180
+ // vibe_v4_message / _meeting / _add_member. Allow-listed ON PURPOSE, so a NEWLY registered tool
181
+ // still has to be named (or added here consciously).
182
+ allowList: [
183
+ 'vibe_v4_send_message', 'vibe_v4_publish_progress', 'vibe_v4_record_proposition',
184
+ 'vibe_v4_record_method', 'vibe_v4_record_subproblem', 'vibe_v4_read_progress',
185
+ 'vibe_v4_list_residents', 'vibe_v4_propose_task', 'vibe_v4_claim_task',
186
+ 'vibe_v4_task_done', 'vibe_v4_list_tasks', 'vibe_v4_report_context',
187
+ 'vibe_v4_claim_write', 'vibe_v4_release_write',
188
+ ],
189
+ required: [],
190
+ lean: { tools: ['vibe_v4_lean_run', 'vibe_v4_lean_archive', 'vibe_v4_lean_lib', 'vibe_v4_lean_read', 'vibe_v4_lean_job'], extra: ['vibe_v4_formal_report'] },
191
+ // the shared math-computation tool (docs/math-computation.md) is prefix-less by
192
+ // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
193
+ math: { tools: ['math_computation'] },
194
+ },
195
+ {
196
+ dir: 'vibe-math-v5',
197
+ js: 'vibe-math-v5.js',
198
+ prefix: 'vibe_v5_',
199
+ tools: 40,
200
+ // Member- and academician-facing tools that the v5 persona describes GENERICALLY; the office (main
201
+ // agent) holds only the institute-level controls plus the hiring authority. The final-paper pair is
202
+ // an OFFICE control and IS named in the persona (vibe_v5_paper / vibe_v5_finalize_paper), so it must
203
+ // NOT be allow-listed here. Allow-listed ON PURPOSE: a NEWLY registered tool still has to be named.
204
+ allowList: [
205
+ 'vibe_v5_wait', 'vibe_v5_record_progress', 'vibe_v5_record_proposition',
206
+ 'vibe_v5_record_method', 'vibe_v5_record_subproblem', 'vibe_v5_read_library',
207
+ 'vibe_v5_propose_verify', 'vibe_v5_verdict', 'vibe_v5_task_create',
208
+ 'vibe_v5_task_list', 'vibe_v5_task_get', 'vibe_v5_task_update',
209
+ 'vibe_v5_overview', 'vibe_v5_assign', 'vibe_v5_prioritize', 'vibe_v5_nudge',
210
+ ],
211
+ required: [],
212
+ lean: { tools: ['vibe_v5_lean_run', 'vibe_v5_lean_archive', 'vibe_v5_lean_lib', 'vibe_v5_lean_read', 'vibe_v5_lean_job'], extra: [] },
213
+ // the shared math-computation tool (docs/math-computation.md) is prefix-less by
214
+ // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
215
+ math: { tools: ['math_computation'] },
216
+ },
217
+ ]
218
+
219
+ const LEAN_PARAMS = ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs', 'leanAsync', 'leanJobsMaxParallel', 'leanInitiative', 'leanSearchPaths']
220
+
221
+ for (const P of PRESETS) {
222
+ const ymlPath = join(BASE, P.dir, 'agent.cordis.yml')
223
+ const jsPath = join(BASE, P.dir, P.js)
224
+ const yml = readFileSync(ymlPath, 'utf8')
225
+ const src = readFileSync(jsPath, 'utf8')
226
+
227
+ // ---- registered tools -------------------------------------------------
228
+ // Two registration shapes exist now:
229
+ // 1. a literal `registerTool('<name>', …)` (most tools, including the shared math tool in
230
+ // v2/v3, which register it by its frozen prefix-less name);
231
+ // 2. the shared math tool in v4/v5, where the plugin only wires a host callback and the module
232
+ // calls `host.register(MATH_TOOL_NAME, …)`. The module's name is FROZEN
233
+ // (MATH_TOOL_NAME = 'math_computation', INTERFACE-FREEZE.md §3), so a source that wires
234
+ // `registerMathComputation(` contributes exactly that one tool. Without this the shared tool
235
+ // would be invisible to this guard on v4/v5 - a silent coverage hole.
236
+ const registered = new Set()
237
+ for (const m of src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)) registered.add(m[1])
238
+ if (/registerMathComputation\s*\(/.test(src)) registered.add('math_computation')
239
+ eq(registered.size, P.tools, `${P.dir}: registered tool count changed (update this snapshot deliberately)`)
240
+ for (const tool of (P.math ? P.math.tools : [])) ok(registered.has(tool), `${P.dir}: the shared math tool ${tool} is not registered`)
241
+
242
+ // ---- A. both literal blocks exist and are non-empty -------------------
243
+ const blocks = literalBlocks(yml)
244
+ const hasPersona = /^\s*-\s*id:\s*persona\s*$/m.test(yml)
245
+ ok(hasPersona, `${P.dir}: no persona row in agent.cordis.yml`)
246
+ for (const key of ['prefix', 'text']) {
247
+ ok(Array.isArray(blocks[key]) && blocks[key].length > 0, `${P.dir}: persona.config.${key} is missing or not a literal block`)
248
+ }
249
+ if (!blocks.prefix || !blocks.text) { ok(false, `${P.dir}: cannot continue without both blocks`); continue }
250
+
251
+ // ---- B. the two blocks must not drift --------------------------------
252
+ const trimTail = (a) => { const b = a.slice(); while (b.length && b[b.length - 1] === '') b.pop(); return b }
253
+ const p = trimTail(blocks.prefix)
254
+ const t = trimTail(blocks.text)
255
+ eq(p.length, t.length, `${P.dir}: prefix and text have different line counts`)
256
+ const drift = []
257
+ for (let i = 1; i < Math.max(p.length, t.length); i++) if (p[i] !== t[i]) drift.push(i)
258
+ ok(drift.length === 0, `${P.dir}: prefix and text differ on line(s) ${drift.slice(0, 5).join(', ')} (only line 0 may differ)`)
259
+ ok(/\{\{model\}\}/.test(p[0]) || /\{\{model\}\}/.test(t[0]), `${P.dir}: line 0 does not interpolate {{model}}`)
260
+
261
+ // ---- C/D. mention <-> registry ----------------------------------------
262
+ for (const key of ['prefix', 'text']) {
263
+ const body = blocks[key].join('\n')
264
+ const mentioned = new Set()
265
+ const wildcards = new Set()
266
+ // Uppercase is part of the token class on purpose: a drifted name such as
267
+ // `vibe_v4_formal_reportX` must be captured whole and judged unregistered, instead of
268
+ // being read as a mention of the registered `vibe_v4_formal_report`.
269
+ // Names that do NOT carry this preset's prefix (the shared `math_computation`) are added to the
270
+ // alternation from the registry, so "every registered tool must be mentioned" stays true for
271
+ // them too instead of forcing them into the `undocumented` snapshot.
272
+ const prefixRoot = P.prefix.replace(/_$/, '')
273
+ const prefixless = [...registered].filter((n) => !new RegExp('^' + prefixRoot + '_').test(n))
274
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
275
+ const re = new RegExp('\\b' + prefixRoot + '_[A-Za-z0-9_]+' + (prefixless.length ? '|\\b' + prefixless.map(escapeRe).join('\\b|\\b') + '\\b' : ''), 'g')
276
+ for (const m of body.matchAll(re)) {
277
+ if (body[m.index + m[0].length] === '*') { wildcards.add(m[0]); continue }
278
+ mentioned.add(m[0])
279
+ }
280
+ const stale = [...mentioned].filter((n) => !registered.has(n)).sort()
281
+ eq(stale, [], `${P.dir} [${key}]: persona advertises tool(s) that are not registered`)
282
+ for (const w of wildcards) {
283
+ ok([...registered].some((n) => n.startsWith(w)), `${P.dir} [${key}]: wildcard mention ${w}* matches no registered tool`)
284
+ }
285
+ const unmentioned = [...registered].filter((n) => !mentioned.has(n)).sort()
286
+ // ALLOW-LIST design (replaces the old "equals a reviewed snapshot" equality): a registered tool may
287
+ // be absent from the persona ONLY if its name is consciously allow-listed for this preset. The
288
+ // allow-list is empty by default (v2/v3), so a newly registered tool turns this red until it is
289
+ // either mentioned in the persona or deliberately added to the list with a reason.
290
+ const allowed = new Set(P.allowList || [])
291
+ const illegal = unmentioned.filter((n) => !allowed.has(n))
292
+ ok(illegal.length === 0, `${P.dir} [${key}]: every registered tool is mentioned in the persona, or explicitly allow-listed`,
293
+ 'unmentioned and not allow-listed: ' + JSON.stringify(illegal))
294
+ const staleAllowed = [...allowed].filter((n) => !registered.has(n)).sort()
295
+ ok(staleAllowed.length === 0, `${P.dir} [${key}]: every allow-list entry is a real registered tool`,
296
+ 'allow-listed but not registered: ' + JSON.stringify(staleAllowed))
297
+ for (const must of (P.required || [])) {
298
+ ok(mentioned.has(must) && !allowed.has(must), `${P.dir} [${key}]: ${must} must be MENTIONED in the persona (never allow-listed)`)
299
+ }
300
+ }
301
+
302
+ // ---- F. the slash-command surface -------------------------------------
303
+ // The persona enumerates the `/vN` subcommands; the plugin advertises them twice more
304
+ // (the command `hint` the user sees while typing, and the `usage` string returned by the
305
+ // unknown-subcommand path) and implements them in a third place. A `/v4` usage string once
306
+ // advertised a `message` subcommand that no branch implemented; the v5 persona and plan
307
+ // omitted `add|remove` although the handler implements them. All three surfaces must agree.
308
+ const region = commandRegion(src)
309
+ if (!ok(region !== null, `${P.dir}: no commands.register(...) block found`)) continue
310
+ const hinted = cmdsFrom(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
311
+ // The LAST `usage:` is the unknown-subcommand advertisement; earlier ones are inline
312
+ // per-branch usage hints (e.g. `/v4 message <to|all> <content>`).
313
+ const usageMatches = [...region.matchAll(/usage:\s*'([^']*)'/g)]
314
+ const used = cmdsFrom(usageMatches.length ? usageMatches[usageMatches.length - 1][1] : '')
315
+ const implemented = [...new Set([...region.matchAll(/cmd\s*===\s*'([a-z0-9_-]+)'/g)].map((m) => m[1]))].sort()
316
+ const hintOpen = /\.\.\./.test(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
317
+ ok(hinted.length > 0 && implemented.length > 0, `${P.dir}: could not extract the slash-command surface (hint ${hinted.length}, branches ${implemented.length})`)
318
+ eq(hinted.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the slash hint advertises subcommand(s) that no branch implements`)
319
+ if (!hintOpen) eq(implemented.filter((c) => !hinted.includes(c)).sort(), [], `${P.dir}: implemented slash subcommand(s) are missing from the hint`)
320
+ eq(used.sort(), implemented, `${P.dir}: the usage string and the implemented slash subcommands disagree`)
321
+ const personaEnum = /slash command mirrors[^(]*\(([\s\S]*?)\)/.exec(blocks.prefix.join('\n') + '\n' + blocks.text.join('\n'))
322
+ // An ellipsis must NOT switch this check off (that is how a dropped `paper` entry survived here),
323
+ // and `...` inside an argument placeholder (`set <k=v>...`) is not an abbreviation at all. So the
324
+ // persona is compared against the BRANCHES (the ground truth), and every implemented subcommand
325
+ // must be named EXPLICITLY. Only a HINT that is itself abbreviated makes the reverse direction
326
+ // (persona ⊆ hint) unprovable, so only that direction is relaxed then.
327
+ if (!personaEnum) {
328
+ ok(false, `${P.dir}: the persona never enumerates its /vN subcommands (list them so a human can see every one)`)
329
+ } else {
330
+ const personaCmds = cmdsFrom(personaEnum[1].replace(/^\s*\/[a-z0-9]+\s+/, ''))
331
+ ok(personaCmds.length > 0, `${P.dir}: no slash subcommand could be parsed from the persona's /vN list`)
332
+ eq(personaCmds.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the persona enumerates slash subcommand(s) the handler does not implement`)
333
+ eq(implemented.filter((c) => !personaCmds.includes(c)).sort(), [], `${P.dir}: the persona's /vN list omits subcommand(s) the handler implements — list them explicitly, do not hide behind "..."`)
334
+ if (!hintOpen) eq(personaCmds.slice().sort(), implemented.slice().sort(), `${P.dir}: the persona's /vN list and the command hint disagree`)
335
+ }
336
+
337
+ // ---- E. the Lean feature's prompt surface ----------------------------
338
+ const names = (s) => new RegExp('\\b' + s + '\\b')
339
+ for (const key of ['prefix', 'text']) {
340
+ const body = blocks[key].join('\n')
341
+ for (const tool of P.lean.tools) ok(names(tool).test(body), `${P.dir} [${key}]: Lean tool ${tool} is never named`)
342
+ for (const tool of P.lean.extra) ok(names(tool).test(body), `${P.dir} [${key}]: ${tool} is never named`)
343
+ for (const prm of LEAN_PARAMS) ok(names(prm).test(body), `${P.dir} [${key}]: Lean parameter ${prm} is never named`)
344
+ for (const mode of ['off', 'encourage', 'require']) ok(body.includes(`'${mode}'`), `${P.dir} [${key}]: Lean mode ${mode} is never spelled out`)
345
+ ok(/FIDELITY|忠实性/.test(body), `${P.dir} [${key}]: the fidelity switch of a passing Lean run is not stated`)
346
+ ok(body.includes('Formal/Lib') && body.includes('Formal/Proved'), `${P.dir} [${key}]: the cross-project Lean library paths are not stated`)
347
+ ok(body.includes('Verified/Lean'), `${P.dir} [${key}]: the archived-proof path Verified/Lean is not stated`)
348
+ }
349
+ }
350
+
351
+ // ---- the shared contract must stay in step with the personas -----------
352
+ try {
353
+ const contract = readFileSync(join(ROOT, 'docs', 'formal-verification.md'), 'utf8')
354
+ for (const prm of LEAN_PARAMS) ok(contract.includes(prm), `docs/formal-verification.md: parameter ${prm} is not documented`)
355
+ for (const sec of ['off', 'encourage', 'require']) ok(new RegExp('`' + sec + '`').test(contract), `docs/formal-verification.md: mode ${sec} is not documented`)
356
+ ok(contract.includes('Verified/Lean'), 'docs/formal-verification.md: the archived-proof path is not documented')
357
+ ok(contract.includes('Formal/Lib') && contract.includes('Formal/Proved'), 'docs/formal-verification.md: the global library paths are not documented')
358
+ } catch (e) {
359
+ ok(false, `docs/formal-verification.md: cannot read the shared contract (${e.message})`)
360
+ }
361
+
362
+ // ---- G. the human-reviewable persona corpus (shipped) -------------------
363
+ // Assertions are not enough: the reviewer must be able to READ what the main agent receives
364
+ // without digging through YAML (see AUDIT-CHECKLIST §2.4). This writes the exact persona text
365
+ // of all four presets to `prompt-corpus-persona/` and is skipped when running against a copy
366
+ // (PERSONA_ROOT), so a mutation probe can never overwrite the shipped corpus.
367
+ {
368
+ const rows = PRESETS.map((P) => {
369
+ const yml = readFileSync(join(BASE, P.dir, 'agent.cordis.yml'), 'utf8')
370
+ const b = literalBlocks(yml)
371
+ const src = readFileSync(join(BASE, P.dir, P.js), 'utf8')
372
+ const registered = [...new Set([...src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)].map((m) => m[1]))].sort()
373
+ const region = commandRegion(src) || ''
374
+ return {
375
+ preset: P.dir,
376
+ tools: registered.length,
377
+ prefix: (b.prefix || []).join('\n'),
378
+ text: (b.text || []).join('\n'),
379
+ slashHint: (region.match(/hint:\s*'\[(.*)\]'/) || [])[1] || '',
380
+ }
381
+ })
382
+ eq(rows.length, PRESETS.length, 'persona corpus: not every preset was captured')
383
+ ok(rows.every((r) => r.prefix.length > 0 && r.text.length > 0), 'persona corpus: a preset has an empty persona block')
384
+ const corpusDir = join(ROOT, 'prompt-corpus-persona')
385
+ if (!process.env.PERSONA_ROOT) {
386
+ const md = ['# 四个预设的 persona 原文(主代理实际收到的提示词)', '',
387
+ '> 本文件由 `audit-persona-surface.test.mjs` 生成,供人工复核:四个预设的主代理分别被告知了',
388
+ '> 哪些工具、哪些参数、哪些斜杠子命令。`prefix` 与 `text` 两个块**只允许第 0 行不同**。', '']
389
+ for (const r of rows) {
390
+ md.push(`## ${r.preset}`, '', `- 注册工具数:**${r.tools}**`, `- 斜杠命令 hint:\`${r.slashHint}\``, '',
391
+ '### config.prefix', '', '```text', r.prefix, '```', '', '### config.text', '', '```text', r.text, '```', '')
392
+ }
393
+ mkdirSync(corpusDir, { recursive: true })
394
+ // CONCURRENCY FIX: `audit-persona-sensitivity` spawns this suite with PERSONA_ROOT, and those child
395
+ // runs READ these two shipped files while a parallel `run-tests` job may be WRITING them. A plain
396
+ // writeFileSync let a reader observe a half-written JSON (the flake: sensitivity red only under
397
+ // concurrency 4, SETUP-FAIL in its control run). Write a unique temp file in the same directory and
398
+ // rename it into place, so a reader sees either the old or the new file, never a partial one.
399
+ const writeAtomic = (target, data) => {
400
+ const tmp = target + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2)
401
+ writeFileSync(tmp, data, 'utf8')
402
+ renameSync(tmp, target)
403
+ }
404
+ writeAtomic(join(corpusDir, 'persona-corpus.md'), md.join('\n'))
405
+ writeAtomic(join(corpusDir, 'persona-corpus.json'), JSON.stringify({ presets: rows }, null, 2) + '\n')
406
+ }
407
+ // Round-trip check: the shipped corpus must name all four presets and carry line 0 of each block.
408
+ // Skipped under PERSONA_ROOT: there `rows` describe the mutated copy while the corpus on disk
409
+ // describes the real presets, so comparing them would be meaningless.
410
+ if (process.env.PERSONA_ROOT) {
411
+ ok(existsSync(join(corpusDir, 'persona-corpus.md')) && existsSync(join(corpusDir, 'persona-corpus.json')),
412
+ 'persona corpus: the shipped corpus files are missing from the repository')
413
+ } else {
414
+ try {
415
+ const back = readFileSync(join(corpusDir, 'persona-corpus.md'), 'utf8')
416
+ for (const r of rows) {
417
+ ok(back.includes(`## ${r.preset}`), `persona corpus: ${r.preset} is missing from the shipped corpus`)
418
+ ok(back.includes(r.prefix.split('\n')[0]), `persona corpus: ${r.preset} prefix line 0 is missing from the shipped corpus`)
419
+ }
420
+ const backJson = JSON.parse(readFileSync(join(corpusDir, 'persona-corpus.json'), 'utf8'))
421
+ eq(backJson.presets.length, PRESETS.length, 'persona corpus (json): not every preset was captured')
422
+ } catch (e) {
423
+ ok(false, `persona corpus: cannot read back the shipped corpus (${e.message})`)
424
+ }
425
+ }
426
+ }
427
+
428
+ console.log('')
429
+ for (const f of failures) console.log(' FAIL ' + f)
430
+ console.log(`\n=== PERSONA SURFACE RESULT: ${passed} passed, ${failed} failed ===`)
431
+ process.exit(failed === 0 ? 0 : 1)