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.
- package/README.en.md +14 -14
- package/README.md +15 -14
- package/cordis.patch.yml +33 -10
- package/docs/AUDIT-CHECKLIST.md +25 -6
- package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
- package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
- package/docs/test-timing.md +73 -40
- package/installer.js +225 -45
- package/package.json +3 -2
- package/preset-declaration.js +38 -7
- package/prompt-corpus-persona/persona-corpus.json +9 -9
- package/prompt-corpus-persona/persona-corpus.md +67 -21
- package/prompt-corpus-v2/formal-verify-v2.json +116 -92
- package/prompt-corpus-v2/formal-verify-v2.md +240 -93
- package/prompt-corpus-v3/formal-verify-v3.json +81 -81
- package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
- package/prompt-corpus-v4/formal-verify-v4.json +38 -8
- package/prompt-corpus-v4/formal-verify-v4.md +157 -25
- package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
- package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
- package/tests/audit-installer-compat.test.mjs +70 -4
- package/tests/audit-installer-policy.test.mjs +229 -4
- package/tests/audit-persona-sensitivity.mjs +11 -4
- package/tests/audit-persona-surface.test.mjs +5 -1
- package/tests/audit-v5-sensitivity.mjs +47 -6
- package/tests/e2e-v5-round2.test.mjs +354 -0
- package/tests/formal-verify-v2.test.mjs +54 -2
- package/tests/formal-verify-v3.test.mjs +63 -2
- package/tests/formal-verify-v4.test.mjs +120 -4
- package/tests/run-tests.mjs +104 -23
- package/vibe-math-v2/agent.cordis.yml +6 -0
- package/vibe-math-v2/vibe-math-v2.js +503 -99
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
- package/vibe-math-v3/agent.cordis.yml +26 -10
- package/vibe-math-v3/vibe-math-v3.js +555 -175
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
- package/vibe-math-v4/agent.cordis.yml +26 -6
- package/vibe-math-v4/vibe-math-v4.js +404 -51
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
- package/vibe-math-v5/agent.cordis.yml +8 -4
- package/vibe-math-v5/vibe-math-v5.js +513 -105
- 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:
|
|
145
|
-
to:
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'>
|
|
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))
|