dsh-vibe-math 2.3.12 → 2.3.14

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 (49) hide show
  1. package/README.md +17 -13
  2. package/cordis.patch.yml +1 -1
  3. package/{AUDIT-CHECKLIST.md → docs/AUDIT-CHECKLIST.md} +323 -304
  4. package/docs/COMPAT-AUDIT-ROUND2.md +325 -0
  5. package/docs/generate_framework_diagram_v2.py +114 -0
  6. package/docs/generate_framework_diagram_v3.py +127 -0
  7. package/{RELEASE-NOTES-2.1.0.md → docs/release-notes/RELEASE-NOTES-2.1.0.md} +143 -143
  8. package/{RELEASE-NOTES-2.2.0.md → docs/release-notes/RELEASE-NOTES-2.2.0.md} +266 -266
  9. package/{RELEASE-NOTES-2.2.1.md → docs/release-notes/RELEASE-NOTES-2.2.1.md} +43 -43
  10. package/{RELEASE-NOTES-2.2.2.md → docs/release-notes/RELEASE-NOTES-2.2.2.md} +88 -88
  11. package/{RELEASE-NOTES-2.3.0.md → docs/release-notes/RELEASE-NOTES-2.3.0.md} +207 -207
  12. package/{RELEASE-NOTES-2.3.1.md → docs/release-notes/RELEASE-NOTES-2.3.1.md} +134 -134
  13. package/{RELEASE-NOTES-2.3.10.md → docs/release-notes/RELEASE-NOTES-2.3.10.md} +105 -105
  14. package/{RELEASE-NOTES-2.3.11.md → docs/release-notes/RELEASE-NOTES-2.3.11.md} +57 -57
  15. package/{RELEASE-NOTES-2.3.12.md → docs/release-notes/RELEASE-NOTES-2.3.12.md} +80 -80
  16. package/docs/release-notes/RELEASE-NOTES-2.3.13.md +137 -0
  17. package/docs/release-notes/RELEASE-NOTES-2.3.14.md +83 -0
  18. package/{RELEASE-NOTES-2.3.2.md → docs/release-notes/RELEASE-NOTES-2.3.2.md} +145 -145
  19. package/{RELEASE-NOTES-2.3.3.md → docs/release-notes/RELEASE-NOTES-2.3.3.md} +115 -115
  20. package/{RELEASE-NOTES-2.3.4.md → docs/release-notes/RELEASE-NOTES-2.3.4.md} +69 -69
  21. package/{RELEASE-NOTES-2.3.5.md → docs/release-notes/RELEASE-NOTES-2.3.5.md} +63 -63
  22. package/{RELEASE-NOTES-2.3.6.md → docs/release-notes/RELEASE-NOTES-2.3.6.md} +66 -66
  23. package/{RELEASE-NOTES-2.3.7.md → docs/release-notes/RELEASE-NOTES-2.3.7.md} +59 -59
  24. package/{RELEASE-NOTES-2.3.8.md → docs/release-notes/RELEASE-NOTES-2.3.8.md} +45 -45
  25. package/{RELEASE-NOTES-2.3.9.md → docs/release-notes/RELEASE-NOTES-2.3.9.md} +70 -70
  26. package/docs/test-timing.md +19 -18
  27. package/installer.js +115 -41
  28. package/package.json +43 -37
  29. package/{audit-formal-sensitivity.mjs → tests/audit-formal-sensitivity.mjs} +342 -342
  30. package/{audit-installer-compat.test.mjs → tests/audit-installer-compat.test.mjs} +136 -136
  31. package/tests/audit-installer-policy.test.mjs +261 -0
  32. package/{audit-persona-sensitivity.mjs → tests/audit-persona-sensitivity.mjs} +249 -249
  33. package/{audit-persona-surface.test.mjs → tests/audit-persona-surface.test.mjs} +349 -349
  34. package/{audit-prompt-invariants.mjs → tests/audit-prompt-invariants.mjs} +508 -508
  35. package/{audit-spec-traceability.mjs → tests/audit-spec-traceability.mjs} +193 -193
  36. package/{audit-v5-integrity.mjs → tests/audit-v5-integrity.mjs} +448 -448
  37. package/{audit-v5-sensitivity.mjs → tests/audit-v5-sensitivity.mjs} +384 -384
  38. package/{e2e-v5-round2.test.mjs → tests/e2e-v5-round2.test.mjs} +521 -521
  39. package/{formal-verify-v2.test.mjs → tests/formal-verify-v2.test.mjs} +1315 -1315
  40. package/{formal-verify-v3.test.mjs → tests/formal-verify-v3.test.mjs} +1257 -1257
  41. package/{formal-verify-v4.test.mjs → tests/formal-verify-v4.test.mjs} +1082 -1082
  42. package/{formal-verify-v5.test.mjs → tests/formal-verify-v5.test.mjs} +708 -708
  43. package/{prompt-v5-integrity.test.mjs → tests/prompt-v5-integrity.test.mjs} +3 -3
  44. package/{run-tests.mjs → tests/run-tests.mjs} +121 -118
  45. package/{selfdrive-v5.mjs → tests/selfdrive-v5.mjs} +470 -470
  46. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  47. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  48. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +1 -1
  49. /package/{RELEASE-NOTES-2.0.22.md → docs/release-notes/RELEASE-NOTES-2.0.22.md} +0 -0
