dsh-vibe-math 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.en.md +14 -14
  2. package/README.md +15 -14
  3. package/cordis.patch.yml +33 -10
  4. package/docs/AUDIT-CHECKLIST.md +25 -6
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  7. package/docs/test-timing.md +73 -40
  8. package/installer.js +225 -45
  9. package/package.json +3 -2
  10. package/preset-declaration.js +38 -7
  11. package/prompt-corpus-persona/persona-corpus.json +9 -9
  12. package/prompt-corpus-persona/persona-corpus.md +67 -21
  13. package/prompt-corpus-v2/formal-verify-v2.json +116 -92
  14. package/prompt-corpus-v2/formal-verify-v2.md +240 -93
  15. package/prompt-corpus-v3/formal-verify-v3.json +81 -81
  16. package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
  17. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  18. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  19. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  20. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  21. package/tests/audit-installer-compat.test.mjs +70 -4
  22. package/tests/audit-installer-policy.test.mjs +229 -4
  23. package/tests/audit-persona-sensitivity.mjs +11 -4
  24. package/tests/audit-persona-surface.test.mjs +5 -1
  25. package/tests/audit-v5-sensitivity.mjs +47 -6
  26. package/tests/e2e-v5-round2.test.mjs +354 -0
  27. package/tests/formal-verify-v2.test.mjs +54 -2
  28. package/tests/formal-verify-v3.test.mjs +63 -2
  29. package/tests/formal-verify-v4.test.mjs +120 -4
  30. package/tests/run-tests.mjs +104 -23
  31. package/vibe-math-v2/agent.cordis.yml +6 -0
  32. package/vibe-math-v2/vibe-math-v2.js +503 -99
  33. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  34. package/vibe-math-v3/agent.cordis.yml +26 -10
  35. package/vibe-math-v3/vibe-math-v3.js +555 -175
  36. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  37. package/vibe-math-v4/agent.cordis.yml +26 -6
  38. package/vibe-math-v4/vibe-math-v4.js +404 -51
  39. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  40. package/vibe-math-v5/agent.cordis.yml +8 -4
  41. package/vibe-math-v5/vibe-math-v5.js +513 -105
  42. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +73 -40
@@ -31,6 +31,31 @@ const original = readFileSync(SRC, 'utf8')
31
31
  const REPO = fileURLToPath(new URL('..', import.meta.url))
32
32
  const dir = mkdtempSync(join(tmpdir(), 'v5-sens-'))
33
33
 
34
+ // `armNextVerify`'s two exclusion gates (a live MEETING and a live VERIFICATION), each written
35
+ // at the function head AND as a re-check inside the `while` loop with no `await` in between.
36
+ // `armGuards(true, true)` is the pristine source text; the probes switch ONE gate off in BOTH
37
+ // copies, which is the minimal edit that actually breaks the guarantee. Keeping the block in
38
+ // one place means the two mutations cannot drift apart from the source they mutate.
39
+ const armGuards = (meetingOn, verifyOn) => [
40
+ ...(meetingOn ? [' if (meeting) return'] : []),
41
+ ' // Only one begin may be in flight. Without this, two callers (a scheduling pass',
42
+ ' // and a fresh proposal) could both pass the `currentVerify()` check before either',
43
+ ' // has published its verdict record and would start the SAME object twice.',
44
+ ' if (beginLock) return',
45
+ ...(verifyOn ? [' if (currentVerify()) return'] : []),
46
+ ' beginLock = true',
47
+ ' try {',
48
+ ' while (true) {',
49
+ ' // NEVER remove a proposal we are not about to run: the entry leaves the durable',
50
+ ' // queue only in the same step that starts it. The old code shifted it out and then',
51
+ ' // returned because a meeting was live, so the proposal existed in neither the queue',
52
+ ' // nor `verdicts` until some later pass happened to re-arm it (audit M2). Re-check',
53
+ ' // the exclusion guards here too: `await putQueue` can let a meeting open.',
54
+ ' const q = inst().queue.slice()',
55
+ ' if (!q.length) return',
56
+ ' if (' + (meetingOn && verifyOn ? 'meeting || currentVerify()' : meetingOn ? 'meeting' : 'currentVerify()') + ') return',
57
+ ].join('\n')
58
+
34
59
  // Each probe: { name, ref, guarantee, from, to }
35
60
  // `from` must occur exactly once, so a mutation can never quietly hit the wrong site.
36
61
  const probes = [
@@ -141,9 +166,19 @@ const probes = [
141
166
  name: 'begin-not-exclusive',
142
167
  ref: 'prompt-v5-integrity.test.mjs',
143
168
  guarantee: '⑱ a second proposal must QUEUE, never start a concurrent ballot',
144
- from: " if (beginLock) return\n if (currentVerify()) return\n beginLock = true",
145
- to: " beginLock = true",
146
- },
169
+ from: armGuards(true, true),
170
+ to: armGuards(true, false),
171
+ },
172
+ // NOTE — the mutual-exclusion gates of `armNextVerify` are written TWICE: once at the
173
+ // function head and once as a re-check inside the `while (true)` loop, and there is NO
174
+ // `await` between them. The head copies are therefore redundant mirrors of the in-loop
175
+ // ones, and a probe that deletes only a head copy is SEMANTICALLY INERT — it can never go
176
+ // red, which would masquerade as a "blind spot" forever (this was the state of both of the
177
+ // ⑱/㉝ probes: the in-loop re-check kept the invariant alive and
178
+ // `prompt-v5-integrity.test.mjs` stayed green). The probes for ⑱ and ㉝ therefore remove
179
+ // BOTH copies of the ONE gate they test, which is the minimal edit that actually lets a
180
+ // second ballot start — and the suite's case 9b ("a second proposal QUEUES") and case 10b
181
+ // ("a verification proposed DURING a meeting must queue") do detect exactly that.
147
182
  // NOTE — a probe for `continueMeetingRound`'s `if (finalizeLock) { armHeartbeat(); return }`
148
183
  // re-arm was REMOVED, not because the re-arm is unnecessary but because the state it
149
184
  // guards is UNREACHABLE, so no black-box probe can detect its removal:
