dsh-vibe-math 2.7.2 → 2.7.3

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 (112) hide show
  1. package/README.en.md +11 -10
  2. package/README.md +13 -12
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +229 -3
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/final-paper.md +2 -2
  7. package/docs/math-computation.md +13 -6
  8. package/docs/parameter-schema.md +37 -0
  9. package/docs/release-notes/README.en.md +1 -0
  10. package/docs/release-notes/README.md +1 -0
  11. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
  12. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
  13. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  15. package/docs/release-notes/TEMPLATE.md +6 -0
  16. package/docs/status-report-fields.md +167 -0
  17. package/docs/test-timing.md +8 -5
  18. package/installer.js +27 -17
  19. package/package.json +250 -203
  20. package/prompt-corpus-persona/persona-corpus.json +9 -9
  21. package/prompt-corpus-persona/persona-corpus.md +225 -33
  22. package/prompt-corpus-v2/formal-verify-v2.json +295 -115
  23. package/prompt-corpus-v2/formal-verify-v2.md +2287 -283
  24. package/prompt-corpus-v3/formal-verify-v3.json +105 -105
  25. package/prompt-corpus-v3/formal-verify-v3.md +213 -143
  26. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  27. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  28. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  29. package/prompt-corpus-v5/prompt-corpus-v5.md +451 -207
  30. package/scripts/update-doc-counts.mjs +71 -0
  31. package/tests/audit-artifact-docs.mjs +72 -0
  32. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  33. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  34. package/tests/audit-installer-compat.test.mjs +52 -4
  35. package/tests/audit-installer-policy.test.mjs +41 -8
  36. package/tests/audit-math-computation-contract.mjs +64 -8
  37. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  38. package/tests/audit-math-computation-parity.mjs +164 -3
  39. package/tests/audit-math-computation-sensitivity.mjs +4 -2
  40. package/tests/audit-package-membership.mjs +57 -0
  41. package/tests/audit-package-membership.mutants.mjs +28 -0
  42. package/tests/audit-participant-set-parity.mjs +160 -0
  43. package/tests/audit-path-discipline.mjs +83 -0
  44. package/tests/audit-path-discipline.mutants.mjs +51 -0
  45. package/tests/audit-persona-sensitivity.mjs +14 -0
  46. package/tests/audit-persona-surface.mutants.mjs +69 -0
  47. package/tests/audit-persona-surface.test.mjs +63 -22
  48. package/tests/audit-prompt-duplication.mjs +143 -0
  49. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  50. package/tests/audit-prompt-invariants.mjs +43 -5
  51. package/tests/audit-readme-bilingual.test.mjs +152 -0
  52. package/tests/audit-readme-counts.mjs +95 -0
  53. package/tests/audit-readme-counts.mutants.mjs +52 -0
  54. package/tests/audit-registration.mjs +78 -0
  55. package/tests/audit-status-report-fields.mjs +172 -0
  56. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  57. package/tests/audit-v3-registration-parity.mjs +156 -0
  58. package/tests/audit-v5-integrity.mjs +300 -13
  59. package/tests/audit-v5-lean-abstention.mjs +392 -0
  60. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  61. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  62. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  63. package/tests/audit-v5-sensitivity.mjs +12 -7
  64. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  65. package/tests/e2e-v5-round2.test.mjs +458 -13
  66. package/tests/formal-verify-v2.test.mjs +91 -43
  67. package/tests/formal-verify-v3.mutants.mjs +117 -0
  68. package/tests/formal-verify-v3.test.mjs +85 -39
  69. package/tests/formal-verify-v4.mutants.mjs +152 -0
  70. package/tests/formal-verify-v4.test.mjs +541 -9
  71. package/tests/formal-verify-v5.test.mjs +68 -9
  72. package/tests/helpers/doc-counts-format.mjs +41 -0
  73. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  74. package/tests/math-computation-a1.mutants.mjs +72 -0
  75. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  76. package/tests/math-computation-discovery.mutants.mjs +76 -0
  77. package/tests/math-computation-shared.test.mjs +274 -5
  78. package/tests/math-computation-v2.test.mjs +767 -0
  79. package/tests/math-computation-v3.test.mjs +759 -0
  80. package/tests/math-computation-v4.mutants.mjs +133 -0
  81. package/tests/math-computation-v4.test.mjs +739 -0
  82. package/tests/prompt-v5-integrity.test.mjs +13 -2
  83. package/tests/run-tests.mjs +139 -9
  84. package/tests/run-tests.mutants.mjs +70 -0
  85. package/tests/selfdrive-v5.mjs +53 -1
  86. package/tests/selfdrive-v5.mutants.mjs +132 -0
  87. package/tests/v2-fix-probes.mutants.mjs +100 -0
  88. package/tests/v2-fix-probes.test.mjs +1039 -0
  89. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  90. package/tests/v3-fix-probes.mutants.mjs +95 -0
  91. package/tests/v3-fix-probes.test.mjs +891 -0
  92. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  93. package/vibe-math-v2/agent.cordis.yml +62 -6
  94. package/vibe-math-v2/math-computation.js +130 -41
  95. package/vibe-math-v2/math-engines.js +12 -12
  96. package/vibe-math-v2/vibe-math-v2.js +388 -113
  97. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  98. package/vibe-math-v3/agent.cordis.yml +72 -6
  99. package/vibe-math-v3/math-computation.js +130 -41
  100. package/vibe-math-v3/math-engines.js +12 -12
  101. package/vibe-math-v3/vibe-math-v3.js +422 -126
  102. package/vibe-math-v4/agent.cordis.yml +44 -8
  103. package/vibe-math-v4/math-computation.js +130 -41
  104. package/vibe-math-v4/math-engines.js +12 -12
  105. package/vibe-math-v4/vibe-math-v4.js +196 -43
  106. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  107. package/vibe-math-v5/agent.cordis.yml +46 -12
  108. package/vibe-math-v5/math-computation.js +130 -41
  109. package/vibe-math-v5/math-engines.js +12 -12
  110. package/vibe-math-v5/vibe-math-v5.js +1173 -218
  111. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +80 -12
  112. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +2 -2
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mutant harness for tests/audit-status-report-fields.mjs (ships: `tests/*.mutants.mjs`).
4
+ *
5
+ * ONE MUTANT PER DIRECTION (the doc↔source comparison must bite both ways):
6
+ * A) doc→source: delete one v2 row from a COPY of docs/status-report-fields.md -> the "every source
7
+ * field has a doc row" assertion reddens by name
8
+ * B) source→doc: add a field to a COPY of vibe-math-v2.js's `fieldScopes.session` -> the "every doc row
9
+ * names a real source field" / missing-row assertion reddens by name
10
+ *
11
+ * Run: node tests/audit-status-report-fields.mutants.mjs
12
+ */
13
+ import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { join } from 'node:path'
16
+ import { spawnSync } from 'node:child_process'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const HERE = fileURLToPath(new URL('./', import.meta.url))
20
+ const REPO = join(HERE, '..')
21
+ const GUARD = join(HERE, 'audit-status-report-fields.mjs')
22
+ let passed = 0, failed = 0
23
+ const ok = (c, l, d) => { if (c) { passed++; console.log(' ok ' + l) } else { failed++; console.log(' FAIL ' + l + (d ? ' — ' + d : '')) } }
24
+ const run = (env) => {
25
+ const r = spawnSync(process.execPath, [GUARD], { cwd: REPO, encoding: 'utf8', env: Object.assign({}, process.env, env || {}) })
26
+ const out = String(r.stdout || '') + String(r.stderr || '')
27
+ return { status: r.status, failLines: out.split('\n').filter((l) => /^ - /.test(l)), tail: out.trim().split('\n').filter(Boolean).slice(-1)[0] || '' }
28
+ }
29
+ const baseline = run(null)
30
+ ok(baseline.status === 0, 'baseline: the field-table guard is green', baseline.tail.slice(0, 80))
31
+
32
+ const dir = mkdtempSync(join(tmpdir(), 'fieldtable-mut-'))
33
+ // A) doc -> source
34
+ const docPath = join(REPO, 'docs/status-report-fields.md')
35
+ const docLines = readFileSync(docPath, 'utf8').split(/\r?\n/)
36
+ const v2Head = docLines.findIndex((l) => l.startsWith('### v2 的字段与作用域'))
37
+ const firstRow = docLines.findIndex((l, i) => i > v2Head && /^\|\s*`[^`]+`\s*\|/.test(l))
38
+ if (v2Head === -1 || firstRow === -1) ok(false, 'A: the v2 section and its first row exist (harness and guard agree on the shape)', 'ANCHOR MISS')
39
+ else {
40
+ const removed = docLines[firstRow]
41
+ const tampered = docLines.slice(0, firstRow).concat(docLines.slice(firstRow + 1)).join('\n')
42
+ writeFileSync(join(dir, 'status-report-fields.md'), tampered)
43
+ const a = run({ FIELD_DOC: join(dir, 'status-report-fields.md') })
44
+ const named = a.failLines.some((l) => l.indexOf('v2: every source field has a doc row') !== -1)
45
+ ok(a.status !== 0 && named, '★ A (doc→source): deleting a v2 row reddens the guard by name', 'removed=' + removed.trim().slice(0, 60) + ' exit=' + a.status)
46
+ if (!named) for (const l of a.failLines.slice(0, 3)) console.log(' guard failure: ' + l.trim().slice(0, 130))
47
+ }
48
+ // B) source -> doc
49
+ const v2Path = join(REPO, 'vibe-math-v2/vibe-math-v2.js')
50
+ const v2src = readFileSync(v2Path, 'utf8')
51
+ const m = /(\n\s*session:\s*\[)([\s\S]*?)(\])/.exec(v2src)
52
+ if (!m) ok(false, 'B: the v2 fieldScopes session array exists (anchor check)', 'ANCHOR MISS')
53
+ else {
54
+ const injected = m[1] + m[2].replace(/\s*$/, '') + ", 'ghostFieldFromMutant'" + m[3]
55
+ writeFileSync(join(dir, 'vibe-math-v2.js'), v2src.replace(m[0], injected))
56
+ const b = run({ FIELD_V2_JS: join(dir, 'vibe-math-v2.js') })
57
+ const named = b.failLines.some((l) => l.indexOf('v2: every source field has a doc row') !== -1 || l.indexOf('v2: every doc row names a real source field') !== -1)
58
+ ok(b.status !== 0 && named, '★ B (source→doc): adding a field to the source reddens the guard by name', 'exit=' + b.status)
59
+ if (!named) for (const l of b.failLines.slice(0, 3)) console.log(' guard failure: ' + l.trim().slice(0, 130))
60
+ }
61
+ rmSync(dir, { recursive: true, force: true })
62
+ console.log('')
63
+ console.log('=== STATUS FIELD TABLE MUTANTS: ' + passed + ' passed, ' + failed + ' failed ===')
64
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Probe for prompt-audit M3 (v3): the two registerTool paths must not drift.
3
+ *
4
+ * v3 declares each tool twice — the session handler table inside makeSession()
5
+ * (registerTool(name, description, parameters, executeFn)) and the real
6
+ * `tools.register` at apply scope (registerTool(name, description, parameters,
7
+ * handlerName)). The parameter schemas are guarded by audit-prompt-invariants
8
+ * (I13/I14); the DESCRIPTIONS were not, and five of them had already drifted.
9
+ *
10
+ * Checks per tool (33 of them):
11
+ * - MISSING : registered on only one path
12
+ * - DESC-DRIFT : the description argument text differs between the two paths
13
+ * - SCHEMA-DRIFT : the parameter-schema argument text differs
14
+ * - NO-SOURCE : a description is not read from the shared module-level table
15
+ * (the "one source of truth" claim would be nominal only)
16
+ * - BAD-REF : a TOOL_DESC.<name> reference has no matching entry in the table
17
+ *
18
+ * Read-only: parses the source text, never executes the plugin.
19
+ *
20
+ * Sensitivity probe (the suite must be falsifiable):
21
+ * node tests/audit-v3-registration-parity.mjs --self-probe '["<from>","<to>"]'
22
+ * The pair is applied to the source text, so a first-occurrence mutation makes the
23
+ * second path disagree and the probe must exit 1.
24
+ */
25
+ import { readFileSync } from 'node:fs';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { dirname, join } from 'node:path';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ let SRC = readFileSync(join(HERE, '..', 'vibe-math-v3', 'vibe-math-v3.js'), 'utf8');
31
+
32
+ const SELF_PROBE = process.argv.indexOf('--self-probe');
33
+ if (SELF_PROBE !== -1 && process.argv[SELF_PROBE + 1]) {
34
+ const [from, to] = JSON.parse(process.argv[SELF_PROBE + 1]);
35
+ const before = SRC;
36
+ SRC = SRC.replace(from, to);
37
+ if (SRC === before) { console.log('MUTATION DID NOT APPLY: ' + from); process.exit(2); }
38
+ }
39
+
40
+ /** Skip a string literal (any quote) starting at i; returns the index after it. */
41
+ function skipString(s, i) {
42
+ const q = s[i];
43
+ i++;
44
+ while (i < s.length) {
45
+ if (s[i] === '\\') { i += 2; continue; }
46
+ if (s[i] === q) return i + 1;
47
+ i++;
48
+ }
49
+ return i;
50
+ }
51
+
52
+ // Normalise line endings: this file is checked out with CRLF on Windows, and a stray \r
53
+ // between an entry's `,` and its key would make the table parser under-count (it did).
54
+ SRC = SRC.replace(/\r/g, '');
55
+
56
+ /** Collect a top-level-comma-separated argument list starting at `open` (an index of `(`). */
57
+ function argsOf(s, open) {
58
+ let depth = 0;
59
+ const args = [];
60
+ let cur = '';
61
+ for (let i = open; i < s.length; i++) {
62
+ const c = s[i];
63
+ if (c === '"' || c === "'" || c === '`') { const j = skipString(s, i); cur += s.slice(i, j); i = j - 1; continue; }
64
+ if (c === '(' || c === '[' || c === '{') { depth++; cur += c; continue; }
65
+ if (c === ')' || c === ']' || c === '}') {
66
+ depth--;
67
+ if (depth === 0) { args.push(cur.trim()); return args; }
68
+ cur += c; continue;
69
+ }
70
+ if (c === ',' && depth === 1) { args.push(cur.trim()); cur = ''; continue; }
71
+ if (depth >= 1) cur += c;
72
+ }
73
+ return args;
74
+ }
75
+
76
+ const calls = [];
77
+ for (const m of SRC.matchAll(/registerTool\(\s*'([A-Za-z0-9_]+)'/g)) {
78
+ const open = SRC.indexOf('(', m.index + 'registerTool'.length);
79
+ calls.push({ name: m[1], at: m.index, args: argsOf(SRC, open) });
80
+ }
81
+
82
+ const byName = new Map();
83
+ for (const c of calls) {
84
+ if (!byName.has(c.name)) byName.set(c.name, []);
85
+ byName.get(c.name).push(c);
86
+ }
87
+
88
+ // The shared description table (module-level `const TOOL_DESC = { … }`).
89
+ //
90
+ // Keys are collected with an ANCHORED PER-LINE regex inside the table's own text slice —
91
+ // string values are blanked out first (keeping every newline) so the `{`/`}` braces inside
92
+ // the sync_meta documentation text cannot shift a brace-depth scanner (that mis-parse is
93
+ // exactly how an earlier version of this probe silently saw 15 of 33 entries).
94
+ const tableAt = SRC.search(/const\s+TOOL_DESC\s*=\s*\{/);
95
+ const tableKeys = new Set();
96
+ if (tableAt !== -1) {
97
+ const open = SRC.indexOf('{', tableAt);
98
+ // slice to the matching close brace with strings blanked (newlines preserved)
99
+ let depth = 0;
100
+ let end = -1;
101
+ let blanked = '';
102
+ for (let i = open; i < SRC.length; i++) {
103
+ const c = SRC[i];
104
+ if (c === '"' || c === "'" || c === '`') {
105
+ const stop = skipString(SRC, i);
106
+ for (let k = i; k < stop; k++) blanked += (SRC[k] === '\n' ? '\n' : ' ');
107
+ i = stop - 1;
108
+ continue;
109
+ }
110
+ blanked += c;
111
+ if (c === '{') depth++;
112
+ else if (c === '}') { depth--; if (depth === 0) { end = i; break; } }
113
+ }
114
+ const tableText = blanked.slice(0, end === -1 ? blanked.length : end - open + 1);
115
+ for (const m of tableText.matchAll(/^[ \t]*([A-Za-z_$][\w$]*)\s*:/gm)) tableKeys.add(m[1]);
116
+ }
117
+
118
+ let descDrift = 0;
119
+ let schemaDrift = 0;
120
+ let noSource = 0;
121
+ let badRef = 0;
122
+ const missing = [];
123
+ for (const [name, list] of byName) {
124
+ if (list.length !== 2) { missing.push(name + ' (' + list.length + ' registration(s))'); continue; }
125
+ const [a, b] = list.map((c) => ({ desc: c.args[1], schema: c.args[2] }));
126
+ if (a.desc !== b.desc) {
127
+ descDrift++;
128
+ console.log('DESC-DRIFT ' + name);
129
+ console.log(' path#1: ' + String(a.desc).slice(0, 140));
130
+ console.log(' path#2: ' + String(b.desc).slice(0, 140));
131
+ }
132
+ if (a.schema !== b.schema) {
133
+ schemaDrift++;
134
+ console.log('SCHEMA-DRIFT ' + name);
135
+ console.log(' path#1: ' + String(a.schema).slice(0, 140));
136
+ console.log(' path#2: ' + String(b.schema).slice(0, 140));
137
+ }
138
+ for (const d of [a.desc, b.desc]) {
139
+ const want = 'TOOL_DESC.' + name;
140
+ if (d === want) continue;
141
+ if (/^TOOL_DESC\./.test(d)) { badRef++; console.log('BAD-REF ' + name + ' → ' + d); continue; }
142
+ noSource++;
143
+ console.log('NO-SOURCE ' + name + ' → ' + String(d).slice(0, 140));
144
+ }
145
+ }
146
+
147
+ console.log('shared TOOL_DESC entries: ' + tableKeys.size);
148
+ console.log('tools registered twice: ' + [...byName.values()].filter((v) => v.length === 2).length + ' / ' + byName.size);
149
+ console.log('DESC-DRIFT count: ' + descDrift);
150
+ console.log('SCHEMA-DRIFT count: ' + schemaDrift);
151
+ console.log('descriptions not from the shared table: ' + noSource);
152
+ console.log('bad TOOL_DESC references: ' + badRef);
153
+ console.log('one-path-only tools: ' + (missing.length ? missing.join(', ') : 'none'));
154
+ const ok = descDrift === 0 && schemaDrift === 0 && noSource === 0 && badRef === 0 && missing.length === 0 && tableKeys.size === byName.size;
155
+ console.log(ok ? '=== DESC PARITY OK ===' : '=== DESC PARITY FAILED ===');
156
+ process.exit(ok ? 0 : 1);
@@ -9,13 +9,125 @@
9
9
  // 5. leftover development markers / TODO scaffolding
10
10
  // Run: node tests/audit-v5-integrity.mjs (exit 1 on any finding)
11
11
  // ============================================================
12
- import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
12
+ import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync, readdirSync } from 'node:fs'
13
13
  import { spawnSync } from 'node:child_process'
14
14
  import { tmpdir } from 'node:os'
15
15
  import { join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
16
17
 
17
- const FILE = new URL('../vibe-math-v5/vibe-math-v5.js', import.meta.url)
18
- const raw = readFileSync(FILE, 'utf8')
18
+ const HERE = new URL('../', import.meta.url)
19
+ /**
20
+ * `V5_INTEGRITY_MUTATE` carries a JSON `[rel, from, to]` triple: that ONE repo-relative file is mutated
21
+ * IN MEMORY for one child run. Combined with `--self-probe` this makes every guard below reproducible
22
+ * from OUTSIDE — a verifier no longer has to copy this audit and rewrite its URLs to point it at a
23
+ * mutated copy (R16's request). Same shape as `audit-prompt-invariants.mjs` and the
24
+ * `math-computation-shared --self-probe` pattern.
25
+ *
26
+ * SEAM COVERAGE (R17 measured, both directions): the seam reaches a **plugin** path (a plugin mutation
27
+ * produces 1 finding) AND the **corpus** path (a corpus mutation produces 2 findings) — i.e. it is not
28
+ * limited to the plugin file, and a corpus-only drift is observable from outside too. The corpus is read
29
+ * through the same `readRaw()`, so any new corpus check inherits the seam.
30
+ */
31
+ const MUT = process.env.V5_INTEGRITY_MUTATE
32
+ function readRaw(rel) {
33
+ let text = readFileSync(new URL(rel, HERE), 'utf8')
34
+ if (MUT) {
35
+ try {
36
+ const [r, from, to] = JSON.parse(MUT)
37
+ if (r === rel && from) text = text.replace(from, to)
38
+ } catch (e) { /* a malformed mutation is a harness error, not an audit failure */ }
39
+ }
40
+ return text
41
+ }
42
+
43
+ /**
44
+ * Each mutation is a REAL defect shape for one of this audit's guards. `expect` is a substring that MUST
45
+ * appear in the failing run; `control: true` marks the unmutated run, which must stay green.
46
+ */
47
+ const SELF_PROBE_MUTATIONS = [
48
+ { name: 'control (no mutation)', rel: '', from: '', to: '', expect: '', control: true },
49
+ {
50
+ name: 'F3: a status() top-level key is renamed (the frozen field surface must redden)',
51
+ rel: 'vibe-math-v5/vibe-math-v5.js',
52
+ from: 'leanNoticesScope: ',
53
+ to: 'leanNoticesScopeRenamed: ',
54
+ expect: 'status() field surface changed',
55
+ },
56
+ {
57
+ name: 'F1: the README resume claim loses its in-instance case (the pairing gate must redden)',
58
+ rel: 'README.md',
59
+ from: '**同实例重建**(宿主丢了子会话、插件实例还在)**继续**原编号',
60
+ to: '**同实例重建**从 1 重新开始',
61
+ expect: 'README resume-claim side missing',
62
+ },
63
+ {
64
+ name: 'F1: a code anchor the README cites is removed (its philosophy gate must redden)',
65
+ rel: 'vibe-math-v5/vibe-math-v5.js',
66
+ from: 'rounds.set(member.id, startRound)',
67
+ to: 'void 0 /* anchor removed (self-probe) */',
68
+ expect: 'philosophy gate missing from the implementation: the founding round is applied only AFTER a successful start',
69
+ },
70
+ {
71
+ name: 'F2/G1: the diagram is mutated back to the pre-G1 inbox order (the pairing gate must redden)',
72
+ rel: 'vibe-math-v5/架构图.md',
73
+ from: '唯一的推进驱动',
74
+ to: '唯一的推进驱动(先 ack 再构造)',
75
+ expect: '架构图 still states the pre-G1 order',
76
+ },
77
+ {
78
+ name: 'F2/G1: the plan is mutated back to the explicit pre-G1 inbox phrasing (the pairing gate must redden)',
79
+ rel: 'vibe-math-v5/实现方案.md',
80
+ from: '**未 ack ⇒ 仍可重投**',
81
+ to: '**先 ack 邮件、再构造**;**未 ack ⇒ 仍可重投**',
82
+ expect: '实现方案.md still states the pre-G1 order',
83
+ },
84
+ {
85
+ name: 'F2/G1: a docs file is mutated back to the named pre-G1 phrasing (the docs-wide gate must redden)',
86
+ rel: 'docs/AUDIT-CHECKLIST.md',
87
+ from: '身份一律显式传递,绝不猜测。',
88
+ to: '身份一律显式传递,绝不猜测。先 ack 再构造',
89
+ expect: 'still states the pre-G1 order',
90
+ },
91
+ {
92
+ name: 'F2/G1: the plan is mutated to re-introduce the pre-G1 re-send contract (the named check must redden)',
93
+ rel: 'vibe-math-v5/实现方案.md',
94
+ from: '`makeFileBackend`',
95
+ to: '`makeFileBackend`(queued 永不重发)',
96
+ expect: 'still states the pre-G1 re-send contract',
97
+ },
98
+ ]
99
+
100
+ if (process.argv.includes('--self-probe')) {
101
+ const bad = []
102
+ for (const mut of SELF_PROBE_MUTATIONS) {
103
+ const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--json'], {
104
+ cwd: fileURLToPath(HERE),
105
+ env: Object.assign({}, process.env, mut.control ? {} : { V5_INTEGRITY_MUTATE: JSON.stringify([mut.rel, mut.from, mut.to]) }),
106
+ encoding: 'utf8',
107
+ })
108
+ let parsed = null
109
+ try { parsed = JSON.parse(r.stdout) } catch (e) { /* fall through to the diagnostic below */ }
110
+ const text = (r.stdout || '') + (r.stderr || '')
111
+ if (mut.control) {
112
+ const ok = r.status === 0 && parsed && parsed.failed === 0
113
+ console.log((ok ? 'PASS ' : 'FAIL ') + 'control: the unmutated run stays green (' + (parsed ? parsed.failed + ' findings, ' + parsed.gates + ' gates' : 'unparsable output') + ')')
114
+ if (!ok) bad.push('control run was not green')
115
+ continue
116
+ }
117
+ const hit = text.includes(mut.expect)
118
+ const red = r.status === 1 && parsed && parsed.failed > 0
119
+ const ok = red && hit
120
+ console.log((ok ? 'PASS ' : 'FAIL ') + mut.name + (ok ? '' : ' → exit=' + r.status + ' hit=' + hit + ' failed=' + (parsed && parsed.failed)))
121
+ if (!ok) bad.push(mut.name + ' (exit ' + r.status + ', expected ' + JSON.stringify(mut.expect) + ')')
122
+ }
123
+ console.log('')
124
+ console.log('V5 INTEGRITY SELF-PROBE: ' + (SELF_PROBE_MUTATIONS.length - bad.length) + '/' + SELF_PROBE_MUTATIONS.length + ' as required')
125
+ for (const b of bad) console.error(' FAIL ' + b)
126
+ process.exit(bad.length === 0 ? 0 : 1)
127
+ }
128
+
129
+ const FILE_REL = 'vibe-math-v5/vibe-math-v5.js'
130
+ const raw = readRaw(FILE_REL)
19
131
  // Strip comments, string literals AND regex literals before any identifier scan. Without this the
