dsh-vibe-math 2.2.1 → 2.3.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.
- package/AUDIT-CHECKLIST.md +48 -2
- package/README.md +368 -81
- package/RELEASE-NOTES-2.2.2.md +88 -0
- package/RELEASE-NOTES-2.3.0.md +207 -0
- package/audit-formal-sensitivity.mjs +247 -0
- package/audit-persona-sensitivity.mjs +249 -0
- package/audit-persona-surface.test.mjs +349 -0
- package/audit-v5-integrity.mjs +40 -1
- package/audit-v5-sensitivity.mjs +84 -6
- package/docs/formal-verification.md +321 -0
- package/docs/generate_framework_diagram_v5.mjs +290 -0
- package/docs//346/236/266/346/236/204/345/233/276.md +75 -0
- package/formal-verify-v2.test.mjs +672 -0
- package/formal-verify-v3.test.mjs +824 -0
- package/formal-verify-v4.test.mjs +603 -0
- package/formal-verify-v5.test.mjs +526 -0
- package/package.json +33 -15
- package/prompt-corpus-persona/persona-corpus.json +32 -0
- package/prompt-corpus-persona/persona-corpus.md +674 -0
- package/prompt-corpus-v3/formal-verify-v3.json +280 -0
- package/prompt-corpus-v3/formal-verify-v3.md +2826 -0
- package/prompt-corpus-v5/prompt-corpus-v5.json +131 -713
- package/prompt-corpus-v5/prompt-corpus-v5.md +1828 -5225
- package/prompt-v5-integrity.test.mjs +154 -11
- package/vibe-math-v2/agent.cordis.yml +40 -2
- package/vibe-math-v2/vibe-math-v2.js +627 -19
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +145 -1
- package/vibe-math-v3/agent.cordis.yml +46 -2
- package/vibe-math-v3/vibe-math-v3.js +749 -21
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +87 -2
- package/vibe-math-v4/agent.cordis.yml +46 -4
- package/vibe-math-v4/vibe-math-v4.js +652 -15
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +226 -0
- package/vibe-math-v5/agent.cordis.yml +41 -5
- package/vibe-math-v5/vibe-math-v5.js +572 -9
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +122 -4
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +426 -0
- package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v5.svg +173 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// PERSONA SURFACE SENSITIVITY PROBES
|
|
3
|
+
//
|
|
4
|
+
// `audit-persona-surface.test.mjs` compares each preset's persona prompt against the tools
|
|
5
|
+
// that preset actually registers. It is the only guard against two failures that no other
|
|
6
|
+
// suite can see (they all call apply(ctx) directly and never load the YAML):
|
|
7
|
+
//
|
|
8
|
+
// · a registered tool that the persona never names → the agent cannot discover it;
|
|
9
|
+
// · a persona that names an unregistered tool → the agent is told to call a tool
|
|
10
|
+
// that does not exist.
|
|
11
|
+
//
|
|
12
|
+
// The Lean formal-verification feature shipped with both, in three of four presets. This
|
|
13
|
+
// script proves the guard is not vacuous: each probe copies the preset tree, applies ONE
|
|
14
|
+
// mutation, and requires the suite to go RED.
|
|
15
|
+
//
|
|
16
|
+
// False-green traps handled (see AUDIT-CHECKLIST.md §2):
|
|
17
|
+
// 1. a probe whose own spawn fails — reported as SETUP-FAIL, never as a detection;
|
|
18
|
+
// 2. a suite that ignores its override env var — `PERSONA_ROOT` is exercised in both
|
|
19
|
+
// directions (a NON-mutated copy must keep the suite GREEN before any probe runs);
|
|
20
|
+
// 3. a semantically inert mutation — the anchor must match an exact, asserted count, and
|
|
21
|
+
// for YAML probes the mutated copy must still yield the persona's two literal blocks;
|
|
22
|
+
// 4. a mutation that breaks syntax — every mutated `.js` copy is `node --check`ed, and a
|
|
23
|
+
// mutated `.yml` whose persona markers are damaged is a SETUP-FAIL, not a detection.
|
|
24
|
+
//
|
|
25
|
+
// Run: node audit-persona-sensitivity.mjs
|
|
26
|
+
// ============================================================
|
|
27
|
+
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, cpSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { spawnSync } from 'node:child_process'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
|
|
33
|
+
const REPO = fileURLToPath(new URL('.', import.meta.url))
|
|
34
|
+
const SUITE = join(REPO, 'audit-persona-surface.test.mjs')
|
|
35
|
+
const PRESETS = [
|
|
36
|
+
{ dir: 'vibe-math-v2', js: 'vibe-math-v2.js' },
|
|
37
|
+
{ dir: 'vibe-math-v3', js: 'vibe-math-v3.js' },
|
|
38
|
+
{ dir: 'vibe-math-v4', js: 'vibe-math-v4.js' },
|
|
39
|
+
{ dir: 'vibe-math-v5', js: 'vibe-math-v5.js' },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/** Prepare a private copy of every preset (persona + plugin), so one probe cannot leak. */
|
|
43
|
+
function prepareRoot() {
|
|
44
|
+
const root = mkdtempSync(join(tmpdir(), 'persona-sens-'))
|
|
45
|
+
for (const P of PRESETS) {
|
|
46
|
+
mkdirSync(join(root, P.dir), { recursive: true })
|
|
47
|
+
cpSync(join(REPO, P.dir, 'agent.cordis.yml'), join(root, P.dir, 'agent.cordis.yml'))
|
|
48
|
+
cpSync(join(REPO, P.dir, P.js), join(root, P.dir, P.js))
|
|
49
|
+
}
|
|
50
|
+
return root
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The persona's two literal blocks must survive a YAML mutation, or the probe is unfair. */
|
|
54
|
+
function yamlStillHasPersona(yml) {
|
|
55
|
+
if (!/^\s*-\s*id:\s*persona\s*$/m.test(yml)) return false
|
|
56
|
+
const markers = yml.match(/^\s*(?:prefix|text):\s*\|-?\s*$/gm) || []
|
|
57
|
+
return markers.length === 2
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runSuite(root) {
|
|
61
|
+
const env = Object.assign({}, process.env, { PERSONA_ROOT: root })
|
|
62
|
+
const r = spawnSync(process.execPath, [SUITE], { env, encoding: 'utf8', cwd: REPO })
|
|
63
|
+
return { status: r.status, error: r.error, out: String(r.stdout || '') + String(r.stderr || '') }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A sanity gate first: the UNMUTATED copy must be GREEN through the override, otherwise
|
|
67
|
+
// every probe below would "detect" the override itself rather than the mutation (trap 2).
|
|
68
|
+
const sanityRoot = prepareRoot()
|
|
69
|
+
{
|
|
70
|
+
const r = runSuite(sanityRoot)
|
|
71
|
+
if (r.status !== 0) {
|
|
72
|
+
console.error(' SETUP-FAIL - the suite is RED on an unmutated copy through PERSONA_ROOT:')
|
|
73
|
+
console.error(r.out.split('\n').filter((l) => l.includes('FAIL')).slice(0, 8).join('\n'))
|
|
74
|
+
rmSync(sanityRoot, { recursive: true, force: true })
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
console.log(' ok - control: unmutated copy is GREEN through PERSONA_ROOT (the override works)')
|
|
78
|
+
}
|
|
79
|
+
rmSync(sanityRoot, { recursive: true, force: true })
|
|
80
|
+
|
|
81
|
+
const probes = [
|
|
82
|
+
// ── v2 ─────────────────────────────────────────────────────────────────────
|
|
83
|
+
{
|
|
84
|
+
name: 'v2-lean-tools-line-removed',
|
|
85
|
+
preset: 'vibe-math-v2', file: 'agent.cordis.yml', nth: 'all', expect: 2,
|
|
86
|
+
guarantee: 'the three Lean tools must be named in the persona the main agent receives',
|
|
87
|
+
from: ' - vibe_math_lean_run / vibe_math_lean_archive / vibe_math_lean_lib — Lean formal\n verification (execute / archive / list the reuse library). The scheduler\'s child agents\n use them too; they work in every mode.\n',
|
|
88
|
+
to: '',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'v2-phantom-tool-mentioned',
|
|
92
|
+
preset: 'vibe-math-v2', file: 'agent.cordis.yml', nth: 'all', expect: 2,
|
|
93
|
+
guarantee: 'the persona must not advertise a tool that is not registered (the agent would call it and fail)',
|
|
94
|
+
from: ' - vibe_math_lean_run / vibe_math_lean_archive / vibe_math_lean_lib — Lean formal',
|
|
95
|
+
to: ' - vibe_math_lean_exec / vibe_math_lean_run / vibe_math_lean_archive / vibe_math_lean_lib — Lean formal',
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// ── v3 ─────────────────────────────────────────────────────────────────────
|
|
99
|
+
{
|
|
100
|
+
name: 'v3-persona-tool-name-typo',
|
|
101
|
+
preset: 'vibe-math-v3', file: 'agent.cordis.yml', nth: 'all', expect: 2,
|
|
102
|
+
guarantee: 'a typo in a persona tool name must be caught (an agent copying it calls a tool that does not exist)',
|
|
103
|
+
from: 'vibe_math_lean_archive',
|
|
104
|
+
to: 'vibe_math_lean_archiv',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'v3-tool-registered-but-not-documented',
|
|
108
|
+
preset: 'vibe-math-v3', file: 'vibe-math-v3.js', nth: 'first', expect: 2,
|
|
109
|
+
guarantee: 'a newly registered tool must be documented in the persona (or the snapshot updated on purpose)',
|
|
110
|
+
from: " registerTool('vibe_math_lean_run', '(member) Execute the Lean toolchain",
|
|
111
|
+
to: " registerTool('vibe_math_extra_tool', 'x', objParams({}), async function () { return {} })\n registerTool('vibe_math_lean_run', '(member) Execute the Lean toolchain",
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// ── v4 ─────────────────────────────────────────────────────────────────────
|
|
115
|
+
{
|
|
116
|
+
name: 'v4-formal-mode-renamed-in-persona',
|
|
117
|
+
preset: 'vibe-math-v4', file: 'agent.cordis.yml', nth: 'all', expect: 2,
|
|
118
|
+
guarantee: 'the persona must spell the three mode names exactly as the parameter accepts them',
|
|
119
|
+
from: " - 'off' (default, no extra requirement) | 'encourage' (the residents decide by implementation\n difficulty whether to formalize in Lean; once a Lean run passes, their unanimous vote becomes\n a FIDELITY review — do the Lean definitions/objects/conditions/assumptions/conclusion match\n the proposition as stated) | 'require' (same, plus a gate: a unanimous true/false verdict is",
|
|
120
|
+
to: " - 'off' (default, no extra requirement) | 'encourage' (the residents decide by implementation\n difficulty whether to formalize in Lean; once a Lean run passes, their unanimous vote becomes\n a FIDELITY review — do the Lean definitions/objects/conditions/assumptions/conclusion match\n the proposition as stated) | 'forced' (same, plus a gate: a unanimous true/false verdict is",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'v4-formal-report-tool-hidden',
|
|
124
|
+
preset: 'vibe-math-v4', file: 'agent.cordis.yml', nth: 'all', expect: 2,
|
|
125
|
+
guarantee: 'the formal-report tool must be named in the persona (it is the coordinator-facing Lean view)',
|
|
126
|
+
from: ' - vibe_v4_formal_report — human-readable Lean formal-verification mirror (mode, Lean-passed',
|
|
127
|
+
to: ' - vibe_v4_formal_reportX — human-readable Lean formal-verification mirror (mode, Lean-passed',
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// ── v5 ─────────────────────────────────────────────────────────────────────
|
|
131
|
+
{
|
|
132
|
+
name: 'v5-prefix-text-drift',
|
|
133
|
+
preset: 'vibe-math-v5', file: 'agent.cordis.yml', nth: 'second', expect: 2,
|
|
134
|
+
guarantee: 'prefix and text must stay identical apart from line 0 (old and new hosts must see the same surface)',
|
|
135
|
+
from: 'TRUST RULE: only Verified/',
|
|
136
|
+
to: 'TRUST RULE (amended): only Verified/',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'v5-tool-renamed-in-plugin',
|
|
140
|
+
preset: 'vibe-math-v5', file: 'vibe-math-v5.js', nth: 'first', expect: 1,
|
|
141
|
+
guarantee: 'renaming a registered tool must be caught (the persona still advertises the old name)',
|
|
142
|
+
from: " registerTool('vibe_v5_meeting',",
|
|
143
|
+
to: " registerTool('vibe_v5_meeting2',",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'v4-usage-advertises-phantom-subcommand',
|
|
147
|
+
preset: 'vibe-math-v4', file: 'vibe-math-v4.js', nth: 'first', expect: 1,
|
|
148
|
+
guarantee: 'the unknown-subcommand usage string must not advertise a subcommand that no branch implements',
|
|
149
|
+
from: "usage:'configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|set'",
|
|
150
|
+
to: "usage:'configure|start|resume|pause|abort|status|report|message <to|all> <content>|meeting|members|add|remove|scan|set'",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'v4-hint-advertises-unimplemented-subcommand',
|
|
154
|
+
preset: 'vibe-math-v4', file: 'vibe-math-v4.js', nth: 'first', expect: 1,
|
|
155
|
+
guarantee: 'the typing hint must not advertise a subcommand the handler does not implement',
|
|
156
|
+
from: "message <to|all> <content>|meeting|members|add|remove|set]'",
|
|
157
|
+
to: "message <to|all> <content>|meeting|add|remove|set]'",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'v5-persona-slash-list-drops-add-remove',
|
|
161
|
+
preset: 'vibe-math-v5', file: 'agent.cordis.yml', nth: 'all', expect: 2,
|
|
162
|
+
guarantee: "the persona's /v5 list must match the command hint (a human reading the persona must see every subcommand)",
|
|
163
|
+
from: 'meeting|hire|fire|add|remove|set)',
|
|
164
|
+
to: 'meeting|hire|fire|set)',
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
let ok = 0
|
|
169
|
+
let bad = 0
|
|
170
|
+
console.log('')
|
|
171
|
+
console.log('-- persona surface sensitivity probes --')
|
|
172
|
+
console.log('(a probe passes when breaking the guarantee turns audit-persona-surface.test.mjs RED)')
|
|
173
|
+
console.log('')
|
|
174
|
+
|
|
175
|
+
for (const p of probes) {
|
|
176
|
+
const root = prepareRoot()
|
|
177
|
+
const target = join(root, p.preset, p.file)
|
|
178
|
+
const original = readFileSync(target, 'utf8')
|
|
179
|
+
// The repo's YAML files are CRLF; anchors are written with \n. Normalise both directions so
|
|
180
|
+
// a multi-line anchor matches regardless of the checkout's line endings.
|
|
181
|
+
const eol = original.includes('\r\n') ? '\r\n' : '\n'
|
|
182
|
+
const from = p.from.split('\n').join(eol)
|
|
183
|
+
const to = p.to.split('\n').join(eol)
|
|
184
|
+
p.from = from
|
|
185
|
+
p.to = to
|
|
186
|
+
const occurrences = original.split(p.from).length - 1
|
|
187
|
+
if (occurrences !== p.expect) {
|
|
188
|
+
console.error(` SETUP-FAIL - ${p.name}: anchor matched ${occurrences} times (need ${p.expect})`)
|
|
189
|
+
bad++
|
|
190
|
+
rmSync(root, { recursive: true, force: true })
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
let mutated
|
|
194
|
+
if (p.nth === 'all') mutated = original.split(p.from).join(p.to)
|
|
195
|
+
else if (p.nth === 'first') mutated = original.replace(p.from, p.to)
|
|
196
|
+
else if (p.nth === 'second') {
|
|
197
|
+
const i = original.indexOf(p.from)
|
|
198
|
+
const j = original.indexOf(p.from, i + 1)
|
|
199
|
+
mutated = original.slice(0, j) + p.to + original.slice(j + p.from.length)
|
|
200
|
+
} else throw new Error('unknown nth ' + p.nth)
|
|
201
|
+
|
|
202
|
+
if (mutated === original) {
|
|
203
|
+
console.error(` SETUP-FAIL - ${p.name}: the mutation is a no-op`)
|
|
204
|
+
bad++
|
|
205
|
+
rmSync(root, { recursive: true, force: true })
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (p.file.endsWith('.yml') && !yamlStillHasPersona(mutated)) {
|
|
209
|
+
console.error(` SETUP-FAIL - ${p.name}: the mutated persona no longer has its two literal blocks (unfair mutation)`)
|
|
210
|
+
bad++
|
|
211
|
+
rmSync(root, { recursive: true, force: true })
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
writeFileSync(target, mutated, 'utf8')
|
|
215
|
+
|
|
216
|
+
if (p.file.endsWith('.js')) {
|
|
217
|
+
const chk = spawnSync(process.execPath, ['--check', target], { encoding: 'utf8' })
|
|
218
|
+
if (chk.status !== 0) {
|
|
219
|
+
console.error(` SETUP-FAIL - ${p.name}: the mutated copy has a syntax error:\n` + String(chk.stderr || '').split('\n').slice(0, 4).join('\n'))
|
|
220
|
+
bad++
|
|
221
|
+
rmSync(root, { recursive: true, force: true })
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const r = runSuite(root)
|
|
227
|
+
if (r.status === null) {
|
|
228
|
+
console.error(` SETUP-FAIL - ${p.name}: the suite could not be started (${String(r.error && r.error.message)})`)
|
|
229
|
+
bad++
|
|
230
|
+
rmSync(root, { recursive: true, force: true })
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
if (r.status !== 0) {
|
|
234
|
+
const firstFail = (r.out.split('\n').find((l) => l.trim().startsWith('FAIL')) || '').trim().slice(0, 110)
|
|
235
|
+
ok++
|
|
236
|
+
console.log(` ok - ${p.name} [${p.preset}] => suite went RED as required [${p.guarantee}]`)
|
|
237
|
+
if (firstFail) console.log(` ${firstFail}`)
|
|
238
|
+
} else {
|
|
239
|
+
bad++
|
|
240
|
+
console.error(` BLIND SPOT - ${p.name} [${p.preset}] => suite stayed GREEN, so it does NOT detect: ${p.guarantee}`)
|
|
241
|
+
}
|
|
242
|
+
rmSync(root, { recursive: true, force: true })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.log('')
|
|
246
|
+
console.log(`persona sensitivity: ${ok} probes detected the break, ${bad} problems`)
|
|
247
|
+
if (bad) process.exit(1)
|
|
248
|
+
console.log('ALL PERSONA PROBES RED AS REQUIRED')
|
|
249
|
+
process.exit(0)
|
|
@@ -0,0 +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)
|