dsh-vibe-math 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.en.md +14 -14
  2. package/README.md +15 -14
  3. package/cordis.patch.yml +33 -10
  4. package/docs/AUDIT-CHECKLIST.md +25 -6
  5. package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
  6. package/docs/release-notes/RELEASE-NOTES-2.5.0.md +173 -0
  7. package/docs/test-timing.md +73 -40
  8. package/installer.js +225 -45
  9. package/package.json +3 -2
  10. package/preset-declaration.js +38 -7
  11. package/prompt-corpus-persona/persona-corpus.json +9 -9
  12. package/prompt-corpus-persona/persona-corpus.md +67 -21
  13. package/prompt-corpus-v2/formal-verify-v2.json +116 -92
  14. package/prompt-corpus-v2/formal-verify-v2.md +240 -93
  15. package/prompt-corpus-v3/formal-verify-v3.json +81 -81
  16. package/prompt-corpus-v3/formal-verify-v3.md +288 -1111
  17. package/prompt-corpus-v4/formal-verify-v4.json +38 -8
  18. package/prompt-corpus-v4/formal-verify-v4.md +157 -25
  19. package/prompt-corpus-v5/prompt-corpus-v5.json +17 -17
  20. package/prompt-corpus-v5/prompt-corpus-v5.md +32 -26
  21. package/tests/audit-installer-compat.test.mjs +70 -4
  22. package/tests/audit-installer-policy.test.mjs +229 -4
  23. package/tests/audit-persona-sensitivity.mjs +11 -4
  24. package/tests/audit-persona-surface.test.mjs +5 -1
  25. package/tests/audit-v5-sensitivity.mjs +47 -6
  26. package/tests/e2e-v5-round2.test.mjs +354 -0
  27. package/tests/formal-verify-v2.test.mjs +54 -2
  28. package/tests/formal-verify-v3.test.mjs +63 -2
  29. package/tests/formal-verify-v4.test.mjs +120 -4
  30. package/tests/run-tests.mjs +104 -23
  31. package/vibe-math-v2/agent.cordis.yml +6 -0
  32. package/vibe-math-v2/vibe-math-v2.js +503 -99
  33. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +20 -4
  34. package/vibe-math-v3/agent.cordis.yml +26 -10
  35. package/vibe-math-v3/vibe-math-v3.js +555 -175
  36. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +3 -3
  37. package/vibe-math-v4/agent.cordis.yml +26 -6
  38. package/vibe-math-v4/vibe-math-v4.js +404 -51
  39. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +91 -9
  40. package/vibe-math-v5/agent.cordis.yml +8 -4
  41. package/vibe-math-v5/vibe-math-v5.js +513 -105
  42. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +73 -40