20
132
  // heuristic matches English words inside comments that merely precede a '(' (e.g.
21
133
  // "// per unit (" becomes a phantom call to unit()), drowning the real findings.
@@ -79,6 +191,8 @@ function stripNoise(s) {
79
191
  const src = stripNoise(raw)
80
192
  const findings = []
81
193
  const notes = []
194
+ // How many philosophy gates the run checked (module scope so `--json` can report it).
195
+ let gateCount = 0
82
196
 
83
197
  const lineOf = (idx) => src.slice(0, idx).split('\n').length
84
198
 
@@ -213,7 +327,7 @@ else {
213
327
  const raised = new Set()
214
328
  for (const m of raw.matchAll(/v5err\(\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
215
329
  for (const m of raw.matchAll(/code:\s*'(V5_[A-Z_]+)'/g)) raised.add(m[1])
216
- const docs = readFileSync(new URL('../vibe-math-v5/实现方案.md', import.meta.url), 'utf8')
330
+ const docs = readRaw('vibe-math-v5/实现方案.md')
217
331
  const documented = new Set()
218
332
  for (const m of docs.matchAll(/`(V5_[A-Z_]+)`/g)) documented.add(m[1])
219
333
  const onlyDocs = new Set()
@@ -262,8 +376,8 @@ function packageRows(yaml) {
262
376
  }
263
377
  return rows
264
378
  }
265
- const v5yaml = readFileSync(new URL('../vibe-math-v5/agent.cordis.yml', import.meta.url), 'utf8')
266
- const v4yaml = readFileSync(new URL('../vibe-math-v4/agent.cordis.yml', import.meta.url), 'utf8')
379
+ const v5yaml = readRaw('vibe-math-v5/agent.cordis.yml')
380
+ const v4yaml = readRaw('vibe-math-v4/agent.cordis.yml')
267
381
  const v5rows = packageRows(v5yaml)
268
382
  const v4names = new Set(packageRows(v4yaml).map(r => r.name))
269
383
  const RELATIVE_OK = new Set(['./vibe-math-v5.js'])
@@ -293,7 +407,7 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
293
407
  // against the implementation: a name in the plan but not the code is an unimplemented
294
408
  // requirement, and a name in the code but not the plan is undocumented surface.
295
409
  {
296
- const plan = readFileSync(new URL('../vibe-math-v5/实现方案.md', import.meta.url), 'utf8')
410
+ const plan = readRaw('vibe-math-v5/实现方案.md')
297
411
  const planTools = new Set()
298
412
  for (const m of plan.matchAll(/\bvibe_v5_[a-z_]+/g)) planTools.add(m[0])
299
413
  const codeTools = new Set()
@@ -325,6 +439,158 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
325
439
  }
326
440
  notes.push('plan §16 parameter rows: ' + paramRows.size)
327
441
 
442
+ // F3 (docs-vs-behaviour sweep): `status()`'s machine surface. OWNERSHIP (agreed division, so the two
443
+ // guards never both claim the same job):
444
+ // • FIELD-LEVEL reconciliation (field ↔ doc row, BOTH directions) is owned by
445
+ // `tests/audit-status-report-fields.mjs`, which carries the table and prints the
446
+ // `frozen / doc-rows / exceptions` counts;
447
+ // • THIS audit keeps ONLY this frozen-key drift check: the extracted 43 keys below, the printed
448
+ // `captured: N`, and the `<30` anti-vacuity floor. Any addition/removal of a top-level key must be
449
+ // an explicit decision here (and in the owning guard's reconciliation).
450
+ // No "doc half" is promised or planned here — the note printed at the end points at the owner instead.
451
+ const stIdx = raw.indexOf('function status() {')
452
+ const statusKeys = []
453
+ if (stIdx === -1) findings.push('could not locate status() — the machine-surface extraction has no anchor')
454
+ else {
455
+ const open = raw.indexOf('return {', stIdx)
456
+ const close = raw.indexOf('\n }', open)
457
+ // Tokenise the returned literal: every key directly inside it — including SHORTHAND keys that share a
458
+ // line (`institute: instituteName, project, key, phase,`) — while SKIPPING each value (so an
459
+ // identifier used as a value can never be mistaken for a key).
460
+ const body = open === -1 ? '' : raw.slice(open + 'return {'.length, close === -1 ? open + 9000 : close)
461
+ // Comments must be skipped IN PLACE: the payload's comments contain prose with apostrophes, parens and
462
+ // commas (`…delivered ONCE…`, `phase:'failed'`, `(installer review)`), which otherwise corrupt both the
463
+ // depth tracking and the value skipping.
464
+ const skipComment = (s, at) => {
465
+ if (s[at + 1] === '/') { let j = at + 2; while (j < s.length && s[j] !== '\n') j += 1; return j }
466
+ if (s[at + 1] === '*') { const j = s.indexOf('*/', at + 2); return j === -1 ? s.length : j + 2 }
467
+ return -1
468
+ }
469
+ let i = 0, depth = 0
470
+ while (i < body.length) {
471
+ const ch = body[i]
472
+ if (ch === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
473
+ if (ch === '"' || ch === "'") { const q = ch; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
474
+ if (ch === '{' || ch === '[' || ch === '(') { depth += 1; i += 1; continue }
475
+ if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) break; depth -= 1; i += 1; continue }
476
+ if (depth === 0 && /[A-Za-z_$]/.test(ch)) {
477
+ const m = /^[A-Za-z_$][\w$]*/.exec(body.slice(i))
478
+ const after = body.slice(i + m[0].length).replace(/^[\s]+/, '')
479
+ if (after[0] === ':') {
480
+ statusKeys.push(m[0])
481
+ i += m[0].length
482
+ let d = 0
483
+ while (i < body.length) { // skip the VALUE up to the next top-level comma
484
+ const c = body[i]
485
+ if (c === '/') { const j = skipComment(body, i); if (j !== -1) { i = j; continue } }
486
+ if (c === '"' || c === "'") { const q = c; i += 1; while (i < body.length && body[i] !== q) { if (body[i] === '\\') i += 1; i += 1 } i += 1; continue }
487
+ if (c === '{' || c === '[' || c === '(') { d += 1; i += 1; continue }
488
+ if (c === '}' || c === ']' || c === ')') { if (d === 0) break; d -= 1; i += 1; continue }
489
+ if (c === ',' && d === 0) { i += 1; break }
490
+ i += 1
491
+ }
492
+ continue
493
+ }
494
+ if (after[0] === ',') { statusKeys.push(m[0]); i += m[0].length; continue }
495
+ i += m[0].length; continue
496
+ }
497
+ i += 1
498
+ }
499
+ }
500
+ notes.push('status() top-level keys captured: ' + statusKeys.length)
501
+ if (statusKeys.length < 30) findings.push('status() key extraction captured only ' + statusKeys.length + ' keys (anchor or payload broke — this check must never pass vacuously)')
502
+ for (const must of ['phase', 'members', 'quorum', 'chat', 'persistence']) {
503
+ if (statusKeys.indexOf(must) === -1) findings.push('status() no longer exposes `' + must + '` (either the extraction anchor or the payload broke)')
504
+ }
505
+ const EXPECTED_STATUS_KEYS = ['ok', 'institute', 'project', 'key', 'phase', 'running', 'autoDone', 'runId', 'leanNotices', 'leanNoticesScope', 'fieldScopes', 'backend', 'diagnostics', 'debug', 'quorum', 'members', 'tasks', 'failedMembers', 'failedMembersNote', 'pendingSpawns', 'pendingSpawnsNote', 'chat', 'chatScope', 'officeRequests', 'officeRequestsShown', 'officeRequestsCap', 'officeRequestsDropped', 'officeRequestsTruncated', 'persistence', 'meeting', 'parkedMeeting', 'verify', 'verifyQueue', 'verified', 'verifiedTrue', 'concludedFalse', 'verifiedNote', 'undecided', 'solveVotes', 'formal', 'paper', 'lastProgressAt', 'params']
506
+ const statusMissing = EXPECTED_STATUS_KEYS.filter((k) => statusKeys.indexOf(k) === -1)
507
+ const statusAdded = statusKeys.filter((k) => EXPECTED_STATUS_KEYS.indexOf(k) === -1)
508
+ if (statusMissing.length || statusAdded.length) {
509
+ findings.push('status() field surface changed — missing=' + JSON.stringify(statusMissing) + ' added=' + JSON.stringify(statusAdded) +
510
+ ': record the decision in this freeze AND let the field-level owner reconcile the documents (tests/audit-status-report-fields.mjs)')
511
+ }
512
+ notes.push('status() field-surface drift: frozen=' + statusKeys.length + ' keys (this audit owns ONLY this freeze + the <30 anti-vacuity floor); ' +
513
+ 'field-level reconciliation (field ↔ doc row, both directions) is OWNED BY tests/audit-status-report-fields.mjs, which prints frozen / doc-rows / exceptions')
514
+
515
+ // F1 (docs-vs-behaviour): the README's TWO-CASE `resume` claim must stay paired with the behavioural
516
+ // anchor that pins the in-instance case (the two CODE anchors are gates in the GATES table below). Only
517
+ // the prose side is checked here, and every side reports how much it matched, so this cannot pass vacuously.
518
+ {
519
+ const readme = readRaw('README.md')
520
+ const suite = readRaw('tests/e2e-v5-round2.test.mjs')
521
+ const readmeClaims = [
522
+ ['the two-case framing', /`resume`\s*后轮次计数的两种情形/],
523
+ ['case 1 = a cross-process reload counts from 1', /跨进程重载[^\n]*从\s*\*\*1\*\*\s*重新计/],
524
+ ['case 2 = an in-instance rebuild CONTINUES the numbering (轮次 2)', /同实例重建[^\n]*继续[^\n]*轮次 2/],
525
+ ['a failed/invalid start no longer erases the existing count', /失败\/无效的启动\*\*不再抹掉\*\*已有计数/],
526
+ ['the claim is pinned to a revision', /口径复核于\s*`?[0-9a-f]{7,40}`?/],
527
+ ]
528
+ let readmeHits = 0
529
+ for (const [label, re] of readmeClaims) {
530
+ if (re.test(readme)) readmeHits += 1
531
+ else findings.push('README resume-claim side missing: ' + label)
532
+ }
533
+ const suiteClaims = [
534
+ ['the in-instance rebuild is asserted as 轮次 2', /a FAILED resume must not move the round number backwards/],
535
+ ['the founding round is asserted as 轮次 1', /the founding prompt starts the member at 轮次 1/],
536
+ ]
537
+ let suiteHits = 0
538
+ for (const [label, re] of suiteClaims) {
539
+ if (re.test(suite)) suiteHits += 1
540
+ else findings.push('README resume-claim has no behavioural anchor: ' + label)
541
+ }
542
+ // The README cites exact code/test lines PINNED to a revision ("口径复核于 <sha>"). Those drift by
543
+ // design, so the drift is a measured NOTE carrying the current lines — never a failure.
544
+ const lineOf = (needle) => { const at = raw.indexOf(needle); return at === -1 ? -1 : raw.slice(0, at).split('\n').length }
545
+ const startLine = lineOf('const startRound = (rounds.get(member.id) || 0) + 1')
546
+ const applyLine = lineOf('rounds.set(member.id, startRound)')
547
+ if (startLine === -1 || applyLine === -1) findings.push('the spawnMember round-count anchors are gone (startRound=' + startLine + ', applied=' + applyLine + ')')
548
+ notes.push('F1 resume-claim pairing: README ' + readmeHits + '/' + readmeClaims.length + ' claims, behavioural anchors ' + suiteHits + '/' + suiteClaims.length + '; cited-line drift (README pins a revision): startRound now :' + startLine + ', applied now :' + applyLine)
549
+ }
550
+
551
+ // F2/G1 pairing (prose ↔ code): the v5 diagram must not state the PRE-G1 order, and must describe the
552
+ // implemented one. Going through `readRaw` gives this block the same `V5_INTEGRITY_MUTATE` seam as every
553
+ // other file, which is what the `--self-probe` case below relies on.
554
+ {
555
+ const diagram = readRaw('vibe-math-v5/架构图.md')
556
+ const FORBID = /先\s*ack[^\n]{0,8}再构造/
557
+ const CLAUSES = /构造[^\n]{0,12}(后|再)[^\n]{0,12}ack|ack[^\n]{0,12}(仅|只)[^\n]{0,12}(成功|送达)/
558
+ if (FORBID.test(diagram)) findings.push('架构图 still states the pre-G1 order: ack before the prompt is built (F2/G1)')
559
+ if (!CLAUSES.test(diagram)) findings.push('架构图 does not describe the implemented inbox order (construct first / ack only on success)')
560
+ if (!raw.includes('if (ok && prompt.pending.length) await ackPending(')) findings.push('the ack is no longer gated by a successful wake (F2/G1 anchor missing)')
561
+ notes.push('架构图 inbox pairing: forbid-hits=' + (FORBID.test(diagram) ? 1 : 0) + ', clauses=' + (CLAUSES.test(diagram) ? 1 : 0))
562
+ // The PLAN uses the explicit pre-G1 phrases only: a sentence that legitimately QUOTES or FORBIDS the old
563
+ // order must not trip this, so the pattern names the phrases instead of the bare `先 ack`.
564
+ const planDoc = readRaw('vibe-math-v5/实现方案.md')
565
+ const PLAN_FORBID = /先\s*ack\s*邮件[、,,]?\s*再构造|先\s*ack[、,,]?\s*后构造/
566
+ if (PLAN_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 order: ack the mail before building the prompt (F2/G1)')
567
+ notes.push('实现方案.md inbox pairing: forbid-hits=' + (PLAN_FORBID.test(planDoc) ? 1 : 0))
568
+ // The SAME class produced five instances, the last one inside the guard checklist itself, so the scan
569
+ // covers every top-level docs/*.md. The phrase is NAMED (never the bare `先 ack`) so a sentence that
570
+ // legitimately QUOTES or FORBIDS the old order cannot trip it. NOTE (applier): `HERE` is the REPO ROOT
571
+ // (see `new URL(rel, HERE)` above), so the docs directory is `new URL('docs/', HERE)` — NOT `../docs/`.
572
+ const DOC_FORBID = /先\s*ack[^\n]{0,8}再构造/
573
+ let docHits = 0, docFiles = 0
574
+ for (const f of readdirSync(new URL('docs/', HERE)).filter((n) => n.endsWith('.md'))) {
575
+ docFiles += 1
576
+ if (DOC_FORBID.test(readRaw('docs/' + f))) { docHits += 1; findings.push('docs/' + f + ' still states the pre-G1 order: ack the mail before building the prompt (F2/G1)') }
577
+ }
578
+ // The scan covers LIVE docs only. `docs/release-notes/**` is a FROZEN published record: the pre-G1
579
+ // wording there is history, not a stale claim, so it must stay OUT of the scan — and the exemption is
580
+ // ASSERTED (a missing/empty directory fails) so a future change to recursive scanning cannot silently
581
+ // start scanning frozen history. Same convention as audit-readme-counts.mjs's frozen TOTAL-65 notes.
582
+ let frozenNotes = []
583
+ try { frozenNotes = readdirSync(new URL('docs/release-notes/', HERE)).filter((n) => n.endsWith('.md')) } catch (e) { frozenNotes = [] }
584
+ if (!frozenNotes.length) findings.push('the frozen release-notes exemption is EMPTY or missing (docs/release-notes/*.md) — re-decide the scan scope explicitly, never widen it silently')
585
+ notes.push('docs/** inbox pairing: files=' + docFiles + ', forbid-hits=' + docHits + '; frozen docs/release-notes/*.md exempted=' + frozenNotes.length)
586
+ // The plan ALSO carried the pre-G1 re-send contract ("queued 不重发/永不重发") in two sketch lines while
587
+ // its own M2 row already said "未 ack ⇒ 仍可重投". NAMED phrase only, scoped to the plan file, so a
588
+ // sentence that legitimately quotes or forbids the old wording cannot trip it.
589
+ const QUEUED_FORBID = /queued\s*(永不|不)重发/
590
+ if (QUEUED_FORBID.test(planDoc)) findings.push('实现方案.md still states the pre-G1 re-send contract (queued 不重发/永不重发): unacked mail is re-deliverable (F2/G1)')
591
+ notes.push('实现方案.md queued-no-resend phrasing: hits=' + (QUEUED_FORBID.test(planDoc) ? 1 : 0))
592
+ }
593
+
328
594
  // The plan's philosophy is enforced by concrete gates; assert the load-bearing ones
329
595
  // still exist so a future edit cannot quietly drop a guard.
330
596
  const GATES = [
@@ -336,24 +602,36 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
336
602
  ['assignment objections are broadcast', 'if (p.reject_assign && typeof p.reject_assign === \'object\' && params.memberMayRejectAssign)'],
337
603
  ['the academician has no extra vote weight', 'const E = voters().map((m) => m.id)'],
338
604
  ['members may reject an assignment', "memberMayRejectAssign: true,"],
339
- ['the charter states the progress definition', "' · Progress/<你>/progress.md —— **你的研究日志**(叙述体,可追加)。'"],
605
+ ['the charter states the progress definition', "' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。'"],
340
606
  ['the charter describes the academician as organizer', "' 【四、你的组织职责与边界(院士)】'"],
341
607
  ['the framework never assigns on its own', "agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?'"],
342
608
 
343
609
  // ── PROMPT / INTERACTION CORRECTNESS GATES ─────────────────────────────
610
+ // F1 (README two-case resume claim): the two code anchors the prose cites.
611
+ ['the founding round is computed from the stored count (never reset)', 'const startRound = (rounds.get(member.id) || 0) + 1'],
612
+ ['the founding round is applied only AFTER a successful start', 'rounds.set(member.id, startRound)'],
344
613
  // The 2026-09 field test shipped a framework whose every member brief named the WRONG
345
614
  // member. These gates keep the structural fixes in place, and the companion
346
615
  // prompt-v5-integrity.test.mjs asserts the TEXT those fixes produce.
347
- ['the status block takes the member it describes', 'function briefBlock(member) {'],
616
+ ['the status block takes the member it describes', 'function briefBlock(member, roundNo) {'],
348
617
  ['an unknown identity fails loudly instead of being guessed', "throw v5err('V5_INTERNAL', 'briefBlock: a member is required"],
349
- ['the status block is built from that member, never a global', 'function stateBlock(member) {\n return briefBlock(member)\n }'],
618
+ ['the status block is built from that member, never a global', 'function stateBlock(member, roundNo) {\n return briefBlock(member, roundNo)\n }'],
619
+ ['the founding prompt is told the round it is starting (never a stored 0)', 'const prompt = initialPrompt(member, initialTask, mode, startRound)'],
350
620
  ['the joiner is committed to the roster BEFORE its brief is built', "member.phase = 'active'\n member.childId = ''\n await putMember(member)"],
351
621
  ['the charter is frozen at hire and reused on resume', 'const persona = member.persona || memberPersona(member)'],
352
622
  ['a rebuilt session is framed as a rebuild', "const resume = mode === 'resume'"],
353
623
  ['leadership text follows the live roster', 'function academicianId() {'],
354
624
  ['no charter invents a leader when there is none', "本所当前**没有在册院士**"],
355
625
  ['the office resolves as the office, never as a guessed member', "try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }"],
356
- ['the mailbox is acked BEFORE the round prompt is built', 'if (pending.length) await ackPending(pending)\n const base = typeof baseFn === \'function\' ? baseFn() : baseFn'],
626
+ ['the mailbox is acked only AFTER the wake actually succeeded', "if (ok && prompt.pending.length) await ackPending(prompt.pending)"],
627
+ // F2: the three REAL mailbox invariants, each at its own source anchor. `inFlightMessages` is a DSH
628
+ // mailbox internal that v5 does NOT have (dedupe is `delivered` + the per-round injection marks), so
629
+ // no gate may reference it.
630
+ ['the acknowledgement is gated by the wake result AND a non-empty pending list', 'const ok = await wakeMember(member, prompt.text, kind)'],
631
+ ['the delivered ledger is CAPPED (oldest evicted, never unbounded)', 'const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered'],
632
+ ['a prompt prepends only messages not already prepended in this round', 'const fresh = pending.filter((p) => !seen.has(p.id))'],
633
+ ['the per-round injection mark is cleared with every concluded attempt', 'inboxInjected.delete(member.id)'],
634
+ ['a prepended inbox suppresses the base block (exactly one inbox section per prompt)', "const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)"],
357
635
  ['framework feedback has its own sender (never a self-message)', "return await say('framework', { to: memberId, kind: 'notice', text: String(text) })"],
358
636
  ['an assignment is framed by its true origin', "if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text"],
359
637
  ['a nudge is framed as supervision, not as an assignment', "to, kind: 'nudge',"],
@@ -388,6 +666,7 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
388
666
  if (!raw.includes(needle)) findings.push('philosophy gate missing from the implementation: ' + label)
389
667
  }
390
668
  notes.push('philosophy gates checked: ' + GATES.length)
669
+ gateCount = GATES.length
391
670
 
392
671
  // The prompt corpus is a SHIPPED deliverable, not a build artifact: a human must be able
393
672
  // to read the exact text every member receives without decoding session logs.
@@ -411,7 +690,7 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
411
690
  // never contain a wrong-identity brief.
412
691
  try {
413
692
  const corpusPath = new URL('prompt-corpus-v5/prompt-corpus-v5.json', REPO_ROOT)
414
- const c = JSON.parse(readFileSync(corpusPath, 'utf8'))
693
+ const c = JSON.parse(readRaw('prompt-corpus-v5/prompt-corpus-v5.json'))
415
694
  const kinds = new Set((c.prompts || []).map(p => p.kind))
416
695
  const need = ['founding', 'founding-temp', 'founding-leaderless', 'resume', 'normal', 'checkpoint',
417
696
  'verify', 'verify-debate', 'meeting', 'meeting-proposal', 'inbox-dm', 'inbox-voters', 'inbox-chat',
@@ -440,7 +719,7 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
440
719
  // it is the only prompt the MAIN agent receives, and no e2e suite loads the YAML, so a
441
720
  // human reviewer needs the corpus on disk (generated by audit-persona-surface.test.mjs).
442
721
  try {
443
- const pc = JSON.parse(readFileSync(new URL('prompt-corpus-persona/persona-corpus.json', REPO_ROOT), 'utf8'))
722
+ const pc = JSON.parse(readRaw('prompt-corpus-persona/persona-corpus.json'))
444
723
  for (const d of ['vibe-math-v2', 'vibe-math-v3', 'vibe-math-v4', 'vibe-math-v5']) {
445
724
  if (!(pc.presets || []).some((p) => p.preset === d)) findings.push('the persona corpus is missing preset ' + d)
446
725
  }
@@ -495,6 +774,14 @@ notes.push('composition rows: ' + v5rows.length + '; non-v4 package rows: ' + v5
495
774
  }
496
775
 
497
776
  // ---- report ------------------------------------------------------------
777
+ // `--json` is the machine-readable contract the `--self-probe` children parse (same shape as
778
+ // audit-prompt-invariants.mjs): { gates, failed, findings, notes }. It is emitted BEFORE the human
779
+ // output so a child run's stdout is parseable in one piece.
780
+ if (process.argv.includes('--json')) {
781
+ console.log(JSON.stringify({ gates: gateCount, failed: findings.length, findings, notes }))
782
+ for (const f of findings) console.error(' FINDING: ' + f)
783
+ process.exit(findings.length ? 1 : 0)
784
+ }
498
785
  console.log('-- V5 integrity audit --')
499
786
  for (const n of notes) console.log(' note: ' + n)
500
787
  console.log('')