@@ -269,8 +304,10 @@ const probes = [
269
304
  name: 'verify-preempts-a-live-meeting',
270
305
  ref: 'prompt-v5-integrity.test.mjs',
271
306
  guarantee: '㉝ a verification proposed during a meeting must QUEUE, never start concurrently (the two are mutually exclusive)',
272
- from: " if (meeting) return\n // Only one begin may be in flight.",
273
- to: " // Only one begin may be in flight.",
307
+ // Removes BOTH `meeting` gates (see the note above `meeting-never-finalized`): deleting
308
+ // only the head copy is inert because the in-loop re-check runs before any `await`.
309
+ from: armGuards(true, true),
310
+ to: armGuards(false, true),
274
311
  },
275
312
 
276
313
  // ── LEAN FORMAL VERIFICATION PROBES (docs/formal-verification.md) ─────────
@@ -362,7 +399,11 @@ for (const p of probes) {
362
399
  continue
363
400
  }
364
401
  const r = spawnSync(process.execPath, [testPath], {
365
- env: Object.assign({}, process.env, { V5_PLUGIN: file }),
402
+ // V5_CORPUS_DIR: a MUTATED run must never rewrite the shipped `prompt-corpus-v5/`. The
403
+ // corpus is the mandatory human-review artifact (AUDIT-CHECKLIST §0.3/§2.4); letting a
404
+ // deliberately broken copy dump its prompts there would leave the working tree holding
405
+ // mutant text until someone re-ran the healthy suite.
406
+ env: Object.assign({}, process.env, { V5_PLUGIN: file, V5_CORPUS_DIR: join(dir, 'corpus') }),
366
407
  encoding: 'utf8',
367
408
  cwd: REPO,
368
409
  })
@@ -347,6 +347,47 @@ console.log('\n[6] quorum recomputed when a voter is dismissed')
347
347
  assert(h.drains.length === 2, 'both dismissed researchers had their resident child released')
348
348
  }
349
349
 
350
+ // ---------- 6b. a dismissed voter's old ballot votes in NEITHER direction ----------
351
+ console.log('\n[6b] a dismissed voter\'s old ballot is dropped from the ledger AND the tally')
352
+ {
353
+ // Round A H2 fixed "a dismissed member's old ballot still counts". The suite used to prove only
354
+ // that m/voterCount were recomputed and that the resident was released — never that the BALLOT
355
+ // stopped counting. This case votes, dismisses, then re-votes, which is the sequence the audit
356
+ // asked for: r-1 asserts TRUE, is dismissed, and the remaining voter's FALSE must now CONCLUDE
357
+ // (with r-1's ballot ignored). If a former member's vote still counted, 1 vs 0 would conflict
358
+ // and the object could never reach Verified/ — so the verdict itself is the discriminator.
359
+ const h = makeHost({ pluginModule })
360
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
361
+ await h.settleSpawns()
362
+ const stateFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
363
+ const ballots = () => {
364
+ try {
365
+ const s = JSON.parse(readFileSync(stateFile, 'utf8'))
366
+ const v = s.institutes[Object.keys(s.institutes)[0]].verdicts['p-dismiss']
367
+ return v ? Object.keys(v.votes || {}) : null
368
+ } catch (e) { return null }
369
+ }
370
+ const s0 = await h.callTool('vibe_v5_status', {})
371
+ assert(s0.quorum.voterCount === 2 && s0.quorum.m === 2, 'baseline: acad + r-1 = 2 voters, m=2 (got P=' + s0.quorum.voterCount + ', m=' + s0.quorum.m + ')')
372
+ await h.callTool('vibe_v5_record_proposition', { id: 'p-dismiss', statement: '被解雇者的旧票不得计入', value: 0.5, motive: 'm', p: 0.5 })
373
+ await h.callTool('vibe_v5_propose_verify', { target: 'p-dismiss', kind: 'proposition', reason: 'r' }, h.childAgent(h.childOf('r-1')))
374
+ const s1 = await h.callTool('vibe_v5_status', {})
375
+ assert(!!s1.verify && s1.verify.target === 'p-dismiss', 'the ballot on p-dismiss is live')
376
+ const voted = await h.callTool('vibe_v5_verdict', { target: 'p-dismiss', verdict: 1, reason: 'r-1 断言为真' }, h.childAgent(h.childOf('r-1')))
377
+ assert(voted.ok === true && voted.allVoted === false, 'r-1 cast the ONLY boolean vote so far (the other voter has not answered)')
378
+ assert((ballots() || []).indexOf('r-1') !== -1, 'the durable open-verdict record carries r-1\'s ballot before the dismissal (' + JSON.stringify(ballots()) + ')')
379
+ const removed = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' })
380
+ assert(removed.ok === true, 'r-1 was dismissed with the ballot still open (' + JSON.stringify(removed).slice(0, 80) + ')')
381
+ const s2 = await h.callTool('vibe_v5_status', {})
382
+ assert(s2.quorum.voterCount === 1 && s2.quorum.m === 1, 'the live roster recomputes immediately: P=1, m=1 (got P=' + s2.quorum.voterCount + ', m=' + s2.quorum.m + ')')
383
+ assert((ballots() || []).indexOf('r-1') === -1, '★ the dismissed member\'s ballot is DELETED from the durable open verdict (votes=' + JSON.stringify(ballots()) + ')')
384
+ const finalVote = await h.callTool('vibe_v5_verdict', { target: 'p-dismiss', verdict: 0, reason: 'acad 断言为假' }, h.childAgent(h.childOf('acad')))
385
+ assert(finalVote.ok === true && finalVote.allVoted === true, 'the remaining voter completed the ballot (allVoted=true)')
386
+ const s3 = await h.callTool('vibe_v5_status', {})
387
+ assert(s3.verified.indexOf('p-dismiss') !== -1,
388
+ '★ the ballot CONCLUDED false — a former member\'s TRUE vote cannot block it (got verified=' + JSON.stringify(s3.verified) + ', undecided=' + JSON.stringify(s3.undecided) + ')')
389
+ }
390
+
350
391
  // ---------- 7. vibe_v5_wait ----------
351
392
  console.log('\n[7] vibe_v5_wait')
