dsh-vibe-math 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -681
  3. package/README.md +683 -682
  4. package/cordis.patch.yml +112 -16
  5. package/docs/AUDIT-CHECKLIST.md +755 -518
  6. package/docs/COMPAT-AUDIT-ROUND2.md +354 -328
  7. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  8. package/docs/audit-methodology.md +93 -0
  9. package/docs/final-paper.md +172 -172
  10. package/docs/formal-verification.md +613 -588
  11. package/docs/generate_framework_diagram_v2.py +114 -114
  12. package/docs/generate_framework_diagram_v3.py +127 -127
  13. package/docs/math-computation.md +195 -187
  14. package/docs/parameter-schema.md +37 -0
  15. package/docs/release-notes/README.en.md +2 -0
  16. package/docs/release-notes/README.md +2 -0
  17. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  18. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  19. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  20. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  21. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  22. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  23. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  24. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  25. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  26. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  27. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  28. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  29. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  30. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  31. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  32. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  33. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  34. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  35. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
  36. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
  37. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  38. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  39. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  40. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  41. package/docs/release-notes/TEMPLATE.md +6 -0
  42. package/docs/release-playbook.md +103 -0
  43. package/docs/slv-playbook.md +117 -0
  44. package/docs/status-report-fields.md +167 -0
  45. package/docs/test-timing.md +158 -154
  46. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  47. package/installer.js +793 -783
  48. package/package.json +260 -203
  49. package/prompt-corpus-persona/persona-corpus.json +9 -9
  50. package/prompt-corpus-persona/persona-corpus.md +225 -33
  51. package/prompt-corpus-v2/formal-verify-v2.json +295 -115
  52. package/prompt-corpus-v2/formal-verify-v2.md +2290 -282
  53. package/prompt-corpus-v3/formal-verify-v3.json +112 -112
  54. package/prompt-corpus-v3/formal-verify-v3.md +227 -143
  55. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  56. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  57. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  58. package/prompt-corpus-v5/prompt-corpus-v5.md +573 -207
  59. package/scripts/clean-temp.mjs +234 -0
  60. package/scripts/update-doc-counts.mjs +71 -0
  61. package/tests/audit-artifact-docs.mjs +72 -0
  62. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  63. package/tests/audit-engine-faces.mjs +298 -0
  64. package/tests/audit-formal-sensitivity.mjs +345 -342
  65. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  66. package/tests/audit-installer-compat.test.mjs +256 -208
  67. package/tests/audit-installer-policy.test.mjs +41 -8
  68. package/tests/audit-math-computation-contract.mjs +64 -8
  69. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  70. package/tests/audit-math-computation-parity.mjs +164 -3
  71. package/tests/audit-math-computation-sensitivity.mjs +5 -3
  72. package/tests/audit-package-membership.mjs +57 -0
  73. package/tests/audit-package-membership.mutants.mjs +28 -0
  74. package/tests/audit-participant-set-parity.mjs +160 -0
  75. package/tests/audit-path-discipline.mjs +83 -0
  76. package/tests/audit-path-discipline.mutants.mjs +51 -0
  77. package/tests/audit-persona-sensitivity.mjs +323 -309
  78. package/tests/audit-persona-surface.mutants.mjs +69 -0
  79. package/tests/audit-persona-surface.test.mjs +431 -390
  80. package/tests/audit-prompt-duplication.mjs +143 -0
  81. package/tests/audit-prompt-duplication.mutants.mjs +51 -0
  82. package/tests/audit-prompt-invariants.mjs +589 -508
  83. package/tests/audit-readme-bilingual.test.mjs +152 -0
  84. package/tests/audit-readme-counts.mjs +95 -0
  85. package/tests/audit-readme-counts.mutants.mjs +52 -0
  86. package/tests/audit-registration.mjs +78 -0
  87. package/tests/audit-spec-traceability.mjs +193 -193
  88. package/tests/audit-status-report-fields.mjs +172 -0
  89. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  90. package/tests/audit-v3-registration-parity.mjs +156 -0
  91. package/tests/audit-v5-integrity.mjs +795 -508
  92. package/tests/audit-v5-lean-abstention.mjs +392 -0
  93. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  94. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  95. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  96. package/tests/audit-v5-sensitivity.mjs +433 -427
  97. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  98. package/tests/e2e-v5-round2.test.mjs +2453 -1981
  99. package/tests/formal-verify-v2.test.mjs +1778 -1724
  100. package/tests/formal-verify-v3.mutants.mjs +117 -0
  101. package/tests/formal-verify-v3.test.mjs +1724 -1671
  102. package/tests/formal-verify-v4.mutants.mjs +152 -0
  103. package/tests/formal-verify-v4.test.mjs +2114 -1574
  104. package/tests/formal-verify-v5.test.mjs +790 -724
  105. package/tests/helpers/doc-counts-format.mjs +41 -0
  106. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  107. package/tests/math-computation-a1.mutants.mjs +72 -0
  108. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  109. package/tests/math-computation-discovery.mutants.mjs +76 -0
  110. package/tests/math-computation-shared.test.mjs +392 -6
  111. package/tests/math-computation-v2.test.mjs +779 -0
  112. package/tests/math-computation-v3.test.mjs +771 -0
  113. package/tests/math-computation-v4.mutants.mjs +133 -0
  114. package/tests/math-computation-v4.test.mjs +739 -0
  115. package/tests/prompt-v5-integrity.test.mjs +1291 -1280
  116. package/tests/run-tests.mjs +393 -202
  117. package/tests/run-tests.mutants.mjs +70 -0
  118. package/tests/selfdrive-v5.mjs +560 -508
  119. package/tests/selfdrive-v5.mutants.mjs +132 -0
  120. package/tests/temp-hygiene.mutants.mjs +80 -0
  121. package/tests/v2-fix-probes.mutants.mjs +104 -0
  122. package/tests/v2-fix-probes.test.mjs +1040 -0
  123. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  124. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  125. package/tests/v3-fix-probes.mutants.mjs +99 -0
  126. package/tests/v3-fix-probes.test.mjs +892 -0
  127. package/tests/v4-final-paper.mutants.mjs +108 -0
  128. package/tests/v5-institute-fixes.mutants.mjs +218 -0
  129. package/vibe-math-v2/agent.cordis.yml +378 -322
  130. package/vibe-math-v2/math-computation.js +144 -53
  131. package/vibe-math-v2/math-engines.js +12 -12
  132. package/vibe-math-v2/preset.yml +2 -2
  133. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  134. package/vibe-math-v2/vibe-math-v2.js +5097 -4755
  135. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +491 -487
  136. package/vibe-math-v3/agent.cordis.yml +422 -356
  137. package/vibe-math-v3/math-computation.js +144 -53
  138. package/vibe-math-v3/math-engines.js +12 -12
  139. package/vibe-math-v3/preset.yml +2 -2
  140. package/vibe-math-v3/vibe-math-v3.js +6235 -5873
  141. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  142. package/vibe-math-v4/agent.cordis.yml +438 -402
  143. package/vibe-math-v4/math-computation.js +144 -53
  144. package/vibe-math-v4/math-engines.js +12 -12
  145. package/vibe-math-v4/preset.yml +2 -2
  146. package/vibe-math-v4/vibe-math-v4.js +4490 -4269
  147. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1013
  148. package/vibe-math-v5/agent.cordis.yml +46 -12
  149. package/vibe-math-v5/math-computation.js +144 -53
  150. package/vibe-math-v5/math-engines.js +12 -12
  151. package/vibe-math-v5/vibe-math-v5.js +1266 -229
  152. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1660
  153. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -1,390 +1,431 @@
