dsh-vibe-math 2.4.0 → 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 +29 -29
- package/README.md +31 -30
- package/SECURITY.md +56 -0
- package/cordis.patch.yml +38 -15
- package/docs/AUDIT-CHECKLIST.md +518 -429
- package/docs/COMPAT-AUDIT-ROUND3.md +16 -1
- package/docs/release-notes/RELEASE-NOTES-2.4.1.md +129 -0
- 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 +6 -3
- package/preset-declaration.js +38 -7
- package/prompt-corpus-persona/persona-corpus.json +9 -9
- package/prompt-corpus-persona/persona-corpus.md +69 -23
- 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 +11 -5
- package/vibe-math-v2/preset.yml +1 -1
- package/vibe-math-v2/vibe-math-v2.js +607 -160
- 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 +744 -305
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -5
- package/vibe-math-v4/agent.cordis.yml +26 -6
- package/vibe-math-v4/vibe-math-v4.js +487 -89
- 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 +607 -160
- 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',
|
|
@@ -129,19 +128,13 @@ export function apply(ctx) {
|
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
// ---- utils -------------------------------------------------------------
|
|
132
|
-
const now = () => Date.now()
|
|
133
131
|
function hex(n) { let s = ''; for (let i = 0; i < n; i++) s += '0123456789abcdef'[Math.floor(Math.random() * 16)]; return s }
|
|
134
132
|
const shortId = () => hex(8)
|
|
135
|
-
function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
|
|
136
133
|
// contextPct is a PERCENT (0-100); never clamp to 0-1 or the compactThreshold
|
|
137
134
|
// comparison (e.g. 66) becomes `1.0 >= 66` and never fires (v4 §17 defect).
|
|
138
|
-
function clPct(x) { const n = Number(x); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, n)) }
|
|
139
135
|
// Positive duration with a safe fallback: a NEGATIVE/NaN duration parameter must
|
|
140
136
|
// never make a watchdog fire instantly or an idle window never elapse (v4 §30-T41).
|
|
141
|
-
function posMs(v, def) { const n = Number(v); return (Number.isFinite(n) && n > 0) ? n : (def || 120000) }
|
|
142
137
|
const textBlock = (t) => ({ type: 'text', text: String(t) })
|
|
143
|
-
function blocksToText(b) { if (!b) return ''; let o = ''; for (const x of b) { if (x && x.type === 'text' && typeof x.text === 'string') o += x.text + '\n' } return o }
|
|
144
|
-
function fmtTime(ts) { try { return new Date(ts || now()).toISOString().replace('T', ' ').slice(0, 19) } catch (e) { return String(ts || '') } }
|
|
145
138
|
function makeSignal(ms) { try { return AbortSignal.timeout(posMs(ms, 30000)) } catch (e) { return undefined } }
|
|
146
139
|
|
|
147
140
|
// Object ids (verify targets, card ids, member ids) become FILE NAMES and DIRECTORY
|
|
@@ -149,10 +142,6 @@ export function apply(ctx) {
|
|
|
149
142
|
// characters would escape the project tree. Keep every harmless character (incl.
|
|
150
143
|
// Chinese) and replace only separators/control chars; strip leading/trailing dots
|
|
151
144
|
// and dashes so the name is never '.' or '..' (v4 §30-T39).
|
|
152
|
-
function idSafe(s) {
|
|
153
|
-
const t = String(s == null ? '' : s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').replace(/-{2,}/g, '-').replace(/^[.\-]+|[.\-]+$/g, '')
|
|
154
|
-
return t
|
|
155
|
-
}
|
|
156
145
|
// Advisory write-scope normalisation, ported from DSH agent-teams: backslashes to
|
|
157
146
|
// '/', strip a leading './' and trailing '/', reject empty/absolute/drive-letter/
|
|
158
147
|
// '..'-segment scopes.
|
|
@@ -174,6 +163,9 @@ export function apply(ctx) {
|
|
|
174
163
|
// The fold is shared by BOTH persistence backends, so the state machine is
|
|
175
164
|
// defined exactly once. It must return a NEW top-level reference whenever
|
|
176
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
|
|
177
169
|
function emptyInstitute(key, project, institute) {
|
|
178
170
|
return {
|
|
179
171
|
key, project, institute,
|
|
@@ -276,10 +268,16 @@ export function apply(ctx) {
|
|
|
276
268
|
for (const id of ids) { if (!set.has(id)) { set.add(id); changed = true } }
|
|
277
269
|
if (!changed) return inst
|
|
278
270
|
// Compact: a message that has been delivered may leave `messages` too, so
|
|
279
|
-
// 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.
|
|
280
277
|
const delivered = Array.from(set)
|
|
278
|
+
const capped = delivered.length > DELIVERED_CAP ? delivered.slice(delivered.length - DELIVERED_CAP) : delivered
|
|
281
279
|
const messages = inst.messages.filter((m) => !set.has(m.id))
|
|
282
|
-
return Object.assign({}, inst, { delivered, messages })
|
|
280
|
+
return Object.assign({}, inst, { delivered: capped, messages })
|
|
283
281
|
})
|
|
284
282
|
}
|
|
285
283
|
if (t === EV.meeting) {
|
|
@@ -318,8 +316,21 @@ export function apply(ctx) {
|
|
|
318
316
|
return Object.assign({}, inst, { formal, todo })
|
|
319
317
|
})
|
|
320
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.
|
|
321
325
|
if (t === EV.queue) {
|
|
322
|
-
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
|
+
})
|
|
323
334
|
}
|
|
324
335
|
if (t === EV.counters) {
|
|
325
336
|
return withInstitute(state, key, (inst) => Object.assign({}, inst, { counters: Object.assign({}, inst.counters, d.counters || {}) }))
|
|
@@ -345,28 +356,84 @@ export function apply(ctx) {
|
|
|
345
356
|
// the whole snapshot under a per-file serialization chain; nothing is written to a host session log
|
|
346
357
|
// (see the PERSISTENCE note at the top of this file for why the projection backend was removed).
|
|
347
358
|
// `pathOf` is a FUNCTION, not a captured string: the state path depends on the
|
|
348
|
-
// project/institute, which
|
|
349
|
-
//
|
|
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.
|
|
350
369
|
function makeFileBackend(readTextAbs, writeTextAbs, pathOf) {
|
|
351
370
|
let mem = initState()
|
|
352
371
|
let chain = Promise.resolve(true)
|
|
353
|
-
let
|
|
354
|
-
|
|
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 = {
|
|
355
396
|
kind: 'file',
|
|
356
397
|
async load() {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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()
|
|
367
412
|
},
|
|
368
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
|
+
},
|
|
369
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
|
+
}
|
|
370
437
|
mem = applyV5Event(mem, { type, data })
|
|
371
438
|
const snapshot = mem
|
|
372
439
|
// Serialize writes per file and defer JSON.stringify to execution time, so a
|
|
@@ -379,6 +446,7 @@ export function apply(ctx) {
|
|
|
379
446
|
return mem
|
|
380
447
|
},
|
|
381
448
|
}
|
|
449
|
+
return backend
|
|
382
450
|
}
|
|
383
451
|
|
|
384
452
|
// ---- session registry --------------------------------------------------
|
|
@@ -506,28 +574,53 @@ export function apply(ctx) {
|
|
|
506
574
|
async function writeTextAbs(p, content) {
|
|
507
575
|
try {
|
|
508
576
|
const t = await fsTargetAbs(p)
|
|
509
|
-
|
|
510
|
-
return true
|
|
511
|
-
|
|
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 }
|
|
512
586
|
}
|
|
513
587
|
async function readTextRel(rel) { return await readTextAbs(instRoot() + '/' + rel) }
|
|
514
|
-
async function writeTextRel(rel, content) { return await writeTextAbs(instRoot() + '/' + rel, content) }
|
|
588
|
+
async function writeTextRel(rel, content) { return (await writeTextAbs(instRoot() + '/' + rel, content)) !== undefined }
|
|
515
589
|
|
|
516
590
|
function installBackend() {
|
|
517
591
|
backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
|
|
518
592
|
return backend
|
|
519
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
|
+
}
|
|
520
601
|
// Every entry point that READS state must await this first. Without it the file
|
|
521
602
|
// backend's `mem` is still the empty initial state, so a fresh process would report
|
|
522
603
|
// an empty roster and `resume` would refuse with "no active member to resume" —
|
|
523
604
|
// i.e. the fallback would silently lose the whole institute across a restart.
|
|
524
605
|
async function ready() {
|
|
525
606
|
if (!backend) installBackend()
|
|
526
|
-
|
|
527
|
-
|
|
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(() => {})
|
|
528
617
|
}
|
|
529
618
|
function state() {
|
|
530
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()
|
|
531
624
|
stateCache = backend.read()
|
|
532
625
|
return stateCache
|
|
533
626
|
}
|
|
@@ -535,10 +628,46 @@ export function apply(ctx) {
|
|
|
535
628
|
const s = state()
|
|
536
629
|
return s.institutes[key] || emptyInstitute(key, project, instituteName)
|
|
537
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
|
+
}
|
|
538
655
|
async function commit(type, data) {
|
|
539
656
|
if (!backend) installBackend()
|
|
540
|
-
|
|
541
|
-
|
|
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()
|
|
542
671
|
const cur = stateCache.institutes[key]
|
|
543
672
|
if (cur) {
|
|
544
673
|
phase = cur.phase || phase
|
|
@@ -557,7 +686,9 @@ export function apply(ctx) {
|
|
|
557
686
|
const putMeeting = (index) => commit(EV.meeting, { index })
|
|
558
687
|
const putDebate = (index) => commit(EV.debate, { index })
|
|
559
688
|
const putVerdict = (target, record) => commit(EV.verdict, { target, record })
|
|
560
|
-
|
|
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).
|
|
561
692
|
const putCounters = (counters) => commit(EV.counters, { counters })
|
|
562
693
|
const markProgress = async () => {
|
|
563
694
|
lastProgressAt = now()
|
|
@@ -582,6 +713,15 @@ export function apply(ctx) {
|
|
|
582
713
|
const a = activeMembers().find((m) => m.kind === 'academician')
|
|
583
714
|
return a ? a.id : ''
|
|
584
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
|
+
}
|
|
585
725
|
|
|
586
726
|
// ---- the institute charter (public regulations) -----------------------
|
|
587
727
|
// Written into every member's `persona` at hire time. `persona` is part of the
|
|
@@ -589,8 +729,12 @@ export function apply(ctx) {
|
|
|
589
729
|
// context compaction WITHOUT being re-injected into prompts — which is what
|
|
590
730
|
// structurally removes v4's "re-anchor the rules after compaction" patch and
|
|
591
731
|
// the "[核心规则重申]+[CONTEXT COMPACT] every round" leak it caused (§24.1-③).
|
|
592
|
-
function rosterLine() {
|
|
593
|
-
|
|
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()
|
|
594
738
|
const acad = ms.filter((m) => m.kind === 'academician').map((m) => m.id)
|
|
595
739
|
const res = ms.filter((m) => m.kind === 'researcher').map((m) => m.id)
|
|
596
740
|
const tmp = ms.filter((m) => m.kind === 'temp').map((m) => m.id + '(' + (m.hiredBy || '?') + '雇)')
|
|
@@ -704,7 +848,8 @@ export function apply(ctx) {
|
|
|
704
848
|
// The leader's REAL id (or '' when the office founded a leaderless institute).
|
|
705
849
|
// Charter text must never name a leader who is not on staff: a member told to
|
|
706
850
|
// "report to the academician" when there is none has no one to report to.
|
|
707
|
-
|
|
851
|
+
// `member` itself is counted as on-staff even before it is committed (see rosterLine).
|
|
852
|
+
const acadId = academicianIdWith(member)
|
|
708
853
|
const L = []
|
|
709
854
|
// ── opening ──────────────────────────────────────────────────────────
|
|
710
855
|
if (kind === 'academician') {
|
|
@@ -771,7 +916,7 @@ export function apply(ctx) {
|
|
|
771
916
|
L.push(' · **所办(对外接口)** —— 不参与研究、不投票。代表本所与外部沟通并转达外部指令。')
|
|
772
917
|
}
|
|
773
918
|
L.push(' 你入职时的在册编制(这是一份**快照**,此后可能变化):')
|
|
774
|
-
L.push(rosterLine())
|
|
919
|
+
L.push(rosterLine(member))
|
|
775
920
|
L.push(' (权威的在册名单与法定票数 m 以每轮提示里的状态块为准;编制可能变化。)')
|
|
776
921
|
L.push('')
|
|
777
922
|
// ── 二、general rules ────────────────────────────────────────────────
|
|
@@ -1032,6 +1177,12 @@ export function apply(ctx) {
|
|
|
1032
1177
|
return { ok: o.exitCode === 0, exitCode: o.exitCode }
|
|
1033
1178
|
} catch (e) { return { ok: false, error: String((e && e.message) || e) } }
|
|
1034
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.
|
|
1035
1186
|
async function mkdirs() {
|
|
1036
1187
|
const base = instRoot()
|
|
1037
1188
|
const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems', 'Formal', 'Verified/Lean']
|
|
@@ -1043,10 +1194,26 @@ export function apply(ctx) {
|
|
|
1043
1194
|
for (const d of ['Progress', 'Propos', 'Methods', 'Subproblems']) dirs.push('Members/' + m.id + '/' + d)
|
|
1044
1195
|
}
|
|
1045
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.
|
|
1046
1212
|
const script = isWindows()
|
|
1047
1213
|
? 'New-Item -Force -ItemType Directory -Path ' + paths.map((p) => psQuote(p)).join(',') + ' | Out-Null'
|
|
1048
1214
|
: 'mkdir -p ' + paths.map((p) => shQuote(p)).join(' ')
|
|
1049
|
-
|
|
1215
|
+
const r = await runShell(script)
|
|
1216
|
+
return Object.assign({ via: 'shell' }, r)
|
|
1050
1217
|
}
|
|
1051
1218
|
|
|
1052
1219
|
// ---- communication (durable per-recipient mailbox) --------------------
|
|
@@ -1174,7 +1341,12 @@ export function apply(ctx) {
|
|
|
1174
1341
|
hiredBy: (opts && opts.hiredBy) || '',
|
|
1175
1342
|
term: (opts && opts.term) || '',
|
|
1176
1343
|
provider: String((opts && opts.provider) || 'spawn'),
|
|
1177
|
-
|
|
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) || '' }),
|
|
1178
1350
|
error: '',
|
|
1179
1351
|
createdAt: now(),
|
|
1180
1352
|
dismissedAt: 0,
|
|
@@ -1278,7 +1450,8 @@ export function apply(ctx) {
|
|
|
1278
1450
|
roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
|
|
1279
1451
|
// The charter is FROZEN at hire time (it is the durable "seal" record and it says
|
|
1280
1452
|
// "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
|
|
1281
|
-
// 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.
|
|
1282
1455
|
const persona = member.persona || memberPersona(member)
|
|
1283
1456
|
member.persona = persona
|
|
1284
1457
|
const prompt = initialPrompt(member, initialTask, mode)
|
|
@@ -1404,8 +1577,7 @@ export function apply(ctx) {
|
|
|
1404
1577
|
// 'office' rather than '' so the framing records a real, non-member sender.
|
|
1405
1578
|
try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }
|
|
1406
1579
|
// An unrelated child agent: report no member. Member-only writes refuse with
|
|
1407
|
-
// V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary)
|
|
1408
|
-
// office-capable tools treat '' as the office.
|
|
1580
|
+
// V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary).
|
|
1409
1581
|
return ''
|
|
1410
1582
|
}
|
|
1411
1583
|
// No session id at all (a synthetic exec context). Only here may we fall back to
|
|
@@ -1413,6 +1585,22 @@ export function apply(ctx) {
|
|
|
1413
1585
|
const c = currentMember
|
|
1414
1586
|
return (c && memberById(c)) ? c : ''
|
|
1415
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
|
+
}
|
|
1416
1604
|
|
|
1417
1605
|
// ---- prompts ----------------------------------------------------------
|
|
1418
1606
|
// The charter lives in `persona` (permanent). Every ROUND prompt therefore
|
|
@@ -1448,9 +1636,9 @@ export function apply(ctx) {
|
|
|
1448
1636
|
L.push(' "task_update": {"task_id":"t-3","expected_revision":2,"action":"complete|release|reopen|edit|set_dependencies|delete"},')
|
|
1449
1637
|
L.push(' "input": "本轮会议/辩论的发言正文(会议轮用;也可直接用 say)",')
|
|
1450
1638
|
if (formalOn()) {
|
|
1451
|
-
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)"}')
|
|
1452
1640
|
L.push(' ← Lean 形式化:' + (formalMode() === 'require'
|
|
1453
|
-
? '**强制**:定论前必须有「Lean
|
|
1641
|
+
? '**强制**:定论前必须有「Lean 已通过」或显式的阻塞记录(decision=\'blocked\' + note;used 的 note 不算),否则本轮裁定记为未定论,'
|
|
1454
1642
|
: '**鼓励**:按实现难度自行决定;做了就归档,没做就写明难度判断,') + '详见提示词里的【Lean 形式化验证】段')
|
|
1455
1643
|
}
|
|
1456
1644
|
L.push(' "reject_assign": {"task_id":"t-3","why":"你对这项分派的异议理由"} ← 有异议时填;理由会被广播给')
|
|
@@ -1605,7 +1793,7 @@ export function apply(ctx) {
|
|
|
1605
1793
|
// The formal field belongs in the VOTING contract too: voters are exactly the agents
|
|
1606
1794
|
// who must either formalize the object or record why they judged it infeasible.
|
|
1607
1795
|
L.push('若你本轮做了形式化或给出难度判断,请一并加上:')
|
|
1608
|
-
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)"}}')
|
|
1609
1797
|
}
|
|
1610
1798
|
return L.join('\n')
|
|
1611
1799
|
}
|
|
@@ -1844,24 +2032,33 @@ export function apply(ctx) {
|
|
|
1844
2032
|
updatedAt: now(),
|
|
1845
2033
|
}))
|
|
1846
2034
|
}
|
|
1847
|
-
// Withdrawing an archived proof
|
|
1848
|
-
//
|
|
1849
|
-
//
|
|
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.
|
|
1850
2041
|
async function removeArchivedProof(rel) {
|
|
1851
2042
|
const abs = leanAbsPath(rel)
|
|
1852
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.
|
|
1853
2052
|
const sub = subprocessOf()
|
|
1854
2053
|
if (sub !== undefined && typeof sub.spawn === 'function') {
|
|
1855
2054
|
const script = isWindows()
|
|
1856
2055
|
? 'Remove-Item -LiteralPath ' + psQuote(abs) + ' -Force -ErrorAction SilentlyContinue'
|
|
1857
2056
|
: 'rm -f ' + shQuote(abs)
|
|
1858
2057
|
try { await runShell(script) } catch (e) { /* fall through to the overwrite */ }
|
|
1859
|
-
// A shell that exits 0 without removing anything (a stub host, a permissions quirk)
|
|
1860
|
-
// must not leave a withdrawn proof where everyone looks for proofs: verify, fall back.
|
|
1861
2058
|
if (await readTextAbs(abs) === undefined) return true
|
|
1862
2059
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
2060
|
+
// 3) last resort: the overwrite again, in case the delete recreated/left the file.
|
|
2061
|
+
return (await writeTextAbs(abs, withdrawn)) !== undefined
|
|
1865
2062
|
}
|
|
1866
2063
|
// A formalization that says something else than the proposition is NOT a refutation:
|
|
1867
2064
|
// withdraw the proof instead of letting the group conclude 假 (contract §4.1).
|
|
@@ -1886,9 +2083,12 @@ export function apply(ctx) {
|
|
|
1886
2083
|
// overwrite also fails. The record is downgraded either way, so say it out loud:
|
|
1887
2084
|
// otherwise the stale file stays at the exact path everyone looks for proofs, and
|
|
1888
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.
|
|
1889
2089
|
const stillThere = removed ? [] : ['|⚠ 归档证明 ', proofRel, ' 未能撤回(宿主删除与覆盖均失败);记录已降级,请不要把它当作该对象的证明。']
|
|
1890
2090
|
await saveChatLine('【形式化】' + (memberId || '成员') + ' 认定 ' + t + ' 的形式化**不忠实**:' + why
|
|
1891
|
-
+ ' —— 已撤回「已通过」状态' + (removed ? '
|
|
2091
|
+
+ ' —— 已撤回「已通过」状态' + (removed ? ',并已使 Verified/Lean/ 中的归档证明失效(删除或覆盖为撤回声明)' : '') + ';请修正形式化、重新跑通后再投票。' + stillThere.join(''))
|
|
1892
2092
|
return { ok: true, target: t, status: 'attempted', removed }
|
|
1893
2093
|
}
|
|
1894
2094
|
function formalPromptBlock(target) {
|
|
@@ -1930,14 +2130,16 @@ export function apply(ctx) {
|
|
|
1930
2130
|
L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义 / 对象 / 条件 / 假设 / 结论是否与')
|
|
1931
2131
|
L.push(' 命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
|
|
1932
2132
|
if (mode === 'require') {
|
|
1933
|
-
L.push(' · **本模式要求**:必须产出 Lean
|
|
1934
|
-
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(' **不会**打开定论门禁。两者都没有时,本次裁定不会生效,')
|
|
1935
2137
|
L.push(' 会被记为未定论(原因 formal-required)并进入「形式化待办」。')
|
|
1936
2138
|
} else {
|
|
1937
2139
|
L.push(" · 若你判断不值得或无法形式化,可以不做,但请在回执的 formal 字段写明难度判断(decision='blocked' 时必须写明 note)。")
|
|
1938
2140
|
}
|
|
1939
2141
|
L.push(' · 归档可复用定义/引理前先跑通(vibe_v5_lean_archive run=true 或先 vibe_v5_lean_run);跑不通不要入库。')
|
|
1940
|
-
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:…})——这算显式阻塞原因,定论门禁可以据此放行。')
|
|
1941
2143
|
}
|
|
1942
2144
|
return L.join('\n')
|
|
1943
2145
|
}
|
|
@@ -2033,8 +2235,8 @@ export function apply(ctx) {
|
|
|
2033
2235
|
await writeFormalIndex()
|
|
2034
2236
|
}
|
|
2035
2237
|
if (run.ok) {
|
|
2036
|
-
await saveChatLine('【形式化】' +
|
|
2037
|
-
+ '(' + (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 + ' 记为已尝试(若此前已通过/已阻塞则保留原状态)' : ''))
|
|
2038
2240
|
}
|
|
2039
2241
|
return Object.assign({ ok: !!run.ok }, run, {
|
|
2040
2242
|
hint: run.ok
|
|
@@ -2125,7 +2327,7 @@ export function apply(ctx) {
|
|
|
2125
2327
|
// (b) in the same wake as a soft-compact directive — never on every round. The
|
|
2126
2328
|
// charter itself lives in `persona` and needs no reinforcement otherwise.
|
|
2127
2329
|
const CORE_RULES = '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
|
|
2128
|
-
'任何对象要进 Verified
|
|
2330
|
+
'任何对象要进 Verified/,必须至少有 m 名有表决权者投出布尔值(恰好 1 或恰好 0)**且没有任何一张反向票**,否则留库附平均概率;' +
|
|
2129
2331
|
'你只写自己的库(Members/<你>/),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
|
|
2130
2332
|
'退出时只输出一个 JSON 对象。'
|
|
2131
2333
|
|
|
@@ -2145,12 +2347,24 @@ export function apply(ctx) {
|
|
|
2145
2347
|
}
|
|
2146
2348
|
|
|
2147
2349
|
// ---- artifact libraries (per member, append/write by the member itself) ----
|
|
2148
|
-
|
|
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'
|
|
2149
2358
|
const isAcademician = (id) => { const m = memberById(id); return !!m && m.kind === 'academician' }
|
|
2150
|
-
|
|
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() {
|
|
2151
2365
|
const inst0 = inst()
|
|
2152
2366
|
const n = (Number(inst0.artifactCount) || 0) + 1
|
|
2153
|
-
commit(EV.progress, { at: now(), artifactCount: n })
|
|
2367
|
+
await commit(EV.progress, { at: now(), artifactCount: n })
|
|
2154
2368
|
// Auto-sync meeting every `meetingKeepEvery` artifacts: the framework only
|
|
2155
2369
|
// CONVENES it, never assigns work. Deferred while a meeting or verification is
|
|
2156
2370
|
// already in progress so consensus is never preempted (v4 §26).
|
|
@@ -2191,7 +2405,7 @@ export function apply(ctx) {
|
|
|
2191
2405
|
'- ID: ' + id,
|
|
2192
2406
|
'- 类型: ' + (kind === 'proposition' ? '命题' : kind === 'method' ? String(args.type || '方法') : '子问题'),
|
|
2193
2407
|
'- 状态: ' + (kind === 'proposition' ? '未定论' : kind === 'method' ? '经验' : '求解中'),
|
|
2194
|
-
|
|
2408
|
+
'- 概率: ' + clamp01(args.p).toFixed(2),
|
|
2195
2409
|
'- 价值程度: ' + clamp01(args.value).toFixed(2),
|
|
2196
2410
|
'- 动机用途计划: ' + String(args.motive),
|
|
2197
2411
|
'- 记录者: ' + memberId,
|
|
@@ -2209,7 +2423,7 @@ export function apply(ctx) {
|
|
|
2209
2423
|
}
|
|
2210
2424
|
const ok = await writeTextRel(rel, head.concat(body).join('\n'))
|
|
2211
2425
|
if (!ok) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
|
|
2212
|
-
bumpArtifacts()
|
|
2426
|
+
await bumpArtifacts()
|
|
2213
2427
|
notifyActivity()
|
|
2214
2428
|
return { ok: true, id, file: rel, kind }
|
|
2215
2429
|
}
|
|
@@ -2334,6 +2548,10 @@ export function apply(ctx) {
|
|
|
2334
2548
|
return taskView(t)
|
|
2335
2549
|
}
|
|
2336
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' }
|
|
2337
2555
|
const args = o || {}
|
|
2338
2556
|
const id = String(args.task_id || args.taskId || '')
|
|
2339
2557
|
const task = inst().tasks.find((x) => x.id === id)
|
|
@@ -2430,6 +2648,7 @@ export function apply(ctx) {
|
|
|
2430
2648
|
// it can never make any statement true, and the assignee may object with reasons
|
|
2431
2649
|
// (the objection is broadcast, not silently swallowed).
|
|
2432
2650
|
async function taskAssign(memberId, o) {
|
|
2651
|
+
if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
|
|
2433
2652
|
if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
|
|
2434
2653
|
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks' }
|
|
2435
2654
|
}
|
|
@@ -2473,6 +2692,7 @@ export function apply(ctx) {
|
|
|
2473
2692
|
return { ok: true, task: taskView(withMeta) }
|
|
2474
2693
|
}
|
|
2475
2694
|
async function taskPrioritize(memberId, o) {
|
|
2695
|
+
if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
|
|
2476
2696
|
if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
|
|
2477
2697
|
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities' }
|
|
2478
2698
|
}
|
|
@@ -2551,7 +2771,6 @@ export function apply(ctx) {
|
|
|
2551
2771
|
}
|
|
2552
2772
|
|
|
2553
2773
|
// ---- consensus verification (m-vote boolean) --------------------------
|
|
2554
|
-
function verifyRecords() { return Object.keys(inst().verdicts) }
|
|
2555
2774
|
function currentVerify() {
|
|
2556
2775
|
const vs = inst().verdicts
|
|
2557
2776
|
for (const k of Object.keys(vs)) { if (vs[k] && !vs[k].closed) return vs[k] }
|
|
@@ -2632,6 +2851,9 @@ export function apply(ctx) {
|
|
|
2632
2851
|
// in the mean. Any opposing assertion BLOCKS the verdict, so a minority can never
|
|
2633
2852
|
// be out-voted by abstention.
|
|
2634
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.
|
|
2635
2857
|
const E = voters().map((m) => m.id)
|
|
2636
2858
|
const P = E.length
|
|
2637
2859
|
const m = quorumM()
|
|
@@ -2666,6 +2888,35 @@ export function apply(ctx) {
|
|
|
2666
2888
|
if (bFalse >= m && bTrue === 0) return Object.assign(base, { outcome: 'false', reason: bFalse + ' >= m=' + m + ', all assert false' })
|
|
2667
2889
|
return Object.assign(base, { outcome: 'undecided', reason: 'quorum not met' })
|
|
2668
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
|
+
}
|
|
2669
2920
|
// Queue a proposal UNLESS the same object was just closed as 真/假 (a dedup window
|
|
2670
2921
|
// prevents several members independently proposing the same object in one tick
|
|
2671
2922
|
// from running it end-to-end twice — v4 §26 test9).
|
|
@@ -2676,18 +2927,20 @@ export function apply(ctx) {
|
|
|
2676
2927
|
if (recent !== undefined && (now() - recent) < recoverStallMs()) {
|
|
2677
2928
|
return { ok: true, deduped: true, message: t + ' 刚刚定论,忽略重复提议' }
|
|
2678
2929
|
}
|
|
2679
|
-
const
|
|
2680
|
-
if (
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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 + ' 已在验证队列中' }
|
|
2684
2937
|
notifyActivity()
|
|
2685
2938
|
// Start it NOW rather than hoping a scheduling pass reaches it. A pass may already
|
|
2686
2939
|
// be in flight and PAST its arming point, in which case a bare `scheduleNext()`
|
|
2687
2940
|
// only sets the trampoline flag and the proposal waits for the next iteration.
|
|
2688
2941
|
await armNextVerify()
|
|
2689
2942
|
if (!hasVerifyInFlight()) await scheduleNext()
|
|
2690
|
-
return { ok: true, queued: t, pendingVerifyCount:
|
|
2943
|
+
return { ok: true, queued: t, pendingVerifyCount: added.length, started: hasVerifyInFlight() }
|
|
2691
2944
|
}
|
|
2692
2945
|
async function beginVerify(proposal) {
|
|
2693
2946
|
dbg.begin += 1
|
|
@@ -2875,8 +3128,7 @@ export function apply(ctx) {
|
|
|
2875
3128
|
// `maybeQueueVerify` calls this directly (bypassing schedulePass, whose meeting
|
|
2876
3129
|
// check is what used to hide the asymmetry). The meeting's watchdog clock would
|
|
2877
3130
|
// then be starved while two coordination processes competed for the same members.
|
|
2878
|
-
// The proposal stays in the queue; schedulePass reaches this again once the
|
|
2879
|
-
// meeting is over.
|
|
3131
|
+
// The proposal stays in the queue; schedulePass reaches this again once the meeting is over.
|
|
2880
3132
|
if (meeting) return
|
|
2881
3133
|
// Only one begin may be in flight. Without this, two callers (a scheduling pass
|
|
2882
3134
|
// and a fresh proposal) could both pass the `currentVerify()` check before either
|
|
@@ -2885,10 +3137,21 @@ export function apply(ctx) {
|
|
|
2885
3137
|
if (currentVerify()) return
|
|
2886
3138
|
beginLock = true
|
|
2887
3139
|
try {
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
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
|
|
2892
3155
|
const recent = verifiedRecently.get(p.target)
|
|
2893
3156
|
if (recent !== undefined && (now() - recent) < recoverStallMs()) continue
|
|
2894
3157
|
const vs = await beginVerify(p)
|
|
@@ -3283,6 +3546,16 @@ export function apply(ctx) {
|
|
|
3283
3546
|
roundsSinceCompact.delete(id)
|
|
3284
3547
|
contextPct.delete(id)
|
|
3285
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
|
+
}
|
|
3286
3559
|
if (meeting) {
|
|
3287
3560
|
delete meeting.inputs[id]
|
|
3288
3561
|
delete meeting.extras[id]
|
|
@@ -3303,6 +3576,7 @@ export function apply(ctx) {
|
|
|
3303
3576
|
return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
|
|
3304
3577
|
}
|
|
3305
3578
|
async function nudge(callerId, o) {
|
|
3579
|
+
if (!callerId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
|
|
3306
3580
|
if (!isOffice(callerId) && !(isAcademician(callerId) && params.academicianLeads)) {
|
|
3307
3581
|
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can nudge members' }
|
|
3308
3582
|
}
|
|
@@ -3382,9 +3656,13 @@ export function apply(ctx) {
|
|
|
3382
3656
|
dbg.passes += 1
|
|
3383
3657
|
clearHeartbeat()
|
|
3384
3658
|
syncParamsFromState()
|
|
3385
|
-
|
|
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).
|
|
3386
3663
|
const vs = currentVerify()
|
|
3387
3664
|
if (vs) { await continueVerifyRound(vs); return }
|
|
3665
|
+
if (meeting) { await continueMeetingRound(); return }
|
|
3388
3666
|
if (!currentVerify()) {
|
|
3389
3667
|
await armNextVerify()
|
|
3390
3668
|
if (hasVerifyInFlight()) return
|
|
@@ -3476,30 +3754,6 @@ export function apply(ctx) {
|
|
|
3476
3754
|
}
|
|
3477
3755
|
|
|
3478
3756
|
// ---- reply parsing -----------------------------------------------------
|
|
3479
|
-
function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
|
|
3480
|
-
function parseReply(text) {
|
|
3481
|
-
let obj
|
|
3482
|
-
const fence = /```(?:json)?[ \t]*([\s\S]*?)```/gi
|
|
3483
|
-
let m
|
|
3484
|
-
while ((m = fence.exec(text)) !== null) {
|
|
3485
|
-
const o = tryJson(String(m[1]).trim())
|
|
3486
|
-
if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
|
|
3487
|
-
}
|
|
3488
|
-
if (!obj) {
|
|
3489
|
-
const w = tryJson(String(text || '').trim())
|
|
3490
|
-
if (w && typeof w === 'object' && !Array.isArray(w)) obj = w
|
|
3491
|
-
}
|
|
3492
|
-
if (!obj) {
|
|
3493
|
-
// Last resort: the outermost {...} span (models sometimes wrap prose around it).
|
|
3494
|
-
const t = String(text || '')
|
|
3495
|
-
const i = t.indexOf('{'), j = t.lastIndexOf('}')
|
|
3496
|
-
if (i !== -1 && j > i) {
|
|
3497
|
-
const o = tryJson(t.slice(i, j + 1))
|
|
3498
|
-
if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
|
|
3499
|
-
}
|
|
3500
|
-
}
|
|
3501
|
-
return obj || {}
|
|
3502
|
-
}
|
|
3503
3757
|
function normVerdictNumber(v) {
|
|
3504
3758
|
// verdict is a PURE 0-1 probability. Models often send a quoted number, and a
|
|
3505
3759
|
// quoted "0.9" used to fall through to a 0.5 default and be silently recorded
|
|
@@ -3704,23 +3958,6 @@ export function apply(ctx) {
|
|
|
3704
3958
|
// Same guard as v2/v3/v4: `tools.restrict` throws for an unregistered name and the throw escapes
|
|
3705
3959
|
// child creation, so a stale `vibe_v5_set{toolAllow|toolDeny|tempToolAllow|tempToolDeny}` would
|
|
3706
3960
|
// make every member spawn fail silently. The host names the registered tools in its rejection.
|
|
3707
|
-
function registeredToolsFromError(message){
|
|
3708
|
-
const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
|
|
3709
|
-
if(!m) return undefined
|
|
3710
|
-
const names = m[1].split(',').map(function(s){ return s.trim() }).filter(Boolean)
|
|
3711
|
-
return names.length > 0 ? new Set(names) : undefined
|
|
3712
|
-
}
|
|
3713
|
-
function sanitizeToolFilter(filter, known){
|
|
3714
|
-
if(!filter || !(known instanceof Set) || known.size === 0) return filter
|
|
3715
|
-
const out = {}
|
|
3716
|
-
for(const key of ['allow','deny']){
|
|
3717
|
-
const list = filter[key]
|
|
3718
|
-
if(!Array.isArray(list)) continue
|
|
3719
|
-
const kept = list.filter(function(n){ return known.has(String(n).trim()) })
|
|
3720
|
-
if(kept.length > 0) out[key] = kept
|
|
3721
|
-
}
|
|
3722
|
-
return (out.allow || out.deny) ? out : undefined
|
|
3723
|
-
}
|
|
3724
3961
|
async function startWithToolFilter(toolFilter, makeSpec){
|
|
3725
3962
|
try {
|
|
3726
3963
|
return await subagents.startContinuable(makeSpec(toolFilter))
|
|
@@ -3803,6 +4040,14 @@ export function apply(ctx) {
|
|
|
3803
4040
|
if (out.leanTimeoutMs !== undefined) out.leanTimeoutMs = Math.max(1000, out.leanTimeoutMs)
|
|
3804
4041
|
if (out.researcherCount !== undefined && out.researcherCount < 0) out.researcherCount = 0
|
|
3805
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
|
|
3806
4051
|
return out
|
|
3807
4052
|
}
|
|
3808
4053
|
async function setParams(input) {
|
|
@@ -3837,6 +4082,12 @@ export function apply(ctx) {
|
|
|
3837
4082
|
}
|
|
3838
4083
|
async function configure(args) {
|
|
3839
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()
|
|
3840
4091
|
// configure is the PRE-START setup tool. Switching project/institute while a run
|
|
3841
4092
|
// is live would split its state across two trees: the members' libraries and
|
|
3842
4093
|
// briefs point at the OLD root while every later write goes to the NEW one.
|
|
@@ -3852,14 +4103,25 @@ export function apply(ctx) {
|
|
|
3852
4103
|
const np = patch.project !== undefined ? patch.project : project
|
|
3853
4104
|
const ni = patch.institute !== undefined ? patch.institute : instituteName
|
|
3854
4105
|
const nkey = np + '::' + ni
|
|
3855
|
-
if (nkey !== key
|
|
3856
|
-
key
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
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
|
+
}
|
|
3863
4125
|
}
|
|
3864
4126
|
}
|
|
3865
4127
|
patch.phase = 'idle'
|
|
@@ -3904,10 +4166,6 @@ export function apply(ctx) {
|
|
|
3904
4166
|
'',
|
|
3905
4167
|
].join('\n'))
|
|
3906
4168
|
}
|
|
3907
|
-
function slugify(s) {
|
|
3908
|
-
const t = String(s == null ? '' : s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, '')
|
|
3909
|
-
return t || ''
|
|
3910
|
-
}
|
|
3911
4169
|
async function doStart(args) {
|
|
3912
4170
|
const a = args || {}
|
|
3913
4171
|
if (running && !autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'already running' }
|
|
@@ -4035,6 +4293,8 @@ export function apply(ctx) {
|
|
|
4035
4293
|
if (m.childId) continue
|
|
4036
4294
|
try {
|
|
4037
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.
|
|
4038
4298
|
await spawnMember(m, seedText ? seedText.slice(-4000) : '(你的 Progress/ 还是空的——请先把当前状态补写进去。)', 'resume')
|
|
4039
4299
|
respawned += 1
|
|
4040
4300
|
} catch (e) {
|
|
@@ -4050,6 +4310,11 @@ export function apply(ctx) {
|
|
|
4050
4310
|
autoDone = false
|
|
4051
4311
|
phase = 'active'
|
|
4052
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()
|
|
4053
4318
|
await saveChatLine('【恢复】研究所继续推进(重建成员 ' + respawned + ' 名)。')
|
|
4054
4319
|
notifyActivity()
|
|
4055
4320
|
await scheduleNext()
|
|
@@ -4098,6 +4363,9 @@ export function apply(ctx) {
|
|
|
4098
4363
|
institute: instituteName, project, key, phase,
|
|
4099
4364
|
running, autoDone, runId: s.runId,
|
|
4100
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 || [],
|
|
4101
4369
|
debug: Object.assign({ scheduling, reschedule }, dbg),
|
|
4102
4370
|
quorum: { m: quorumM(), mode: params.quorumMode, voters: voters().map((m) => m.id), voterCount: voterCount() },
|
|
4103
4371
|
members: s.members.map((m) => ({
|
|
@@ -4176,6 +4444,11 @@ export function apply(ctx) {
|
|
|
4176
4444
|
if (pendingMeeting) L.push('- 暂存会议:' + pendingMeeting.agenda)
|
|
4177
4445
|
L.push('- 历史会议:' + s.meetings.length + ' 次|辩论录:' + s.debates.length + ' 份')
|
|
4178
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
|
+
}
|
|
4179
4452
|
L.push('')
|
|
4180
4453
|
L.push('## Lean 形式化')
|
|
4181
4454
|
if (!formalOn()) L.push('- 未启用(`formalVerify` = off;可用 vibe_v5_set 切到 encourage / require)')
|
|
@@ -4254,7 +4527,7 @@ export function apply(ctx) {
|
|
|
4254
4527
|
// consensus / meetings
|
|
4255
4528
|
maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
|
|
4256
4529
|
// authorization helpers (used by tool handlers)
|
|
4257
|
-
memberIdOfAgent, isOffice, isAcademician, memberById, activeMembers,
|
|
4530
|
+
memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
|
|
4258
4531
|
}
|
|
4259
4532
|
}
|
|
4260
4533
|
|
|
@@ -4282,16 +4555,51 @@ export function apply(ctx) {
|
|
|
4282
4555
|
}
|
|
4283
4556
|
const S = { type: 'string' }, N = { type: 'number' }, I = { type: 'integer' }, B = { type: 'boolean' }
|
|
4284
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
|
+
}
|
|
4285
4593
|
|
|
4286
4594
|
// ── office / host controls ────────────────────────────────────────────────
|
|
4287
|
-
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))
|
|
4288
|
-
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))
|
|
4289
|
-
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())
|
|
4290
|
-
registerTool('vibe_v5_pause', 'Pause the institute (in-flight turns finish; no new wakes until resume).', objParams({}), (s) => s.setPause())
|
|
4291
|
-
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()))
|
|
4292
4600
|
registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
|
|
4293
4601
|
registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
|
|
4294
|
-
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({
|
|
4295
4603
|
academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
|
|
4296
4604
|
quorumCap: I, quorumMode: S, verdictMaxRounds: I,
|
|
4297
4605
|
maxTempPerMember: I, maxTempTotal: I,
|
|
@@ -4300,20 +4608,49 @@ export function apply(ctx) {
|
|
|
4300
4608
|
formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] },
|
|
4301
4609
|
leanCommand: S, leanArgs: SA, leanTimeoutMs: I,
|
|
4302
4610
|
provider: S, model: S, staffPersona: S, toolAllow: SA, toolDeny: SA, tempToolAllow: SA, tempToolDeny: SA,
|
|
4303
|
-
}), (s, a) => s.setParams(a))
|
|
4304
|
-
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' }
|
|
4305
4621
|
const to = String(a.to || 'all')
|
|
4306
4622
|
return s.say('office', { to, text: String(a.content), kind: to === 'all' || to === 'voters' ? 'office' : 'dm' })
|
|
4307
4623
|
})
|
|
4308
|
-
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
|
+
})
|
|
4309
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 }))
|
|
4310
|
-
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) => {
|
|
4311
|
-
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' }
|
|
4312
4633
|
return s.hire(caller, a)
|
|
4313
4634
|
})
|
|
4314
|
-
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) =>
|
|
4315
|
-
|
|
4316
|
-
|
|
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
|
+
)))
|
|
4317
4654
|
|
|
4318
4655
|
// ── member-facing controls ────────────────────────────────────────────────
|
|
4319
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) => {
|
|
@@ -4337,12 +4674,12 @@ export function apply(ctx) {
|
|
|
4337
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))
|
|
4338
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))
|
|
4339
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))
|
|
4340
|
-
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)))
|
|
4341
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))
|
|
4342
|
-
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)))
|
|
4343
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) }))
|
|
4344
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) }))
|
|
4345
|
-
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)))
|
|
4346
4683
|
|
|
4347
4684
|
// ── the academician's organizational tools ────────────────────────────────
|
|
4348
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) => {
|
|
@@ -4384,17 +4721,17 @@ export function apply(ctx) {
|
|
|
4384
4721
|
parts.push('- 距上次实质进展:' + Math.round(idleFor / 1000) + ' 秒')
|
|
4385
4722
|
return { ok: true, overview: parts.join('\n') }
|
|
4386
4723
|
})
|
|
4387
|
-
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
|
|
4388
|
-
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
|
|
4389
|
-
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)))
|
|
4390
4727
|
|
|
4391
4728
|
// ── Lean formal verification (docs/formal-verification.md) ────────────────
|
|
4392
4729
|
// These three tools are registered UNCONDITIONALLY: tool registration is static (a
|
|
4393
4730
|
// dynamic registration would depend on a runtime knob and break the effect discipline),
|
|
4394
4731
|
// while the MODE only decides whether the framework TELLS members about them. In 'off'
|
|
4395
4732
|
// mode they still work if a human or agent calls them deliberately.
|
|
4396
|
-
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
|
|
4397
|
-
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)))
|
|
4398
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) => {
|
|
4399
4736
|
const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
|
|
4400
4737
|
const st = s.status()
|
|
@@ -4414,8 +4751,20 @@ export function apply(ctx) {
|
|
|
4414
4751
|
input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set]' },
|
|
4415
4752
|
handler: async function (inv) {
|
|
4416
4753
|
const s = getSession(inv && inv.agent)
|
|
4417
|
-
if (!s) return { kind: '
|
|
4754
|
+
if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
|
|
4418
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
|
+
}
|
|
4419
4768
|
const line = String(inv && inv.rawInput ? inv.rawInput : '').trim()
|
|
4420
4769
|
const parts = line.split(/\s+/)
|
|
4421
4770
|
const cmd = parts[0] || ''
|
|
@@ -4446,7 +4795,11 @@ export function apply(ctx) {
|
|
|
4446
4795
|
}
|
|
4447
4796
|
r = await s.setParams(upd)
|
|
4448
4797
|
} else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set' }
|
|
4449
|
-
|
|
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) }
|
|
4450
4803
|
},
|
|
4451
4804
|
}))
|
|
4452
4805
|
|
|
@@ -4486,3 +4839,97 @@ export function apply(ctx) {
|
|
|
4486
4839
|
.finally(() => s.forgetAgent(info.id))
|
|
4487
4840
|
})
|
|
4488
4841
|
}
|
|
4842
|
+
|
|
4843
|
+
const now = () => Date.now()
|
|
4844
|
+
|
|
4845
|
+
// ---- test seam: pure, stateless helpers --------------------------------
|
|
4846
|
+
// These helpers were declared inside `apply()` and are now declared at module scope, so
|
|
4847
|
+
// `apply()` closes over exactly the same function objects this export hands out. The audit
|
|
4848
|
+
// suites therefore exercise the REAL implementations by importing this module, instead of
|
|
4849
|
+
// extracting source text and compiling function bodies through the Function constructor
|
|
4850
|
+
// (dynamic code execution, rejected by the plugin-catalog security scan as
|
|
4851
|
+
// DANGEROUS_DYNAMIC_EXECUTION).
|
|
4852
|
+
//
|
|
4853
|
+
// Contract: no member may touch `ctx`, session state or mutable module state. Most are pure;
|
|
4854
|
+
// three are deliberately non-deterministic (`uuid`/`shortId` use Math.random, `fmtTime` falls back
|
|
4855
|
+
// to the clock) and `parseProgress` normalises the object it is handed in place (pre-existing).
|
|
4856
|
+
// Nothing here is used by the plugin at runtime except through `apply()`, and behaviour is
|
|
4857
|
+
// byte-identical to the previous in-`apply` declarations.
|
|
4858
|
+
export const __testHelpers = {
|
|
4859
|
+
clamp01,
|
|
4860
|
+
clPct,
|
|
4861
|
+
posMs,
|
|
4862
|
+
blocksToText,
|
|
4863
|
+
fmtTime,
|
|
4864
|
+
idSafe,
|
|
4865
|
+
slugify,
|
|
4866
|
+
tryJson,
|
|
4867
|
+
parseReply,
|
|
4868
|
+
sanitizeToolFilter,
|
|
4869
|
+
registeredToolsFromError,
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
|
|
4873
|
+
|
|
4874
|
+
function clPct(x) { const n = Number(x); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, n)) }
|
|
4875
|
+
|
|
4876
|
+
function posMs(v, def) { const n = Number(v); return (Number.isFinite(n) && n > 0) ? n : (def || 120000) }
|
|
4877
|
+
|
|
4878
|
+
function blocksToText(b) { if (!b) return ''; let o = ''; for (const x of b) { if (x && x.type === 'text' && typeof x.text === 'string') o += x.text + '\n' } return o }
|
|
4879
|
+
|
|
4880
|
+
function fmtTime(ts) { try { return new Date(ts || now()).toISOString().replace('T', ' ').slice(0, 19) } catch (e) { return String(ts || '') } }
|
|
4881
|
+
|
|
4882
|
+
function idSafe(s) {
|
|
4883
|
+
const t = String(s == null ? '' : s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').replace(/-{2,}/g, '-').replace(/^[.\-]+|[.\-]+$/g, '')
|
|
4884
|
+
return t
|
|
4885
|
+
}
|
|
4886
|
+
|
|
4887
|
+
function slugify(s) {
|
|
4888
|
+
const t = String(s == null ? '' : s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, '')
|
|
4889
|
+
return t || ''
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4892
|
+
function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
|
|
4893
|
+
|
|
4894
|
+
function parseReply(text) {
|
|
4895
|
+
let obj
|
|
4896
|
+
const fence = /```(?:json)?[ \t]*([\s\S]*?)```/gi
|
|
4897
|
+
let m
|
|
4898
|
+
while ((m = fence.exec(text)) !== null) {
|
|
4899
|
+
const o = tryJson(String(m[1]).trim())
|
|
4900
|
+
if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
|
|
4901
|
+
}
|
|
4902
|
+
if (!obj) {
|
|
4903
|
+
const w = tryJson(String(text || '').trim())
|
|
4904
|
+
if (w && typeof w === 'object' && !Array.isArray(w)) obj = w
|
|
4905
|
+
}
|
|
4906
|
+
if (!obj) {
|
|
4907
|
+
// Last resort: the outermost {...} span (models sometimes wrap prose around it).
|
|
4908
|
+
const t = String(text || '')
|
|
4909
|
+
const i = t.indexOf('{'), j = t.lastIndexOf('}')
|
|
4910
|
+
if (i !== -1 && j > i) {
|
|
4911
|
+
const o = tryJson(t.slice(i, j + 1))
|
|
4912
|
+
if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
4915
|
+
return obj || {}
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4918
|
+
function sanitizeToolFilter(filter, known){
|
|
4919
|
+
if(!filter || !(known instanceof Set) || known.size === 0) return filter
|
|
4920
|
+
const out = {}
|
|
4921
|
+
for(const key of ['allow','deny']){
|
|
4922
|
+
const list = filter[key]
|
|
4923
|
+
if(!Array.isArray(list)) continue
|
|
4924
|
+
const kept = list.filter(function(n){ return known.has(String(n).trim()) })
|
|
4925
|
+
if(kept.length > 0) out[key] = kept
|
|
4926
|
+
}
|
|
4927
|
+
return (out.allow || out.deny) ? out : undefined
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
function registeredToolsFromError(message){
|
|
4931
|
+
const m = /known global tools:\s*([^]*)$/.exec(String(message || ''))
|
|
4932
|
+
if(!m) return undefined
|
|
4933
|
+
const names = m[1].split(',').map(function(s){ return s.trim() }).filter(Boolean)
|
|
4934
|
+
return names.length > 0 ? new Set(names) : undefined
|
|
4935
|
+
}
|