352
393
  {
@@ -584,6 +625,319 @@ console.log('\n[15] the host live-child cap (ACTIVATION_LIMIT_REACHED) is named,
584
625
  assert(st2.members.filter(m => m.phase === 'active' && m.childId).length === CAP, 'the institute is back at the host ceiling (active=' + st2.members.filter(m => m.phase === 'active').map(m => m.id).join(',') + ')')
585
626
  }
586
627
 
628
+ // ---------- 16. a state file that was never loaded must never be overwritten (audit H1) --------
629
+ console.log('\n[16] a pre-seeded snapshot is ADOPTED, never overwritten with an empty institute')
630
+ {
631
+ // Round A H1. Ported from the reproduction that lived outside the repository
632
+ // (`_oneoff/audit241-v5-overwrite.mjs`): a host with no `sandboxPolicy` service (the supported
633
+ // case the e2e harness itself models) used to latch the DEFAULT path on its first read, after
634
+ // which `configure {institute:'alpha'}` wrote an EMPTY institute over alpha's real file.
635
+ const ws = mkdtempSync(join(tmpdir(), 'vibe-v5-seed-'))
636
+ const seedFile = join(ws, 'VibeMath', 'Projects', 'default', 'Institutes', 'alpha', 'State', 'alpha.v5state.json')
637
+ mkdirSync(dirname(seedFile), { recursive: true })
638
+ const seeded = {
639
+ v: 1,
640
+ institutes: {
641
+ 'default::alpha': {
642
+ key: 'default::alpha', project: 'default', institute: 'alpha', createdAt: 1, phase: 'active',
643
+ problem: { id: 'p', statement: 'a real problem' }, params: {}, runId: 'run-x',
644
+ members: ['acad', 'r-1', 'r-2', 'r-3', 't-1'].map((id) => ({
645
+ id, kind: id === 'acad' ? 'academician' : id[0] === 'r' ? 'researcher' : 'temp',
646
+ childId: '', phase: 'active', direction: 'd', hiredBy: '', term: '', provider: 'spawn',
647
+ persona: 'CHARTER', error: '', createdAt: 1, dismissedAt: 0, dismissReason: '',
648
+ })),
649
+ tasks: [], messages: [], delivered: [], meetings: [], debates: [], verdicts: {}, formal: {},
650
+ todo: [], queue: [], counters: { academician: 1, researcher: 3, temp: 1, task: 0, meeting: 0, message: 0, verify: 0 },
651
+ lastProgressAt: 1, artifactCount: 7, diagnostics: [],
652
+ },
653
+ },
654
+ order: ['default::alpha'],
655
+ }
656
+ writeFileSync(seedFile, JSON.stringify(seeded, null, 2), 'utf8')
657
+ const h = makeHost({ pluginModule, ws })
658
+ const c = await h.callTool('vibe_v5_configure', { institute: 'alpha', problem: 'a real problem' })
659
+ assert(c.ok === true && c.institute === 'alpha', 'configure accepted the existing named institute (' + JSON.stringify(c).slice(0, 90) + ')')
660
+ const after = JSON.parse(readFileSync(seedFile, 'utf8')).institutes['default::alpha']
661
+ assert(after && after.members.length === 5, '★ the 5-member snapshot on disk survived configure (got ' + (after && after.members.length) + ')')
662
+ assert(after.artifactCount === 7 && after.runId === 'run-x', 'the rest of the snapshot survived too (artifactCount=' + after.artifactCount + ', runId=' + after.runId + ')')
663
+ const st = await h.callTool('vibe_v5_status', {})
664
+ assert(st.key === 'default::alpha' && st.members.length === 5, 'status reads the ADOPTED institute, not an empty one (key=' + st.key + ', members=' + st.members.length + ')')
665
+ }
666
+
667
+ // ---------- 17. the NAMED institute's own file is really read (audit H1, second half) ----------
668
+ console.log('\n[17] a second host reads the NAMED institute from its own file')
669
+ {
670
+ // Ported from `_oneoff/audit241-v5-key.mjs`. `configure` must read `Institutes/<name>/State/
671
+ // <name>.v5state.json` — the audit's bug was that it silently created a NEW empty institute
672
+ // under the requested name while the real file stayed untouched.
673
+ const h1 = makeHost({ pluginModule })
674
+ const c1 = await h1.callTool('vibe_v5_configure', { institute: 'alpha', problem: 'named institute' })
675
+ assert(c1.ok === true && c1.institute === 'alpha', 'host A configured the named institute')
676
+ const s1 = await h1.callTool('vibe_v5_status', {})
677
+ assert(s1.key === 'default::alpha', 'host A reports key=default::alpha (got ' + s1.key + ')')
678
+ await h1.callTool('vibe_v5_start', { researcherCount: 1 })
679
+ await h1.settleSpawns()
680
+ const namedFile = join(h1.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'alpha', 'State', 'alpha.v5state.json')
681
+ const onDisk = () => { try { return JSON.parse(readFileSync(namedFile, 'utf8')).institutes['default::alpha'] } catch (e) { return null } }
682
+ let snapshot = null
683
+ for (let i = 0; i < 80 && !(snapshot && snapshot.members.length >= 2); i++) { snapshot = onDisk(); if (!(snapshot && snapshot.members.length >= 2)) await sleep(25) }
684
+ assert(!!snapshot && snapshot.members.length >= 2, 'host A persisted the institute into the file NAMED AFTER IT (' + JSON.stringify(snapshot && snapshot.members.map(m => m.id)) + ')')
685
+ const ids = (snapshot || { members: [] }).members.map(m => m.id)
686
+ // A fresh host (fresh module instance) over the SAME workspace: this is what a user restarting
687
+ // DSH sees. It must adopt the institute, not found a second empty one.
688
+ const h2 = makeHost({ pluginModule, ws: h1.WS })
689
+ const c2 = await h2.callTool('vibe_v5_configure', { institute: 'alpha', problem: 'named institute' })
690
+ assert(c2.ok === true, 'host B configured the same named institute (' + JSON.stringify(c2).slice(0, 90) + ')')
691
+ const s2 = await h2.callTool('vibe_v5_status', {})
692
+ assert(s2.key === 'default::alpha' && s2.backend === 'file', 'host B reports the named institute over the file backend (key=' + s2.key + ', backend=' + s2.backend + ')')
693
+ assert(ids.length > 0 && ids.every(id => s2.members.some(m => m.id === id)),
694
+ '★ host B READ the existing members instead of starting empty (got ' + JSON.stringify(s2.members.map(m => m.id)) + ', expected to include ' + JSON.stringify(ids) + ')')
695
+ const resumed = await h2.callTool('vibe_v5_resume', {})
696
+ assert(resumed.ok === true, 'resume on the adopted institute succeeded (' + JSON.stringify(resumed).slice(0, 120) + ')')
697
+ }
698
+
699
+ // ---------- 18. a FAILED turn: stopReason=error, lastAssistantMessage OMITTED -------------------
700
+ console.log('\n[18] a turn ending with stopReason=error is recorded, with or without output')
701
+ {
702
+ // The real host emits `stopReason:'error'` for a failed turn and OMITS `lastAssistantMessage`
703
+ // when there is nothing to report (dsh-subagent index.js:268-278/317-324). Every other mock in
704
+ // the suite fires 'completed' WITH a message, so this branch — and the omission it must survive —
705
+ // was never executed in CI (audit A-7).
706
+ const h = makeHost({ pluginModule })
707
+ const chatText = () => {
708
+ const dir = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'Chat')
709
+ return existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith('.md')).map(f => readFileSync(join(dir, f), 'utf8')).join('\n') : ''
710
+ }
711
+ // The exact host event, with the field ABSENT (not an empty array).
712
+ const fireBare = (childId, stopReason) => { for (const fn of h.listeners['subagent/end'] || []) fn({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason }) }
713
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
714
+ await h.settleSpawns()
715
+ // Wake r-1 first: an end for a member with no in-flight token is ignored BY DESIGN (the
716
+ // idempotence guard), so without this the error branch would be unreachable.
717
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: '请回报进展。' }, h.childAgent(h.childOf('acad')))
718
+ await sleep(40)
719
+ const st0 = await h.callTool('vibe_v5_status', {})
720
+ assert((st0.members.find(m => m.id === 'r-1') || {}).busy === true, 'precondition: r-1 has a turn in flight')
721
+ const before = chatText().length
722
+ fireBare(h.childOf('r-1'), 'error')
723
+ await sleep(80)
724
+ const written = chatText().slice(before)
725
+ const seg = written.indexOf('【异常】') === -1 ? '' : written.slice(written.indexOf('【异常】'), written.indexOf('【异常】') + 400)
726
+ const line = (seg.match(/【异常】[^\n]*/) || [''])[0]
727
+ assert(/【异常】r-1/.test(line) && /error/.test(line), '★ a failed turn is recorded in the group chat as 【异常】 for its OWNER: ' + JSON.stringify(line.slice(0, 120)))
728
+ assert(seg.indexOf('最后输出') === -1, 'an OMITTED lastAssistantMessage adds no phantom output text')
729
+ const st1 = await h.callTool('vibe_v5_status', {})
730
+ assert((st1.members.find(m => m.id === 'r-1') || {}).busy !== true, 'the failed member is not left marked busy')
731
+ assert(st1.running === true, 'the institute keeps running after a failed turn')
732
+ // And the SAME branch WITH output must carry it — otherwise the two assertions above could hold
733
+ // for a handler that only ever writes the member id.
734
+ await h.callTool('vibe_v5_say', { to: 'r-1', text: '再来一次。' }, h.childAgent(h.childOf('acad')))
735
+ await sleep(40)
736
+ const mark = chatText().length
737
+ h.fireEnd(h.childOf('r-1'), { progress: 'partial text before the failure' }, 'max-tokens')
738
+ await sleep(80)
739
+ const after = chatText().slice(mark)
740
+ const idx2 = after.indexOf('【异常】')
741
+ const seg2 = idx2 === -1 ? '' : after.slice(idx2, idx2 + 400)
742
+ const line2 = (seg2.match(/【异常】[^\n]*/) || [''])[0]
743
+ assert(/max-tokens/.test(line2) && /最后输出/.test(line2) && /partial text before the failure/.test(seg2),
744
+ '★ a non-completed turn WITH output carries that output and its stop reason: ' + JSON.stringify(seg2.slice(0, 140)))
745
+ }
746
+
747
+ // ---------- 19. two proposals in the SAME tick are never lost -------------------
748
+ console.log('\n[19] concurrent propose_verify: the durable queue must not lose one')
749
+ {
750
+ // The verify queue was mutated with a whole-array read-modify-write (`inst().queue.slice()`,
751
+ // push, `await putQueue(q)`). Two members proposing in the same tick both read the same array
752
+ // and each committed its own copy, so the last writer won and the first proposal vanished from
753
+ // the durable queue with BOTH calls reporting ok:true. The mutation now happens inside the
754
+ // event fold (`appendToQueue`), so no same-tick append can overwrite another.
755
+ const h = makeHost({ pluginModule })
756
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
757
+ await h.settleSpawns()
758
+ const who = h.childAgent(h.childOf('r-1'))
759
+ const [pa, pb] = await Promise.all([
760
+ h.callTool('vibe_v5_propose_verify', { target: 'p-race-a', kind: 'proposition', reason: 'race A' }, who),
761
+ h.callTool('vibe_v5_propose_verify', { target: 'p-race-b', kind: 'proposition', reason: 'race B' }, who),
762
+ ])
763
+ assert(pa.ok === true && pb.ok === true, 'both concurrent proposals report ok (the buggy code reported ok for the lost one too)')
764
+ const s = await h.callTool('vibe_v5_status', {})
765
+ const seen = (s.verify ? [s.verify.target] : []).concat(s.verifyQueue)
766
+ assert(seen.indexOf('p-race-a') !== -1 && seen.indexOf('p-race-b') !== -1,
767
+ '★ neither same-tick proposal is lost: each is in-flight or queued (verify=' + JSON.stringify(s.verify && s.verify.target) + ', queue=' + JSON.stringify(s.verifyQueue) + ')')
768
+ await sleep(30)
769
+ const st = JSON.parse(readFileSync(statePathOf(h.WS), 'utf8'))
770
+ const inst = st.institutes[Object.keys(st.institutes)[0]]
771
+ const durable = (inst.queue || []).map(q => q.target)
772
+ const open = Object.keys(inst.verdicts || {}).filter(k => !inst.verdicts[k].closed)
773
+ const bothDurable = (durable.indexOf('p-race-a') !== -1 || open.indexOf('p-race-a') !== -1) &&
774
+ (durable.indexOf('p-race-b') !== -1 || open.indexOf('p-race-b') !== -1)
775
+ assert(bothDurable, '★ the durable state file keeps both as well (queue=' + JSON.stringify(durable) + ', open verdicts=' + JSON.stringify(open) + ')')
776
+ }
777
+
778
+ // ---------- 20. invalid numeric params are clamped, not silently destructive ----------
779
+ console.log('\n[20] invalid numeric params are clamped instead of silently destructive')
780
+ {
781
+ // `compactThreshold <= 0` made EVERY round look over the threshold (a permanent compaction
782
+ // directive), `verdictMaxRounds < 1` collapsed every debate to one round, and `chatDigestMax < 1`
783
+ // emptied the digest bucket. `normalizeParams` now applies the same discipline the durations use.
784
+ const h = makeHost({ pluginModule })
785
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
786
+ await h.settleSpawns()
787
+ const r = await h.callTool('vibe_v5_set', { verdictMaxRounds: 0, chatDigestMax: -3, compactThreshold: 0 })
788
+ assert(r.ok === true && r.params.verdictMaxRounds === 1, 'verdictMaxRounds=0 floors at 1 (got ' + r.params.verdictMaxRounds + ')')
789
+ assert(r.params.chatDigestMax === 1, 'chatDigestMax=-3 floors at 1 (got ' + r.params.chatDigestMax + ')')
790
+ assert(r.params.compactThreshold === 66, 'compactThreshold=0 falls back to the default 66 (got ' + r.params.compactThreshold + ')')
791
+ }
792
+
793
+ // ---------- 21. an office-only tool never impersonates the office for an unknown caller ----
794
+ console.log('\n[21] office-only tools refuse a caller that is neither a member nor the session root')
795
+ {
796
+ // `memberIdOfAgent` answers 'office' for the root AND '' for an unrelated descendant, and
797
+ // `isOffice('')` used to be true — so the old handlers fell back to the office and let any
798
+ // unresolvable caller convene meetings / hire / fire / ASSIGN / NUDGE / create tasks as the
799
+ // office (audit L6 and its follow-up). `officeCaller` now refuses, every writing tool resolves
800
+ // through it, and `isOffice` itself only accepts the literal 'office'.
801
+ const h = makeHost({ pluginModule })
802
+ await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
803
+ await h.settleSpawns()
804
+ const ghost = h.childAgent('c-nobody')
805
+ const mtg = await h.callTool('vibe_v5_meeting', { agenda: '冒充所办', kind: 'sync' }, ghost)
806
+ assert(mtg.ok === false && mtg.code === 'V5_MEMBER_NOT_FOUND',
807
+ 'meeting is refused for a caller that is not a member or the session root (' + JSON.stringify(mtg).slice(0, 90) + ')')
808
+ const res = await h.callTool('vibe_v5_add_researcher', { direction: 'x' }, ghost)
809
+ assert(res.ok === false && res.code === 'V5_MEMBER_NOT_FOUND',
810
+ 'add_researcher is refused for the same caller (' + JSON.stringify(res).slice(0, 90) + ')')
811
+ // The whole family of writing tools must refuse that caller, not just the office-only ones:
812
+ // each of these used to receive '' from memberIdOfAgent and treat it as the office. A REAL task
813
+ // is opened first so the ghost's task_update / prioritize have live state to damage.
814
+ const t1 = await h.callTool('vibe_v5_task_create', { subject: '真实任务' })
815
+ assert(t1.ok === true, 'a real caller can still open a task (' + JSON.stringify(t1).slice(0, 90) + ')')
816
+ const boardBefore = (await h.callTool('vibe_v5_task_list', {})).tasks.length
817
+ const ghostWrites = [
818
+ ['vibe_v5_assign', { subject: '冒充分派', to: 'r-1', why: 'w', acceptance: 'a' }],
819
+ ['vibe_v5_nudge', { to: 'r-1', why: 'w' }],
820
+ ['vibe_v5_task_create', { subject: '冒充所办建的任务' }],
821
+ ['vibe_v5_task_update', { task_id: t1.task.id, expected_revision: 1, action: 'delete' }],
822
+ ['vibe_v5_prioritize', { order: [{ task_id: t1.task.id, priority: 9 }], why: 'w' }],
823
+ ['vibe_v5_propose_verify', { target: 'p-ghost', kind: 'proposition', reason: 'r' }],
824
+ ['vibe_v5_lean_run', { file: 'Formal/ghost.lean' }],
825
+ ]
826
+ for (const [name, args] of ghostWrites) {
827
+ const r = await h.callTool(name, args, ghost)
828
+ assert(r.ok === false && r.code === 'V5_MEMBER_NOT_FOUND',
829
+ name + ' is refused for the unidentifiable caller (' + JSON.stringify(r).slice(0, 90) + ')')
830
+ }
831
+ const boardAfter = await h.callTool('vibe_v5_task_list', {})
832
+ const t1After = boardAfter.tasks.find((t) => t.id === t1.task.id)
833
+ assert(boardAfter.tasks.length === boardBefore,
834
+ 'no refused write reached the board (before=' + boardBefore + ', after=' + boardAfter.tasks.length + ')')
835
+ assert(t1After && t1After.status !== 'deleted', 'the refused task_update did not delete a real task')
836
+ assert(t1After && t1After.priority === 0, 'the refused prioritize did not reorder a real task (priority=' + (t1After && t1After.priority) + ')')
837
+ // ...and the real identities still work: the office signs as the office, a member acts as itself.
838
+ const asgOffice = await h.callTool('vibe_v5_assign', { subject: '所办分派', to: 'r-1', why: 'w', acceptance: 'a' })
839
+ assert(asgOffice.ok === true && asgOffice.task && asgOffice.task.assignedBy === 'office',
840
+ 'the office still assigns and is signed as office (' + JSON.stringify(asgOffice).slice(0, 110) + ')')
841
+ const tcOffice = await h.callTool('vibe_v5_task_create', { subject: '所办任务' })
842
+ assert(tcOffice.ok === true && tcOffice.task.createdBy === 'office', 'the office task_create still records createdBy=office')
843
+ const nudgeAcad = await h.callTool('vibe_v5_nudge', { to: 'r-1', why: '院士督办' }, h.childAgent(h.childOf('acad')))
844
+ assert(nudgeAcad.ok === true, 'the academician still nudges (' + JSON.stringify(nudgeAcad).slice(0, 90) + ')')
845
+ const office = await h.callTool('vibe_v5_meeting', { agenda: '所办直接开会', kind: 'sync' })
846
+ assert(office.ok === true, 'the real office (session root) still convenes a meeting (' + JSON.stringify(office).slice(0, 90) + ')')
847
+ // Two office-impersonation holes the independent verifier found AFTER Round D — same L6 class,
848
+ // and neither was covered by the assertions above: `vibe_v5_message` signed EVERY caller's text
849
+ // as the office (no caller resolution at all), and `vibe_v5_remove_researcher` ("Office only")
850
+ // accepted any caller. Both now resolve through `officeCaller` and refuse.
851
+ const ghostMsg = await h.callTool('vibe_v5_message', { to: 'all', content: '冒充所办通知' }, ghost)
852
+ assert(ghostMsg.ok === false && ghostMsg.code === 'V5_MEMBER_NOT_FOUND',
853
+ 'vibe_v5_message is refused for the unidentifiable caller (' + JSON.stringify(ghostMsg).slice(0, 90) + ')')
854
+ const memberMsg = await h.callTool('vibe_v5_message', { to: 'all', content: '成员冒充所办通知' }, h.childAgent(h.childOf('r-1')))
855
+ assert(memberMsg.ok === false && memberMsg.code === 'V5_NOT_OFFICE',
856
+ 'vibe_v5_message is refused for a MEMBER too — the relay is signed as the office, members use vibe_v5_say (' + JSON.stringify(memberMsg).slice(0, 90) + ')')
857
+ const ghostRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, ghost)
858
+ assert(ghostRm.ok === false && ghostRm.code === 'V5_MEMBER_NOT_FOUND',
859
+ 'vibe_v5_remove_researcher is refused for the unidentifiable caller (' + JSON.stringify(ghostRm).slice(0, 90) + ')')
860
+ const memberRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, h.childAgent(h.childOf('r-1')))
861
+ assert(memberRm.ok === false && memberRm.code === 'V5_NOT_OFFICE',
862
+ '...and refused for a member (the tool says Office only), not just for ghosts (' + JSON.stringify(memberRm).slice(0, 90) + ')')
863
+ const r1Still = (await h.callTool('vibe_v5_members', {})).members.find((m) => m.id === 'r-1')
864
+ assert(r1Still && r1Still.phase === 'active', 'neither refused call dismissed r-1 (phase=' + JSON.stringify(r1Still && r1Still.phase) + ')')
865
+ const officeMsg = await h.callTool('vibe_v5_message', { to: 'all', content: '所办通知' })
866
+ assert(officeMsg.ok === true, 'the office still relays a message (' + JSON.stringify(officeMsg).slice(0, 90) + ')')
867
+ // The `/v5` SLASH-COMMAND line is the third surface of the same class: it hardcoded 'office' for
868
+ // every subcommand (`remove`/`fire`/`hire`/`set`/`stop`/`message`…), and a member child maps to
869
+ // the same institute session — so a member (or an unidentifiable descendant) could drive it. It
870
+ // now resolves the caller too.
871
+ const cmdV5 = h.commandRegs.find((c) => c.name === 'v5')
872
+ const cmdGhost = JSON.parse((await cmdV5.handler({ agent: ghost, rawInput: 'remove r-1' })).text)
873
+ assert(cmdGhost.ok === false && cmdGhost.code === 'V5_NOT_OFFICE',
874
+ '/v5 remove is refused for the unidentifiable caller (' + JSON.stringify(cmdGhost).slice(0, 90) + ')')
875
+ const cmdMember = JSON.parse((await cmdV5.handler({ agent: h.childAgent(h.childOf('r-1')), rawInput: 'remove r-1' })).text)
876
+ assert(cmdMember.ok === false && cmdMember.code === 'V5_NOT_OFFICE',
877
+ '/v5 remove is refused for a member child too (' + JSON.stringify(cmdMember).slice(0, 90) + ')')
878
+ const r1AfterCmd = (await h.callTool('vibe_v5_members', {})).members.find((m) => m.id === 'r-1')
879
+ assert(r1AfterCmd && r1AfterCmd.phase === 'active', 'the refused /v5 remove did not dismiss r-1 (phase=' + JSON.stringify(r1AfterCmd && r1AfterCmd.phase) + ')')
880
+ const cmdOffice = JSON.parse((await cmdV5.handler({ agent: h.ROOT, rawInput: 'status' })).text)
881
+ assert(cmdOffice.ok === true, 'the office still drives the /v5 line (' + JSON.stringify(cmdOffice).slice(0, 90) + ')')
882
+ const officeRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' })
883
+ assert(officeRm.ok === true, 'the office can still dismiss a permanent researcher (' + JSON.stringify(officeRm).slice(0, 90) + ')')
884
+ }
885
+
886
+ // ---------- 22. the office-only LIFECYCLE controls resolve their caller too ----------
887
+ console.log('\n[22] configure/start/resume/pause/stop/set belong to the PROVABLE session root')
888
+ {
889
+ // A member child and the office share ONE institute session, so `getSession()` alone let a member
890
+ // (or an unidentifiable descendant of the root) drive these handlers as the office: stop the
891
+ // institute, or rewrite quorum/params. Absence of a caller is NOT the office either.
892
+ const h = makeHost({ pluginModule })
893
+ // (a) the root/UI path keeps working end-to-end: these are exactly the handlers `/v5` drives.
894
+ const cfg = await h.callTool('vibe_v5_configure', { institute: 'life', problem: PROBLEM })
895
+ assert(cfg.ok === true, 'the office configures the institute (' + JSON.stringify(cfg).slice(0, 90) + ')')
896
+ const started = await h.callTool('vibe_v5_start', { researcherCount: 1 })
897
+ assert(started.ok === true, 'the office founds it (' + JSON.stringify(started).slice(0, 90) + ')')
898
+ await h.settleSpawns()
899
+ const setOk = await h.callTool('vibe_v5_set', { maxParallel: 2 })
900
+ assert(setOk.ok === true && setOk.params.maxParallel === 2, 'the office tunes parameters (' + JSON.stringify(setOk).slice(0, 90) + ')')
901
+ const pauseOk = await h.callTool('vibe_v5_pause', {})
902
+ assert(pauseOk.ok === true, 'the office pauses (' + JSON.stringify(pauseOk).slice(0, 90) + ')')
903
+ const resumeOk = await h.callTool('vibe_v5_resume', {})
904
+ assert(resumeOk.ok === true, 'the office resumes (' + JSON.stringify(resumeOk).slice(0, 90) + ')')
905
+ // (b) a MEMBER child and an unidentifiable descendant of the office root are both refused.
906
+ const member = h.childAgent(h.childOf('r-1'))
907
+ const ghost = h.childAgent('c-nobody')
908
+ const officeOnly = [
909
+ ['vibe_v5_configure', { institute: 'stolen', problem: 'x' }],
910
+ ['vibe_v5_start', { researcherCount: 3 }],
911
+ ['vibe_v5_resume', {}],
912
+ ['vibe_v5_pause', {}],
913
+ ['vibe_v5_stop', {}],
914
+ ['vibe_v5_set', { maxParallel: 9 }],
915
+ ]
916
+ for (const [name, args] of officeOnly) {
917
+ const asMember = await h.callTool(name, args, member)
918
+ assert(asMember.ok === false && asMember.code === 'V5_NOT_OFFICE',
919
+ name + ' is refused for a MEMBER child (' + JSON.stringify(asMember).slice(0, 80) + ')')
920
+ const asGhost = await h.callTool(name, args, ghost)
921
+ assert(asGhost.ok === false && asGhost.code === 'V5_NOT_OFFICE',
922
+ name + ' is refused for the unidentifiable caller (' + JSON.stringify(asGhost).slice(0, 80) + ')')
923
+ }
924
+ // (c) NO caller at all is not the office either — driven through the real registered wrapper (the
925
+ // harness' callTool defaults to ROOT, so `agent: undefined` is passed explicitly here). The wrapper
926
+ // refuses it as 'no session' BEFORE the gate, i.e. absence never reaches the office path.
927
+ const stopSpec = h.toolRegs.find((t) => t.name === 'vibe_v5_stop')
928
+ const noCaller = JSON.parse(await stopSpec.execute({}, { agent: undefined }))
929
+ assert(noCaller.ok === false,
930
+ 'an agent-less call never reaches the lifecycle handler as the office (' + JSON.stringify(noCaller).slice(0, 80) + ')')
931
+ // (d) nothing the refusals asked for took effect.
932
+ const after = await h.callTool('vibe_v5_status', {})
933
+ assert(after && after.ok !== false && after.params && after.params.maxParallel === 2,
934
+ 'no refused lifecycle call took effect (maxParallel=' + (after.params && after.params.maxParallel) + ')')
935
+ assert(after.members && after.members.some((m) => m.phase !== 'dismissed'),
936
+ 'the refused stop did not dissolve the roster (' + (after.members || []).map((m) => m.id + ':' + m.phase).join(',') + ')')
937
+ const finalStop = await h.callTool('vibe_v5_stop', {})
938
+ assert(finalStop.ok === true, 'the office can still stop it (' + JSON.stringify(finalStop).slice(0, 90) + ')')
939
+ }
940
+
587
941
  console.log('')
