dsh-vibe-math 2.4.1 → 2.6.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 +53 -14
- package/README.md +54 -14
- package/cordis.patch.yml +54 -17
- package/docs/AUDIT-CHECKLIST.md +26 -7
- package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
- package/docs/final-paper.md +171 -0
- package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
- package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
- package/docs/test-timing.md +77 -40
- package/installer.js +225 -45
- package/package.json +6 -3
- package/preset-declaration.js +38 -7
- package/prompt-corpus-persona/persona-corpus.json +13 -13
- package/prompt-corpus-persona/persona-corpus.md +113 -39
- package/prompt-corpus-v2/formal-verify-v2.json +128 -92
- package/prompt-corpus-v2/formal-verify-v2.md +380 -142
- package/prompt-corpus-v3/formal-verify-v3.json +117 -81
- package/prompt-corpus-v3/formal-verify-v3.md +693 -1189
- 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 +68 -8
- package/tests/audit-persona-surface.test.mjs +21 -8
- package/tests/audit-v5-sensitivity.mjs +47 -6
- package/tests/e2e-v5-round2.test.mjs +825 -1
- package/tests/formal-verify-v2.test.mjs +57 -2
- package/tests/formal-verify-v3.test.mjs +67 -2
- package/tests/formal-verify-v4.test.mjs +120 -4
- package/tests/prompt-v5-integrity.test.mjs +9 -3
- package/tests/run-tests.mjs +104 -23
- package/tests/selfdrive-v5.mjs +55 -4
- package/vibe-math-v2/agent.cordis.yml +20 -4
- package/vibe-math-v2/vibe-math-v2.js +1318 -106
- 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 +36 -14
- package/vibe-math-v3/vibe-math-v3.js +1348 -179
- 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 +36 -8
- package/vibe-math-v4/vibe-math-v4.js +1195 -57
- 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 +16 -8
- package/vibe-math-v5/vibe-math-v5.js +1628 -112
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +143 -41
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// · the host session log is NEVER written, and the state reloads through the JSON file
|
|
17
17
|
// Run: node tests/e2e-v5-round2.test.mjs
|
|
18
18
|
// ============================================================
|
|
19
|
-
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
20
20
|
import { tmpdir } from 'node:os'
|
|
21
21
|
import { join, dirname, isAbsolute } from 'node:path'
|
|
22
22
|
|
|
@@ -70,6 +70,9 @@ function makeHost(opts) {
|
|
|
70
70
|
if (name === 'sandboxPolicy') return undefined
|
|
71
71
|
if (name === 'compaction') return o.compaction
|
|
72
72
|
if (name === 'subprocess') {
|
|
73
|
+
// o.subprocess injects a FAKE LaTeX toolchain for the final-paper tests (the plugin
|
|
74
|
+
// detects/compiles through this exact service, the same seam the Lean tests use).
|
|
75
|
+
if (o.subprocess) return o.subprocess
|
|
73
76
|
return {
|
|
74
77
|
async spawn({ argv }) {
|
|
75
78
|
const script = argv[argv.length - 1] || ''
|
|
@@ -197,6 +200,89 @@ function makeHost(opts) {
|
|
|
197
200
|
const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
|
|
198
201
|
const PROBLEM = '证明素数有无穷多个'
|
|
199
202
|
|
|
203
|
+
// ── final-paper test helpers ────────────────────────────────────────────────
|
|
204
|
+
// A FAKE LaTeX toolchain (docs/final-paper.md §10: "临时目录里放一个假的 xelatex/latexmk 脚本,或注入 runner").
|
|
205
|
+
// It is injected through the SAME `subprocess` service the plugin detects and compiles with, so
|
|
206
|
+
// the plugin's real detection/compile/repair code runs unchanged; only the toolchain is fake.
|
|
207
|
+
// `installed` — which engine names resolve (others throw, like a missing binary)
|
|
208
|
+
// `failEngines` — engines whose process exits 1
|
|
209
|
+
// `failOn` — a RegExp: if the tex on disk matches, the run exits 1 (drives the repair)
|
|
210
|
+
// `alwaysFail` — every run exits 1 (drives the degrade path)
|
|
211
|
+
function fakeLatex(opts) {
|
|
212
|
+
const o = opts || {}
|
|
213
|
+
const calls = []
|
|
214
|
+
const installed = o.installed || ['xelatex', 'pdflatex']
|
|
215
|
+
return {
|
|
216
|
+
calls,
|
|
217
|
+
async resolveExecutable(name) {
|
|
218
|
+
if (installed.indexOf(name) === -1) throw new Error('ENOENT: ' + name)
|
|
219
|
+
return 'C:/fake/' + name
|
|
220
|
+
},
|
|
221
|
+
spawn(spec) {
|
|
222
|
+
const argv = spec.argv || []
|
|
223
|
+
const name = String(argv[0] || '').split(/[\\/]/).pop()
|
|
224
|
+
const dir = String(spec.cwd || '')
|
|
225
|
+
let tex = ''
|
|
226
|
+
try { tex = readFileSync(join(dir, 'paper.tex'), 'utf8') } catch (e) { /* first attempt may race */ }
|
|
227
|
+
calls.push({ name, args: argv.slice(1), dir, tex })
|
|
228
|
+
let ok = true
|
|
229
|
+
if (o.failEngines && o.failEngines.indexOf(name) !== -1) ok = false
|
|
230
|
+
if (o.failOn && o.failOn.test(tex)) ok = false
|
|
231
|
+
if (o.alwaysFail) ok = false
|
|
232
|
+
if (ok) {
|
|
233
|
+
try { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'paper.pdf'), '%PDF-1.4 fake\n', 'utf8') } catch (e) { ok = false }
|
|
234
|
+
}
|
|
235
|
+
return { done: Promise.resolve({ exitCode: ok ? 0 : 1 }), terminate() {} }
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const paperKindOf = (p) => /【最终论文·撰写/.test(p) ? 'write'
|
|
240
|
+
: /【最终论文·互审/.test(p) ? 'review'
|
|
241
|
+
: /【最终论文·定稿/.test(p) ? 'final' : ''
|
|
242
|
+
const paperDirOf = (h, id) => join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Paper', id || 'institute')
|
|
243
|
+
function listFilesUnder(root, rel) {
|
|
244
|
+
const out = []
|
|
245
|
+
const base = rel === undefined ? '' : rel
|
|
246
|
+
for (const e of readdirSync(root, { withFileTypes: true })) {
|
|
247
|
+
const r = base ? base + '/' + e.name : e.name
|
|
248
|
+
if (e.isDirectory()) out.push(...listFilesUnder(join(root, e.name), r))
|
|
249
|
+
else out.push(r)
|
|
250
|
+
}
|
|
251
|
+
return out
|
|
252
|
+
}
|
|
253
|
+
// Answer the paper phase's own prompts until the flow stops asking: each member writes its
|
|
254
|
+
// part, cross-reviews another member's part, and the editor finalises. `opts.write/review/final`
|
|
255
|
+
// override the default (deliverable) replies, which is how the objection/cap paths are driven.
|
|
256
|
+
async function drivePaper(h, opts) {
|
|
257
|
+
const o = opts || {}
|
|
258
|
+
let steps = 0, idle = 0
|
|
259
|
+
while (steps < (o.max || 40) && idle < 8) {
|
|
260
|
+
const i = h.wakes.findIndex(w => paperKindOf((w.blocks && w.blocks[0] && w.blocks[0].text) || ''))
|
|
261
|
+
if (i === -1) { idle++; await sleep(25); continue }
|
|
262
|
+
idle = 0
|
|
263
|
+
const w = h.wakes.splice(i, 1)[0]
|
|
264
|
+
const prompt = (w.blocks && w.blocks[0] && w.blocks[0].text) || ''
|
|
265
|
+
const kind = paperKindOf(prompt)
|
|
266
|
+
const who = h.labelOf(w.childId)
|
|
267
|
+
let reply
|
|
268
|
+
if (kind === 'write') {
|
|
269
|
+
reply = o.write ? o.write(who, prompt) : ({ paper_part: { title: who + ' 的贡献', solution: who + ':原问题的完整解法(只写有证据的部分)。', methods: who + ' 的方法与经验。', rules: who + ' 归纳的规律。', limits: who + ' 的局限(未定论项已标注)。', evidence: ['Members/' + who + '/Propos/p-' + who + '.md'] } })
|
|
270
|
+
} else if (kind === 'review') {
|
|
271
|
+
const om = /待审部分(([^)]*))/.exec(prompt)
|
|
272
|
+
reply = o.review ? o.review(who, om ? om[1] : '', prompt) : ({ paper_review: { of: om ? om[1] : '', deliverable: true, comments: who + ':证据与表决记录一致,可交付。' } })
|
|
273
|
+
} else {
|
|
274
|
+
reply = o.final ? o.final(who, prompt) : ({ paper_final: { decision: 'deliverable', note: '已核对合并稿与互审意见,统一术语与符号。', conclusion: '题设范围内结论成立。' } })
|
|
275
|
+
}
|
|
276
|
+
// `capture` lets a case assert on the TEXT the member really received (the evidence-only
|
|
277
|
+
// clause lives in the prompt, not only in the plugin source).
|
|
278
|
+
if (o.capture) o.capture.push({ kind, who, prompt })
|
|
279
|
+
h.fireEnd(w.childId, reply)
|
|
280
|
+
steps++
|
|
281
|
+
await sleep(20)
|
|
282
|
+
}
|
|
283
|
+
return steps
|
|
284
|
+
}
|
|
285
|
+
|
|
200
286
|
// The durable authority is the hardened JSON file. A commit is applied to the in-memory
|
|
201
287
|
// snapshot immediately but written through a deferred per-file chain, so a test that wants to
|
|
202
288
|
// observe "this was committed" must poll the FILE — reading the session log is no longer possible
|
|
@@ -347,6 +433,47 @@ console.log('\n[6] quorum recomputed when a voter is dismissed')
|
|
|
347
433
|
assert(h.drains.length === 2, 'both dismissed researchers had their resident child released')
|
|
348
434
|
}
|
|
349
435
|
|
|
436
|
+
// ---------- 6b. a dismissed voter's old ballot votes in NEITHER direction ----------
|
|
437
|
+
console.log('\n[6b] a dismissed voter\'s old ballot is dropped from the ledger AND the tally')
|
|
438
|
+
{
|
|
439
|
+
// Round A H2 fixed "a dismissed member's old ballot still counts". The suite used to prove only
|
|
440
|
+
// that m/voterCount were recomputed and that the resident was released — never that the BALLOT
|
|
441
|
+
// stopped counting. This case votes, dismisses, then re-votes, which is the sequence the audit
|
|
442
|
+
// asked for: r-1 asserts TRUE, is dismissed, and the remaining voter's FALSE must now CONCLUDE
|
|
443
|
+
// (with r-1's ballot ignored). If a former member's vote still counted, 1 vs 0 would conflict
|
|
444
|
+
// and the object could never reach Verified/ — so the verdict itself is the discriminator.
|
|
445
|
+
const h = makeHost({ pluginModule })
|
|
446
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
447
|
+
await h.settleSpawns()
|
|
448
|
+
const stateFile = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'State', 'institute.v5state.json')
|
|
449
|
+
const ballots = () => {
|
|
450
|
+
try {
|
|
451
|
+
const s = JSON.parse(readFileSync(stateFile, 'utf8'))
|
|
452
|
+
const v = s.institutes[Object.keys(s.institutes)[0]].verdicts['p-dismiss']
|
|
453
|
+
return v ? Object.keys(v.votes || {}) : null
|
|
454
|
+
} catch (e) { return null }
|
|
455
|
+
}
|
|
456
|
+
const s0 = await h.callTool('vibe_v5_status', {})
|
|
457
|
+
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 + ')')
|
|
458
|
+
await h.callTool('vibe_v5_record_proposition', { id: 'p-dismiss', statement: '被解雇者的旧票不得计入', value: 0.5, motive: 'm', p: 0.5 })
|
|
459
|
+
await h.callTool('vibe_v5_propose_verify', { target: 'p-dismiss', kind: 'proposition', reason: 'r' }, h.childAgent(h.childOf('r-1')))
|
|
460
|
+
const s1 = await h.callTool('vibe_v5_status', {})
|
|
461
|
+
assert(!!s1.verify && s1.verify.target === 'p-dismiss', 'the ballot on p-dismiss is live')
|
|
462
|
+
const voted = await h.callTool('vibe_v5_verdict', { target: 'p-dismiss', verdict: 1, reason: 'r-1 断言为真' }, h.childAgent(h.childOf('r-1')))
|
|
463
|
+
assert(voted.ok === true && voted.allVoted === false, 'r-1 cast the ONLY boolean vote so far (the other voter has not answered)')
|
|
464
|
+
assert((ballots() || []).indexOf('r-1') !== -1, 'the durable open-verdict record carries r-1\'s ballot before the dismissal (' + JSON.stringify(ballots()) + ')')
|
|
465
|
+
const removed = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' })
|
|
466
|
+
assert(removed.ok === true, 'r-1 was dismissed with the ballot still open (' + JSON.stringify(removed).slice(0, 80) + ')')
|
|
467
|
+
const s2 = await h.callTool('vibe_v5_status', {})
|
|
468
|
+
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 + ')')
|
|
469
|
+
assert((ballots() || []).indexOf('r-1') === -1, '★ the dismissed member\'s ballot is DELETED from the durable open verdict (votes=' + JSON.stringify(ballots()) + ')')
|
|
470
|
+
const finalVote = await h.callTool('vibe_v5_verdict', { target: 'p-dismiss', verdict: 0, reason: 'acad 断言为假' }, h.childAgent(h.childOf('acad')))
|
|
471
|
+
assert(finalVote.ok === true && finalVote.allVoted === true, 'the remaining voter completed the ballot (allVoted=true)')
|
|
472
|
+
const s3 = await h.callTool('vibe_v5_status', {})
|
|
473
|
+
assert(s3.verified.indexOf('p-dismiss') !== -1,
|
|
474
|
+
'★ the ballot CONCLUDED false — a former member\'s TRUE vote cannot block it (got verified=' + JSON.stringify(s3.verified) + ', undecided=' + JSON.stringify(s3.undecided) + ')')
|
|
475
|
+
}
|
|
476
|
+
|
|
350
477
|
// ---------- 7. vibe_v5_wait ----------
|
|
351
478
|
console.log('\n[7] vibe_v5_wait')
|
|
352
479
|
{
|
|
@@ -584,6 +711,703 @@ console.log('\n[15] the host live-child cap (ACTIVATION_LIMIT_REACHED) is named,
|
|
|
584
711
|
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
712
|
}
|
|
586
713
|
|
|
714
|
+
// ---------- 16. a state file that was never loaded must never be overwritten (audit H1) --------
|
|
715
|
+
console.log('\n[16] a pre-seeded snapshot is ADOPTED, never overwritten with an empty institute')
|
|
716
|
+
{
|
|
717
|
+
// Round A H1. Ported from the reproduction that lived outside the repository
|
|
718
|
+
// (`_oneoff/audit241-v5-overwrite.mjs`): a host with no `sandboxPolicy` service (the supported
|
|
719
|
+
// case the e2e harness itself models) used to latch the DEFAULT path on its first read, after
|
|
720
|
+
// which `configure {institute:'alpha'}` wrote an EMPTY institute over alpha's real file.
|
|
721
|
+
const ws = mkdtempSync(join(tmpdir(), 'vibe-v5-seed-'))
|
|
722
|
+
const seedFile = join(ws, 'VibeMath', 'Projects', 'default', 'Institutes', 'alpha', 'State', 'alpha.v5state.json')
|
|
723
|
+
mkdirSync(dirname(seedFile), { recursive: true })
|
|
724
|
+
const seeded = {
|
|
725
|
+
v: 1,
|
|
726
|
+
institutes: {
|
|
727
|
+
'default::alpha': {
|
|
728
|
+
key: 'default::alpha', project: 'default', institute: 'alpha', createdAt: 1, phase: 'active',
|
|
729
|
+
problem: { id: 'p', statement: 'a real problem' }, params: {}, runId: 'run-x',
|
|
730
|
+
members: ['acad', 'r-1', 'r-2', 'r-3', 't-1'].map((id) => ({
|
|
731
|
+
id, kind: id === 'acad' ? 'academician' : id[0] === 'r' ? 'researcher' : 'temp',
|
|
732
|
+
childId: '', phase: 'active', direction: 'd', hiredBy: '', term: '', provider: 'spawn',
|
|
733
|
+
persona: 'CHARTER', error: '', createdAt: 1, dismissedAt: 0, dismissReason: '',
|
|
734
|
+
})),
|
|
735
|
+
tasks: [], messages: [], delivered: [], meetings: [], debates: [], verdicts: {}, formal: {},
|
|
736
|
+
todo: [], queue: [], counters: { academician: 1, researcher: 3, temp: 1, task: 0, meeting: 0, message: 0, verify: 0 },
|
|
737
|
+
lastProgressAt: 1, artifactCount: 7, diagnostics: [],
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
order: ['default::alpha'],
|
|
741
|
+
}
|
|
742
|
+
writeFileSync(seedFile, JSON.stringify(seeded, null, 2), 'utf8')
|
|
743
|
+
const h = makeHost({ pluginModule, ws })
|
|
744
|
+
const c = await h.callTool('vibe_v5_configure', { institute: 'alpha', problem: 'a real problem' })
|
|
745
|
+
assert(c.ok === true && c.institute === 'alpha', 'configure accepted the existing named institute (' + JSON.stringify(c).slice(0, 90) + ')')
|
|
746
|
+
const after = JSON.parse(readFileSync(seedFile, 'utf8')).institutes['default::alpha']
|
|
747
|
+
assert(after && after.members.length === 5, '★ the 5-member snapshot on disk survived configure (got ' + (after && after.members.length) + ')')
|
|
748
|
+
assert(after.artifactCount === 7 && after.runId === 'run-x', 'the rest of the snapshot survived too (artifactCount=' + after.artifactCount + ', runId=' + after.runId + ')')
|
|
749
|
+
const st = await h.callTool('vibe_v5_status', {})
|
|
750
|
+
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 + ')')
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------- 17. the NAMED institute's own file is really read (audit H1, second half) ----------
|
|
754
|
+
console.log('\n[17] a second host reads the NAMED institute from its own file')
|
|
755
|
+
{
|
|
756
|
+
// Ported from `_oneoff/audit241-v5-key.mjs`. `configure` must read `Institutes/<name>/State/
|
|
757
|
+
// <name>.v5state.json` — the audit's bug was that it silently created a NEW empty institute
|
|
758
|
+
// under the requested name while the real file stayed untouched.
|
|
759
|
+
const h1 = makeHost({ pluginModule })
|
|
760
|
+
const c1 = await h1.callTool('vibe_v5_configure', { institute: 'alpha', problem: 'named institute' })
|
|
761
|
+
assert(c1.ok === true && c1.institute === 'alpha', 'host A configured the named institute')
|
|
762
|
+
const s1 = await h1.callTool('vibe_v5_status', {})
|
|
763
|
+
assert(s1.key === 'default::alpha', 'host A reports key=default::alpha (got ' + s1.key + ')')
|
|
764
|
+
await h1.callTool('vibe_v5_start', { researcherCount: 1 })
|
|
765
|
+
await h1.settleSpawns()
|
|
766
|
+
const namedFile = join(h1.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'alpha', 'State', 'alpha.v5state.json')
|
|
767
|
+
const onDisk = () => { try { return JSON.parse(readFileSync(namedFile, 'utf8')).institutes['default::alpha'] } catch (e) { return null } }
|
|
768
|
+
let snapshot = null
|
|
769
|
+
for (let i = 0; i < 80 && !(snapshot && snapshot.members.length >= 2); i++) { snapshot = onDisk(); if (!(snapshot && snapshot.members.length >= 2)) await sleep(25) }
|
|
770
|
+
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)) + ')')
|
|
771
|
+
const ids = (snapshot || { members: [] }).members.map(m => m.id)
|
|
772
|
+
// A fresh host (fresh module instance) over the SAME workspace: this is what a user restarting
|
|
773
|
+
// DSH sees. It must adopt the institute, not found a second empty one.
|
|
774
|
+
const h2 = makeHost({ pluginModule, ws: h1.WS })
|
|
775
|
+
const c2 = await h2.callTool('vibe_v5_configure', { institute: 'alpha', problem: 'named institute' })
|
|
776
|
+
assert(c2.ok === true, 'host B configured the same named institute (' + JSON.stringify(c2).slice(0, 90) + ')')
|
|
777
|
+
const s2 = await h2.callTool('vibe_v5_status', {})
|
|
778
|
+
assert(s2.key === 'default::alpha' && s2.backend === 'file', 'host B reports the named institute over the file backend (key=' + s2.key + ', backend=' + s2.backend + ')')
|
|
779
|
+
assert(ids.length > 0 && ids.every(id => s2.members.some(m => m.id === id)),
|
|
780
|
+
'★ 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) + ')')
|
|
781
|
+
const resumed = await h2.callTool('vibe_v5_resume', {})
|
|
782
|
+
assert(resumed.ok === true, 'resume on the adopted institute succeeded (' + JSON.stringify(resumed).slice(0, 120) + ')')
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---------- 18. a FAILED turn: stopReason=error, lastAssistantMessage OMITTED -------------------
|
|
786
|
+
console.log('\n[18] a turn ending with stopReason=error is recorded, with or without output')
|
|
787
|
+
{
|
|
788
|
+
// The real host emits `stopReason:'error'` for a failed turn and OMITS `lastAssistantMessage`
|
|
789
|
+
// when there is nothing to report (dsh-subagent index.js:268-278/317-324). Every other mock in
|
|
790
|
+
// the suite fires 'completed' WITH a message, so this branch — and the omission it must survive —
|
|
791
|
+
// was never executed in CI (audit A-7).
|
|
792
|
+
const h = makeHost({ pluginModule })
|
|
793
|
+
const chatText = () => {
|
|
794
|
+
const dir = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Shared', 'Chat')
|
|
795
|
+
return existsSync(dir) ? readdirSync(dir).filter(f => f.endsWith('.md')).map(f => readFileSync(join(dir, f), 'utf8')).join('\n') : ''
|
|
796
|
+
}
|
|
797
|
+
// The exact host event, with the field ABSENT (not an empty array).
|
|
798
|
+
const fireBare = (childId, stopReason) => { for (const fn of h.listeners['subagent/end'] || []) fn({ id: childId, runId: 'r', provider: 'spawn', local: true, stopReason }) }
|
|
799
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
800
|
+
await h.settleSpawns()
|
|
801
|
+
// Wake r-1 first: an end for a member with no in-flight token is ignored BY DESIGN (the
|
|
802
|
+
// idempotence guard), so without this the error branch would be unreachable.
|
|
803
|
+
await h.callTool('vibe_v5_say', { to: 'r-1', text: '请回报进展。' }, h.childAgent(h.childOf('acad')))
|
|
804
|
+
await sleep(40)
|
|
805
|
+
const st0 = await h.callTool('vibe_v5_status', {})
|
|
806
|
+
assert((st0.members.find(m => m.id === 'r-1') || {}).busy === true, 'precondition: r-1 has a turn in flight')
|
|
807
|
+
const before = chatText().length
|
|
808
|
+
fireBare(h.childOf('r-1'), 'error')
|
|
809
|
+
await sleep(80)
|
|
810
|
+
const written = chatText().slice(before)
|
|
811
|
+
const seg = written.indexOf('【异常】') === -1 ? '' : written.slice(written.indexOf('【异常】'), written.indexOf('【异常】') + 400)
|
|
812
|
+
const line = (seg.match(/【异常】[^\n]*/) || [''])[0]
|
|
813
|
+
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)))
|
|
814
|
+
assert(seg.indexOf('最后输出') === -1, 'an OMITTED lastAssistantMessage adds no phantom output text')
|
|
815
|
+
const st1 = await h.callTool('vibe_v5_status', {})
|
|
816
|
+
assert((st1.members.find(m => m.id === 'r-1') || {}).busy !== true, 'the failed member is not left marked busy')
|
|
817
|
+
assert(st1.running === true, 'the institute keeps running after a failed turn')
|
|
818
|
+
// And the SAME branch WITH output must carry it — otherwise the two assertions above could hold
|
|
819
|
+
// for a handler that only ever writes the member id.
|
|
820
|
+
await h.callTool('vibe_v5_say', { to: 'r-1', text: '再来一次。' }, h.childAgent(h.childOf('acad')))
|
|
821
|
+
await sleep(40)
|
|
822
|
+
const mark = chatText().length
|
|
823
|
+
h.fireEnd(h.childOf('r-1'), { progress: 'partial text before the failure' }, 'max-tokens')
|
|
824
|
+
await sleep(80)
|
|
825
|
+
const after = chatText().slice(mark)
|
|
826
|
+
const idx2 = after.indexOf('【异常】')
|
|
827
|
+
const seg2 = idx2 === -1 ? '' : after.slice(idx2, idx2 + 400)
|
|
828
|
+
const line2 = (seg2.match(/【异常】[^\n]*/) || [''])[0]
|
|
829
|
+
assert(/max-tokens/.test(line2) && /最后输出/.test(line2) && /partial text before the failure/.test(seg2),
|
|
830
|
+
'★ a non-completed turn WITH output carries that output and its stop reason: ' + JSON.stringify(seg2.slice(0, 140)))
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ---------- 19. two proposals in the SAME tick are never lost -------------------
|
|
834
|
+
console.log('\n[19] concurrent propose_verify: the durable queue must not lose one')
|
|
835
|
+
{
|
|
836
|
+
// The verify queue was mutated with a whole-array read-modify-write (`inst().queue.slice()`,
|
|
837
|
+
// push, `await putQueue(q)`). Two members proposing in the same tick both read the same array
|
|
838
|
+
// and each committed its own copy, so the last writer won and the first proposal vanished from
|
|
839
|
+
// the durable queue with BOTH calls reporting ok:true. The mutation now happens inside the
|
|
840
|
+
// event fold (`appendToQueue`), so no same-tick append can overwrite another.
|
|
841
|
+
const h = makeHost({ pluginModule })
|
|
842
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
843
|
+
await h.settleSpawns()
|
|
844
|
+
const who = h.childAgent(h.childOf('r-1'))
|
|
845
|
+
const [pa, pb] = await Promise.all([
|
|
846
|
+
h.callTool('vibe_v5_propose_verify', { target: 'p-race-a', kind: 'proposition', reason: 'race A' }, who),
|
|
847
|
+
h.callTool('vibe_v5_propose_verify', { target: 'p-race-b', kind: 'proposition', reason: 'race B' }, who),
|
|
848
|
+
])
|
|
849
|
+
assert(pa.ok === true && pb.ok === true, 'both concurrent proposals report ok (the buggy code reported ok for the lost one too)')
|
|
850
|
+
const s = await h.callTool('vibe_v5_status', {})
|
|
851
|
+
const seen = (s.verify ? [s.verify.target] : []).concat(s.verifyQueue)
|
|
852
|
+
assert(seen.indexOf('p-race-a') !== -1 && seen.indexOf('p-race-b') !== -1,
|
|
853
|
+
'★ 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) + ')')
|
|
854
|
+
await sleep(30)
|
|
855
|
+
const st = JSON.parse(readFileSync(statePathOf(h.WS), 'utf8'))
|
|
856
|
+
const inst = st.institutes[Object.keys(st.institutes)[0]]
|
|
857
|
+
const durable = (inst.queue || []).map(q => q.target)
|
|
858
|
+
const open = Object.keys(inst.verdicts || {}).filter(k => !inst.verdicts[k].closed)
|
|
859
|
+
const bothDurable = (durable.indexOf('p-race-a') !== -1 || open.indexOf('p-race-a') !== -1) &&
|
|
860
|
+
(durable.indexOf('p-race-b') !== -1 || open.indexOf('p-race-b') !== -1)
|
|
861
|
+
assert(bothDurable, '★ the durable state file keeps both as well (queue=' + JSON.stringify(durable) + ', open verdicts=' + JSON.stringify(open) + ')')
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ---------- 20. invalid numeric params are clamped, not silently destructive ----------
|
|
865
|
+
console.log('\n[20] invalid numeric params are clamped instead of silently destructive')
|
|
866
|
+
{
|
|
867
|
+
// `compactThreshold <= 0` made EVERY round look over the threshold (a permanent compaction
|
|
868
|
+
// directive), `verdictMaxRounds < 1` collapsed every debate to one round, and `chatDigestMax < 1`
|
|
869
|
+
// emptied the digest bucket. `normalizeParams` now applies the same discipline the durations use.
|
|
870
|
+
const h = makeHost({ pluginModule })
|
|
871
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
872
|
+
await h.settleSpawns()
|
|
873
|
+
const r = await h.callTool('vibe_v5_set', { verdictMaxRounds: 0, chatDigestMax: -3, compactThreshold: 0 })
|
|
874
|
+
assert(r.ok === true && r.params.verdictMaxRounds === 1, 'verdictMaxRounds=0 floors at 1 (got ' + r.params.verdictMaxRounds + ')')
|
|
875
|
+
assert(r.params.chatDigestMax === 1, 'chatDigestMax=-3 floors at 1 (got ' + r.params.chatDigestMax + ')')
|
|
876
|
+
assert(r.params.compactThreshold === 66, 'compactThreshold=0 falls back to the default 66 (got ' + r.params.compactThreshold + ')')
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ---------- 21. an office-only tool never impersonates the office for an unknown caller ----
|
|
880
|
+
console.log('\n[21] office-only tools refuse a caller that is neither a member nor the session root')
|
|
881
|
+
{
|
|
882
|
+
// `memberIdOfAgent` answers 'office' for the root AND '' for an unrelated descendant, and
|
|
883
|
+
// `isOffice('')` used to be true — so the old handlers fell back to the office and let any
|
|
884
|
+
// unresolvable caller convene meetings / hire / fire / ASSIGN / NUDGE / create tasks as the
|
|
885
|
+
// office (audit L6 and its follow-up). `officeCaller` now refuses, every writing tool resolves
|
|
886
|
+
// through it, and `isOffice` itself only accepts the literal 'office'.
|
|
887
|
+
const h = makeHost({ pluginModule })
|
|
888
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
889
|
+
await h.settleSpawns()
|
|
890
|
+
const ghost = h.childAgent('c-nobody')
|
|
891
|
+
const mtg = await h.callTool('vibe_v5_meeting', { agenda: '冒充所办', kind: 'sync' }, ghost)
|
|
892
|
+
assert(mtg.ok === false && mtg.code === 'V5_MEMBER_NOT_FOUND',
|
|
893
|
+
'meeting is refused for a caller that is not a member or the session root (' + JSON.stringify(mtg).slice(0, 90) + ')')
|
|
894
|
+
const res = await h.callTool('vibe_v5_add_researcher', { direction: 'x' }, ghost)
|
|
895
|
+
assert(res.ok === false && res.code === 'V5_MEMBER_NOT_FOUND',
|
|
896
|
+
'add_researcher is refused for the same caller (' + JSON.stringify(res).slice(0, 90) + ')')
|
|
897
|
+
// The whole family of writing tools must refuse that caller, not just the office-only ones:
|
|
898
|
+
// each of these used to receive '' from memberIdOfAgent and treat it as the office. A REAL task
|
|
899
|
+
// is opened first so the ghost's task_update / prioritize have live state to damage.
|
|
900
|
+
const t1 = await h.callTool('vibe_v5_task_create', { subject: '真实任务' })
|
|
901
|
+
assert(t1.ok === true, 'a real caller can still open a task (' + JSON.stringify(t1).slice(0, 90) + ')')
|
|
902
|
+
const boardBefore = (await h.callTool('vibe_v5_task_list', {})).tasks.length
|
|
903
|
+
const ghostWrites = [
|
|
904
|
+
['vibe_v5_assign', { subject: '冒充分派', to: 'r-1', why: 'w', acceptance: 'a' }],
|
|
905
|
+
['vibe_v5_nudge', { to: 'r-1', why: 'w' }],
|
|
906
|
+
['vibe_v5_task_create', { subject: '冒充所办建的任务' }],
|
|
907
|
+
['vibe_v5_task_update', { task_id: t1.task.id, expected_revision: 1, action: 'delete' }],
|
|
908
|
+
['vibe_v5_prioritize', { order: [{ task_id: t1.task.id, priority: 9 }], why: 'w' }],
|
|
909
|
+
['vibe_v5_propose_verify', { target: 'p-ghost', kind: 'proposition', reason: 'r' }],
|
|
910
|
+
['vibe_v5_lean_run', { file: 'Formal/ghost.lean' }],
|
|
911
|
+
]
|
|
912
|
+
for (const [name, args] of ghostWrites) {
|
|
913
|
+
const r = await h.callTool(name, args, ghost)
|
|
914
|
+
assert(r.ok === false && r.code === 'V5_MEMBER_NOT_FOUND',
|
|
915
|
+
name + ' is refused for the unidentifiable caller (' + JSON.stringify(r).slice(0, 90) + ')')
|
|
916
|
+
}
|
|
917
|
+
const boardAfter = await h.callTool('vibe_v5_task_list', {})
|
|
918
|
+
const t1After = boardAfter.tasks.find((t) => t.id === t1.task.id)
|
|
919
|
+
assert(boardAfter.tasks.length === boardBefore,
|
|
920
|
+
'no refused write reached the board (before=' + boardBefore + ', after=' + boardAfter.tasks.length + ')')
|
|
921
|
+
assert(t1After && t1After.status !== 'deleted', 'the refused task_update did not delete a real task')
|
|
922
|
+
assert(t1After && t1After.priority === 0, 'the refused prioritize did not reorder a real task (priority=' + (t1After && t1After.priority) + ')')
|
|
923
|
+
// ...and the real identities still work: the office signs as the office, a member acts as itself.
|
|
924
|
+
const asgOffice = await h.callTool('vibe_v5_assign', { subject: '所办分派', to: 'r-1', why: 'w', acceptance: 'a' })
|
|
925
|
+
assert(asgOffice.ok === true && asgOffice.task && asgOffice.task.assignedBy === 'office',
|
|
926
|
+
'the office still assigns and is signed as office (' + JSON.stringify(asgOffice).slice(0, 110) + ')')
|
|
927
|
+
const tcOffice = await h.callTool('vibe_v5_task_create', { subject: '所办任务' })
|
|
928
|
+
assert(tcOffice.ok === true && tcOffice.task.createdBy === 'office', 'the office task_create still records createdBy=office')
|
|
929
|
+
const nudgeAcad = await h.callTool('vibe_v5_nudge', { to: 'r-1', why: '院士督办' }, h.childAgent(h.childOf('acad')))
|
|
930
|
+
assert(nudgeAcad.ok === true, 'the academician still nudges (' + JSON.stringify(nudgeAcad).slice(0, 90) + ')')
|
|
931
|
+
const office = await h.callTool('vibe_v5_meeting', { agenda: '所办直接开会', kind: 'sync' })
|
|
932
|
+
assert(office.ok === true, 'the real office (session root) still convenes a meeting (' + JSON.stringify(office).slice(0, 90) + ')')
|
|
933
|
+
// Two office-impersonation holes the independent verifier found AFTER Round D — same L6 class,
|
|
934
|
+
// and neither was covered by the assertions above: `vibe_v5_message` signed EVERY caller's text
|
|
935
|
+
// as the office (no caller resolution at all), and `vibe_v5_remove_researcher` ("Office only")
|
|
936
|
+
// accepted any caller. Both now resolve through `officeCaller` and refuse.
|
|
937
|
+
const ghostMsg = await h.callTool('vibe_v5_message', { to: 'all', content: '冒充所办通知' }, ghost)
|
|
938
|
+
assert(ghostMsg.ok === false && ghostMsg.code === 'V5_MEMBER_NOT_FOUND',
|
|
939
|
+
'vibe_v5_message is refused for the unidentifiable caller (' + JSON.stringify(ghostMsg).slice(0, 90) + ')')
|
|
940
|
+
const memberMsg = await h.callTool('vibe_v5_message', { to: 'all', content: '成员冒充所办通知' }, h.childAgent(h.childOf('r-1')))
|
|
941
|
+
assert(memberMsg.ok === false && memberMsg.code === 'V5_NOT_OFFICE',
|
|
942
|
+
'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) + ')')
|
|
943
|
+
const ghostRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, ghost)
|
|
944
|
+
assert(ghostRm.ok === false && ghostRm.code === 'V5_MEMBER_NOT_FOUND',
|
|
945
|
+
'vibe_v5_remove_researcher is refused for the unidentifiable caller (' + JSON.stringify(ghostRm).slice(0, 90) + ')')
|
|
946
|
+
const memberRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, h.childAgent(h.childOf('r-1')))
|
|
947
|
+
assert(memberRm.ok === false && memberRm.code === 'V5_NOT_OFFICE',
|
|
948
|
+
'...and refused for a member (the tool says Office only), not just for ghosts (' + JSON.stringify(memberRm).slice(0, 90) + ')')
|
|
949
|
+
const r1Still = (await h.callTool('vibe_v5_members', {})).members.find((m) => m.id === 'r-1')
|
|
950
|
+
assert(r1Still && r1Still.phase === 'active', 'neither refused call dismissed r-1 (phase=' + JSON.stringify(r1Still && r1Still.phase) + ')')
|
|
951
|
+
const officeMsg = await h.callTool('vibe_v5_message', { to: 'all', content: '所办通知' })
|
|
952
|
+
assert(officeMsg.ok === true, 'the office still relays a message (' + JSON.stringify(officeMsg).slice(0, 90) + ')')
|
|
953
|
+
// The `/v5` SLASH-COMMAND line is the third surface of the same class: it hardcoded 'office' for
|
|
954
|
+
// every subcommand (`remove`/`fire`/`hire`/`set`/`stop`/`message`…), and a member child maps to
|
|
955
|
+
// the same institute session — so a member (or an unidentifiable descendant) could drive it. It
|
|
956
|
+
// now resolves the caller too.
|
|
957
|
+
const cmdV5 = h.commandRegs.find((c) => c.name === 'v5')
|
|
958
|
+
const cmdGhost = JSON.parse((await cmdV5.handler({ agent: ghost, rawInput: 'remove r-1' })).text)
|
|
959
|
+
assert(cmdGhost.ok === false && cmdGhost.code === 'V5_NOT_OFFICE',
|
|
960
|
+
'/v5 remove is refused for the unidentifiable caller (' + JSON.stringify(cmdGhost).slice(0, 90) + ')')
|
|
961
|
+
const cmdMember = JSON.parse((await cmdV5.handler({ agent: h.childAgent(h.childOf('r-1')), rawInput: 'remove r-1' })).text)
|
|
962
|
+
assert(cmdMember.ok === false && cmdMember.code === 'V5_NOT_OFFICE',
|
|
963
|
+
'/v5 remove is refused for a member child too (' + JSON.stringify(cmdMember).slice(0, 90) + ')')
|
|
964
|
+
const r1AfterCmd = (await h.callTool('vibe_v5_members', {})).members.find((m) => m.id === 'r-1')
|
|
965
|
+
assert(r1AfterCmd && r1AfterCmd.phase === 'active', 'the refused /v5 remove did not dismiss r-1 (phase=' + JSON.stringify(r1AfterCmd && r1AfterCmd.phase) + ')')
|
|
966
|
+
const cmdOffice = JSON.parse((await cmdV5.handler({ agent: h.ROOT, rawInput: 'status' })).text)
|
|
967
|
+
assert(cmdOffice.ok === true, 'the office still drives the /v5 line (' + JSON.stringify(cmdOffice).slice(0, 90) + ')')
|
|
968
|
+
const officeRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' })
|
|
969
|
+
assert(officeRm.ok === true, 'the office can still dismiss a permanent researcher (' + JSON.stringify(officeRm).slice(0, 90) + ')')
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ---------- 22. the office-only LIFECYCLE controls resolve their caller too ----------
|
|
973
|
+
console.log('\n[22] configure/start/resume/pause/stop/set belong to the PROVABLE session root')
|
|
974
|
+
{
|
|
975
|
+
// A member child and the office share ONE institute session, so `getSession()` alone let a member
|
|
976
|
+
// (or an unidentifiable descendant of the root) drive these handlers as the office: stop the
|
|
977
|
+
// institute, or rewrite quorum/params. Absence of a caller is NOT the office either.
|
|
978
|
+
const h = makeHost({ pluginModule })
|
|
979
|
+
// (a) the root/UI path keeps working end-to-end: these are exactly the handlers `/v5` drives.
|
|
980
|
+
const cfg = await h.callTool('vibe_v5_configure', { institute: 'life', problem: PROBLEM })
|
|
981
|
+
assert(cfg.ok === true, 'the office configures the institute (' + JSON.stringify(cfg).slice(0, 90) + ')')
|
|
982
|
+
const started = await h.callTool('vibe_v5_start', { researcherCount: 1 })
|
|
983
|
+
assert(started.ok === true, 'the office founds it (' + JSON.stringify(started).slice(0, 90) + ')')
|
|
984
|
+
await h.settleSpawns()
|
|
985
|
+
const setOk = await h.callTool('vibe_v5_set', { maxParallel: 2 })
|
|
986
|
+
assert(setOk.ok === true && setOk.params.maxParallel === 2, 'the office tunes parameters (' + JSON.stringify(setOk).slice(0, 90) + ')')
|
|
987
|
+
const pauseOk = await h.callTool('vibe_v5_pause', {})
|
|
988
|
+
assert(pauseOk.ok === true, 'the office pauses (' + JSON.stringify(pauseOk).slice(0, 90) + ')')
|
|
989
|
+
const resumeOk = await h.callTool('vibe_v5_resume', {})
|
|
990
|
+
assert(resumeOk.ok === true, 'the office resumes (' + JSON.stringify(resumeOk).slice(0, 90) + ')')
|
|
991
|
+
// (b) a MEMBER child and an unidentifiable descendant of the office root are both refused.
|
|
992
|
+
const member = h.childAgent(h.childOf('r-1'))
|
|
993
|
+
const ghost = h.childAgent('c-nobody')
|
|
994
|
+
const officeOnly = [
|
|
995
|
+
['vibe_v5_configure', { institute: 'stolen', problem: 'x' }],
|
|
996
|
+
['vibe_v5_start', { researcherCount: 3 }],
|
|
997
|
+
['vibe_v5_resume', {}],
|
|
998
|
+
['vibe_v5_pause', {}],
|
|
999
|
+
['vibe_v5_stop', {}],
|
|
1000
|
+
['vibe_v5_set', { maxParallel: 9 }],
|
|
1001
|
+
]
|
|
1002
|
+
for (const [name, args] of officeOnly) {
|
|
1003
|
+
const asMember = await h.callTool(name, args, member)
|
|
1004
|
+
assert(asMember.ok === false && asMember.code === 'V5_NOT_OFFICE',
|
|
1005
|
+
name + ' is refused for a MEMBER child (' + JSON.stringify(asMember).slice(0, 80) + ')')
|
|
1006
|
+
const asGhost = await h.callTool(name, args, ghost)
|
|
1007
|
+
assert(asGhost.ok === false && asGhost.code === 'V5_NOT_OFFICE',
|
|
1008
|
+
name + ' is refused for the unidentifiable caller (' + JSON.stringify(asGhost).slice(0, 80) + ')')
|
|
1009
|
+
}
|
|
1010
|
+
// (c) NO caller at all is not the office either — driven through the real registered wrapper (the
|
|
1011
|
+
// harness' callTool defaults to ROOT, so `agent: undefined` is passed explicitly here). The wrapper
|
|
1012
|
+
// refuses it as 'no session' BEFORE the gate, i.e. absence never reaches the office path.
|
|
1013
|
+
const stopSpec = h.toolRegs.find((t) => t.name === 'vibe_v5_stop')
|
|
1014
|
+
const noCaller = JSON.parse(await stopSpec.execute({}, { agent: undefined }))
|
|
1015
|
+
assert(noCaller.ok === false,
|
|
1016
|
+
'an agent-less call never reaches the lifecycle handler as the office (' + JSON.stringify(noCaller).slice(0, 80) + ')')
|
|
1017
|
+
// (d) nothing the refusals asked for took effect.
|
|
1018
|
+
const after = await h.callTool('vibe_v5_status', {})
|
|
1019
|
+
assert(after && after.ok !== false && after.params && after.params.maxParallel === 2,
|
|
1020
|
+
'no refused lifecycle call took effect (maxParallel=' + (after.params && after.params.maxParallel) + ')')
|
|
1021
|
+
assert(after.members && after.members.some((m) => m.phase !== 'dismissed'),
|
|
1022
|
+
'the refused stop did not dissolve the roster (' + (after.members || []).map((m) => m.id + ':' + m.phase).join(',') + ')')
|
|
1023
|
+
const finalStop = await h.callTool('vibe_v5_stop', {})
|
|
1024
|
+
assert(finalStop.ok === true, 'the office can still stop it (' + JSON.stringify(finalStop).slice(0, 90) + ')')
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ---------- 23. final-paper params: closed schema, defaults, explicit coercion ----------
|
|
1028
|
+
console.log('\n[23] final-paper params: closed schema, defaults and explicit coercion')
|
|
1029
|
+
{
|
|
1030
|
+
const h = makeHost({ pluginModule })
|
|
1031
|
+
const setSpec = h.toolRegs.find(t => t.name === 'vibe_v5_set')
|
|
1032
|
+
const keys = ['finalPaper', 'paperFormat', 'paperLanguage', 'paperCompilePdf', 'paperEditor', 'paperLatexCommand']
|
|
1033
|
+
assert(!!setSpec && keys.every(k => Object.prototype.hasOwnProperty.call(setSpec.parameters.properties, k)),
|
|
1034
|
+
'★ vibe_v5_set advertises all six final-paper keys (the schema is closed, so an unlisted key is unreachable)')
|
|
1035
|
+
const props = setSpec.parameters.properties
|
|
1036
|
+
assert(JSON.stringify(props.paperFormat.enum) === JSON.stringify(['both', 'md', 'tex']) &&
|
|
1037
|
+
JSON.stringify(props.paperLanguage.enum) === JSON.stringify(['zh', 'en']) &&
|
|
1038
|
+
JSON.stringify(props.paperEditor.enum) === JSON.stringify(['office', 'academician']),
|
|
1039
|
+
'the three enums are narrowed in the schema (a typo must not become a fourth mode)')
|
|
1040
|
+
const s0 = (await h.callTool('vibe_v5_status', {})).params
|
|
1041
|
+
assert(s0.finalPaper === true && s0.paperFormat === 'both' && s0.paperLanguage === 'zh' && s0.paperCompilePdf === true &&
|
|
1042
|
+
s0.paperEditor === 'academician' && s0.paperLatexCommand === '',
|
|
1043
|
+
'the documented defaults are live (' + JSON.stringify({ f: s0.finalPaper, fmt: s0.paperFormat, lang: s0.paperLanguage, pdf: s0.paperCompilePdf, ed: s0.paperEditor }) + ')')
|
|
1044
|
+
const r = await h.callTool('vibe_v5_set', { finalPaper: 'false', paperFormat: 'bogus', paperLanguage: 'EN', paperEditor: 'root', paperCompilePdf: 1, paperLatexCommand: 'lualatex' })
|
|
1045
|
+
assert(r.ok === true && r.params.finalPaper === false, "★ finalPaper:'false' is coerced to false (v2 §B: an unknown spelling must not stay truthy)")
|
|
1046
|
+
assert(r.params.paperFormat === 'both', 'an unknown paperFormat falls back to both')
|
|
1047
|
+
assert(r.params.paperLanguage === 'en' && r.params.paperEditor === 'office', 'enums are case-insensitive and legacy aliases resolve (EN → en, root → office)')
|
|
1048
|
+
assert(r.params.paperCompilePdf === true && r.params.paperLatexCommand === 'lualatex', 'paperCompilePdf accepts 1 as true; paperLatexCommand is stored')
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// ---------- 24. the team flow with the OFFICE as editor (+ the consultation gate) ----------
|
|
1052
|
+
console.log('\n[24] manual paper, office editor: parts → cross-review → consultation → finalise')
|
|
1053
|
+
{
|
|
1054
|
+
const h = makeHost({ pluginModule })
|
|
1055
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
1056
|
+
await h.settleSpawns()
|
|
1057
|
+
// ONE verified object, so section 4 has real evidence to index.
|
|
1058
|
+
await h.callTool('vibe_v5_record_proposition', { id: 'p-paper', statement: '最终论文只整理已有证据,不得编造', value: 0.9, motive: '论文证据', p: 1 }, h.childAgent(h.childOf('r-1')))
|
|
1059
|
+
await h.callTool('vibe_v5_propose_verify', { target: 'p-paper', kind: 'proposition', reason: '论文证据' }, h.childAgent(h.childOf('r-1')))
|
|
1060
|
+
h.plannedVotes = new Map([['acad', 1], ['r-1', 1]])
|
|
1061
|
+
await h.drain(10)
|
|
1062
|
+
const sv = await h.callTool('vibe_v5_status', {})
|
|
1063
|
+
assert(sv.verified.indexOf('p-paper') !== -1, 'precondition: one object is verified (the paper needs evidence)')
|
|
1064
|
+
assert(sv.paper === null, 'NEGATIVE: a run that has NOT concluded does not auto-start a paper')
|
|
1065
|
+
// A second object is left UNDECIDED (both voters abstain), so section 8 has an unresolved item
|
|
1066
|
+
// that MUST be marked as such.
|
|
1067
|
+
await h.callTool('vibe_v5_set', { verdictMaxRounds: 1 })
|
|
1068
|
+
await h.callTool('vibe_v5_record_proposition', { id: 'p-open', statement: '仍未定论的对象必须显式标注', value: 0.5, motive: '未决项', p: 0.5 }, h.childAgent(h.childOf('r-1')))
|
|
1069
|
+
await h.callTool('vibe_v5_propose_verify', { target: 'p-open', kind: 'proposition', reason: '未决项' }, h.childAgent(h.childOf('r-1')))
|
|
1070
|
+
h.plannedVotes = new Map([['acad', 0.5], ['r-1', 0.5]])
|
|
1071
|
+
await h.drain(10)
|
|
1072
|
+
const su = await h.callTool('vibe_v5_status', {})
|
|
1073
|
+
assert(su.undecided.indexOf('p-open') !== -1, 'precondition: one object is recorded as 未定论 (got ' + JSON.stringify(su.undecided) + ')')
|
|
1074
|
+
h.plannedVotes = new Map()
|
|
1075
|
+
const started = await h.callTool('vibe_v5_paper', { lang: 'en', format: 'both', editor: 'office' })
|
|
1076
|
+
assert(started.ok === true && started.started === true && started.editor === 'office',
|
|
1077
|
+
'the manual paper starts with the office as its editor (' + JSON.stringify(started).slice(0, 130) + ')')
|
|
1078
|
+
await drivePaper(h)
|
|
1079
|
+
let st = await h.callTool('vibe_v5_status', {})
|
|
1080
|
+
assert(st.paper && st.paper.status === 'awaiting-editor' && st.paper.parts.length === 2 && st.paper.reviews.length === 2,
|
|
1081
|
+
'★ both permanent members wrote a part and cross-reviewed another part (' + JSON.stringify({ s: st.paper && st.paper.status, parts: st.paper && st.paper.parts.length, reviews: st.paper && st.paper.reviews.length }) + ')')
|
|
1082
|
+
const refused = await h.callTool('vibe_v5_finalize_paper', { decision: 'deliverable', note: 'x' })
|
|
1083
|
+
assert(refused.ok === false && refused.code === 'V5_PAPER_CONSULT_REQUIRED',
|
|
1084
|
+
'★ NEGATIVE: the office cannot finalise before consulting the institute (' + JSON.stringify(refused).slice(0, 140) + ')')
|
|
1085
|
+
const msg = await h.callTool('vibe_v5_message', { to: 'all', content: '请各自核对证据与结论,我们随后定稿。' })
|
|
1086
|
+
assert(msg.ok === true, 'the office message is delivered')
|
|
1087
|
+
await h.callTool('vibe_v5_meeting', { agenda: '定稿前审查:证据与结论是否一致', kind: 'sync' })
|
|
1088
|
+
await h.drain(12)
|
|
1089
|
+
st = await h.callTool('vibe_v5_status', {})
|
|
1090
|
+
assert(st.paper.consult.messages >= 1 && st.paper.consult.meetings >= 1,
|
|
1091
|
+
'★ the office consultation is RECORDED (messages=' + st.paper.consult.messages + ', meetings=' + st.paper.consult.meetings + ')')
|
|
1092
|
+
const fin = await h.callTool('vibe_v5_finalize_paper', { decision: 'deliverable', note: '已与全所逐条核对证据,术语与符号统一,结论与表决记录一致。', conclusion: '题设范围内结论成立。' })
|
|
1093
|
+
assert(fin.ok === true && fin.finalized === true, 'the office finalises after consulting (' + JSON.stringify(fin).slice(0, 150) + ')')
|
|
1094
|
+
assert(fin.compile === 'not-detected', '★ this machine has no LaTeX: the compile degrades to not-detected instead of failing (' + fin.compile + ')')
|
|
1095
|
+
const dir = paperDirOf(h, 'institute')
|
|
1096
|
+
const md = existsSync(join(dir, 'paper.md')) ? readFileSync(join(dir, 'paper.md'), 'utf8') : ''
|
|
1097
|
+
const tex = existsSync(join(dir, 'paper.tex')) ? readFileSync(join(dir, 'paper.tex'), 'utf8') : ''
|
|
1098
|
+
const meta = existsSync(join(dir, 'paper.meta.json')) ? JSON.parse(readFileSync(join(dir, 'paper.meta.json'), 'utf8')) : null
|
|
1099
|
+
const heads = (md.match(/^## \d+\. /gm) || []).length
|
|
1100
|
+
assert(heads === 9, '★ the md carries exactly the 9-section skeleton (got ' + heads + ')')
|
|
1101
|
+
assert(md.indexOf('p-paper') !== -1, '★ the verified proposition is listed with its evidence path')
|
|
1102
|
+
assert(md.indexOf('未定论:p-open') !== -1, '★ an undecided object is EXPLICITLY marked in the paper (never silently dropped or promoted)')
|
|
1103
|
+
assert(tex.indexOf('\\documentclass') !== -1 && /\\section\{1\. /.test(tex), '★ the tex version is generated from the same sections')
|
|
1104
|
+
assert(md.indexOf('已与全所逐条核对证据') !== -1, 'the office finalisation note is written into the paper')
|
|
1105
|
+
assert(!!meta && meta.editor === 'office' && meta.consultation.messages >= 1 && meta.consultation.meetings >= 1 && meta.compile.status === 'not-detected',
|
|
1106
|
+
'the meta records the editor, the consultation evidence and the compile result')
|
|
1107
|
+
assert(existsSync(join(dir, 'paper.log.md')), 'the paper log records who wrote/reviewed what')
|
|
1108
|
+
const instRoot = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute')
|
|
1109
|
+
const stray = listFilesUnder(instRoot).filter(p => /(^|\/)paper\.(md|tex|pdf|meta\.json|log\.md)$/i.test(p) && p.indexOf('Paper/institute/') !== 0)
|
|
1110
|
+
assert(stray.length === 0, '★ every paper artifact lives under Paper/<id>/ only (no Verified//State/ pollution): ' + JSON.stringify(stray))
|
|
1111
|
+
assert((await h.callTool('vibe_v5_status', {})).autoDone === false, 'a MANUAL paper does not conclude the run')
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// ---------- 25. idempotency: a repeated trigger only fills missing artifacts ----------
|
|
1115
|
+
console.log('\n[25] paper idempotency: repeated triggers only fill missing artifacts')
|
|
1116
|
+
{
|
|
1117
|
+
const h = makeHost({ pluginModule })
|
|
1118
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1119
|
+
await h.settleSpawns()
|
|
1120
|
+
const first = await h.callTool('vibe_v5_paper', {})
|
|
1121
|
+
assert(first.ok === true && first.started === true, 'the single-member institute starts its paper')
|
|
1122
|
+
await drivePaper(h)
|
|
1123
|
+
const dir = paperDirOf(h, 'institute')
|
|
1124
|
+
const metaPath = join(dir, 'paper.meta.json')
|
|
1125
|
+
assert(existsSync(join(dir, 'paper.md')) && existsSync(metaPath), 'the first run delivered md + meta')
|
|
1126
|
+
const meta1 = readFileSync(metaPath, 'utf8')
|
|
1127
|
+
const again = await h.callTool('vibe_v5_paper', {})
|
|
1128
|
+
assert(again.ok === true && again.alreadyFinalized === true, 'a repeated trigger reports alreadyFinalized instead of re-running the team (' + JSON.stringify(again).slice(0, 120) + ')')
|
|
1129
|
+
assert(readFileSync(metaPath, 'utf8') === meta1, '★ paper.meta.json is NOT rewritten (finalizedAt/inputs untouched)')
|
|
1130
|
+
unlinkSync(join(dir, 'paper.md'))
|
|
1131
|
+
const refill = await h.callTool('vibe_v5_paper', {})
|
|
1132
|
+
assert(refill.ok === true && existsSync(join(dir, 'paper.md')), '★ the missing artifact is refilled (' + JSON.stringify(refill.refill) + ')')
|
|
1133
|
+
assert(readFileSync(metaPath, 'utf8') === meta1, 'the refill did not touch the settled meta either')
|
|
1134
|
+
const forced = await h.callTool('vibe_v5_paper', { force: true, reason: 'manual force' })
|
|
1135
|
+
assert(forced.ok === true && forced.started === true && !forced.alreadyFinalized,
|
|
1136
|
+
'★ force re-runs the whole team flow instead of refilling (' + JSON.stringify(forced).slice(0, 120) + ')')
|
|
1137
|
+
const stF = await h.callTool('vibe_v5_status', {})
|
|
1138
|
+
assert(stF.paper.status === 'writing' && stF.paper.round === 1, 'the forced run restarted at round 1')
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ---------- 26. fake LaTeX: the success path produces paper.pdf ----------
|
|
1142
|
+
console.log('\n[26] fake LaTeX compiler: success path produces paper.pdf')
|
|
1143
|
+
{
|
|
1144
|
+
const fake = fakeLatex({ installed: ['xelatex', 'pdflatex'] })
|
|
1145
|
+
const h = makeHost({ pluginModule, subprocess: fake })
|
|
1146
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1147
|
+
await h.settleSpawns()
|
|
1148
|
+
await h.callTool('vibe_v5_paper', { format: 'both' })
|
|
1149
|
+
await drivePaper(h)
|
|
1150
|
+
const st = await h.callTool('vibe_v5_status', {})
|
|
1151
|
+
assert(st.paper && st.paper.status === 'finalized' && st.paper.compile === 'compiled', 'the paper finalised with the fake compiler (' + JSON.stringify(st.paper && { s: st.paper.status, c: st.paper.compile }) + ')')
|
|
1152
|
+
const dir = paperDirOf(h, 'institute')
|
|
1153
|
+
assert(existsSync(join(dir, 'paper.pdf')), '★ paper.pdf was produced by the compiler subprocess inside Paper/<id>/')
|
|
1154
|
+
const meta = JSON.parse(readFileSync(join(dir, 'paper.meta.json'), 'utf8'))
|
|
1155
|
+
assert(meta.compile.status === 'compiled' && meta.compile.engine === 'xelatex' && meta.compile.attempts.length === 1,
|
|
1156
|
+
'the meta records compiled/xelatex on the FIRST attempt (' + JSON.stringify(meta.compile) + ')')
|
|
1157
|
+
assert(fake.calls.length >= 2 && fake.calls.every(c => c.args.indexOf('-interaction=nonstopmode') !== -1),
|
|
1158
|
+
'★ the engine ran twice with -interaction=nonstopmode (docs/final-paper.md §8)')
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// ---------- 27. fake LaTeX: a failing package is repaired, then it degrades ----------
|
|
1162
|
+
console.log('\n[27] fake LaTeX compiler: repair path and persistent-failure degradation')
|
|
1163
|
+
{
|
|
1164
|
+
// The fake refuses `\\usepackage[hidelinks]{hyperref}`; the repair pass strips optional
|
|
1165
|
+
// packages, so attempt 3 succeeds (attempts 1-2 fail on the full tex).
|
|
1166
|
+
const fake = fakeLatex({ installed: ['xelatex'], failOn: /\\usepackage\[hidelinks\]\{hyperref\}/ })
|
|
1167
|
+
const h = makeHost({ pluginModule, subprocess: fake })
|
|
1168
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1169
|
+
await h.settleSpawns()
|
|
1170
|
+
await h.callTool('vibe_v5_paper', { format: 'both' })
|
|
1171
|
+
await drivePaper(h)
|
|
1172
|
+
const dir = paperDirOf(h, 'institute')
|
|
1173
|
+
const meta = JSON.parse(readFileSync(join(dir, 'paper.meta.json'), 'utf8'))
|
|
1174
|
+
const at = meta.compile.attempts || []
|
|
1175
|
+
assert(meta.compile.status === 'compiled' && existsSync(join(dir, 'paper.pdf')), '★ the repair retry turned a failing compile into a PDF')
|
|
1176
|
+
assert(at.length === 3 && at[0].ok === false && at[1].ok === false && at[2].ok === true && at[2].label.indexOf('stripped') !== -1,
|
|
1177
|
+
'★ attempts: full FAIL → nonstopmode rerun FAIL → stripped-package SUCCESS (' + JSON.stringify(at.map(a => a.label + ':' + a.ok)) + ')')
|
|
1178
|
+
// A compiler that always fails must degrade, not throw, and must not block finalisation.
|
|
1179
|
+
const fake2 = fakeLatex({ installed: ['xelatex'], alwaysFail: true })
|
|
1180
|
+
const h2 = makeHost({ pluginModule, subprocess: fake2 })
|
|
1181
|
+
await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1182
|
+
await h2.settleSpawns()
|
|
1183
|
+
const fin = await h2.callTool('vibe_v5_paper', { format: 'both' })
|
|
1184
|
+
await drivePaper(h2)
|
|
1185
|
+
const st2 = await h2.callTool('vibe_v5_status', {})
|
|
1186
|
+
const dir2 = paperDirOf(h2, 'institute')
|
|
1187
|
+
const meta2 = JSON.parse(readFileSync(join(dir2, 'paper.meta.json'), 'utf8'))
|
|
1188
|
+
assert(fin.ok === true && st2.paper.status === 'finalized' && st2.paper.compile === 'failed',
|
|
1189
|
+
'★ a persistent compile failure DEGRADES (finalisation still succeeds, compile=failed) (' + st2.paper.compile + ')')
|
|
1190
|
+
assert(existsSync(join(dir2, 'paper.tex')) && existsSync(join(dir2, 'paper.md')) && !existsSync(join(dir2, 'paper.pdf')),
|
|
1191
|
+
'tex+md are kept and no bogus pdf is written')
|
|
1192
|
+
assert(/\\title\{/.test(readFileSync(join(dir2, 'paper.tex'), 'utf8')),
|
|
1193
|
+
'★ the DELIVERED tex is the canonical generated one, not the minimal repair variant used by the last attempt')
|
|
1194
|
+
assert((meta2.compile.attempts || []).length === 4, '★ the retry plan is CAPPED at 4 attempts (full → nonstopmode → stripped/engine-swap → minimal template)')
|
|
1195
|
+
assert((meta2.warnings || []).join(' ').indexOf('编译失败') !== -1, 'the failure is reported as a warning in the meta')
|
|
1196
|
+
// Engine swap: the first installed engine fails, so attempt 3 (the NEXT engine) succeeds.
|
|
1197
|
+
const fake3 = fakeLatex({ installed: ['xelatex', 'pdflatex'], failEngines: ['xelatex'] })
|
|
1198
|
+
const h3 = makeHost({ pluginModule, subprocess: fake3 })
|
|
1199
|
+
await h3.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1200
|
+
await h3.settleSpawns()
|
|
1201
|
+
await h3.callTool('vibe_v5_paper', { format: 'both' })
|
|
1202
|
+
await drivePaper(h3)
|
|
1203
|
+
const dir3 = paperDirOf(h3, 'institute')
|
|
1204
|
+
const meta3 = JSON.parse(readFileSync(join(dir3, 'paper.meta.json'), 'utf8'))
|
|
1205
|
+
assert(meta3.compile.status === 'compiled' && meta3.compile.engine === 'pdflatex' && (meta3.compile.attempts || []).length === 3,
|
|
1206
|
+
'★ engine swap: xelatex fails twice, pdflatex succeeds on the third attempt (' + JSON.stringify({ e: meta3.compile.engine, n: (meta3.compile.attempts || []).length }) + ')')
|
|
1207
|
+
// paperFormat=md with paperCompilePdf=true must skip compilation SILENTLY (no "missing tex"
|
|
1208
|
+
// warning) — docs/final-paper.md §8.
|
|
1209
|
+
const hm = makeHost({ pluginModule, subprocess: fakeLatex({ installed: ['xelatex'] }) })
|
|
1210
|
+
await hm.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1211
|
+
await hm.settleSpawns()
|
|
1212
|
+
await hm.callTool('vibe_v5_paper', { format: 'md' })
|
|
1213
|
+
await drivePaper(hm)
|
|
1214
|
+
const dm = paperDirOf(hm, 'institute')
|
|
1215
|
+
const metam = JSON.parse(readFileSync(join(dm, 'paper.meta.json'), 'utf8'))
|
|
1216
|
+
assert(existsSync(join(dm, 'paper.md')) && !existsSync(join(dm, 'paper.tex')) && !existsSync(join(dm, 'paper.pdf')),
|
|
1217
|
+
'paperFormat=md delivers md only (no tex, no pdf)')
|
|
1218
|
+
assert(metam.compile.status === 'skipped' && !/编译|tex/.test((metam.warnings || []).join(' ')),
|
|
1219
|
+
'★ paperFormat=md skips compilation silently (no missing-tex warning): ' + JSON.stringify(metam.warnings))
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ---------- 28. office-only surface + id normalisation ----------
|
|
1223
|
+
console.log('\n[28] the final-paper surface is office-only and its directory id cannot escape Paper/')
|
|
1224
|
+
{
|
|
1225
|
+
const h = makeHost({ pluginModule })
|
|
1226
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
1227
|
+
await h.settleSpawns()
|
|
1228
|
+
const member = h.childAgent(h.childOf('r-1'))
|
|
1229
|
+
const asMember = await h.callTool('vibe_v5_paper', {}, member)
|
|
1230
|
+
assert(asMember.ok === false && asMember.code === 'V5_NOT_OFFICE', 'a member cannot start the paper (' + JSON.stringify(asMember).slice(0, 100) + ')')
|
|
1231
|
+
const finAsMember = await h.callTool('vibe_v5_finalize_paper', { decision: 'deliverable', note: 'x' }, member)
|
|
1232
|
+
assert(finAsMember.ok === false && finAsMember.code === 'V5_NOT_OFFICE', 'a member cannot finalise the paper')
|
|
1233
|
+
// A traversal-shaped institute name must never become a path in the paper id.
|
|
1234
|
+
const h2 = makeHost({ pluginModule })
|
|
1235
|
+
await h2.callTool('vibe_v5_configure', { institute: 'evil/../x' })
|
|
1236
|
+
await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1237
|
+
await h2.settleSpawns()
|
|
1238
|
+
await h2.callTool('vibe_v5_paper', {})
|
|
1239
|
+
await drivePaper(h2)
|
|
1240
|
+
const p2 = (await h2.callTool('vibe_v5_status', {})).paper
|
|
1241
|
+
const id = String(p2.dir || '').replace(/^Paper\//, '').replace(/\/$/, '')
|
|
1242
|
+
assert(id.length > 0 && id.indexOf('/') === -1 && id.indexOf('\\') === -1 && id !== '.' && id !== '..',
|
|
1243
|
+
'★ the paper directory id is ONE normalised name (never a path): ' + JSON.stringify(id))
|
|
1244
|
+
assert(existsSync(join(h2.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'x', 'Paper', id, 'paper.log.md')),
|
|
1245
|
+
'the paper landed under Paper/<id>/ inside the resolved institute tree')
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// ---------- 29. /v5 paper: overrides, force, kind:error; finalPaper=false gates AUTO ----------
|
|
1249
|
+
console.log('\n[29] /v5 paper: one-shot overrides, kind:error, and finalPaper=false')
|
|
1250
|
+
{
|
|
1251
|
+
const h = makeHost({ pluginModule })
|
|
1252
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1253
|
+
await h.settleSpawns()
|
|
1254
|
+
const cmd = h.commandRegs.find(c => c.name === 'v5')
|
|
1255
|
+
assert(!!cmd, 'the /v5 command is registered')
|
|
1256
|
+
const bad = await cmd.handler({ agent: h.ROOT, rawInput: 'paper bogus=1' })
|
|
1257
|
+
assert(bad.kind === 'error', '★ an unknown paper option is a FAILED command (kind:error): ' + String(bad.text).slice(0, 100))
|
|
1258
|
+
const memberCall = await cmd.handler({ agent: h.childAgent(h.childOf('acad')), rawInput: 'paper' })
|
|
1259
|
+
assert(memberCall.kind === 'error', 'a member child cannot drive the /v5 control line')
|
|
1260
|
+
const okCmd = await cmd.handler({ agent: h.ROOT, rawInput: 'paper lang=en format=tex editor=office' })
|
|
1261
|
+
assert(okCmd.kind === 'success', 'the paper subcommand succeeds: ' + String(okCmd.text).slice(0, 120))
|
|
1262
|
+
const st = await h.callTool('vibe_v5_status', {})
|
|
1263
|
+
assert(st.paper && st.paper.lang === 'en' && st.paper.format === 'tex' && st.paper.editor === 'office',
|
|
1264
|
+
'★ the command overrides language/format/editor FOR THIS PAPER (' + JSON.stringify({ l: st.paper.lang, f: st.paper.format, e: st.paper.editor }) + ')')
|
|
1265
|
+
assert(st.params.paperLanguage === 'zh' && st.params.paperEditor === 'academician',
|
|
1266
|
+
'the one-shot overrides do NOT rewrite the persisted params')
|
|
1267
|
+
// finalPaper=false: the AUTO trigger is off, the run concludes immediately, the manual path says so.
|
|
1268
|
+
const h2 = makeHost({ pluginModule })
|
|
1269
|
+
await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
1270
|
+
await h2.settleSpawns()
|
|
1271
|
+
await h2.callTool('vibe_v5_set', { finalPaper: false })
|
|
1272
|
+
h2.solvePlan = true
|
|
1273
|
+
await h2.callTool('vibe_v5_meeting', { agenda: '是否已解决原问题?', kind: 'solve-vote' })
|
|
1274
|
+
await h2.drain(24)
|
|
1275
|
+
const s2 = await h2.callTool('vibe_v5_status', {})
|
|
1276
|
+
assert(s2.autoDone === true && !s2.paper, '★ finalPaper=false: the run concludes WITHOUT starting a paper (' + JSON.stringify({ autoDone: s2.autoDone, paper: s2.paper }) + ')')
|
|
1277
|
+
const manual = await h2.callTool('vibe_v5_paper', {})
|
|
1278
|
+
assert(manual.ok === true && manual.autoDisabled === true && /自动已关闭/.test(String(manual.note)),
|
|
1279
|
+
'the manual command still works when finalPaper=false and says the automatic path is off (' + JSON.stringify(manual).slice(0, 220) + ')')
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// ---------- 30. unanimity is required; the round cap records the disagreement ----------
|
|
1283
|
+
console.log('\n[30] unanimity: an objection does NOT finalise, the cap records the disagreement in the appendix')
|
|
1284
|
+
{
|
|
1285
|
+
const h = makeHost({ pluginModule })
|
|
1286
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1287
|
+
await h.settleSpawns()
|
|
1288
|
+
const objecting = () => ({ paper_review: { deliverable: false, comments: '证据不足,需补充' } })
|
|
1289
|
+
await h.callTool('vibe_v5_paper', {})
|
|
1290
|
+
// Round 1 stopped after its write+review: the objection must NOT finalise anything.
|
|
1291
|
+
await drivePaper(h, { max: 2, review: () => objecting() })
|
|
1292
|
+
let st = await h.callTool('vibe_v5_status', {})
|
|
1293
|
+
assert(st.paper && st.paper.status === 'writing' && st.paper.round === 2,
|
|
1294
|
+
'★ an objection does NOT finalise: the flow iterates to the next round (status=' + st.paper.status + ', round=' + st.paper.round + ')')
|
|
1295
|
+
assert(st.paper.disagreement.length >= 1, 'the objection is recorded as a disagreement')
|
|
1296
|
+
// Keep objecting; at the cap the editor (academician) is asked and asks to revise again — the
|
|
1297
|
+
// spec says a cap is reached, a warning is recorded and the disagreement goes into the appendix.
|
|
1298
|
+
await drivePaper(h, {
|
|
1299
|
+
review: () => objecting(),
|
|
1300
|
+
final: () => ({ paper_final: { decision: 'revise', note: '仍有未达成一致的意见' } }),
|
|
1301
|
+
})
|
|
1302
|
+
const st2 = await h.callTool('vibe_v5_status', {})
|
|
1303
|
+
assert(st2.paper && st2.paper.status === 'finalized' && st2.paper.forcedAfterCap === true,
|
|
1304
|
+
'★ the round cap was enforced, the disagreement recorded, and the editor finalised (' + JSON.stringify({ s: st2.paper && st2.paper.status, cap: st2.paper && st2.paper.forcedAfterCap }) + ')')
|
|
1305
|
+
assert((st2.paper.warnings || []).join(' ').indexOf('上限') !== -1, 'the cap produced a WARNING (' + JSON.stringify(st2.paper.warnings) + ')')
|
|
1306
|
+
const md = readFileSync(join(paperDirOf(h, 'institute'), 'paper.md'), 'utf8')
|
|
1307
|
+
assert(md.indexOf('分歧记录') !== -1 && md.indexOf('证据不足,需补充') !== -1,
|
|
1308
|
+
'★ the unresolved disagreement is written into the APPENDIX of the paper')
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// ---------- 31. the DEFAULT auto trigger: the paper phase runs BEFORE completion ----------
|
|
1312
|
+
console.log('\n[31] a unanimous solve vote with finalPaper=true enters the paper phase BEFORE completion')
|
|
1313
|
+
{
|
|
1314
|
+
// The mutant guard: deleting the auto-trigger branch in `checkSolved`
|
|
1315
|
+
// (`if (params.finalPaper !== false)` -> `if (false)`) used to leave this whole suite green,
|
|
1316
|
+
// because every other paper case drives the flow through the MANUAL vibe_v5_paper tool and
|
|
1317
|
+
// the only auto assertion was "finalPaper=false => no paper" (which the mutant satisfies).
|
|
1318
|
+
// This case pins the real contract: the default path must NOT complete the run until the
|
|
1319
|
+
// paper is finalised, and the run must be observably alive with a paper in progress.
|
|
1320
|
+
const h = makeHost({ pluginModule })
|
|
1321
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
1322
|
+
await h.settleSpawns()
|
|
1323
|
+
await h.callTool('vibe_v5_set', { activityTimeoutMs: 50 })
|
|
1324
|
+
h.solvePlan = true
|
|
1325
|
+
await h.callTool('vibe_v5_meeting', { agenda: '是否已解决原问题?', kind: 'solve-vote' })
|
|
1326
|
+
await h.drain(24)
|
|
1327
|
+
const mid = await h.callTool('vibe_v5_status', {})
|
|
1328
|
+
assert(mid.autoDone === false,
|
|
1329
|
+
'★ a unanimous solve vote with finalPaper=true does NOT complete the run immediately (got autoDone=' + mid.autoDone + ')')
|
|
1330
|
+
const p0 = mid.paper || {}
|
|
1331
|
+
assert(!!mid.paper && p0.status !== 'finalized',
|
|
1332
|
+
'★ ... it ENTERS the paper phase instead (' + JSON.stringify({ s: p0.status, e: p0.editor }) + ')')
|
|
1333
|
+
assert(p0.editor === 'academician' && p0.completesRun === true,
|
|
1334
|
+
'the automatic run is edited by the wakeable academician and is marked as completing the run (' + JSON.stringify({ e: p0.editor, c: p0.completesRun }) + ')')
|
|
1335
|
+
assert(mid.running === true, 'the run stays ALIVE while the paper phase runs (the machinery would refuse a concluded institute)')
|
|
1336
|
+
const capture = []
|
|
1337
|
+
await drivePaper(h, { capture })
|
|
1338
|
+
let done = null
|
|
1339
|
+
for (let i = 0; i < 40; i++) {
|
|
1340
|
+
done = await h.callTool('vibe_v5_status', {})
|
|
1341
|
+
if (done.autoDone) break
|
|
1342
|
+
await drivePaper(h, { max: 4, capture })
|
|
1343
|
+
await sleep(20)
|
|
1344
|
+
}
|
|
1345
|
+
assert(done.autoDone === true && done.running === false,
|
|
1346
|
+
'★ the run is marked complete only AFTER the paper finalised (' + JSON.stringify({ a: done.autoDone, r: done.running }) + ')')
|
|
1347
|
+
assert(done.paper && done.paper.status === 'finalized', 'the paper reached the finalized stage (' + JSON.stringify(done.paper && done.paper.status) + ')')
|
|
1348
|
+
assert(existsSync(join(paperDirOf(h, 'institute'), 'paper.md')), 'the automatic path delivered Paper/<id>/paper.md')
|
|
1349
|
+
// The members' own prompts carry the evidence-only clause (never invent content).
|
|
1350
|
+
const write = capture.find(c => c.kind === 'write')
|
|
1351
|
+
assert(!!write && /不得编造/.test(write.prompt) && /只写你自己库里已有证据支撑/.test(write.prompt),
|
|
1352
|
+
'★ the paper write prompt says only-evidence/no-fabrication: ' + JSON.stringify(write && write.prompt.slice(0, 120)))
|
|
1353
|
+
assert(!!write && /未决 \/ 被否证的条目必须显式标注/.test(write.prompt), 'the prompt requires undecided/refuted items to be marked explicitly')
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ---------- 32. a run that has NOT closed writes no Paper/ directory ----------
|
|
1357
|
+
console.log('\n[32] a run that has NOT closed writes NO Paper/ directory')
|
|
1358
|
+
{
|
|
1359
|
+
const h = makeHost({ pluginModule })
|
|
1360
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
1361
|
+
await h.settleSpawns()
|
|
1362
|
+
// Real, non-closing work: one verification concludes, and the solve vote is NOT unanimous.
|
|
1363
|
+
await h.callTool('vibe_v5_record_proposition', { id: 'p-not-closed', statement: '未收口就不得产出论文目录', value: 0.8, motive: '负用例', p: 1 }, h.childAgent(h.childOf('r-1')))
|
|
1364
|
+
await h.callTool('vibe_v5_propose_verify', { target: 'p-not-closed', kind: 'proposition', reason: '负用例' }, h.childAgent(h.childOf('r-1')))
|
|
1365
|
+
h.plannedVotes = new Map([['acad', 1], ['r-1', 1]])
|
|
1366
|
+
await h.drain(12)
|
|
1367
|
+
h.solvePlan = false
|
|
1368
|
+
await h.callTool('vibe_v5_meeting', { agenda: '是否已解决?(未达成一致)', kind: 'solve-vote' })
|
|
1369
|
+
await h.drain(16)
|
|
1370
|
+
const st = await h.callTool('vibe_v5_status', {})
|
|
1371
|
+
assert(st.autoDone === false && !st.paper, 'the run is still open and no paper phase started (' + JSON.stringify({ a: st.autoDone, p: st.paper }) + ')')
|
|
1372
|
+
const paperRoot = join(h.WS, 'VibeMath', 'Projects', 'default', 'Institutes', 'institute', 'Paper')
|
|
1373
|
+
assert(!existsSync(paperRoot), '★ a run that has NOT closed writes no Paper/ directory at all')
|
|
1374
|
+
const rel = await h.callTool('vibe_v5_report', {})
|
|
1375
|
+
assert(String(rel.report || '').indexOf('最终论文') !== -1 && String(rel.report || '').indexOf('尚未开始') !== -1,
|
|
1376
|
+
'the report tells the operator the paper has not started yet')
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// ---------- 33. an existing paper.pdf is never clobbered ----------
|
|
1380
|
+
console.log('\n[33] an existing Paper/<id>/paper.pdf is never overwritten or deleted')
|
|
1381
|
+
{
|
|
1382
|
+
const PRE = '%PDF-1.4 PRE-EXISTING DELIVERY\n'
|
|
1383
|
+
// (a) a compiler that WOULD succeed: the existing pdf must make the flow skip compilation.
|
|
1384
|
+
const fake = fakeLatex({ installed: ['xelatex'] })
|
|
1385
|
+
const h = makeHost({ pluginModule, subprocess: fake })
|
|
1386
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1387
|
+
await h.settleSpawns()
|
|
1388
|
+
const dir = paperDirOf(h, 'institute')
|
|
1389
|
+
mkdirSync(dir, { recursive: true })
|
|
1390
|
+
writeFileSync(join(dir, 'paper.pdf'), PRE, 'utf8')
|
|
1391
|
+
await h.callTool('vibe_v5_paper', { format: 'both' })
|
|
1392
|
+
await drivePaper(h)
|
|
1393
|
+
assert(readFileSync(join(dir, 'paper.pdf'), 'utf8') === PRE, '★ the pre-existing pdf is byte-identical after the run (never overwritten)')
|
|
1394
|
+
assert(fake.calls.length === 0, '★ compilation was SKIPPED entirely because a pdf already existed (never clobber)')
|
|
1395
|
+
const meta = JSON.parse(readFileSync(join(dir, 'paper.meta.json'), 'utf8'))
|
|
1396
|
+
assert(meta.compile.status === 'kept-existing', 'the meta records compile=kept-existing (' + meta.compile.status + ')')
|
|
1397
|
+
// (b) a compiler that ALWAYS FAILS: still skipped, so a failing toolchain cannot destroy it.
|
|
1398
|
+
const fake2 = fakeLatex({ installed: ['xelatex'], alwaysFail: true })
|
|
1399
|
+
const h2 = makeHost({ pluginModule, subprocess: fake2 })
|
|
1400
|
+
await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
1401
|
+
await h2.settleSpawns()
|
|
1402
|
+
const dir2 = paperDirOf(h2, 'institute')
|
|
1403
|
+
mkdirSync(dir2, { recursive: true })
|
|
1404
|
+
writeFileSync(join(dir2, 'paper.pdf'), PRE, 'utf8')
|
|
1405
|
+
await h2.callTool('vibe_v5_paper', { format: 'both' })
|
|
1406
|
+
await drivePaper(h2)
|
|
1407
|
+
assert(readFileSync(join(dir2, 'paper.pdf'), 'utf8') === PRE, '★ even an always-failing toolchain leaves the existing pdf untouched')
|
|
1408
|
+
assert(fake2.calls.length === 0, 'no compiler process was launched for the already-delivered pdf')
|
|
1409
|
+
}
|
|
1410
|
+
|
|
587
1411
|
console.log('')
|
|
588
1412
|
console.log('passed=' + passed + ' failed=' + failed)
|
|
589
1413
|
if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
|