dsh-vibe-math 2.0.22 → 2.2.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.
@@ -0,0 +1,3696 @@
1
+ // Vibe Math V5 — the research-institute framework.
2
+ //
3
+ // A self-organizing RESEARCH INSTITUTE that solves a research problem by talking:
4
+ // · 院士 (academician) — the leader / ORGANIZATIONAL CENTRE: institute-wide view,
5
+ // decomposes the problem into tasks and ASSIGNS them, sets priorities, chairs
6
+ // meetings, supervises progress, reallocates temp workers. No extra vote weight.
7
+ // · 常驻研究员 (permanent researchers) — hold the vote; hire/fire their own temps.
8
+ // · 临时工 (temp workers) — hired per task; may read/think/speak/own a library,
9
+ // no vote.
10
+ //
11
+ // The framework is ONLY the medium: message relay (group chat / DMs), meetings,
12
+ // a compare-and-set task DAG, per-member artifact libraries, the m-vote boolean
13
+ // consensus tally, context compaction and resume. It NEVER assigns tasks — the
14
+ // academician does, as a member who is himself bound by the same m-vote rule.
15
+ //
16
+ // WHAT A MEMBER READS IS THE PRODUCT. Every prompt builder takes the member it addresses
17
+ // and derives the [状态] block, the roster, the quorum and the charter from THAT member —
18
+ // never from a "the last member we touched" global. A prompt naming the wrong identity is
19
+ // a fatal bug no tool-level assertion can see, so:
20
+ // · `briefBlock` FAILS LOUDLY when it is not told which member it describes;
21
+ // · `spawnMember` commits the member to the ACTIVE roster BEFORE building its brief;
22
+ // · the charter is frozen at hire (it says "你入职时的在册编制") and reused on resume;
23
+ // · a rebuilt session is framed as a rebuild, never as an induction;
24
+ // · framing names the TRUE sender and kind, and framework feedback has its own sender.
25
+ // `prompt-v5-integrity.test.mjs` asserts all of that against the real prompt text and
26
+ // writes the full corpus to `prompt-corpus-v5/` for human review (实现方案.md §14.5).
27
+ //
28
+ // Durable state lives in a HOST-ONLY session projection unit (key `vibeMathV5`):
29
+ // institute events are appended to the session log, never enter the model history
30
+ // (zero context cost), are checkpointed by DSH, and are replayed on restore — which
31
+ // structurally removes v4's `State/*.json` corruption/lost-write/resume-staleness
32
+ // class of bugs. A hardened file backend is used only if the projection registry
33
+ // is genuinely absent.
34
+ //
35
+ // NOTE: must declare `inject` for every service read as a ctx property (the Guard
36
+ // rejects undeclared dependencies), and must use the `timer` Service (ctx.timeout),
37
+ // not global setTimeout/clearTimeout, which do not exist in the plugin runtime.
38
+ export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands', 'timer', 'sessions']
39
+
40
+ const PROJECTION_KEY = 'vibeMathV5'
41
+ const PROJECTION_VERSION = 1
42
+ const EV = {
43
+ institute: 'vibe5/institute',
44
+ member: 'vibe5/member',
45
+ task: 'vibe5/task',
46
+ message: 'vibe5/message',
47
+ delivered: 'vibe5/delivered',
48
+ meeting: 'vibe5/meeting',
49
+ debate: 'vibe5/debate',
50
+ verdict: 'vibe5/verdict',
51
+ queue: 'vibe5/queue',
52
+ counters: 'vibe5/counters',
53
+ progress: 'vibe5/progress',
54
+ }
55
+
56
+ // Stable error codes (ported from DSH agent-teams' typed-error discipline).
57
+ function v5err(code, message) {
58
+ const e = new Error(message || code)
59
+ e.code = code
60
+ return e
61
+ }
62
+
63
+ export function apply(ctx) {
64
+ const subagents = ctx.subagents
65
+ const agents = ctx.agents
66
+ const fs = ctx.fs
67
+ const tools = ctx.tools
68
+ const commands = ctx.commands
69
+ const store = ctx.sessions
70
+
71
+ // Optional services are resolved LAZILY at call time, never snapshotted in apply():
72
+ // a `ctx.get()` snapshot taken here is order-sensitive, so a service provided later
73
+ // would stay undefined for the whole session.
74
+ const sandboxPolicyOf = () => { try { return ctx.get('sandboxPolicy') } catch (e) { return undefined } }
75
+ const subprocessOf = () => { try { return ctx.get('subprocess') } catch (e) { return undefined } }
76
+ const compactionOf = () => { try { return ctx.get('compaction') } catch (e) { return undefined } }
77
+ const projectionsOf = () => { try { return ctx.get('sessionProjections') } catch (e) { return undefined } }
78
+
79
+ // ---- utils -------------------------------------------------------------
80
+ const now = () => Date.now()
81
+ function hex(n) { let s = ''; for (let i = 0; i < n; i++) s += '0123456789abcdef'[Math.floor(Math.random() * 16)]; return s }
82
+ const shortId = () => hex(8)
83
+ function clamp01(v) { const n = Number(v); if (!Number.isFinite(n)) return 0.5; return Math.max(0, Math.min(1, n)) }
84
+ // contextPct is a PERCENT (0-100); never clamp to 0-1 or the compactThreshold
85
+ // comparison (e.g. 66) becomes `1.0 >= 66` and never fires (v4 §17 defect).
86
+ function clPct(x) { const n = Number(x); if (!Number.isFinite(n)) return 0; return Math.max(0, Math.min(100, n)) }
87
+ // Positive duration with a safe fallback: a NEGATIVE/NaN duration parameter must
88
+ // never make a watchdog fire instantly or an idle window never elapse (v4 §30-T41).
89
+ function posMs(v, def) { const n = Number(v); return (Number.isFinite(n) && n > 0) ? n : (def || 120000) }
90
+ const textBlock = (t) => ({ type: 'text', text: String(t) })
91
+ 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 }
92
+ function fmtTime(ts) { try { return new Date(ts || now()).toISOString().replace('T', ' ').slice(0, 19) } catch (e) { return String(ts || '') } }
93
+ function makeSignal(ms) { try { return AbortSignal.timeout(posMs(ms, 30000)) } catch (e) { return undefined } }
94
+
95
+ // Object ids (verify targets, card ids, member ids) become FILE NAMES and DIRECTORY
96
+ // PATHS. A hostile/sloppy id containing separators ('../../x') or Windows-forbidden
97
+ // characters would escape the project tree. Keep every harmless character (incl.
98
+ // Chinese) and replace only separators/control chars; strip leading/trailing dots
99
+ // and dashes so the name is never '.' or '..' (v4 §30-T39).
100
+ function idSafe(s) {
101
+ const t = String(s == null ? '' : s).trim().replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').replace(/-{2,}/g, '-').replace(/^[.\-]+|[.\-]+$/g, '')
102
+ return t
103
+ }
104
+ // Advisory write-scope normalisation, ported from DSH agent-teams: backslashes to
105
+ // '/', strip a leading './' and trailing '/', reject empty/absolute/drive-letter/
106
+ // '..'-segment scopes.
107
+ function normalizeScope(s) {
108
+ const t = String(s == null ? '' : s).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
109
+ if (!t) return undefined
110
+ if (t.startsWith('/') || /^[a-z]:/i.test(t)) return undefined
111
+ for (const seg of t.split('/')) { if (seg === '' || seg === '.' || seg === '..') return undefined }
112
+ return t
113
+ }
114
+ function scopesOverlap(a, b) {
115
+ const ap = String(a).split('/'), bp = String(b).split('/')
116
+ const n = Math.min(ap.length, bp.length)
117
+ for (let i = 0; i < n; i++) if (ap[i] !== bp[i]) return false
118
+ return true
119
+ }
120
+
121
+ // ---- projection: pure fold --------------------------------------------
122
+ // The fold is shared by BOTH persistence backends, so the state machine is
123
+ // defined exactly once. It must return a NEW top-level reference whenever
124
+ // anything changed (the projection's change feed compares by Object.is).
125
+ function emptyInstitute(key, project, institute) {
126
+ return {
127
+ key, project, institute,
128
+ createdAt: now(), phase: 'idle',
129
+ problem: { id: '', statement: '' },
130
+ params: {},
131
+ members: [],
132
+ tasks: [],
133
+ messages: [],
134
+ delivered: [],
135
+ meetings: [],
136
+ debates: [],
137
+ verdicts: {},
138
+ queue: [],
139
+ counters: { academician: 0, researcher: 0, temp: 0, task: 0, meeting: 0, message: 0, verify: 0 },
140
+ runId: '',
141
+ lastProgressAt: now(),
142
+ artifactCount: 0,
143
+ diagnostics: [],
144
+ }
145
+ }
146
+ function initState() { return { v: PROJECTION_VERSION, institutes: {}, order: [] } }
147
+
148
+ function withInstitute(state, key, mut) {
149
+ const cur = state.institutes[key] || emptyInstitute(key, '', '')
150
+ const nextInst = mut(cur)
151
+ if (nextInst === cur) return state
152
+ const institutes = Object.assign({}, state.institutes)
153
+ institutes[key] = nextInst
154
+ const order = state.order.indexOf(key) === -1 ? state.order.concat([key]) : state.order
155
+ return { v: state.v, institutes, order, diagnostics: state.diagnostics }
156
+ }
157
+
158
+ // Fold ONE event. Unknown/malformed events are SKIPPED and recorded in
159
+ // `diagnostics` rather than latching a permanent failure: availability beats
160
+ // log purism, and a malformed event is a code defect that tests must catch.
161
+ // (DSH's own team projection latches `state.failure` forever instead — a shape
162
+ // v5 deliberately does not copy.)
163
+ function applyV5Event(state, event) {
164
+ try {
165
+ if (!event || typeof event.type !== 'string') return state
166
+ const t = event.type
167
+ if (t.indexOf('vibe5/') !== 0) return state
168
+ const d = event.data
169
+ if (!d || typeof d !== 'object' || typeof d.key !== 'string') return state
170
+ const key = d.key
171
+ if (t === EV.institute) {
172
+ return withInstitute(state, key, (inst) => {
173
+ const n = Object.assign({}, inst)
174
+ const patch = d.patch || {}
175
+ if (patch.project !== undefined) n.project = String(patch.project)
176
+ if (patch.institute !== undefined) n.institute = String(patch.institute)
177
+ if (patch.phase !== undefined) n.phase = String(patch.phase)
178
+ if (patch.problem !== undefined) n.problem = { id: String(patch.problem.id || ''), statement: String(patch.problem.statement || '') }
179
+ if (patch.params !== undefined) n.params = Object.assign({}, patch.params)
180
+ if (patch.runId !== undefined) n.runId = String(patch.runId)
181
+ if (patch.lastProgressAt !== undefined) n.lastProgressAt = Number(patch.lastProgressAt) || 0
182
+ if (patch.artifactCount !== undefined) n.artifactCount = Number(patch.artifactCount) || 0
183
+ return n
184
+ })
185
+ }
186
+ if (t === EV.member) {
187
+ return withInstitute(state, key, (inst) => {
188
+ const m = d.member
189
+ if (!m || typeof m.id !== 'string') return inst
190
+ const members = inst.members.slice()
191
+ const i = members.findIndex((x) => x.id === m.id)
192
+ if (i === -1) members.push(m); else members[i] = m
193
+ return Object.assign({}, inst, { members })
194
+ })
195
+ }
196
+ if (t === EV.task) {
197
+ return withInstitute(state, key, (inst) => {
198
+ const task = d.task
199
+ if (!task || typeof task.id !== 'string') return inst
200
+ const tasks = inst.tasks.slice()
201
+ const i = tasks.findIndex((x) => x.id === task.id)
202
+ if (i === -1) tasks.push(task); else tasks[i] = task
203
+ return Object.assign({}, inst, { tasks })
204
+ })
205
+ }
206
+ if (t === EV.message) {
207
+ return withInstitute(state, key, (inst) => {
208
+ const msg = d.message
209
+ if (!msg || typeof msg.id !== 'string') return inst
210
+ if (inst.messages.some((x) => x.id === msg.id)) return inst
211
+ return Object.assign({}, inst, { messages: inst.messages.concat([msg]) })
212
+ })
213
+ }
214
+ if (t === EV.delivered) {
215
+ return withInstitute(state, key, (inst) => {
216
+ const ids = Array.isArray(d.ids) ? d.ids.map(String) : []
217
+ if (!ids.length) return inst
218
+ const set = new Set(inst.delivered)
219
+ let changed = false
220
+ for (const id of ids) { if (!set.has(id)) { set.add(id); changed = true } }
221
+ if (!changed) return inst
222
+ // Compact: a message that has been delivered may leave `messages` too, so
223
+ // the queue never grows without bound over a long run.
224
+ const delivered = Array.from(set)
225
+ const messages = inst.messages.filter((m) => !set.has(m.id))
226
+ return Object.assign({}, inst, { delivered, messages })
227
+ })
228
+ }
229
+ if (t === EV.meeting) {
230
+ return withInstitute(state, key, (inst) => {
231
+ const idx = d.index
232
+ if (!idx || typeof idx.id !== 'string') return inst
233
+ if (inst.meetings.some((x) => x.id === idx.id)) return inst
234
+ const meetings = inst.meetings.concat([idx])
235
+ return Object.assign({}, inst, { meetings: meetings.length > 200 ? meetings.slice(meetings.length - 200) : meetings })
236
+ })
237
+ }
238
+ if (t === EV.debate) {
239
+ return withInstitute(state, key, (inst) => {
240
+ const idx = d.index
241
+ if (!idx || typeof idx.target !== 'string') return inst
242
+ const debates = inst.debates.concat([idx])
243
+ return Object.assign({}, inst, { debates: debates.length > 200 ? debates.slice(debates.length - 200) : debates })
244
+ })
245
+ }
246
+ if (t === EV.verdict) {
247
+ return withInstitute(state, key, (inst) => {
248
+ if (!d.target || typeof d.target !== 'string') return inst
249
+ const verdicts = Object.assign({}, inst.verdicts)
250
+ if (d.record === null) delete verdicts[d.target]
251
+ else verdicts[d.target] = d.record
252
+ return Object.assign({}, inst, { verdicts })
253
+ })
254
+ }
255
+ if (t === EV.queue) {
256
+ return withInstitute(state, key, (inst) => Object.assign({}, inst, { queue: Array.isArray(d.queue) ? d.queue : [] }))
257
+ }
258
+ if (t === EV.counters) {
259
+ return withInstitute(state, key, (inst) => Object.assign({}, inst, { counters: Object.assign({}, inst.counters, d.counters || {}) }))
260
+ }
261
+ if (t === EV.progress) {
262
+ return withInstitute(state, key, (inst) => Object.assign({}, inst, {
263
+ lastProgressAt: Number(d.at) || now(),
264
+ artifactCount: Number(d.artifactCount) || inst.artifactCount,
265
+ }))
266
+ }
267
+ return state
268
+ } catch (e) {
269
+ // Never throw out of the fold: one bad event must not break every later read.
270
+ try {
271
+ const diagnostics = (state.diagnostics || []).concat([{ at: now(), type: String(event && event.type), error: String((e && e.message) || e) }])
272
+ return { v: state.v, institutes: state.institutes, order: state.order, diagnostics: diagnostics.slice(-50) }
273
+ } catch (e2) { return state }
274
+ }
275
+ }
276
+
277
+ // Minimal structural validator standing in for a zod schema. The projection
278
+ // registry only ever calls `stateSchema.parse(row.val)` when it reloads a value
279
+ // from a CHECKPOINT row, so this is the guard against a corrupt persisted row —
280
+ // a plain object with `.parse` is sufficient and needs no module import (a
281
+ // preset-local file cannot reliably resolve `zod`).
282
+ const STATE_SCHEMA = {
283
+ parse(v) {
284
+ if (!v || typeof v !== 'object') throw new Error('vibe-math-v5: projection state is not an object')
285
+ if (v.v !== PROJECTION_VERSION) throw new Error('vibe-math-v5: projection state version mismatch')
286
+ if (!v.institutes || typeof v.institutes !== 'object') throw new Error('vibe-math-v5: projection state lacks institutes')
287
+ if (!Array.isArray(v.order)) throw new Error('vibe-math-v5: projection state lacks order')
288
+ return v
289
+ },
290
+ }
291
+
292
+ // ---- persistence backend ----------------------------------------------
293
+ // Primary: append an event to the session log and read the folded state back.
294
+ // Fallback: apply the SAME fold in memory and persist hardened JSON.
295
+ function makeProjectionBackend(proj, session) {
296
+ return {
297
+ kind: 'projection',
298
+ read() {
299
+ const s = proj.stateOf(session, PROJECTION_KEY)
300
+ return s === undefined ? initState() : s
301
+ },
302
+ async commit(type, data) {
303
+ session.append(type, data)
304
+ try { await store.flush(session) } catch (e) { /* flush is a durability hint */ }
305
+ return this.read()
306
+ },
307
+ }
308
+ }
309
+ // `pathOf` is a FUNCTION, not a captured string: the state path depends on the
310
+ // project/institute, which the first successful load syncs back into this session —
311
+ // a captured path would keep writing to the pre-load guess forever.
312
+ function makeFileBackend(readTextAbs, writeTextAbs, pathOf) {
313
+ let mem = initState()
314
+ let chain = Promise.resolve(true)
315
+ let loaded = false
316
+ return {
317
+ kind: 'file',
318
+ async load() {
319
+ if (loaded) return mem
320
+ loaded = true
321
+ try {
322
+ const raw = await readTextAbs(pathOf())
323
+ if (raw) {
324
+ const parsed = JSON.parse(raw)
325
+ if (parsed && parsed.v === PROJECTION_VERSION) mem = parsed
326
+ }
327
+ } catch (e) { /* a corrupt mirror is ignored; it is not authoritative */ }
328
+ return mem
329
+ },
330
+ read() { return mem },
331
+ async commit(type, data) {
332
+ mem = applyV5Event(mem, { type, data })
333
+ const snapshot = mem
334
+ // Serialize writes per file and defer JSON.stringify to execution time, so a
335
+ // late writer always lands the FULL newest state and can never overwrite with
336
+ // a stale subset (v4 §27 writeJson defect).
337
+ chain = chain.then(async () => {
338
+ try { await writeTextAbs(pathOf(), JSON.stringify(snapshot, null, 2)) } catch (e) { /* best effort */ }
339
+ return true
340
+ })
341
+ return mem
342
+ },
343
+ }
344
+ }
345
+
346
+ // ---- session registry --------------------------------------------------
347
+ const sessions = new Map() // rootAgentId -> session object
348
+ const childOwner = new Map() // childId -> rootAgentId
349
+
350
+ function sessionIdOf(agent) { try { return (agent && agent.id) ? String(agent.id) : undefined } catch (e) { return undefined } }
351
+ function rootOf(agent) {
352
+ try {
353
+ let cur = agent; const seen = new Set()
354
+ while (cur) {
355
+ const id = cur.id
356
+ if (seen.has(id)) return cur
357
+ seen.add(id)
358
+ const p = (cur.session && cur.session.header) ? cur.session.header.parentSession : undefined
359
+ if (p === undefined) return cur
360
+ const par = agents.get(p)
361
+ if (!par) return cur
362
+ cur = par
363
+ }
364
+ } catch (e) { /* fall through */ }
365
+ return agent
366
+ }
367
+ function getSession(agent) {
368
+ const root = rootOf(agent)
369
+ const sid = sessionIdOf(root)
370
+ if (sid === undefined) return undefined
371
+ let s = sessions.get(sid)
372
+ if (!s) { s = makeSession(root, sid); sessions.set(sid, s) }
373
+ return s
374
+ }
375
+
376
+ function makeSession(rootAgent, sessionId) {
377
+ const DEFAULT_PARAMS = {
378
+ // ── offices / quorum ──────────────────────────────────────────────────
379
+ academician: true,
380
+ academicianLeads: true,
381
+ memberMayRejectAssign: true,
382
+ researcherCount: 3,
383
+ quorumCap: 3, // m = min(quorumCap, |voters|)
384
+ quorumMode: 'm-unanimous', // 'm-unanimous' (v5) | 'all-unanimous' (v4 legacy)
385
+ verdictMaxRounds: 3,
386
+ // ── staffing ─────────────────────────────────────────────────────────
387
+ maxTempPerMember: 3, // simultaneously employed temps per academician/researcher
388
+ maxTempTotal: 12,
389
+ // ── context ──────────────────────────────────────────────────────────
390
+ compactThreshold: 66,
391
+ compactAfterRounds: 8,
392
+ // ── scheduling ───────────────────────────────────────────────────────
393
+ maxParallel: 3,
394
+ activityTimeoutMs: 120000,
395
+ stallAutoMeetingMs: 360000,
396
+ chatDigestMs: 45000,
397
+ chatDigestMax: 12,
398
+ meetingKeepEvery: 5,
399
+ // ── model / tools ────────────────────────────────────────────────────
400
+ provider: '',
401
+ model: '',
402
+ toolAllow: [],
403
+ toolDeny: [],
404
+ staffPersona: '',
405
+ tempToolAllow: [],
406
+ tempToolDeny: [],
407
+ }
408
+
409
+ // ---- ephemeral (never persisted; rebuilt on resume) -------------------
410
+ let params = Object.assign({}, DEFAULT_PARAMS)
411
+ let project = 'default'
412
+ let instituteName = 'institute'
413
+ let key = project + '::' + instituteName
414
+ let phase = 'idle'
415
+ let running = false, autoDone = false
416
+ let runId = ''
417
+ const busy = new Set()
418
+ const wakeKind = new Map()
419
+ const rounds = new Map() // memberId -> rounds since spawn
420
+ const roundsSinceCompact = new Map()
421
+ const contextPct = new Map()
422
+ const needReanchor = new Set()
423
+ const seeds = new Map() // memberId -> condensed self-summary seed
424
+ const lastActiveAt = new Map()
425
+ let currentMember = ''
426
+ let finalizeLock = null
427
+ const verifiedRecently = new Map()
428
+ const liveAgents = new Map() // childId -> WeakRef<Agent>
429
+ const inflight = new Map() // childId -> turn token (dedupes duplicate subagent/end)
430
+ let heartbeatDisposer = null
431
+ let meeting = null // in-flight meeting round state
432
+ let pendingMeeting = null // parked meeting (never preempts verification)
433
+ let digestTimer = null
434
+ let lastProgressAt = now()
435
+ let persistedEpoch = ''
436
+ const dbg = { passes: 0, schedEnter: 0, schedSkip: 0, arm: 0, begin: 0 }
437
+
438
+ // ---- persistence ------------------------------------------------------
439
+ let backend = null
440
+ let stateCache = null
441
+
442
+ function workspaceRoot() {
443
+ try { if (rootAgent && rootAgent.session && rootAgent.session.header && rootAgent.session.header.cwd) return rootAgent.session.header.cwd } catch (e) { /* fall through */ }
444
+ const sp = sandboxPolicyOf()
445
+ if (sp && sp.workspaceRoot) return sp.workspaceRoot
446
+ return '.'
447
+ }
448
+ const vibeRoot = () => (workspaceRoot() + '/VibeMath').replace(/\\/g, '/')
449
+ const projectRoot = () => vibeRoot() + '/Projects/' + project
450
+ const instRoot = () => projectRoot() + '/Institutes/' + instituteName
451
+
452
+ function getPolicy() {
453
+ const sp = sandboxPolicyOf()
454
+ if (!sp) return undefined
455
+ try { if (rootAgent && rootAgent.session) return sp.resolve({ session: rootAgent.session }) } catch (e) { /* fall through */ }
456
+ try { return sp.resolve({}) } catch (e) { return undefined }
457
+ }
458
+ async function fsTargetAbs(p) { return await fs.resolve(p) }
459
+ async function readTextAbs(p) { try { const t = await fsTargetAbs(p); if (await fs.stat(t) === undefined) return undefined; return await fs.readText(t) } catch (e) { return undefined } }
460
+ async function writeTextAbs(p, content) {
461
+ try {
462
+ const t = await fsTargetAbs(p)
463
+ await fs.writeText(t, content, undefined, undefined, getPolicy())
464
+ return true
465
+ } catch (e) { return false }
466
+ }
467
+ async function readTextRel(rel) { return await readTextAbs(instRoot() + '/' + rel) }
468
+ async function writeTextRel(rel, content) { return await writeTextAbs(instRoot() + '/' + rel, content) }
469
+
470
+ function installBackend() {
471
+ const proj = projectionsOf()
472
+ const sess = (rootAgent && rootAgent.session) ? rootAgent.session : undefined
473
+ if (proj && sess && typeof proj.stateOf === 'function') backend = makeProjectionBackend(proj, sess)
474
+ else if (proj && sess && typeof proj.register === 'function') backend = makeProjectionBackend(proj, sess)
475
+ else backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json')
476
+ return backend
477
+ }
478
+ // Every entry point that READS state must await this first. Without it the file
479
+ // backend's `mem` is still the empty initial state, so a fresh process would report
480
+ // an empty roster and `resume` would refuse with "no active member to resume" —
481
+ // i.e. the fallback would silently lose the whole institute across a restart.
482
+ async function ready() {
483
+ if (!backend) installBackend()
484
+ if (backend.kind === 'file' && typeof backend.load === 'function') await backend.load()
485
+ return true
486
+ }
487
+ function state() {
488
+ if (!backend) installBackend()
489
+ stateCache = backend.read()
490
+ return stateCache
491
+ }
492
+ function inst() {
493
+ const s = state()
494
+ return s.institutes[key] || emptyInstitute(key, project, instituteName)
495
+ }
496
+ async function commit(type, data) {
497
+ if (!backend) installBackend()
498
+ if (backend.kind === 'file' && backend.load) await backend.load()
499
+ stateCache = await backend.commit(type, Object.assign({ version: PROJECTION_VERSION, key }, data))
500
+ const cur = stateCache.institutes[key]
501
+ if (cur) {
502
+ phase = cur.phase || phase
503
+ params = Object.assign({}, DEFAULT_PARAMS, cur.params || {})
504
+ project = cur.project || project
505
+ instituteName = cur.institute || instituteName
506
+ }
507
+ return cur
508
+ }
509
+ // Convenience commit wrappers.
510
+ const patchInstitute = (patch) => commit(EV.institute, { patch })
511
+ const putMember = (member) => commit(EV.member, { member })
512
+ const putTask = (task) => commit(EV.task, { task })
513
+ const putMessage = (message) => commit(EV.message, { message })
514
+ const ackDelivered = (ids) => commit(EV.delivered, { ids })
515
+ const putMeeting = (index) => commit(EV.meeting, { index })
516
+ const putDebate = (index) => commit(EV.debate, { index })
517
+ const putVerdict = (target, record) => commit(EV.verdict, { target, record })
518
+ const putQueue = (queue) => commit(EV.queue, { queue })
519
+ const putCounters = (counters) => commit(EV.counters, { counters })
520
+ const markProgress = async () => {
521
+ lastProgressAt = now()
522
+ await commit(EV.progress, { at: lastProgressAt, artifactCount: inst().artifactCount })
523
+ }
524
+
525
+ // ---- roster helpers ---------------------------------------------------
526
+ const activeMembers = () => inst().members.filter((m) => m.phase === 'active')
527
+ const memberById = (id) => inst().members.find((m) => m.id === id)
528
+ const voters = () => activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher')
529
+ const voterCount = () => voters().length
530
+ function quorumM() {
531
+ const cap = Math.max(1, Math.floor(Number(params.quorumCap) || 3))
532
+ return Math.max(1, Math.min(cap, voterCount()))
533
+ }
534
+ function byChild(childId) { return inst().members.find((m) => m.childId === childId) }
535
+ // The live academician's id, or '' when the office founded the institute with
536
+ // `academician: false`. Every piece of charter text that talks about "the leader"
537
+ // must go through this: a hard-coded 'acad' told members to report to a leader who
538
+ // does not exist, and described organizational duties nobody holds.
539
+ function academicianId() {
540
+ const a = activeMembers().find((m) => m.kind === 'academician')
541
+ return a ? a.id : ''
542
+ }
543
+
544
+ // ---- the institute charter (public regulations) -----------------------
545
+ // Written into every member's `persona` at hire time. `persona` is part of the
546
+ // durable continuable descriptor, so the charter survives cold resume AND
547
+ // context compaction WITHOUT being re-injected into prompts — which is what
548
+ // structurally removes v4's "re-anchor the rules after compaction" patch and
549
+ // the "[核心规则重申]+[CONTEXT COMPACT] every round" leak it caused (§24.1-③).
550
+ function rosterLine() {
551
+ const ms = activeMembers()
552
+ const acad = ms.filter((m) => m.kind === 'academician').map((m) => m.id)
553
+ const res = ms.filter((m) => m.kind === 'researcher').map((m) => m.id)
554
+ const tmp = ms.filter((m) => m.kind === 'temp').map((m) => m.id + '(' + (m.hiredBy || '?') + '雇)')
555
+ return [
556
+ ' 在册院士:' + (acad.length ? acad.join('、') : '(无)'),
557
+ ' 在册常驻研究员:' + (res.length ? res.join('、') : '(无)'),
558
+ ' 在册临时工:' + (tmp.length ? tmp.join('、') : '(无)'),
559
+ ].join('\n')
560
+ }
561
+ const LIB_SPEC = [
562
+ ' · Progress/<你>/progress.md —— **你的研究日志**(叙述体,可追加)。',
563
+ ' 主要内容是:尝试过的各方法、路线、历程、进度;当前研究进展/进度;将来的计划与打算;',
564
+ ' 及各路线、过程中遇到的障碍及其原因;对各路线、方法的看法、可行性评估;自己研究过程',
565
+ ' 中的一些有价值看法、感想、猜想、理解。以及其它各种你认为有价值的值得记录的事物、',
566
+ ' 经验、方法/想法、创新等,都可进行记录。',
567
+ ' ▸ **它的用途(为什么必须认真写)**:',
568
+ ' - 它是你**持续投入的思考痕迹**——别人和院士靠它了解你在做什么、做到哪一步了;',
569
+ ' - 它是**上下文被压缩后你恢复状态的主要依据**:压缩会丢掉对话细节,却丢不掉你写的',
570
+ ' 文件。请让它随时能让你自己看懂——我在哪、试过什么、为什么放弃、下一步做什么;',
571
+ ' - 它是**院士统筹全所的输入**:院士督导进度、牵线搭桥、避免重复劳动,读的就是它;',
572
+ ' - **失败与死路同样值得记**:写下"试过但为什么不行",能替全所省下重复的弯路。',
573
+ ' ▸ 写法建议:按时间追加,每次记一小节;把"结论/进展"与"理由/证据"分开写;',
574
+ ' 悬而未决的问题明确标出。',
575
+ '',
576
+ ' · Propos/<你>/<id>.md —— **你的命题/引理**。格式:',
577
+ ' - ID: p-<id>; - 状态: 未定论; - 概率: <0-1>; - 价值程度: <0-1>; - 动机用途计划: <为何重要/打算怎么用>',
578
+ ' 然后 ## 陈述 <完整陈述>;## 证明尝试;## 证伪尝试。',
579
+ ' · Methods/<你>/<id>.md —— **你的理论/方法/工具**。格式:',
580
+ ' - ID: m-<id>; - 状态: 经验; - 可信断言: []; - 价值程度: <0-1>; - 动机用途计划: ...',
581
+ ' 然后 ## 核心内容;## 定义与记号;## 应用记录;## 改进历史。',
582
+ ' · Subproblems/<你>/<id>.md —— **你的子问题**。格式:',
583
+ ' - ID: s-<id>; - 状态: 求解中; - 价值程度: <0-1>; - 动机用途计划: ...',
584
+ ' 然后 ## 陈述;## 进度。',
585
+ '',
586
+ ' 三条硬要求:',
587
+ ' ① 凡入库必须写明 **价值程度 / 动机用途计划 / 你对该对象为真的概率估计**(缺一不可);',
588
+ ' ② **只写自己的库**;读别人的库是允许且被鼓励的;',
589
+ ' ③ 推荐**直接用 fs 写你自己的文件**;vibe_v5_record_* 只是便捷记录器,不是必需。',
590
+ ].join('\n')
591
+
592
+ // Computed per hire (a FUNCTION, not a frozen const): the text names the live
593
+ // academician, and must describe a leaderless institute honestly when the office
594
+ // founded one with `academician: false`.
595
+ function orgCommon() {
596
+ const a = academicianId()
597
+ const L = [
598
+ ' 本所是自组织的,但**不是没有组织**——现实中一个研究所也有所长/学术带头人统筹全局。',
599
+ ]
600
+ if (a) {
601
+ L.push(
602
+ ' 本所的领头人是**院士 ' + a + '**。它以**全所视角**组织与协调:',
603
+ ' ① **统筹全局**:掌握各方向布局、谁在做什么、哪里是瓶颈、哪里有重复或空白;',
604
+ ' ② **规划与分派**:把原问题拆成值得做的工作,作为**任务**分派给合适的成员(含临时工)。',
605
+ ' 分派是它的职责,不是越权;',
606
+ ' ③ **设定优先级**:多个方向并行时,它有责任指明"先做什么、什么可以缓、什么该放弃";',
607
+ ' ④ **协调资源**:决定临时工往哪里调配;建议增聘/解聘常驻研究员;',
608
+ ' ⑤ **主持会议**:由它召集正式会议、设定议程、维持讨论不跑偏,并把结论落实为任务;',
609
+ ' ⑥ **督导进度**:定期检查各成员的 Progress/ 与会议发言,催办停滞的方向、纠正偏离、',
610
+ ' 在成员之间牵线;',
611
+ ' ⑦ **对外代表**:通过所办向外部汇报与提要求。',
612
+ '',
613
+ ' 对**你**(非院士)的要求:',
614
+ ' · **主动汇报**:把你这一轮的进展、发现、卡点写进你自己的 Progress/,并把关键结论在',
615
+ ' 群聊里说出来——院士需要这些信息才能统筹;',
616
+ ' · **接受分派,但不要盲从**:院士分派给你的任务,默认应当执行;如果你认为方向错了、',
617
+ ' 信息过时、或你有更好的路线,**先说清理由再决定**——本所允许并鼓励有理据的反对。',
618
+ ' 真正的原则是:组织由院士负责,但**判断属于每个人自己**;',
619
+ ' · **有异议走会议**:若你与院士在方向上持续分歧,提议开会,让全所讨论;',
620
+ ' · **不要重复劳动**:做之前先看任务板和别人的库;发现别人已在做同一件事,告诉院士。')
621
+ } else {
622
+ L.push(
623
+ ' 本所当前**没有在册院士**(所办以无领头人方式建所):组织与协调由**全体有表决权者',
624
+ ' 共同商议**,通过群聊、提议开会(vibe_v5_meeting)与任务板完成。请特别注意:',
625
+ ' · 没有谁替你分派工作——**方向要你们自己讨论出来**,并把讨论结果落到任务板上;',
626
+ ' · 提议开会需要有人附议/由所办确认(只有院士或所办能直接召开);',
627
+ ' · **主动汇报**:把你的进展、发现、卡点写进你自己的 Progress/ 并说在群聊里,',
628
+ ' 否则别人无从与你协作;',
629
+ ' · **不要重复劳动**:做之前先看任务板和别人的库;发现重复,直接在群聊里指出。')
630
+ }
631
+ return L.join('\n')
632
+ }
633
+
634
+ const ACAD_ORG = [
635
+ ' 【四、你的组织职责与边界(院士)】',
636
+ ' 作为院士,你对本所的组织与推进负总责:',
637
+ ' ① **建立并维护全所视图**——谁在做什么、进展如何、瓶颈在哪、哪里有重复或空白。',
638
+ ' 用 vibe_v5_overview 查看,不要凭印象指挥;',
639
+ ' ② **拆解与分派**——把原问题拆成值得做的工作,用 vibe_v5_assign 分派给合适的成员',
640
+ ' (含临时工),并说清理由与验收标准。选人时优先考虑"谁最适合",而不只是"谁有空";',
641
+ ' ③ **设定优先级**——用 vibe_v5_prioritize 指明先做什么、什么该缓、什么该放弃;',
642
+ ' ④ **主持会议**——召集正式会议、设定议程、维持讨论不跑偏,并把讨论收敛成任务;',
643
+ ' ⑤ **督导进度**——用 vibe_v5_nudge 催办停滞的方向、纠正偏离、在成员之间牵线搭桥、',
644
+ ' 避免重复劳动。对停滞者不要只是催促,要给出具体的下一步或配对建议;',
645
+ ' ⑥ **协调资源**——决定临时工往哪里调配;向所办建议增聘/解聘常驻研究员;',
646
+ ' ⑦ **对外代表**——通过所办向外部汇报与提要求。',
647
+ '',
648
+ ' 你必须守住四条边界:',
649
+ ' · 你的**一票与所有人等重**,没有加权票、没有否决权;',
650
+ ' · 你**分派的是工作,不是结论**——你不能代替别人思考,也不能让任何断言因为你的',
651
+ ' 身份而变正确;任何对象要进 Verified/ 仍须 m 票布尔一致;',
652
+ ' · 成员**有权据理反对**你的分派;请认真对待——**理据优先于职位**;',
653
+ ' · 你**不能自我扩张编制**:增聘/解聘常驻研究员需所办/人批准。',
654
+ '',
655
+ ' 如果你发现自己大部分时间在处理杂事而无法做研究,那说明你该多雇几个临时工、或把',
656
+ ' 某些协调工作交给合适的成员——但协调的**最终责任**始终在你。',
657
+ ].join('\n')
658
+
659
+ function charterFor(member) {
660
+ const kind = member.kind
661
+ const m = quorumM()
662
+ // The leader's REAL id (or '' when the office founded a leaderless institute).
663
+ // Charter text must never name a leader who is not on staff: a member told to
664
+ // "report to the academician" when there is none has no one to report to.
665
+ const acadId = academicianId()
666
+ const L = []
667
+ // ── opening ──────────────────────────────────────────────────────────
668
+ if (kind === 'academician') {
669
+ L.push('你是「' + instituteName + '」的**院士**,本所的领头人与组织协调中心。你不仅亲自做研究,')
670
+ L.push('还向全所负责组织与推进。本所的目标是解决下述研究对象(原问题):')
671
+ } else if (kind === 'temp') {
672
+ L.push('你是「' + instituteName + '」的**临时工**,代号 ' + member.id + ',由 ' + (member.hiredBy || '?') + ' 雇入,')
673
+ L.push('用途:' + (member.direction || '(未说明)') + '。本所的目标是解决下述研究对象(原问题):')
674
+ } else {
675
+ L.push('你是「' + instituteName + '」的一名常驻研究员,代号 ' + member.id + '。本所是一个自组织的合作研究')
676
+ L.push('机构,目标是解决下述研究对象(原问题):')
677
+ }
678
+ L.push('')
679
+ L.push(' ' + (inst().problem.statement || '(尚未设定)'))
680
+ L.push('')
681
+ if (kind === 'temp') {
682
+ L.push('你的任务期至:' + (member.term || '雇主另行通知') + '。任务完成后请主动告知雇主。')
683
+ L.push('')
684
+ }
685
+ if (acadId || kind === 'academician') {
686
+ L.push('本所没有**外部**派活:做什么、往哪走,由所内自己决定。所内的组织与协调由**院士**牵头——')
687
+ L.push('它统筹全局、把工作拆解成分派下去、设定优先级、主持会议、督导进度;你则在自己的方向上')
688
+ L.push('深入钻研,把进展与判断汇报给它和全所。请记住这条分工:**组织由院士负责,但判断属于')
689
+ L.push('你自己**——它分派的是工作,不是结论。')
690
+ } else {
691
+ L.push('本所没有**外部**派活:做什么、往哪走,由所内自己决定。本所当前**没有在册院士**,')
692
+ L.push('组织与协调由**全体有表决权者共同商议**(所办代表外部);但请守住同一条分工:')
693
+ L.push('**组织归集体,判断属于你自己**——讨论决定的是工作,不是结论。')
694
+ }
695
+ L.push('')
696
+ L.push('────────────────────────────────────────')
697
+ // ── 一、roster ───────────────────────────────────────────────────────
698
+ L.push('【一、所内编制与你的同事】')
699
+ if (kind === 'temp') {
700
+ if (acadId) L.push(' · **院士 ' + acadId + '** —— 本所领头人,组织与协调中心。它统筹全所、分派任务、主持')
701
+ if (acadId) L.push(' 会议、督导进度,也可以直接分派任务给你。')
702
+ L.push(' · **常驻研究员** —— 本所有表决权者。你是临时雇入的协作人员。')
703
+ L.push(' · 你的雇主:' + (member.hiredBy || '?') + '。它给你派活' + (acadId ? ';院士也可以给你派活。' : '。'))
704
+ if (!acadId) L.push(' · 本所当前**没有在册院士**;组织与协调由全体有表决权者共同商议。')
705
+ } else if (kind === 'academician') {
706
+ L.push(' · **院士 ' + member.id + '(你)** —— 本所领头人,本所的**组织与协调中心**。你亲自参与')
707
+ L.push(' 研究,同时向全所负责:建立全所视图、拆解并分派工作、设定优先级、主持会议、')
708
+ L.push(' 督导进度、调配临时工,并代表本所向外部汇报。')
709
+ L.push(' 但你的一票与其他有表决权者**等重**,不能单方面定论。')
710
+ L.push(' · **常驻研究员** —— 有表决权。可自主雇佣/解雇自己的临时工。向你汇报进展、')
711
+ L.push(' 接受你的组织与分派。')
712
+ L.push(' · **临时工** —— 由某位研究员或你为特定任务临时雇入。可读、可想、可发言、')
713
+ L.push(' 可写自己的成果库、可认领或被分派任务,但**没有表决权**。')
714
+ L.push(' · **所办(对外接口)** —— 不参与研究、不投票。代表本所与外部沟通并转达外部指令。')
715
+ } else {
716
+ if (acadId) {
717
+ L.push(' · **院士 ' + acadId + '** —— 本所领头人,本所的**组织与协调中心**。它亲自参与研究,同时')
718
+ L.push(' 向全所负责:建立全所视图、把原问题拆解成工作并**分派**给合适的成员(含临时工)、')
719
+ L.push(' 设定优先级与路线取舍、召集并主持会议、督导进度与催办停滞、调配临时工。')
720
+ L.push(' 但它的一票与你**等重**,不能单方面定论。')
721
+ L.push(' · **常驻研究员(含你)** —— 有表决权。可自主雇佣/解雇自己的临时工。')
722
+ L.push(' 向院士汇报进展、接受其组织与分派。')
723
+ } else {
724
+ L.push(' · **常驻研究员(含你)** —— 有表决权。可自主雇佣/解雇自己的临时工。')
725
+ L.push(' 本所当前**没有在册院士**:方向由你们共同商议决定,不要等别人来派活。')
726
+ }
727
+ L.push(' · **临时工** —— 由某位研究员' + (acadId ? '或院士' : '') + '为特定任务临时雇入。可读、可想、可发言、')
728
+ L.push(' 可写自己的成果库、可认领或被分派任务,但**没有表决权**。')
729
+ L.push(' · **所办(对外接口)** —— 不参与研究、不投票。代表本所与外部沟通并转达外部指令。')
730
+ }
731
+ L.push(' 你入职时的在册编制(这是一份**快照**,此后可能变化):')
732
+ L.push(rosterLine())
733
+ L.push(' (权威的在册名单与法定票数 m 以每轮提示里的状态块为准;编制可能变化。)')
734
+ L.push('')
735
+ // ── 二、general rules ────────────────────────────────────────────────
736
+ L.push('【二、通用规章(全员必读)】')
737
+ L.push(' 1. 本所一切任务安排由成员讨论' + (acadId ? '与院士组织' : '共同') + '决定;没有**外部**给你派活。')
738
+ L.push(' 2. 只有 Verified/ 目录下的结论(以及成果卡中标注"已验证·真/假"的条目)绝对可信。')
739
+ L.push(' 其余一切——他人的推测、你自己的未验结论、Progress/、Methods/ 里的未验证断言——')
740
+ L.push(' 都只是经验性参考,引用时必须注明"未验证"。')
741
+ L.push(' 3. 任何人可以读任何人的成果库;你只能写自己的库(Members/<你>/)。')
742
+ L.push(' 4. 你写下的有价值内容由你自己判断是否入库,但入库必须写明三项:')
743
+ L.push(' 价值程度、动机用途计划、你自己对"该对象为真"的概率估计。')
744
+ L.push(' 5. 你随时可以在群聊里说话;要单独找人可以私信。需要集体决策就提议开会。')
745
+ L.push(' 6. 请主动读同事的库,对齐事实、避免重复劳动、发现冲突。')
746
+ if (acadId) {
747
+ L.push(' 7. **主动向院士汇报**:它需要你的进展、发现与卡点才能统筹全所;把关键结论在群聊里')
748
+ L.push(' 说出来,把细节留在你自己的 Progress/ 里。')
749
+ } else {
750
+ L.push(' 7. **主动在群聊里汇报**:本所没有院士替你统筹,你不说别人就无从与你协作;把关键')
751
+ L.push(' 结论说出来,把细节留在你自己的 Progress/ 里。')
752
+ }
753
+ L.push('')
754
+ // ── 三、libraries ───────────────────────────────────────────────────
755
+ L.push('【三、你的资料库、progress 与卡片格式】')
756
+ L.push(' 你的资料库根目录:Members/' + member.id + '/')
757
+ L.push(' (以下路径都相对该目录。你**只写这里**,但可以读任何人的对应目录。)')
758
+ L.push('')
759
+ L.push(LIB_SPEC)
760
+ L.push('')
761
+ // ── 四、organization ────────────────────────────────────────────────
762
+ if (kind === 'academician') {
763
+ L.push(ACAD_ORG)
764
+ } else {
765
+ L.push('【四、所内的组织与协调' + (acadId ? '(院士领头)' : '(无院士:集体商议)') + '】')
766
+ L.push(orgCommon())
767
+ if (kind === 'temp' && acadId) {
768
+ L.push(' · **院士也可以直接分派任务给你**(它统筹全所)。雇主与院士的分派都应执行;')
769
+ L.push(' 若你认为分派有误,先说清理由。')
770
+ } else if (kind === 'temp') {
771
+ L.push(' · 本所当前没有在册院士:你只需向**雇主**负责(它给你派活)。')
772
+ }
773
+ }
774
+ L.push('')
775
+ if (acadId || kind === 'academician') {
776
+ L.push(' 【重要】分派**不改变求真规则**:院士分派任务、设定优先级,但**不能**因此让任何结论')
777
+ L.push(' 变得"正确"。任何对象要进 Verified/,仍然必须满足 m 票布尔一致(见【五】)。院士自己')
778
+ L.push(' 的一票与别人**等重**。')
779
+ } else {
780
+ L.push(' 【重要】组织工作**不改变求真规则**:谁开任务、谁定优先级,都**不能**因此让任何结论')
781
+ L.push(' 变得"正确"。任何对象要进 Verified/,仍然必须满足 m 票布尔一致(见【五】)。任何人的')
782
+ L.push(' 一票都与别人**等重**。')
783
+ }
784
+ L.push('')
785
+ // ── 五、voting ──────────────────────────────────────────────────────
786
+ L.push('【五、表决与定论(求真门槛)】')
787
+ if (kind === 'temp') {
788
+ L.push(' · 本所结论由有表决权者(' + (acadId ? '院士与' : '') + '常驻研究员)按 m 票布尔一致决定。**你没有表决权**,')
789
+ L.push(' 但你的判断很重要——请把你的意见和理由清楚地告诉雇主或在群聊里说出来,供他们')
790
+ L.push(' 参考。若你认为某个结论该被验证,可以提议。')
791
+ } else {
792
+ L.push(' · 任何命题 / 论断 / 方法 / 子问题的结论,要进入 Verified/,必须满足:')
793
+ L.push(' (a) 至少有 m = ' + m + ' 名有表决权者(' + (acadId ? '院士 + ' : '') + '常驻研究员)投出**布尔概率值**;')
794
+ L.push(' (b) 这些票**全部**是 1(绝对为真)或**全部**是 0(绝对为假);')
795
+ L.push(' (c) 若同时出现 1 和 0(分歧),或投布尔票者不足 m 人 → 不能定论。')
796
+ L.push(' · m 随在册有表决权者人数变化(m = min(所办设定的上限, 人数));本规章里的 m 是')
797
+ L.push(' **你入职时的值**,请始终以每轮状态块里的 m 为准。')
798
+ L.push(' · 你的票是一个 [0,1] 的数值概率:1 = 你认为绝对为真;0 = 你认为绝对为假;')
799
+ L.push(' 介于 0 与 1 之间表示你不确定——这会被记为"弃权/存疑",**不计入**上述 m 票,')
800
+ L.push(' 但会连同你的理由一起进入辩论录,并参与"全组平均概率"的计算。')
801
+ L.push(' · 表决分两段:先【独立初评】——你在看不到别人意见的情况下独立给出票与理由;')
802
+ L.push(' 若未定论,再进入【公开辩论】——框架会把所有人的意见公开给所有人,你们可以')
803
+ L.push(' 引用、反驳、修改,然后重新投票。辩论轮次上限 ' + params.verdictMaxRounds + ' 轮。')
804
+ L.push(' · 仍未定论的对象**留在原库中**,并附上全组平均概率与完整辩论记录;它不会被强行')
805
+ L.push(' 判真或判假。若日后你认为条件成熟,可以再次提议验证。')
806
+ L.push(' · **永远不要为了让流程往前走而投出你不相信的 1 或 0。** 诚实的"不确定"远好过')
807
+ L.push(' 虚假的"一致"。本所宁可留下未定论,也不要一个骗人的 Verified。')
808
+ if (kind === 'academician') {
809
+ L.push(' · 你享有与所有有表决权者相同的**一票**,不享有更高票权,也不能单方面定论。')
810
+ }
811
+ }
812
+ L.push('')
813
+ // ── 六、each round ──────────────────────────────────────────────────
814
+ L.push('【六、你每一轮做什么(默认节奏)】')
815
+ L.push(' ① 推进你的方向:思考、读同事成果、做推导、做验证尝试;')
816
+ L.push(' ② 自查刚得到的东西,按价值决定是否写进你自己的成果库(写明价值程度 / 动机用途计划 /')
817
+ L.push(' 你的概率估计);')
818
+ L.push(' ③ 决定要不要在群聊里说话、要不要私信某人、要不要提议开会、要不要提议对某个对象')
819
+ L.push(' 发起验证;')
820
+ L.push(' ④ 在会议或辩论中表态(包括对"是否已解决原问题"表态)。')
821
+ L.push(' 本所鼓励你(但不强迫)**自主构建新的理论框架或工具**——把某类结构抽象化、一般化,')
822
+ L.push(' 抽离出更普遍的理论体系,再在其下推出定理与结论(历史上为解方程而发明群论、为分析')
823
+ L.push(' 而建立泛函分析,都是这种工作)。若你这样做,请写清它对原问题的用处与价值,并把它')
824
+ L.push(' 记入你的 Methods/ 库,之后可以不断完善与推广。')
825
+ L.push('')
826
+ // ── 七、hire / fire ─────────────────────────────────────────────────
827
+ L.push('【七、雇佣与解雇】')
828
+ if (kind === 'temp') {
829
+ L.push(' · 你可以建议雇主雇佣或解雇他人,但雇佣/解雇的决定权在雇主' + (acadId ? '与院士' : '') + '。')
830
+ } else {
831
+ L.push(' · 你可以自主雇佣临时工:当你需要某个具体任务的帮助时,用 vibe_v5_hire 申请,')
832
+ L.push(' 说明用途与初始任务。框架会代为创建,成功后你会拿到它的代号,之后你可以直接')
833
+ L.push(' 给它派活(私信/任务板)。')
834
+ L.push(' · 你也可以自主解雇**你雇的**临时工:用 vibe_v5_fire 说明理由即可。解雇后它的')
835
+ L.push(' 当前工作会被停止,未完成任务会被收回,它将不再是本所成员,也不再收到任何消息。')
836
+ L.push(' 它的档案会留在所史里(代号永不复用)。')
837
+ L.push(' · 解雇别人雇的临时工,或增聘/解聘常驻研究员,只能向全所提议,由' + (acadId ? '院士/' : '') + '所办决定。')
838
+ L.push(' · 请节约用人:临时工是有成本的。任务完成、且你不再需要它时,请主动解雇。')
839
+ if (acadId) {
840
+ L.push(' · **院士统筹全所的用人**:它可以决定把临时工调配到哪个方向,也可以解雇任何临时工;')
841
+ L.push(' 若它把你的临时工调走了,请配合——全所效率优先于个人便利。')
842
+ }
843
+ }
844
+ L.push('')
845
+ // ── 八、task board ──────────────────────────────────────────────────
846
+ L.push('【八、任务板】')
847
+ L.push(' · 任何成员都可以在任务板上开任务(标题、详情、可选依赖、可选涉及文件范围、优先级)。')
848
+ L.push(' · 任务只有在它的**全部依赖都已完成**之后才能被认领。')
849
+ L.push(' · 认领即拥有;完成后标记完成,或释放回板上,或重新打开。')
850
+ L.push(' · 每次修改都基于版本号比较交换:拿着过期副本去改会被拒绝,所以改之前先读最新版。')
851
+ if (acadId) {
852
+ L.push(' · **院士可以直接分派任务**(vibe_v5_assign):它可以把任务指派给指定成员(含临时工),')
853
+ L.push(' 并说明理由与验收标准。被分派者默认应当执行,但有权先说明理由再决定。')
854
+ L.push(' · **优先级由院士牵头决定**:院士可以调整任务的优先级;你若认为安排有误,说出来。')
855
+ L.push(' · 除院士的分派之外,任务是**协调工具**而非派活指令:认领与否、做什么,主要靠你们自己。')
856
+ } else {
857
+ L.push(' · 任务是**协调工具**而非派活指令:本所没有院士,认领与否、做什么,靠你们自己协商')
858
+ L.push(' 决定;所办也可以直接分派任务(vibe_v5_assign)。被分派者默认应当执行,但有权先')
859
+ L.push(' 说明理由再决定。')
860
+ L.push(' · **优先级由集体协商决定**;所办可以协助调整。')
861
+ }
862
+ L.push('')
863
+ // ── 九、context ─────────────────────────────────────────────────────
864
+ L.push('【九、上下文与纪律】')
865
+ L.push(' · 你的上下文达到阈值时会被自动压缩。压缩后本规章**依然有效**(它在你的人设里,')
866
+ L.push(' 不在对话里),但请把你当前的工作状态、关键中间结论、待办写进你自己的 Progress/,')
867
+ L.push(' 以免压缩损失细节。')
868
+ L.push(' · 你的一轮结束时,请给出一个 JSON 对象(格式见每轮提示末尾),供框架收集你的')
869
+ L.push(' 发言/提议/投票/进度。JSON 之外的正文无需拘谨,但请保持言简意赅。')
870
+ L.push('')
871
+ // ── 十、stop ────────────────────────────────────────────────────────
872
+ L.push('【十、停止】')
873
+ L.push(' · 当且仅当**全体有表决权者一致认为原问题已解决**时,本所才会停止推进。')
874
+ L.push(' · 外部(所办/人)随时可能给本所留言、提要求、要求开会、增减成员或暂停全所——')
875
+ L.push(' 服从并响应。')
876
+ return L.join('\n')
877
+ }
878
+
879
+ // A minimal per-round status block: everything VOLATILE lives here rather than in
880
+ // the immutable persona (roster, current m, pending chat, this round's ask).
881
+ //
882
+ // `member` is the member this block DESCRIBES and MUST be the one the prompt is
883
+ // addressed to. It is a required parameter on purpose: this block used to read a
884
+ // mutable "currentMember" global, and because the founding path assigned that global
885
+ // only AFTER the subagent had already been started, every member's induction brief
886
+ // named the PREVIOUSLY founded member (the academician was told it was "?"). The
887
+ // model's whole self-model, its library path and its vote were therefore wrong.
888
+ function briefBlock(member) {
889
+ if (!member || typeof member.id !== 'string' || !member.id) {
890
+ throw v5err('V5_INTERNAL', 'briefBlock: a member is required (a status block must never be built for an unknown identity)')
891
+ }
892
+ const ms = activeMembers()
893
+ const b = []
894
+ b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (rounds.get(member.id) || 0) +
895
+ '|法定票数 m=' + quorumM() + '|有表决权者 ' + voterCount() + ' 人')
896
+ b.push('[在册] ' + (ms.length ? ms.map((x) => x.id).join('、') : '(无)'))
897
+ // Members that are on the books but NOT on the floor. Silently omitting them made a
898
+ // failed provision invisible to the whole institute.
899
+ const absent = inst().members.filter((m) => m.phase !== 'active' && m.phase !== 'dismissed')
900
+ if (absent.length) b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))
901
+ const tasks = inst().tasks.filter((t) => t.status !== 'deleted')
902
+ const mine = tasks.filter((t) => t.ownerId === member.id && t.status === 'in_progress')
903
+ const ready = tasks.filter((t) => t.status === 'pending' && taskReady(t))
904
+ if (tasks.length) {
905
+ b.push('[任务板] 进行中 ' + tasks.filter((t) => t.status === 'in_progress').length +
906
+ '|可认领 ' + ready.length + '|我负责 ' + (mine.length ? mine.map((t) => t.id + '「' + t.subject + '」').join('、') : '无'))
907
+ }
908
+ if (mine.length) {
909
+ for (const t of mine) {
910
+ b.push(' ▸ 我的任务 ' + t.id + ':' + t.subject + (t.acceptance ? '|验收:' + t.acceptance : '') +
911
+ (t.assignedBy ? '|由 ' + t.assignedBy + ' 分派' : ''))
912
+ if (t.description) b.push(' ' + String(t.description).split('\n')[0])
913
+ }
914
+ }
915
+ const pending = pendingFor(member.id)
916
+ if (pending.length) {
917
+ b.push('[新到的消息/通知]')
918
+ for (const p of pending) b.push(' ' + p.line)
919
+ }
920
+ return b.join('\n')
921
+ }
922
+ function kindLabel(k) { return k === 'academician' ? '院士' : k === 'researcher' ? '常驻研究员' : '临时工' }
923
+
924
+ // ---- activity waiting (ported from DSH agent-teams' TeamActivity) -----
925
+ // A one-shot, future-only waiter notified by the first committed state change.
926
+ // This is what replaces v4's `activityTimeoutMs` polling: members call
927
+ // `vibe_v5_wait` and are woken by real activity instead of busy-looping.
928
+ const waiters = new Set()
929
+ function notifyActivity() {
930
+ if (!waiters.size) return
931
+ const pending = Array.from(waiters)
932
+ waiters.clear()
933
+ for (const w of pending) { try { w.resolve({ timedOut: false }) } catch (e) { /* ignore */ } }
934
+ }
935
+ function waitForActivity(ms, signal) {
936
+ const timeoutMs = Number(ms)
937
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 10000 || timeoutMs > 3600000) {
938
+ throw v5err('V5_INVALID_TIMEOUT', 'timeout_ms must be an integer from 10000 through 3600000')
939
+ }
940
+ return new Promise((resolve, reject) => {
941
+ let done = false
942
+ const entry = {
943
+ resolve: (v) => { if (!done) { done = true; cleanup(); resolve(v) } },
944
+ }
945
+ const onAbort = () => {
946
+ if (done) return
947
+ done = true
948
+ cleanup()
949
+ const reason = signal && signal.reason
950
+ if (reason instanceof Error) reject(reason)
951
+ else reject(v5err('V5_WAIT_ABORTED', 'vibe_v5_wait aborted: ' + String(reason === undefined ? 'signal' : reason)))
952
+ }
953
+ let timerDisposer = null
954
+ function cleanup() {
955
+ waiters.delete(entry)
956
+ if (timerDisposer) { try { timerDisposer() } catch (e) { /* ignore */ } }
957
+ if (signal && typeof signal.removeEventListener === 'function') { try { signal.removeEventListener('abort', onAbort) } catch (e) { /* ignore */ } }
958
+ }
959
+ waiters.add(entry)
960
+ if (signal) {
961
+ if (signal.aborted) { onAbort(); return }
962
+ if (typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort)
963
+ }
964
+ timerDisposer = ctx.timeout(() => { if (!done) { done = true; cleanup(); resolve({ timedOut: true }) } }, timeoutMs)
965
+ })
966
+ }
967
+
968
+ // ---- project tree ------------------------------------------------------
969
+ function psQuote(p) { return "'" + String(p).replace(/'/g, "''") + "'" }
970
+ function shQuote(p) { return "'" + String(p).replace(/'/g, "'\\''") + "'" }
971
+ const isWindows = () => process.platform === 'win32'
972
+ async function runShell(script, cwd) {
973
+ const subprocess = subprocessOf()
974
+ if (subprocess === undefined) return { ok: false, error: 'no-subprocess' }
975
+ try {
976
+ const argv = isWindows()
977
+ ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', script]
978
+ : ['/bin/sh', '-c', script]
979
+ const h = subprocess.spawn({ argv, cwd: cwd || workspaceRoot(), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 20000 })
980
+ const o = await h.done
981
+ return { ok: o.exitCode === 0, exitCode: o.exitCode }
982
+ } catch (e) { return { ok: false, error: String((e && e.message) || e) } }
983
+ }
984
+ async function mkdirs() {
985
+ const base = instRoot()
986
+ const dirs = ['Shared/Chat', 'Shared/Meetings', 'Shared/Debates', 'State', 'Problems']
987
+ for (const m of activeMembers()) {
988
+ for (const d of ['Progress', 'Propos', 'Methods', 'Subproblems']) dirs.push('Members/' + m.id + '/' + d)
989
+ }
990
+ const script = isWindows()
991
+ ? 'New-Item -Force -ItemType Directory -Path ' + dirs.map((d) => psQuote(base + '/' + d)).join(',') + ' | Out-Null'
992
+ : 'mkdir -p ' + dirs.map((d) => shQuote(base + '/' + d)).join(' ')
993
+ return await runShell(script)
994
+ }
995
+
996
+ // ---- communication (durable per-recipient mailbox) --------------------
997
+ // DSH's own neighbouring-agent send is adjacency-restricted (only a direct
998
+ // parent <-> direct continuable child), so member-to-member traffic is
999
+ // impossible directly. Every in-institute message is therefore relayed BY THE
1000
+ // FRAMEWORK, which delivers with the ROOT agent as the transport identity and
1001
+ // records the true sender in the message body. Delivery is per-recipient (the
1002
+ // faithful port of DSH's mailbox, where every message has exactly one
1003
+ // targetId), so one member's acknowledgement can never consume another's copy.
1004
+ async function say(from, opts) {
1005
+ const text = String((opts && opts.text) || '').trim()
1006
+ if (!text) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'empty message' }
1007
+ const to = String((opts && opts.to) || 'all')
1008
+ const kind = String((opts && opts.kind) || 'chat')
1009
+ let targets
1010
+ if (to === 'all' || to === '') targets = activeMembers().filter((m) => m.id !== from)
1011
+ else if (to === 'voters') targets = voters().filter((m) => m.id !== from)
1012
+ else {
1013
+ const t = memberById(to)
1014
+ if (!t || t.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + to + '" not found' }
1015
+ if (t.id === from) return { ok: false, code: 'V5_SELF_MESSAGE', message: 'cannot message yourself' }
1016
+ targets = [t]
1017
+ }
1018
+ if (!targets.length) return { ok: true, delivered: 0, note: 'no other active member' }
1019
+ const counters = Object.assign({}, inst().counters)
1020
+ let n = Math.max(Number(counters.message) || 0, 0)
1021
+ const at = now()
1022
+ for (const t of targets) {
1023
+ n += 1
1024
+ await putMessage({ id: 'msg-' + n, from, to: t.id, kind, text, at })
1025
+ }
1026
+ counters.message = n
1027
+ await putCounters(counters)
1028
+ notifyActivity()
1029
+ // Kick one scheduling pass so an ADDRESSED message (dm/office/assign) wakes its
1030
+ // recipient promptly instead of waiting for the digest window. Plain chat stays
1031
+ // batched because deliveryDecision gates it — the kick only starts the pass.
1032
+ scheduleNext().catch(() => {})
1033
+ return { ok: true, delivered: targets.length, to: targets.map((t) => t.id).join(',') }
1034
+ }
1035
+ // A framework NOTICE to one member. This must NOT be sent as the member itself:
1036
+ // `say()` refuses a self-addressed message (V5_SELF_MESSAGE), so the previous
1037
+ // `say(member.id, {to: member.id, …})` calls returned an error object that nobody
1038
+ // checked and the member never received the feedback ("claim failed", "verdict must
1039
+ // be a number"). The framework is a first-class sender with its own framing.
1040
+ async function notice(memberId, text) {
1041
+ if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
1042
+ const m = memberById(memberId)
1043
+ if (!m || m.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + memberId + '" not found' }
1044
+ return await say('framework', { to: memberId, kind: 'notice', text: String(text) })
1045
+ }
1046
+ // Pending = durable messages addressed to this member and not yet acknowledged.
1047
+ // (Acknowledging is what removes them, so the queue is exactly "queued minus delivered".)
1048
+ function pendingFor(memberId) {
1049
+ const out = []
1050
+ for (const m of inst().messages) {
1051
+ if (m.to !== memberId) continue
1052
+ out.push({
1053
+ id: m.id, kind: m.kind, from: m.from, at: m.at,
1054
+ line: frameLine(m),
1055
+ })
1056
+ }
1057
+ return out.sort((a, b) => a.at - b.at)
1058
+ }
1059
+ function frameLine(m) {
1060
+ if (m.kind === 'chat') return '【研究所·群聊】' + m.from + ':' + m.text
1061
+ if (m.kind === 'dm') return '【研究所·私信 from ' + m.from + '】' + m.text
1062
+ if (m.kind === 'voters') return '【研究所·致全体表决者 from ' + m.from + '】' + m.text
1063
+ if (m.kind === 'office') return '【所办通知】' + m.text
1064
+ if (m.kind === 'meeting') return '【研究所·会议】' + m.text
1065
+ if (m.kind === 'verify') return '【研究所·表决】' + m.text
1066
+ // An assignment is framed by its TRUE origin: the office can assign too, and
1067
+ // labelling an office assignment "院士分派" told the assignee to answer to
1068
+ // someone who never asked.
1069
+ if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text
1070
+ if (m.kind === 'nudge') return '【督办 from ' + m.from + '】' + m.text
1071
+ if (m.kind === 'notice') return '【框架提示】' + m.text
1072
+ return '【研究所·' + m.kind + ' from ' + m.from + '】' + m.text
1073
+ }
1074
+ // Batch plain chat so a chatty institute cannot cause a wake storm; anything
1075
+ // addressed or time-critical (dm/office/meeting/verify/assign) is delivered the
1076
+ // moment its recipient next runs.
1077
+ function deliveryDecision(memberId) {
1078
+ const pending = pendingFor(memberId)
1079
+ if (!pending.length) return { deliver: false, pending }
1080
+ const urgent = pending.filter((p) => p.kind !== 'chat')
1081
+ if (urgent.length) return { deliver: true, pending, urgent: true }
1082
+ const maxN = Math.max(1, Math.floor(Number(params.chatDigestMax) || 12))
1083
+ const windowMs = posMs(params.chatDigestMs, 45000)
1084
+ const oldest = pending[0].at
1085
+ const deliver = pending.length >= maxN || (now() - oldest) >= windowMs
1086
+ return { deliver, pending, urgent: false }
1087
+ }
1088
+ async function ackPending(pending) {
1089
+ if (!pending.length) return
1090
+ await ackDelivered(pending.map((p) => p.id))
1091
+ }
1092
+ // Compose the block a member sees for its newly delivered traffic.
1093
+ function composeInbox(pending) {
1094
+ if (!pending.length) return ''
1095
+ const lines = pending.map((p) => ' ' + p.line)
1096
+ const header = pending.length > 1
1097
+ ? '[新到的消息(' + pending.length + ' 条)]'
1098
+ : '[新到的消息]'
1099
+ return header + '\n' + lines.join('\n')
1100
+ }
1101
+
1102
+ // ---- roster lifecycle --------------------------------------------------
1103
+ // Counters are session-monotonic per kind and ids are NEVER reused (ported from
1104
+ // DSH's "names are immortal" rule) so a re-hired temp can never inherit a
1105
+ // dismissed member's archives or task ownership.
1106
+ async function newMember(kind, opts) {
1107
+ const counters = Object.assign({}, inst().counters)
1108
+ let id
1109
+ if (kind === 'academician') { counters.academician = Math.max(1, Number(counters.academician) || 0); id = 'acad' }
1110
+ else if (kind === 'researcher') { counters.researcher = (Number(counters.researcher) || 0) + 1; id = 'r-' + counters.researcher }
1111
+ else { counters.temp = (Number(counters.temp) || 0) + 1; id = 't-' + counters.temp }
1112
+ await putCounters(counters)
1113
+ const member = {
1114
+ id, kind,
1115
+ childId: '',
1116
+ phase: 'provisioning',
1117
+ direction: String((opts && opts.direction) || ''),
1118
+ hiredBy: (opts && opts.hiredBy) || '',
1119
+ term: (opts && opts.term) || '',
1120
+ provider: String((opts && opts.provider) || 'spawn'),
1121
+ persona: '', // filled at spawn; kept for the durable-seal record
1122
+ error: '',
1123
+ createdAt: now(),
1124
+ dismissedAt: 0,
1125
+ dismissReason: '',
1126
+ }
1127
+ await putMember(member)
1128
+ return member
1129
+ }
1130
+ function memberPersona(member) {
1131
+ const extra = String(params.staffPersona || '').trim()
1132
+ return (extra ? extra + '\n\n' : '') + charterFor(member)
1133
+ }
1134
+ function memberToolFilter(member) {
1135
+ const allowSrc = member.kind === 'temp' ? params.tempToolAllow : params.toolAllow
1136
+ const denySrc = member.kind === 'temp' ? params.tempToolDeny : params.toolDeny
1137
+ const allow = Array.isArray(allowSrc) ? allowSrc.map(String).filter((x) => x.trim()) : []
1138
+ const deny = Array.isArray(denySrc) ? denySrc.map(String).filter((x) => x.trim()) : []
1139
+ // An empty allow:[] would deny EVERY tool, so only emit a filter when at least
1140
+ // one side has entries (v4 §24.1-② / the "deny-all trap").
1141
+ if (!allow.length && !deny.length) return undefined
1142
+ const f = {}
1143
+ if (allow.length) f.allow = allow
1144
+ if (deny.length) f.deny = deny
1145
+ return f
1146
+ }
1147
+ function memberAgentOptions() {
1148
+ const ao = {}
1149
+ if (params.provider) ao.provider = params.provider
1150
+ if (params.model) ao.model = params.model
1151
+ return ao
1152
+ }
1153
+ function pickProvider() {
1154
+ try {
1155
+ const n = (typeof subagents.list === 'function') ? subagents.list() : []
1156
+ if (n && n.indexOf('spawn') !== -1) return 'spawn'
1157
+ if (n && n.indexOf('fork') !== -1) return 'fork'
1158
+ } catch (e) { /* ignore */ }
1159
+ return 'spawn'
1160
+ }
1161
+ // Bring a member into being. ORDER IS LOAD-BEARING and is the fix for the
1162
+ // "every brief describes the wrong person" bug:
1163
+ // 1. commit the member as ACTIVE first, so that everything derived from
1164
+ // `activeMembers()` — the [状态]/[在册] block, the quorum m, the voter count and
1165
+ // the charter's induction roster — describes the institute WITH this member in
1166
+ // it. Committing after the spawn made a joiner's own brief omit itself and
1167
+ // report m/P from before it joined.
1168
+ // 2. mark it busy, so the scheduler cannot try to wake a half-born member.
1169
+ // 3. build the persona and the founding prompt (both pure, both identity-checked).
1170
+ // 4. create its directories and write the mirrors BEFORE its first turn, so the
1171
+ // member finds its own Progress/Propos/Methods/Subproblems already in place.
1172
+ // 5. only then start the child.
1173
+ // `mode` is 'founding' for a genuinely new member and 'resume' for one whose child
1174
+ // session is being rebuilt: the latter must NOT be told it "just joined the
1175
+ // institute" and must not be shown the induction blurb.
1176
+ async function spawnMember(member, initialTask, mode) {
1177
+ const provider = member.provider || pickProvider()
1178
+ const ao = memberAgentOptions()
1179
+ const tf = memberToolFilter(member)
1180
+ const kind = mode === 'resume' ? 'resume' : 'initial'
1181
+ member.phase = 'active'
1182
+ member.childId = ''
1183
+ await putMember(member)
1184
+ busy.add(member.id)
1185
+ wakeKind.set(member.id, kind)
1186
+ currentMember = member.id
1187
+ lastActiveAt.set(member.id, now())
1188
+ rounds.set(member.id, (rounds.get(member.id) || 0) + 1)
1189
+ roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
1190
+ // The charter is FROZEN at hire time (it is the durable "seal" record and it says
1191
+ // "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
1192
+ // hire-time snapshot into a resume-time one and make the sentence untrue.
1193
+ const persona = member.persona || memberPersona(member)
1194
+ member.persona = persona
1195
+ const prompt = initialPrompt(member, initialTask, mode)
1196
+ await putMember(member)
1197
+ await mkdirs()
1198
+ await writeRosterMirror()
1199
+ let started
1200
+ try {
1201
+ started = await subagents.startContinuable({
1202
+ provider,
1203
+ label: 'vibe5 ' + member.id + ' (' + kindLabel(member.kind) + ')',
1204
+ request: Object.assign({
1205
+ prompt: [textBlock(prompt)],
1206
+ parent: rootAgent,
1207
+ persona,
1208
+ }, Object.keys(ao).length ? { agentOptions: ao } : {}, tf ? { toolFilter: tf } : {}),
1209
+ signal: makeSignal(params.activityTimeoutMs),
1210
+ })
1211
+ } catch (e) {
1212
+ // Roll the in-memory marks back so a failed provisioning leaves no phantom
1213
+ // "busy, round 1" member behind; the member record itself goes to `failed` and
1214
+ // the caller's catch reports it.
1215
+ busy.delete(member.id)
1216
+ wakeKind.delete(member.id)
1217
+ rounds.delete(member.id)
1218
+ roundsSinceCompact.delete(member.id)
1219
+ await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
1220
+ throw e
1221
+ }
1222
+ member.childId = started.childId
1223
+ childOwner.set(started.childId, sessionId)
1224
+ // Register the FOUNDING turn as in-flight, exactly like a normal wake does.
1225
+ // Without this the child's first `subagent/end` has no token to match, so
1226
+ // onMemberEnd would ignore it: the founding round would never be processed and
1227
+ // the member would be re-woken with a heartbeat prompt instead of a brainstorm.
1228
+ inflight.set(started.childId, shortId())
1229
+ await putMember(member)
1230
+ return member
1231
+ }
1232
+ // Deliver one prompt to a member. MUST use `subagents.sendMessage` — the
1233
+ // `subagents` SERVICE has no `followup` (that is only an Agent method); calling
1234
+ // it threw a TypeError on every wake and silently stalled the whole group (v4 §25).
1235
+ async function wakeMember(member, promptText, kind) {
1236
+ if (!member || !member.childId || member.phase !== 'active') return false
1237
+ clearHeartbeat()
1238
+ const token = shortId()
1239
+ inflight.set(member.childId, token)
1240
+ busy.add(member.id)
1241
+ wakeKind.set(member.id, kind || 'normal')
1242
+ currentMember = member.id
1243
+ lastActiveAt.set(member.id, now())
1244
+ rounds.set(member.id, (rounds.get(member.id) || 0) + 1)
1245
+ roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
1246
+ // Context directives. TWO distinct needs, and confusing them is what made
1247
+ // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the head of nearly every prompt
1248
+ // (v4 §24.1-③):
1249
+ // (a) a soft-compact trigger (context % or rounds) => ask for a self-summary,
1250
+ // but ONLY on a normal research round: a meeting/verify reply carries no
1251
+ // contextPct/compacted field, so a directive injected there can never be
1252
+ // acknowledged and would otherwise repeat forever;
1253
+ // (b) a REAL /compact just ran => the rules may be blurred, so re-anchor the
1254
+ // short core rules once on the next wake of ANY kind and clear the flag.
1255
+ let prompt = promptText
1256
+ // Inject the soft-compact directive on every MEMBER RESEARCH round — 'normal'
1257
+ // and 'checkpoint' alike. A member that only ever receives heartbeat checkpoints
1258
+ // would otherwise sit at 100% context forever and never compact. meeting/verify
1259
+ // are excluded because their replies are a different shape, and a directive there
1260
+ // could never be acknowledged.
1261
+ const wake = kind || 'normal'
1262
+ if (wake === 'normal' || wake === 'checkpoint') {
1263
+ const soft = (contextPct.get(member.id) || 0) >= Number(params.compactThreshold) ||
1264
+ (roundsSinceCompact.get(member.id) || 0) >= Number(params.compactAfterRounds)
1265
+ if (soft) {
1266
+ prompt = CORE_RULES + '\n[CONTEXT COMPACT — 你的对话已接近上限。不要重新推导历史。\n' +
1267
+ '请把当前工作状态浓缩成一段自述(已有发现、当前方向、已记录的关键成果、下一步具体动作、未决问题),' +
1268
+ '然后照常以 JSON 回答本轮。请在回复里填 "contextPct": 15 与 "compacted": true。]\n\n' + prompt
1269
+ // Reset the counter WITH the injection so the directive cannot repeat on the
1270
+ // very next round even if the member forgets to report `compacted`.
1271
+ roundsSinceCompact.set(member.id, 0)
1272
+ }
1273
+ }
1274
+ if (needReanchor.has(member.id)) {
1275
+ prompt = CORE_RULES + '\n' + prompt
1276
+ needReanchor.delete(member.id)
1277
+ }
1278
+ try {
1279
+ if (typeof subagents.sendMessage !== 'function') throw new Error('no subagents.sendMessage continuation API')
1280
+ await subagents.sendMessage(rootAgent, member.childId, [textBlock(prompt)], { signal: makeSignal(params.activityTimeoutMs) })
1281
+ return true
1282
+ } catch (e) {
1283
+ console.error('vibe-math-v5: wake ' + member.id + ' failed: ' + String((e && e.message) || e))
1284
+ inflight.delete(member.childId)
1285
+ busy.delete(member.id)
1286
+ return false
1287
+ }
1288
+ }
1289
+ // WHICH member (or office) is calling a tool. The answer must be DERIVED, never
1290
+ // guessed: the previous fallback answered "whoever this session woke last" whenever
1291
+ // the caller was not a member child — so the OFFICE (the session root, i.e. the
1292
+ // human/host) was impersonated as a random member. Concretely, the office calling
1293
+ // vibe_v5_assign was resolved to a researcher and refused with V5_NOT_ACADEMICIAN,
1294
+ // and its assignments/nudges would have been signed by the wrong person.
1295
+ function memberIdOfAgent(agent) {
1296
+ const id = sessionIdOf(agent)
1297
+ if (id !== undefined) {
1298
+ const m = byChild(id)
1299
+ if (m) return m.id
1300
+ // Not one of our member children. If it is a session ROOT it is the office —
1301
+ // 'office' rather than '' so the framing records a real, non-member sender.
1302
+ try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }
1303
+ // An unrelated child agent: report no member. Member-only writes refuse with
1304
+ // V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary), and the
1305
+ // office-capable tools treat '' as the office.
1306
+ return ''
1307
+ }
1308
+ // No session id at all (a synthetic exec context). Only here may we fall back to
1309
+ // the last-woken member, and only while it still genuinely exists.
1310
+ const c = currentMember
1311
+ return (c && memberById(c)) ? c : ''
1312
+ }
1313
+
1314
+ // ---- prompts ----------------------------------------------------------
1315
+ // The charter lives in `persona` (permanent). Every ROUND prompt therefore
1316
+ // carries only: a tiny current-state block, the newly delivered traffic, and
1317
+ // this round's ask. That is what keeps the per-round context small and stops
1318
+ // the charter from being re-injected on every turn.
1319
+ function replySpec(kind) {
1320
+ const L = []
1321
+ L.push('结束时请**只**输出一个 JSON 对象(放在 ```json 围栏内,围栏外不要有文字)。支持以下字段,除特别说明外都可省略:')
1322
+ L.push('{')
1323
+ L.push(' "say": "你想对全所说的话(群聊)" 或 {"to":"r-2","text":"…"}(私信) 或 {"to":"voters","text":"…"}(只对表决者),')
1324
+ L.push(' "progress": "本轮进展叙述(会被追加到你的 Progress/progress.md)",')
1325
+ L.push(' "record": [ {"kind":"proposition|method|subproblem","id":"p-x","title":"…","statement":"…",')
1326
+ L.push(' "content":"…(method 用)","value":0.6,"motive":"为何重要/打算怎么用","p":0.7} ],')
1327
+ if (kind !== 'temp') {
1328
+ L.push(' "propose_verify": {"target":"p-x","kind":"proposition|method|subproblem","reason":"为何值得验证"},')
1329
+ L.push(' "verdict": {"target":"p-x","verdict":1,"reason":"你的理由"} ← verdict ∈ [0,1];**只有 1 或 0 算表决**,')
1330
+ L.push(' 介于两者之间=弃权/存疑;只在被要求表决时填。')
1331
+ } else {
1332
+ L.push(' "propose_verify": {"target":"p-x","kind":"proposition","reason":"为何值得验证"} ← 你可以提议,但没有表决权,')
1333
+ L.push(' "verdict" 字段对你不适用(填了也会被记为无表决权)。')
1334
+ }
1335
+ L.push(' "propose_meeting": {"agenda":"…","kind":"sync|division|verify-request|solve-vote","target":"…"},')
1336
+ if (kind === 'academician') {
1337
+ L.push(' "convene_meeting": {"agenda":"…","kind":"…","target":"…"} ← 你(院士)可以直接召开,无需他人附议,')
1338
+ L.push(' "assign": {"subject":"…","description":"…","to":"r-2","why":"为何派给他","acceptance":"验收标准","priority":1} ← 院士分派任务,')
1339
+ L.push(' "prioritize": {"order":[{"task_id":"t-1","priority":2}],"why":"…"} ← 设定全所优先级,')
1340
+ L.push(' "nudge": {"to":"r-2","why":"为何督办","next_step":"建议的具体下一步"},')
1341
+ }
1342
+ L.push(' "task_create": {"subject":"…","description":"…","blocked_by":["t-1"],"write_scopes":["Members/r-1/Propos"]},')
1343
+ L.push(' "task_claim": "t-3",')
1344
+ L.push(' "task_done": "t-3",')
1345
+ L.push(' "task_update": {"task_id":"t-3","expected_revision":2,"action":"complete|release|reopen|edit|set_dependencies|delete"},')
1346
+ L.push(' "input": "本轮会议/辩论的发言正文(会议轮用;也可直接用 say)",')
1347
+ L.push(' "reject_assign": {"task_id":"t-3","why":"你对这项分派的异议理由"} ← 有异议时填;理由会被广播给')
1348
+ L.push(' 全体表决者(任务仍会执行,但你的理由不会被埋掉),')
1349
+ if (kind !== 'temp') {
1350
+ L.push(' "hire": {"purpose":"…","initial_task":"…","direction":"…"} ← 雇佣一名临时工(说明用途与初始任务),')
1351
+ L.push(' "fire": {"id":"t-2","reason":"…"} ← 解雇(雇主/院士;你只能解雇你雇的),')
1352
+ }
1353
+ L.push(' "vote_solved": true|false, ← 你是否认为**原问题已解决**(会议/结题表决用;必须诚实)')
1354
+ L.push(' "solved": false, ← 你这一轮的个人判断(框架据此了解全所收敛度)')
1355
+ L.push(' "contextPct": 40, ← 你当前上下文的占用百分比(0-100)')
1356
+ L.push(' "compacted": false ← 若框架要求你压缩,填 true 并在 progress 里写下浓缩后的工作状态')
1357
+ L.push('}')
1358
+ return L.join('\n')
1359
+ }
1360
+ // Every prompt builder below passes the member it is addressing. There is
1361
+ // deliberately NO fallback to "the last member we happened to touch": guessing the
1362
+ // identity is what produced the wrong-identity briefs in the first place.
1363
+ function stateBlock(member) {
1364
+ return briefBlock(member)
1365
+ }
1366
+ function initialPrompt(member, initialTask, mode) {
1367
+ const L = []
1368
+ const resume = mode === 'resume'
1369
+ L.push(resume
1370
+ ? '【会话重建 —— ' + kindLabel(member.kind) + ' ' + member.id + '】'
1371
+ : '【入职首轮 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1372
+ L.push('')
1373
+ if (resume) {
1374
+ L.push('你的常驻会话已被重建(进程重启或被所办停止后恢复),现在继续工作。')
1375
+ L.push('请**先读回你自己的 Progress/ 与成果库**,确认你在哪、做到哪一步、下一步做什么,')
1376
+ L.push('然后接着推进——不要从头再来,也不要重新做已经做过的事。')
1377
+ } else {
1378
+ L.push('你刚刚加入本所。请你先**独立**想清楚:面对这个问题,你打算从哪个方向切入?')
1379
+ L.push('给出你的初始见解、思路与可行的方向;如果已有具体想法,可以顺手记进你自己的 '
1380
+ + 'Progress/ 与成果库。')
1381
+ }
1382
+ L.push('')
1383
+ if (initialTask) { L.push(resume ? '恢复说明:' : '你的初始任务/用途:'); L.push(' ' + initialTask); L.push('') }
1384
+ if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
1385
+ L.push('------------')
1386
+ L.push(stateBlock(member))
1387
+ L.push('------------')
1388
+ L.push(replySpec(member.kind))
1389
+ return L.join('\n')
1390
+ }
1391
+ function normalPrompt(member) {
1392
+ const L = []
1393
+ L.push('【第 ' + (rounds.get(member.id) || 0) + ' 轮 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1394
+ L.push('')
1395
+ L.push('请推进你的研究:思考、读同事的成果库、做推导或验证尝试,并按价值把有价值的')
1396
+ L.push('结论写进你自己的成果库。然后决定要不要发消息、提议开会、提议验证。')
1397
+ if (member.kind === 'academician' && params.academicianLeads) {
1398
+ L.push('')
1399
+ L.push('作为院士,除了做研究,你还要**统筹全所**:用 vibe_v5_overview 看清谁在做什么、')
1400
+ L.push('哪里是瓶颈;把工作拆成任务并用 vibe_v5_assign 分派;必要时用 vibe_v5_nudge 督办。')
1401
+ }
1402
+ L.push('')
1403
+ L.push('------------')
1404
+ L.push(stateBlock(member))
1405
+ L.push('------------')
1406
+ L.push(replySpec(member.kind))
1407
+ return L.join('\n')
1408
+ }
1409
+ function checkpointPrompt(member) {
1410
+ const L = []
1411
+ L.push('【心跳检查 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1412
+ L.push('')
1413
+ L.push('所内一段时间没有新进展了。请**继续推进**这个问题,而不是停在原地:')
1414
+ L.push('读一读同事的库、推进你的子问题/引理/方法、尝试一条新路线;')
1415
+ L.push('或者向团队发消息(say)、开一个议题(propose_meeting)、给某个方向开任务(task_create)。')
1416
+ L.push('如果你确实已无路可走或认为原问题接近解决,请说明你的判断与理由。')
1417
+ L.push('')
1418
+ L.push('------------')
1419
+ L.push(stateBlock(member))
1420
+ L.push('------------')
1421
+ L.push(replySpec(member.kind))
1422
+ return L.join('\n')
1423
+ }
1424
+ function meetingPrompt(member, mn) {
1425
+ const L = []
1426
+ L.push('【研究所会议 ' + mn.id + ' 进行中 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1427
+ L.push('')
1428
+ L.push('议程:' + mn.agenda + '(类型:' + mn.kind + ')')
1429
+ L.push('')
1430
+ const others = Object.keys(mn.inputs || {}).filter((k) => k !== member.id)
1431
+ if (others.length) {
1432
+ L.push('### 其他成员本次会议已发表的意见(框架已转发给你,请参考、补充或反驳)')
1433
+ for (const k of others) L.push('- ' + k + ':' + String(mn.inputs[k]).split('\n').join('\n '))
1434
+ L.push('')
1435
+ } else {
1436
+ L.push('(你是本次会议的第一位发言者,目前还没有别人发言。)')
1437
+ L.push('')
1438
+ }
1439
+ L.push('请就议程发表你的意见。分工、优先级、下一步做什么、是否认为原问题已解决,都可以说。')
1440
+ L.push('(会议轮请把你的发言同时填进 JSON 的 "input" 字段,框架据此写会议纪要。)')
1441
+ L.push('如果你认为原问题已解决,请填 "vote_solved": true —— 只有当**全体有表决权者**都')
1442
+ L.push('一致认为是真时,本所才会停下来。')
1443
+ L.push('')
1444
+ L.push('------------')
1445
+ L.push(stateBlock(member))
1446
+ L.push('------------')
1447
+ L.push(replySpec(member.kind))
1448
+ return L.join('\n')
1449
+ }
1450
+ function verifyPrompt(member, vs) {
1451
+ const L = []
1452
+ L.push('【求真表决 —— ' + kindLabel(member.kind) + ' ' + member.id + ' 就对象 ' + vs.target + ' 投票】')
1453
+ L.push('')
1454
+ L.push('本所正在对下列对象发起共识验证:')
1455
+ L.push(' 对象:' + vs.target + '(类型:' + kindLabel2(vs.kind) + ')')
1456
+ if (vs.statement) L.push(' 陈述:' + String(vs.statement).slice(0, 800))
1457
+ L.push('')
1458
+ L.push('请给出你**诚实独立的判断**:')
1459
+ L.push(' verdict = 1 表示你认为该对象**绝对为真**;')
1460
+ L.push(' verdict = 0 表示你认为该对象**绝对为假**;')
1461
+ L.push(' 介于 0 与 1 之间(例如 0.9)表示你不确定——这会被记为**弃权/存疑**,')
1462
+ L.push(' 不计入法定票数 m,但会计入全组平均概率。')
1463
+ L.push('')
1464
+ if (vs.stage === 'debate' && vs.history) {
1465
+ L.push('### 上一轮各成员的意见(框架已公开给你,请参考后重新判断)')
1466
+ for (const [k, v] of Object.entries(vs.history)) {
1467
+ L.push('- ' + k + ':verdict=' + Number(v.prob) + '|' + String(v.reason || '(无理由)'))
1468
+ }
1469
+ L.push('')
1470
+ L.push('你可以维持、修改或反驳任何人的看法。')
1471
+ }
1472
+ L.push('**不要为了配合别人而改票,也不要为了让流程往前走而给出你不相信的 1 或 0。**')
1473
+ L.push('本所宁可留下未定论,也不要一个骗人的结论。')
1474
+ L.push('')
1475
+ L.push('------------')
1476
+ L.push(stateBlock(member))
1477
+ L.push('------------')
1478
+ L.push('结束时请**只**输出一个 JSON 对象(```json 围栏内):')
1479
+ L.push('{"verdict":{"target":"' + vs.target + '","verdict":<0-1 数值>,"reason":"<你的理由>"}, "contextPct": 40}')
1480
+ return L.join('\n')
1481
+ }
1482
+ function kindLabel2(k) { return k === 'method' ? '方法/理论' : k === 'subproblem' ? '子问题' : '命题' }
1483
+
1484
+ // ---- heartbeat / watchdog timing --------------------------------------
1485
+ // A meeting/verify may run at most 2× activityTimeoutMs without collecting a
1486
+ // NEW input/verdict before we treat it as deadlocked and abandon it. Every
1487
+ // duration read goes through posMs, so a negative/NaN parameter can never make
1488
+ // the watchdog fire instantly or an idle window never elapse (v4 §30-T41).
1489
+ function recoverStallMs() { return posMs(params.activityTimeoutMs, 120000) * 2 }
1490
+ function clearHeartbeat() {
1491
+ if (heartbeatDisposer) { try { heartbeatDisposer() } catch (e) { /* ignore */ } heartbeatDisposer = null }
1492
+ if (digestTimer) { try { digestTimer() } catch (e) { /* ignore */ } digestTimer = null }
1493
+ }
1494
+ // Re-arm the scheduler later. EVERY wake-failure path must call this: v4 once
1495
+ // returned early after a failed wake and never re-armed, so a single exception
1496
+ // stopped the whole group forever (§25).
1497
+ function armHeartbeat(ms) {
1498
+ if (!running || autoDone) return
1499
+ if (heartbeatDisposer) { try { heartbeatDisposer() } catch (e) { /* ignore */ } heartbeatDisposer = null }
1500
+ const delay = posMs(ms, posMs(params.activityTimeoutMs, 120000))
1501
+ heartbeatDisposer = ctx.timeout(() => {
1502
+ heartbeatDisposer = null
1503
+ scheduleNext().catch((e) => console.error('vibe-math-v5: heartbeat: ' + String((e && e.message) || e)))
1504
+ }, delay)
1505
+ }
1506
+ // Digest timer: a chatty institute must not wake everyone per message.
1507
+ function armDigest() {
1508
+ if (digestTimer || !running) return
1509
+ digestTimer = ctx.timeout(() => {
1510
+ digestTimer = null
1511
+ scheduleNext().catch((e) => console.error('vibe-math-v5: digest: ' + String((e && e.message) || e)))
1512
+ }, posMs(params.chatDigestMs, 45000))
1513
+ }
1514
+
1515
+ // ---- task board primitives -------------------------------------------
1516
+ function taskReady(task) {
1517
+ if (!task || task.status !== 'pending') return false
1518
+ const tasks = inst().tasks
1519
+ for (const id of (task.blockedBy || [])) {
1520
+ const b = tasks.find((t) => t.id === id)
1521
+ if (!b || b.status !== 'completed') return false
1522
+ }
1523
+ return true
1524
+ }
1525
+ function writeScopeWarnings(task) {
1526
+ const out = []
1527
+ for (const other of inst().tasks) {
1528
+ if (other.id === task.id || other.status !== 'in_progress') continue
1529
+ for (const a of (task.writeScopes || [])) {
1530
+ for (const b of (other.writeScopes || [])) {
1531
+ if (scopesOverlap(a, b)) out.push('与 ' + other.id + '(' + other.ownerId + ')的范围重叠:' + a + ' ~ ' + b)
1532
+ }
1533
+ }
1534
+ }
1535
+ return Array.from(new Set(out))
1536
+ }
1537
+ function taskView(task) {
1538
+ const t = Object.assign({}, task)
1539
+ t.ready = taskReady(task)
1540
+ t.ownerName = task.ownerId || ''
1541
+ t.writeScopeWarnings = writeScopeWarnings(task)
1542
+ return t
1543
+ }
1544
+
1545
+ // ---- context / compaction accounting ---------------------------------
1546
+ // A SHORT core-rules recap, injected ONLY (a) right after a REAL compaction, or
1547
+ // (b) in the same wake as a soft-compact directive — never on every round. The
1548
+ // charter itself lives in `persona` and needs no reinforcement otherwise.
1549
+ const CORE_RULES = '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
1550
+ '任何对象要进 Verified/ 必须 ≥m 名有表决权者一致给出 1 或 0,否则留库附平均概率;' +
1551
+ '你只写自己的库(Members/<你>/),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
1552
+ '退出时只输出一个 JSON 对象。'
1553
+
1554
+ // ONE place accounts for context usage on EVERY reply (normal, meeting, verify,
1555
+ // checkpoint). v4's defect (§24.1-③) was that only the normal branch consumed
1556
+ // `contextPct/compacted/needCompact`, so the flag stuck true and the compression
1557
+ // directive re-appeared at the head of every later prompt forever.
1558
+ function postmark(member, parsed) {
1559
+ if (!member) return
1560
+ if (parsed && parsed.contextPct !== undefined) contextPct.set(member.id, clPct(parsed.contextPct))
1561
+ if (parsed && parsed.compacted) {
1562
+ roundsSinceCompact.set(member.id, 0)
1563
+ contextPct.set(member.id, 15)
1564
+ seeds.set(member.id, String(parsed.progress || parsed.summary || '').slice(0, 4000))
1565
+ }
1566
+ needReanchor.delete(member.id)
1567
+ }
1568
+
1569
+ // ---- artifact libraries (per member, append/write by the member itself) ----
1570
+ const isOffice = (id) => !id || id === 'office'
1571
+ const isAcademician = (id) => { const m = memberById(id); return !!m && m.kind === 'academician' }
1572
+ function bumpArtifacts() {
1573
+ const inst0 = inst()
1574
+ const n = (Number(inst0.artifactCount) || 0) + 1
1575
+ commit(EV.progress, { at: now(), artifactCount: n }).catch(() => {})
1576
+ // Auto-sync meeting every `meetingKeepEvery` artifacts: the framework only
1577
+ // CONVENES it, never assigns work. Deferred while a meeting or verification is
1578
+ // already in progress so consensus is never preempted (v4 §26).
1579
+ const every = Math.max(0, Math.floor(Number(params.meetingKeepEvery) || 0))
1580
+ if (every > 0 && n % every === 0 && !meeting && !pendingMeeting && !hasVerifyInFlight()) {
1581
+ startMeeting('office', { agenda: '定期同步:分工 / 进展 / 是否需要验证', kind: 'sync' }).catch(() => {})
1582
+ }
1583
+ return n
1584
+ }
1585
+ async function publishProgress(memberId, text) {
1586
+ if (!memberId || !memberById(memberId)) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
1587
+ if (!String(text || '').trim()) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'empty progress' }
1588
+ const rel = 'Members/' + memberId + '/Progress/progress.md'
1589
+ const prev = (await readTextRel(rel)) || ''
1590
+ const ok = await writeTextRel(rel, prev + '\n### ' + fmtTime() + '|' + memberId + '\n' + String(text) + '\n')
1591
+ if (!ok) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
1592
+ await markProgress()
1593
+ return { ok: true, file: rel }
1594
+ }
1595
+ // Every recorded card must state 价值程度 / 动机用途计划 / 概率 — the charter's three
1596
+ // hard requirements. Missing fields are refused rather than silently defaulted,
1597
+ // so the libraries keep their meaning.
1598
+ async function recordCard(memberId, kind, o) {
1599
+ if (!memberId || !memberById(memberId)) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member' }
1600
+ const args = o || {}
1601
+ const missing = []
1602
+ if (args.value === undefined || args.value === null) missing.push('value(价值程度)')
1603
+ if (!String(args.motive || '').trim()) missing.push('motive(动机用途计划)')
1604
+ if (args.p === undefined || args.p === null) missing.push('p(你对它为真的概率估计)')
1605
+ if (missing.length) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: '入库必须写明:' + missing.join('、') }
1606
+ const prefix = kind === 'proposition' ? 'p' : kind === 'method' ? 'm' : 's'
1607
+ const id = idSafe(args.id) || (prefix + '-' + shortId())
1608
+ const dir = kind === 'proposition' ? 'Propos' : kind === 'method' ? 'Methods' : 'Subproblems'
1609
+ const rel = 'Members/' + memberId + '/' + dir + '/' + id + '.md'
1610
+ const head = [
1611
+ '# ' + (kind === 'proposition' ? '命题' : kind === 'method' ? '方法' : '子问题') + '|' + (args.title || id),
1612
+ '- 标题: ' + String(args.title || id),
1613
+ '- ID: ' + id,
1614
+ '- 类型: ' + (kind === 'proposition' ? '命题' : kind === 'method' ? String(args.type || '方法') : '子问题'),
1615
+ '- 状态: ' + (kind === 'proposition' ? '未定论' : kind === 'method' ? '经验' : '求解中'),
1616
+ kind === 'proposition' ? '- 概率: ' + clamp01(args.p).toFixed(2) : '- 概率: ' + clamp01(args.p).toFixed(2),
1617
+ '- 价值程度: ' + clamp01(args.value).toFixed(2),
1618
+ '- 动机用途计划: ' + String(args.motive),
1619
+ '- 记录者: ' + memberId,
1620
+ '- 记录时间: ' + fmtTime(),
1621
+ '- 依赖: []',
1622
+ '',
1623
+ ]
1624
+ let body
1625
+ if (kind === 'proposition') {
1626
+ body = ['## 陈述', String(args.statement || ''), '', '## 证明尝试', '', '## 证伪尝试', '']
1627
+ } else if (kind === 'method') {
1628
+ body = ['## 核心内容', String(args.content || args.statement || ''), '', '## 定义与记号', String(args.notation || ''), '', '## 应用记录', '## 改进历史', '']
1629
+ } else {
1630
+ body = ['## 陈述', String(args.statement || ''), '', '## 进度', '']
1631
+ }
1632
+ const ok = await writeTextRel(rel, head.concat(body).join('\n'))
1633
+ if (!ok) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + rel }
1634
+ bumpArtifacts()
1635
+ notifyActivity()
1636
+ return { ok: true, id, file: rel, kind }
1637
+ }
1638
+ async function readLibrary(query) {
1639
+ const q = query || {}
1640
+ const wantMember = q.member ? String(q.member) : ''
1641
+ const wantKind = q.kind ? String(q.kind) : ''
1642
+ const wantId = q.id ? idSafe(q.id) : ''
1643
+ const dirs = [['proposition', 'Propos'], ['method', 'Methods'], ['subproblem', 'Subproblems']]
1644
+ const members = wantMember ? [memberById(wantMember)].filter(Boolean) : activeMembers()
1645
+ const out = []
1646
+ for (const m of members) {
1647
+ for (const [kind, dir] of dirs) {
1648
+ if (wantKind && wantKind !== kind) continue
1649
+ if (wantId) {
1650
+ const t = await readTextRel('Members/' + m.id + '/' + dir + '/' + wantId + '.md')
1651
+ if (t !== undefined) out.push({ member: m.id, kind, id: wantId, text: t })
1652
+ continue
1653
+ }
1654
+ try {
1655
+ const dirT = await fs.resolve(instRoot() + '/Members/' + m.id + '/' + dir)
1656
+ if (await fs.stat(dirT) === undefined) continue
1657
+ const entries = await fs.listDir(dirT)
1658
+ for (const e of entries || []) {
1659
+ if (!e || e.type !== 'file' || !/\.md$/.test(String(e.name))) continue
1660
+ const t = await readTextRel('Members/' + m.id + '/' + dir + '/' + e.name)
1661
+ out.push({ member: m.id, kind, id: String(e.name).replace(/\.md$/, ''), text: String(t || '').slice(0, 4000) })
1662
+ }
1663
+ } catch (e) { /* listing is best-effort */ }
1664
+ }
1665
+ if (!wantKind && !wantId) {
1666
+ const p = await readTextRel('Members/' + m.id + '/Progress/progress.md')
1667
+ if (p !== undefined) out.push({ member: m.id, kind: 'progress', id: 'progress', text: String(p).slice(-6000) })
1668
+ }
1669
+ }
1670
+ return { ok: true, count: out.length, items: out }
1671
+ }
1672
+
1673
+ // ---- task board (compare-and-set DAG, ported from DSH agent-teams) ----
1674
+ function nextTaskId() {
1675
+ const c = Number(inst().counters.task) || 0
1676
+ return { id: 't-' + (c + 1), n: c + 1 }
1677
+ }
1678
+ // DAG validation: self-reference, duplicates, and missing/deleted blockers are
1679
+ // refused up front; a cycle is detected over the WHOLE candidate graph, exactly
1680
+ // like the DSH original, so a bad dependency can never be stored.
1681
+ function validateDeps(candidateId, blockedBy) {
1682
+ const tasks = inst().tasks
1683
+ const seen = new Set()
1684
+ for (const raw of (blockedBy || [])) {
1685
+ const id = String(raw)
1686
+ if (id === candidateId) throw v5err('V5_TASK_DEPENDENCY_CYCLE', 'a task cannot depend on itself')
1687
+ if (seen.has(id)) throw v5err('V5_INVALID_ARGUMENT', 'duplicate blocker ' + id)
1688
+ seen.add(id)
1689
+ const t = tasks.find((x) => x.id === id)
1690
+ if (!t || t.status === 'deleted') throw v5err('V5_TASK_NOT_FOUND', 'blocker ' + id + ' not found')
1691
+ }
1692
+ // cycle detection over the candidate graph
1693
+ const graph = new Map()
1694
+ for (const t of tasks) {
1695
+ if (t.status === 'deleted') continue
1696
+ graph.set(t.id, t.id === candidateId ? Array.from(seen) : (t.blockedBy || []).slice())
1697
+ }
1698
+ if (!graph.has(candidateId)) graph.set(candidateId, Array.from(seen))
1699
+ const state = new Map()
1700
+ const walk = (id) => {
1701
+ const st = state.get(id)
1702
+ if (st === 1) return true
1703
+ if (st === 2) return false
1704
+ state.set(id, 1)
1705
+ for (const d of (graph.get(id) || [])) { if (graph.has(d) && walk(d)) return true }
1706
+ state.set(id, 2)
1707
+ return false
1708
+ }
1709
+ for (const id of graph.keys()) { if (walk(id)) throw v5err('V5_TASK_DEPENDENCY_CYCLE', 'dependency cycle through ' + id) }
1710
+ }
1711
+ async function taskCreate(memberId, o) {
1712
+ const args = o || {}
1713
+ const subject = String(args.subject || '').trim()
1714
+ if (!subject) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'subject is required' }
1715
+ if (subject.length > 200) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'subject must be <= 200 chars' }
1716
+ const description = String(args.description || '')
1717
+ if (description.length > 16384) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'description must be <= 16384 chars' }
1718
+ const scopes = []
1719
+ for (const s of (args.write_scopes || args.writeScopes || [])) {
1720
+ const n = normalizeScope(s)
1721
+ if (n === undefined) return { ok: false, code: 'V5_INVALID_WRITE_SCOPE', message: 'invalid write scope: ' + String(s) }
1722
+ if (scopes.indexOf(n) === -1) scopes.push(n)
1723
+ }
1724
+ const blockedBy = (args.blocked_by || args.blockedBy || []).map(String)
1725
+ const { id, n } = nextTaskId()
1726
+ validateDeps(id, blockedBy)
1727
+ const counters = Object.assign({}, inst().counters); counters.task = n
1728
+ const task = {
1729
+ id, revision: 1, subject, description,
1730
+ status: 'pending', ownerId: '',
1731
+ blockedBy, writeScopes: scopes,
1732
+ priority: Number.isFinite(Number(args.priority)) ? Number(args.priority) : 0,
1733
+ createdBy: isOffice(memberId) ? 'office' : memberId,
1734
+ assignedBy: '', why: '', acceptance: '',
1735
+ createdAt: now(), updatedAt: now(),
1736
+ }
1737
+ await putCounters(counters)
1738
+ await putTask(task)
1739
+ await writeTaskboardMirror()
1740
+ await markProgress()
1741
+ notifyActivity()
1742
+ return { ok: true, task: taskView(task) }
1743
+ }
1744
+ function listTasks(filter) {
1745
+ const f = filter || {}
1746
+ let ts = inst().tasks.filter((t) => t.status !== 'deleted')
1747
+ if (f.status) ts = ts.filter((t) => t.status === f.status)
1748
+ if (f.owner) ts = ts.filter((t) => (f.owner === 'unowned' ? !t.ownerId : t.ownerId === f.owner))
1749
+ if (f.ready === true) ts = ts.filter((t) => taskReady(t))
1750
+ ts = ts.slice().sort((a, b) => (b.priority - a.priority) || (a.createdAt - b.createdAt))
1751
+ return ts.map(taskView)
1752
+ }
1753
+ function getTask(id) {
1754
+ const t = inst().tasks.find((x) => x.id === String(id))
1755
+ if (!t) throw v5err('V5_TASK_NOT_FOUND', 'task ' + id + ' not found')
1756
+ return taskView(t)
1757
+ }
1758
+ async function taskUpdate(memberId, o) {
1759
+ const args = o || {}
1760
+ const id = String(args.task_id || args.taskId || '')
1761
+ const task = inst().tasks.find((x) => x.id === id)
1762
+ if (!task) return { ok: false, code: 'V5_TASK_NOT_FOUND', message: 'task ' + id + ' not found' }
1763
+ if (task.status === 'deleted') return { ok: false, code: 'V5_TASK_DELETED', message: 'task ' + id + ' is deleted' }
1764
+ const expected = Number(args.expected_revision !== undefined ? args.expected_revision : args.expectedRevision)
1765
+ if (!Number.isFinite(expected)) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'expected_revision is required' }
1766
+ if (expected !== task.revision) {
1767
+ return { ok: false, code: 'V5_TASK_STALE_REVISION', message: 'task ' + id + ' is at revision ' + task.revision + ', not ' + expected + ' — re-read it with vibe_v5_task_get' }
1768
+ }
1769
+ const action = String(args.action || '')
1770
+ const office = isOffice(memberId)
1771
+ const acad = isAcademician(memberId)
1772
+ const lead = office || acad
1773
+ const owner = task.ownerId === memberId
1774
+ const requireOwnerOrLead = () => {
1775
+ if (!lead && !owner) throw v5err('V5_TASK_UNAUTHORIZED', 'task mutation requires its owner, the academician, or the office')
1776
+ }
1777
+ const next = Object.assign({}, task)
1778
+ try {
1779
+ if (action === 'claim') {
1780
+ if (task.ownerId && task.ownerId !== memberId) throw v5err('V5_TASK_ALREADY_CLAIMED', 'task ' + id + ' is owned by ' + task.ownerId)
1781
+ if (task.status !== 'pending') throw v5err('V5_TASK_INVALID_TRANSITION', 'only a pending task can be claimed')
1782
+ if (!taskReady(task)) throw v5err('V5_TASK_BLOCKED', 'task ' + id + ' still has incomplete blockers')
1783
+ next.status = 'in_progress'
1784
+ next.ownerId = office ? (task.ownerId || '') : memberId
1785
+ } else if (action === 'release') {
1786
+ requireOwnerOrLead()
1787
+ if (task.status !== 'in_progress') throw v5err('V5_TASK_INVALID_TRANSITION', 'only an in-progress task can be released')
1788
+ next.status = 'pending'; next.ownerId = ''
1789
+ } else if (action === 'edit') {
1790
+ requireOwnerOrLead()
1791
+ if (args.subject === undefined && args.description === undefined && args.write_scopes === undefined && args.writeScopes === undefined) {
1792
+ throw v5err('V5_INVALID_ARGUMENT', 'edit needs at least one of subject/description/write_scopes')
1793
+ }
1794
+ if (args.subject !== undefined) next.subject = String(args.subject).slice(0, 200)
1795
+ if (args.description !== undefined) next.description = String(args.description).slice(0, 16384)
1796
+ if (args.write_scopes !== undefined || args.writeScopes !== undefined) {
1797
+ const scopes = []
1798
+ for (const s of (args.write_scopes || args.writeScopes || [])) {
1799
+ const n = normalizeScope(s)
1800
+ if (n === undefined) throw v5err('V5_INVALID_WRITE_SCOPE', 'invalid write scope: ' + String(s))
1801
+ if (scopes.indexOf(n) === -1) scopes.push(n)
1802
+ }
1803
+ next.writeScopes = scopes
1804
+ }
1805
+ } else if (action === 'set_dependencies') {
1806
+ requireOwnerOrLead()
1807
+ const raw = args.blocked_by !== undefined ? args.blocked_by : args.blockedBy
1808
+ if (raw === undefined) throw v5err('V5_INVALID_ARGUMENT', 'set_dependencies needs blocked_by (may be [])')
1809
+ const deps = (raw || []).map(String)
1810
+ validateDeps(id, deps)
1811
+ next.blockedBy = deps
1812
+ } else if (action === 'complete') {
1813
+ requireOwnerOrLead()
1814
+ if (task.status !== 'in_progress') throw v5err('V5_TASK_INVALID_TRANSITION', 'only an in-progress task can be completed')
1815
+ next.status = 'completed'
1816
+ } else if (action === 'reopen') {
1817
+ requireOwnerOrLead()
1818
+ if (task.status !== 'completed') throw v5err('V5_TASK_INVALID_TRANSITION', 'only a completed task can be reopened')
1819
+ next.status = 'pending'; next.ownerId = ''
1820
+ } else if (action === 'reassign') {
1821
+ if (!lead) throw v5err('V5_TASK_UNAUTHORIZED', 'only the academician or the office can reassign tasks')
1822
+ if (task.status !== 'pending' && task.status !== 'in_progress') throw v5err('V5_TASK_INVALID_TRANSITION', 'only pending/in-progress tasks can be reassigned')
1823
+ const target = String(args.owner || '').trim()
1824
+ if (!target) { next.status = 'pending'; next.ownerId = '' }
1825
+ else {
1826
+ const m = memberById(target)
1827
+ if (!m || m.phase !== 'active') throw v5err('V5_MEMBER_NOT_FOUND', 'active member "' + target + '" not found')
1828
+ if (!taskReady(task)) throw v5err('V5_TASK_BLOCKED', 'task ' + id + ' still has incomplete blockers')
1829
+ next.status = 'in_progress'; next.ownerId = target
1830
+ }
1831
+ } else if (action === 'delete') {
1832
+ requireOwnerOrLead()
1833
+ const dependents = inst().tasks.filter((t) => t.status !== 'deleted' && t.id !== id && (t.blockedBy || []).indexOf(id) !== -1)
1834
+ if (dependents.length) throw v5err('V5_TASK_HAS_DEPENDENTS', 'cannot delete ' + id + ': ' + dependents.map((d) => d.id).join(', ') + ' depend(s) on it')
1835
+ next.status = 'deleted'
1836
+ } else {
1837
+ throw v5err('V5_INVALID_ARGUMENT', 'unknown action "' + action + '"')
1838
+ }
1839
+ } catch (e) {
1840
+ return { ok: false, code: e.code || 'V5_INVALID_ARGUMENT', message: String((e && e.message) || e) }
1841
+ }
1842
+ next.revision = task.revision + 1
1843
+ next.updatedAt = now()
1844
+ await putTask(next)
1845
+ await writeTaskboardMirror()
1846
+ await markProgress()
1847
+ notifyActivity()
1848
+ return { ok: true, task: taskView(next) }
1849
+ }
1850
+ // The academician's ASSIGN. Mechanically this is a reassign that also records WHY
1851
+ // and the acceptance criteria, and then wakes the assignee. It affects WORK only:
1852
+ // it can never make any statement true, and the assignee may object with reasons
1853
+ // (the objection is broadcast, not silently swallowed).
1854
+ async function taskAssign(memberId, o) {
1855
+ if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
1856
+ return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks' }
1857
+ }
1858
+ const args = o || {}
1859
+ const to = String(args.to || '').trim()
1860
+ const target = memberById(to)
1861
+ if (!target || target.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + to + '" not found' }
1862
+ const why = String(args.why || '').trim()
1863
+ if (!why) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'assign 必须写明 why(为什么派给他)' }
1864
+ const acceptance = String(args.acceptance || '').trim()
1865
+ if (!acceptance) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'assign 必须写明 acceptance(验收标准)' }
1866
+ let taskId = args.task_id ? String(args.task_id) : ''
1867
+ if (!taskId) {
1868
+ const created = await taskCreate(memberId, {
1869
+ subject: String(args.subject || '').trim() || ('(院士分派)' + why.slice(0, 60)),
1870
+ description: String(args.description || why),
1871
+ priority: args.priority,
1872
+ write_scopes: args.write_scopes,
1873
+ })
1874
+ if (!created.ok) return created
1875
+ taskId = created.task.id
1876
+ }
1877
+ const cur = inst().tasks.find((t) => t.id === taskId)
1878
+ if (cur && (cur.blockedBy || []).length && !taskReady(cur)) {
1879
+ return { ok: false, code: 'V5_TASK_BLOCKED', message: 'task ' + taskId + ' still has incomplete blockers' }
1880
+ }
1881
+ const r = await taskUpdate(memberId, { task_id: taskId, expected_revision: (cur ? cur.revision : 1), action: 'reassign', owner: to })
1882
+ if (!r.ok) return r
1883
+ const after = inst().tasks.find((t) => t.id === taskId)
1884
+ const withMeta = Object.assign({}, after, { assignedBy: isOffice(memberId) ? 'office' : memberId, why, acceptance })
1885
+ await putTask(withMeta)
1886
+ await writeTaskboardMirror()
1887
+ const assignerIsOffice = isOffice(memberId)
1888
+ await say(assignerIsOffice ? 'office' : memberId, {
1889
+ to, kind: 'assign',
1890
+ text: '任务 ' + taskId + '「' + withMeta.subject + '」分派给你。理由:' + why + '|验收标准:' + acceptance +
1891
+ '。默认应当执行;若你认为方向有误,请说明理由(会被广播给全所)。' +
1892
+ '若你有异议,请在 JSON 里填 reject_assign。',
1893
+ })
1894
+ await wakeIfIdle(target)
1895
+ return { ok: true, task: taskView(withMeta) }
1896
+ }
1897
+ async function taskPrioritize(memberId, o) {
1898
+ if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
1899
+ return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities' }
1900
+ }
1901
+ const order = (o && o.order) || []
1902
+ if (!Array.isArray(order) || !order.length) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'order must be a non-empty array of {task_id, priority}' }
1903
+ const applied = []
1904
+ for (const row of order) {
1905
+ const t = inst().tasks.find((x) => x.id === String(row && row.task_id))
1906
+ if (!t || t.status === 'deleted') continue
1907
+ const next = Object.assign({}, t, {
1908
+ priority: Number.isFinite(Number(row.priority)) ? Number(row.priority) : t.priority,
1909
+ revision: t.revision + 1, updatedAt: now(),
1910
+ })
1911
+ await putTask(next)
1912
+ applied.push({ id: next.id, priority: next.priority })
1913
+ }
1914
+ await writeTaskboardMirror()
1915
+ notifyActivity()
1916
+ return { ok: true, applied, why: String((o && o.why) || '') }
1917
+ }
1918
+ // Reclaim every task a dismissed member owns — DSH's own board explicitly does
1919
+ // NOT auto-release an owner (documented limitation), which is the gap v5 closes.
1920
+ async function releaseTasksOf(memberId, reason) {
1921
+ const mine = inst().tasks.filter((t) => t.ownerId === memberId && t.status === 'in_progress')
1922
+ for (const t of mine) {
1923
+ await putTask(Object.assign({}, t, { status: 'pending', ownerId: '', revision: t.revision + 1, updatedAt: now(), releaseReason: reason || '' }))
1924
+ }
1925
+ if (mine.length) await writeTaskboardMirror()
1926
+ return mine.map((t) => t.id)
1927
+ }
1928
+ async function writeTaskboardMirror() {
1929
+ const ts = listTasks()
1930
+ const lines = ['# 任务板(人读镜像)|' + instituteName + '|' + fmtTime(), '',
1931
+ '> 权威状态在会话日志投影里;本文件只是给人和所办看的快照,勿手改。', '']
1932
+ if (!ts.length) lines.push('(暂无任务)')
1933
+ for (const t of ts) {
1934
+ lines.push('- [' + t.status + '] ' + t.id + '|' + t.subject + '|owner=' + (t.owner || t.ownerName || '(未认领)') +
1935
+ '|rev=' + t.revision + '|优先级=' + t.priority + (t.ready ? '|可认领' : ''))
1936
+ if (t.assignedBy) lines.push(' 分派者:' + t.assignedBy + '|理由:' + (t.why || '') + '|验收:' + (t.acceptance || ''))
1937
+ if ((t.blockedBy || []).length) lines.push(' 依赖:' + t.blockedBy.join('、'))
1938
+ if ((t.writeScopeWarnings || []).length) lines.push(' ⚠ ' + t.writeScopeWarnings.join(';'))
1939
+ }
1940
+ await writeTextRel('Shared/TaskBoard.md', lines.join('\n'))
1941
+ }
1942
+ // The roster mirror (§8.4): a human-readable staffing table. Like the task-board
1943
+ // mirror it is WRITE-ONLY — the authoritative roster is the session-log projection,
1944
+ // so losing or hand-editing this file can never corrupt the institute.
1945
+ async function writeRosterMirror() {
1946
+ const s = inst()
1947
+ const lines = ['# 研究所编制表(人读镜像)|' + instituteName + '|' + fmtTime(), '',
1948
+ '> 权威状态在会话日志投影里;本文件只是快照,勿手改。', '']
1949
+ lines.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
1950
+ lines.push('- 阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
1951
+ lines.push('')
1952
+ lines.push('| 代号 | 职位 | 状态 | 雇主 | 方向/用途 | 轮次 | 上下文% |')
1953
+ lines.push('|---|---|---|---|---|---|---|')
1954
+ // Dismissed members belong ONLY in the 已除名 section below. Listing them here too
1955
+ // showed the same person twice and made the roster look like they were still on
1956
+ // staff.
1957
+ const onBooks = s.members.filter((m) => m.phase !== 'dismissed')
1958
+ if (!onBooks.length) lines.push('| (暂无成员) | | | | | | |')
1959
+ for (const m of onBooks) {
1960
+ lines.push('| ' + m.id + ' | ' + kindLabel(m.kind) + ' | ' + m.phase + ' | ' + (m.hiredBy || '—') + ' | ' +
1961
+ String(m.direction || '—').replace(/\|/g, '/').slice(0, 80) + ' | ' + (rounds.get(m.id) || 0) + ' | ' +
1962
+ (contextPct.get(m.id) || 0) + ' |')
1963
+ }
1964
+ lines.push('')
1965
+ if (s.members.some((m) => m.phase === 'dismissed')) {
1966
+ lines.push('## 已除名(代号永不复用)')
1967
+ for (const m of s.members.filter((x) => x.phase === 'dismissed')) {
1968
+ lines.push('- ' + m.id + '(' + kindLabel(m.kind) + ')|' + fmtTime(m.dismissedAt) + '|原因:' + (m.dismissReason || '未说明'))
1969
+ }
1970
+ lines.push('')
1971
+ }
1972
+ await writeTextRel('Institutes.md', lines.join('\n'))
1973
+ }
1974
+
1975
+ // ---- consensus verification (m-vote boolean) --------------------------
1976
+ function verifyRecords() { return Object.keys(inst().verdicts) }
1977
+ function currentVerify() {
1978
+ const vs = inst().verdicts
1979
+ for (const k of Object.keys(vs)) { if (vs[k] && !vs[k].closed) return vs[k] }
1980
+ return null
1981
+ }
1982
+ function hasVerifyInFlight() { return !!currentVerify() }
1983
+ function guessTargetKind(target) {
1984
+ const t = String(target || '')
1985
+ if (/^m[-_]/.test(t)) return 'method'
1986
+ if (/^s[-_]/.test(t)) return 'subproblem'
1987
+ return 'proposition'
1988
+ }
1989
+ function cardDeclaresId(content, target) {
1990
+ if (!content || !target) return false
1991
+ const m = /-\s*ID:\s*([^;\n]+)/.exec(content)
1992
+ return !!(m && String(m[1]).trim() === String(target).trim())
1993
+ }
1994
+ // Locate the source card. Returns null rather than a guessed path: writing to
1995
+ // 'Propos/<target>.md' when the card does not exist used to create a 0-byte
1996
+ // stray file AND leave the real card unwritten (v4 §26 test9).
1997
+ async function findSourceRel(target, owner) {
1998
+ const members = activeMembers().map((m) => m.id)
1999
+ const order = owner ? [owner].concat(members.filter((k) => k !== owner)) : members
2000
+ for (const rid of order) {
2001
+ for (const base of ['Propos', 'Methods', 'Subproblems']) {
2002
+ const cand = 'Members/' + rid + '/' + base + '/' + target + '.md'
2003
+ const t = await readTextRel(cand)
2004
+ if (t !== undefined) return cand
2005
+ }
2006
+ }
2007
+ // Declared-ID scan: members sometimes name a file differently from the ID it
2008
+ // declares (e.g. p-01.md declaring "- ID: p-r3-01").
2009
+ try {
2010
+ for (const rid of order) {
2011
+ for (const base of ['Propos', 'Methods', 'Subproblems']) {
2012
+ const dirPath = instRoot() + '/Members/' + rid + '/' + base
2013
+ const dirT = await fs.resolve(dirPath)
2014
+ if (await fs.stat(dirT) === undefined) continue
2015
+ const entries = await fs.listDir(dirT)
2016
+ for (const e of entries || []) {
2017
+ if (!e || e.type !== 'file' || !/\.md$/.test(String(e.name))) continue
2018
+ const c = await readTextRel('Members/' + rid + '/' + base + '/' + e.name)
2019
+ if (c !== undefined && cardDeclaresId(c, target)) return 'Members/' + rid + '/' + base + '/' + e.name
2020
+ }
2021
+ }
2022
+ }
2023
+ } catch (e) { /* scanning is best-effort */ }
2024
+ return null
2025
+ }
2026
+ async function resolveTargetStatement(target, owner) {
2027
+ const rel = await findSourceRel(target, owner)
2028
+ if (!rel) return { rel: null, statement: '' }
2029
+ const text = (await readTextRel(rel)) || ''
2030
+ const m = /##\s*陈述\s*\n([\s\S]*?)(?:\n##\s|$)/.exec(text)
2031
+ return { rel, statement: String(m ? m[1] : text).trim().slice(0, 1200) }
2032
+ }
2033
+ // Update one `- 字段:` of a source card. Members hand-write cards in two shapes —
2034
+ // one field per line, or one line with '; '-separated fields — so the anchor may
2035
+ // sit anywhere on a line and consume up to the next ';' (v4 §26 test9).
2036
+ function rewriteCardField(text, field, newValue) {
2037
+ if (!text) return text
2038
+ const esc = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2039
+ const re = new RegExp('(-\\s*' + esc + '\\s*:\\s*)([^;\\n]*)')
2040
+ if (re.test(text)) return text.replace(re, (_all, p1) => p1 + newValue)
2041
+ return text
2042
+ }
2043
+ async function rewriteSource(target, owner, patch) {
2044
+ const rel = await findSourceRel(target, owner)
2045
+ if (!rel) return false
2046
+ let text = await readTextRel(rel)
2047
+ if (text === undefined) return false
2048
+ for (const [field, value] of Object.entries(patch)) text = rewriteCardField(text, field, value)
2049
+ return await writeTextRel(rel, text)
2050
+ }
2051
+ // The judgement rule (§9.2 of the plan). A vote is a [0,1] probability; ONLY
2052
+ // exactly 1 (assert true) or exactly 0 (assert false) counts as an assertion.
2053
+ // Anything strictly between is an abstention: excluded from the quorum, included
2054
+ // in the mean. Any opposing assertion BLOCKS the verdict, so a minority can never
2055
+ // be out-voted by abstention.
2056
+ function judgeVerdict(vs) {
2057
+ const E = voters().map((m) => m.id)
2058
+ const P = E.length
2059
+ const m = quorumM()
2060
+ const votes = vs.votes || {}
2061
+ let bTrue = 0, bFalse = 0, abstain = 0
2062
+ const all = []
2063
+ for (const id of E) {
2064
+ const v = votes[id]
2065
+ if (!v) continue
2066
+ const p = Number(v.prob)
2067
+ all.push(p)
2068
+ if (p === 1) bTrue += 1
2069
+ else if (p === 0) bFalse += 1
2070
+ else abstain += 1
2071
+ }
2072
+ const mean = all.length ? all.reduce((a, x) => a + x, 0) / all.length : 0.5
2073
+ const base = { m, P, bTrue, bFalse, abstain, mean, votedCount: all.length, voters: E }
2074
+ if (params.quorumMode === 'all-unanimous') {
2075
+ const allVoted = E.length > 0 && E.every((id) => votes[id])
2076
+ if (!allVoted) return Object.assign(base, { outcome: 'undecided', reason: 'not every voter has voted' })
2077
+ if (bTrue === E.length && bFalse === 0) return Object.assign(base, { outcome: 'true', reason: 'unanimous true' })
2078
+ if (bFalse === E.length && bTrue === 0) return Object.assign(base, { outcome: 'false', reason: 'unanimous false' })
2079
+ return Object.assign(base, { outcome: 'undecided', reason: 'not unanimous' })
2080
+ }
2081
+ if (bTrue + bFalse < m) {
2082
+ return Object.assign(base, { outcome: 'undecided', reason: 'only ' + (bTrue + bFalse) + ' boolean vote(s); m=' + m + ' required' })
2083
+ }
2084
+ if (bTrue > 0 && bFalse > 0) {
2085
+ return Object.assign(base, { outcome: 'undecided', reason: 'conflicting assertions (true=' + bTrue + ', false=' + bFalse + ')' })
2086
+ }
2087
+ if (bTrue >= m && bFalse === 0) return Object.assign(base, { outcome: 'true', reason: bTrue + ' >= m=' + m + ', all assert true' })
2088
+ if (bFalse >= m && bTrue === 0) return Object.assign(base, { outcome: 'false', reason: bFalse + ' >= m=' + m + ', all assert false' })
2089
+ return Object.assign(base, { outcome: 'undecided', reason: 'quorum not met' })
2090
+ }
2091
+ // Queue a proposal UNLESS the same object was just closed as 真/假 (a dedup window
2092
+ // prevents several members independently proposing the same object in one tick
2093
+ // from running it end-to-end twice — v4 §26 test9).
2094
+ async function maybeQueueVerify(target, kind, proposer, reason) {
2095
+ const t = idSafe(target)
2096
+ if (!t) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'target id is empty after sanitising' }
2097
+ const recent = verifiedRecently.get(t)
2098
+ if (recent !== undefined && (now() - recent) < recoverStallMs()) {
2099
+ return { ok: true, deduped: true, message: t + ' 刚刚定论,忽略重复提议' }
2100
+ }
2101
+ const q = inst().queue.slice()
2102
+ if (q.some((p) => p.target === t)) return { ok: true, deduped: true, message: t + ' 已在验证队列中' }
2103
+ if (currentVerify() && currentVerify().target === t) return { ok: true, deduped: true, message: t + ' 正在验证中' }
2104
+ q.push({ target: t, kind: kind || guessTargetKind(t), proposer: isOffice(proposer) ? 'office' : proposer, reason: String(reason || ''), at: now() })
2105
+ await putQueue(q)
2106
+ notifyActivity()
2107
+ // Start it NOW rather than hoping a scheduling pass reaches it. A pass may already
2108
+ // be in flight and PAST its arming point, in which case a bare `scheduleNext()`
2109
+ // only sets the trampoline flag and the proposal waits for the next iteration.
2110
+ await armNextVerify()
2111
+ if (!hasVerifyInFlight()) await scheduleNext()
2112
+ return { ok: true, queued: t, pendingVerifyCount: q.length, started: hasVerifyInFlight() }
2113
+ }
2114
+ async function beginVerify(proposal) {
2115
+ dbg.begin += 1
2116
+ const target = proposal.target
2117
+ const resolved = await resolveTargetStatement(target, proposal.proposer)
2118
+ const vs = {
2119
+ target,
2120
+ kind: proposal.kind || guessTargetKind(target),
2121
+ proposer: proposal.proposer || '',
2122
+ reason: proposal.reason || '',
2123
+ statement: resolved.statement,
2124
+ sourceRel: resolved.rel || '',
2125
+ stage: 'initial',
2126
+ round: 1,
2127
+ votes: {},
2128
+ history: null,
2129
+ lastVoteAt: now(),
2130
+ closed: false,
2131
+ outcome: '',
2132
+ mean: 0,
2133
+ m: quorumM(),
2134
+ P: voterCount(),
2135
+ createdAt: now(),
2136
+ }
2137
+ await putVerdict(target, vs)
2138
+ await saveChatLine('【求真表决】对 ' + target + '(' + kindLabel2(vs.kind) + ')发起验证;法定票数 m=' + vs.m +
2139
+ ',有表决权者 ' + vs.P + ' 人。先独立初评(彼此不可见),未定论再公开辩论。')
2140
+ notifyActivity()
2141
+ return vs
2142
+ }
2143
+ async function askVoters(vs) {
2144
+ let asked = 0
2145
+ for (const v of voters()) {
2146
+ if (vs.votes[v.id]) continue
2147
+ const ok = await wakeMember(v, verifyPrompt(v, vs), 'verify')
2148
+ if (ok) asked += 1
2149
+ }
2150
+ if (!asked) armHeartbeat()
2151
+ return asked
2152
+ }
2153
+ async function continueVerifyRound(vs) {
2154
+ if (!vs || vs.closed) return
2155
+ const stale = now() - Number(vs.lastVoteAt || vs.createdAt || now())
2156
+ if (stale >= recoverStallMs()) {
2157
+ // Deadlock watchdog: a broken verification may block consensus for at most
2158
+ // recoverStallMs, then it is abandoned and control returns to the institute's
2159
+ // own self-organization (v4 §26).
2160
+ await putVerdict(vs.target, Object.assign({}, vs, {
2161
+ closed: true, outcome: 'undecided', reason: 'abandoned (stuck)',
2162
+ mean: judgeVerdict(vs).mean, closedAt: now(),
2163
+ }))
2164
+ await saveChatLine('【求真表决】' + vs.target + ' 因长时间无新票而被放弃,保留为未定论(附平均概率)。')
2165
+ await armNextVerify()
2166
+ await scheduleNext()
2167
+ return
2168
+ }
2169
+ // A round ENDS only when every voter has answered. This guard is load-bearing:
2170
+ // continueVerifyRound runs on EVERY scheduling pass, so without it an unrelated
2171
+ // member's turn would advance (and eventually exhaust) the debate rounds with no
2172
+ // new information at all — silently closing a verification nobody had voted on.
2173
+ const need = voters().map((m) => m.id)
2174
+ const missing = need.filter((id) => !vs.votes[id])
2175
+ if (missing.length) {
2176
+ let asked = 0
2177
+ for (const id of missing) {
2178
+ const m = memberById(id)
2179
+ if (!m || m.phase !== 'active' || busy.has(id)) continue
2180
+ const ok = await wakeMember(m, verifyPrompt(m, vs), 'verify')
2181
+ if (ok) asked += 1
2182
+ }
2183
+ if (!asked) armHeartbeat()
2184
+ return
2185
+ }
2186
+ // Only ONE settle may run at a time: two concurrent subagent/end handlers can both
2187
+ // observe "every voter has answered" and would otherwise close the SAME object
2188
+ // twice — a duplicate Verified card and a duplicate debate record. The lock is
2189
+ // released BEFORE the trailing scheduling pass, so a chained verification is
2190
+ // never swallowed by a still-held lock (v4 §26).
2191
+ if (finalizeLock) return
2192
+ finalizeLock = 'verify'
2193
+ try {
2194
+ const j = judgeVerdict(vs)
2195
+ if (j.outcome === 'true' || j.outcome === 'false') {
2196
+ await closeVerify(vs, j.outcome === 'true', j)
2197
+ } else if (vs.round >= Math.max(1, Math.floor(Number(params.verdictMaxRounds) || 3))) {
2198
+ await finalizeUndecided(vs, j)
2199
+ } else {
2200
+ // Move to a REAL debate round: snapshot this round's votes into `history`
2201
+ // (so the next prompt can show what others thought), then CLEAR `votes` so every
2202
+ // voter is genuinely re-asked. Without the clear, "all voted" stays true and the
2203
+ // debate rounds burn through with NOBODY being re-asked (v4 §8 implementation note).
2204
+ const next = Object.assign({}, vs, {
2205
+ history: Object.assign({}, vs.votes),
2206
+ votes: {},
2207
+ stage: 'debate',
2208
+ round: vs.round + 1,
2209
+ lastVoteAt: now(),
2210
+ })
2211
+ await putVerdict(vs.target, next)
2212
+ await saveChatLine('【求真表决】' + vs.target + ' 第 ' + vs.round + ' 轮未定论(' + j.reason + ')。' +
2213
+ '公开辩论并重新表决:' + Object.entries(next.history).map(([k, v]) => k + '=' + Number(v.prob)).join('、'))
2214
+ await askVoters(next)
2215
+ }
2216
+ } finally {
2217
+ finalizeLock = null
2218
+ }
2219
+ await scheduleNext()
2220
+ }
2221
+ async function finalizeUndecided(vs, j) {
2222
+ await writeDebateDoc(vs, false, j)
2223
+ // Keep it in the library with the group's MEAN probability — the design's
2224
+ // "留库附概率". A missing source card is skipped rather than creating garbage.
2225
+ await rewriteSource(vs.target, vs.proposer, { '状态': '未定论', '概率': Number(j.mean).toFixed(2) })
2226
+ await putVerdict(vs.target, Object.assign({}, vs, {
2227
+ closed: true, outcome: 'undecided', reason: j.reason, mean: j.mean,
2228
+ m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
2229
+ }))
2230
+ await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: 'undecided' })
2231
+ await saveChatLine('【求真表决】' + vs.target + ' 未达门槛(' + j.reason + ');留库为未定论,平均概率 ' +
2232
+ Number(j.mean).toFixed(2) + '。辩论记录见 Shared/Debates/' + vs.target + '.md')
2233
+ await markProgress()
2234
+ await armNextVerify()
2235
+ await scheduleNext()
2236
+ }
2237
+ async function closeVerify(vs, isTrue, j) {
2238
+ await writeDebateDoc(vs, true, isTrue ? 1 : 0)
2239
+ await writeVerifiedCard(vs, isTrue, j)
2240
+ const status = isTrue ? '已验证·真' : '已验证·假'
2241
+ await rewriteSource(vs.target, vs.proposer, { '状态': status, '概率': isTrue ? '1' : '0' })
2242
+ verifiedRecently.set(vs.target, now())
2243
+ await putVerdict(vs.target, Object.assign({}, vs, {
2244
+ closed: true, outcome: isTrue ? 'true' : 'false', mean: isTrue ? 1 : 0,
2245
+ m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
2246
+ }))
2247
+ await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: isTrue ? 'true' : 'false' })
2248
+ await saveChatLine('【求真结论】' + vs.target + ' 经 ' + (isTrue ? j.bTrue : j.bFalse) + ' 名有表决权者一致判' +
2249
+ (isTrue ? '真' : '假') + '(m=' + j.m + '),已写入 Verified/。来源卡已标注「' + status + '」。')
2250
+ await markProgress()
2251
+ await armNextVerify()
2252
+ await scheduleNext()
2253
+ }
2254
+ let beginLock = false
2255
+ async function armNextVerify() {
2256
+ dbg.arm += 1
2257
+ // Only one begin may be in flight. Without this, two callers (a scheduling pass
2258
+ // and a fresh proposal) could both pass the `currentVerify()` check before either
2259
+ // has published its verdict record and would start the SAME object twice.
2260
+ if (beginLock) return
2261
+ if (currentVerify()) return
2262
+ beginLock = true
2263
+ try {
2264
+ const q = inst().queue.slice()
2265
+ while (q.length) {
2266
+ const p = q.shift()
2267
+ await putQueue(q)
2268
+ const recent = verifiedRecently.get(p.target)
2269
+ if (recent !== undefined && (now() - recent) < recoverStallMs()) continue
2270
+ const vs = await beginVerify(p)
2271
+ await askVoters(vs)
2272
+ return
2273
+ }
2274
+ } finally {
2275
+ beginLock = false
2276
+ }
2277
+ }
2278
+ async function writeDebateDoc(vs, done, val) {
2279
+ const j = typeof val === 'object' ? val : null
2280
+ const lines = ['# 验证辩论|' + vs.target + '(' + kindLabel2(vs.kind) + ')|' + fmtTime(), '']
2281
+ if (done) lines.push('**结论**:全体一致为' + (val === 1 || val === 'true' ? '真' : '假') + '(写入 Verified/)')
2282
+ else lines.push('**未达门槛**:平均概率 ' + Number(j ? j.mean : val).toFixed(2) + '|原因:' + (j ? j.reason : '') +
2283
+ '|m=' + (j ? j.m : '?') + '|布尔票 真' + (j ? j.bTrue : '?') + '/假' + (j ? j.bFalse : '?') + '/弃权' + (j ? j.abstain : '?'))
2284
+ lines.push('')
2285
+ lines.push('- 提出者:' + (vs.proposer || '(office)'))
2286
+ lines.push('- 类型:' + vs.kind)
2287
+ lines.push('- 法定票数 m:' + vs.m + '|有表决权者:' + vs.P)
2288
+ lines.push('- 轮次:' + vs.round + '|阶段:' + vs.stage)
2289
+ lines.push('')
2290
+ if (vs.statement) { lines.push('## 对象陈述'); lines.push(vs.statement); lines.push('') }
2291
+ lines.push('## 各表决者最终意见')
2292
+ for (const [k, v] of Object.entries(vs.votes || {})) {
2293
+ lines.push('- ' + k + ':verdict=' + Number(v.prob) + '|' + String(v.reason || '(无理由)'))
2294
+ }
2295
+ if (vs.history) {
2296
+ lines.push('')
2297
+ lines.push('## 上一轮(辩论前)意见')
2298
+ for (const [k, v] of Object.entries(vs.history)) {
2299
+ lines.push('- ' + k + ':verdict=' + Number(v.prob) + '|' + String(v.reason || '(无理由)'))
2300
+ }
2301
+ }
2302
+ await writeTextRel('Shared/Debates/' + vs.target + '.md', lines.join('\n') + '\n')
2303
+ }
2304
+ async function writeVerifiedCard(vs, isTrue, j) {
2305
+ const type = vs.kind === 'subproblem' ? '问题' : vs.kind === 'method' ? '方法' : '命题'
2306
+ const dir = vs.kind === 'subproblem' ? '问题' : vs.kind === 'method' ? '方法' : '命题'
2307
+ const text = [
2308
+ '# 已验证|' + vs.target,
2309
+ '- ID: ' + vs.target,
2310
+ '- 类型: ' + type,
2311
+ '- 结论: ' + (isTrue ? '真' : '假'),
2312
+ '- 概率: ' + (isTrue ? 1 : 0),
2313
+ '- 来源: ' + (isTrue ? j.bTrue : j.bFalse) + ' 名有表决权者一致判' + (isTrue ? '真' : '假') + '(m=' + j.m + ')',
2314
+ '- 表决者: ' + j.voters.join('、'),
2315
+ '- 弃权: ' + j.abstain + '|全组平均概率: ' + Number(j.mean).toFixed(2),
2316
+ '- 时间: ' + fmtTime(),
2317
+ '',
2318
+ '## 陈述',
2319
+ vs.statement || '参见来源卡。',
2320
+ '',
2321
+ '## 辩论记录',
2322
+ 'Shared/Debates/' + vs.target + '.md',
2323
+ '',
2324
+ ].join('\n')
2325
+ await writeTextRel('Verified/' + dir + '/' + vs.target + '.md', text)
2326
+ }
2327
+ // Record one vote and, when every voter has answered, settle the round.
2328
+ async function castVerdict(memberId, target, verdict, reason) {
2329
+ const member = memberById(memberId)
2330
+ if (!member) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
2331
+ if (member.kind === 'temp') {
2332
+ // Temp workers have no vote — but their judgement still matters, so it is
2333
+ // relayed to the group instead of being silently dropped.
2334
+ await say(memberId, { to: 'voters', kind: 'voters', text: '(临时工 ' + memberId + ' 的参考意见,无表决权)对 ' + target + ':' + String(reason || '') })
2335
+ return { ok: false, code: 'V5_NOT_VOTER', message: '临时工没有表决权;你的意见已转达给表决者' }
2336
+ }
2337
+ const vs = currentVerify()
2338
+ if (!vs) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'no verification in progress' }
2339
+ if (String(target) && String(target) !== vs.target) {
2340
+ return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'the object under verification is ' + vs.target }
2341
+ }
2342
+ const p = Number(verdict)
2343
+ if (!Number.isFinite(p) || p < 0 || p > 1) return { ok: false, code: 'V5_INVALID_VERDICT', message: 'verdict must be a number in [0,1]' }
2344
+ const votes = Object.assign({}, vs.votes)
2345
+ votes[memberId] = { prob: p, reason: String(reason || ''), at: now() }
2346
+ const next = Object.assign({}, vs, { votes, lastVoteAt: now() })
2347
+ await putVerdict(vs.target, next)
2348
+ const need = voters().map((m) => m.id)
2349
+ const allVoted = need.length > 0 && need.every((id) => votes[id])
2350
+ if (allVoted) await continueVerifyRound(next)
2351
+ else await scheduleNext()
2352
+ return { ok: true, voted: memberId, verdict: p, allVoted }
2353
+ }
2354
+
2355
+ // ---- chat log / meeting plumbing --------------------------------------
2356
+ async function saveChatLine(text) {
2357
+ const line = String(text || '').trim()
2358
+ if (!line) return false
2359
+ const day = fmtTime().slice(0, 10)
2360
+ const rel = 'Shared/Chat/' + day + '.md'
2361
+ const prev = (await readTextRel(rel)) || ('# 研究所群聊记录|' + instituteName + '|' + day + '\n\n')
2362
+ return await writeTextRel(rel, prev + '- ' + fmtTime().slice(11) + '|' + line + '\n')
2363
+ }
2364
+ // Wake a member only when it is not already running. Never called while paused
2365
+ // (a paused institute must not be nudged into new work — v4 §29-T36).
2366
+ async function wakeIfIdle(member, kind) {
2367
+ if (!running || autoDone) return false
2368
+ if (!member || member.phase !== 'active') return false
2369
+ if (busy.has(member.id)) return false
2370
+ return await wakeMember(member, normalPrompt(member), kind || 'normal')
2371
+ }
2372
+ // ---- meetings ---------------------------------------------------------
2373
+ // A meeting may never PREEMPT a verification: while a verification is in flight a
2374
+ // meeting request is PARKED (first one wins; later requests do not overwrite it)
2375
+ // and resumed once the verification clears. The watchdog clock starts only when
2376
+ // the meeting ACTUALLY begins (v4 §26/§27).
2377
+ const solveVotes = new Map() // memberId -> boolean, for the current solve question
2378
+ async function startMeeting(callerId, opts) {
2379
+ const o = opts || {}
2380
+ const agenda = String(o.agenda || '').trim()
2381
+ if (!agenda) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'agenda is required' }
2382
+ const kind = String(o.kind || 'sync')
2383
+ const academician = isAcademician(callerId)
2384
+ const office = isOffice(callerId)
2385
+ if (!office && !(academician && params.academicianLeads)) {
2386
+ // Everyone else may only PROPOSE; the request is relayed to the academician
2387
+ // and the office instead of silently doing nothing.
2388
+ //
2389
+ // The relay must carry the TRUE proposer. It used to be sent as `currentMember`
2390
+ // — "whoever this session last woke" — so a proposal by r-1 arrived signed by
2391
+ // r-2 and the voters replied to the wrong person.
2392
+ if (callerId) {
2393
+ await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })
2394
+ }
2395
+ return { ok: true, proposed: true, message: '已向院士/所办提议开会(只有院士或所办可以直接召开)' }
2396
+ }
2397
+ if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute has already concluded; start a new run to convene again' }
2398
+ if (!running) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
2399
+ const inFounding = activeMembers().some((m) => !m.direction && m.kind !== 'temp' && (rounds.get(m.id) || 0) === 0)
2400
+ if (meeting || hasVerifyInFlight() || inFounding) {
2401
+ if (!pendingMeeting) {
2402
+ pendingMeeting = { agenda, kind, target: String(o.target || ''), by: office ? 'office' : callerId, at: now() }
2403
+ }
2404
+ return { ok: true, parked: true, message: '会议已暂存(验证进行中或尚未就绪);前置事项清空后会真正召开' }
2405
+ }
2406
+ return await beginMeeting({ agenda, kind, target: String(o.target || ''), by: office ? 'office' : callerId })
2407
+ }
2408
+ async function beginMeeting(opts) {
2409
+ const counters = Object.assign({}, inst().counters)
2410
+ counters.meeting = (Number(counters.meeting) || 0) + 1
2411
+ await putCounters(counters)
2412
+ const id = 'mt-' + counters.meeting
2413
+ const order = activeMembers().map((m) => m.id)
2414
+ // Rotate who speaks first: with a fixed order the same member always speaks
2415
+ // before it can see the others (v4 §24.1-④).
2416
+ for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = order[i]; order[i] = order[j]; order[j] = t }
2417
+ meeting = {
2418
+ id, agenda: opts.agenda, kind: opts.kind || 'sync', target: opts.target || '',
2419
+ by: opts.by || 'office', order, inputs: {}, extras: {}, lastInputAt: now(), startedAt: now(),
2420
+ }
2421
+ solveVotes.clear()
2422
+ await putMeeting({ id, agenda: meeting.agenda, kind: meeting.kind, at: now(), file: 'Shared/Meetings/' + id + '.md' })
2423
+ await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
2424
+ await mkdirs()
2425
+ await writeTextRel('Shared/Meetings/' + id + '.md', [
2426
+ '# 会议纪要|' + id + '|' + instituteName,
2427
+ '- 议程: ' + meeting.agenda,
2428
+ '- 类型: ' + meeting.kind + (meeting.target ? '|目标: ' + meeting.target : ''),
2429
+ '- 召集人: ' + meeting.by,
2430
+ '- 开始时间: ' + fmtTime(meeting.startedAt),
2431
+ '- 发言顺序: ' + order.join(' → '),
2432
+ '',
2433
+ '## 各成员发言',
2434
+ '',
2435
+ ].join('\n'))
2436
+ await askMeetingRound()
2437
+ return { ok: true, meeting: meeting.id, agenda: meeting.agenda, order: order.join('、') }
2438
+ }
2439
+ async function askMeetingRound() {
2440
+ if (!meeting) return 0
2441
+ // Reconcile the speaking order with the LIVE roster. A member who joins during a
2442
+ // meeting must be asked, and a dismissed one must stop being waited for — the
2443
+ // original v4 defect kept polling a ghost and deadlocked the meeting until the
2444
+ // watchdog abandoned it.
2445
+ const live = activeMembers().map((m) => m.id)
2446
+ meeting.order = meeting.order.filter((id) => live.indexOf(id) !== -1)
2447
+ for (const id of live) { if (meeting.order.indexOf(id) === -1) meeting.order.push(id) }
2448
+ let asked = 0
2449
+ for (const id of meeting.order) {
2450
+ if (meeting.inputs[id] !== undefined) continue
2451
+ const m = memberById(id)
2452
+ if (!m || m.phase !== 'active') continue
2453
+ if (busy.has(id)) continue
2454
+ const ok = await wakeMember(m, meetingPrompt(m, meeting), 'meeting')
2455
+ if (ok) asked += 1
2456
+ if (asked >= Math.max(1, Math.floor(Number(params.maxParallel) || 3))) break
2457
+ }
2458
+ if (!asked) armHeartbeat()
2459
+ return asked
2460
+ }
2461
+ async function continueMeetingRound() {
2462
+ if (!meeting) return
2463
+ // Same reentrancy guard as verification: two concurrent end handlers can both see
2464
+ // the last speaker arrive and would otherwise finalize the meeting twice
2465
+ // (duplicate transcript tail, duplicate task/verify fan-out, duplicate solve vote).
2466
+ // Re-arm on the way out: a pass that bails here does no work of its own, so
2467
+ // without a heartbeat nothing would retry it once the lock clears.
2468
+ if (finalizeLock) { armHeartbeat(); return }
2469
+ const stale = now() - Number(meeting.lastInputAt || meeting.startedAt || now())
2470
+ if (stale >= recoverStallMs()) {
2471
+ const abandoned = meeting
2472
+ meeting = null
2473
+ await appendMeetingTail(abandoned, '⚠ 本次会议因长时间无新发言而被放弃(看门狗);团队回到自组织推进。')
2474
+ await saveChatLine('【会议 ' + abandoned.id + '】因卡死被放弃(' + Math.round(stale / 1000) + 's 无新发言)。')
2475
+ await scheduleNext()
2476
+ return
2477
+ }
2478
+ const need = activeMembers().map((m) => m.id)
2479
+ const missing = need.filter((id) => meeting.inputs[id] === undefined)
2480
+ if (missing.length) {
2481
+ // Collect from the members who have not spoken yet. Never break a member that
2482
+ // is genuinely still working; the watchdog handles a truly stuck one.
2483
+ const asked = await askMeetingRound()
2484
+ if (!asked) armHeartbeat()
2485
+ return
2486
+ }
2487
+ finalizeLock = 'meeting'
2488
+ try {
2489
+ await finalizeMeeting(meeting)
2490
+ } finally {
2491
+ finalizeLock = null
2492
+ }
2493
+ }
2494
+ async function appendMeetingTail(mn, text) {
2495
+ const rel = 'Shared/Meetings/' + mn.id + '.md'
2496
+ const prev = (await readTextRel(rel)) || ('# 会议纪要|' + mn.id + '\n\n')
2497
+ await writeTextRel(rel, prev + '\n' + text + '\n')
2498
+ }
2499
+ async function finalizeMeeting(mn) {
2500
+ meeting = null
2501
+ const lines = []
2502
+ for (const id of mn.order) {
2503
+ const text = mn.inputs[id]
2504
+ if (text === undefined) continue
2505
+ lines.push('### ' + id)
2506
+ lines.push(String(text || '(无发言)'))
2507
+ lines.push('')
2508
+ }
2509
+ // Count solve votes over VOTERS ONLY: a temp worker's opinion is welcome but it
2510
+ // holds no vote, and counting it here would inflate the numerator and make the
2511
+ // unanimity comparison against the voter count impossible to satisfy.
2512
+ const voterIds = voters().map((m) => m.id)
2513
+ const solvedTrue = voterIds.filter((id) => { const e = mn.extras[id]; return e && e.voteSolved === true })
2514
+ const solvedNot = voterIds.filter((id) => !(mn.extras[id] && mn.extras[id].voteSolved === true))
2515
+ lines.push('## 表决')
2516
+ lines.push('- 有表决权者:' + (voterIds.length ? voterIds.join('、') : '(无)'))
2517
+ lines.push('- 认为原问题已解决:' + (solvedTrue.length ? solvedTrue.join('、') : '(无人)'))
2518
+ lines.push('- 尚未认为已解决/未表态:' + (solvedNot.length ? solvedNot.join('、') : '(无人)'))
2519
+ lines.push('- 临时工意见(无表决权):' + (mn.order.filter((id) => !voterIds.includes(id)).map((id) => id + '=' + ((mn.extras[id] && mn.extras[id].voteSolved) === true)).join('、') || '(无)'))
2520
+ lines.push('- 结论:' + (voterIds.length > 0 && solvedTrue.length === voterIds.length
2521
+ ? '**全体有表决权者一致认为原问题已解决**'
2522
+ : '未达成全体一致(' + solvedTrue.length + '/' + voterIds.length + '),本所继续推进'))
2523
+ lines.push('')
2524
+ const rel = 'Shared/Meetings/' + mn.id + '.md'
2525
+ const prev = (await readTextRel(rel)) || ('# 会议纪要|' + mn.id + '\n\n')
2526
+ await writeTextRel(rel, prev + '\n' + lines.join('\n'))
2527
+ await saveChatLine('【会议 ' + mn.id + '】结束。已解决票 ' + solvedTrue.length + '/' + voterCount() + '。纪要见 ' + rel)
2528
+ await markProgress()
2529
+ await checkSolved()
2530
+ // A parked meeting is resumed only once nothing else is in flight.
2531
+ if (!autoDone && pendingMeeting && !hasVerifyInFlight()) {
2532
+ const p = pendingMeeting
2533
+ pendingMeeting = null
2534
+ await beginMeeting({ agenda: p.agenda, kind: p.kind, target: p.target, by: p.by })
2535
+ return
2536
+ }
2537
+ await scheduleNext()
2538
+ }
2539
+ // Stop ONLY on a unanimous true solve-vote from every VOTING member. There is no
2540
+ // forced/flat/near-consensus closure: any objection keeps the institute working.
2541
+ async function checkSolved() {
2542
+ const vs = voters().map((m) => m.id)
2543
+ if (!vs.length) return false
2544
+ if (!vs.every((id) => solveVotes.get(id) === true)) return false
2545
+ await finishRun('全体有表决权者一致认为原问题已解决')
2546
+ return true
2547
+ }
2548
+ async function recordSolveVote(memberId, val) {
2549
+ const m = memberById(memberId)
2550
+ if (!m) return
2551
+ if (m.kind === 'temp') return // no vote
2552
+ solveVotes.set(memberId, val === true)
2553
+ // Evaluate the stop condition on EVERY solve vote, not only when a meeting
2554
+ // finalizes. A vote that lands after the meeting closed — a late reply, or an
2555
+ // ordinary round carrying vote_solved — would otherwise be recorded and never
2556
+ // read, leaving a unanimously-concluded institute running forever.
2557
+ await checkSolved()
2558
+ }
2559
+ async function finishRun(reason) {
2560
+ clearHeartbeat()
2561
+ autoDone = true
2562
+ running = false
2563
+ phase = 'solved'
2564
+ await patchInstitute({ phase: 'solved', lastProgressAt: now() })
2565
+ await writeTextRel('Problems/conclusion.md', [
2566
+ '# 结题|' + instituteName,
2567
+ '- 时间: ' + fmtTime(),
2568
+ '- 依据: ' + reason,
2569
+ '- 有表决权者: ' + voters().map((m) => m.id).join('、'),
2570
+ '',
2571
+ '## 已确立(Verified/)',
2572
+ ...Object.keys(inst().verdicts).filter((k) => inst().verdicts[k] && inst().verdicts[k].outcome === 'true').map((k) => '- ' + k),
2573
+ '',
2574
+ '## 未定论(留库附概率)',
2575
+ ...Object.keys(inst().verdicts).filter((k) => { const v = inst().verdicts[k]; return v && v.closed && v.outcome === 'undecided' }).map((k) => '- ' + k + '(平均概率 ' + Number(inst().verdicts[k].mean).toFixed(2) + ')'),
2576
+ '',
2577
+ ].join('\n'))
2578
+ await saveChatLine('【结题】' + reason + '。本所停止推进;成果已归档在项目目录。')
2579
+ notifyActivity()
2580
+ }
2581
+
2582
+ // ---- hire / fire -------------------------------------------------------
2583
+ function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
2584
+ // ANY academician or permanently-employed researcher may hire its own temp
2585
+ // workers, and may fire the ones it hired. This is the requirement the official
2586
+ // DSH team service cannot satisfy: there, only the Lead may spawn, and a roster
2587
+ // entry can never be removed.
2588
+ async function hire(callerId, o) {
2589
+ const args = o || {}
2590
+ const office = isOffice(callerId)
2591
+ const caller = memberById(callerId)
2592
+ if (!office) {
2593
+ if (!caller || caller.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'only an active member may hire' }
2594
+ if (caller.kind === 'temp') return { ok: false, code: 'V5_NOT_VOTER', message: '临时工不能雇佣他人(只有院士与常驻研究员可以)' }
2595
+ }
2596
+ if (!running || autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not hiring right now' }
2597
+ const purpose = String(args.purpose || args.direction || '').trim()
2598
+ if (!purpose) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'hire 必须写明 purpose(雇它做什么)' }
2599
+ const initialTask = String(args.initial_task || args.initialTask || '').trim()
2600
+ if (!initialTask) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'hire 必须写明 initial_task(它的初始任务)' }
2601
+ const perCap = Math.max(1, Math.floor(Number(params.maxTempPerMember) || 3))
2602
+ const totalCap = Math.max(1, Math.floor(Number(params.maxTempTotal) || 12))
2603
+ const mine = employedTemps().filter((m) => m.hiredBy === (office ? 'office' : callerId)).length
2604
+ if (mine >= perCap) return { ok: false, code: 'V5_MEMBER_LIMIT', message: '你名下同时最多 ' + perCap + ' 名临时工(先在册 ' + mine + ' 名);请先解雇不再需要的' }
2605
+ if (employedTemps().length >= totalCap) return { ok: false, code: 'V5_MEMBER_LIMIT', message: '全所同时在册临时工已达上限 ' + totalCap }
2606
+ const member = await newMember('temp', {
2607
+ direction: purpose, hiredBy: office ? 'office' : callerId, term: String(args.term || ''), provider: pickProvider(),
2608
+ })
2609
+ try {
2610
+ await spawnMember(member, initialTask)
2611
+ } catch (e) {
2612
+ await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
2613
+ return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '临时工创建失败:' + String((e && e.message) || e) }
2614
+ }
2615
+ await saveChatLine('【雇佣】' + (office ? '所办' : callerId) + ' 雇入临时工 ' + member.id + ',用途:' + purpose)
2616
+ await markProgress()
2617
+ notifyActivity()
2618
+ return { ok: true, id: member.id, kind: 'temp', purpose, note: '现在可以用 vibe_v5_say {to:"' + member.id + '"} 或 vibe_v5_assign 给它派活' }
2619
+ }
2620
+ // Firing is REAL: the current turn is cancelled, the resident continuable child is
2621
+ // released, its tasks are reclaimed, its queued mail is dropped and it is marked
2622
+ // dismissed. Its id is never reused, so a re-hire can never inherit its archives.
2623
+ async function fire(callerId, o) {
2624
+ const args = o || {}
2625
+ const id = String(args.id || args.member || '').trim()
2626
+ const target = memberById(id)
2627
+ if (!target) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member ' + id }
2628
+ if (target.phase === 'dismissed') return { ok: true, already: true, message: id + ' 已被解雇' }
2629
+ const office = isOffice(callerId)
2630
+ const acad = isAcademician(callerId)
2631
+ const allowed = office || (acad && params.academicianLeads && target.kind === 'temp') || (target.kind === 'temp' && target.hiredBy === callerId)
2632
+ if (!allowed) {
2633
+ return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: '你只能解雇你雇的临时工;解雇他人雇的或常驻研究员需由院士/所办执行' }
2634
+ }
2635
+ if (target.kind !== 'temp' && !office) {
2636
+ return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)' }
2637
+ }
2638
+ const reason = String(args.reason || '').trim()
2639
+ const reclaimed = await releaseTasksOf(id, 'dismissed: ' + reason)
2640
+ if (target.childId) {
2641
+ try { if (typeof subagents.interrupt === 'function') subagents.interrupt(target.childId, { kind: 'ancestor', agent: rootAgent }) } catch (e) { /* fire-and-return */ }
2642
+ try {
2643
+ if (typeof subagents.drainContinuableChildren === 'function') await subagents.drainContinuableChildren(rootAgent, [target.childId])
2644
+ } catch (e) { console.error('vibe-math-v5: drain ' + id + ': ' + String((e && e.message) || e)) }
2645
+ childOwner.delete(target.childId)
2646
+ inflight.delete(target.childId)
2647
+ liveAgents.delete(target.childId)
2648
+ }
2649
+ busy.delete(id)
2650
+ solveVotes.delete(id)
2651
+ rounds.delete(id)
2652
+ roundsSinceCompact.delete(id)
2653
+ contextPct.delete(id)
2654
+ seeds.delete(id)
2655
+ if (meeting) {
2656
+ delete meeting.inputs[id]
2657
+ delete meeting.extras[id]
2658
+ meeting.order = meeting.order.filter((x) => x !== id)
2659
+ }
2660
+ // Drop its queued mail: a dismissed member must never be messaged again.
2661
+ const ids = inst().messages.filter((m) => m.to === id).map((m) => m.id)
2662
+ if (ids.length) await ackDelivered(ids)
2663
+ await putMember(Object.assign({}, target, {
2664
+ phase: 'dismissed', dismissedAt: now(), dismissReason: reason, childId: '',
2665
+ }))
2666
+ await writeRosterMirror()
2667
+ await saveChatLine('【解雇】' + id + ' 已由 ' + (office ? '所办' : callerId) + ' 解雇(原因:' + (reason || '未说明') +
2668
+ ')。代号永不复用;其未完成任务已收回' + (reclaimed.length ? '(' + reclaimed.join('、') + ')' : '') + '。')
2669
+ await markProgress()
2670
+ notifyActivity()
2671
+ await scheduleNext()
2672
+ return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
2673
+ }
2674
+ async function nudge(callerId, o) {
2675
+ if (!isOffice(callerId) && !(isAcademician(callerId) && params.academicianLeads)) {
2676
+ return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can nudge members' }
2677
+ }
2678
+ const args = o || {}
2679
+ const to = String(args.to || '').trim()
2680
+ const target = memberById(to)
2681
+ if (!target || target.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + to + '" not found' }
2682
+ const why = String(args.why || '').trim()
2683
+ // A nudge is SUPERVISION, not an assignment, and it must name its true origin:
2684
+ // an office nudge previously arrived labelled "院士督办" under the 【院士分派】
2685
+ // prefix, so the member was told the academician had spoken when it had not.
2686
+ const office = isOffice(callerId)
2687
+ await say(office ? 'office' : callerId, {
2688
+ to, kind: 'nudge',
2689
+ text: (office ? '所办督办' : '院士督办') + ':' + (why || '(未说明)') +
2690
+ (args.next_step ? '|建议的下一步:' + String(args.next_step) : ''),
2691
+ })
2692
+ await wakeIfIdle(target)
2693
+ return { ok: true, nudged: to }
2694
+ }
2695
+
2696
+ // ---- inbox-aware waking -------------------------------------------------
2697
+ // The mailbox is drained BEFORE the round prompt is built, and the round prompt is
2698
+ // therefore passed as a THUNK. Order matters: `promptFor` used to receive an already
2699
+ // built prompt (which had already embedded the pending mail through
2700
+ // briefBlock's [新到的消息/通知]) and then prepended the same messages again, so a
2701
+ // member read every newly delivered message TWICE in one prompt — once in the
2702
+ // prepended inbox block and once inside its own [状态] block.
2703
+ async function promptFor(member, baseFn) {
2704
+ const pending = pendingFor(member.id)
2705
+ const inbox = pending.length ? composeInbox(pending) : ''
2706
+ if (pending.length) await ackPending(pending)
2707
+ const base = typeof baseFn === 'function' ? baseFn() : baseFn
2708
+ return (inbox ? inbox + '\n\n' : '') + base
2709
+ }
2710
+ async function wakeWithInbox(member, baseFn, kind) {
2711
+ return await wakeMember(member, await promptFor(member, baseFn), kind)
2712
+ }
2713
+
2714
+ // ---- scheduling / graded keep-alive (ported and upgraded from v4 §25) ---
2715
+ // Priority: verification -> meeting -> parked meeting -> queued verification ->
2716
+ // assigned/claimed work -> urgent mail -> chat digest -> stall meeting -> heartbeat.
2717
+ // The heartbeat is only the LAST resort; the primary driver is the one-shot
2718
+ // activity wait, which costs no tokens while the institute is genuinely idle.
2719
+ function syncParamsFromState() {
2720
+ const cur = inst()
2721
+ if (cur && cur.params) params = Object.assign({}, DEFAULT_PARAMS, cur.params)
2722
+ if (cur && cur.project) project = cur.project
2723
+ if (cur && cur.institute) instituteName = cur.institute
2724
+ if (cur) phase = cur.phase || phase
2725
+ }
2726
+ // Scheduling TRAMPOLINE. The consensus finalizers call `scheduleNext()` themselves
2727
+ // (a settled verification should immediately drive whatever comes next), so a
2728
+ // direct call would recurse: scheduleNext -> continueVerifyRound -> finalize ->
2729
+ // scheduleNext -> ... Instead a nested call only REQUESTS another pass, and the
2730
+ // outermost frame drains the request in a loop. This also keeps exactly one
2731
+ // scheduling pass in flight, which is what makes the busy-set budget meaningful.
2732
+ let scheduling = false
2733
+ let reschedule = false
2734
+ async function scheduleNext() {
2735
+ dbg.schedEnter += 1
2736
+ if (!running || autoDone) return
2737
+ if (scheduling) { dbg.schedSkip += 1; reschedule = true; return }
2738
+ scheduling = true
2739
+ try {
2740
+ do {
2741
+ reschedule = false
2742
+ await schedulePass()
2743
+ } while (reschedule && running && !autoDone)
2744
+ } catch (e) {
2745
+ console.error('vibe-math-v5: scheduling pass failed: ' + String((e && e.stack) || e))
2746
+ } finally {
2747
+ scheduling = false
2748
+ }
2749
+ }
2750
+ async function schedulePass() {
2751
+ dbg.passes += 1
2752
+ clearHeartbeat()
2753
+ syncParamsFromState()
2754
+ if (meeting) { await continueMeetingRound(); return }
2755
+ const vs = currentVerify()
2756
+ if (vs) { await continueVerifyRound(vs); return }
2757
+ if (!currentVerify()) {
2758
+ await armNextVerify()
2759
+ if (hasVerifyInFlight()) return
2760
+ }
2761
+ if (pendingMeeting) {
2762
+ const p = pendingMeeting
2763
+ pendingMeeting = null
2764
+ await beginMeeting({ agenda: p.agenda, kind: p.kind, target: p.target, by: p.by })
2765
+ return
2766
+ }
2767
+ const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
2768
+ const idleMs = posMs(params.activityTimeoutMs, 120000)
2769
+ let filled = 0
2770
+ // (a) members with work they already own or were assigned.
2771
+ // PACED by the same idle window the heartbeat uses. Without the gate this branch
2772
+ // re-woke a task owner the instant its turn ended — and because every reply drives
2773
+ // another scheduling pass, a single unfinished task turned into an unbounded
2774
+ // wake -> turn -> wake chain that no parameter could slow down and that no pause
2775
+ // could interrupt between turns. New traffic still gets through immediately: the
2776
+ // addressed-mail branch below is not paced.
2777
+ const tasks = inst().tasks
2778
+ for (const t of tasks) {
2779
+ if (filled >= budget) break
2780
+ if (t.status !== 'in_progress' || !t.ownerId) continue
2781
+ const m = memberById(t.ownerId)
2782
+ if (!m || m.phase !== 'active' || busy.has(m.id)) continue
2783
+ if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue
2784
+ const ok = await wakeWithInbox(m, () => normalPrompt(m), 'normal')
2785
+ if (ok) filled += 1
2786
+ else armHeartbeat()
2787
+ }
2788
+ if (filled > 0) armHeartbeat()
2789
+ // (b) urgent mail (anything addressed, or a due chat digest)
2790
+ const idle = activeMembers().filter((m) => !busy.has(m.id))
2791
+ for (const m of idle) {
2792
+ if (filled >= budget) break
2793
+ const d = deliveryDecision(m.id)
2794
+ if (!d.deliver || !d.urgent) continue
2795
+ const ok = await wakeWithInbox(m, () => normalPrompt(m), 'normal')
2796
+ if (ok) filled += 1
2797
+ else armHeartbeat()
2798
+ }
2799
+ if (filled >= budget) { armDigest(); armHeartbeat(); return }
2800
+ // (c) due chat digest for otherwise-idle members
2801
+ const chatDue = idle.filter((m) => { const d = deliveryDecision(m.id); return d.deliver && !d.urgent })
2802
+ if (chatDue.length) {
2803
+ for (const m of chatDue) {
2804
+ if (filled >= budget) break
2805
+ const ok = await wakeWithInbox(m, () => normalPrompt(m), 'normal')
2806
+ if (ok) filled += 1
2807
+ }
2808
+ if (filled) { armHeartbeat(); return }
2809
+ armDigest()
2810
+ }
2811
+ // (d) stalled institute -> convene a coordination meeting (the framework only
2812
+ // CONVENES; it never assigns). Guarded on busy.size===0 so an in-flight round is
2813
+ // never pre-empted.
2814
+ const stallMs = posMs(params.stallAutoMeetingMs, 360000)
2815
+ if (!meeting && !pendingMeeting && !hasVerifyInFlight() && phase === 'active' &&
2816
+ busy.size === 0 && (now() - lastProgressAt) >= stallMs) {
2817
+ await startMeeting('office', { agenda: '本所较长时间没有新进展。请你们自行讨论:现在最该推进的是什么?谁来做?是否需要发起验证?', kind: 'sync' })
2818
+ return
2819
+ }
2820
+ // (e) heartbeat: push the longest-idle member to make progress rather than just
2821
+ // asking "are we done" (v4's original heartbeat invited stagnation). A member that
2822
+ // owns in-progress work gets a WORK round (its task block in [状态] tells it what
2823
+ // it owes); an otherwise idle member gets the heartbeat that asks it to advance the
2824
+ // problem by itself.
2825
+ if (busy.size < budget && idle.length) {
2826
+ const candidates = idle.slice().sort((a, b) => (lastActiveAt.get(a.id) || 0) - (lastActiveAt.get(b.id) || 0))
2827
+ const pick = candidates[0]
2828
+ if (pick && (now() - (lastActiveAt.get(pick.id) || 0)) >= idleMs) {
2829
+ const owns = inst().tasks.some((t) => t.ownerId === pick.id && t.status === 'in_progress')
2830
+ const ok = await wakeWithInbox(pick,
2831
+ () => (owns ? normalPrompt(pick) : checkpointPrompt(pick)),
2832
+ owns ? 'normal' : 'checkpoint')
2833
+ // ALWAYS re-arm after a wake, even on success. A wake whose turn never ends
2834
+ // (a host that drops the delivery, a child that vanished) would otherwise
2835
+ // leave nothing to schedule the next pass and the institute would freeze
2836
+ // permanently — the same failure class as v4 §25. The armed pass is cheap and
2837
+ // cannot double-wake anyone, because every branch checks `busy` first.
2838
+ armHeartbeat()
2839
+ if (!ok) { /* the next armed pass will retry another member */ }
2840
+ return
2841
+ }
2842
+ }
2843
+ armDigest()
2844
+ armHeartbeat()
2845
+ }
2846
+
2847
+ // ---- reply parsing -----------------------------------------------------
2848
+ function tryJson(s) { try { return JSON.parse(s) } catch (e) { return undefined } }
2849
+ function parseReply(text) {
2850
+ let obj
2851
+ const fence = /```(?:json)?[ \t]*([\s\S]*?)```/gi
2852
+ let m
2853
+ while ((m = fence.exec(text)) !== null) {
2854
+ const o = tryJson(String(m[1]).trim())
2855
+ if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
2856
+ }
2857
+ if (!obj) {
2858
+ const w = tryJson(String(text || '').trim())
2859
+ if (w && typeof w === 'object' && !Array.isArray(w)) obj = w
2860
+ }
2861
+ if (!obj) {
2862
+ // Last resort: the outermost {...} span (models sometimes wrap prose around it).
2863
+ const t = String(text || '')
2864
+ const i = t.indexOf('{'), j = t.lastIndexOf('}')
2865
+ if (i !== -1 && j > i) {
2866
+ const o = tryJson(t.slice(i, j + 1))
2867
+ if (o && typeof o === 'object' && !Array.isArray(o)) obj = o
2868
+ }
2869
+ }
2870
+ return obj || {}
2871
+ }
2872
+ function normVerdictNumber(v) {
2873
+ // verdict is a PURE 0-1 probability. Models often send a quoted number, and a
2874
+ // quoted "0.9" used to fall through to a 0.5 default and be silently recorded
2875
+ // as "unsure" (v4 §27). Legacy "TRUE"/"FALSE" strings map to 1/0.
2876
+ if (typeof v === 'string') {
2877
+ const s = v.trim().toUpperCase()
2878
+ if (s === 'TRUE') return 1
2879
+ if (s === 'FALSE') return 0
2880
+ }
2881
+ const n = Number(v)
2882
+ return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : undefined
2883
+ }
2884
+
2885
+ // ---- reply dispatch ----------------------------------------------------
2886
+ // EVERY reply kind funnels through here, so no control channel can be honoured on
2887
+ // one path and silently dropped on another.
2888
+ async function handleReply(member, parsed, kind) {
2889
+ const p = parsed || {}
2890
+ postmark(member, p)
2891
+ // (1) speech (named `speech`, not `s`: `s` is the session API in this scope)
2892
+ const speech = p.say
2893
+ if (typeof speech === 'string' && speech.trim()) await say(member.id, { to: 'all', text: speech, kind: 'chat' })
2894
+ else if (speech && typeof speech === 'object' && speech.text) {
2895
+ const to = String(speech.to || 'all')
2896
+ // A "to: voters" broadcast is not a private message: framing it 私信 told the
2897
+ // voters they had been singled out when the whole voting body was addressed.
2898
+ const kind = to === 'all' ? 'chat' : to === 'voters' ? 'voters' : 'dm'
2899
+ await say(member.id, { to, text: String(speech.text), kind })
2900
+ }
2901
+ // (2) progress log
2902
+ if (typeof p.progress === 'string' && p.progress.trim()) await publishProgress(member.id, p.progress)
2903
+ // (3) library records
2904
+ if (Array.isArray(p.record)) {
2905
+ for (const r of p.record) {
2906
+ if (!r || typeof r !== 'object') continue
2907
+ const k = String(r.kind || '')
2908
+ if (k !== 'proposition' && k !== 'method' && k !== 'subproblem') continue
2909
+ await recordCard(member.id, k, r)
2910
+ }
2911
+ }
2912
+ // (4) task board
2913
+ if (p.task_create && typeof p.task_create === 'object') await taskCreate(member.id, p.task_create)
2914
+ if (p.task_claim) {
2915
+ const t = inst().tasks.find((x) => x.id === String(p.task_claim))
2916
+ if (t) {
2917
+ const r = await taskUpdate(member.id, { task_id: t.id, expected_revision: t.revision, action: 'claim' })
2918
+ // Report a REFUSED claim back to the claimer: silently doing nothing left the
2919
+ // member believing it owned a task it does not own (and the notice used to be
2920
+ // dropped as a self-message).
2921
+ if (r && r.ok === false) await notice(member.id, '认领 ' + t.id + ' 失败(' + (r.code || '') + '):' + (r.message || ''))
2922
+ } else await notice(member.id, '认领失败:没有任务 ' + String(p.task_claim))
2923
+ }
2924
+ if (p.task_update && typeof p.task_update === 'object') {
2925
+ const r = await taskUpdate(member.id, p.task_update)
2926
+ if (r && r.ok === false) await notice(member.id, 'task_update 未生效(' + (r.code || '') + '):' + (r.message || ''))
2927
+ }
2928
+ if (p.task_done) {
2929
+ const t = inst().tasks.find((x) => x.id === String(p.task_done))
2930
+ if (t) {
2931
+ const r = await taskUpdate(member.id, { task_id: t.id, expected_revision: t.revision, action: 'complete' })
2932
+ if (r && r.ok === false) await notice(member.id, '完成任务 ' + t.id + ' 失败(' + (r.code || '') + '):' + (r.message || ''))
2933
+ } else await notice(member.id, '标记完成失败:没有任务 ' + String(p.task_done))
2934
+ }
2935
+ // (5) verification
2936
+ if (p.propose_verify) {
2937
+ const pv = typeof p.propose_verify === 'string' ? { target: p.propose_verify } : p.propose_verify
2938
+ if (pv && pv.target) {
2939
+ const r = await maybeQueueVerify(pv.target, pv.kind, member.id, pv.reason)
2940
+ if (r && r.ok === false) await notice(member.id, '提议验证 ' + String(pv.target) + ' 未受理(' + (r.code || '') + '):' + (r.message || ''))
2941
+ }
2942
+ }
2943
+ if (p.verdict && typeof p.verdict === 'object') {
2944
+ const n = normVerdictNumber(p.verdict.verdict)
2945
+ if (n === undefined) await notice(member.id, 'verdict 必须是 0-1 的数值;本轮的票未被记录。')
2946
+ else {
2947
+ const r = await castVerdict(member.id, String(p.verdict.target || ''), n, p.verdict.reason)
2948
+ if (r && r.ok === false) await notice(member.id, '本轮的票未被记录(' + (r.code || '') + '):' + (r.message || ''))
2949
+ }
2950
+ }
2951
+ // (6) meetings
2952
+ if (p.propose_meeting) {
2953
+ const pm = typeof p.propose_meeting === 'string' ? { agenda: p.propose_meeting } : p.propose_meeting
2954
+ if (pm && pm.agenda) {
2955
+ const r = await startMeeting(member.id, pm)
2956
+ if (r && r.ok === false) await notice(member.id, '提议开会未受理(' + (r.code || '') + '):' + (r.message || ''))
2957
+ }
2958
+ }
2959
+ if (p.convene_meeting && typeof p.convene_meeting === 'object' && isAcademician(member.id) && params.academicianLeads) {
2960
+ await startMeeting(member.id, p.convene_meeting)
2961
+ }
2962
+ // (7) the academician's organizational powers
2963
+ if (p.assign && typeof p.assign === 'object') await taskAssign(member.id, p.assign)
2964
+ if (p.prioritize && typeof p.prioritize === 'object') await taskPrioritize(member.id, p.prioritize)
2965
+ if (p.nudge && typeof p.nudge === 'object') await nudge(member.id, p.nudge)
2966
+ // (8) staffing
2967
+ if (p.hire && typeof p.hire === 'object') await hire(member.id, p.hire)
2968
+ if (p.fire && typeof p.fire === 'object') await fire(member.id, p.fire)
2969
+ // (9) objecting to an assignment: recorded and BROADCAST, never silently swallowed
2970
+ if (p.reject_assign && typeof p.reject_assign === 'object' && params.memberMayRejectAssign) {
2971
+ const ra = p.reject_assign
2972
+ await say(member.id, {
2973
+ to: 'voters', kind: 'voters',
2974
+ text: '【反对分派】我对任务 ' + String(ra.task_id || '(未指明)') + ' 有异议:' + String(ra.why || '(未说明理由)') +
2975
+ '。任务仍会执行,但请' + (academicianId() ? '院士与全所' : '全所') + '知悉我的理由。',
2976
+ })
2977
+ }
2978
+ // (10) solve votes / personal judgement
2979
+ if (p.vote_solved !== undefined) await recordSolveVote(member.id, p.vote_solved === true)
2980
+ // (11) meeting input collection (keyed on the LIVE member set, so a member who
2981
+ // joined mid-meeting still has to speak and a dismissed one stops blocking it)
2982
+ if (kind === 'meeting' && meeting) {
2983
+ const text = (typeof p.input === 'string' && p.input.trim())
2984
+ ? p.input
2985
+ : (typeof p.say === 'string' && p.say.trim() ? p.say : (typeof p.summary === 'string' ? p.summary : ''))
2986
+ meeting.inputs[member.id] = text || '(无发言)'
2987
+ meeting.extras[member.id] = p
2988
+ meeting.lastInputAt = now()
2989
+ // APPEND to the transcript (never clobber it): the file is a human artifact and
2990
+ // must stay readable even if the process dies in the middle of a meeting.
2991
+ const rel = 'Shared/Meetings/' + meeting.id + '.md'
2992
+ const prev = (await readTextRel(rel)) || ('# 会议纪要|' + meeting.id + '\n\n')
2993
+ await writeTextRel(rel, prev + '### ' + member.id + '\n' + (text || '(无发言)') + '\n\n')
2994
+ }
2995
+ if (p.solved !== undefined) {
2996
+ await writeTextRel('Shared/State-of-institute.md', [
2997
+ '# 研究所判断快照|' + instituteName,
2998
+ '- 时间: ' + fmtTime(),
2999
+ '- 记录者: ' + member.id,
3000
+ '- 该成员认为原问题已解决: ' + (p.solved === true),
3001
+ '- 有表决权的解决票: ' + Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、'),
3002
+ '',
3003
+ ].join('\n'))
3004
+ }
3005
+ }
3006
+ // ---- one turn finished -------------------------------------------------
3007
+ // The end event is the ONLY driver of the institute's progression. It must be
3008
+ // idempotent: a replayed or late `subagent/end` used to run EVERY side effect
3009
+ // twice (v4 §28-T29), so a turn is only honoured while its in-flight token is
3010
+ // still registered.
3011
+ async function onMemberEnd(childId, info) {
3012
+ const token = inflight.get(childId)
3013
+ if (token === undefined) return
3014
+ inflight.delete(childId)
3015
+ await ready()
3016
+ const member = byChild(childId)
3017
+ if (!member) return
3018
+ busy.delete(member.id)
3019
+ const text = blocksToText(info && info.lastAssistantMessage)
3020
+ const stopReason = String((info && info.stopReason) || 'completed')
3021
+ if (stopReason !== 'completed') {
3022
+ await saveChatLine('【异常】' + member.id + ' 的一轮以 ' + stopReason + ' 结束' +
3023
+ (text ? '|最后输出:' + String(text).slice(0, 300) : ''))
3024
+ }
3025
+ let parsed = {}
3026
+ try { parsed = parseReply(text) } catch (e) { parsed = {} }
3027
+ try {
3028
+ await handleReply(member, parsed, wakeKind.get(member.id) || 'normal')
3029
+ } catch (e) {
3030
+ console.error('vibe-math-v5: reply dispatch for ' + member.id + ': ' + String((e && e.stack) || e))
3031
+ }
3032
+ try { await maybeRealCompact(childId, member) } catch (e) { /* compaction is best-effort */ }
3033
+ wakeKind.delete(member.id)
3034
+ if (member.activeMeetingId) delete member.activeMeetingId
3035
+ await scheduleNext()
3036
+ }
3037
+ async function maybeRealCompact(childId, member) {
3038
+ const roundN = roundsSinceCompact.get(member.id) || 0
3039
+ const pct = contextPct.get(member.id) || 0
3040
+ const soft = pct >= Number(params.compactThreshold) || roundN >= Number(params.compactAfterRounds)
3041
+ if (!soft) return
3042
+ const comp = compactionOf()
3043
+ const agent = liveAgentOf(childId)
3044
+ if (comp && agent && agent.session && typeof comp.compactIfNeeded === 'function') {
3045
+ try {
3046
+ const r = await comp.compactIfNeeded(agent, 'pressure', makeSignal(params.activityTimeoutMs))
3047
+ if (r) { roundsSinceCompact.set(member.id, 0); needReanchor.add(member.id); return }
3048
+ } catch (e) { /* fall through to the soft path */ }
3049
+ }
3050
+ needReanchor.add(member.id)
3051
+ }
3052
+ function rememberAgent(childId, agent) {
3053
+ if (!childId || !agent) return
3054
+ try { liveAgents.set(childId, new WeakRef(agent)) } catch (e) { liveAgents.set(childId, { deref: () => agent }) }
3055
+ }
3056
+ function forgetAgent(childId) { liveAgents.delete(childId) }
3057
+ function liveAgentOf(childId) {
3058
+ const ref = liveAgents.get(childId)
3059
+ if (!ref) return undefined
3060
+ try { return ref.deref() } catch (e) { return undefined }
3061
+ }
3062
+
3063
+ // ---- control plane -----------------------------------------------------
3064
+ function normalizeParams(input) {
3065
+ const out = {}
3066
+ const ints = ['researcherCount', 'quorumCap', 'verdictMaxRounds', 'maxTempPerMember', 'maxTempTotal',
3067
+ 'compactThreshold', 'compactAfterRounds', 'maxParallel', 'activityTimeoutMs', 'stallAutoMeetingMs',
3068
+ 'chatDigestMs', 'chatDigestMax', 'meetingKeepEvery']
3069
+ const bools = ['academician', 'academicianLeads', 'memberMayRejectAssign']
3070
+ const strs = ['quorumMode', 'provider', 'model', 'staffPersona']
3071
+ const arrs = ['toolAllow', 'toolDeny', 'tempToolAllow', 'tempToolDeny']
3072
+ for (const k of ints) if (input[k] !== undefined) { const n = Math.floor(Number(input[k])); if (Number.isFinite(n)) out[k] = n }
3073
+ for (const k of bools) if (input[k] !== undefined) out[k] = (input[k] === true || input[k] === 'true')
3074
+ for (const k of strs) if (input[k] !== undefined) out[k] = String(input[k])
3075
+ for (const k of arrs) {
3076
+ if (input[k] === undefined) continue
3077
+ const v = input[k]
3078
+ out[k] = Array.isArray(v) ? v.map(String).filter((x) => x.trim()) : String(v).split(',').map((x) => x.trim()).filter(Boolean)
3079
+ }
3080
+ if (out.quorumMode !== undefined && out.quorumMode !== 'm-unanimous' && out.quorumMode !== 'all-unanimous') out.quorumMode = 'm-unanimous'
3081
+ // Guard every duration against a negative/NaN value: such a value would make a
3082
+ // watchdog fire instantly and abandon all consensus (v4 §30-T41).
3083
+ for (const k of ['activityTimeoutMs', 'stallAutoMeetingMs', 'chatDigestMs']) {
3084
+ if (out[k] !== undefined && !(out[k] > 0)) delete out[k]
3085
+ }
3086
+ if (out.researcherCount !== undefined && out.researcherCount < 0) out.researcherCount = 0
3087
+ if (out.quorumCap !== undefined && out.quorumCap < 1) out.quorumCap = 1
3088
+ return out
3089
+ }
3090
+ async function setParams(input) {
3091
+ const patch = normalizeParams(input || {})
3092
+ const merged = Object.assign({}, params, patch)
3093
+ await patchInstitute({ params: merged })
3094
+ params = Object.assign({}, DEFAULT_PARAMS, merged)
3095
+ // An already-armed heartbeat keeps the delay it was armed with, so a lowered
3096
+ // activityTimeoutMs (or a raised maxParallel) would not take effect until some
3097
+ // unrelated event drove a pass. Tuning must apply immediately.
3098
+ if (running && !autoDone) await scheduleNext()
3099
+ return { ok: true, params: visibleParams() }
3100
+ }
3101
+ function visibleParams() {
3102
+ return {
3103
+ academician: params.academician, academicianLeads: params.academicianLeads,
3104
+ memberMayRejectAssign: params.memberMayRejectAssign,
3105
+ researcherCount: params.researcherCount,
3106
+ quorumCap: params.quorumCap, quorumMode: params.quorumMode,
3107
+ m: quorumM(), voterCount: voterCount(),
3108
+ verdictMaxRounds: params.verdictMaxRounds,
3109
+ maxTempPerMember: params.maxTempPerMember, maxTempTotal: params.maxTempTotal,
3110
+ compactThreshold: params.compactThreshold, compactAfterRounds: params.compactAfterRounds,
3111
+ maxParallel: params.maxParallel, activityTimeoutMs: params.activityTimeoutMs,
3112
+ stallAutoMeetingMs: params.stallAutoMeetingMs, chatDigestMs: params.chatDigestMs,
3113
+ chatDigestMax: params.chatDigestMax, meetingKeepEvery: params.meetingKeepEvery,
3114
+ provider: params.provider, model: params.model,
3115
+ toolAllow: params.toolAllow, toolDeny: params.toolDeny,
3116
+ }
3117
+ }
3118
+ async function configure(args) {
3119
+ const a = args || {}
3120
+ // configure is the PRE-START setup tool. Switching project/institute while a run
3121
+ // is live would split its state across two trees: the members' libraries and
3122
+ // briefs point at the OLD root while every later write goes to the NEW one.
3123
+ if (running && !autoDone) {
3124
+ return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'cannot reconfigure while the institute is running (pause/stop first; use vibe_v5_set to tune params)' }
3125
+ }
3126
+ const patch = {}
3127
+ if (a.project !== undefined && String(a.project).trim()) patch.project = String(a.project).trim()
3128
+ if (a.institute !== undefined && String(a.institute).trim()) patch.institute = String(a.institute).trim()
3129
+ if (a.problem !== undefined) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
3130
+ if (a.params && typeof a.params === 'object') patch.params = Object.assign({}, params, normalizeParams(a.params))
3131
+ if (patch.project !== undefined || patch.institute !== undefined) {
3132
+ const np = patch.project !== undefined ? patch.project : project
3133
+ const ni = patch.institute !== undefined ? patch.institute : instituteName
3134
+ const nkey = np + '::' + ni
3135
+ if (nkey !== key && !inst().members.length) {
3136
+ key = nkey
3137
+ project = np
3138
+ instituteName = ni
3139
+ patch.project = np
3140
+ patch.institute = ni
3141
+ } else if (nkey !== key) {
3142
+ return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
3143
+ }
3144
+ }
3145
+ patch.phase = 'idle'
3146
+ await patchInstitute(patch)
3147
+ syncParamsFromState()
3148
+ await mkdirs()
3149
+ if (inst().problem.statement) {
3150
+ await writeTextRel('Problems/' + (inst().problem.id || 'problem') + '.md', [
3151
+ '# 问题|' + (inst().problem.id || 'problem'),
3152
+ '- ID: ' + (inst().problem.id || 'problem'),
3153
+ '- 类型: 问题',
3154
+ '- 状态: 求解中',
3155
+ '- 时间: ' + fmtTime(),
3156
+ '',
3157
+ '## 陈述',
3158
+ inst().problem.statement,
3159
+ '',
3160
+ ].join('\n'))
3161
+ }
3162
+ await writeStateReadme()
3163
+ return {
3164
+ ok: true, project, institute: instituteName,
3165
+ problem: inst().problem.statement ? inst().problem.statement.slice(0, 80) : '',
3166
+ params: visibleParams(),
3167
+ note: '现在可以 vibe_v5_start 开工',
3168
+ }
3169
+ }
3170
+ // State/ holds only human-readable mirrors. Say so IN the directory, so a user who
3171
+ // finds State/<institute>.v5state.json (the degraded fallback) or the mirror files
3172
+ // does not mistake them for the authoritative state and hand-edit them.
3173
+ async function writeStateReadme() {
3174
+ await writeTextRel('State/README.md', [
3175
+ '# 关于 State/',
3176
+ '',
3177
+ '本研究所的**权威状态在会话日志投影里**(投影键 `vibeMathV5`),不是这里的文件。',
3178
+ '本目录只存放人可读的镜像/说明,**请勿手改**;改动不会影响真正的状态。',
3179
+ '要查看状态请用 `vibe_v5_status` / `vibe_v5_report`。',
3180
+ '',
3181
+ '唯一例外:当宿主没有 `sessionProjections` 服务时,v5 会回退到',
3182
+ '`State/<institute>.v5state.json`(加固 JSON 后端),此时它才是权威源。',
3183
+ '安装器会在启动自检里报告这一降级。',
3184
+ '',
3185
+ ].join('\n'))
3186
+ }
3187
+ function slugify(s) {
3188
+ const t = String(s == null ? '' : s).trim().toLowerCase().replace(/[^a-z0-9_\-\u4e00-\u9fa5]+/g, '-').replace(/^-+|-+$/g, '')
3189
+ return t || ''
3190
+ }
3191
+ async function doStart(args) {
3192
+ const a = args || {}
3193
+ if (running && !autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'already running' }
3194
+ if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this institute already concluded; configure a new one in a new session' }
3195
+ const patch = {}
3196
+ if (a.problem !== undefined && String(a.problem).trim()) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
3197
+ const p = Object.assign({}, params, normalizeParams(a.params || {}))
3198
+ if (a.researcherCount !== undefined) p.researcherCount = Math.max(0, Math.floor(Number(a.researcherCount)) || 0)
3199
+ if (a.academician !== undefined) p.academician = a.academician === true || a.academician === 'true'
3200
+ // A misconfigured 0/negative count used to start a run that spawned nobody yet
3201
+ // reported running=true (v4 §30).
3202
+ if (!(p.researcherCount >= 0)) p.researcherCount = DEFAULT_PARAMS.researcherCount
3203
+ if (p.academician === false && p.researcherCount < 1) p.researcherCount = 1
3204
+ patch.params = p
3205
+ patch.phase = 'founding'
3206
+ patch.runId = 'run-' + shortId()
3207
+ await patchInstitute(patch)
3208
+ syncParamsFromState()
3209
+ runId = inst().runId
3210
+ await mkdirs()
3211
+ await writeStateReadme()
3212
+ if (inst().problem.statement) {
3213
+ await writeTextRel('Problems/' + (inst().problem.id || 'problem') + '.md', [
3214
+ '# 问题|' + (inst().problem.id || 'problem'), '- ID: ' + (inst().problem.id || 'problem'),
3215
+ '- 类型: 问题', '- 状态: 求解中', '- 时间: ' + fmtTime(), '', '## 陈述', inst().problem.statement, '',
3216
+ ].join('\n'))
3217
+ }
3218
+ const seeds = Array.isArray(a.seedDirections) ? a.seedDirections.map(String) : []
3219
+ const DEFAULT_DIRS = [
3220
+ '从最基础的定义与已知结论出发,寻找可用的经典工具与已有定理。',
3221
+ '尝试构造反例或极端情形,界定命题的适用范围与边界。',
3222
+ '把它归约到一个更小、更本质的核心里程,先攻这个核心。',
3223
+ '寻找与其它领域的类比,把问题嵌入一个更一般的结构里。',
3224
+ '从已知的相近结论出发,看能否推广或加强得到所需结果。',
3225
+ ]
3226
+ const spawned = []
3227
+ // The academician is founded FIRST so it is on the roster for every later member's
3228
+ // induction brief, and so it can begin overseeing the founding round. Each member
3229
+ // is committed to the ACTIVE roster before its own brief is built (see
3230
+ // spawnMember), so every founding brief describes a roster that includes its
3231
+ // reader.
3232
+ if (params.academician) {
3233
+ const m = await newMember('academician', { direction: '统领全所:统筹全局、拆解并分派工作、设定优先级、督导进度。' })
3234
+ try {
3235
+ await spawnMember(m, '你是本所的院士。请先独立研判这个问题:它的关键困难在哪?应当拆成哪几块工作?'
3236
+ + '你打算如何组织全所(谁适合做什么、先做什么)?把你的判断写进你的 Progress/,并把关键结论在群聊里说出来。')
3237
+ spawned.push(m.id)
3238
+ } catch (e) {
3239
+ await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
3240
+ }
3241
+ }
3242
+ for (let i = 0; i < params.researcherCount; i++) {
3243
+ const dir = seeds[i] || DEFAULT_DIRS[i % DEFAULT_DIRS.length]
3244
+ const m = await newMember('researcher', { direction: dir })
3245
+ try {
3246
+ await spawnMember(m, null)
3247
+ spawned.push(m.id)
3248
+ } catch (e) {
3249
+ await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
3250
+ }
3251
+ }
3252
+ if (!spawned.length) {
3253
+ await patchInstitute({ phase: 'idle' })
3254
+ return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '没有任何成员创建成功;请检查 subagents 提供者与会话持久化是否可用' }
3255
+ }
3256
+ running = true
3257
+ autoDone = false
3258
+ phase = 'active'
3259
+ await patchInstitute({ phase: 'active', lastProgressAt: now() })
3260
+ await saveChatLine('【建所】' + instituteName + ' 成立。院士/研究员到岗:' + spawned.join('、') +
3261
+ '。研究对象已写入 Problems/。全体先各自独立研判,然后自行组织推进。')
3262
+ await markProgress()
3263
+ notifyActivity()
3264
+ await scheduleNext()
3265
+ return { ok: true, institute: instituteName, project, members: spawned, running: true, quorumM: quorumM(), voters: voterCount() }
3266
+ }
3267
+ // Reconcile durable `provisioning` members against their independently persisted
3268
+ // child sessions (ported from DSH agent-teams). Anything that cannot be proven live
3269
+ // becomes `failed` rather than being silently resurrected or silently dropped.
3270
+ async function reconcileProvisioning() {
3271
+ for (const m of inst().members.slice()) {
3272
+ if (m.phase !== 'provisioning') continue
3273
+ let live = false
3274
+ if (m.childId) { try { live = !!(agents.get(m.childId)) } catch (e) { live = false } }
3275
+ if (live) { await putMember(Object.assign({}, m, { phase: 'active' })); continue }
3276
+ await putMember(Object.assign({}, m, {
3277
+ phase: 'failed', childId: '',
3278
+ error: (m.error || '') + '|重启对账:找不到该成员的常驻会话,标记为 failed',
3279
+ }))
3280
+ }
3281
+ for (const m of inst().members.slice()) {
3282
+ if (m.phase !== 'active' || !m.childId) continue
3283
+ let live = false
3284
+ try { live = !!(agents.get(m.childId)) } catch (e) { live = false }
3285
+ if (!live) await putMember(Object.assign({}, m, { childId: '' }))
3286
+ }
3287
+ }
3288
+ async function resume() {
3289
+ await reconcileProvisioning()
3290
+ syncParamsFromState()
3291
+ if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this institute already concluded; configure a new one in a new session' }
3292
+ const members = activeMembers()
3293
+ if (!members.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
3294
+ let respawned = 0
3295
+ for (const m of members) {
3296
+ if (m.childId) continue
3297
+ try {
3298
+ const seedText = (await readTextRel('Members/' + m.id + '/Progress/progress.md')) || ''
3299
+ await spawnMember(m, seedText ? seedText.slice(-4000) : '(你的 Progress/ 还是空的——请先把当前状态补写进去。)', 'resume')
3300
+ respawned += 1
3301
+ } catch (e) {
3302
+ await putMember(Object.assign({}, m, { phase: 'failed', error: String((e && e.message) || e) }))
3303
+ }
3304
+ }
3305
+ // A meeting/verify that was in flight when the process died has a stale watchdog
3306
+ // clock, so refresh it instead of letting the first pass abandon it (v4 §27-T28).
3307
+ if (meeting) meeting.lastInputAt = now()
3308
+ const cv = currentVerify()
3309
+ if (cv) await putVerdict(cv.target, Object.assign({}, cv, { lastVoteAt: now() }))
3310
+ running = true
3311
+ autoDone = false
3312
+ phase = 'active'
3313
+ await patchInstitute({ phase: 'active', lastProgressAt: now() })
3314
+ await saveChatLine('【恢复】研究所继续推进(重建成员 ' + respawned + ' 名)。')
3315
+ notifyActivity()
3316
+ await scheduleNext()
3317
+ return { ok: true, resumed: true, members: members.map((m) => m.id), respawned, running: true }
3318
+ }
3319
+ function setPause() {
3320
+ clearHeartbeat()
3321
+ running = false
3322
+ return { ok: true, paused: true, message: '已暂停调度;成员的在途回合结束后不会被再次唤醒。用 vibe_v5_resume 继续。' }
3323
+ }
3324
+ async function initStop() {
3325
+ clearHeartbeat()
3326
+ running = false
3327
+ autoDone = false
3328
+ for (const m of activeMembers()) {
3329
+ if (m.childId) {
3330
+ try { if (typeof subagents.interrupt === 'function') subagents.interrupt(m.childId, { kind: 'ancestor', agent: rootAgent }) } catch (e) { /* ignore */ }
3331
+ childOwner.delete(m.childId)
3332
+ inflight.delete(m.childId)
3333
+ }
3334
+ await putMember(Object.assign({}, m, { childId: '' }))
3335
+ }
3336
+ // Wipe coordination state so status is truthful between stop and the next action:
3337
+ // the interrupted members' childIds are gone, so no end event can ever clear those
3338
+ // marks and they would otherwise report a phantom in-flight meeting/verify forever
3339
+ // (v4 §30-T38).
3340
+ meeting = null
3341
+ pendingMeeting = null
3342
+ solveVotes.clear()
3343
+ busy.clear()
3344
+ wakeKind.clear()
3345
+ currentMember = ''
3346
+ finalizeLock = null
3347
+ for (const cv of Object.values(inst().verdicts)) {
3348
+ if (cv && !cv.closed) await putVerdict(cv.target, Object.assign({}, cv, { closed: true, outcome: 'undecided', reason: 'stopped by the office', closedAt: now() }))
3349
+ }
3350
+ await patchInstitute({ phase: 'idle' })
3351
+ return { ok: true, stopped: true }
3352
+ }
3353
+ function status() {
3354
+ syncParamsFromState()
3355
+ const s = inst()
3356
+ const cv = currentVerify()
3357
+ return {
3358
+ ok: true,
3359
+ institute: instituteName, project, key, phase,
3360
+ running, autoDone, runId: s.runId,
3361
+ backend: backend ? backend.kind : 'uninitialized',
3362
+ debug: Object.assign({ scheduling, reschedule }, dbg),
3363
+ quorum: { m: quorumM(), mode: params.quorumMode, voters: voters().map((m) => m.id), voterCount: voterCount() },
3364
+ members: s.members.map((m) => ({
3365
+ id: m.id, kind: m.kind, phase: m.phase, direction: m.direction, hiredBy: m.hiredBy,
3366
+ rounds: rounds.get(m.id) || 0, busy: busy.has(m.id), contextPct: contextPct.get(m.id) || 0,
3367
+ childId: m.childId ? m.childId.slice(0, 12) : '', error: m.error || '',
3368
+ })),
3369
+ tasks: listTasks(),
3370
+ chat: { pending: s.messages.length, delivered: s.delivered.length },
3371
+ meeting: meeting ? { id: meeting.id, agenda: meeting.agenda, kind: meeting.kind, spoke: Object.keys(meeting.inputs), order: meeting.order } : null,
3372
+ parkedMeeting: pendingMeeting ? { agenda: pendingMeeting.agenda, kind: pendingMeeting.kind } : null,
3373
+ verify: cv ? { target: cv.target, kind: cv.kind, stage: cv.stage, round: cv.round, voted: Object.keys(cv.votes), m: quorumM(), P: voterCount() } : null,
3374
+ verifyQueue: s.queue.map((q) => q.target),
3375
+ verified: Object.keys(s.verdicts).filter((k) => s.verdicts[k] && s.verdicts[k].closed && s.verdicts[k].outcome !== 'undecided'),
3376
+ undecided: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'undecided' }),
3377
+ solveVotes: Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v),
3378
+ lastProgressAt, params: visibleParams(),
3379
+ }
3380
+ }
3381
+ function report() {
3382
+ syncParamsFromState()
3383
+ const s = inst()
3384
+ const L = []
3385
+ L.push('# 「' + instituteName + '」研究所汇报')
3386
+ L.push('')
3387
+ L.push('- 项目:' + project + '|阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
3388
+ L.push('- 研究对象:' + (s.problem.statement ? s.problem.statement.slice(0, 200) : '(未设定)'))
3389
+ L.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
3390
+ L.push('')
3391
+ L.push('## 编制')
3392
+ if (!s.members.length) L.push('(暂无成员)')
3393
+ for (const m of s.members) {
3394
+ L.push('- ' + m.id + '|' + kindLabel(m.kind) + '|' + m.phase +
3395
+ (m.hiredBy ? '|雇主 ' + m.hiredBy : '') +
3396
+ (m.direction ? '|方向:' + m.direction : '') +
3397
+ '|轮次 ' + (rounds.get(m.id) || 0) +
3398
+ (m.error ? '|⚠ ' + m.error : ''))
3399
+ }
3400
+ L.push('')
3401
+ L.push('## 任务板')
3402
+ const ts = listTasks()
3403
+ if (!ts.length) L.push('(暂无任务)')
3404
+ for (const t of ts) {
3405
+ L.push('- [' + t.status + '] ' + t.id + '|' + t.subject + '|owner=' + (t.ownerName || '(未认领)') +
3406
+ (t.assignedBy ? '|院士分派' : '') + (t.ready ? '|可认领' : ''))
3407
+ }
3408
+ L.push('')
3409
+ L.push('## 共识')
3410
+ const closed = Object.keys(s.verdicts).filter((k) => s.verdicts[k] && s.verdicts[k].closed)
3411
+ if (!closed.length) L.push('(尚未对任何对象定论)')
3412
+ for (const k of closed) {
3413
+ const v = s.verdicts[k]
3414
+ L.push('- ' + k + '|' + (v.outcome === 'true' ? '**真**' : v.outcome === 'false' ? '**假**' : '未定论') +
3415
+ '(m=' + v.m + '|真' + (v.bTrue || 0) + '/假' + (v.bFalse || 0) + '/弃权' + (v.abstain || 0) +
3416
+ '|平均概率 ' + Number(v.mean || 0).toFixed(2) + '|' + (v.reason || '') + ')')
3417
+ }
3418
+ const cv = currentVerify()
3419
+ if (cv) L.push('- 进行中:' + cv.target + '|' + cv.stage + ' 第 ' + cv.round + ' 轮|已投 ' + Object.keys(cv.votes).join('、'))
3420
+ if (s.queue.length) L.push('- 队列:' + s.queue.map((q) => q.target).join('、'))
3421
+ L.push('')
3422
+ L.push('## 群聊 / 会议')
3423
+ L.push('- 未读消息:' + s.messages.length + '|已投递:' + s.delivered.length)
3424
+ if (meeting) L.push('- 进行中会议:' + meeting.id + '|' + meeting.agenda + '|已发言 ' + Object.keys(meeting.inputs).join('、'))
3425
+ if (pendingMeeting) L.push('- 暂存会议:' + pendingMeeting.agenda)
3426
+ L.push('- 历史会议:' + s.meetings.length + ' 次|辩论录:' + s.debates.length + ' 份')
3427
+ L.push('- 解决票:' + (solveVotes.size ? Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、') : '(无)'))
3428
+ L.push('')
3429
+ L.push('## 文件位置')
3430
+ L.push('- 根目录:' + instRoot())
3431
+ L.push('- 已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
3432
+ return { ok: true, report: L.join('\n') }
3433
+ }
3434
+ // Adding/removing a PERMANENT researcher is a change to the institute's public
3435
+ // structure, so members may only propose it; the office decides and executes.
3436
+ async function addResearcher(callerId, direction) {
3437
+ if (!isOffice(callerId)) {
3438
+ await say(callerId, { to: 'voters', kind: 'voters', text: '提议增聘一名常驻研究员(方向:' + String(direction || '未指定') + ')' })
3439
+ return { ok: true, proposed: true, message: '已向所办提议增聘常驻研究员(编制变更需所办批准)' }
3440
+ }
3441
+ if (!running || autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
3442
+ const m = await newMember('researcher', { direction: String(direction || '') })
3443
+ try {
3444
+ await spawnMember(m, null)
3445
+ } catch (e) {
3446
+ await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
3447
+ return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: String((e && e.message) || e) }
3448
+ }
3449
+ await saveChatLine('【编制】所办增聘常驻研究员 ' + m.id + (direction ? '(方向:' + direction + ')' : '') +
3450
+ '。求真门槛 m 现为 ' + quorumM() + '。')
3451
+ await scheduleNext()
3452
+ return { ok: true, id: m.id, kind: 'researcher', quorumM: quorumM() }
3453
+ }
3454
+ async function removeResearcher(id) {
3455
+ const m = memberById(String(id))
3456
+ if (!m) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member' }
3457
+ if (m.kind !== 'researcher') return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'use vibe_v5_fire for temp workers; this tool removes a PERMANENT researcher' }
3458
+ const r = await fire('office', { id: m.id, reason: 'office decision' })
3459
+ return r
3460
+ }
3461
+
3462
+ return {
3463
+ sessionId,
3464
+ key: () => key,
3465
+ institute: () => instituteName,
3466
+ project: () => project,
3467
+ params: () => Object.assign({}, params),
3468
+ visibleParams,
3469
+ phase: () => phase,
3470
+ running: () => running,
3471
+ autoDone: () => autoDone,
3472
+ state,
3473
+ inst,
3474
+ ready,
3475
+ // lifecycle
3476
+ onMemberEnd, rememberAgent, forgetAgent,
3477
+ configure, doStart, resume, setPause, initStop, status, report, setParams,
3478
+ reconcileProvisioning,
3479
+ kick: () => scheduleNext().catch(() => {}),
3480
+ // communication
3481
+ say, waitForActivity, wakeIfIdle,
3482
+ // staffing
3483
+ hire, fire, addResearcher, removeResearcher, nudge,
3484
+ // tasks
3485
+ taskCreate, taskList: listTasks, getTask, taskUpdate, taskAssign, taskPrioritize,
3486
+ // libraries
3487
+ publishProgress, recordCard, readLibrary,
3488
+ // consensus / meetings
3489
+ maybeQueueVerify, castVerdict, currentVerify, hasVerifyInFlight, startMeeting, quorumM, voterCount,
3490
+ // authorization helpers (used by tool handlers)
3491
+ memberIdOfAgent, isOffice, isAcademician, memberById, activeMembers,
3492
+ }
3493
+ }
3494
+
3495
+ // ================= apply-level registration (ONCE) =================
3496
+ function objParams(props, required) { return { type: 'object', properties: props, additionalProperties: false, required: required || [] } }
3497
+ // tools.register()/commands.register() return a Cordis effect disposer. Keeping the
3498
+ // registration inside ctx.effect() is what unwinds it when the preset subtree
3499
+ // unloads: v2/v3 did this, v4 once dropped the disposer so a second mount collided
3500
+ // on the already-registered names and the entries survived an unload.
3501
+ function registerTool(name, description, parameters, fn) {
3502
+ ctx.effect(() => tools.register({
3503
+ name, description, parameters,
3504
+ output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: String(v) }] },
3505
+ execute: async (args, exec) => {
3506
+ try {
3507
+ const s = getSession(exec && exec.agent)
3508
+ if (!s) return JSON.stringify({ ok: false, error: 'no session' })
3509
+ await s.ready()
3510
+ return JSON.stringify(await fn(s, args || {}, exec && exec.agent))
3511
+ } catch (e) {
3512
+ return JSON.stringify({ ok: false, error: String((e && e.message) || e) })
3513
+ }
3514
+ },
3515
+ }))
3516
+ }
3517
+ const S = { type: 'string' }, N = { type: 'number' }, I = { type: 'integer' }, B = { type: 'boolean' }
3518
+ const SA = { type: 'array', items: { type: 'string' } }
3519
+
3520
+ // ── office / host controls ────────────────────────────────────────────────
3521
+ 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))
3522
+ 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))
3523
+ 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())
3524
+ registerTool('vibe_v5_pause', 'Pause the institute (in-flight turns finish; no new wakes until resume).', objParams({}), (s) => s.setPause())
3525
+ registerTool('vibe_v5_stop', 'Stop the institute: interrupt every member, clear coordination state, and release their child sessions.', objParams({}), (s) => s.initStop())
3526
+ registerTool('vibe_v5_status', 'Machine-readable institute status (members, tasks, quorum, meetings, verification, mail).', objParams({}), (s) => s.status())
3527
+ registerTool('vibe_v5_report', 'Human-readable institute report (staffing, tasks, consensus, meetings, file locations).', objParams({}), (s) => s.report())
3528
+ registerTool('vibe_v5_set', 'Tune institute parameters (persisted in the session-log projection). 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).', objParams({
3529
+ academician: B, academicianLeads: B, memberMayRejectAssign: B, researcherCount: I,
3530
+ quorumCap: I, quorumMode: S, verdictMaxRounds: I,
3531
+ maxTempPerMember: I, maxTempTotal: I,
3532
+ compactThreshold: I, compactAfterRounds: I, maxParallel: I,
3533
+ activityTimeoutMs: I, stallAutoMeetingMs: I, chatDigestMs: I, chatDigestMax: I, meetingKeepEvery: I,
3534
+ provider: S, model: S, staffPersona: S, toolAllow: SA, toolDeny: SA, tempToolAllow: SA, tempToolDeny: SA,
3535
+ }), (s, a) => s.setParams(a))
3536
+ 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) => {
3537
+ const to = String(a.to || 'all')
3538
+ return s.say('office', { to, text: String(a.content), kind: to === 'all' || to === 'voters' ? 'office' : 'dm' })
3539
+ })
3540
+ registerTool('vibe_v5_meeting', 'Convene a meeting (office/academician) or propose one (any other member — relayed to the academician/office). Parked automatically while a verification is in flight.', objParams({ agenda: S, kind: { type: 'string', enum: ['sync', 'division', 'verify-request', 'solve-vote'] }, target: S }, ['agenda']), (s, a, x) => s.startMeeting(s.memberIdOfAgent(x) || 'office', a))
3541
+ 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 }))
3542
+ 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) => {
3543
+ const caller = a.to ? String(a.to) : (s.isOffice(s.memberIdOfAgent(x)) ? 'office' : s.memberIdOfAgent(x))
3544
+ return s.hire(caller, a)
3545
+ })
3546
+ registerTool('vibe_v5_fire', 'Dismiss a temp worker for real: cancel its turn, release its resident child, reclaim its tasks, drop its mail, and mark it dismissed (its id is never reused).', objParams({ id: S, reason: S }, ['id']), (s, a, x) => s.fire(s.memberIdOfAgent(x) || 'office', a))
3547
+ registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => s.addResearcher(s.memberIdOfAgent(x) || 'office', a && a.direction))
3548
+ registerTool('vibe_v5_remove_researcher', 'Office only: dismiss a PERMANENT researcher.', objParams({ id: S }, ['id']), (s, a) => s.removeResearcher(a.id))
3549
+
3550
+ // ── member-facing controls ────────────────────────────────────────────────
3551
+ 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) => {
3552
+ const from = s.memberIdOfAgent(x)
3553
+ if (!from) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
3554
+ const to = a.to || 'all'
3555
+ return s.say(from, { to, text: a.text, kind: to === 'voters' ? 'voters' : (a.to ? 'dm' : 'chat') })
3556
+ })
3557
+ registerTool('vibe_v5_wait', '(member) Wait for the next institute change (roster/task/mail/status) WITHOUT polling. Returns immediately with noProgress when nobody else is running or provisioning. timeout_ms: 10000-3600000 (default 30000).', objParams({ timeout_ms: I, reason: S }), async (s, a, x) => {
3558
+ const me = s.memberIdOfAgent(x)
3559
+ const others = s.activeMembers().filter((m) => m.id !== me)
3560
+ const ms = a.timeout_ms === undefined ? 30000 : Number(a.timeout_ms)
3561
+ if (others.length === 0) {
3562
+ return { ok: true, timedOut: false, noProgress: { reason: 'no-active-peer', message: '没有其他在册成员可以等待;请先用 vibe_v5_say / vibe_v5_hire / vibe_v5_task_create 让事情发生。' } }
3563
+ }
3564
+ const r = await s.waitForActivity(ms, x && x.signal)
3565
+ return { ok: true, timedOut: r.timedOut, note: '醒来后请重新读取状态(vibe_v5_task_list / 状态块),本工具只报告是否超时。' }
3566
+ })
3567
+ registerTool('vibe_v5_record_progress', '(member) Append to YOUR progress.md — your research log. Include what you tried, the routes and their obstacles, your current state, your plans, and failed/dead ends (they save the institute from repeating them).', objParams({ content: S }, ['content']), (s, a, x) => s.publishProgress(s.memberIdOfAgent(x), a.content))
3568
+ registerTool('vibe_v5_record_proposition', '(member) Record a proposition/lemma in your library. REQUIRES value (价值程度), motive (动机用途计划) and p (your probability that it is true).', 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), 'proposition', a))
3569
+ 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))
3570
+ 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))
3571
+ 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))
3572
+ registerTool('vibe_v5_propose_verify', '(member) Propose an object for consensus verification. Any member may propose; only voting members decide.', objParams({ target: S, kind: S, reason: S }, ['target']), (s, a, x) => s.maybeQueueVerify(a.target, a.kind, s.memberIdOfAgent(x), a.reason))
3573
+ 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))
3574
+ registerTool('vibe_v5_task_create', '(member) Open a task on the shared board (subject, description, optional blockers, advisory write scopes, priority).', objParams({ subject: S, description: S, blocked_by: SA, write_scopes: SA, priority: I }, ['subject']), (s, a, x) => s.taskCreate(s.memberIdOfAgent(x), a))
3575
+ 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) }))
3576
+ 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) }))
3577
+ registerTool('vibe_v5_task_update', '(member) Compare-and-set a task action: claim|release|edit|set_dependencies|complete|reopen|reassign|delete. Pass expected_revision from task_get/task_list; a stale revision is refused.', objParams({ task_id: S, expected_revision: I, action: S, subject: S, description: S, blocked_by: SA, write_scopes: SA, owner: S }, ['task_id', 'expected_revision', 'action']), (s, a, x) => s.taskUpdate(s.memberIdOfAgent(x), a))
3578
+
3579
+ // ── the academician's organizational tools ────────────────────────────────
3580
+ 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) => {
3581
+ const parts = []
3582
+ const st = s.status()
3583
+ parts.push('## 编制'); for (const m of st.members) parts.push('- ' + m.id + '|' + m.kind + '|' + m.phase + '|轮次 ' + m.rounds + (m.direction ? '|' + m.direction : ''))
3584
+ parts.push(''); parts.push('## 任务板')
3585
+ for (const t of st.tasks) parts.push('- [' + t.status + '] ' + t.id + '|' + t.subject + '|owner=' + (t.ownerName || '(未认领)') + '|rev=' + t.revision + '|优先级=' + t.priority)
3586
+ if (!st.tasks.length) parts.push('(暂无任务)')
3587
+ parts.push(''); parts.push('## 各成员 Progress 摘要')
3588
+ for (const m of st.members) {
3589
+ const p = await s.readLibrary({ member: m.id })
3590
+ const prog = (p.items || []).filter((i) => i.kind === 'progress').map((i) => i.text).join('')
3591
+ parts.push('### ' + m.id)
3592
+ parts.push(prog ? String(prog).slice(-1200) : '(尚未写 Progress/)')
3593
+ parts.push('')
3594
+ }
3595
+ parts.push('## 最近群聊')
3596
+ parts.push('(见 Shared/Chat/ 目录;未读消息 ' + st.chat.pending + ' 条)')
3597
+ if (st.verify) parts.push('## 进行中表决\n' + JSON.stringify(st.verify))
3598
+ if (st.meeting) parts.push('## 进行中会议\n' + JSON.stringify(st.meeting))
3599
+ parts.push('## 停滞提示')
3600
+ const idleFor = Date.now() - st.lastProgressAt
3601
+ parts.push('- 距上次实质进展:' + Math.round(idleFor / 1000) + ' 秒')
3602
+ return { ok: true, overview: parts.join('\n') }
3603
+ })
3604
+ registerTool('vibe_v5_assign', '(academician) ASSIGN work: create or pick a task and give it to a specific member (including temp workers), stating WHY and the acceptance criteria. The assignee executes by default and may object with reasons (which are broadcast).', objParams({ task_id: S, subject: S, description: S, to: S, why: S, acceptance: S, priority: I, write_scopes: SA }, ['to', 'why', 'acceptance']), (s, a, x) => s.taskAssign(s.memberIdOfAgent(x), a))
3605
+ registerTool('vibe_v5_prioritize', '(academician) Set institute-wide priorities: an ordered list of {task_id, priority} plus WHY. This orders work only — it never changes what is true.', objParams({ order: { type: 'array', items: { type: 'object' } }, why: S }), (s, a, x) => s.taskPrioritize(s.memberIdOfAgent(x), a))
3606
+ registerTool('vibe_v5_nudge', '(academician) Supervise: wake one member with a stated reason and a concrete suggested next step. Prefer a specific next step over a bare "hurry up".', objParams({ to: S, why: S, next_step: S }, ['to', 'why']), (s, a, x) => s.nudge(s.memberIdOfAgent(x), a))
3607
+
3608
+ // ── /v5 slash command ────────────────────────────────────────────────────
3609
+ ctx.effect(() => commands.register({
3610
+ name: 'v5', description: 'control the Vibe Math V5 research institute',
3611
+ input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set]' },
3612
+ handler: async function (inv) {
3613
+ const s = getSession(inv && inv.agent)
3614
+ if (!s) return { kind: 'success', text: JSON.stringify({ ok: false, error: 'no session' }) }
3615
+ await s.ready()
3616
+ const line = String(inv && inv.rawInput ? inv.rawInput : '').trim()
3617
+ const parts = line.split(/\s+/)
3618
+ const cmd = parts[0] || ''
3619
+ const rest = parts.slice(1)
3620
+ let r
3621
+ if (cmd === 'configure') r = await s.configure({ institute: rest[0] || '', problem: parts.slice(2).join(' ') })
3622
+ else if (cmd === 'start') r = await s.doStart({})
3623
+ else if (cmd === 'resume') r = await s.resume()
3624
+ else if (cmd === 'pause') r = s.setPause()
3625
+ else if (cmd === 'stop') r = await s.initStop()
3626
+ else if (cmd === 'status') r = s.status()
3627
+ else if (cmd === 'report') r = s.report()
3628
+ else if (cmd === 'members') r = { ok: true, members: s.status().members }
3629
+ else if (cmd === 'message') r = await s.say('office', { to: rest[0] || 'all', text: rest.slice(1).join(' '), kind: 'office' })
3630
+ else if (cmd === 'meeting') r = await s.startMeeting('office', { agenda: rest.join(' '), kind: 'sync' })
3631
+ else if (cmd === 'hire') r = await s.hire('office', { purpose: rest[0] || '', initial_task: rest.slice(1).join(' ') || rest[0] || '' })
3632
+ else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
3633
+ else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
3634
+ else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
3635
+ else if (cmd === 'set') {
3636
+ const upd = {}
3637
+ for (const tok of rest) {
3638
+ const eq = tok.indexOf('=')
3639
+ if (eq <= 0) continue
3640
+ const k = tok.slice(0, eq), v = tok.slice(eq + 1)
3641
+ const n = Number(v)
3642
+ upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
3643
+ }
3644
+ r = await s.setParams(upd)
3645
+ } else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set' }
3646
+ return { kind: 'success', text: JSON.stringify(r, null, 2) }
3647
+ },
3648
+ }))
3649
+
3650
+ // ── the institute-state projection unit (registered ONCE, host-only) ──────
3651
+ // No `wire`, so this unit is omitted from client snapshots but is checkpointed like
3652
+ // every other unit — verified on this host: checkpoint() carries it, restore()
3653
+ // refolds it from checkpoint + log tail, and it never enters `deriveMessages()`.
3654
+ const projections = projectionsOf()
3655
+ if (projections && typeof projections.register === 'function') {
3656
+ ctx.effect(() => projections.register({
3657
+ key: PROJECTION_KEY,
3658
+ stateVersion: PROJECTION_VERSION,
3659
+ stateSchema: STATE_SCHEMA,
3660
+ init: initState,
3661
+ apply: applyV5Event,
3662
+ }))
3663
+ } else {
3664
+ console.error('vibe-math-v5: sessionProjections unavailable — falling back to the hardened JSON state file (State/<institute>.v5state.json)')
3665
+ }
3666
+
3667
+ // ── agent lifecycle wiring ───────────────────────────────────────────────
3668
+ // Capture the live child Agent while it is STILL registered: `subagent/end` is
3669
+ // emitted only after the child's Activation teardown removed it from the agent
3670
+ // registry, so an end-time agents.get(childId) can never resolve (real /compact was
3671
+ // dead code in v4 for exactly this reason). A WeakRef means a missed release merely
3672
+ // delays collection rather than pinning the Agent.
3673
+ ctx.on('subagent/start', function (info) {
3674
+ if (!info || !info.id) return
3675
+ const sid = childOwner.get(info.id)
3676
+ const s = sid !== undefined ? sessions.get(sid) : undefined
3677
+ if (!s) return
3678
+ let agent
3679
+ try { agent = agents.get(info.id) } catch (e) { agent = undefined }
3680
+ if (agent) s.rememberAgent(info.id, agent)
3681
+ })
3682
+ ctx.on('subagent/end', function (info) {
3683
+ if (!info || !info.id) return
3684
+ const sid = childOwner.get(info.id)
3685
+ const s = sid !== undefined ? sessions.get(sid) : undefined
3686
+ if (!s) return
3687
+ s.onMemberEnd(info.id, info)
3688
+ .catch((e) => {
3689
+ console.error('vibe-math-v5: end handler: ' + String((e && e.stack) || e))
3690
+ // Last line of defence: an exceptional turn must never leave the institute with
3691
+ // no end-event and no heartbeat to continue it (v4 §30).
3692
+ s.kick()
3693
+ })
3694
+ .finally(() => s.forgetAgent(info.id))
3695
+ })
3696
+ }