588
942
  console.log('passed=' + passed + ' failed=' + failed)
589
943
  if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
@@ -184,7 +184,11 @@ function makeHost(WS, opts = {}) {
184
184
  }
185
185
 
186
186
  function makeRoot(id, WS) {
187
- return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, followup() {} }
187
+ // `pushed` records the framework's OWN messages to the main agent (`rootAgent.followup`), i.e. the
188
+ // reportMode=push progress report. It used to be a no-op, which is why that channel had no corpus
189
+ // entry at all (AUDIT §2.4 "框架→人 的反馈与推送类提示").
190
+ const pushed = []
191
+ return { id, options: { provider: 'mock', model: 'mock' }, session: { id, header: { cwd: WS, parentSession: undefined } }, pushed, followup(m) { const c = (m && m.content) || []; pushed.push(c.map((b) => (b && b.text) || '').join('')) } }
188
192
  }
189
193
 
190
194
  const hosts = []
@@ -1240,6 +1244,44 @@ section("15b a \`used\` reply must NOT downgrade an already-passed object")
1240
1244
  }
1241
1245
  }
1242
1246
 
1247
+ // ---------- 15c. the framework's OWN feedback channels (push report + gate notice) ----------
1248
+ // Every corpus entry so far was a child prompt (spawn/wake). The framework also speaks to the main
1249
+ // agent/human on two other channels — the `reportMode=push` progress report and the activity log's
1250
+ // formal-gate notice — and neither had any reviewable artefact (AUDIT §2.4 "框架→人 的反馈与推送类
1251
+ // 提示"). Both are captured DETERMINISTICALLY (no wall-clock, no timing-dependent counts):
1252
+ // · push = the forced push of the strict-termination branch on an EMPTY project (fixed counters);
1253
+ // · feedback = the FIRST formal-gate activity line of one controlled require-mode deferral.
1254
+ section('15c the framework push report and the gate notice are captured')
1255
+ {
1256
+ const h = await makeCase('push-report')
1257
+ await h.call('vibe_math_set_params', { reportMode: 'push', tickIntervalMs: 200 })
1258
+ await h.call('vibe_math_start', {})
1259
+ const pushed = await waitFor(() => (h.root.pushed && h.root.pushed.length > 0 ? h.root.pushed : undefined), 80, 100)
1260
+ assert(!!pushed, 'push-report: the scheduler pushed a progress report to the main agent')
1261
+ assert(!!pushed && /\[Vibe Math V2\] 进度更新/.test(pushed[0]), '★★ the push text is the real framework→main-agent prompt (reportMode=push)')
1262
+ assert(!!pushed && /vibe_math_report/.test(pushed[0]), 'and it names the FULL tool the main agent must call')
1263
+ await h.call('vibe_math_pause', {})
1264
+
1265
+ const g = await makeCase('gate-feedback')
1266
+ await g.call('vibe_math_set_params', { formalVerify: 'require', maxParallelThreshold: 8 })
1267
+ await g.call('vibe_math_add_proposition', { id: 'pFeed', 概述: '门禁反馈语料', 布尔估计: 0.5, 优先级: 1, '价值/关键性': 0.5, 细类型: { 数论: {} } })
1268
+ await startScheduler(g)
1269
+ const gvs = await waitFor(() => { const x = verifiersOf(g, 'r-pFeed'); return x.length >= 2 ? x : undefined }, 60, 250)
1270
+ assert(!!gvs, 'gate-feedback: verifiers were spawned for the proposition')
1271
+ if (gvs) for (let i = 0; i < gvs.length && i < 2; i++) replyFrom(g, gvs[i].childId, { Result: 1, Reason: '门禁反馈用例' })
1272
+ let gline
1273
+ for (let i = 0; i < 60 && !gline; i++) {
1274
+ const acts = (await g.call('vibe_math_status', {})).recentActivity || []
1275
+ const hit = acts.find((a) => /formal-gate/.test(String(a.event)))
1276
+ if (hit) gline = String(hit.event) + ': ' + String(hit.detail)
1277
+ else await sleep(150)
1278
+ }
1279
+ assert(!!gline && /require 模式搁置/.test(gline), '★★ the require-gate withholding reaches the human-readable channel (captured verbatim)')
1280
+ assert(!!gline && /Formal\/TODO\.md/.test(gline), 'and the notice says where the withholding was recorded')
1281
+ g.feedbackLine = gline || ''
1282
+ await g.call('vibe_math_pause', {})
1283
+ }
1284
+
1243
1285
  section('16 the captured prompt corpus is written for human review')