1
- #!/usr/bin/env node
2
- /**
3
- * PERSONA SURFACE — prompt/allocation consistency for the four presets.
4
- *
5
- * WHY THIS EXISTS
6
- * The persona row of each `agent.cordis.yml` is the prompt the MAIN agent actually
7
- * receives, and it is the only place the main agent learns which `vibe_*` tools exist
8
- * and which parameters it may tune. Two failure modes are invisible to every other
9
- * suite, because those suites call `apply(ctx)` directly and never load the YAML:
10
- *
11
- * 1. a tool is registered but never named in the persona → the agent does not know
12
- * the capability exists (it cannot even guess the exact name);
13
- * 2. the persona names a tool that is NOT registered → the agent calls a tool
14
- * that will fail, and (in the worst case) is told to rely on it.
15
- *
16
- * The Lean formal-verification feature shipped with exactly this bug in three of four
17
- * presets: the three `*_lean_*` tools were registered unconditionally but the v2/v3/v4
18
- * personas never listed them (v5's did), and v4's `vibe_v4_set` parameter list omitted
19
- * `formalVerify`/`leanCommand`/`leanArgs`/`leanTimeoutMs`, so the coordinator could not
20
- * discover the switch at all. Nothing in the build noticed. Hence this guard.
21
- *
22
- * INVARIANTS (per preset)
23
- * A. the persona row carries both `prefix` and `text` literal blocks, and both parse
24
- * into a non-empty body (both keys are required for DSH schema/back-compat);
25
- * B. the two blocks are IDENTICAL apart from their first line — an old host that uses
26
- * `text` must not receive a different tool surface from a new host that uses `prefix`;
27
- * C. mentions ⊆ registered: every `vibe_*` token in the persona is a registered tool
28
- * (a `name*` mention is allowed when some registered tool carries that prefix);
29
- * D. registered \ mentioned equals an explicit, reviewed snapshot — so adding a tool
30
- * without documenting it (or deleting a tool the persona still advertises) fails
31
- * loudly and forces the author to make a decision;
32
- * E. the parameter surface of the Lean feature (the four names) and its semantics
33
- * (three modes, the fidelity switch, the project/global paths) appear in BOTH blocks.
34
- *
35
- * The snapshots below are the reviewed answer to "is this tool intentionally absent from
36
- * the coordinator's persona?" — virtually all of them are `(member)` / `(academician)` /
37
- * `(resident)` tools that only subagents call.
38
- */
39
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
40
- import { join, resolve } from 'node:path'
41
- import { fileURLToPath } from 'node:url'
42
-
43
- const ROOT = fileURLToPath(new URL('../', import.meta.url))
44
- /**
45
- * `PERSONA_ROOT` points the preset lookup at a prepared copy. It exists so that
46
- * `audit-persona-sensitivity.mjs` can prove this suite is not vacuous: each probe mutates a
47
- * copy of one persona (or one plugin) and requires this suite to go RED. Without the
48
- * override the mutation would never be loaded and every probe would be a false green.
49
- * `docs/formal-verification.md` is always read from the repository root, because the
50
- * shared contract is not part of a preset copy.
51
- */
52
- const BASE = process.env.PERSONA_ROOT ? resolve(process.env.PERSONA_ROOT) : ROOT
53
-
54
- let passed = 0
55
- let failed = 0
56
- const failures = []
57
- function ok(cond, msg) {
58
- if (cond) { passed++; return true }
59
- failed++
60
- failures.push(msg)
61
- return false
62
- }
63
- function eq(actual, expected, msg) {
64
- const a = JSON.stringify(actual)
65
- const e = JSON.stringify(expected)
66
- return ok(a === e, `${msg}\n expected: ${e}\n actual: ${a}`)
67
- }
68
-
69
- /**
70
- * Extract a YAML literal block scalar (`prefix: |-`) by indentation. Deliberately does
71
- * not depend on a YAML library: this file must run inside the published package, where
72
- * only the DSH host (not this repo) has one. The block's own indentation is stripped so
73
- * the `prefix` and `text` bodies can be compared line by line.
74
- */
75
- function literalBlocks(yml) {
76
- const out = {}
77
- const lines = yml.split(/\r?\n/)
78
- for (let i = 0; i < lines.length; i++) {
79
- const m = /^(\s*)([A-Za-z_][\w-]*):\s*\|-?\s*$/.exec(lines[i])
80
- if (!m) continue
81
- const ind = m[1].length
82
- const body = []
83
- for (let j = i + 1; j < lines.length; j++) {
84
- const L = lines[j]
85
- if (L.trim() === '') { body.push(''); continue }
86
- const li = L.length - L.trimStart().length
87
- if (li <= ind) break
88
- body.push(L.slice(ind + 2))
89
- }
90
- out[m[2]] = body
91
- }
92
- return out
93
- }
94
-
95
- /**
96
- * The slice of a plugin that describes and implements its `/vN` slash command: the
97
- * `commands.register({...})` block, plus — for v2/v3, which dispatch through a named function —
98
- * the `dispatchVibeCommand` body (that is where their branch list and usage string live).
99
- * The anchor includes the `{` on purpose: the files also mention `commands.register()` in a
100
- * prose comment *before* the real call, and anchoring on the call's object literal skips it.
101
- */
102
- function commandRegion(src) {
103
- const i = src.indexOf('commands.register({')
104
- if (i < 0) return null
105
- const end = src.indexOf('\n }))', i)
106
- let region = src.slice(i, end > i ? end : Math.min(src.length, i + 8000))
107
- if (!/cmd\s*===\s*'/.test(region)) {
108
- const d = src.indexOf('dispatchVibeCommand(cmd, args)')
109
- if (d >= 0) {
110
- const dEnd = src.indexOf('\n }', d)
111
- region += '\n' + src.slice(d, dEnd > d ? dEnd : d + 8000)
112
- }
113
- }
114
- return region
115
- }
116
-
117
- /** Split a `a|b <x|y>|c` advertised list into bare subcommand names. */
118
- function cmdsFrom(list) {
119
- const out = []
120
- const bare = String(list).replace(/\[[^\]]*\]/g, ' ').replace(/<[^>]*>/g, ' ')
121
- for (const tok of bare.split('|')) {
122
- const t = tok.trim()
123
- if (!t || t === '...') continue
124
- const name = t.split(/\s+/)[0]
125
- if (/^[a-z][a-z0-9-]*$/.test(name)) out.push(name)
126
- }
127
- return out
128
- }
129
-
130
- const PRESETS = [
131
- {
132
- dir: 'vibe-math-v2',
133
- js: 'vibe-math-v2.js',
134
- prefix: 'vibe_math_',
135
- tools: 28,
136
- // member-facing write-lock / scheduler-metadata tools; the v2 coordinator never
137
- // writes Markdown itself, so they stay out of its persona.
138
- undocumented: [],
139
- lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib', 'vibe_math_lean_read', 'vibe_math_lean_job'], extra: [] },
140
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
141
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
142
- math: { tools: ['math_computation'] },
143
- },
144
- {
145
- dir: 'vibe-math-v3',
146
- js: 'vibe-math-v3.js',
147
- prefix: 'vibe_math_',
148
- tools: 36,
149
- // `vibe_math_sync_meta` is no longer undocumented: the promotion-contract line of the
150
- // persona now names it as the place to report `lemmas[].价值/关键性` (v3 audit M1 —
151
- // without a prompt-side source for that field the promotion main line is unreachable).
152
- // The write-lock pair stays out: solver/method-keeper prompts carry it via kcWriteRules().
153
- undocumented: ['vibe_math_claim_write', 'vibe_math_release_write'],
154
- lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib', 'vibe_math_lean_read', 'vibe_math_lean_job'], extra: [] },
155
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
156
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
157
- math: { tools: ['math_computation'] },
158
- },
159
- {
160
- dir: 'vibe-math-v4',
161
- js: 'vibe-math-v4.js',
162
- prefix: 'vibe_v4_',
163
- tools: 35,
164
- // resident-facing tools (mail, library cards, task board, write lock). The
165
- // coordinator drives residents through vibe_v4_message / _meeting / _add_member.
166
- undocumented: [
167
- 'vibe_v4_send_message', 'vibe_v4_publish_progress', 'vibe_v4_record_proposition',
168
- 'vibe_v4_record_method', 'vibe_v4_record_subproblem', 'vibe_v4_read_progress',
169
- 'vibe_v4_list_residents', 'vibe_v4_propose_task', 'vibe_v4_claim_task',
170
- 'vibe_v4_task_done', 'vibe_v4_list_tasks', 'vibe_v4_report_context',
171
- 'vibe_v4_claim_write', 'vibe_v4_release_write',
172
- ],
173
- 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'] },
174
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
175
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
176
- math: { tools: ['math_computation'] },
177
- },
178
- {
179
- dir: 'vibe-math-v5',
180
- js: 'vibe-math-v5.js',
181
- prefix: 'vibe_v5_',
182
- tools: 40,
183
- // member- and academician-facing tools; the office (main agent) holds only the
184
- // institute-level controls plus the hiring authority. The final-paper pair is an
185
- // OFFICE control and IS named in the persona (vibe_v5_paper / vibe_v5_finalize_paper),
186
- // so it must not appear in this snapshot.
187
- undocumented: [
188
- 'vibe_v5_wait', 'vibe_v5_record_progress', 'vibe_v5_record_proposition',
189
- 'vibe_v5_record_method', 'vibe_v5_record_subproblem', 'vibe_v5_read_library',
190
- 'vibe_v5_propose_verify', 'vibe_v5_verdict', 'vibe_v5_task_create',
191
- 'vibe_v5_task_list', 'vibe_v5_task_get', 'vibe_v5_task_update',
192
- 'vibe_v5_overview', 'vibe_v5_assign', 'vibe_v5_prioritize', 'vibe_v5_nudge',
193
- ],
194
- lean: { tools: ['vibe_v5_lean_run', 'vibe_v5_lean_archive', 'vibe_v5_lean_lib', 'vibe_v5_lean_read', 'vibe_v5_lean_job'], extra: [] },
195
- // the shared math-computation tool (docs/math-computation.md) is prefix-less by
196
- // design (INTERFACE-FREEZE.md §3), so it is pinned explicitly here.
197
- math: { tools: ['math_computation'] },
198
- },
199
- ]
200
-
201
- const LEAN_PARAMS = ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs', 'leanAsync', 'leanJobsMaxParallel', 'leanInitiative', 'leanSearchPaths']
202
-
203
- for (const P of PRESETS) {
204
- const ymlPath = join(BASE, P.dir, 'agent.cordis.yml')
205
- const jsPath = join(BASE, P.dir, P.js)
206
- const yml = readFileSync(ymlPath, 'utf8')
207
- const src = readFileSync(jsPath, 'utf8')
208
-
209
- // ---- registered tools -------------------------------------------------
210
- // Two registration shapes exist now:
211
- // 1. a literal `registerTool('<name>', …)` (most tools, including the shared math tool in
212
- // v2/v3, which register it by its frozen prefix-less name);
213
- // 2. the shared math tool in v4/v5, where the plugin only wires a host callback and the module
214
- // calls `host.register(MATH_TOOL_NAME, …)`. The module's name is FROZEN
215
- // (MATH_TOOL_NAME = 'math_computation', INTERFACE-FREEZE.md §3), so a source that wires
216
- // `registerMathComputation(` contributes exactly that one tool. Without this the shared tool
217
- // would be invisible to this guard on v4/v5 - a silent coverage hole.
218
- const registered = new Set()
219
- for (const m of src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)) registered.add(m[1])
220
- if (/registerMathComputation\s*\(/.test(src)) registered.add('math_computation')
221
- eq(registered.size, P.tools, `${P.dir}: registered tool count changed (update this snapshot deliberately)`)
222
- for (const tool of (P.math ? P.math.tools : [])) ok(registered.has(tool), `${P.dir}: the shared math tool ${tool} is not registered`)
223
-
224
- // ---- A. both literal blocks exist and are non-empty -------------------
225
- const blocks = literalBlocks(yml)
226
- const hasPersona = /^\s*-\s*id:\s*persona\s*$/m.test(yml)
227
- ok(hasPersona, `${P.dir}: no persona row in agent.cordis.yml`)
228
- for (const key of ['prefix', 'text']) {
229
- ok(Array.isArray(blocks[key]) && blocks[key].length > 0, `${P.dir}: persona.config.${key} is missing or not a literal block`)
230
- }
231
- if (!blocks.prefix || !blocks.text) { ok(false, `${P.dir}: cannot continue without both blocks`); continue }
232
-
233
- // ---- B. the two blocks must not drift --------------------------------
234
- const trimTail = (a) => { const b = a.slice(); while (b.length && b[b.length - 1] === '') b.pop(); return b }
235
- const p = trimTail(blocks.prefix)
236
- const t = trimTail(blocks.text)
237
- eq(p.length, t.length, `${P.dir}: prefix and text have different line counts`)
238
- const drift = []
239
- for (let i = 1; i < Math.max(p.length, t.length); i++) if (p[i] !== t[i]) drift.push(i)
240
- ok(drift.length === 0, `${P.dir}: prefix and text differ on line(s) ${drift.slice(0, 5).join(', ')} (only line 0 may differ)`)
241
- ok(/\{\{model\}\}/.test(p[0]) || /\{\{model\}\}/.test(t[0]), `${P.dir}: line 0 does not interpolate {{model}}`)
242
-
243
- // ---- C/D. mention <-> registry ----------------------------------------
244
- for (const key of ['prefix', 'text']) {
245
- const body = blocks[key].join('\n')
246
- const mentioned = new Set()
247
- const wildcards = new Set()
248
- // Uppercase is part of the token class on purpose: a drifted name such as
249
- // `vibe_v4_formal_reportX` must be captured whole and judged unregistered, instead of
250
- // being read as a mention of the registered `vibe_v4_formal_report`.
251
- // Names that do NOT carry this preset's prefix (the shared `math_computation`) are added to the
252
- // alternation from the registry, so "every registered tool must be mentioned" stays true for
253
- // them too instead of forcing them into the `undocumented` snapshot.
254
- const prefixRoot = P.prefix.replace(/_$/, '')
255
- const prefixless = [...registered].filter((n) => !new RegExp('^' + prefixRoot + '_').test(n))
256
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
257
- const re = new RegExp('\\b' + prefixRoot + '_[A-Za-z0-9_]+' + (prefixless.length ? '|\\b' + prefixless.map(escapeRe).join('\\b|\\b') + '\\b' : ''), 'g')
258
- for (const m of body.matchAll(re)) {
259
- if (body[m.index + m[0].length] === '*') { wildcards.add(m[0]); continue }
260
- mentioned.add(m[0])
261
- }
262
- const stale = [...mentioned].filter((n) => !registered.has(n)).sort()
263
- eq(stale, [], `${P.dir} [${key}]: persona advertises tool(s) that are not registered`)
264
- for (const w of wildcards) {
265
- ok([...registered].some((n) => n.startsWith(w)), `${P.dir} [${key}]: wildcard mention ${w}* matches no registered tool`)
266
- }
267
- const undocumented = [...registered].filter((n) => !mentioned.has(n)).sort()
268
- eq(undocumented, [...P.undocumented].sort(), `${P.dir} [${key}]: undocumented-tool snapshot changed`)
269
- }
270
-
271
- // ---- F. the slash-command surface -------------------------------------
272
- // The persona enumerates the `/vN` subcommands; the plugin advertises them twice more
273
- // (the command `hint` the user sees while typing, and the `usage` string returned by the
274
- // unknown-subcommand path) and implements them in a third place. A `/v4` usage string once
275
- // advertised a `message` subcommand that no branch implemented; the v5 persona and plan
276
- // omitted `add|remove` although the handler implements them. All three surfaces must agree.
277
- const region = commandRegion(src)
278
- if (!ok(region !== null, `${P.dir}: no commands.register(...) block found`)) continue
279
- const hinted = cmdsFrom(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
280
- // The LAST `usage:` is the unknown-subcommand advertisement; earlier ones are inline
281
- // per-branch usage hints (e.g. `/v4 message <to|all> <content>`).
282
- const usageMatches = [...region.matchAll(/usage:\s*'([^']*)'/g)]
283
- const used = cmdsFrom(usageMatches.length ? usageMatches[usageMatches.length - 1][1] : '')
284
- const implemented = [...new Set([...region.matchAll(/cmd\s*===\s*'([a-z0-9_-]+)'/g)].map((m) => m[1]))].sort()
285
- const hintOpen = /\.\.\./.test(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
286
- ok(hinted.length > 0 && implemented.length > 0, `${P.dir}: could not extract the slash-command surface (hint ${hinted.length}, branches ${implemented.length})`)
287
- eq(hinted.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the slash hint advertises subcommand(s) that no branch implements`)
288
- if (!hintOpen) eq(implemented.filter((c) => !hinted.includes(c)).sort(), [], `${P.dir}: implemented slash subcommand(s) are missing from the hint`)
289
- eq(used.sort(), implemented, `${P.dir}: the usage string and the implemented slash subcommands disagree`)
290
- const personaEnum = /slash command mirrors[^(]*\(([\s\S]*?)\)/.exec(blocks.prefix.join('\n') + '\n' + blocks.text.join('\n'))
291
- // An ellipsis must NOT switch this check off (that is how a dropped `paper` entry survived here),
292
- // and `...` inside an argument placeholder (`set <k=v>...`) is not an abbreviation at all. So the
293
- // persona is compared against the BRANCHES (the ground truth), and every implemented subcommand
294
- // must be named EXPLICITLY. Only a HINT that is itself abbreviated makes the reverse direction
295
- // (persona ⊆ hint) unprovable, so only that direction is relaxed then.
296
- if (!personaEnum) {
297
- ok(false, `${P.dir}: the persona never enumerates its /vN subcommands (list them so a human can see every one)`)
298
- } else {
299
- const personaCmds = cmdsFrom(personaEnum[1].replace(/^\s*\/[a-z0-9]+\s+/, ''))
300
- ok(personaCmds.length > 0, `${P.dir}: no slash subcommand could be parsed from the persona's /vN list`)
301
- eq(personaCmds.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the persona enumerates slash subcommand(s) the handler does not implement`)
302
- 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 "..."`)
303
- if (!hintOpen) eq(personaCmds.slice().sort(), implemented.slice().sort(), `${P.dir}: the persona's /vN list and the command hint disagree`)
304
- }
305
-
306
- // ---- E. the Lean feature's prompt surface ----------------------------
307
- const names = (s) => new RegExp('\\b' + s + '\\b')
308
- for (const key of ['prefix', 'text']) {
309
- const body = blocks[key].join('\n')
310
- for (const tool of P.lean.tools) ok(names(tool).test(body), `${P.dir} [${key}]: Lean tool ${tool} is never named`)
311
- for (const tool of P.lean.extra) ok(names(tool).test(body), `${P.dir} [${key}]: ${tool} is never named`)
312
- for (const prm of LEAN_PARAMS) ok(names(prm).test(body), `${P.dir} [${key}]: Lean parameter ${prm} is never named`)
313
- for (const mode of ['off', 'encourage', 'require']) ok(body.includes(`'${mode}'`), `${P.dir} [${key}]: Lean mode ${mode} is never spelled out`)
314
- ok(/FIDELITY|忠实性/.test(body), `${P.dir} [${key}]: the fidelity switch of a passing Lean run is not stated`)
315
- ok(body.includes('Formal/Lib') && body.includes('Formal/Proved'), `${P.dir} [${key}]: the cross-project Lean library paths are not stated`)
316
- ok(body.includes('Verified/Lean'), `${P.dir} [${key}]: the archived-proof path Verified/Lean is not stated`)
317
- }
318
- }
319
-
320
- // ---- the shared contract must stay in step with the personas -----------
321
- try {
322
- const contract = readFileSync(join(ROOT, 'docs', 'formal-verification.md'), 'utf8')
323
- for (const prm of LEAN_PARAMS) ok(contract.includes(prm), `docs/formal-verification.md: parameter ${prm} is not documented`)
324
- for (const sec of ['off', 'encourage', 'require']) ok(new RegExp('`' + sec + '`').test(contract), `docs/formal-verification.md: mode ${sec} is not documented`)
325
- ok(contract.includes('Verified/Lean'), 'docs/formal-verification.md: the archived-proof path is not documented')
326
- ok(contract.includes('Formal/Lib') && contract.includes('Formal/Proved'), 'docs/formal-verification.md: the global library paths are not documented')
327
- } catch (e) {
328
- ok(false, `docs/formal-verification.md: cannot read the shared contract (${e.message})`)
329
- }
330
-
331
- // ---- G. the human-reviewable persona corpus (shipped) -------------------
332
- // Assertions are not enough: the reviewer must be able to READ what the main agent receives
333
- // without digging through YAML (see AUDIT-CHECKLIST §2.4). This writes the exact persona text
334
- // of all four presets to `prompt-corpus-persona/` and is skipped when running against a copy
335
- // (PERSONA_ROOT), so a mutation probe can never overwrite the shipped corpus.
336
- {
337
- const rows = PRESETS.map((P) => {
338
- const yml = readFileSync(join(BASE, P.dir, 'agent.cordis.yml'), 'utf8')
339
- const b = literalBlocks(yml)
340
- const src = readFileSync(join(BASE, P.dir, P.js), 'utf8')
341
- const registered = [...new Set([...src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)].map((m) => m[1]))].sort()
342
- const region = commandRegion(src) || ''
343
- return {
344
- preset: P.dir,
345
- tools: registered.length,
346
- prefix: (b.prefix || []).join('\n'),
347
- text: (b.text || []).join('\n'),
348
- slashHint: (region.match(/hint:\s*'\[(.*)\]'/) || [])[1] || '',
349
- }
350
- })
351
- eq(rows.length, PRESETS.length, 'persona corpus: not every preset was captured')
352
- ok(rows.every((r) => r.prefix.length > 0 && r.text.length > 0), 'persona corpus: a preset has an empty persona block')
353
- const corpusDir = join(ROOT, 'prompt-corpus-persona')
354
- if (!process.env.PERSONA_ROOT) {
355
- const md = ['# 四个预设的 persona 原文(主代理实际收到的提示词)', '',
356
- '> 本文件由 `audit-persona-surface.test.mjs` 生成,供人工复核:四个预设的主代理分别被告知了',
357
- '> 哪些工具、哪些参数、哪些斜杠子命令。`prefix` 与 `text` 两个块**只允许第 0 行不同**。', '']
358
- for (const r of rows) {
359
- md.push(`## ${r.preset}`, '', `- 注册工具数:**${r.tools}**`, `- 斜杠命令 hint:\`${r.slashHint}\``, '',
360
- '### config.prefix', '', '```text', r.prefix, '```', '', '### config.text', '', '```text', r.text, '```', '')
361
- }
362
- mkdirSync(corpusDir, { recursive: true })
363
- writeFileSync(join(corpusDir, 'persona-corpus.md'), md.join('\n'), 'utf8')
364
- writeFileSync(join(corpusDir, 'persona-corpus.json'), JSON.stringify({ presets: rows }, null, 2) + '\n', 'utf8')
365
- }
366
- // Round-trip check: the shipped corpus must name all four presets and carry line 0 of each block.
367
- // Skipped under PERSONA_ROOT: there `rows` describe the mutated copy while the corpus on disk
368
- // describes the real presets, so comparing them would be meaningless.
369
- if (process.env.PERSONA_ROOT) {
370
- ok(existsSync(join(corpusDir, 'persona-corpus.md')) && existsSync(join(corpusDir, 'persona-corpus.json')),
371
- 'persona corpus: the shipped corpus files are missing from the repository')
372
- } else {
373
- try {
374
- const back = readFileSync(join(corpusDir, 'persona-corpus.md'), 'utf8')
375
- for (const r of rows) {
376
- ok(back.includes(`## ${r.preset}`), `persona corpus: ${r.preset} is missing from the shipped corpus`)
377
- ok(back.includes(r.prefix.split('\n')[0]), `persona corpus: ${r.preset} prefix line 0 is missing from the shipped corpus`)
378
- }
379
- const backJson = JSON.parse(readFileSync(join(corpusDir, 'persona-corpus.json'), 'utf8'))
380
- eq(backJson.presets.length, PRESETS.length, 'persona corpus (json): not every preset was captured')
381
- } catch (e) {
382
- ok(false, `persona corpus: cannot read back the shipped corpus (${e.message})`)
383
- }
384
- }
385
- }
386
-
387
- console.log('')
388
- for (const f of failures) console.log(' FAIL ' + f)
389
- console.log(`\n=== PERSONA SURFACE RESULT: ${passed} passed, ${failed} failed ===`)
390
- 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)