@@ -1,349 +1,349 @@
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 this file's own directory, 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: 25,
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'], extra: [] },
140
- },
141
- {
142
- dir: 'vibe-math-v3',
143
- js: 'vibe-math-v3.js',
144
- prefix: 'vibe_math_',
145
- tools: 33,
146
- undocumented: ['vibe_math_claim_write', 'vibe_math_release_write', 'vibe_math_sync_meta'],
147
- lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib'], extra: [] },
148
- },
149
- {
150
- dir: 'vibe-math-v4',
151
- js: 'vibe-math-v4.js',
152
- prefix: 'vibe_v4_',
153
- tools: 32,
154
- // resident-facing tools (mail, library cards, task board, write lock). The
155
- // coordinator drives residents through vibe_v4_message / _meeting / _add_member.
156
- undocumented: [
157
- 'vibe_v4_send_message', 'vibe_v4_publish_progress', 'vibe_v4_record_proposition',
158
- 'vibe_v4_record_method', 'vibe_v4_record_subproblem', 'vibe_v4_read_progress',
159
- 'vibe_v4_list_residents', 'vibe_v4_propose_task', 'vibe_v4_claim_task',
160
- 'vibe_v4_task_done', 'vibe_v4_list_tasks', 'vibe_v4_report_context',
161
- 'vibe_v4_claim_write', 'vibe_v4_release_write',
162
- ],
163
- lean: { tools: ['vibe_v4_lean_run', 'vibe_v4_lean_archive', 'vibe_v4_lean_lib'], extra: ['vibe_v4_formal_report'] },
164
- },
165
- {
166
- dir: 'vibe-math-v5',
167
- js: 'vibe-math-v5.js',
168
- prefix: 'vibe_v5_',
169
- tools: 35,
170
- // member- and academician-facing tools; the office (main agent) holds only the
171
- // institute-level controls plus the hiring authority.
172
- undocumented: [
173
- 'vibe_v5_wait', 'vibe_v5_record_progress', 'vibe_v5_record_proposition',
174
- 'vibe_v5_record_method', 'vibe_v5_record_subproblem', 'vibe_v5_read_library',
175
- 'vibe_v5_propose_verify', 'vibe_v5_verdict', 'vibe_v5_task_create',
176
- 'vibe_v5_task_list', 'vibe_v5_task_get', 'vibe_v5_task_update',
177
- 'vibe_v5_overview', 'vibe_v5_assign', 'vibe_v5_prioritize', 'vibe_v5_nudge',
178
- ],
179
- lean: { tools: ['vibe_v5_lean_run', 'vibe_v5_lean_archive', 'vibe_v5_lean_lib'], extra: [] },
180
- },
181
- ]
182
-
183
- const LEAN_PARAMS = ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']
184
-
185
- for (const P of PRESETS) {
186
- const ymlPath = join(BASE, P.dir, 'agent.cordis.yml')
187
- const jsPath = join(BASE, P.dir, P.js)
188
- const yml = readFileSync(ymlPath, 'utf8')
189
- const src = readFileSync(jsPath, 'utf8')
190
-
191
- // ---- registered tools -------------------------------------------------
192
- const registered = new Set()
193
- for (const m of src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)) registered.add(m[1])
194
- eq(registered.size, P.tools, `${P.dir}: registered tool count changed (update this snapshot deliberately)`)
195
-
196
- // ---- A. both literal blocks exist and are non-empty -------------------
197
- const blocks = literalBlocks(yml)
198
- const hasPersona = /^\s*-\s*id:\s*persona\s*$/m.test(yml)
199
- ok(hasPersona, `${P.dir}: no persona row in agent.cordis.yml`)
200
- for (const key of ['prefix', 'text']) {
201
- ok(Array.isArray(blocks[key]) && blocks[key].length > 0, `${P.dir}: persona.config.${key} is missing or not a literal block`)
202
- }
203
- if (!blocks.prefix || !blocks.text) { ok(false, `${P.dir}: cannot continue without both blocks`); continue }
204
-
205
- // ---- B. the two blocks must not drift --------------------------------
206
- const trimTail = (a) => { const b = a.slice(); while (b.length && b[b.length - 1] === '') b.pop(); return b }
207
- const p = trimTail(blocks.prefix)
208
- const t = trimTail(blocks.text)
209
- eq(p.length, t.length, `${P.dir}: prefix and text have different line counts`)
210
- const drift = []
211
- for (let i = 1; i < Math.max(p.length, t.length); i++) if (p[i] !== t[i]) drift.push(i)
212
- ok(drift.length === 0, `${P.dir}: prefix and text differ on line(s) ${drift.slice(0, 5).join(', ')} (only line 0 may differ)`)
213
- ok(/\{\{model\}\}/.test(p[0]) || /\{\{model\}\}/.test(t[0]), `${P.dir}: line 0 does not interpolate {{model}}`)
214
-
215
- // ---- C/D. mention <-> registry ----------------------------------------
216
- for (const key of ['prefix', 'text']) {
217
- const body = blocks[key].join('\n')
218
- const mentioned = new Set()
219
- const wildcards = new Set()
220
- // Uppercase is part of the token class on purpose: a drifted name such as
221
- // `vibe_v4_formal_reportX` must be captured whole and judged unregistered, instead of
222
- // being read as a mention of the registered `vibe_v4_formal_report`.
223
- const re = new RegExp('\\b' + P.prefix.replace(/_$/, '') + '_[A-Za-z0-9_]+', 'g')
224
- for (const m of body.matchAll(re)) {
225
- if (body[m.index + m[0].length] === '*') { wildcards.add(m[0]); continue }
226
- mentioned.add(m[0])
227
- }
228
- const stale = [...mentioned].filter((n) => !registered.has(n)).sort()
229
- eq(stale, [], `${P.dir} [${key}]: persona advertises tool(s) that are not registered`)
230
- for (const w of wildcards) {
231
- ok([...registered].some((n) => n.startsWith(w)), `${P.dir} [${key}]: wildcard mention ${w}* matches no registered tool`)
232
- }
233
- const undocumented = [...registered].filter((n) => !mentioned.has(n)).sort()
234
- eq(undocumented, [...P.undocumented].sort(), `${P.dir} [${key}]: undocumented-tool snapshot changed`)
235
- }
236
-
237
- // ---- F. the slash-command surface -------------------------------------
238
- // The persona enumerates the `/vN` subcommands; the plugin advertises them twice more
239
- // (the command `hint` the user sees while typing, and the `usage` string returned by the
240
- // unknown-subcommand path) and implements them in a third place. A `/v4` usage string once
241
- // advertised a `message` subcommand that no branch implemented; the v5 persona and plan
242
- // omitted `add|remove` although the handler implements them. All three surfaces must agree.
243
- const region = commandRegion(src)
244
- if (!ok(region !== null, `${P.dir}: no commands.register(...) block found`)) continue
245
- const hinted = cmdsFrom(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
246
- // The LAST `usage:` is the unknown-subcommand advertisement; earlier ones are inline
247
- // per-branch usage hints (e.g. `/v4 message <to|all> <content>`).
248
- const usageMatches = [...region.matchAll(/usage:\s*'([^']*)'/g)]
249
- const used = cmdsFrom(usageMatches.length ? usageMatches[usageMatches.length - 1][1] : '')
250
- const implemented = [...new Set([...region.matchAll(/cmd\s*===\s*'([a-z0-9_-]+)'/g)].map((m) => m[1]))].sort()
251
- const hintOpen = /\.\.\./.test(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
252
- ok(hinted.length > 0 && implemented.length > 0, `${P.dir}: could not extract the slash-command surface (hint ${hinted.length}, branches ${implemented.length})`)
253
- eq(hinted.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the slash hint advertises subcommand(s) that no branch implements`)
254
- if (!hintOpen) eq(implemented.filter((c) => !hinted.includes(c)).sort(), [], `${P.dir}: implemented slash subcommand(s) are missing from the hint`)
255
- eq(used.sort(), implemented, `${P.dir}: the usage string and the implemented slash subcommands disagree`)
256
- const personaEnum = /slash command mirrors[^(]*\(([\s\S]*?)\)/.exec(blocks.prefix.join('\n') + '\n' + blocks.text.join('\n'))
257
- if (personaEnum && personaEnum[1].includes('|')) {
258
- const personaCmds = cmdsFrom(personaEnum[1].replace(/^\s*\/[a-z0-9]+\s+/, ''))
259
- if (personaCmds.length) {
260
- eq(personaCmds.filter((c) => !hinted.includes(c)).sort(), [], `${P.dir}: the persona enumerates slash subcommand(s) the hint does not know`)
261
- if (!/\.\.\./.test(personaEnum[1]) && !hintOpen) eq(personaCmds.slice().sort(), hinted.slice().sort(), `${P.dir}: the persona's /vN list and the command hint disagree`)
262
- }
263
- }
264
-
265
- // ---- E. the Lean feature's prompt surface ----------------------------
266
- const names = (s) => new RegExp('\\b' + s + '\\b')
267
- for (const key of ['prefix', 'text']) {
268
- const body = blocks[key].join('\n')
269
- for (const tool of P.lean.tools) ok(names(tool).test(body), `${P.dir} [${key}]: Lean tool ${tool} is never named`)
270
- for (const tool of P.lean.extra) ok(names(tool).test(body), `${P.dir} [${key}]: ${tool} is never named`)
271
- for (const prm of LEAN_PARAMS) ok(names(prm).test(body), `${P.dir} [${key}]: Lean parameter ${prm} is never named`)
272
- for (const mode of ['off', 'encourage', 'require']) ok(body.includes(`'${mode}'`), `${P.dir} [${key}]: Lean mode ${mode} is never spelled out`)
273
- ok(/FIDELITY|忠实性/.test(body), `${P.dir} [${key}]: the fidelity switch of a passing Lean run is not stated`)
274
- ok(body.includes('Formal/Lib') && body.includes('Formal/Proved'), `${P.dir} [${key}]: the cross-project Lean library paths are not stated`)
275
- ok(body.includes('Verified/Lean'), `${P.dir} [${key}]: the archived-proof path Verified/Lean is not stated`)
276
- }
277
- }
278
-
279
- // ---- the shared contract must stay in step with the personas -----------
280
- try {
281
- const contract = readFileSync(join(ROOT, 'docs', 'formal-verification.md'), 'utf8')
282
- for (const prm of LEAN_PARAMS) ok(contract.includes(prm), `docs/formal-verification.md: parameter ${prm} is not documented`)
283
- for (const sec of ['off', 'encourage', 'require']) ok(new RegExp('`' + sec + '`').test(contract), `docs/formal-verification.md: mode ${sec} is not documented`)
284
- ok(contract.includes('Verified/Lean'), 'docs/formal-verification.md: the archived-proof path is not documented')
285
- ok(contract.includes('Formal/Lib') && contract.includes('Formal/Proved'), 'docs/formal-verification.md: the global library paths are not documented')
286
- } catch (e) {
287
- ok(false, `docs/formal-verification.md: cannot read the shared contract (${e.message})`)
288
- }
289
-
290
- // ---- G. the human-reviewable persona corpus (shipped) -------------------
291
- // Assertions are not enough: the reviewer must be able to READ what the main agent receives
292
- // without digging through YAML (see AUDIT-CHECKLIST §2.4). This writes the exact persona text
293
- // of all four presets to `prompt-corpus-persona/` and is skipped when running against a copy
294
- // (PERSONA_ROOT), so a mutation probe can never overwrite the shipped corpus.
295
- {
296
- const rows = PRESETS.map((P) => {
297
- const yml = readFileSync(join(BASE, P.dir, 'agent.cordis.yml'), 'utf8')
298
- const b = literalBlocks(yml)
299
- const src = readFileSync(join(BASE, P.dir, P.js), 'utf8')
300
- const registered = [...new Set([...src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)].map((m) => m[1]))].sort()
301
- const region = commandRegion(src) || ''
302
- return {
303
- preset: P.dir,
304
- tools: registered.length,
305
- prefix: (b.prefix || []).join('\n'),
306
- text: (b.text || []).join('\n'),
307
- slashHint: (region.match(/hint:\s*'\[(.*)\]'/) || [])[1] || '',
308
- }
309
- })
310
- eq(rows.length, PRESETS.length, 'persona corpus: not every preset was captured')
311
- ok(rows.every((r) => r.prefix.length > 0 && r.text.length > 0), 'persona corpus: a preset has an empty persona block')
312
- const corpusDir = join(ROOT, 'prompt-corpus-persona')
313
- if (!process.env.PERSONA_ROOT) {
314
- const md = ['# 四个预设的 persona 原文(主代理实际收到的提示词)', '',
315
- '> 本文件由 `audit-persona-surface.test.mjs` 生成,供人工复核:四个预设的主代理分别被告知了',
316
- '> 哪些工具、哪些参数、哪些斜杠子命令。`prefix` 与 `text` 两个块**只允许第 0 行不同**。', '']
317
- for (const r of rows) {
318
- md.push(`## ${r.preset}`, '', `- 注册工具数:**${r.tools}**`, `- 斜杠命令 hint:\`${r.slashHint}\``, '',
319
- '### config.prefix', '', '```text', r.prefix, '```', '', '### config.text', '', '```text', r.text, '```', '')
320
- }
321
- mkdirSync(corpusDir, { recursive: true })
322
- writeFileSync(join(corpusDir, 'persona-corpus.md'), md.join('\n'), 'utf8')
323
- writeFileSync(join(corpusDir, 'persona-corpus.json'), JSON.stringify({ presets: rows }, null, 2) + '\n', 'utf8')
324
- }
325
- // Round-trip check: the shipped corpus must name all four presets and carry line 0 of each block.
326
- // Skipped under PERSONA_ROOT: there `rows` describe the mutated copy while the corpus on disk
327
- // describes the real presets, so comparing them would be meaningless.
328
- if (process.env.PERSONA_ROOT) {
329
- ok(existsSync(join(corpusDir, 'persona-corpus.md')) && existsSync(join(corpusDir, 'persona-corpus.json')),
330
- 'persona corpus: the shipped corpus files are missing from the repository')
331
- } else {
332
- try {
333
- const back = readFileSync(join(corpusDir, 'persona-corpus.md'), 'utf8')
334
- for (const r of rows) {
335
- ok(back.includes(`## ${r.preset}`), `persona corpus: ${r.preset} is missing from the shipped corpus`)
336
- ok(back.includes(r.prefix.split('\n')[0]), `persona corpus: ${r.preset} prefix line 0 is missing from the shipped corpus`)
337
- }
338
- const backJson = JSON.parse(readFileSync(join(corpusDir, 'persona-corpus.json'), 'utf8'))
339
- eq(backJson.presets.length, PRESETS.length, 'persona corpus (json): not every preset was captured')
340
- } catch (e) {
341
- ok(false, `persona corpus: cannot read back the shipped corpus (${e.message})`)
342
- }
343
- }
344
- }
345
-
346
- console.log('')
347
- for (const f of failures) console.log(' FAIL ' + f)
348
- console.log(`\n=== PERSONA SURFACE RESULT: ${passed} passed, ${failed} failed ===`)
349
- process.exit(failed === 0 ? 0 : 1)
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: 25,
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'], extra: [] },
140
+ },
141
+ {
142
+ dir: 'vibe-math-v3',
143
+ js: 'vibe-math-v3.js',
144
+ prefix: 'vibe_math_',
145
+ tools: 33,
146
+ undocumented: ['vibe_math_claim_write', 'vibe_math_release_write', 'vibe_math_sync_meta'],
147
+ lean: { tools: ['vibe_math_lean_run', 'vibe_math_lean_archive', 'vibe_math_lean_lib'], extra: [] },
148
+ },
149
+ {
150
+ dir: 'vibe-math-v4',
151
+ js: 'vibe-math-v4.js',
152
+ prefix: 'vibe_v4_',
153
+ tools: 32,
154
+ // resident-facing tools (mail, library cards, task board, write lock). The
155
+ // coordinator drives residents through vibe_v4_message / _meeting / _add_member.
156
+ undocumented: [
157
+ 'vibe_v4_send_message', 'vibe_v4_publish_progress', 'vibe_v4_record_proposition',
158
+ 'vibe_v4_record_method', 'vibe_v4_record_subproblem', 'vibe_v4_read_progress',
159
+ 'vibe_v4_list_residents', 'vibe_v4_propose_task', 'vibe_v4_claim_task',
160
+ 'vibe_v4_task_done', 'vibe_v4_list_tasks', 'vibe_v4_report_context',
161
+ 'vibe_v4_claim_write', 'vibe_v4_release_write',
162
+ ],
163
+ lean: { tools: ['vibe_v4_lean_run', 'vibe_v4_lean_archive', 'vibe_v4_lean_lib'], extra: ['vibe_v4_formal_report'] },
164
+ },
165
+ {
166
+ dir: 'vibe-math-v5',
167
+ js: 'vibe-math-v5.js',
168
+ prefix: 'vibe_v5_',
169
+ tools: 35,
170
+ // member- and academician-facing tools; the office (main agent) holds only the
171
+ // institute-level controls plus the hiring authority.
172
+ undocumented: [
173
+ 'vibe_v5_wait', 'vibe_v5_record_progress', 'vibe_v5_record_proposition',
174
+ 'vibe_v5_record_method', 'vibe_v5_record_subproblem', 'vibe_v5_read_library',
175
+ 'vibe_v5_propose_verify', 'vibe_v5_verdict', 'vibe_v5_task_create',
176
+ 'vibe_v5_task_list', 'vibe_v5_task_get', 'vibe_v5_task_update',
177
+ 'vibe_v5_overview', 'vibe_v5_assign', 'vibe_v5_prioritize', 'vibe_v5_nudge',
178
+ ],
179
+ lean: { tools: ['vibe_v5_lean_run', 'vibe_v5_lean_archive', 'vibe_v5_lean_lib'], extra: [] },
180
+ },
181
+ ]
182
+
183
+ const LEAN_PARAMS = ['formalVerify', 'leanCommand', 'leanArgs', 'leanTimeoutMs']
184
+
185
+ for (const P of PRESETS) {
186
+ const ymlPath = join(BASE, P.dir, 'agent.cordis.yml')
187
+ const jsPath = join(BASE, P.dir, P.js)
188
+ const yml = readFileSync(ymlPath, 'utf8')
189
+ const src = readFileSync(jsPath, 'utf8')
190
+
191
+ // ---- registered tools -------------------------------------------------
192
+ const registered = new Set()
193
+ for (const m of src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)) registered.add(m[1])
194
+ eq(registered.size, P.tools, `${P.dir}: registered tool count changed (update this snapshot deliberately)`)
195
+
196
+ // ---- A. both literal blocks exist and are non-empty -------------------
197
+ const blocks = literalBlocks(yml)
198
+ const hasPersona = /^\s*-\s*id:\s*persona\s*$/m.test(yml)
199
+ ok(hasPersona, `${P.dir}: no persona row in agent.cordis.yml`)
200
+ for (const key of ['prefix', 'text']) {
201
+ ok(Array.isArray(blocks[key]) && blocks[key].length > 0, `${P.dir}: persona.config.${key} is missing or not a literal block`)
202
+ }
203
+ if (!blocks.prefix || !blocks.text) { ok(false, `${P.dir}: cannot continue without both blocks`); continue }
204
+
205
+ // ---- B. the two blocks must not drift --------------------------------
206
+ const trimTail = (a) => { const b = a.slice(); while (b.length && b[b.length - 1] === '') b.pop(); return b }
207
+ const p = trimTail(blocks.prefix)
208
+ const t = trimTail(blocks.text)
209
+ eq(p.length, t.length, `${P.dir}: prefix and text have different line counts`)
210
+ const drift = []
211
+ for (let i = 1; i < Math.max(p.length, t.length); i++) if (p[i] !== t[i]) drift.push(i)
212
+ ok(drift.length === 0, `${P.dir}: prefix and text differ on line(s) ${drift.slice(0, 5).join(', ')} (only line 0 may differ)`)
213
+ ok(/\{\{model\}\}/.test(p[0]) || /\{\{model\}\}/.test(t[0]), `${P.dir}: line 0 does not interpolate {{model}}`)
214
+
215
+ // ---- C/D. mention <-> registry ----------------------------------------
216
+ for (const key of ['prefix', 'text']) {
217
+ const body = blocks[key].join('\n')
218
+ const mentioned = new Set()
219
+ const wildcards = new Set()
220
+ // Uppercase is part of the token class on purpose: a drifted name such as
221
+ // `vibe_v4_formal_reportX` must be captured whole and judged unregistered, instead of
222
+ // being read as a mention of the registered `vibe_v4_formal_report`.
223
+ const re = new RegExp('\\b' + P.prefix.replace(/_$/, '') + '_[A-Za-z0-9_]+', 'g')
224
+ for (const m of body.matchAll(re)) {
225
+ if (body[m.index + m[0].length] === '*') { wildcards.add(m[0]); continue }
226
+ mentioned.add(m[0])
227
+ }
228
+ const stale = [...mentioned].filter((n) => !registered.has(n)).sort()
229
+ eq(stale, [], `${P.dir} [${key}]: persona advertises tool(s) that are not registered`)
230
+ for (const w of wildcards) {
231
+ ok([...registered].some((n) => n.startsWith(w)), `${P.dir} [${key}]: wildcard mention ${w}* matches no registered tool`)
232
+ }
233
+ const undocumented = [...registered].filter((n) => !mentioned.has(n)).sort()
234
+ eq(undocumented, [...P.undocumented].sort(), `${P.dir} [${key}]: undocumented-tool snapshot changed`)
235
+ }
236
+
237
+ // ---- F. the slash-command surface -------------------------------------
238
+ // The persona enumerates the `/vN` subcommands; the plugin advertises them twice more
239
+ // (the command `hint` the user sees while typing, and the `usage` string returned by the
240
+ // unknown-subcommand path) and implements them in a third place. A `/v4` usage string once
241
+ // advertised a `message` subcommand that no branch implemented; the v5 persona and plan
242
+ // omitted `add|remove` although the handler implements them. All three surfaces must agree.
243
+ const region = commandRegion(src)
244
+ if (!ok(region !== null, `${P.dir}: no commands.register(...) block found`)) continue
245
+ const hinted = cmdsFrom(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
246
+ // The LAST `usage:` is the unknown-subcommand advertisement; earlier ones are inline
247
+ // per-branch usage hints (e.g. `/v4 message <to|all> <content>`).
248
+ const usageMatches = [...region.matchAll(/usage:\s*'([^']*)'/g)]
249
+ const used = cmdsFrom(usageMatches.length ? usageMatches[usageMatches.length - 1][1] : '')
250
+ const implemented = [...new Set([...region.matchAll(/cmd\s*===\s*'([a-z0-9_-]+)'/g)].map((m) => m[1]))].sort()
251
+ const hintOpen = /\.\.\./.test(region.match(/hint:\s*'\[(.*)\]'/)?.[1] || '')
252
+ ok(hinted.length > 0 && implemented.length > 0, `${P.dir}: could not extract the slash-command surface (hint ${hinted.length}, branches ${implemented.length})`)
253
+ eq(hinted.filter((c) => !implemented.includes(c)).sort(), [], `${P.dir}: the slash hint advertises subcommand(s) that no branch implements`)
254
+ if (!hintOpen) eq(implemented.filter((c) => !hinted.includes(c)).sort(), [], `${P.dir}: implemented slash subcommand(s) are missing from the hint`)
255
+ eq(used.sort(), implemented, `${P.dir}: the usage string and the implemented slash subcommands disagree`)
256
+ const personaEnum = /slash command mirrors[^(]*\(([\s\S]*?)\)/.exec(blocks.prefix.join('\n') + '\n' + blocks.text.join('\n'))
257
+ if (personaEnum && personaEnum[1].includes('|')) {
258
+ const personaCmds = cmdsFrom(personaEnum[1].replace(/^\s*\/[a-z0-9]+\s+/, ''))
259
+ if (personaCmds.length) {
260
+ eq(personaCmds.filter((c) => !hinted.includes(c)).sort(), [], `${P.dir}: the persona enumerates slash subcommand(s) the hint does not know`)
261
+ if (!/\.\.\./.test(personaEnum[1]) && !hintOpen) eq(personaCmds.slice().sort(), hinted.slice().sort(), `${P.dir}: the persona's /vN list and the command hint disagree`)
262
+ }
263
+ }
264
+
265
+ // ---- E. the Lean feature's prompt surface ----------------------------
266
+ const names = (s) => new RegExp('\\b' + s + '\\b')
267
+ for (const key of ['prefix', 'text']) {
268
+ const body = blocks[key].join('\n')
269
+ for (const tool of P.lean.tools) ok(names(tool).test(body), `${P.dir} [${key}]: Lean tool ${tool} is never named`)
270
+ for (const tool of P.lean.extra) ok(names(tool).test(body), `${P.dir} [${key}]: ${tool} is never named`)
271
+ for (const prm of LEAN_PARAMS) ok(names(prm).test(body), `${P.dir} [${key}]: Lean parameter ${prm} is never named`)
272
+ for (const mode of ['off', 'encourage', 'require']) ok(body.includes(`'${mode}'`), `${P.dir} [${key}]: Lean mode ${mode} is never spelled out`)
273
+ ok(/FIDELITY|忠实性/.test(body), `${P.dir} [${key}]: the fidelity switch of a passing Lean run is not stated`)
274
+ ok(body.includes('Formal/Lib') && body.includes('Formal/Proved'), `${P.dir} [${key}]: the cross-project Lean library paths are not stated`)
275
+ ok(body.includes('Verified/Lean'), `${P.dir} [${key}]: the archived-proof path Verified/Lean is not stated`)
276
+ }
277
+ }
278
+
279
+ // ---- the shared contract must stay in step with the personas -----------
280
+ try {
281
+ const contract = readFileSync(join(ROOT, 'docs', 'formal-verification.md'), 'utf8')
282
+ for (const prm of LEAN_PARAMS) ok(contract.includes(prm), `docs/formal-verification.md: parameter ${prm} is not documented`)
283
+ for (const sec of ['off', 'encourage', 'require']) ok(new RegExp('`' + sec + '`').test(contract), `docs/formal-verification.md: mode ${sec} is not documented`)
284
+ ok(contract.includes('Verified/Lean'), 'docs/formal-verification.md: the archived-proof path is not documented')
285
+ ok(contract.includes('Formal/Lib') && contract.includes('Formal/Proved'), 'docs/formal-verification.md: the global library paths are not documented')
286
+ } catch (e) {
287
+ ok(false, `docs/formal-verification.md: cannot read the shared contract (${e.message})`)
288
+ }
289
+
290
+ // ---- G. the human-reviewable persona corpus (shipped) -------------------
291
+ // Assertions are not enough: the reviewer must be able to READ what the main agent receives
292
+ // without digging through YAML (see AUDIT-CHECKLIST §2.4). This writes the exact persona text
293
+ // of all four presets to `prompt-corpus-persona/` and is skipped when running against a copy
294
+ // (PERSONA_ROOT), so a mutation probe can never overwrite the shipped corpus.
295
+ {
296
+ const rows = PRESETS.map((P) => {
297
+ const yml = readFileSync(join(BASE, P.dir, 'agent.cordis.yml'), 'utf8')
298
+ const b = literalBlocks(yml)
299
+ const src = readFileSync(join(BASE, P.dir, P.js), 'utf8')
300
+ const registered = [...new Set([...src.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)].map((m) => m[1]))].sort()
301
+ const region = commandRegion(src) || ''
302
+ return {
303
+ preset: P.dir,
304
+ tools: registered.length,
305
+ prefix: (b.prefix || []).join('\n'),
306
+ text: (b.text || []).join('\n'),
307
+ slashHint: (region.match(/hint:\s*'\[(.*)\]'/) || [])[1] || '',
308
+ }
309
+ })
310
+ eq(rows.length, PRESETS.length, 'persona corpus: not every preset was captured')
311
+ ok(rows.every((r) => r.prefix.length > 0 && r.text.length > 0), 'persona corpus: a preset has an empty persona block')
312
+ const corpusDir = join(ROOT, 'prompt-corpus-persona')
313
+ if (!process.env.PERSONA_ROOT) {
314
+ const md = ['# 四个预设的 persona 原文(主代理实际收到的提示词)', '',
315
+ '> 本文件由 `audit-persona-surface.test.mjs` 生成,供人工复核:四个预设的主代理分别被告知了',
316
+ '> 哪些工具、哪些参数、哪些斜杠子命令。`prefix` 与 `text` 两个块**只允许第 0 行不同**。', '']
317
+ for (const r of rows) {
318
+ md.push(`## ${r.preset}`, '', `- 注册工具数:**${r.tools}**`, `- 斜杠命令 hint:\`${r.slashHint}\``, '',
319
+ '### config.prefix', '', '```text', r.prefix, '```', '', '### config.text', '', '```text', r.text, '```', '')
320
+ }
321
+ mkdirSync(corpusDir, { recursive: true })
322
+ writeFileSync(join(corpusDir, 'persona-corpus.md'), md.join('\n'), 'utf8')
323
+ writeFileSync(join(corpusDir, 'persona-corpus.json'), JSON.stringify({ presets: rows }, null, 2) + '\n', 'utf8')
324
+ }
325
+ // Round-trip check: the shipped corpus must name all four presets and carry line 0 of each block.
326
+ // Skipped under PERSONA_ROOT: there `rows` describe the mutated copy while the corpus on disk
327
+ // describes the real presets, so comparing them would be meaningless.
328
+ if (process.env.PERSONA_ROOT) {
329
+ ok(existsSync(join(corpusDir, 'persona-corpus.md')) && existsSync(join(corpusDir, 'persona-corpus.json')),
330
+ 'persona corpus: the shipped corpus files are missing from the repository')
331
+ } else {
332
+ try {
333
+ const back = readFileSync(join(corpusDir, 'persona-corpus.md'), 'utf8')
334
+ for (const r of rows) {
335
+ ok(back.includes(`## ${r.preset}`), `persona corpus: ${r.preset} is missing from the shipped corpus`)
336
+ ok(back.includes(r.prefix.split('\n')[0]), `persona corpus: ${r.preset} prefix line 0 is missing from the shipped corpus`)
337
+ }
338
+ const backJson = JSON.parse(readFileSync(join(corpusDir, 'persona-corpus.json'), 'utf8'))
339
+ eq(backJson.presets.length, PRESETS.length, 'persona corpus (json): not every preset was captured')
340
+ } catch (e) {
341
+ ok(false, `persona corpus: cannot read back the shipped corpus (${e.message})`)
342
+ }
343
+ }
344
+ }
345
+
346
+ console.log('')
347
+ for (const f of failures) console.log(' FAIL ' + f)
348
+ console.log(`\n=== PERSONA SURFACE RESULT: ${passed} passed, ${failed} failed ===`)
349
+ process.exit(failed === 0 ? 0 : 1)