1244
1286
  {
1245
1287
  // Freeze the scheduler in every case FIRST: a still-running tick loop could emit one more
@@ -1261,6 +1303,13 @@ section('16 the captured prompt corpus is written for human review')
1261
1303
  const owner = h.spawns.find((s) => s.childId === f.childId)
1262
1304
  entries.push({ kind: 'wake', case: h.label, label: owner ? owner.label : f.childId, prompt: scrub(h, f.prompt) })
1263
1305
  }
1306
+ // The framework's own messages to the main agent (reportMode=push). Only the FIRST push per case is
1307
+ // kept: whether a second identical push follows depends on tick/reportDirty timing, and the corpus
1308
+ // must stay byte-identical across runs.
1309
+ if (h.root && Array.isArray(h.root.pushed) && h.root.pushed.length > 0) entries.push({ kind: 'push', case: h.label, label: 'main-agent', prompt: scrub(h, h.root.pushed[0]) })
1310
+ // The framework's human-readable feedback channel: the activity-log line that carries the require
1311
+ // gate's withholding notice. It is captured once, in case 15c, for determinism.
1312
+ if (typeof h.feedbackLine === 'string' && h.feedbackLine) entries.push({ kind: 'feedback', case: h.label, label: 'activity-log', prompt: scrub(h, h.feedbackLine) })
1264
1313
  }
