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.
- package/README.md +71 -4
- package/RELEASE-NOTES-2.1.0.md +143 -0
- package/RELEASE-NOTES-2.2.0.md +266 -0
- package/audit-v5-integrity.mjs +343 -0
- package/audit-v5-sensitivity.mjs +306 -0
- package/cordis.patch.yml +4 -3
- package/e2e-v5-round2.test.mjs +521 -0
- package/installer.js +14 -3
- package/package.json +17 -4
- package/prompt-corpus-v5/prompt-corpus-v5.json +561 -0
- package/prompt-corpus-v5/prompt-corpus-v5.md +3804 -0
- package/prompt-v5-integrity.test.mjs +942 -0
- package/selfdrive-v5.mjs +470 -0
- package/vibe-math-v5/agent.cordis.yml +342 -0
- package/vibe-math-v5/preset.yml +2 -0
- package/vibe-math-v5/vibe-math-v5.js +3696 -0
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1289 -0
|
@@ -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
|
+
}
|