dsh-vibe-math 2.7.2 → 2.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +11 -10
- package/README.md +13 -12
- package/cordis.patch.yml +112 -16
- package/docs/AUDIT-CHECKLIST.md +229 -3
- package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
- package/docs/final-paper.md +2 -2
- package/docs/math-computation.md +13 -6
- package/docs/parameter-schema.md +37 -0
- package/docs/release-notes/README.en.md +1 -0
- package/docs/release-notes/README.md +1 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
- package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
- package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
- package/docs/release-notes/TEMPLATE.md +6 -0
- package/docs/status-report-fields.md +167 -0
- package/docs/test-timing.md +8 -5
- package/installer.js +27 -17
- package/package.json +250 -203
- package/prompt-corpus-persona/persona-corpus.json +9 -9
- package/prompt-corpus-persona/persona-corpus.md +225 -33
- package/prompt-corpus-v2/formal-verify-v2.json +295 -115
- package/prompt-corpus-v2/formal-verify-v2.md +2287 -283
- package/prompt-corpus-v3/formal-verify-v3.json +105 -105
- package/prompt-corpus-v3/formal-verify-v3.md +213 -143
- package/prompt-corpus-v4/formal-verify-v4.json +18 -18
- package/prompt-corpus-v4/formal-verify-v4.md +49 -44
- package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
- package/prompt-corpus-v5/prompt-corpus-v5.md +451 -207
- package/scripts/update-doc-counts.mjs +71 -0
- package/tests/audit-artifact-docs.mjs +72 -0
- package/tests/audit-artifact-docs.mutants.mjs +58 -0
- package/tests/audit-installer-assertions.mutants.mjs +63 -0
- package/tests/audit-installer-compat.test.mjs +52 -4
- package/tests/audit-installer-policy.test.mjs +41 -8
- package/tests/audit-math-computation-contract.mjs +64 -8
- package/tests/audit-math-computation-contract.mutants.mjs +106 -0
- package/tests/audit-math-computation-parity.mjs +164 -3
- package/tests/audit-math-computation-sensitivity.mjs +4 -2
- package/tests/audit-package-membership.mjs +57 -0
- package/tests/audit-package-membership.mutants.mjs +28 -0
- package/tests/audit-participant-set-parity.mjs +160 -0
- package/tests/audit-path-discipline.mjs +83 -0
- package/tests/audit-path-discipline.mutants.mjs +51 -0
- package/tests/audit-persona-sensitivity.mjs +14 -0
- package/tests/audit-persona-surface.mutants.mjs +69 -0
- package/tests/audit-persona-surface.test.mjs +63 -22
- package/tests/audit-prompt-duplication.mjs +143 -0
- package/tests/audit-prompt-duplication.mutants.mjs +50 -0
- package/tests/audit-prompt-invariants.mjs +43 -5
- package/tests/audit-readme-bilingual.test.mjs +152 -0
- package/tests/audit-readme-counts.mjs +95 -0
- package/tests/audit-readme-counts.mutants.mjs +52 -0
- package/tests/audit-registration.mjs +78 -0
- package/tests/audit-status-report-fields.mjs +172 -0
- package/tests/audit-status-report-fields.mutants.mjs +64 -0
- package/tests/audit-v3-registration-parity.mjs +156 -0
- package/tests/audit-v5-integrity.mjs +300 -13
- package/tests/audit-v5-lean-abstention.mjs +392 -0
- package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
- package/tests/audit-v5-prompt-duplication.mjs +88 -0
- package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
- package/tests/audit-v5-sensitivity.mjs +12 -7
- package/tests/e2e-identity-a6.mutants.mjs +65 -0
- package/tests/e2e-v5-round2.test.mjs +458 -13
- package/tests/formal-verify-v2.test.mjs +91 -43
- package/tests/formal-verify-v3.mutants.mjs +117 -0
- package/tests/formal-verify-v3.test.mjs +85 -39
- package/tests/formal-verify-v4.mutants.mjs +152 -0
- package/tests/formal-verify-v4.test.mjs +541 -9
- package/tests/formal-verify-v5.test.mjs +68 -9
- package/tests/helpers/doc-counts-format.mjs +41 -0
- package/tests/helpers/math-computation-fake-seam.mjs +4 -0
- package/tests/math-computation-a1.mutants.mjs +72 -0
- package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
- package/tests/math-computation-discovery.mutants.mjs +76 -0
- package/tests/math-computation-shared.test.mjs +274 -5
- package/tests/math-computation-v2.test.mjs +767 -0
- package/tests/math-computation-v3.test.mjs +759 -0
- package/tests/math-computation-v4.mutants.mjs +133 -0
- package/tests/math-computation-v4.test.mjs +739 -0
- package/tests/prompt-v5-integrity.test.mjs +13 -2
- package/tests/run-tests.mjs +139 -9
- package/tests/run-tests.mutants.mjs +70 -0
- package/tests/selfdrive-v5.mjs +53 -1
- package/tests/selfdrive-v5.mutants.mjs +132 -0
- package/tests/v2-fix-probes.mutants.mjs +100 -0
- package/tests/v2-fix-probes.test.mjs +1039 -0
- package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
- package/tests/v3-fix-probes.mutants.mjs +95 -0
- package/tests/v3-fix-probes.test.mjs +891 -0
- package/tests/v5-institute-fixes.mutants.mjs +190 -0
- package/vibe-math-v2/agent.cordis.yml +62 -6
- package/vibe-math-v2/math-computation.js +130 -41
- package/vibe-math-v2/math-engines.js +12 -12
- package/vibe-math-v2/vibe-math-v2.js +388 -113
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
- package/vibe-math-v3/agent.cordis.yml +72 -6
- package/vibe-math-v3/math-computation.js +130 -41
- package/vibe-math-v3/math-engines.js +12 -12
- package/vibe-math-v3/vibe-math-v3.js +422 -126
- package/vibe-math-v4/agent.cordis.yml +44 -8
- package/vibe-math-v4/math-computation.js +130 -41
- package/vibe-math-v4/math-engines.js +12 -12
- package/vibe-math-v4/vibe-math-v4.js +196 -43
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
- package/vibe-math-v5/agent.cordis.yml +46 -12
- package/vibe-math-v5/math-computation.js +130 -41
- package/vibe-math-v5/math-engines.js +12 -12
- package/vibe-math-v5/vibe-math-v5.js +1173 -218
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +80 -12
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +2 -2
|
@@ -71,7 +71,24 @@ function makeSession(id, parentSession) {
|
|
|
71
71
|
function makeHost(opts) {
|
|
72
72
|
const o = opts || {}
|
|
73
73
|
const WS = o.ws || mkdtempSync(join(tmpdir(), 'vibe-v5r2-'))
|
|
74
|
-
const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = []
|
|
74
|
+
const listeners = {}, toolRegs = [], commandRegs = [], spawns = [], wakes = [], interrupts = [], drains = [], sendAttempts = []
|
|
75
|
+
// Successful sends that SURVIVE consumption: `peekWakeOf` splices items out of `wakes`, so a probe that
|
|
76
|
+
// must compare "rounds" against "successful wakes" needs a counter that is never drained.
|
|
77
|
+
let wakeSends = 0
|
|
78
|
+
// G1 seam: force every `subagents.sendMessage` to throw (a real host failure / a member whose
|
|
79
|
+
// continuation is gone). `sendAttempts` records that the wake was TRIED, so a probe can tell
|
|
80
|
+
// "the wake failed" apart from "no wake was scheduled at all".
|
|
81
|
+
let failSend = !!o.failSendMessage
|
|
82
|
+
// spawnMember seam: force every `subagents.startContinuable` to throw (the host cap / a host outage).
|
|
83
|
+
// `startAttempts` records that founding was TRIED, so a probe can tell "the start failed" apart from
|
|
84
|
+
// "no start was attempted".
|
|
85
|
+
let failStart = !!o.failStartContinuable
|
|
86
|
+
const startAttempts = []
|
|
87
|
+
// F6 seam: refuse writes whose resolved path matches (the v4 paper suite's G3 seam, same shape), and —
|
|
88
|
+
// optionally — whose CONTENT matches as well. Every PROVIDED predicate must match, so a probe can target
|
|
89
|
+
// the FINALIZE-time append to paper.log.md without touching the ~15 log writes the flow makes earlier.
|
|
90
|
+
let failWriteOn = o.failWriteOn || null
|
|
91
|
+
let failWriteContent = o.failWriteContent || null
|
|
75
92
|
// Every effect disposer, so a test can simulate a plugin UNLOAD (the Lean queue's disposer is
|
|
76
93
|
// registered first: it terminates in-flight compiles and marks them interrupted).
|
|
77
94
|
const effectDisposers = []
|
|
@@ -111,13 +128,26 @@ function makeHost(opts) {
|
|
|
111
128
|
subagents: {
|
|
112
129
|
list() { return ['spawn'] },
|
|
113
130
|
async startContinuable({ label, request }) {
|
|
131
|
+
startAttempts.push(label)
|
|
132
|
+
if (failStart) {
|
|
133
|
+
// The HOST-CAP shape (not a generic error): `spawnMember` maps it to ACTIVATION_LIMIT_REACHED and
|
|
134
|
+
// the founding loop QUEUES the member, which is the path a healed host retries.
|
|
135
|
+
const e = new Error('subagent limit reached (active child limit: 2); wait for an existing child to finish or complete this work with the current agents')
|
|
136
|
+
e.code = 'ACTIVATION_LIMIT_REACHED'
|
|
137
|
+
throw e
|
|
138
|
+
}
|
|
114
139
|
const id = 'c' + (spawns.length + 1)
|
|
115
140
|
const childSession = makeSession(id, 'sess-A'); childSession.header.cwd = WS
|
|
116
141
|
liveAgents.set(id, { id, session: childSession, options: request && request.agentOptions })
|
|
117
142
|
spawns.push({ label, request, childId: id, persona: request && request.persona, ended: false })
|
|
118
143
|
return { childId: id, messageId: 'm' + spawns.length }
|
|
119
144
|
},
|
|
120
|
-
async sendMessage(parent, childId, blocks) {
|
|
145
|
+
async sendMessage(parent, childId, blocks) {
|
|
146
|
+
sendAttempts.push(childId)
|
|
147
|
+
if (failSend) throw new Error('mock sendMessage failure (G1 seam)')
|
|
148
|
+
wakeSends += 1
|
|
149
|
+
wakes.push({ childId, blocks }); return 'w' + wakes.length
|
|
150
|
+
},
|
|
121
151
|
interrupt(childId) { interrupts.push(childId) },
|
|
122
152
|
async drainContinuableChildren(parent, ids) { drains.push(...ids); for (const i of ids) liveAgents.delete(i) },
|
|
123
153
|
},
|
|
@@ -130,7 +160,15 @@ function makeHost(opts) {
|
|
|
130
160
|
},
|
|
131
161
|
async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
|
|
132
162
|
async readText(t) { return readFileSync(t.targetKey, 'utf8') },
|
|
133
|
-
async writeText(t, c) {
|
|
163
|
+
async writeText(t, c) {
|
|
164
|
+
// F6 seam: a REAL fs refusal (sandbox denial) rejects, which `writeTextAbs` catches and reports as
|
|
165
|
+
// "not written" — the exact shape a failed required artifact write has in production.
|
|
166
|
+
const refuse = (failWriteOn || failWriteContent) &&
|
|
167
|
+
(!failWriteOn || failWriteOn.test(String(t.targetKey))) &&
|
|
168
|
+
(!failWriteContent || failWriteContent.test(String(c)))
|
|
169
|
+
if (refuse) throw new Error('F6_WRITE_FAIL (injected): ' + t.targetKey)
|
|
170
|
+
mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8')
|
|
171
|
+
},
|
|
134
172
|
async listDir(t) { if (!existsSync(t.targetKey)) return []; return readdirSync(t.targetKey, { withFileTypes: true }).map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' })) },
|
|
135
173
|
},
|
|
136
174
|
}
|
|
@@ -228,7 +266,7 @@ function makeHost(opts) {
|
|
|
228
266
|
}
|
|
229
267
|
return null
|
|
230
268
|
}
|
|
231
|
-
return { WS, ctx, ROOT, ROOT_SESSION, removedServiceQueries, spawns, wakes, interrupts, drains, toolRegs, commandRegs, listeners, effectDisposers, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, peekWakeWhere, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
|
|
269
|
+
return { WS, ctx, ROOT, ROOT_SESSION, removedServiceQueries, spawns, wakes, interrupts, drains, sendAttempts, startAttempts, setFailSend(v) { failSend = !!v }, setFailStart(v) { failStart = !!v }, setFailWriteOn(v) { failWriteOn = v || null }, setFailWriteContent(v) { failWriteContent = v || null }, get wakeSends() { return wakeSends }, toolRegs, commandRegs, listeners, effectDisposers, callTool, childAgent, fireEnd, spawnOf, childOf, labelOf, kindOf, settleSpawns, drain, peekWakeOf, peekWakeWhere, set plannedVotes(v) { plannedVotes = v }, get plannedVotes() { return plannedVotes }, set solvePlan(v) { solvePlan = v } }
|
|
232
270
|
}
|
|
233
271
|
|
|
234
272
|
const pluginModule = await import(PLUGIN.href + '?t=' + Date.now())
|
|
@@ -410,6 +448,20 @@ console.log('\n[1] the hardened JSON backend (host with no session services at a
|
|
|
410
448
|
const parsed = JSON.parse(readFileSync(stFile, 'utf8'))
|
|
411
449
|
const inst = parsed.institutes['default::institute']
|
|
412
450
|
assert(!!inst && inst.members.length === 3, 'the persisted state holds 1 academician + 2 researchers (got ' + (inst ? inst.members.length : 'none') + ')')
|
|
451
|
+
// MEASURED (R14, FULL runs): under the A5-ids and A5-kinds mutants BOTH count assertions stay GREEN
|
|
452
|
+
// (0 red) while the new ID-SET / KIND assertion necessarily reddens; those runs show 82 / 21 reds
|
|
453
|
+
// overall, and the remaining ones are the CASCADE of the same mutation (downstream cases reference
|
|
454
|
+
// the id prefix or the kind). So the claim is NOT "only the new assertion reddens".
|
|
455
|
+
// A5: the COUNT is only a lead - assert the semantic unit too. The persisted roster must be
|
|
456
|
+
// exactly {acad, r-1, r-2} by ID and {academician:1, researcher:2} by KIND, so a founding that
|
|
457
|
+
// minted a duplicate id (the allocator returning the same value twice), founded a researcher with
|
|
458
|
+
// the academician kind, or dropped/added a member while still totalling 3 now FAILS.
|
|
459
|
+
const ids = inst.members.map((m) => String(m.id)).sort()
|
|
460
|
+
assert(ids.join(',') === ['acad', 'r-1', 'r-2'].sort().join(','), '* A5 the founded ID SET is exactly {acad, r-1, r-2} (got ' + JSON.stringify(ids) + ')')
|
|
461
|
+
const kinds = {}
|
|
462
|
+
for (const m of inst.members) kinds[String(m.kind)] = (kinds[String(m.kind)] || 0) + 1
|
|
463
|
+
const kindList = Object.keys(kinds).sort().map((k) => k + ':' + kinds[k])
|
|
464
|
+
assert(JSON.stringify(kindList) === JSON.stringify(['academician:1', 'researcher:2']), '* A5 the founded KIND MULTISET is exactly {academician:1, researcher:2} (got ' + JSON.stringify(kinds) + ')')
|
|
413
465
|
}
|
|
414
466
|
assert(h.ROOT_SESSION._events.length === 0, 'the whole founding wrote NOTHING to the host session log (' + h.ROOT_SESSION._events.length + ' events)')
|
|
415
467
|
}
|
|
@@ -789,12 +841,49 @@ console.log('\n[15] the host live-child cap (ACTIVATION_LIMIT_REACHED) is named,
|
|
|
789
841
|
assert(hired.ok === false && hired.code === 'ACTIVATION_LIMIT_REACHED', 'hire reports the host cap by its typed code (' + JSON.stringify(hired).slice(0, 160) + ')')
|
|
790
842
|
assert(/maxActiveSubagents/.test(hired.message || ''), 'the hire failure names maxActiveSubagents, not the opaque host string')
|
|
791
843
|
assert(hostCalls === callsBefore, 'the host was NOT asked again once the ceiling was known (skip-before-spawn; hostCalls=' + hostCalls + ')')
|
|
792
|
-
// The refused work is QUEUED, not lost: freeing a slot
|
|
844
|
+
// The refused work is QUEUED, not lost. G-6: freeing a slot is now picked up by the framework's
|
|
845
|
+
// OWN scheduling pass (`retryPendingSpawns()` is the first line of `schedulePass`), so the
|
|
846
|
+
// PRIMARY assertion here is the TERMINAL STATE — timing-independent, path-independent:
|
|
847
|
+
// baseline: the product's OWN derived queue, snapshotted immediately before the fire
|
|
848
|
+
const preAuto = await h.callTool('vibe_v5_status', {})
|
|
849
|
+
const queuedBeforeAuto = (preAuto.pendingSpawns || []).map(p => p.id)
|
|
850
|
+
assert(queuedBeforeAuto.length >= 1, 'precondition: deferred members are queued before the automatic case (' + JSON.stringify(queuedBeforeAuto) + ')')
|
|
793
851
|
await h.callTool('vibe_v5_fire', { id: 'r-1', reason: 'cap test' })
|
|
794
|
-
|
|
795
|
-
assert(resumed.ok === true && resumed.respawned >= 1, 'a slot freed by firing a member lets resume rebuild a capped member (respawned=' + (resumed && resumed.respawned) + ')')
|
|
852
|
+
await h.settleSpawns()
|
|
796
853
|
const st2 = await h.callTool('vibe_v5_status', {})
|
|
797
854
|
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(',') + ')')
|
|
855
|
+
// ONE freed slot is spent on the deferred work: the queue SHRINKS by exactly one (more members may
|
|
856
|
+
// be refused than a single freed slot can take — the rest stay queued, visibly).
|
|
857
|
+
const pendingAfterAuto = (st2.pendingSpawns || []).map(p => p.id)
|
|
858
|
+
const rebuiltByAuto = queuedBeforeAuto.filter(id => pendingAfterAuto.indexOf(id) === -1)
|
|
859
|
+
assert(rebuiltByAuto.length === 1 && pendingAfterAuto.length === queuedBeforeAuto.length - 1,
|
|
860
|
+
'★ the freed capacity was spent on exactly ONE deferred member (queued=' + JSON.stringify(queuedBeforeAuto) + ' → pending=' + JSON.stringify(pendingAfterAuto) + ', rebuilt=' + JSON.stringify(rebuiltByAuto) + ')')
|
|
861
|
+
assert(rebuiltByAuto.every(id => (st2.members.find(m => m.id === id) || {}).phase === 'active'),
|
|
862
|
+
'★ the rebuilt deferred member is ACTIVE (' + JSON.stringify(rebuiltByAuto) + ')')
|
|
863
|
+
// The MANUAL `resume` rebuild path stays pinned too — with the automatic retry disabled by the
|
|
864
|
+
// documented test seam, `resume` is the only thing that can rebuild, so this cannot race the
|
|
865
|
+
// scheduler (a disjunction accepting either path would be fine ONLY next to the terminal check).
|
|
866
|
+
process.env.V5_SPAWN_RETRY = 'manual'
|
|
867
|
+
try {
|
|
868
|
+
const beforeManual = await h.callTool('vibe_v5_status', {})
|
|
869
|
+
const queuedBeforeManual = (beforeManual.pendingSpawns || []).map(p => p.id)
|
|
870
|
+
assert(queuedBeforeManual.length >= 1, 'precondition: at least one deferred member is queued before the manual case (' + JSON.stringify(queuedBeforeManual) + ')')
|
|
871
|
+
// free exactly one slot; with the seam on, NOTHING rebuilds it — that is what makes the next
|
|
872
|
+
// assertion attributable to `resume` alone
|
|
873
|
+
const victim = beforeManual.members.find(m => m.phase === 'active' && m.childId)
|
|
874
|
+
await h.callTool('vibe_v5_fire', { id: victim.id, reason: 'manual-resume case' })
|
|
875
|
+
await h.settleSpawns()
|
|
876
|
+
const afterFree = await h.callTool('vibe_v5_status', {})
|
|
877
|
+
assert((afterFree.pendingSpawns || []).length === queuedBeforeManual.length,
|
|
878
|
+
'★ with the automatic retry disabled the freed slot leaves the queue UNTOUCHED (' + JSON.stringify((afterFree.pendingSpawns || []).map(p => p.id)) + ')')
|
|
879
|
+
const resumed = await h.callTool('vibe_v5_resume', {})
|
|
880
|
+
assert(resumed.ok === true && resumed.respawned >= 1,
|
|
881
|
+
'the MANUAL resume path rebuilds a deferred member on its own (respawned=' + (resumed && resumed.respawned) + ')')
|
|
882
|
+
const st3 = await h.callTool('vibe_v5_status', {})
|
|
883
|
+
const rebuilt = queuedBeforeManual.filter(id => (st3.members.find(m => m.id === id) || {}).phase === 'active')
|
|
884
|
+
assert(rebuilt.length >= 1, '★ the member `resume` was asked to rebuild is ACTIVE now (' + JSON.stringify(rebuilt) + ')')
|
|
885
|
+
assert(st3.members.filter(m => m.phase === 'active' && m.childId).length >= CAP, 'after the manual resume the terminal state holds the ceiling (active=' + st3.members.filter(m => m.phase === 'active').map(m => m.id).join(',') + ')')
|
|
886
|
+
} finally { delete process.env.V5_SPAWN_RETRY }
|
|
798
887
|
}
|
|
799
888
|
|
|
800
889
|
// ---------- 16. a state file that was never loaded must never be overwritten (audit H1) --------
|
|
@@ -1029,6 +1118,24 @@ console.log('\n[21] office-only tools refuse a caller that is neither a member n
|
|
|
1029
1118
|
const ghostRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, ghost)
|
|
1030
1119
|
assert(ghostRm.ok === false && ghostRm.code === 'V5_MEMBER_NOT_FOUND',
|
|
1031
1120
|
'vibe_v5_remove_researcher is refused for the unidentifiable caller (' + JSON.stringify(ghostRm).slice(0, 90) + ')')
|
|
1121
|
+
// ---- 6.4: the DIAGNOSIS, not just the code -------------------------------------------------
|
|
1122
|
+
const ghostRec = await h.callTool('vibe_v5_record_progress', { content: 'x' }, ghost)
|
|
1123
|
+
assert(ghostRec.ok === false && ghostRec.code === 'V5_MEMBER_NOT_FOUND' && ghostRec.next && ghostRec.next.kind === 'member-call' && ghostRec.next.tool === 'vibe_v5_members',
|
|
1124
|
+
'* 6.4 branch 3 (unidentifiable caller / the office has no member identity): code + next{kind:member-call, tool:vibe_v5_members} (' + JSON.stringify(ghostRec).slice(0, 120) + ')')
|
|
1125
|
+
assert(typeof ghostRec.message === 'string' && /\u6240\u529e|\u6210\u5458/.test(ghostRec.message),
|
|
1126
|
+
'* 6.4 branch 3 EXPLAINS the office/member distinction instead of a bare refusal (' + String(ghostRec.message).slice(0, 90) + ')')
|
|
1127
|
+
const neverH = makeHost({ pluginModule })
|
|
1128
|
+
const neverStarted = await neverH.callTool('vibe_v5_record_progress', { content: 'x' })
|
|
1129
|
+
assert(neverStarted.ok === false && neverStarted.code === 'V5_MEMBER_NOT_FOUND' && neverStarted.next && neverStarted.next.kind === 'start' && neverStarted.next.tool === 'vibe_v5_start',
|
|
1130
|
+
'* 6.4 branch 1 (the institute was never started): code + next{kind:start, tool:vibe_v5_start} (' + JSON.stringify(neverStarted).slice(0, 120) + ')')
|
|
1131
|
+
// Branches 2 (running, zero active) and 4 (id no longer on the roster) are not reachable through
|
|
1132
|
+
// the tool surface from this harness, so they are pinned STRUCTURALLY (each branch with its own
|
|
1133
|
+
// next{} kind), while branches 1 and 3 above are pinned BEHAVIOURALLY.
|
|
1134
|
+
const src5 = readFileSync(PLUGIN, 'utf8')
|
|
1135
|
+
for (const k of ['start', 'staff', 'member-call', 'roster']) {
|
|
1136
|
+
assert(new RegExp("kind: '" + k + "'").test(src5), '* 6.4 memberDiagnosis branch ' + k + ' exists with its own next{} kind')
|
|
1137
|
+
}
|
|
1138
|
+
assert(/vibe_v5_start/.test(src5) && /vibe_v5_add_researcher/.test(src5), '* 6.4 the start/staff branches point at real tools (vibe_v5_start / vibe_v5_add_researcher)')
|
|
1032
1139
|
const memberRm = await h.callTool('vibe_v5_remove_researcher', { id: 'r-1' }, h.childAgent(h.childOf('r-1')))
|
|
1033
1140
|
assert(memberRm.ok === false && memberRm.code === 'V5_NOT_OFFICE',
|
|
1034
1141
|
'...and refused for a member (the tool says Office only), not just for ghosts (' + JSON.stringify(memberRm).slice(0, 90) + ')')
|
|
@@ -1871,7 +1978,7 @@ console.log('\n[42] every Lean prompt site obeys the leanInitiative gate in both
|
|
|
1871
1978
|
}
|
|
1872
1979
|
|
|
1873
1980
|
// ---------- 43. pre-start diagnosis + coherent quorum (architecture self-test defects 1/2) -------
|
|
1874
|
-
// The self-test (§18 of _oneoff/method3-scripted-driving.md) found that before `start`:
|
|
1981
|
+
// The self-test (§18 of _oneoff/slv-playbook.md(原 method3-scripted-driving.md)) found that before `start`:
|
|
1875
1982
|
// (1) record_progress / record_proposition / verdict answered a BARE `V5_MEMBER_NOT_FOUND`
|
|
1876
1983
|
// with no hint about why or what to do first;
|
|
1877
1984
|
// (2) `vibe_v5_status` reported `quorum.m=1` with `voters=0` — reading like a satisfied quorum.
|
|
@@ -1928,11 +2035,16 @@ console.log('\n[44] paths stay inside the project root; every quorum view agrees
|
|
|
1928
2035
|
const brief = h.spawns.map((sp) => JSON.stringify(sp.request || {})).join('\n')
|
|
1929
2036
|
assert(brief.indexOf('VibeMath/Projects/default/Institutes/institute/Members/') !== -1,
|
|
1930
2037
|
'★ the member brief carries the CWD-relative library root (' + (brief.match(/Members\/[^"\\]{0,40}/) || [''])[0] + ')')
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2038
|
+
// The contract is ROOT-QUALIFIED declarations: the brief names the CWD-relative root (A1 above)
|
|
2039
|
+
// AND every library declaration it offers carries the member root (`Members/<你>/<section>/…`), so a
|
|
2040
|
+
// member copying one composes a path inside `Members/<id>/`. The legacy institute-relative shapes
|
|
2041
|
+
// (`Progress/<你>/…` and friends) must be gone - those used to send the write outside the member tree.
|
|
2042
|
+
const decls = (brief.match(/Members\/<[^>]*>\/(?:Progress|Propos|Methods|Subproblems)\//g) || [])
|
|
2043
|
+
assert(decls.length >= 4,
|
|
2044
|
+
'* every member-facing library declaration is root-qualified (Members/<你>/<section>/…) (found ' + decls.length + ')')
|
|
2045
|
+
const legacy = (brief.match(/(?:^|[^A-Za-z0-9/_.-])(?:Progress|Propos|Methods|Subproblems)\/<你>\//g) || [])
|
|
2046
|
+
assert(legacy.length === 0,
|
|
2047
|
+
'no member-facing declaration is left in the legacy institute-relative shape (' + JSON.stringify(legacy) + ')')
|
|
1936
2048
|
await h.settleSpawns()
|
|
1937
2049
|
// (A2) a member card write must not add anything at the workspace TOP level.
|
|
1938
2050
|
const topBefore = readdirSync(h.WS).sort().join(',')
|
|
@@ -1974,6 +2086,339 @@ console.log('\n[44] paths stay inside the project root; every quorum view agrees
|
|
|
1974
2086
|
'the roster change moved both m and rosterVersion (' + JSON.stringify({ before: v1.st.quorum.m, after: v2.st.quorum.m, v: [v1.st.quorum.rosterVersion, v2.st.quorum.rosterVersion] }) + ')')
|
|
1975
2087
|
}
|
|
1976
2088
|
|
|
2089
|
+
// ---------- 45. F2: paperSummary() semantics (md runs must not claim tex / compilation) ----------
|
|
2090
|
+
console.log('\n[45] paperSummary(): md runs claim no tex, no compilation, no engine')
|
|
2091
|
+
{
|
|
2092
|
+
const h = makeHost({ pluginModule })
|
|
2093
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2094
|
+
const mdZh = await h.callTool('vibe_v5_paper', { lang: 'zh', format: 'md', editor: 'office' })
|
|
2095
|
+
const st = await h.callTool('vibe_v5_status', {})
|
|
2096
|
+
const p = st.paper || {}
|
|
2097
|
+
assert(mdZh.ok === true && p.format === 'md', '* the md run is reported as format=md (' + JSON.stringify({ ok: mdZh.ok, format: p.format }) + ')')
|
|
2098
|
+
const arts = Array.isArray(p.artifacts) ? p.artifacts : []
|
|
2099
|
+
assert(arts.indexOf('tex') === -1 && arts.indexOf('pdf') === -1, '* md runs list NO tex/pdf artifacts (found ' + JSON.stringify(arts) + ')')
|
|
2100
|
+
assert(!p.engine, '* md runs report no LaTeX engine - i.e. no requirement on a LaTeX toolchain (engine=' + JSON.stringify(p.engine) + ')')
|
|
2101
|
+
assert(typeof p.dir === 'string' && p.dir.indexOf('Paper/') === 0, '* status.paper.dir is the institute Paper/ dir (' + JSON.stringify(p.dir) + ')')
|
|
2102
|
+
assert(p.meta === null || p.meta === undefined, '* meta stays empty until the paper is finalized (meta=' + JSON.stringify(p.meta) + ')')
|
|
2103
|
+
// md x language and md x editor combinations
|
|
2104
|
+
const h2 = makeHost({ pluginModule }) // independent host: the first paper is already finalised
|
|
2105
|
+
await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2106
|
+
const mdEn = await h2.callTool('vibe_v5_paper', { lang: 'en', format: 'md', editor: 'academician' })
|
|
2107
|
+
console.log(' [45] fresh-host vibe_v5_paper -> ' + JSON.stringify(mdEn).slice(0, 300))
|
|
2108
|
+
const p2 = (await h2.callTool('vibe_v5_status', {})).paper || {}
|
|
2109
|
+
assert(p2.format === 'md' && p2.lang === 'en', '* md x language: lang is honoured and the format stays md (' + JSON.stringify({ format: p2.format, lang: p2.lang, call: mdEn }) + ')')
|
|
2110
|
+
const p2arts = Array.isArray(p2.artifacts) ? p2.artifacts : []
|
|
2111
|
+
assert(p2arts.indexOf('tex') === -1 && p2arts.indexOf('pdf') === -1 && !p2.engine, '* md x editor: still no tex/pdf and no engine (editor=member)')
|
|
2112
|
+
// the `editor` closed set is a CONTRACT: a valid value succeeds, an invalid one is refused by code
|
|
2113
|
+
const hOk = makeHost({ pluginModule })
|
|
2114
|
+
await hOk.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2115
|
+
const okOffice = await hOk.callTool('vibe_v5_paper', { lang: 'zh', format: 'md', editor: 'office' })
|
|
2116
|
+
assert(okOffice.ok === true, '* editor=office is accepted (the closed set is office|academician) (' + JSON.stringify(okOffice).slice(0, 100) + ')')
|
|
2117
|
+
const hBad = makeHost({ pluginModule })
|
|
2118
|
+
await hBad.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2119
|
+
const badEditor = await hBad.callTool('vibe_v5_paper', { lang: 'zh', format: 'md', editor: 'member' })
|
|
2120
|
+
assert(badEditor.ok === false && badEditor.code === 'V5_INVALID_ARGUMENT', '* an out-of-set editor is refused with V5_INVALID_ARGUMENT, not silently coerced (' + JSON.stringify(badEditor).slice(0, 120) + ')')
|
|
2121
|
+
}
|
|
2122
|
+
// ---------- 46. F6: memberDiagnosis branches — kind AND exact next.tool, exclusive & exhaustive -------
|
|
2123
|
+
console.log('\n[46] memberDiagnosis: per-branch kind + exact next.tool')
|
|
2124
|
+
{
|
|
2125
|
+
const { readFileSync } = await import('node:fs')
|
|
2126
|
+
const { fileURLToPath } = await import('node:url')
|
|
2127
|
+
const src = readFileSync(fileURLToPath(new URL('../vibe-math-v5/vibe-math-v5.js', import.meta.url)), 'utf8')
|
|
2128
|
+
const dStart = src.indexOf('function memberDiagnosis(')
|
|
2129
|
+
const dEnd = src.indexOf('\n function ', dStart + 10)
|
|
2130
|
+
const body = src.slice(dStart, dEnd > 0 ? dEnd : dStart + 4000) // scope: the diagnosis branches only
|
|
2131
|
+
const pairs = [["start","vibe_v5_start"],["staff","vibe_v5_add_researcher"],["member-call","vibe_v5_members"],["roster","vibe_v5_members"]]
|
|
2132
|
+
for (const [kind, tool] of pairs) {
|
|
2133
|
+
assert(new RegExp("kind: '" + kind + "'[^\\n]*?tool: '" + tool + "'").test(body),
|
|
2134
|
+
'* F6 branch ' + kind + ' pairs with next.tool=' + tool + ' (exact - not just ANY next{})')
|
|
2135
|
+
}
|
|
2136
|
+
for (const [kind] of pairs) {
|
|
2137
|
+
const n = (body.match(new RegExp("kind: '" + kind + "'", 'g')) || []).length
|
|
2138
|
+
assert(n === 1, '* F6 kind ' + kind + ' is declared exactly ONCE (mutually exclusive branches, no duplicate/typo kind) - found ' + n)
|
|
2139
|
+
}
|
|
2140
|
+
assert((body.match(/next: \{/g) || []).length >= 4, '* F6 the four branches are exhaustive: every refusal path carries its own next{}')
|
|
2141
|
+
}
|
|
2142
|
+
// ---------- 47. G1: a FAILED wake must not consume the mailbox (ack only after a successful send) ----
|
|
2143
|
+
console.log('\n[47] G1: a failed wake leaves the message PENDING (no ack before the send)')
|
|
2144
|
+
{
|
|
2145
|
+
const h = makeHost({ pluginModule, failSendMessage: true })
|
|
2146
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2147
|
+
await h.settleSpawns()
|
|
2148
|
+
// Every wake is forced to fail while the dm is queued, so this message can never be delivered yet.
|
|
2149
|
+
const attemptsBefore = h.sendAttempts.length
|
|
2150
|
+
const said = await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G1 未送达的消息' }, h.childAgent(h.childOf('acad')))
|
|
2151
|
+
assert(said.ok === true && said.delivered === 1,
|
|
2152
|
+
'precondition: the dm was accepted for exactly one recipient (' + JSON.stringify(said).slice(0, 120) + ')')
|
|
2153
|
+
// `say` to an addressed recipient kicks a scheduling pass; wait until a wake is ATTEMPTED.
|
|
2154
|
+
for (let i = 0; i < 60 && h.sendAttempts.length === attemptsBefore; i++) await sleep(25)
|
|
2155
|
+
assert(h.sendAttempts.length > attemptsBefore,
|
|
2156
|
+
'★ the wake was attempted while every send fails (attempts ' + attemptsBefore + ' -> ' + h.sendAttempts.length + ')')
|
|
2157
|
+
assert(h.wakes.length === 0, 'no wake was queued while sendMessage throws (got ' + h.wakes.length + ')')
|
|
2158
|
+
// Heal the host, then trigger a wake DETERMINISTICALLY: a second dm kicks a scheduling pass
|
|
2159
|
+
// (v5 `say` -> scheduleNext), so we do not depend on the heartbeat timing. The prompt must carry
|
|
2160
|
+
// BOTH messages — the first one is the assertion (a failed wake must not consume it), the second
|
|
2161
|
+
// is the positive control that a wake really happened.
|
|
2162
|
+
h.setFailSend(false)
|
|
2163
|
+
await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G1 正控消息' }, h.childAgent(h.childOf('acad')))
|
|
2164
|
+
let prompt = ''
|
|
2165
|
+
for (let i = 0; i < 4 && prompt.indexOf('G1 正控消息') === -1; i++) {
|
|
2166
|
+
const w = await h.peekWakeOf('r-1', 4000)
|
|
2167
|
+
if (w && w.text) prompt = w.text
|
|
2168
|
+
}
|
|
2169
|
+
assert(prompt.indexOf('G1 正控消息') !== -1,
|
|
2170
|
+
'★ positive control: the second dm did wake r-1 (prompt=' + JSON.stringify(prompt.slice(0, 160)) + ')')
|
|
2171
|
+
assert(prompt.indexOf('G1 未送达的消息') !== -1,
|
|
2172
|
+
'★★★ [G1] a message whose wake FAILED is still pending on the next successful wake (prompt=' + JSON.stringify(prompt.slice(0, 220)) + ')')
|
|
2173
|
+
// Two-sided: a SUCCESSFUL wake acks both once — neither may be delivered again.
|
|
2174
|
+
h.fireEnd(h.childOf('r-1'), { progress: '收到', solved: false, contextPct: 20 })
|
|
2175
|
+
await sleep(40)
|
|
2176
|
+
await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G1 第三条' }, h.childAgent(h.childOf('acad')))
|
|
2177
|
+
let after = ''
|
|
2178
|
+
for (let i = 0; i < 4 && after.indexOf('G1 第三条') === -1; i++) {
|
|
2179
|
+
const w = await h.peekWakeOf('r-1', 4000)
|
|
2180
|
+
if (w && w.text) after = w.text
|
|
2181
|
+
}
|
|
2182
|
+
assert(after.indexOf('G1 第三条') !== -1,
|
|
2183
|
+
'★ positive control: the third dm woke r-1 again (prompt=' + JSON.stringify(after.slice(0, 160)) + ')')
|
|
2184
|
+
assert(after.indexOf('G1 未送达的消息') === -1 && after.indexOf('G1 正控消息') === -1,
|
|
2185
|
+
'★★ [G1] after a successful wake the message is ACKED exactly once (not redelivered; prompt=' + JSON.stringify(after.slice(0, 220)) + ')')
|
|
2186
|
+
}
|
|
2187
|
+
// ---------- 48. G2: a FAILED send must not consume a round (counters after the send) ----------------
|
|
2188
|
+
console.log('\n[48] G2: a failed wake does not consume a round (rounds == successful sends)')
|
|
2189
|
+
{
|
|
2190
|
+
const h = makeHost({ pluginModule, failSendMessage: true })
|
|
2191
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2192
|
+
await h.settleSpawns()
|
|
2193
|
+
const r1 = h.childOf('r-1')
|
|
2194
|
+
// Addressed mail wakes r-1; while the host is broken every such wake FAILS.
|
|
2195
|
+
await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G2 失败唤醒' }, h.childAgent(h.childOf('acad')))
|
|
2196
|
+
for (let i = 0; i < 60 && h.sendAttempts.filter(c => c === r1).length === 0; i++) await sleep(25)
|
|
2197
|
+
const failedAttempts = h.sendAttempts.filter(c => c === r1).length
|
|
2198
|
+
assert(failedAttempts >= 1, 'precondition: at least one wake for r-1 was attempted and failed (' + failedAttempts + ')')
|
|
2199
|
+
assert(h.wakes.filter(w => w.childId === r1).length === 0, 'precondition: no wake for r-1 succeeded yet')
|
|
2200
|
+
// Heal the host and wake r-1 for real.
|
|
2201
|
+
h.setFailSend(false)
|
|
2202
|
+
await h.callTool('vibe_v5_say', { to: 'r-1', text: 'G2 成功唤醒' }, h.childAgent(h.childOf('acad')))
|
|
2203
|
+
let prompt = ''
|
|
2204
|
+
for (let i = 0; i < 4 && prompt.indexOf('G2 成功唤醒') === -1; i++) {
|
|
2205
|
+
const w = await h.peekWakeOf('r-1', 4000)
|
|
2206
|
+
if (w && w.text) prompt = w.text
|
|
2207
|
+
}
|
|
2208
|
+
assert(prompt.indexOf('G2 成功唤醒') !== -1, '★ positive control: the healed host woke r-1 (' + JSON.stringify(prompt.slice(0, 140)) + ')')
|
|
2209
|
+
// `wakeSends` survives `peekWakeOf`'s splice; the status line is built BEFORE the send, so the round it
|
|
2210
|
+
// shows is the one being started: it must equal the number of successes (not attempts).
|
|
2211
|
+
const succeeded = h.wakeSends
|
|
2212
|
+
const mm = /轮次 (\d+)/.exec(prompt)
|
|
2213
|
+
const shown = mm ? Number(mm[1]) : -1
|
|
2214
|
+
assert(succeeded >= 1 && failedAttempts >= 1,
|
|
2215
|
+
'precondition: at least one success and one failure for r-1 (successes=' + succeeded + ', failures=' + failedAttempts + ')')
|
|
2216
|
+
assert(shown === succeeded,
|
|
2217
|
+
'★★★ [G2] a failed send must not consume a round: 轮次=' + shown + ' vs 成功发送=' + succeeded +
|
|
2218
|
+
' (失败尝试=' + failedAttempts + ') — attempts must not count')
|
|
2219
|
+
}
|
|
2220
|
+
// ---------- 49. G4: assignment metadata travels INSIDE the CAS write (no second task write) ---------
|
|
2221
|
+
console.log('\n[49] G4: the assign writes the task exactly once, and a stale CAS is refused')
|
|
2222
|
+
{
|
|
2223
|
+
const h = makeHost({ pluginModule })
|
|
2224
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 2 })
|
|
2225
|
+
await h.settleSpawns()
|
|
2226
|
+
const created = await h.callTool('vibe_v5_assign', { subject: 'G4 任务', to: 'r-1', why: 'G4 理由', acceptance: 'G4 验收' })
|
|
2227
|
+
assert(created.ok === true && created.task && created.task.id, 'the office can assign a task (' + JSON.stringify(created).slice(0, 140) + ')')
|
|
2228
|
+
const got = await h.callTool('vibe_v5_task_get', { task_id: created.task.id })
|
|
2229
|
+
const t = (got && got.task) || got
|
|
2230
|
+
assert(t && t.assignedBy === 'office' && t.why === 'G4 理由' && t.acceptance === 'G4 验收',
|
|
2231
|
+
'★ the metadata is part of the SAME task value (' + JSON.stringify([t && t.assignedBy, t && t.why, t && t.acceptance]) + ')')
|
|
2232
|
+
assert(t && t.status === 'in_progress' && t.ownerId === 'r-1' && t.revision === 2,
|
|
2233
|
+
'★ the reassign landed as exactly one revision step (revision=' + (t && t.revision) + ', owner=' + (t && t.ownerId) + ', status=' + (t && t.status) + ')')
|
|
2234
|
+
// The compare-and-set token really is compared: the PRE-assign revision must be refused.
|
|
2235
|
+
const stale = await h.callTool('vibe_v5_task_update', { task_id: created.task.id, expected_revision: 1, action: 'release' }, h.childAgent(h.childOf('acad')))
|
|
2236
|
+
assert(stale.ok === false && stale.code === 'V5_TASK_STALE_REVISION',
|
|
2237
|
+
'★★ a stale expected_revision is refused with V5_TASK_STALE_REVISION (' + JSON.stringify(stale).slice(0, 150) + ')')
|
|
2238
|
+
// …and the reason the lost-update window existed: a second, unprotected write in the assign path.
|
|
2239
|
+
// The source MUST be read through the SAME seam the plugin was loaded from (`PLUGIN`), or a mutant
|
|
2240
|
+
// copy would be invisible here and this assertion would pass against the unmutated repo file.
|
|
2241
|
+
const { readFileSync: rf } = await import('node:fs')
|
|
2242
|
+
const { fileURLToPath: fp } = await import('node:url')
|
|
2243
|
+
const src = rf(fp(PLUGIN), 'utf8')
|
|
2244
|
+
const s0 = src.indexOf('async function taskAssign(')
|
|
2245
|
+
const s1 = src.indexOf('\n async function ', s0 + 10)
|
|
2246
|
+
const body = src.slice(s0, s1 > 0 ? s1 : s0 + 4000)
|
|
2247
|
+
const writes = (body.match(/putTask\(/g) || []).length
|
|
2248
|
+
assert(writes === 0,
|
|
2249
|
+
'★★★ [G4] the assign path issues NO second, unprotected task write (found ' + writes + ' putTask( in taskAssign) — ' +
|
|
2250
|
+
'a second write reusing the just-read revision is the lost-update window')
|
|
2251
|
+
assert(/assignedBy: isOffice\(memberId\)/.test(body),
|
|
2252
|
+
'★ the metadata goes through the CAS call itself (assignedBy/why/acceptance passed as the internal meta argument)')
|
|
2253
|
+
}
|
|
2254
|
+
// ---------- 50. spawnMember: the founding round counts only on a SUCCESSFUL start -------------------
|
|
2255
|
+
console.log('\n[50] spawnMember: the first prompt shows 轮次 1, and a failed start consumes no round')
|
|
2256
|
+
{
|
|
2257
|
+
const h = makeHost({ pluginModule })
|
|
2258
|
+
const startPromptOf = (m) => {
|
|
2259
|
+
const sp = h.spawnOf(m)
|
|
2260
|
+
const blocks = (sp && sp.request && sp.request.prompt) || []
|
|
2261
|
+
return blocks.map((b) => (b && b.text) || '').join('\n')
|
|
2262
|
+
}
|
|
2263
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2264
|
+
const first = startPromptOf('r-1')
|
|
2265
|
+
assert(h.spawns.length >= 2 && first.length > 0, 'precondition: the founding prompt of r-1 was recorded (' + h.spawns.length + ' spawn(s))')
|
|
2266
|
+
assert(/轮次 1/.test(first), '★ the FOUNDING prompt announces the round it is starting (轮次 1): ' + JSON.stringify((/轮次 \d+/.exec(first) || ['<none>'])[0]))
|
|
2267
|
+
assert(!/轮次 0/.test(first), '★★ the founding prompt never displays 轮次 0 (the counter is applied after the start, so the value must be passed in explicitly)')
|
|
2268
|
+
await h.settleSpawns()
|
|
2269
|
+
// A FAILED founding must not consume a round: the retry's prompt must still say 轮次 1.
|
|
2270
|
+
const h2 = makeHost({ pluginModule, failStartContinuable: true })
|
|
2271
|
+
const startPromptOf2 = (m) => {
|
|
2272
|
+
const sp = h2.spawnOf(m)
|
|
2273
|
+
const blocks = (sp && sp.request && sp.request.prompt) || []
|
|
2274
|
+
return blocks.map((b) => (b && b.text) || '').join('\n')
|
|
2275
|
+
}
|
|
2276
|
+
await h2.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2277
|
+
assert(h2.startAttempts.length >= 1 && h2.spawns.length === 0,
|
|
2278
|
+
'precondition: founding was ATTEMPTED and every start failed (attempts=' + h2.startAttempts.length + ', spawns=' + h2.spawns.length + ')')
|
|
2279
|
+
const st0 = await h2.callTool('vibe_v5_status', {})
|
|
2280
|
+
assert(st0.members.some((m) => m.phase === 'failed'), 'the failed founders are recorded as failed, not left active')
|
|
2281
|
+
// Heal the host and rebuild through the documented manual resume path.
|
|
2282
|
+
h2.setFailStart(false)
|
|
2283
|
+
await h2.callTool('vibe_v5_resume', {})
|
|
2284
|
+
await h2.settleSpawns()
|
|
2285
|
+
const retry = startPromptOf2('r-1')
|
|
2286
|
+
assert(retry.length > 0, 'precondition: the resumed member was actually started on the retry')
|
|
2287
|
+
assert(/轮次 1/.test(retry),
|
|
2288
|
+
'★★★ [spawnMember] a FAILED start must not consume a round: the rebuilt member\'s first prompt shows 轮次 1, not 2 (' + JSON.stringify((/轮次 \d+/.exec(retry) || ['<none>'])[0]) + ')')
|
|
2289
|
+
}
|
|
2290
|
+
// ---------- 51. spawnMember: a FAILED resume must not move the round number backwards ----------------
|
|
2291
|
+
console.log('\n[51] spawnMember: a failed resume keeps the existing round number (never restarts it)')
|
|
2292
|
+
{
|
|
2293
|
+
const h = makeHost({ pluginModule })
|
|
2294
|
+
// The FIRST matching spawn is the founding one; after a rebuild the newest entry is the resumed one.
|
|
2295
|
+
const lastStartPromptOf = (m) => {
|
|
2296
|
+
const sp = h.spawns.slice().reverse().find((s) => String(s.label || '').indexOf('vibe5 ' + m + ' ') !== -1)
|
|
2297
|
+
const blocks = (sp && sp.request && sp.request.prompt) || []
|
|
2298
|
+
return blocks.map((b) => (b && b.text) || '').join('\n')
|
|
2299
|
+
}
|
|
2300
|
+
const roundOf = (t) => ((/轮次 (\d+)/.exec(t) || [])[1] || '<none>')
|
|
2301
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 1 })
|
|
2302
|
+
await h.settleSpawns()
|
|
2303
|
+
assert(roundOf(lastStartPromptOf('r-1')) === '1',
|
|
2304
|
+
'precondition: the founding prompt starts the member at 轮次 1 (got ' + roundOf(lastStartPromptOf('r-1')) + ')')
|
|
2305
|
+
// Simulate the HOST losing the children (a restart) WITHOUT dismissing the member: its count survives.
|
|
2306
|
+
// (`vibe_v5_fire` deletes the counters on purpose, so it cannot be used for this scenario.)
|
|
2307
|
+
await h.ctx.subagents.drainContinuableChildren(h.ROOT, [h.childOf('r-1')])
|
|
2308
|
+
const attemptsBefore = h.startAttempts.length
|
|
2309
|
+
h.setFailStart(true)
|
|
2310
|
+
await h.callTool('vibe_v5_resume', {})
|
|
2311
|
+
assert(h.startAttempts.length > attemptsBefore,
|
|
2312
|
+
'precondition: the resume was ATTEMPTED while every start fails (attempts ' + attemptsBefore + ' -> ' + h.startAttempts.length + ')')
|
|
2313
|
+
assert(roundOf(lastStartPromptOf('r-1')) === '1',
|
|
2314
|
+
'precondition: the failed resume started nobody (the newest prompt is still the founding one)')
|
|
2315
|
+
// Heal and resume again: the count must CONTINUE, not restart.
|
|
2316
|
+
h.setFailStart(false)
|
|
2317
|
+
await h.callTool('vibe_v5_resume', {})
|
|
2318
|
+
await h.settleSpawns()
|
|
2319
|
+
const resumed = lastStartPromptOf('r-1')
|
|
2320
|
+
assert(h.spawns.filter((s) => String(s.label || '').indexOf('vibe5 r-1 ') !== -1).length >= 2,
|
|
2321
|
+
'precondition: the member was actually rebuilt on the healed resume (' + h.spawns.length + ' spawns total)')
|
|
2322
|
+
assert(roundOf(resumed) === '2',
|
|
2323
|
+
'★★★ [spawnMember] a FAILED resume must not move the round number backwards: the rebuilt prompt says 轮次 ' +
|
|
2324
|
+
roundOf(resumed) + ', expected 轮次 2 (the existing count must survive the failed attempt)')
|
|
2325
|
+
assert(!/轮次 1/.test(resumed), '★★ the existing count was not restarted — no 轮次 1 on the resumed prompt')
|
|
2326
|
+
}
|
|
2327
|
+
// ---------- 52. F6: a failed REQUIRED artifact write is NAMED (once), and `files` stays honest ------
|
|
2328
|
+
console.log('\n[52] F6: a refused required write is named exactly once; a successful one is silent')
|
|
2329
|
+
{
|
|
2330
|
+
const settle = async (h) => {
|
|
2331
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
2332
|
+
await h.settleSpawns()
|
|
2333
|
+
await h.callTool('vibe_v5_paper', {})
|
|
2334
|
+
await drivePaper(h)
|
|
2335
|
+
}
|
|
2336
|
+
// (a) ANTI-VACUITY: a healthy finalisation writes meta and says NOTHING about write failures.
|
|
2337
|
+
const okHost = makeHost({ pluginModule })
|
|
2338
|
+
await settle(okHost)
|
|
2339
|
+
const okSt = await okHost.callTool('vibe_v5_status', {})
|
|
2340
|
+
const okDir = paperDirOf(okHost, 'institute')
|
|
2341
|
+
assert(existsSync(join(okDir, 'paper.meta.json')), 'precondition: the healthy run wrote paper.meta.json')
|
|
2342
|
+
const okWarn = (okSt.paper && okSt.paper.warnings) || []
|
|
2343
|
+
assert(!okWarn.some((w) => /产物写入失败/.test(w)),
|
|
2344
|
+
'★ anti-vacuity: a SUCCESSFUL required write produces no failure warning (' + JSON.stringify(okWarn) + ')')
|
|
2345
|
+
assert((okSt.paper.artifacts || []).indexOf('paper.meta.json') !== -1,
|
|
2346
|
+
'★ the healthy run lists paper.meta.json among its artifacts (' + JSON.stringify(okSt.paper.artifacts) + ')')
|
|
2347
|
+
// (b) THE REFUSAL: paper.meta.json cannot be written.
|
|
2348
|
+
const h = makeHost({ pluginModule, failWriteOn: /paper\.meta\.json$/ })
|
|
2349
|
+
await settle(h)
|
|
2350
|
+
const st = await h.callTool('vibe_v5_status', {})
|
|
2351
|
+
const dir = paperDirOf(h, 'institute')
|
|
2352
|
+
assert(!existsSync(join(dir, 'paper.meta.json')), 'precondition: the injected refusal really prevented the write')
|
|
2353
|
+
const warns = (st.paper && st.paper.warnings) || []
|
|
2354
|
+
const named = warns.filter((w) => /产物写入失败/.test(w) && /paper\.meta\.json/.test(w))
|
|
2355
|
+
assert(named.length === 1,
|
|
2356
|
+
'★★★ [F6] a failed REQUIRED write is named EXACTLY once, by artifact (' + JSON.stringify(warns) + ')')
|
|
2357
|
+
assert(/refillPaperArtifacts|补写/.test(named[0]),
|
|
2358
|
+
'★ the warning names the recovery path, not just the symptom (' + JSON.stringify(named[0].slice(0, 160)) + ')')
|
|
2359
|
+
assert((st.paper.artifacts || []).indexOf('paper.meta.json') === -1,
|
|
2360
|
+
'★ `files` stays HONEST — the artifact that did not land is absent from the list (' + JSON.stringify(st.paper.artifacts) + ')')
|
|
2361
|
+
assert(existsSync(join(dir, 'paper.md')), 'the other artifacts still landed (only the refused one is missing)')
|
|
2362
|
+
// …and the idempotent refill is what repairs it once the host allows writes again.
|
|
2363
|
+
h.setFailWriteOn(null)
|
|
2364
|
+
const refill = await h.callTool('vibe_v5_paper', {})
|
|
2365
|
+
assert(refill.ok === true && existsSync(join(dir, 'paper.meta.json')),
|
|
2366
|
+
'★ the documented recovery path refills the missing artifact (' + JSON.stringify(refill.refill || refill).slice(0, 140) + ')')
|
|
2367
|
+
const st2 = await h.callTool('vibe_v5_status', {})
|
|
2368
|
+
const named2 = ((st2.paper && st2.paper.warnings) || []).filter((w) => /产物写入失败/.test(w) && /paper\.meta\.json/.test(w))
|
|
2369
|
+
assert(named2.length === 1, '★★ the read-only refill does NOT multiply the warning (still exactly one): ' + JSON.stringify(named2))
|
|
2370
|
+
// (c) THE FLOW LOG, scoped to the FINALIZE-time append only (path + content): the ~15 earlier log writes
|
|
2371
|
+
// must still land, so `paper.log.md` exists but lacks the 定稿 section. The content predicate is NOT
|
|
2372
|
+
// anchored — an append carries the whole earlier log in front of the new section.
|
|
2373
|
+
const hl = makeHost({ pluginModule, failWriteOn: /paper\.log\.md$/, failWriteContent: /## 定稿(/ })
|
|
2374
|
+
await settle(hl)
|
|
2375
|
+
const ldir = paperDirOf(hl, 'institute')
|
|
2376
|
+
const logPath = join(ldir, 'paper.log.md')
|
|
2377
|
+
assert(existsSync(logPath), 'precondition: the earlier paper.log.md writes landed (only the final append is refused)')
|
|
2378
|
+
const logText = readFileSync(logPath, 'utf8')
|
|
2379
|
+
assert(logText.indexOf('## 定稿(') === -1, 'precondition: the refused append really did not reach the log')
|
|
2380
|
+
assert(logText.indexOf('## ') !== -1, 'precondition: the log still carries the flow entries written before finalisation')
|
|
2381
|
+
const lst = await hl.callTool('vibe_v5_status', {})
|
|
2382
|
+
const lwarn = ((lst.paper && lst.paper.warnings) || []).filter((w) => /产物写入失败/.test(w) && /paper\.log\.md/.test(w))
|
|
2383
|
+
assert(lwarn.length === 1, '★★ [F6] a failed FINALIZE-time log write is named exactly once (' + JSON.stringify(lst.paper && lst.paper.warnings) + ')')
|
|
2384
|
+
assert(/refillPaperArtifacts|补写/.test(lwarn[0]), '★ the log warning names the recovery path too')
|
|
2385
|
+
const lmeta = existsSync(join(ldir, 'paper.meta.json')) ? JSON.parse(readFileSync(join(ldir, 'paper.meta.json'), 'utf8')) : null
|
|
2386
|
+
assert(!!lmeta && (lmeta.warnings || []).some((w) => /产物写入失败/.test(w) && /paper\.log\.md/.test(w)),
|
|
2387
|
+
'★★ the DURABLE meta is re-written so it also names the missing log (' + JSON.stringify(lmeta && lmeta.warnings) + ')')
|
|
2388
|
+
assert((lst.paper.artifacts || []).indexOf('paper.meta.json') !== -1, 'the meta still landed and stays in the artifact list')
|
|
2389
|
+
// Anti-vacuity for (c): the healthy run's log DOES contain the 定稿 section.
|
|
2390
|
+
const okLog = existsSync(join(okDir, 'paper.log.md')) ? readFileSync(join(okDir, 'paper.log.md'), 'utf8') : ''
|
|
2391
|
+
assert(okLog.indexOf('## 定稿(') !== -1, '★ anti-vacuity: the healthy run\'s log carries the 定稿 section (the seam is what removed it)')
|
|
2392
|
+
}
|
|
2393
|
+
// ---------- 53. v5 L1: a broken Lean path contract is SURFACED in report()'s diagnostics ------------
|
|
2394
|
+
console.log('\n[53] v5 L1 leanPathContractOk: the queued note reaches report() diagnostics')
|
|
2395
|
+
{
|
|
2396
|
+
const { readFileSync: rf1 } = await import('node:fs')
|
|
2397
|
+
const { fileURLToPath: fp1 } = await import('node:url')
|
|
2398
|
+
const src = rf1(fp1(PLUGIN), 'utf8')
|
|
2399
|
+
assert(src.indexOf('function leanPathContractOk() {') !== -1, '★ [v5 L1] the contract checker exists (symbol anchor)')
|
|
2400
|
+
assert(src.indexOf("if (!leanPathContractOk()) noteLoadProblem('Lean path contract broken") !== -1,
|
|
2401
|
+
'★ [v5 L1] the load path CALLS it and queues the named note (source anchor)')
|
|
2402
|
+
assert(src.indexOf('drainLoadNotes()') > src.indexOf('leanPathContractOk()'),
|
|
2403
|
+
'★ [v5 L1] the queued note is DRAINED on the next commit (the drain site follows the check)')
|
|
2404
|
+
const h = makeHost({ pluginModule })
|
|
2405
|
+
await h.callTool('vibe_v5_start', { problem: PROBLEM, researcherCount: 0 })
|
|
2406
|
+
await h.settleSpawns()
|
|
2407
|
+
// ONE more committing action: `commit()` is what drains the queued load notes into the diagnostics log.
|
|
2408
|
+
await h.callTool('vibe_v5_message', { to: 'all', content: 'L1 probe commit' })
|
|
2409
|
+
// `report()` returns a STRING, so the tool spec is invoked directly (callTool would JSON.parse it).
|
|
2410
|
+
const spec = h.toolRegs.find((t) => t.name === 'vibe_v5_report')
|
|
2411
|
+
const rep = spec ? String(await spec.execute({}, { agent: h.ROOT })) : ''
|
|
2412
|
+
// MEASURED (2026-10, this suite): a healthy host's report() is ~1050 chars and does NOT contain the
|
|
2413
|
+
// diagnostics section — that section is CONDITIONAL (it renders only when there is something to report),
|
|
2414
|
+
// so the anti-vacuity check must be "the report tool was found and returned a substantive string", not
|
|
2415
|
+
// "the diagnostics heading is present". The negative L1 assertion below is what discriminates, and the
|
|
2416
|
+
// shipped family's v5-L1 mutant proves it can redden (12/12, named red on the line beneath).
|
|
2417
|
+
assert(spec !== undefined && rep.length > 200,
|
|
2418
|
+
'★ precondition: the report tool was found and returned a substantive report (' + rep.length + ' chars)')
|
|
2419
|
+
assert(rep.indexOf('Lean path contract broken') === -1,
|
|
2420
|
+
'★★ [v5 L1] a HEALTHY composition surfaces no Lean path contract note in report() diagnostics')
|
|
2421
|
+
}
|
|
1977
2422
|
console.log('')
|
|
1978
2423
|
console.log('passed=' + passed + ' failed=' + failed)
|
|
1979
2424
|
if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
|