1265
1314
  mkdirSync(CORPUS_DIR, { recursive: true })
1266
1315
  writeFileSync(join(CORPUS_DIR, 'formal-verify-v2.json'), JSON.stringify({ entries: entries }, null, 2), 'utf8')
@@ -1268,7 +1317,8 @@ section('16 the captured prompt corpus is written for human review')
1268
1317
  '> 由 `formal-verify-v2.test.mjs` 落盘:框架**真正发出**的每一条提示词原文。',
1269
1318
  '> 工作区路径归一化为 `<WS>`、VibeMath 根归一化为 `<VIBEMATH>`,因此可 diff、不泄露本机路径。',
1270
1319
  '> 覆盖:off 档(无任何 Lean 文字)、encourage 与 require 的表决初评/辩论、passed 后的忠实性分支、',
1271
- '> 以及平时工作轮的「顺手形式化」段落与 formal 回执契约。', '']
1320
+ '> 平时工作轮的「顺手形式化」段落与 formal 回执契约,以及框架自己的两条反馈通道:',
1321
+ '> `reportMode=push` 的推送汇报(kind=push)与 require 门禁搁置的活动日志公告(kind=feedback)。', '']
1272
1322
  for (let i = 0; i < entries.length; i++) {
1273
1323
  const e = entries[i]
1274
1324
  md.push('## [' + i + '] ' + e.kind + ' · ' + e.label + ' · case=' + e.case)
@@ -1288,6 +1338,8 @@ section('16 the captured prompt corpus is written for human review')
1288
1338
  assert(entries.some((e) => /【顺手形式化/.test(e.prompt) && /"formal":\{"target":"<对象id>","decision":"used\|blocked\|defect"/.test(e.prompt)), 'the corpus contains the formal reply contract line')
1289
1339
  assert(entries.some((e) => !/Lean|形式化/.test(e.prompt)), 'the corpus contains off-mode prompts with no Lean text at all')
1290
1340
  assert(entries.some((e) => e.kind === 'wake'), 'the corpus also keeps the continuation prompts (debate rounds)')
1341
+ assert(entries.some((e) => e.kind === 'push' && /\[Vibe Math V2\] 进度更新/.test(e.prompt)), '★ the corpus carries the framework\'s PUSH report (reportMode=push)')
1342
+ assert(entries.some((e) => e.kind === 'feedback' && /require 模式搁置/.test(e.prompt)), '★ and the require-gate withholding notice (the framework→human feedback channel)')
1291
1343
  // Generic sweeps over EVERY captured prompt, not spot checks (AUDIT §2.1).
1292
1344
  const joined = entries.map((e) => e.prompt).join('\n')
1293
1345
  const bare = entries.filter((e) => /(^|[^a-z_])lean_(run|archive|lib)/.test(e.prompt))