@@ -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) => Object.assign({}, inst, { queue: Array.isArray(d.queue) ? d.queue : [] }))
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 the first successful load syncs back into this session —
339
- // a captured path would keep writing to the pre-load guess forever.
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 loaded = false
344
- return {
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
- if (loaded) return mem
348
- loaded = true
349
- try {
350
- const raw = await readTextAbs(pathOf())
351
- if (raw) {
352
- const parsed = JSON.parse(raw)
353
- if (parsed && parsed.v === PROJECTION_VERSION) mem = parsed
354
- }
355
- } catch (e) { /* a corrupt mirror is ignored; it is not authoritative */ }
356
- return mem
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
- await fs.writeText(t, content, undefined, undefined, getPolicy())
500
- return true
501
- } catch (e) { return false }
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
- if (backend.kind === 'file' && typeof backend.load === 'function') await backend.load()
517
- return true
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
- if (backend.kind === 'file' && backend.load) await backend.load()
531
- stateCache = await backend.commit(type, Object.assign({ version: PROJECTION_VERSION, key }, data))
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
- const putQueue = (queue) => commit(EV.queue, { queue })
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
- const ms = activeMembers()
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
- const acadId = academicianId()
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
- return await runShell(script)
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
- persona: '', // filled at spawn; kept for the durable-seal record
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), and the
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 已通过」或显式阻塞原因(note 必填),否则本轮裁定记为未定论,'
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 needs a DELETE, but the fs service exposes no unlink and
1838
- // subprocess is optional. Use the shell when it is available; otherwise overwrite the
1839
- // file with an explicit withdrawal notice, so it can never be read as the object's proof.
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
- return (await writeTextAbs(abs, '-- 已撤回(' + fmtTime() + '):该形式化被认定与命题原文不一致。\n'
1854
- + '-- 原代码保留在工作文件 Formal/' + String(rel).split('/').pop() + ';修正并重新跑通后重新归档。\n')) !== false
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 ? '并从 Verified/Lean/ 删除归档证明' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
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 形式化,或**必须**给出显式的阻塞原因(vibe_v5_lean_archive')
1924
- L.push(' kind=\'blocked\' note=… 或回执 formal.note)。若两者都没有,本次裁定不会生效,')
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)时:把代码写下来归档,并在回执的 note 里写明"宿主无 Lean 工具链"——这两种都算显式阻塞原因,定论门禁可以据此放行。')
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('【形式化】' + (memberId || 'office') + ' 运行 Lean 通过:' + run.file
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/ 必须 ≥m 名有表决权者一致给出 1 或 0,否则留库附平均概率;' +
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
- const isOffice = (id) => !id || id === 'office'
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
- function bumpArtifacts() {
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 }).catch(() => {})
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
- kind === 'proposition' ? '- 概率: ' + clamp01(args.p).toFixed(2) : '- 概率: ' + clamp01(args.p).toFixed(2),
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 q = inst().queue.slice()
2670
- if (q.some((p) => p.target === t)) return { ok: true, deduped: true, message: t + ' 已在验证队列中' }
2671
- if (currentVerify() && currentVerify().target === t) return { ok: true, deduped: true, message: t + ' 正在验证中' }
2672
- q.push({ target: t, kind: kind || guessTargetKind(t), proposer: isOffice(proposer) ? 'office' : proposer, reason: String(reason || ''), at: now() })
2673
- await putQueue(q)
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: q.length, started: hasVerifyInFlight() }
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
- const q = inst().queue.slice()
2879
- while (q.length) {
2880
- const p = q.shift()
2881
- await putQueue(q)
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
- if (meeting) { await continueMeetingRound(); return }
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 && !inst().members.length) {
3805
- key = nkey
3806
- project = np
3807
- instituteName = ni
3808
- patch.project = np
3809
- patch.institute = ni
3810
- } else if (nkey !== key) {
3811
- return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
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) => s.startMeeting(s.memberIdOfAgent(x) || 'office', a))
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) : (s.isOffice(s.memberIdOfAgent(x)) ? 'office' : s.memberIdOfAgent(x))
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) => s.fire(s.memberIdOfAgent(x) || 'office', a))
4260
- registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => s.addResearcher(s.memberIdOfAgent(x) || 'office', a && a.direction))
4261
- registerTool('vibe_v5_remove_researcher', 'Office only: dismiss a PERMANENT researcher.', objParams({ id: S }, ['id']), (s, a) => s.removeResearcher(a.id))
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, s.memberIdOfAgent(x), a.reason))
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.taskCreate(s.memberIdOfAgent(x), a))
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.taskUpdate(s.memberIdOfAgent(x), a))
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.taskAssign(s.memberIdOfAgent(x), a))
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.taskPrioritize(s.memberIdOfAgent(x), a))
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.nudge(s.memberIdOfAgent(x), a))
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.leanRunTool(s.memberIdOfAgent(x), a))
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.leanArchive(s.memberIdOfAgent(x), a))
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: 'success', text: JSON.stringify({ ok: false, error: 'no session' }) }
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
- return { kind: 'success', text: JSON.stringify(r, null, 2) }
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