dsh-vibe-math 2.4.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +14 -14
- package/README.md +15 -14
- package/cordis.patch.yml +33 -10
- package/docs/AUDIT-CHECKLIST.md +25 -6
- package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
- package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
- package/docs/test-timing.md +73 -40
- package/installer.js +225 -45
- package/package.json +3 -2
- package/preset-declaration.js +38 -7
- package/prompt-corpus-persona/persona-corpus.json +9 -9
- package/prompt-corpus-persona/persona-corpus.md +67 -21
- package/prompt-corpus-v2/formal-verify-v2.json +116 -92
- package/prompt-corpus-v2/formal-verify-v2.md +240 -93
- package/prompt-corpus-v3/formal-verify-v3.json +81 -81
- package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
- package/prompt-corpus-v4/formal-verify-v4.json +38 -8
- package/prompt-corpus-v4/formal-verify-v4.md +157 -25
- package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
- package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
- package/tests/audit-installer-compat.test.mjs +70 -4
- package/tests/audit-installer-policy.test.mjs +229 -4
- package/tests/audit-persona-sensitivity.mjs +11 -4
- package/tests/audit-persona-surface.test.mjs +5 -1
- package/tests/audit-v5-sensitivity.mjs +47 -6
- package/tests/e2e-v5-round2.test.mjs +354 -0
- package/tests/formal-verify-v2.test.mjs +54 -2
- package/tests/formal-verify-v3.test.mjs +63 -2
- package/tests/formal-verify-v4.test.mjs +120 -4
- package/tests/run-tests.mjs +104 -23
- package/vibe-math-v2/agent.cordis.yml +6 -0
- package/vibe-math-v2/vibe-math-v2.js +503 -99
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
- package/vibe-math-v3/agent.cordis.yml +26 -10
- package/vibe-math-v3/vibe-math-v3.js +555 -175
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
- package/vibe-math-v4/agent.cordis.yml +26 -6
- package/vibe-math-v4/vibe-math-v4.js +404 -51
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
- package/vibe-math-v5/agent.cordis.yml +8 -4
- package/vibe-math-v5/vibe-math-v5.js +513 -105
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +73 -40
|
@@ -52,7 +52,6 @@
|
|
|
52
52
|
// field — so the old projection-based primary made the user's own session unresumable.
|
|
53
53
|
export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer']
|
|
54
54
|
|
|
55
|
-
const PROJECTION_KEY = 'vibeMathV5'
|
|
56
55
|
const PROJECTION_VERSION = 1
|
|
57
56
|
const EV = {
|
|
58
57
|
institute: 'vibe5/institute',
|
|
@@ -164,6 +163,9 @@ export function apply(ctx) {
|
|
|
164
163
|
// The fold is shared by BOTH persistence backends, so the state machine is
|
|
165
164
|
// defined exactly once. It must return a NEW top-level reference whenever
|
|
166
165
|
// anything changed (the state store compares by Object.is).
|
|
166
|
+
// The acknowledgement ledger is an LRU window, NOT a full history: it exists to answer
|
|
167
|
+
// "was this message delivered?", and the whole snapshot is rewritten on every commit.
|
|
168
|
+
const DELIVERED_CAP = 500
|
|
167
169
|
function emptyInstitute(key, project, institute) {
|
|
168
170
|
return {
|
|
169
171
|
key, project, institute,
|
|
@@ -266,10 +268,16 @@ export function apply(ctx) {
|
|
|
266
268
|
for (const id of ids) { if (!set.has(id)) { set.add(id); changed = true } }
|
|
267
269
|
if (!changed) return inst
|
|
268
270
|
// Compact: a message that has been delivered may leave `messages` too, so
|
|
269
|
-
// the queue never grows without bound over a long run.
|
|
271
|
+
// the queue never grows without bound over a long run. `delivered` itself is
|
|
272
|
+
// bounded the same way `debates`/`meetings` are: it is an acknowledgement LEDGER,
|
|
273
|
+
// and every commit rewrites the whole snapshot, so an ever-growing array of every
|
|
274
|
+
// message id ever acked would make each write progressively more expensive
|
|
275
|
+
// (audit M7). Oldest entries are dropped first; `messages` holds only undelivered
|
|
276
|
+
// ids and is filtered by the same set, so dropping an id can never re-deliver.
|
|
270
277
|
const delivered = Array.from(set)
|
|
278
|
+
const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered
|
|
271
279
|
const messages = inst.messages.filter((m) => !set.has(m.id))
|
|
272
|
-
return Object.assign({}, inst, { delivered, messages })
|
|
280
|
+
return Object.assign({}, inst, { delivered: capped, messages })
|
|
273
281
|
})
|
|
274
282
|
}
|
|
275
283
|
if (t === EV.meeting) {
|
|
@@ -308,8 +316,21 @@ export function apply(ctx) {
|
|
|
308
316
|
return Object.assign({}, inst, { formal, todo })
|
|
309
317
|
})
|
|
310
318
|
}
|
|
319
|
+
// `d.queue` is normally the new ARRAY, but a queue MUTATION (appending a proposal,
|
|
320
|
+
// taking the head to run) is passed as a FUNCTION instead. Applying it here, inside the
|
|
321
|
+
// fold, makes its read and its write one atomic step of the serial event chain: the old
|
|
322
|
+
// shape (read `inst().queue`, mutate the copy, `await putQueue(copy)`) lost one of two
|
|
323
|
+
// proposals made in the same tick, because both callers read the same array and each
|
|
324
|
+
// committed its own copy.
|
|
311
325
|
if (t === EV.queue) {
|
|
312
|
-
return withInstitute(state, key, (inst) =>
|
|
326
|
+
return withInstitute(state, key, (inst) => {
|
|
327
|
+
const next = typeof d.queue === 'function' ? d.queue(inst.queue) : d.queue
|
|
328
|
+
const list = Array.isArray(next) ? next : []
|
|
329
|
+
// A mutation that changed nothing (a duplicate proposal) leaves the queue object
|
|
330
|
+
// identical: return the institute unchanged rather than minting a new revision.
|
|
331
|
+
if (list === inst.queue) return inst
|
|
332
|
+
return Object.assign({}, inst, { queue: list })
|
|
333
|
+
})
|
|
313
334
|
}
|
|
314
335
|
if (t === EV.counters) {
|
|
315
336
|
return withInstitute(state, key, (inst) => Object.assign({}, inst, { counters: Object.assign({}, inst.counters, d.counters || {}) }))
|
|
@@ -335,28 +356,84 @@ export function apply(ctx) {
|
|
|
335
356
|
// the whole snapshot under a per-file serialization chain; nothing is written to a host session log
|
|
336
357
|
// (see the PERSISTENCE note at the top of this file for why the projection backend was removed).
|
|
337
358
|
// `pathOf` is a FUNCTION, not a captured string: the state path depends on the
|
|
338
|
-
// project/institute, which
|
|
339
|
-
//
|
|
359
|
+
// project/institute, which `configure` may change mid-session — a captured path would
|
|
360
|
+
// keep writing to the pre-load guess forever.
|
|
361
|
+
//
|
|
362
|
+
// The load latch is PER PATH, and a write is only allowed for a path whose load
|
|
363
|
+
// SUCCEEDED. Both are needed to make the state file safe (the 2.4.1 audit's H1):
|
|
364
|
+
// a boolean "we have loaded something" latch let the first tool call of a session
|
|
365
|
+
// (typically `vibe_v5_status`, against the DEFAULT institute) latch the backend to the
|
|
366
|
+
// default path, after which `vibe_v5_configure {institute:'alpha'}` never read alpha's
|
|
367
|
+
// file and wrote an EMPTY institute over it. Switching paths now re-reads the new path
|
|
368
|
+
// first, and if that read fails the backend refuses to write instead of clobbering.
|
|
340
369
|
function makeFileBackend(readTextAbs, writeTextAbs, pathOf) {
|
|
341
370
|
let mem = initState()
|
|
342
371
|
let chain = Promise.resolve(true)
|
|
343
|
-
let
|
|
344
|
-
|
|
372
|
+
let loadedPath // the path whose file is currently folded into `mem`
|
|
373
|
+
let loadOk = false // did the read of `loadedPath` actually succeed?
|
|
374
|
+
let loadPromise = null // the in-flight load (dedupes concurrent callers)
|
|
375
|
+
let loadPendingPath = null
|
|
376
|
+
// Fold ONE path into `mem`. `readTextAbs` returning undefined means "no file yet", which
|
|
377
|
+
// is the only case that licences a later write; anything else (a throw, a parse error, a
|
|
378
|
+
// version mismatch) leaves `loadOk` false so commit() refuses to clobber it.
|
|
379
|
+
async function doLoad(p) {
|
|
380
|
+
let ok = false
|
|
381
|
+
try {
|
|
382
|
+
const raw = await readTextAbs(p)
|
|
383
|
+
if (raw === undefined || raw === null || raw === '') ok = true
|
|
384
|
+
else {
|
|
385
|
+
const parsed = JSON.parse(raw)
|
|
386
|
+
if (parsed && parsed.v === PROJECTION_VERSION) { mem = parsed; ok = true }
|
|
387
|
+
}
|
|
388
|
+
} catch (e) { /* a corrupt/unreadable file is ignored; it is not authoritative */ }
|
|
389
|
+
loadedPath = p
|
|
390
|
+
loadOk = ok
|
|
391
|
+
loadPromise = null
|
|
392
|
+
loadPendingPath = null
|
|
393
|
+
return mem
|
|
394
|
+
}
|
|
395
|
+
const backend = {
|
|
345
396
|
kind: 'file',
|
|
346
397
|
async load() {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
398
|
+
const path = pathOf()
|
|
399
|
+
if (loadedPath === path && (loadOk || loadPromise === null)) return mem
|
|
400
|
+
// A different path (or a previously FAILED load of this one) is read again before it
|
|
401
|
+
// is ever written to. Switching paths starts from the empty initial state: `mem`
|
|
402
|
+
// holds only the fold of the file that is on disk for the CURRENT path.
|
|
403
|
+
if (loadedPath !== path) { mem = initState(); loadPromise = null }
|
|
404
|
+
if (loadPromise && loadPendingPath === path) return await loadPromise
|
|
405
|
+
loadPendingPath = path
|
|
406
|
+
loadPromise = doLoad(path)
|
|
407
|
+
await loadPromise
|
|
408
|
+
if (loadedPath === pathOf()) return mem
|
|
409
|
+
// The path moved again while we were reading: hand back the state for the path
|
|
410
|
+
// that is current NOW rather than one for a directory we are no longer using.
|
|
411
|
+
return await backend.load()
|
|
357
412
|
},
|
|
358
413
|
read() { return mem },
|
|
414
|
+
loadedFor() { return { path: loadedPath, ok: loadOk } },
|
|
415
|
+
// Read ONE specific path (used by `configure`, which must know whether the institute
|
|
416
|
+
// it is being pointed at already exists on disk) and report the state belonging to
|
|
417
|
+
// `useKey`. The path latch deliberately stays on the file that was actually read.
|
|
418
|
+
async loadAt(path, useKey) {
|
|
419
|
+
loadedPath = path
|
|
420
|
+
loadOk = false
|
|
421
|
+
loadPromise = null
|
|
422
|
+
loadPendingPath = null
|
|
423
|
+
mem = initState()
|
|
424
|
+
await doLoad(path)
|
|
425
|
+
return { state: mem, institute: useKey === undefined ? undefined : mem.institutes[String(useKey)] }
|
|
426
|
+
},
|
|
359
427
|
async commit(type, data) {
|
|
428
|
+
// NEVER write a snapshot whose file was not successfully read first: an unread file
|
|
429
|
+
// is indistinguishable from "the read never ran", and whole-snapshot writes make
|
|
430
|
+
// that failure mode destructive.
|
|
431
|
+
const path = pathOf()
|
|
432
|
+
if (loadedPath !== path) await backend.load()
|
|
433
|
+
if (loadedPath !== path || !loadOk) {
|
|
434
|
+
throw v5err('V5_STATE_NOT_LOADED',
|
|
435
|
+
'refusing to overwrite ' + path + ' : its state file was never read successfully (a failed or unreadable load must not be clobbered)')
|
|
436
|
+
}
|
|
360
437
|
mem = applyV5Event(mem, { type, data })
|
|
361
438
|
const snapshot = mem
|
|
362
439
|
// Serialize writes per file and defer JSON.stringify to execution time, so a
|
|
@@ -369,6 +446,7 @@ export function apply(ctx) {
|
|
|
369
446
|
return mem
|
|
370
447
|
},
|
|
371
448
|
}
|
|
449
|
+
return backend
|
|
372
450
|
}
|
|
373
451
|
|
|
374
452
|
// ---- session registry --------------------------------------------------
|
|
@@ -496,28 +574,53 @@ export function apply(ctx) {
|
|
|
496
574
|
async function writeTextAbs(p, content) {
|
|
497
575
|
try {
|
|
498
576
|
const t = await fsTargetAbs(p)
|
|
499
|
-
|
|
500
|
-
return true
|
|
501
|
-
|
|
577
|
+
// `fs.writeText` RESOLVES with an FsWriteOutcome ({operation, version, before, after})
|
|
578
|
+
// and REJECTS on a real failure, so the old `return true` here meant only "the call did
|
|
579
|
+
// not throw" and discarded the outcome (audit L4). Return the outcome itself: `undefined`
|
|
580
|
+
// now means "not written", an object means "written, and here is what the fs reported".
|
|
581
|
+
// A stub/mock fs that resolves with nothing is reported as an unknown-but-successful
|
|
582
|
+
// write, because the real host never resolves without an outcome.
|
|
583
|
+
const outcome = await fs.writeText(t, content, undefined, undefined, getPolicy())
|
|
584
|
+
return (outcome === undefined || outcome === null) ? { operation: 'unknown' } : outcome
|
|
585
|
+
} catch (e) { return undefined }
|
|
502
586
|
}
|
|
503
587
|
async function readTextRel(rel) { return await readTextAbs(instRoot() + '/' + rel) }
|
|
504
|
-
async function writeTextRel(rel, content) { return await writeTextAbs(instRoot() + '/' + rel, content) }
|
|
588
|
+
async function writeTextRel(rel, content) { return (await writeTextAbs(instRoot() + '/' + rel, content)) !== undefined }
|
|
505
589
|
|
|
506
590
|
function installBackend() {
|
|
507
591
|
backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
|
|
508
592
|
return backend
|
|
509
593
|
}
|
|
594
|
+
// The ONE call site that awaits the backend's load (the sensitivity probe for
|
|
595
|
+
// "the file fallback must LOAD persisted state before any read" keys on it, and a
|
|
596
|
+
// duplicated copy of it would make that probe ambiguous).
|
|
597
|
+
async function awaitBackendLoad() {
|
|
598
|
+
if (backend.kind === 'file' && typeof backend.load === 'function') await backend.load()
|
|
599
|
+
return true
|
|
600
|
+
}
|
|
510
601
|
// Every entry point that READS state must await this first. Without it the file
|
|
511
602
|
// backend's `mem` is still the empty initial state, so a fresh process would report
|
|
512
603
|
// an empty roster and `resume` would refuse with "no active member to resume" —
|
|
513
604
|
// i.e. the fallback would silently lose the whole institute across a restart.
|
|
514
605
|
async function ready() {
|
|
515
606
|
if (!backend) installBackend()
|
|
516
|
-
|
|
517
|
-
|
|
607
|
+
return await awaitBackendLoad()
|
|
608
|
+
}
|
|
609
|
+
// Load the CURRENT state path before its first read. `state()` is synchronous by design
|
|
610
|
+
// (the whole fold is read through it), so it kicks the load off eagerly; the caller that
|
|
611
|
+
// matters — `commit()` — always awaits it before the snapshot may be written.
|
|
612
|
+
function ensureLoaded() {
|
|
613
|
+
if (!backend) installBackend()
|
|
614
|
+
if (backend.kind !== 'file' || typeof backend.load !== 'function') return
|
|
615
|
+
const p = backend.load()
|
|
616
|
+
if (p && typeof p.then === 'function') p.catch(() => {})
|
|
518
617
|
}
|
|
519
618
|
function state() {
|
|
520
619
|
if (!backend) installBackend()
|
|
620
|
+
// A read must never be served from the empty initial state while the path's file has
|
|
621
|
+
// not been folded in yet (the 2.4.1 audit's H1: a read-less `state()` was half of the
|
|
622
|
+
// overwrite). `ready()`/`commit()` await the same load; here it only has to START.
|
|
623
|
+
ensureLoaded()
|
|
521
624
|
stateCache = backend.read()
|
|
522
625
|
return stateCache
|
|
523
626
|
}
|
|
@@ -525,10 +628,46 @@ export function apply(ctx) {
|
|
|
525
628
|
const s = state()
|
|
526
629
|
return s.institutes[key] || emptyInstitute(key, project, instituteName)
|
|
527
630
|
}
|
|
631
|
+
// The institute stored in ANOTHER (project, institute) state file, or undefined. Used by
|
|
632
|
+
// `configure` before it switches identity: the requested institute may already exist on
|
|
633
|
+
// disk, and adopting it is the only alternative to overwriting it (audit H1).
|
|
634
|
+
async function instituteAt(np, ni, nkey) {
|
|
635
|
+
if (!backend) installBackend()
|
|
636
|
+
const path = (vibeRoot() + '/Projects/' + np + '/Institutes/' + ni + '/State/' + ni + '.v5state.json')
|
|
637
|
+
const r = await backend.loadAt(path, nkey)
|
|
638
|
+
return r.institute
|
|
639
|
+
}
|
|
640
|
+
// A FAILED load of an EXISTING file must never be silent: it is the one state in which
|
|
641
|
+
// the backend refuses to write (audit H1). The note is buffered here and folded into the
|
|
642
|
+
// state on the next commit that IS allowed, so it can never recurse into a commit.
|
|
643
|
+
let pendingLoadNotes = []
|
|
644
|
+
function noteLoadProblem(text) {
|
|
645
|
+
const t = String(text || '').slice(0, 300)
|
|
646
|
+
if (t && pendingLoadNotes.indexOf(t) === -1) pendingLoadNotes.push(t)
|
|
647
|
+
if (pendingLoadNotes.length > 5) pendingLoadNotes = pendingLoadNotes.slice(-5)
|
|
648
|
+
}
|
|
649
|
+
function drainLoadNotes() {
|
|
650
|
+
if (!pendingLoadNotes.length || !stateCache) return
|
|
651
|
+
const list = (stateCache.diagnostics || []).concat(pendingLoadNotes.map((t) => ({ at: now(), type: 'vibe5/load', error: t })))
|
|
652
|
+
pendingLoadNotes = []
|
|
653
|
+
stateCache = Object.assign({}, stateCache, { diagnostics: list.slice(-50) })
|
|
654
|
+
}
|
|
528
655
|
async function commit(type, data) {
|
|
529
656
|
if (!backend) installBackend()
|
|
530
|
-
|
|
531
|
-
|
|
657
|
+
// Explicit call (the audit's H3): `await backend.load` awaited the METHOD OBJECT, so
|
|
658
|
+
// the only load was the one the backend happened to do inside commit.
|
|
659
|
+
await awaitBackendLoad()
|
|
660
|
+
const loaded = backend.kind === 'file' && typeof backend.loadedFor === 'function' ? backend.loadedFor() : { ok: true }
|
|
661
|
+
try {
|
|
662
|
+
stateCache = await backend.commit(type, Object.assign({ version: PROJECTION_VERSION, key }, data))
|
|
663
|
+
} catch (e) {
|
|
664
|
+
// The write was refused because the state file could not be read. Say so loudly
|
|
665
|
+
// instead of clobbering it, and keep the note for the next write that is allowed.
|
|
666
|
+
noteLoadProblem('write REFUSED (a failed state-file load must not be clobbered): ' + String((e && e.message) || e))
|
|
667
|
+
throw e
|
|
668
|
+
}
|
|
669
|
+
if (!loaded.ok) noteLoadProblem('the state file ' + String(loaded.path || '?') + ' could not be read (corrupt, unreadable or version-mismatched); it will never be overwritten')
|
|
670
|
+
drainLoadNotes()
|
|
532
671
|
const cur = stateCache.institutes[key]
|
|
533
672
|
if (cur) {
|
|
534
673
|
phase = cur.phase || phase
|
|
@@ -547,7 +686,9 @@ export function apply(ctx) {
|
|
|
547
686
|
const putMeeting = (index) => commit(EV.meeting, { index })
|
|
548
687
|
const putDebate = (index) => commit(EV.debate, { index })
|
|
549
688
|
const putVerdict = (target, record) => commit(EV.verdict, { target, record })
|
|
550
|
-
|
|
689
|
+
// The verify queue has NO whole-array setter any more: both of its mutations go through
|
|
690
|
+
// `appendToQueue`/`takeQueueHead` below, which hand a FUNCTION to the event fold so the
|
|
691
|
+
// read-modify-write is atomic (the lost-proposal race).
|
|
551
692
|
const putCounters = (counters) => commit(EV.counters, { counters })
|
|
552
693
|
const markProgress = async () => {
|
|
553
694
|
lastProgressAt = now()
|
|
@@ -572,6 +713,15 @@ export function apply(ctx) {
|
|
|
572
713
|
const a = activeMembers().find((m) => m.kind === 'academician')
|
|
573
714
|
return a ? a.id : ''
|
|
574
715
|
}
|
|
716
|
+
// `academicianId()` resolved against a roster that also counts a member which is NOT yet
|
|
717
|
+
// committed (the joiner whose charter is being built). Kept separate so the canonical
|
|
718
|
+
// resolver stays the live-roster one — the sensitivity probe for the leaderless charter
|
|
719
|
+
// mutates the `academicianId()` CALL, and that mutation must keep reaching the text.
|
|
720
|
+
function academicianIdWith(extraMember) {
|
|
721
|
+
if (!extraMember) return academicianId()
|
|
722
|
+
const a = activeMembers().concat([extraMember]).find((m) => m.kind === 'academician')
|
|
723
|
+
return a ? a.id : ''
|
|
724
|
+
}
|
|
575
725
|
|
|
576
726
|
// ---- the institute charter (public regulations) -----------------------
|
|
577
727
|
// Written into every member's `persona` at hire time. `persona` is part of the
|
|
@@ -579,8 +729,12 @@ export function apply(ctx) {
|
|
|
579
729
|
// context compaction WITHOUT being re-injected into prompts — which is what
|
|
580
730
|
// structurally removes v4's "re-anchor the rules after compaction" patch and
|
|
581
731
|
// the "[核心规则重申]+[CONTEXT COMPACT] every round" leak it caused (§24.1-③).
|
|
582
|
-
function rosterLine() {
|
|
583
|
-
|
|
732
|
+
function rosterLine(extraMember) {
|
|
733
|
+
// Written ONCE, at the member's creation (`newMember`), so the hire-time snapshot is
|
|
734
|
+
// frozen even for a member the host's live-child cap refuses and `resume` re-spawns
|
|
735
|
+
// later (audit M5). The joiner is folded in here so its own charter shows the institute
|
|
736
|
+
// WITH itself on the roster — exactly what the original build-at-spawn code produced.
|
|
737
|
+
const ms = extraMember ? activeMembers().concat([extraMember]) : activeMembers()
|
|
584
738
|
const acad = ms.filter((m) => m.kind === 'academician').map((m) => m.id)
|
|
585
739
|
const res = ms.filter((m) => m.kind === 'researcher').map((m) => m.id)
|
|
586
740
|
const tmp = ms.filter((m) => m.kind === 'temp').map((m) => m.id + '(' + (m.hiredBy || '?') + '雇)')
|
|
@@ -694,7 +848,8 @@ export function apply(ctx) {
|
|
|
694
848
|
// The leader's REAL id (or '' when the office founded a leaderless institute).
|
|
695
849
|
// Charter text must never name a leader who is not on staff: a member told to
|
|
696
850
|
// "report to the academician" when there is none has no one to report to.
|
|
697
|
-
|
|
851
|
+
// `member` itself is counted as on-staff even before it is committed (see rosterLine).
|
|
852
|
+
const acadId = academicianIdWith(member)
|
|
698
853
|
const L = []
|
|
699
854
|
// ── opening ──────────────────────────────────────────────────────────
|
|
700
855
|
if (kind === 'academician') {
|
|
@@ -761,7 +916,7 @@ export function apply(ctx) {
|
|
|
761
916
|
L.push(' · **所办(对外接口)** —— 不参与研究、不投票。代表本所与外部沟通并转达外部指令。')
|
|
762
917
|
}
|
|
763
918
|
L.push(' 你入职时的在册编制(这是一份**快照**,此后可能变化):')
|
|
764
|
-
L.push(rosterLine())
|
|
919
|
+
L.push(rosterLine(member))
|
|
765
920
|
L.push(' (权威的在册名单与法定票数 m 以每轮提示里的状态块为准;编制可能变化。)')
|
|
766
921
|
L.push('')
|
|
767
922
|
// ── 二、general rules ────────────────────────────────────────────────
|
|
@@ -1022,6 +1177,12 @@ export function apply(ctx) {
|
|
|
1022
1177
|
return { ok: o.exitCode === 0, exitCode: o.exitCode }
|
|
1023
1178
|
} catch (e) { return { ok: false, error: String((e && e.message) || e) } }
|
|
1024
1179
|
}
|
|
1180
|
+
// Directory creation must go through the INJECTED `fs` service, not the platform shell:
|
|
1181
|
+
// the shell path passes no policy, so a deployment that confines writes through
|
|
1182
|
+
// `fs` (read-only or narrowed workspace) was not confining this one (audit M8). The
|
|
1183
|
+
// local/sandboxed fs backend creates missing parent directories on write
|
|
1184
|
+
// (`writeFileAtomic` → `mkdir(dirname, {recursive:true})`), so writing a marker file per
|
|
1185
|
+
// directory is what actually creates the tree — inside the policy.
|
|
1025
1186
|
async function mkdirs() {
|
|
1026
1187
|
const base = instRoot()
|
|
1027
1188
|
const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems', 'Formal', 'Verified/Lean']
|
|
@@ -1033,10 +1194,26 @@ export function apply(ctx) {
|
|
|
1033
1194
|
for (const d of ['Progress', 'Propos', 'Methods', 'Subproblems']) dirs.push('Members/' + m.id + '/' + d)
|
|
1034
1195
|
}
|
|
1035
1196
|
const paths = dirs.map((d) => base + '/' + d).concat(globalDirs.map((d) => vibeRoot() + '/' + d))
|
|
1197
|
+
let fsCreated = 0
|
|
1198
|
+
let fsFailed = false
|
|
1199
|
+
try {
|
|
1200
|
+
if (fs && typeof fs.writeText === 'function' && typeof fs.resolve === 'function') {
|
|
1201
|
+
for (const p of paths) {
|
|
1202
|
+
const t = await fsTargetAbs(p + '/.keep')
|
|
1203
|
+
await fs.writeText(t, '', undefined, undefined, getPolicy())
|
|
1204
|
+
fsCreated += 1
|
|
1205
|
+
}
|
|
1206
|
+
} else fsFailed = true
|
|
1207
|
+
} catch (e) { fsFailed = true }
|
|
1208
|
+
if (!fsFailed && fsCreated === paths.length) return { ok: true, dirs: fsCreated, via: 'fs' }
|
|
1209
|
+
// Guarded fallback for a host whose fs backend refuses (or offers no writeText): the
|
|
1210
|
+
// platform shell still creates the tree, exactly as before this change. It is only
|
|
1211
|
+
// reached when the policy-compliant route failed, and it reports which route ran.
|
|
1036
1212
|
const script = isWindows()
|
|
1037
1213
|
? 'New-Item -Force -ItemType Directory -Path ' + paths.map((p) => psQuote(p)).join(',') + ' | Out-Null'
|
|
1038
1214
|
: 'mkdir -p ' + paths.map((p) => shQuote(p)).join(' ')
|
|
1039
|
-
|
|
1215
|
+
const r = await runShell(script)
|
|
1216
|
+
return Object.assign({ via: 'shell' }, r)
|
|
1040
1217
|
}
|
|
1041
1218
|
|
|
1042
1219
|
// ---- communication (durable per-recipient mailbox) --------------------
|
|
@@ -1164,7 +1341,12 @@ export function apply(ctx) {
|
|
|
1164
1341
|
hiredBy: (opts && opts.hiredBy) || '',
|
|
1165
1342
|
term: (opts && opts.term) || '',
|
|
1166
1343
|
provider: String((opts && opts.provider) || 'spawn'),
|
|
1167
|
-
|
|
1344
|
+
// The charter is FROZEN at hire time — it says "你入职时的在册编制(这是一份**快照**)".
|
|
1345
|
+
// It therefore has to be CAPTURED here, not at the first successful spawn: a member
|
|
1346
|
+
// the host's live-child cap refused is recorded `failed` and only spawned later by
|
|
1347
|
+
// `resume`, and rebuilding the charter then would describe the resume-time roster
|
|
1348
|
+
// (`memberPersona(member)` would also silently drop any `staffPersona` change).
|
|
1349
|
+
persona: memberPersona({ id, kind, direction: String((opts && opts.direction) || ''), hiredBy: (opts && opts.hiredBy) || '' }),
|
|
1168
1350
|
error: '',
|
|
1169
1351
|
createdAt: now(),
|
|
1170
1352
|
dismissedAt: 0,
|
|
@@ -1268,7 +1450,8 @@ export function apply(ctx) {
|
|
|
1268
1450
|
roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
|
|
1269
1451
|
// The charter is FROZEN at hire time (it is the durable "seal" record and it says
|
|
1270
1452
|
// "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
|
|
1271
|
-
// hire-time snapshot into a resume-time one and make the sentence untrue.
|
|
1453
|
+
// hire-time snapshot into a resume-time one and make the sentence untrue. `newMember`
|
|
1454
|
+
// captures it, so every member has one by the time it can be spawned.
|
|
1272
1455
|
const persona = member.persona || memberPersona(member)
|
|
1273
1456
|
member.persona = persona
|
|
1274
1457
|
const prompt = initialPrompt(member, initialTask, mode)
|
|
@@ -1394,8 +1577,7 @@ export function apply(ctx) {
|
|
|
1394
1577
|
// 'office' rather than '' so the framing records a real, non-member sender.
|
|
1395
1578
|
try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }
|
|
1396
1579
|
// An unrelated child agent: report no member. Member-only writes refuse with
|
|
1397
|
-
// V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary)
|
|
1398
|
-
// office-capable tools treat '' as the office.
|
|
1580
|
+
// V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary).
|
|
1399
1581
|
return ''
|
|
1400
1582
|
}
|
|
1401
1583
|
// No session id at all (a synthetic exec context). Only here may we fall back to
|
|
@@ -1403,6 +1585,22 @@ export function apply(ctx) {
|
|
|
1403
1585
|
const c = currentMember
|
|
1404
1586
|
return (c && memberById(c)) ? c : ''
|
|
1405
1587
|
}
|
|
1588
|
+
// Is this caller PROVABLY the office — the session root itself? `memberIdOfAgent` answers
|
|
1589
|
+
// 'office' both for the root AND for any descendant of the root that is not a member child
|
|
1590
|
+
// (a dismissed member's stale child, a nested helper agent), so it cannot tell them apart;
|
|
1591
|
+
// only the direct root test can (audit L6). `isOffice(id)` no longer treats '' as the office
|
|
1592
|
+
// either, so the office-capable handlers resolve their caller HERE and refuse on ''.
|
|
1593
|
+
function isProvablyOffice(agent) {
|
|
1594
|
+
try { return !!agent && sessionIdOf(agent) !== undefined && rootOf(agent) === agent } catch (e) { return false }
|
|
1595
|
+
}
|
|
1596
|
+
// The caller of an OFFICE-ONLY tool: a real member id, or 'office' when the caller is
|
|
1597
|
+
// provably the session root, or '' when nobody can be identified. The tool handler must
|
|
1598
|
+
// refuse on '' (V5_MEMBER_NOT_FOUND) instead of impersonating the office (audit L6).
|
|
1599
|
+
function officeCaller(agent) {
|
|
1600
|
+
const id = memberIdOfAgent(agent)
|
|
1601
|
+
if (id && id !== 'office') return id
|
|
1602
|
+
return isProvablyOffice(agent) ? 'office' : ''
|
|
1603
|
+
}
|
|
1406
1604
|
|
|
1407
1605
|
// ---- prompts ----------------------------------------------------------
|
|
1408
1606
|
// The charter lives in `persona` (permanent). Every ROUND prompt therefore
|
|
@@ -1438,9 +1636,9 @@ export function apply(ctx) {
|
|
|
1438
1636
|
L.push(' "task_update": {"task_id":"t-3","expected_revision":2,"action":"complete|release|reopen|edit|set_dependencies|delete"},')
|
|
1439
1637
|
L.push(' "input": "本轮会议/辩论的发言正文(会议轮用;也可直接用 say)",')
|
|
1440
1638
|
if (formalOn()) {
|
|
1441
|
-
L.push(' "formal": {"target":"p-x","decision":"used|blocked|defect","file":"Formal/p-x.lean","note":"
|
|
1639
|
+
L.push(' "formal": {"target":"p-x","decision":"used|blocked|defect","file":"Formal/p-x.lean","note":"难度判断(used)/ 阻塞原因(blocked)/ 具体偏差(defect)"}')
|
|
1442
1640
|
L.push(' ← Lean 形式化:' + (formalMode() === 'require'
|
|
1443
|
-
? '**强制**:定论前必须有「Lean
|
|
1641
|
+
? '**强制**:定论前必须有「Lean 已通过」或显式的阻塞记录(decision=\'blocked\' + note;used 的 note 不算),否则本轮裁定记为未定论,'
|
|
1444
1642
|
: '**鼓励**:按实现难度自行决定;做了就归档,没做就写明难度判断,') + '详见提示词里的【Lean 形式化验证】段')
|
|
1445
1643
|
}
|
|
1446
1644
|
L.push(' "reject_assign": {"task_id":"t-3","why":"你对这项分派的异议理由"} ← 有异议时填;理由会被广播给')
|
|
@@ -1595,7 +1793,7 @@ export function apply(ctx) {
|
|
|
1595
1793
|
// The formal field belongs in the VOTING contract too: voters are exactly the agents
|
|
1596
1794
|
// who must either formalize the object or record why they judged it infeasible.
|
|
1597
1795
|
L.push('若你本轮做了形式化或给出难度判断,请一并加上:')
|
|
1598
|
-
L.push('{"formal":{"target":"' + vs.target + '","decision":"used|blocked|defect","file":"Formal/' + vs.target + '.lean","note":"
|
|
1796
|
+
L.push('{"formal":{"target":"' + vs.target + '","decision":"used|blocked|defect","file":"Formal/' + vs.target + '.lean","note":"难度判断(used)/ 阻塞原因(blocked)/ 具体偏差(defect)"}}')
|
|
1599
1797
|
}
|
|
1600
1798
|
return L.join('\n')
|
|
1601
1799
|
}
|
|
@@ -1834,24 +2032,33 @@ export function apply(ctx) {
|
|
|
1834
2032
|
updatedAt: now(),
|
|
1835
2033
|
}))
|
|
1836
2034
|
}
|
|
1837
|
-
// Withdrawing an archived proof
|
|
1838
|
-
//
|
|
1839
|
-
//
|
|
2035
|
+
// Withdrawing an archived proof means the file must stop looking like the object's proof.
|
|
2036
|
+
// The POLICY-COMPLIANT route is an overwrite through the injected `fs` service (it carries
|
|
2037
|
+
// `getPolicy()`); the platform shell is only a fallback, because `subprocess.spawn` has no
|
|
2038
|
+
// policy slot and the shell path therefore sat outside a confining deployment's fs policy
|
|
2039
|
+
// (audit M8). A stub host whose shell exits 0 without deleting anything is also handled by
|
|
2040
|
+
// the read-back check.
|
|
1840
2041
|
async function removeArchivedProof(rel) {
|
|
1841
2042
|
const abs = leanAbsPath(rel)
|
|
1842
2043
|
if (abs === null) return false
|
|
2044
|
+
const withdrawn = '-- 已撤回(' + fmtTime() + '):该形式化被认定与命题原文不一致。\n'
|
|
2045
|
+
+ '-- 原代码保留在工作文件 Formal/' + String(rel).split('/').pop() + ';修正并重新跑通后重新归档。\n'
|
|
2046
|
+
// 1) the fs service (policy-confined) — an overwritten file is no longer a proof.
|
|
2047
|
+
if ((await writeTextAbs(abs, withdrawn)) !== undefined) {
|
|
2048
|
+
const nowText = await readTextAbs(abs)
|
|
2049
|
+
if (nowText !== undefined && nowText.indexOf('已撤回') !== -1) return true
|
|
2050
|
+
}
|
|
2051
|
+
// 2) guarded fallback: a real DELETE through the platform shell, when it exists.
|
|
1843
2052
|
const sub = subprocessOf()
|
|
1844
2053
|
if (sub !== undefined && typeof sub.spawn === 'function') {
|
|
1845
2054
|
const script = isWindows()
|
|
1846
2055
|
? 'Remove-Item -LiteralPath ' + psQuote(abs) + ' -Force -ErrorAction SilentlyContinue'
|
|
1847
2056
|
: 'rm -f ' + shQuote(abs)
|
|
1848
2057
|
try { await runShell(script) } catch (e) { /* fall through to the overwrite */ }
|
|
1849
|
-
// A shell that exits 0 without removing anything (a stub host, a permissions quirk)
|
|
1850
|
-
// must not leave a withdrawn proof where everyone looks for proofs: verify, fall back.
|
|
1851
2058
|
if (await readTextAbs(abs) === undefined) return true
|
|
1852
2059
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
2060
|
+
// 3) last resort: the overwrite again, in case the delete recreated/left the file.
|
|
2061
|
+
return (await writeTextAbs(abs, withdrawn)) !== undefined
|
|
1855
2062
|
}
|
|
1856
2063
|
// A formalization that says something else than the proposition is NOT a refutation:
|
|
1857
2064
|
// withdraw the proof instead of letting the group conclude 假 (contract §4.1).
|
|
@@ -1876,9 +2083,12 @@ export function apply(ctx) {
|
|
|
1876
2083
|
// overwrite also fails. The record is downgraded either way, so say it out loud:
|
|
1877
2084
|
// otherwise the stale file stays at the exact path everyone looks for proofs, and
|
|
1878
2085
|
// nothing in any prompt or index would disclose that the withdrawal was incomplete.
|
|
2086
|
+
// `removed` means the archive no longer reads as a proof — either deleted, or
|
|
2087
|
+
// OVERWRITTEN with the withdrawal notice (the fs-service route). Name both honestly:
|
|
2088
|
+
// "deleted" would be false whenever the policy-confined overwrite was the route.
|
|
1879
2089
|
const stillThere = removed ? [] : ['|⚠ 归档证明 ', proofRel, ' 未能撤回(宿主删除与覆盖均失败);记录已降级,请不要把它当作该对象的证明。']
|
|
1880
2090
|
await saveChatLine('【形式化】' + (memberId || '成员') + ' 认定 ' + t + ' 的形式化**不忠实**:' + why
|
|
1881
|
-
+ ' —— 已撤回「已通过」状态' + (removed ? '
|
|
2091
|
+
+ ' —— 已撤回「已通过」状态' + (removed ? ',并已使 Verified/Lean/ 中的归档证明失效(删除或覆盖为撤回声明)' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
|
|
1882
2092
|
return { ok: true, target: t, status: 'attempted', removed }
|
|
1883
2093
|
}
|
|
1884
2094
|
function formalPromptBlock(target) {
|
|
@@ -1920,14 +2130,16 @@ export function apply(ctx) {
|
|
|
1920
2130
|
L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义 / 对象 / 条件 / 假设 / 结论是否与')
|
|
1921
2131
|
L.push(' 命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
|
|
1922
2132
|
if (mode === 'require') {
|
|
1923
|
-
L.push(' · **本模式要求**:必须产出 Lean
|
|
1924
|
-
L.push(
|
|
2133
|
+
L.push(' · **本模式要求**:必须产出 Lean 形式化,或**必须**给出显式的阻塞原因——用')
|
|
2134
|
+
L.push(" vibe_v5_lean_archive kind='blocked' note=… 记录,或用回执 formal:{decision:'blocked', note:…}。")
|
|
2135
|
+
L.push(' 只有 decision=\'blocked\' 的 note 会写成阻塞记录;decision=\'used\' 的 note 只是难度判断,')
|
|
2136
|
+
L.push(' **不会**打开定论门禁。两者都没有时,本次裁定不会生效,')
|
|
1925
2137
|
L.push(' 会被记为未定论(原因 formal-required)并进入「形式化待办」。')
|
|
1926
2138
|
} else {
|
|
1927
2139
|
L.push(" · 若你判断不值得或无法形式化,可以不做,但请在回执的 formal 字段写明难度判断(decision='blocked' 时必须写明 note)。")
|
|
1928
2140
|
}
|
|
1929
2141
|
L.push(' · 归档可复用定义/引理前先跑通(vibe_v5_lean_archive run=true 或先 vibe_v5_lean_run);跑不通不要入库。')
|
|
1930
|
-
L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS
|
|
2142
|
+
L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或根本没有 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并把"宿主无 Lean 工具链"写成**阻塞记录**(vibe_v5_lean_archive kind=\'blocked\' note=… 或回执 formal:{decision:\'blocked\', note:…})——这算显式阻塞原因,定论门禁可以据此放行。')
|
|
1931
2143
|
}
|
|
1932
2144
|
return L.join('\n')
|
|
1933
2145
|
}
|
|
@@ -2023,8 +2235,8 @@ export function apply(ctx) {
|
|
|
2023
2235
|
await writeFormalIndex()
|
|
2024
2236
|
}
|
|
2025
2237
|
if (run.ok) {
|
|
2026
|
-
await saveChatLine('【形式化】' +
|
|
2027
|
-
+ '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target + '
|
|
2238
|
+
await saveChatLine('【形式化】' + memberId + ' 运行 Lean 通过:' + run.file
|
|
2239
|
+
+ '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target + ' 记为已尝试(若此前已通过/已阻塞则保留原状态)' : ''))
|
|
2028
2240
|
}
|
|
2029
2241
|
return Object.assign({ ok: !!run.ok }, run, {
|
|
2030
2242
|
hint: run.ok
|
|
@@ -2115,7 +2327,7 @@ export function apply(ctx) {
|
|
|
2115
2327
|
// (b) in the same wake as a soft-compact directive — never on every round. The
|
|
2116
2328
|
// charter itself lives in `persona` and needs no reinforcement otherwise.
|
|
2117
2329
|
const CORE_RULES = '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
|
|
2118
|
-
'任何对象要进 Verified
|
|
2330
|
+
'任何对象要进 Verified/,必须至少有 m 名有表决权者投出布尔值(恰好 1 或恰好 0)**且没有任何一张反向票**,否则留库附平均概率;' +
|
|
2119
2331
|
'你只写自己的库(Members/<你>/),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
|
|
2120
2332
|
'退出时只输出一个 JSON 对象。'
|
|
2121
2333
|
|
|
@@ -2135,12 +2347,24 @@ export function apply(ctx) {
|
|
|
2135
2347
|
}
|
|
2136
2348
|
|
|
2137
2349
|
// ---- artifact libraries (per member, append/write by the member itself) ----
|
|
2138
|
-
|
|
2350
|
+
// ONLY the literal 'office' is the office. An EMPTY id means "nobody identifiable" and is
|
|
2351
|
+
// NOT the office: `memberIdOfAgent` answers '' for an unrelated descendant of the root (a
|
|
2352
|
+
// dismissed member's stale child, a nested helper under the office root) and for a synthetic
|
|
2353
|
+
// context with no known member, so `!id || id === 'office'` let such a caller pass EVERY
|
|
2354
|
+
// office gate and sign its writes as the office (audit L6 follow-up). Office-capable tool
|
|
2355
|
+
// handlers now resolve their caller with `officeCaller` and refuse on '' before reaching
|
|
2356
|
+
// here; this predicate fails closed as the second line of defence.
|
|
2357
|
+
const isOffice = (id) => id === 'office'
|
|
2139
2358
|
const isAcademician = (id) => { const m = memberById(id); return !!m && m.kind === 'academician' }
|
|
2140
|
-
|
|
2359
|
+
// MUST be awaited by its callers: `inst()` reads `stateCache`, which `commit` only updates
|
|
2360
|
+
// once its own await resolves. Fire-and-forget here meant two cards written in one reply
|
|
2361
|
+
// (a single reply can carry `record: [ … ]`) both read the same `artifactCount` and
|
|
2362
|
+
// committed the same `n`, and the `meetingKeepEvery` auto-sync could fire twice on one
|
|
2363
|
+
// count (audit M6).
|
|
2364
|
+
async function bumpArtifacts() {
|
|
2141
2365
|
const inst0 = inst()
|
|
2142
2366
|
const n = (Number(inst0.artifactCount) || 0) + 1
|
|
2143
|
-
commit(EV.progress, { at: now(), artifactCount: n })
|
|
2367
|
+
await commit(EV.progress, { at: now(), artifactCount: n })
|
|
2144
2368
|
// Auto-sync meeting every `meetingKeepEvery` artifacts: the framework only
|
|
2145
2369
|
// CONVENES it, never assigns work. Deferred while a meeting or verification is
|
|
2146
2370
|
// already in progress so consensus is never preempted (v4 §26).
|
|
@@ -2181,7 +2405,7 @@ export function apply(ctx) {
|
|
|
2181
2405
|
'- ID: ' + id,
|
|
2182
2406
|
'- 类型: ' + (kind === 'proposition' ? '命题' : kind === 'method' ? String(args.type || '方法') : '子问题'),
|
|
2183
2407
|
'- 状态: ' + (kind === 'proposition' ? '未定论' : kind === 'method' ? '经验' : '求解中'),
|
|
2184
|
-
|
|
2408
|
+
'- 概率: ' + clamp01(args.p).toFixed(2),
|
|
2185
2409
|
'- 价值程度: ' + clamp01(args.value).toFixed(2),
|
|
2186
2410
|
'- 动机用途计划: ' + String(args.motive),
|
|
2187
2411
|
'- 记录者: ' + memberId,
|
|
@@ -2199,7 +2423,7 @@ export function apply(ctx) {
|
|
|
2199
2423
|
}
|
|
2200
2424
|
const ok = await writeTextRel(rel, head.concat(body).join('\n'))
|
|
2201
2425
|
if (!ok) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
|
|
2202
|
-
bumpArtifacts()
|
|
2426
|
+
await bumpArtifacts()
|
|
2203
2427
|
notifyActivity()
|
|
2204
2428
|
return { ok: true, id, file: rel, kind }
|
|
2205
2429
|
}
|
|
@@ -2324,6 +2548,10 @@ export function apply(ctx) {
|
|
|
2324
2548
|
return taskView(t)
|
|
2325
2549
|
}
|
|
2326
2550
|
async function taskUpdate(memberId, o) {
|
|
2551
|
+
// An unknown caller must be refused HERE as well: `owner = task.ownerId === memberId` is true
|
|
2552
|
+
// for an UNOWNED task (ownerId '') when memberId is '', so without this guard an
|
|
2553
|
+
// unidentifiable caller would count as the owner of every unclaimed task (audit L6 follow-up).
|
|
2554
|
+
if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
|
|
2327
2555
|
const args = o || {}
|
|
2328
2556
|
const id = String(args.task_id || args.taskId || '')
|
|
2329
2557
|
const task = inst().tasks.find((x) => x.id === id)
|
|
@@ -2420,6 +2648,7 @@ export function apply(ctx) {
|
|
|
2420
2648
|
// it can never make any statement true, and the assignee may object with reasons
|
|
2421
2649
|
// (the objection is broadcast, not silently swallowed).
|
|
2422
2650
|
async function taskAssign(memberId, o) {
|
|
2651
|
+
if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
|
|
2423
2652
|
if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
|
|
2424
2653
|
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks' }
|
|
2425
2654
|
}
|
|
@@ -2463,6 +2692,7 @@ export function apply(ctx) {
|
|
|
2463
2692
|
return { ok: true, task: taskView(withMeta) }
|
|
2464
2693
|
}
|
|
2465
2694
|
async function taskPrioritize(memberId, o) {
|
|
2695
|
+
if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
|
|
2466
2696
|
if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
|
|
2467
2697
|
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities' }
|
|
2468
2698
|
}
|
|
@@ -2541,7 +2771,6 @@ export function apply(ctx) {
|
|
|
2541
2771
|
}
|
|
2542
2772
|
|
|
2543
2773
|
// ---- consensus verification (m-vote boolean) --------------------------
|
|
2544
|
-
function verifyRecords() { return Object.keys(inst().verdicts) }
|
|
2545
2774
|
function currentVerify() {
|
|
2546
2775
|
const vs = inst().verdicts
|
|
2547
2776
|
for (const k of Object.keys(vs)) { if (vs[k] && !vs[k].closed) return vs[k] }
|
|
@@ -2622,6 +2851,9 @@ export function apply(ctx) {
|
|
|
2622
2851
|
// in the mean. Any opposing assertion BLOCKS the verdict, so a minority can never
|
|
2623
2852
|
// be out-voted by abstention.
|
|
2624
2853
|
function judgeVerdict(vs) {
|
|
2854
|
+
// E = the LIVE voter set (roster, recomputed on every call). A ballot cast by someone
|
|
2855
|
+
// who is no longer in E — a dismissed member whose entry survived in `vs.votes` — is
|
|
2856
|
+
// NOT counted, in either mode: `Verified/` may only ever be reached by current voters.
|
|
2625
2857
|
const E = voters().map((m) => m.id)
|
|
2626
2858
|
const P = E.length
|
|
2627
2859
|
const m = quorumM()
|
|
@@ -2656,6 +2888,35 @@ export function apply(ctx) {
|
|
|
2656
2888
|
if (bFalse >= m && bTrue === 0) return Object.assign(base, { outcome: 'false', reason: bFalse + ' >= m=' + m + ', all assert false' })
|
|
2657
2889
|
return Object.assign(base, { outcome: 'undecided', reason: 'quorum not met' })
|
|
2658
2890
|
}
|
|
2891
|
+
// ONE atomic append to the durable verify queue. The duplicate check and the append both run
|
|
2892
|
+
// INSIDE the event fold, so two proposals made in the same tick can no longer both read the
|
|
2893
|
+
// same array and overwrite each other's entry (the audit's lost-proposal race: a
|
|
2894
|
+
// `Promise.all` of two `vibe_v5_propose_verify` calls kept only the second, while both calls
|
|
2895
|
+
// reported `ok:true` and the first target existed in neither the queue nor `verdicts`).
|
|
2896
|
+
async function appendToQueue(entry) {
|
|
2897
|
+
let result = { appended: false, length: 0 }
|
|
2898
|
+
await commit(EV.queue, { queue: (cur) => {
|
|
2899
|
+
const list = Array.isArray(cur) ? cur : []
|
|
2900
|
+
if (list.some((p) => p && p.target === entry.target)) { result = { appended: false, length: list.length }; return list }
|
|
2901
|
+
const next = list.concat([entry])
|
|
2902
|
+
result = { appended: true, length: next.length }
|
|
2903
|
+
return next
|
|
2904
|
+
} })
|
|
2905
|
+
return result
|
|
2906
|
+
}
|
|
2907
|
+
// Remove and return the head in ONE atomic commit: an append landing in between can never be
|
|
2908
|
+
// lost by it, and a removed entry can never be resurrected by an append that read the queue
|
|
2909
|
+
// before the removal.
|
|
2910
|
+
async function takeQueueHead() {
|
|
2911
|
+
let head = null
|
|
2912
|
+
await commit(EV.queue, { queue: (cur) => {
|
|
2913
|
+
const list = Array.isArray(cur) ? cur : []
|
|
2914
|
+
if (!list.length) return list
|
|
2915
|
+
head = list[0]
|
|
2916
|
+
return list.slice(1)
|
|
2917
|
+
} })
|
|
2918
|
+
return head
|
|
2919
|
+
}
|
|
2659
2920
|
// Queue a proposal UNLESS the same object was just closed as 真/假 (a dedup window
|
|
2660
2921
|
// prevents several members independently proposing the same object in one tick
|
|
2661
2922
|
// from running it end-to-end twice — v4 §26 test9).
|
|
@@ -2666,18 +2927,20 @@ export function apply(ctx) {
|
|
|
2666
2927
|
if (recent !== undefined && (now() - recent) < recoverStallMs()) {
|
|
2667
2928
|
return { ok: true, deduped: true, message: t + ' 刚刚定论,忽略重复提议' }
|
|
2668
2929
|
}
|
|
2669
|
-
const
|
|
2670
|
-
if (
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2930
|
+
const cv = currentVerify()
|
|
2931
|
+
if (cv && cv.target === t) return { ok: true, deduped: true, message: t + ' 正在验证中' }
|
|
2932
|
+
const added = await appendToQueue({
|
|
2933
|
+
target: t, kind: kind || guessTargetKind(t),
|
|
2934
|
+
proposer: isOffice(proposer) ? 'office' : proposer, reason: String(reason || ''), at: now(),
|
|
2935
|
+
})
|
|
2936
|
+
if (!added.appended) return { ok: true, deduped: true, message: t + ' 已在验证队列中' }
|
|
2674
2937
|
notifyActivity()
|
|
2675
2938
|
// Start it NOW rather than hoping a scheduling pass reaches it. A pass may already
|
|
2676
2939
|
// be in flight and PAST its arming point, in which case a bare `scheduleNext()`
|
|
2677
2940
|
// only sets the trampoline flag and the proposal waits for the next iteration.
|
|
2678
2941
|
await armNextVerify()
|
|
2679
2942
|
if (!hasVerifyInFlight()) await scheduleNext()
|
|
2680
|
-
return { ok: true, queued: t, pendingVerifyCount:
|
|
2943
|
+
return { ok: true, queued: t, pendingVerifyCount: added.length, started: hasVerifyInFlight() }
|
|
2681
2944
|
}
|
|
2682
2945
|
async function beginVerify(proposal) {
|
|
2683
2946
|
dbg.begin += 1
|
|
@@ -2865,8 +3128,7 @@ export function apply(ctx) {
|
|
|
2865
3128
|
// `maybeQueueVerify` calls this directly (bypassing schedulePass, whose meeting
|
|
2866
3129
|
// check is what used to hide the asymmetry). The meeting's watchdog clock would
|
|
2867
3130
|
// then be starved while two coordination processes competed for the same members.
|
|
2868
|
-
// The proposal stays in the queue; schedulePass reaches this again once the
|
|
2869
|
-
// meeting is over.
|
|
3131
|
+
// The proposal stays in the queue; schedulePass reaches this again once the meeting is over.
|
|
2870
3132
|
if (meeting) return
|
|
2871
3133
|
// Only one begin may be in flight. Without this, two callers (a scheduling pass
|
|
2872
3134
|
// and a fresh proposal) could both pass the `currentVerify()` check before either
|
|
@@ -2875,10 +3137,21 @@ export function apply(ctx) {
|
|
|
2875
3137
|
if (currentVerify()) return
|
|
2876
3138
|
beginLock = true
|
|
2877
3139
|
try {
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
3140
|
+
while (true) {
|
|
3141
|
+
// NEVER remove a proposal we are not about to run: the entry leaves the durable
|
|
3142
|
+
// queue only in the same step that starts it. The old code shifted it out and then
|
|
3143
|
+
// returned because a meeting was live, so the proposal existed in neither the queue
|
|
3144
|
+
// nor `verdicts` until some later pass happened to re-arm it (audit M2). Re-check
|
|
3145
|
+
// the exclusion guards here too: `await putQueue` can let a meeting open.
|
|
3146
|
+
const q = inst().queue.slice()
|
|
3147
|
+
if (!q.length) return
|
|
3148
|
+
if (meeting || currentVerify()) return
|
|
3149
|
+
// Take the head in ONE atomic commit (`takeQueueHead` re-reads the queue inside the
|
|
3150
|
+
// fold). The exclusion guards above are deliberately NOT re-checked inside it: a third
|
|
3151
|
+
// copy of them would make the sensitivity probes that remove the two real copies inert
|
|
3152
|
+
// (they would still hold, and the suite would look like a blind spot).
|
|
3153
|
+
const p = await takeQueueHead()
|
|
3154
|
+
if (!p) return
|
|
2882
3155
|
const recent = verifiedRecently.get(p.target)
|
|
2883
3156
|
if (recent !== undefined && (now() - recent) < recoverStallMs()) continue
|
|
2884
3157
|
const vs = await beginVerify(p)
|
|
@@ -3273,6 +3546,16 @@ export function apply(ctx) {
|
|
|
3273
3546
|
roundsSinceCompact.delete(id)
|
|
3274
3547
|
contextPct.delete(id)
|
|
3275
3548
|
seeds.delete(id)
|
|
3549
|
+
// A dismissed member is no longer a voter: drop its ballot from every OPEN verdict, so
|
|
3550
|
+
// a verdict that has not closed yet can never be carried by a former member's vote
|
|
3551
|
+
// (audit H2). `judgeVerdict` reads only the live roster anyway; this keeps the durable
|
|
3552
|
+
// record honest too. Already-closed verdicts are history and are left untouched.
|
|
3553
|
+
const openVerdicts = Object.values(inst().verdicts).filter((v) => v && !v.closed && v.votes && v.votes[id] !== undefined)
|
|
3554
|
+
for (const v of openVerdicts) {
|
|
3555
|
+
const votes = Object.assign({}, v.votes)
|
|
3556
|
+
delete votes[id]
|
|
3557
|
+
await putVerdict(v.target, Object.assign({}, v, { votes }))
|
|
3558
|
+
}
|
|
3276
3559
|
if (meeting) {
|
|
3277
3560
|
delete meeting.inputs[id]
|
|
3278
3561
|
delete meeting.extras[id]
|
|
@@ -3293,6 +3576,7 @@ export function apply(ctx) {
|
|
|
3293
3576
|
return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
|
|
3294
3577
|
}
|
|
3295
3578
|
async function nudge(callerId, o) {
|
|
3579
|
+
if (!callerId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
|
|
3296
3580
|
if (!isOffice(callerId) && !(isAcademician(callerId) && params.academicianLeads)) {
|
|
3297
3581
|
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can nudge members' }
|
|
3298
3582
|
}
|
|
@@ -3372,9 +3656,13 @@ export function apply(ctx) {
|
|
|
3372
3656
|
dbg.passes += 1
|
|
3373
3657
|
clearHeartbeat()
|
|
3374
3658
|
syncParamsFromState()
|
|
3375
|
-
|
|
3659
|
+
// A verification that is ALREADY in flight is served first: the two coordination
|
|
3660
|
+
// processes are mutually exclusive by construction, so the order only decides which
|
|
3661
|
+
// of two impossible states wins — but checking `meeting` first meant a meeting that
|
|
3662
|
+
// can never close starved the verification's own watchdog forever (audit M3).
|
|
3376
3663
|
const vs = currentVerify()
|
|
3377
3664
|
if (vs) { await continueVerifyRound(vs); return }
|
|
3665
|
+
if (meeting) { await continueMeetingRound(); return }
|
|
3378
3666
|
if (!currentVerify()) {
|
|
3379
3667
|
await armNextVerify()
|
|
3380
3668
|
if (hasVerifyInFlight()) return
|
|
@@ -3752,6 +4040,14 @@ export function apply(ctx) {
|
|
|
3752
4040
|
if (out.leanTimeoutMs !== undefined) out.leanTimeoutMs = Math.max(1000, out.leanTimeoutMs)
|
|
3753
4041
|
if (out.researcherCount !== undefined && out.researcherCount < 0) out.researcherCount = 0
|
|
3754
4042
|
if (out.quorumCap !== undefined && out.quorumCap < 1) out.quorumCap = 1
|
|
4043
|
+
// NOT every non-positive number is harmless (audit L3). `compactThreshold <= 0` makes
|
|
4044
|
+
// EVERY round look over the threshold (a permanent compaction directive), so it falls back
|
|
4045
|
+
// to the default like a bad duration; `chatDigestMax < 1` would empty the digest bucket and
|
|
4046
|
+
// `verdictMaxRounds < 1` silently collapsed every debate to a single round, so both floor
|
|
4047
|
+
// at the smallest value that still means what the name says.
|
|
4048
|
+
if (out.compactThreshold !== undefined && !(out.compactThreshold > 0)) delete out.compactThreshold
|
|
4049
|
+
if (out.chatDigestMax !== undefined && out.chatDigestMax < 1) out.chatDigestMax = 1
|
|
4050
|
+
if (out.verdictMaxRounds !== undefined && out.verdictMaxRounds < 1) out.verdictMaxRounds = 1
|
|
3755
4051
|
return out
|
|
3756
4052
|
}
|
|
3757
4053
|
async function setParams(input) {
|
|
@@ -3786,6 +4082,12 @@ export function apply(ctx) {
|
|
|
3786
4082
|
}
|
|
3787
4083
|
async function configure(args) {
|
|
3788
4084
|
const a = args || {}
|
|
4085
|
+
// READ THE FILE FOR THE PATH THIS SESSION WAS ALREADY POINTED AT BEFORE ANY WORK.
|
|
4086
|
+
// configure ends in a whole-snapshot commit; without this load the commit is exactly
|
|
4087
|
+
// the write that destroyed a previously-run institute's state file (audit H1: the
|
|
4088
|
+
// session's first tool call — typically `vibe_v5_status` — latched the DEFAULT path,
|
|
4089
|
+
// after which `configure {institute:'alpha'}` never read alpha's file).
|
|
4090
|
+
await ready()
|
|
3789
4091
|
// configure is the PRE-START setup tool. Switching project/institute while a run
|
|
3790
4092
|
// is live would split its state across two trees: the members' libraries and
|
|
3791
4093
|
// briefs point at the OLD root while every later write goes to the NEW one.
|
|
@@ -3801,14 +4103,25 @@ export function apply(ctx) {
|
|
|
3801
4103
|
const np = patch.project !== undefined ? patch.project : project
|
|
3802
4104
|
const ni = patch.institute !== undefined ? patch.institute : instituteName
|
|
3803
4105
|
const nkey = np + '::' + ni
|
|
3804
|
-
if (nkey !== key
|
|
3805
|
-
key
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
4106
|
+
if (nkey !== key) {
|
|
4107
|
+
// The requested key lives in a DIFFERENT state file. Read that file first: a
|
|
4108
|
+
// previously-run institute must be adopted, never overwritten with an empty one.
|
|
4109
|
+
const stored = await instituteAt(np, ni, nkey)
|
|
4110
|
+
if (stored && Array.isArray(stored.members) && stored.members.length) {
|
|
4111
|
+
key = nkey
|
|
4112
|
+
project = np
|
|
4113
|
+
instituteName = ni
|
|
4114
|
+
patch.project = np
|
|
4115
|
+
patch.institute = ni
|
|
4116
|
+
} else if (!inst().members.length) {
|
|
4117
|
+
key = nkey
|
|
4118
|
+
project = np
|
|
4119
|
+
instituteName = ni
|
|
4120
|
+
patch.project = np
|
|
4121
|
+
patch.institute = ni
|
|
4122
|
+
} else {
|
|
4123
|
+
return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
|
|
4124
|
+
}
|
|
3812
4125
|
}
|
|
3813
4126
|
}
|
|
3814
4127
|
patch.phase = 'idle'
|
|
@@ -3980,6 +4293,8 @@ export function apply(ctx) {
|
|
|
3980
4293
|
if (m.childId) continue
|
|
3981
4294
|
try {
|
|
3982
4295
|
const seedText = (await readTextRel('Members/' + m.id + '/Progress/progress.md')) || ''
|
|
4296
|
+
// `mode='resume'` is what makes this a RESUME rather than an instruction: the prompt
|
|
4297
|
+
// prints the seed as "恢复说明" (your own log, restored), not as a task to execute.
|
|
3983
4298
|
await spawnMember(m, seedText ? seedText.slice(-4000) : '(你的 Progress/ 还是空的——请先把当前状态补写进去。)', 'resume')
|
|
3984
4299
|
respawned += 1
|
|
3985
4300
|
} catch (e) {
|
|
@@ -3995,6 +4310,11 @@ export function apply(ctx) {
|
|
|
3995
4310
|
autoDone = false
|
|
3996
4311
|
phase = 'active'
|
|
3997
4312
|
await patchInstitute({ phase: 'active', lastProgressAt: now() })
|
|
4313
|
+
// The mirrors must be refreshed on THIS path too (audit M9): resume is the one place
|
|
4314
|
+
// where members' phase/rounds/employer actually change under them, and a staffing table
|
|
4315
|
+
// that contradicts the authoritative roster is the opposite of a mirror's purpose.
|
|
4316
|
+
await writeRosterMirror()
|
|
4317
|
+
await writeTaskboardMirror()
|
|
3998
4318
|
await saveChatLine('【恢复】研究所继续推进(重建成员 ' + respawned + ' 名)。')
|
|
3999
4319
|
notifyActivity()
|
|
4000
4320
|
await scheduleNext()
|
|
@@ -4043,6 +4363,9 @@ export function apply(ctx) {
|
|
|
4043
4363
|
institute: instituteName, project, key, phase,
|
|
4044
4364
|
running, autoDone, runId: s.runId,
|
|
4045
4365
|
backend: backend ? backend.kind : 'uninitialized',
|
|
4366
|
+
// Skipped/malformed events AND state-file load problems. Without this the two
|
|
4367
|
+
// failure modes that silently drop state were invisible in the operator's view.
|
|
4368
|
+
diagnostics: s.diagnostics || [],
|
|
4046
4369
|
debug: Object.assign({ scheduling, reschedule }, dbg),
|
|
4047
4370
|
quorum: { m: quorumM(), mode: params.quorumMode, voters: voters().map((m) => m.id), voterCount: voterCount() },
|
|
4048
4371
|
members: s.members.map((m) => ({
|
|
@@ -4121,6 +4444,11 @@ export function apply(ctx) {
|
|
|
4121
4444
|
if (pendingMeeting) L.push('- 暂存会议:' + pendingMeeting.agenda)
|
|
4122
4445
|
L.push('- 历史会议:' + s.meetings.length + ' 次|辩论录:' + s.debates.length + ' 份')
|
|
4123
4446
|
L.push('- 解决票:' + (solveVotes.size ? Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、') : '(无)'))
|
|
4447
|
+
if ((s.diagnostics || []).length) {
|
|
4448
|
+
L.push('')
|
|
4449
|
+
L.push('## ⚠ 状态诊断(被跳过的事件 / 状态文件读取问题)')
|
|
4450
|
+
for (const d of s.diagnostics.slice(-10)) L.push('- ' + fmtTime(d.at) + '|' + d.type + '|' + d.error)
|
|
4451
|
+
}
|
|
4124
4452
|
L.push('')
|
|
4125
4453
|
L.push('## Lean 形式化')
|
|
4126
4454
|
if (!formalOn()) L.push('- 未启用(`formalVerify` = off;可用 vibe_v5_set 切到 encourage / require)')
|
|
@@ -4199,7 +4527,7 @@ export function apply(ctx) {
|
|
|
4199
4527
|
// consensus / meetings
|
|
4200
4528
|
maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
|
|
4201
4529
|
// authorization helpers (used by tool handlers)
|
|
4202
|
-
memberIdOfAgent, isOffice, isAcademician, memberById, activeMembers,
|
|
4530
|
+
memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
|
|
4203
4531
|
}
|
|
4204
4532
|
}
|
|
4205
4533
|
|
|
@@ -4227,16 +4555,51 @@ export function apply(ctx) {
|
|
|
4227
4555
|
}
|
|
4228
4556
|
const S = { type: 'string' }, N = { type: 'number' }, I = { type: 'integer' }, B = { type: 'boolean' }
|
|
4229
4557
|
const SA = { type: 'array', items: { type: 'string' } }
|
|
4558
|
+
/**
|
|
4559
|
+
* Resolve the caller of a WRITING tool through `officeCaller` and refuse when nobody can be
|
|
4560
|
+
* identified. `memberIdOfAgent` answers '' for a caller that is neither a member child nor the
|
|
4561
|
+
* session root itself (a dismissed member's stale child, a nested helper under the office root,
|
|
4562
|
+
* a synthetic context with no known member), and the office gates used to treat '' as the office
|
|
4563
|
+
* (`isOffice`), so such a caller could sign assignments/priorities/tasks as 'office' (audit L6
|
|
4564
|
+
* follow-up). A real member id and the provable session root ('office') still pass unchanged.
|
|
4565
|
+
*/
|
|
4566
|
+
function withCaller(s, x, what, fn) {
|
|
4567
|
+
const caller = s.officeCaller(x)
|
|
4568
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: ' + what + ' needs a resolved member id or the office (the session root)' }
|
|
4569
|
+
return fn(caller)
|
|
4570
|
+
}
|
|
4571
|
+
/**
|
|
4572
|
+
* Resolve the caller of an OFFICE-ONLY control and refuse anything that is not the PROVABLE
|
|
4573
|
+
* session root. `officeCaller` answers 'office' for that root, a member id for a member child, and
|
|
4574
|
+
* '' for everyone else — an unidentifiable descendant of the root (a nested helper, a dismissed
|
|
4575
|
+
* member's stale child) or a context with no session. A member child and the office share ONE
|
|
4576
|
+
* institute session, so without this every lifecycle control (configure/start/resume/pause/stop/
|
|
4577
|
+
* set) could be driven by a member: it could stop the institute or rewrite quorum/params as the
|
|
4578
|
+
* office (audit L6 follow-up). ABSENCE of a caller is not the office either — that is exactly the
|
|
4579
|
+
* shape an unidentifiable caller has, and the tool wrapper already refuses an agent-less call
|
|
4580
|
+
* ('no session') before this point, so nothing here re-admits one.
|
|
4581
|
+
*/
|
|
4582
|
+
function withOffice(s, x, what, fn) {
|
|
4583
|
+
const caller = s.officeCaller(x)
|
|
4584
|
+
if (caller !== 'office') {
|
|
4585
|
+
return {
|
|
4586
|
+
ok: false, code: 'V5_NOT_OFFICE',
|
|
4587
|
+
message: 'only the office (the PROVABLE session root) may ' + what +
|
|
4588
|
+
(caller ? '; the caller is member ' + caller : '; no caller could be identified'),
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
return fn()
|
|
4592
|
+
}
|
|
4230
4593
|
|
|
4231
4594
|
// ── office / host controls ────────────────────────────────────────────────
|
|
4232
|
-
registerTool('vibe_v5_configure', 'Create/configure the research institute (project, institute name, problem, params) WITHOUT starting it. Use this FIRST, then vibe_v5_start.', objParams({ project: S, institute: S, problem: S, params: { type: 'object' } }), (s, a) => s.configure(a))
|
|
4233
|
-
registerTool('vibe_v5_start', 'Found the institute: create the academician + N permanent researchers and begin. They brainstorm independently, then self-organize (the academician organizes and assigns; the framework only facilitates).', objParams({ problem: S, researcherCount: I, academician: B, params: { type: 'object' }, seedDirections: SA }), (s, a) => s.doStart(a))
|
|
4234
|
-
registerTool('vibe_v5_resume', 'Resume a persisted institute: reconcile members against their durable sessions, rebuild any missing one from its Progress/, refresh consensus watchdogs, and restart scheduling.', objParams({}), (s) => s.resume())
|
|
4235
|
-
registerTool('vibe_v5_pause', 'Pause the institute (in-flight turns finish; no new wakes until resume).', objParams({}), (s) => s.setPause())
|
|
4236
|
-
registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s) => s.initStop())
|
|
4595
|
+
registerTool('vibe_v5_configure', 'Create/configure the research institute (project, institute name, problem, params) WITHOUT starting it. Use this FIRST, then vibe_v5_start.', objParams({ project: S, institute: S, problem: S, params: { type: 'object' } }), (s, a, x) => withOffice(s, x, 'configure the institute', () => s.configure(a)))
|
|
4596
|
+
registerTool('vibe_v5_start', 'Found the institute: create the academician + N permanent researchers and begin. They brainstorm independently, then self-organize (the academician organizes and assigns; the framework only facilitates).', objParams({ problem: S, researcherCount: I, academician: B, params: { type: 'object' }, seedDirections: SA }), (s, a, x) => withOffice(s, x, 'found the institute', () => s.doStart(a)))
|
|
4597
|
+
registerTool('vibe_v5_resume', 'Resume a persisted institute: reconcile members against their durable sessions, rebuild any missing one from its Progress/, refresh consensus watchdogs, and restart scheduling.', objParams({}), (s, a, x) => withOffice(s, x, 'resume the institute', () => s.resume()))
|
|
4598
|
+
registerTool('vibe_v5_pause', 'Pause the institute (in-flight turns finish; no new wakes until resume).', objParams({}), (s, a, x) => withOffice(s, x, 'pause the institute', () => s.setPause()))
|
|
4599
|
+
registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s, a, x) => withOffice(s, x, 'stop the institute', () => s.initStop()))
|
|
4237
4600
|
registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
|
|
4238
4601
|
registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
|
|
4239
|
-
registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record).', objParams({
|
|
4602
|
+
registerTool('vibe_v5_set', 'Tune institute parameters (persisted in State/<institute>.v5state.json). provider/model override staff LLM routes (empty = inherit the office route). toolAllow/toolDeny restrict PERMANENT staff tools; tempToolAllow/tempToolDeny restrict temp workers. quorumCap sets m = min(quorumCap, voters); an m-vote passes only when at least m Boolean votes (exactly 1 or exactly 0) exist AND no voter returns an opposing Boolean, so with the default roster it degenerates to unanimity among the current voters; only current voters count (a dismissed member\'s earlier ballot is dropped). quorumMode "m-unanimous" (v5) or "all-unanimous" (v4 legacy). formalVerify: "off" (default, no extra requirement) | "encourage" (agents decide by implementation difficulty whether to formalize in Lean; a passing Lean run turns the vote into a FIDELITY review of the Lean statements) | "require" (same, plus a gate: a true/false verdict is withheld as undecided until the object is Lean-passed or has an explicit reasoned blocker record).', objParams({
|
|
4240
4603
|
academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
|
|
4241
4604
|
quorumCap: I, quorumMode: S, verdictMaxRounds: I,
|
|
4242
4605
|
maxTempPerMember: I, maxTempTotal: I,
|
|
@@ -4245,20 +4608,49 @@ export function apply(ctx) {
|
|
|
4245
4608
|
formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] },
|
|
4246
4609
|
leanCommand: S, leanArgs: SA, leanTimeoutMs: I,
|
|
4247
4610
|
provider: S, model: S, staffPersona: S, toolAllow: SA, toolDeny: SA, tempToolAllow: SA, tempToolDeny: SA,
|
|
4248
|
-
}), (s, a) => s.setParams(a))
|
|
4249
|
-
registerTool('vibe_v5_message', 'Relay a message from the office/human into the institute (to a member id, to "all", or to "voters").', objParams({ to: S, content: S }, ['to', 'content']), (s, a) => {
|
|
4611
|
+
}), (s, a, x) => withOffice(s, x, 'tune institute parameters', () => s.setParams(a)))
|
|
4612
|
+
registerTool('vibe_v5_message', 'Relay a message from the office/human into the institute (to a member id, to "all", or to "voters").', objParams({ to: S, content: S }, ['to', 'content']), (s, a, x) => {
|
|
4613
|
+
// The relay is SIGNED as the office (`say('office', …)`), so only the PROVABLE session root may
|
|
4614
|
+
// send it. `memberIdOfAgent` answers '' for an unidentifiable descendant of the root (a nested
|
|
4615
|
+
// helper, a dismissed member's stale child) and for a synthetic context, and a member child is
|
|
4616
|
+
// not the office either — without this check ANY caller's text was delivered as the office
|
|
4617
|
+
// (audit L6 follow-up; the tool's own description says "from the office/human").
|
|
4618
|
+
const caller = s.officeCaller(x)
|
|
4619
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: relaying a message as the office needs the office (the session root)' }
|
|
4620
|
+
if (caller !== 'office') return { ok: false, code: 'V5_NOT_OFFICE', message: 'vibe_v5_message relays the OFFICE voice; a member speaks with vibe_v5_say' }
|
|
4250
4621
|
const to = String(a.to || 'all')
|
|
4251
4622
|
return s.say('office', { to, text: String(a.content), kind: to === 'all' || to === 'voters' ? 'office' : 'dm' })
|
|
4252
4623
|
})
|
|
4253
|
-
registerTool('vibe_v5_meeting', 'Convene a meeting (office/academician) or propose one (any other member — relayed to the academician/office). Parked automatically while a verification is in flight.', objParams({ agenda: S, kind: { type: 'string', enum: ['sync', 'division', 'verify-request', 'solve-vote'] }, target: S }, ['agenda']), (s, a, x) =>
|
|
4624
|
+
registerTool('vibe_v5_meeting', 'Convene a meeting (office/academician) or propose one (any other member — relayed to the academician/office). Parked automatically while a verification is in flight. kind picks the meeting type: "sync" = routine coordination (default), "division" = split the work, "verify-request" = ask the group to verify an object, "solve-vote" = put "is the original problem solved?" to a vote; target names the object for verify-request/solve-vote.', objParams({ agenda: S, kind: { type: 'string', enum: ['sync', 'division', 'verify-request', 'solve-vote'] }, target: S }, ['agenda']), (s, a, x) => {
|
|
4625
|
+
const caller = s.officeCaller(x)
|
|
4626
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may convene/propose a meeting' }
|
|
4627
|
+
return s.startMeeting(caller, a)
|
|
4628
|
+
})
|
|
4254
4629
|
registerTool('vibe_v5_members', 'List the institute roster (office, employer, phase, direction, rounds).', objParams({}), (s) => ({ ok: true, members: s.status().members, quorum: s.status().quorum }))
|
|
4255
|
-
registerTool('vibe_v5_hire', 'Hire one temp worker (office; the academician and every permanent researcher may also hire their own). Requires purpose and initial_task.', objParams({ purpose: S, initial_task: S, direction: S, term: S, to: S }, ['purpose', 'initial_task']), (s, a, x) => {
|
|
4256
|
-
const caller = a.to ? String(a.to) :
|
|
4630
|
+
registerTool('vibe_v5_hire', 'Hire one temp worker (office; the academician and every permanent researcher may also hire their own). Requires purpose and initial_task. term (optional) is the task deadline/period text the worker is told at onboarding; direction (optional) states what it should work on.', objParams({ purpose: S, initial_task: S, direction: S, term: S, to: S }, ['purpose', 'initial_task']), (s, a, x) => {
|
|
4631
|
+
const caller = a.to ? String(a.to) : s.officeCaller(x)
|
|
4632
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may hire' }
|
|
4257
4633
|
return s.hire(caller, a)
|
|
4258
4634
|
})
|
|
4259
|
-
registerTool('vibe_v5_fire', 'Dismiss a temp worker for real: cancel its turn, release its resident child, reclaim its tasks, drop its mail, and mark it dismissed (its id is never reused).', objParams({ id: S, reason: S }, ['id']), (s, a, x) =>
|
|
4260
|
-
|
|
4261
|
-
|
|
4635
|
+
registerTool('vibe_v5_fire', 'Dismiss a temp worker for real: cancel its turn, release its resident child, reclaim its tasks, drop its mail, and mark it dismissed (its id is never reused).', objParams({ id: S, reason: S }, ['id']), (s, a, x) => {
|
|
4636
|
+
const caller = s.officeCaller(x)
|
|
4637
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or the employer may fire' }
|
|
4638
|
+
return s.fire(caller, a)
|
|
4639
|
+
})
|
|
4640
|
+
registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => {
|
|
4641
|
+
const caller = s.officeCaller(x)
|
|
4642
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: adding a permanent researcher is the office\'s decision (the session root)' }
|
|
4643
|
+
return s.addResearcher(caller, a && a.direction)
|
|
4644
|
+
})
|
|
4645
|
+
// "Office only" (the tool's own description) must be ENFORCED, not just documented: the handler
|
|
4646
|
+
// used to call `removeResearcher` with no caller at all, so any member child — or an
|
|
4647
|
+
// unidentifiable descendant of the root — could dismiss a PERMANENT researcher (audit L6
|
|
4648
|
+
// follow-up; the academician can only PROPOSE additions/removals).
|
|
4649
|
+
registerTool('vibe_v5_remove_researcher', 'Office only: dismiss a PERMANENT researcher.', objParams({ id: S }, ['id']), (s, a, x) => withCaller(s, x, 'removing a permanent researcher', (caller) => (
|
|
4650
|
+
s.isOffice(caller)
|
|
4651
|
+
? s.removeResearcher(a.id)
|
|
4652
|
+
: { ok: false, code: 'V5_NOT_OFFICE', message: 'only the office (the session root) may dismiss a permanent researcher' }
|
|
4653
|
+
)))
|
|
4262
4654
|
|
|
4263
4655
|
// ── member-facing controls ────────────────────────────────────────────────
|
|
4264
4656
|
registerTool('vibe_v5_say', '(member) Speak in the group chat (omit "to"), send a private message ("to":"r-2"), or address only the voters ("to":"voters").', objParams({ text: S, to: S }, ['text']), (s, a, x) => {
|
|
@@ -4282,12 +4674,12 @@ export function apply(ctx) {
|
|
|
4282
4674
|
registerTool('vibe_v5_record_method', '(member) Record a theory/method/tool in your library. REQUIRES value, motive and p.', objParams({ id: S, title: S, type: S, content: S, notation: S, value: N, motive: S, p: N }, ['content', 'value', 'motive', 'p']), (s, a, x) => s.recordCard(s.memberIdOfAgent(x), 'method', a))
|
|
4283
4675
|
registerTool('vibe_v5_record_subproblem', '(member) Record a sub-problem in your library. REQUIRES value, motive and p.', objParams({ id: S, title: S, statement: S, value: N, motive: S, p: N }, ['statement', 'value', 'motive', 'p']), (s, a, x) => s.recordCard(s.memberIdOfAgent(x), 'subproblem', a))
|
|
4284
4676
|
registerTool('vibe_v5_read_library', '(member) Read anyone\'s library (read-only): their progress and recorded cards. Omit member to read everyone.', objParams({ member: S, kind: S, id: S }), (s, a) => s.readLibrary(a))
|
|
4285
|
-
registerTool('vibe_v5_propose_verify', '(member) Propose an object for consensus verification. Any member may propose; only voting members decide.', objParams({ target: S, kind: S, reason: S }, ['target']), (s, a, x) => s.maybeQueueVerify(a.target, a.kind,
|
|
4677
|
+
registerTool('vibe_v5_propose_verify', '(member) Propose an object for consensus verification. Any member may propose; only voting members decide.', objParams({ target: S, kind: S, reason: S }, ['target']), (s, a, x) => withCaller(s, x, 'a verification proposal', (caller) => s.maybeQueueVerify(a.target, a.kind, caller, a.reason)))
|
|
4286
4678
|
registerTool('vibe_v5_verdict', '(member) Cast your boolean verdict on the object under verification. verdict is [0,1]: exactly 1 = assert true, exactly 0 = assert false, anything in between = abstention (not counted toward m, counted in the mean).', objParams({ target: S, verdict: N, reason: S }, ['verdict']), (s, a, x) => s.castVerdict(s.memberIdOfAgent(x), a.target, a.verdict, a.reason))
|
|
4287
|
-
registerTool('vibe_v5_task_create', '(member) Open a task on the shared board (subject, description, optional blockers, advisory write scopes, priority).', objParams({ subject: S, description: S, blocked_by: SA, write_scopes: SA, priority: I }, ['subject']), (s, a, x) => s
|
|
4679
|
+
registerTool('vibe_v5_task_create', '(member) Open a task on the shared board (subject, description, optional blockers, advisory write scopes, priority).', objParams({ subject: S, description: S, blocked_by: SA, write_scopes: SA, priority: I }, ['subject']), (s, a, x) => withCaller(s, x, 'creating a task', (caller) => s.taskCreate(caller, a)))
|
|
4288
4680
|
registerTool('vibe_v5_task_list', '(member) List shared tasks with readiness, owner, revision, blockers and write-scope warnings.', objParams({ status: S, owner: S, ready: B }), (s, a) => ({ ok: true, tasks: s.taskList(a) }))
|
|
4289
4681
|
registerTool('vibe_v5_task_get', '(member) Read one task\'s latest value BEFORE changing it (the revision is the CAS precondition).', objParams({ task_id: S }, ['task_id']), (s, a) => ({ ok: true, task: s.getTask(a.task_id) }))
|
|
4290
|
-
registerTool('vibe_v5_task_update', '(member) Compare-and-set a task action: claim|release|edit|set_dependencies|complete|reopen|reassign|delete. Pass expected_revision from task_get/task_list; a stale revision is refused.', objParams({ task_id: S, expected_revision: I, action: S, subject: S, description: S, blocked_by: SA, write_scopes: SA, owner: S }, ['task_id', 'expected_revision', 'action']), (s, a, x) => s
|
|
4682
|
+
registerTool('vibe_v5_task_update', '(member) Compare-and-set a task action: claim|release|edit|set_dependencies|complete|reopen|reassign|delete. Pass expected_revision from task_get/task_list; a stale revision is refused.', objParams({ task_id: S, expected_revision: I, action: S, subject: S, description: S, blocked_by: SA, write_scopes: SA, owner: S }, ['task_id', 'expected_revision', 'action']), (s, a, x) => withCaller(s, x, 'a task mutation', (caller) => s.taskUpdate(caller, a)))
|
|
4291
4683
|
|
|
4292
4684
|
// ── the academician's organizational tools ────────────────────────────────
|
|
4293
4685
|
registerTool('vibe_v5_overview', '(academician) Institute-wide view: roster, task board, every member\'s Progress tail, recent chat, and stall warnings. Use it instead of guessing.', objParams({}), async (s) => {
|
|
@@ -4329,17 +4721,17 @@ export function apply(ctx) {
|
|
|
4329
4721
|
parts.push('- 距上次实质进展:' + Math.round(idleFor / 1000) + ' 秒')
|
|
4330
4722
|
return { ok: true, overview: parts.join('\n') }
|
|
4331
4723
|
})
|
|
4332
|
-
registerTool('vibe_v5_assign', '(academician) ASSIGN work: create or pick a task and give it to a specific member (including temp workers), stating WHY and the acceptance criteria. The assignee executes by default and may object with reasons (which are broadcast).', objParams({ task_id: S, subject: S, description: S, to: S, why: S, acceptance: S, priority: I, write_scopes: SA }, ['to', 'why', 'acceptance']), (s, a, x) => s
|
|
4333
|
-
registerTool('vibe_v5_prioritize', '(academician) Set institute-wide priorities: an ordered list of {task_id, priority} plus WHY. This orders work only — it never changes what is true.', objParams({ order: { type: 'array', items: { type: 'object' } }, why: S }), (s, a, x) => s
|
|
4334
|
-
registerTool('vibe_v5_nudge', '(academician) Supervise: wake one member with a stated reason and a concrete suggested next step. Prefer a specific next step over a bare "hurry up".', objParams({ to: S, why: S, next_step: S }, ['to', 'why']), (s, a, x) => s
|
|
4724
|
+
registerTool('vibe_v5_assign', '(academician) ASSIGN work: create or pick a task and give it to a specific member (including temp workers), stating WHY and the acceptance criteria. The assignee executes by default and may object with reasons (which are broadcast).', objParams({ task_id: S, subject: S, description: S, to: S, why: S, acceptance: S, priority: I, write_scopes: SA }, ['to', 'why', 'acceptance']), (s, a, x) => withCaller(s, x, 'an assignment', (caller) => s.taskAssign(caller, a)))
|
|
4725
|
+
registerTool('vibe_v5_prioritize', '(academician) Set institute-wide priorities: an ordered list of {task_id, priority} plus WHY. This orders work only — it never changes what is true.', objParams({ order: { type: 'array', items: { type: 'object' } }, why: S }), (s, a, x) => withCaller(s, x, 'setting priorities', (caller) => s.taskPrioritize(caller, a)))
|
|
4726
|
+
registerTool('vibe_v5_nudge', '(academician) Supervise: wake one member with a stated reason and a concrete suggested next step. Prefer a specific next step over a bare "hurry up".', objParams({ to: S, why: S, next_step: S }, ['to', 'why']), (s, a, x) => withCaller(s, x, 'a nudge', (caller) => s.nudge(caller, a)))
|
|
4335
4727
|
|
|
4336
4728
|
// ── Lean formal verification (docs/formal-verification.md) ────────────────
|
|
4337
4729
|
// These three tools are registered UNCONDITIONALLY: tool registration is static (a
|
|
4338
4730
|
// dynamic registration would depend on a runtime knob and break the effect discipline),
|
|
4339
4731
|
// while the MODE only decides whether the framework TELLS members about them. In 'off'
|
|
4340
4732
|
// mode they still work if a human or agent calls them deliberately.
|
|
4341
|
-
registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT, and a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => s
|
|
4342
|
-
registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => s
|
|
4733
|
+
registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT, and a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
|
|
4734
|
+
registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
|
|
4343
4735
|
registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
|
|
4344
4736
|
const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
|
|
4345
4737
|
const st = s.status()
|
|
@@ -4359,8 +4751,20 @@ export function apply(ctx) {
|
|
|
4359
4751
|
input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set]' },
|
|
4360
4752
|
handler: async function (inv) {
|
|
4361
4753
|
const s = getSession(inv && inv.agent)
|
|
4362
|
-
if (!s) return { kind: '
|
|
4754
|
+
if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
|
|
4363
4755
|
await s.ready()
|
|
4756
|
+
// The `/v5` line is the OFFICE/human control surface (README: the institute-office row) and
|
|
4757
|
+
// EVERY subcommand below acts AS the office (`say('office', …)`, `hire('office', …)`,
|
|
4758
|
+
// `removeResearcher`, `setParams`, `initStop` …). It used to accept ANY caller whose session
|
|
4759
|
+
// maps to the institute — a member child maps to the same office session — so the caller is
|
|
4760
|
+
// resolved here too and refused unless it is the PROVABLE session root (audit L6 follow-up:
|
|
4761
|
+
// the command surface had no caller check at all). Read-only subcommands are gated with the
|
|
4762
|
+
// rest: a member reads state with vibe_v5_status / vibe_v5_report / vibe_v5_members.
|
|
4763
|
+
const caller = s.officeCaller(inv && inv.agent)
|
|
4764
|
+
if (caller !== 'office') {
|
|
4765
|
+
const refused = { ok: false, code: 'V5_NOT_OFFICE', message: 'the /v5 control line belongs to the office (the session root); members use the vibe_v5_* tools' }
|
|
4766
|
+
return { kind: 'error', text: JSON.stringify(refused, null, 2) }
|
|
4767
|
+
}
|
|
4364
4768
|
const line = String(inv && inv.rawInput ? inv.rawInput : '').trim()
|
|
4365
4769
|
const parts = line.split(/\s+/)
|
|
4366
4770
|
const cmd = parts[0] || ''
|
|
@@ -4391,7 +4795,11 @@ export function apply(ctx) {
|
|
|
4391
4795
|
}
|
|
4392
4796
|
r = await s.setParams(upd)
|
|
4393
4797
|
} else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set' }
|
|
4394
|
-
|
|
4798
|
+
// A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
|
|
4799
|
+
// CommandResult union distinguishes success from error, and returning 'success' made a rejected
|
|
4800
|
+
// invocation look identical to a successful one (same fix as v2/v3).
|
|
4801
|
+
const failed = r !== null && typeof r === 'object' && r.ok === false
|
|
4802
|
+
return { kind: failed ? 'error' : 'success', text: JSON.stringify(r, null, 2) }
|
|
4395
4803
|
},
|
|
4396
4804
|
}))
|
|
4397
4805
|
|