dsh-vibe-math 2.7.2 → 2.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +11 -10
- package/README.md +13 -12
- package/cordis.patch.yml +112 -16
- package/docs/AUDIT-CHECKLIST.md +229 -3
- package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
- package/docs/final-paper.md +2 -2
- package/docs/math-computation.md +13 -6
- package/docs/parameter-schema.md +37 -0
- package/docs/release-notes/README.en.md +1 -0
- package/docs/release-notes/README.md +1 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +2 -2
- package/docs/release-notes/RELEASE-NOTES-2.7.2.md +3 -3
- package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
- package/docs/release-notes/TEMPLATE.md +6 -0
- package/docs/status-report-fields.md +167 -0
- package/docs/test-timing.md +8 -5
- package/installer.js +27 -17
- package/package.json +250 -203
- package/prompt-corpus-persona/persona-corpus.json +9 -9
- package/prompt-corpus-persona/persona-corpus.md +225 -33
- package/prompt-corpus-v2/formal-verify-v2.json +295 -115
- package/prompt-corpus-v2/formal-verify-v2.md +2287 -283
- package/prompt-corpus-v3/formal-verify-v3.json +105 -105
- package/prompt-corpus-v3/formal-verify-v3.md +213 -143
- package/prompt-corpus-v4/formal-verify-v4.json +18 -18
- package/prompt-corpus-v4/formal-verify-v4.md +49 -44
- package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
- package/prompt-corpus-v5/prompt-corpus-v5.md +451 -207
- package/scripts/update-doc-counts.mjs +71 -0
- package/tests/audit-artifact-docs.mjs +72 -0
- package/tests/audit-artifact-docs.mutants.mjs +58 -0
- package/tests/audit-installer-assertions.mutants.mjs +63 -0
- package/tests/audit-installer-compat.test.mjs +52 -4
- package/tests/audit-installer-policy.test.mjs +41 -8
- package/tests/audit-math-computation-contract.mjs +64 -8
- package/tests/audit-math-computation-contract.mutants.mjs +106 -0
- package/tests/audit-math-computation-parity.mjs +164 -3
- package/tests/audit-math-computation-sensitivity.mjs +4 -2
- package/tests/audit-package-membership.mjs +57 -0
- package/tests/audit-package-membership.mutants.mjs +28 -0
- package/tests/audit-participant-set-parity.mjs +160 -0
- package/tests/audit-path-discipline.mjs +83 -0
- package/tests/audit-path-discipline.mutants.mjs +51 -0
- package/tests/audit-persona-sensitivity.mjs +14 -0
- package/tests/audit-persona-surface.mutants.mjs +69 -0
- package/tests/audit-persona-surface.test.mjs +63 -22
- package/tests/audit-prompt-duplication.mjs +143 -0
- package/tests/audit-prompt-duplication.mutants.mjs +50 -0
- package/tests/audit-prompt-invariants.mjs +43 -5
- package/tests/audit-readme-bilingual.test.mjs +152 -0
- package/tests/audit-readme-counts.mjs +95 -0
- package/tests/audit-readme-counts.mutants.mjs +52 -0
- package/tests/audit-registration.mjs +78 -0
- package/tests/audit-status-report-fields.mjs +172 -0
- package/tests/audit-status-report-fields.mutants.mjs +64 -0
- package/tests/audit-v3-registration-parity.mjs +156 -0
- package/tests/audit-v5-integrity.mjs +300 -13
- package/tests/audit-v5-lean-abstention.mjs +392 -0
- package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
- package/tests/audit-v5-prompt-duplication.mjs +88 -0
- package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
- package/tests/audit-v5-sensitivity.mjs +12 -7
- package/tests/e2e-identity-a6.mutants.mjs +65 -0
- package/tests/e2e-v5-round2.test.mjs +458 -13
- package/tests/formal-verify-v2.test.mjs +91 -43
- package/tests/formal-verify-v3.mutants.mjs +117 -0
- package/tests/formal-verify-v3.test.mjs +85 -39
- package/tests/formal-verify-v4.mutants.mjs +152 -0
- package/tests/formal-verify-v4.test.mjs +541 -9
- package/tests/formal-verify-v5.test.mjs +68 -9
- package/tests/helpers/doc-counts-format.mjs +41 -0
- package/tests/helpers/math-computation-fake-seam.mjs +4 -0
- package/tests/math-computation-a1.mutants.mjs +72 -0
- package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
- package/tests/math-computation-discovery.mutants.mjs +76 -0
- package/tests/math-computation-shared.test.mjs +274 -5
- package/tests/math-computation-v2.test.mjs +767 -0
- package/tests/math-computation-v3.test.mjs +759 -0
- package/tests/math-computation-v4.mutants.mjs +133 -0
- package/tests/math-computation-v4.test.mjs +739 -0
- package/tests/prompt-v5-integrity.test.mjs +13 -2
- package/tests/run-tests.mjs +139 -9
- package/tests/run-tests.mutants.mjs +70 -0
- package/tests/selfdrive-v5.mjs +53 -1
- package/tests/selfdrive-v5.mutants.mjs +132 -0
- package/tests/v2-fix-probes.mutants.mjs +100 -0
- package/tests/v2-fix-probes.test.mjs +1039 -0
- package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
- package/tests/v3-fix-probes.mutants.mjs +95 -0
- package/tests/v3-fix-probes.test.mjs +891 -0
- package/tests/v5-institute-fixes.mutants.mjs +190 -0
- package/vibe-math-v2/agent.cordis.yml +62 -6
- package/vibe-math-v2/math-computation.js +130 -41
- package/vibe-math-v2/math-engines.js +12 -12
- package/vibe-math-v2/vibe-math-v2.js +388 -113
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
- package/vibe-math-v3/agent.cordis.yml +72 -6
- package/vibe-math-v3/math-computation.js +130 -41
- package/vibe-math-v3/math-engines.js +12 -12
- package/vibe-math-v3/vibe-math-v3.js +422 -126
- package/vibe-math-v4/agent.cordis.yml +44 -8
- package/vibe-math-v4/math-computation.js +130 -41
- package/vibe-math-v4/math-engines.js +12 -12
- package/vibe-math-v4/vibe-math-v4.js +196 -43
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
- package/vibe-math-v5/agent.cordis.yml +46 -12
- package/vibe-math-v5/math-computation.js +130 -41
- package/vibe-math-v5/math-engines.js +12 -12
- package/vibe-math-v5/vibe-math-v5.js +1173 -218
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +80 -12
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +2 -2
|
@@ -86,7 +86,7 @@ const EV = {
|
|
|
86
86
|
debate: 'vibe5/debate',
|
|
87
87
|
verdict: 'vibe5/verdict',
|
|
88
88
|
queue: 'vibe5/queue',
|
|
89
|
-
|
|
89
|
+
solve: 'vibe5/solve',
|
|
90
90
|
progress: 'vibe5/progress',
|
|
91
91
|
formal: 'vibe5/formal',
|
|
92
92
|
}
|
|
@@ -330,26 +330,47 @@ export function apply(ctx) {
|
|
|
330
330
|
formal: {},
|
|
331
331
|
todo: [],
|
|
332
332
|
queue: [],
|
|
333
|
+
// Per-member answers to the "is the original problem solved?" question (HIGH 3). It used to
|
|
334
|
+
// live in an in-memory Map: the tally gated `solved` and was PUBLISHED by status/report, but
|
|
335
|
+
// a restart or a second session over the same institute lost it, so cross-session unanimity
|
|
336
|
+
// was impossible and the published view was wrong. Durable like `verdicts`.
|
|
337
|
+
solve: {},
|
|
333
338
|
counters: { academician: 0, researcher: 0, temp: 0, task: 0, meeting: 0, message: 0, verify: 0 },
|
|
334
339
|
runId: '',
|
|
335
340
|
lastProgressAt: now(),
|
|
336
341
|
artifactCount: 0,
|
|
337
342
|
diagnostics: [],
|
|
343
|
+
// F6 (status/report review): the durable marker of a meeting that STARTED but never
|
|
344
|
+
// FINALIZED. The meeting index entry is appended at `beginMeeting`, so before this marker a
|
|
345
|
+
// restart made an unfinished meeting look like ordinary history (minutes never written).
|
|
346
|
+
meetingOpen: null,
|
|
347
|
+
// F4 (status/report review): how many office-addressed requests the fold had to drop because
|
|
348
|
+
// of `OFFICE_REQUEST_CAP`. Without a counter the truncation was invisible in status/report.
|
|
349
|
+
officeRequestsDropped: 0,
|
|
338
350
|
// The final-paper flow (stage machine + its artifacts state). Durable like everything
|
|
339
351
|
// else, so a restart resumes the same stage instead of writing a second paper.
|
|
340
352
|
paper: null,
|
|
341
353
|
}
|
|
342
354
|
}
|
|
343
|
-
|
|
355
|
+
// LOW (deep review): `state.order` used to be maintained here and preserved in the diagnostics
|
|
356
|
+
// fallback, but NOTHING ever read it — dead payload written into every snapshot. Removed: the
|
|
357
|
+
// institutes map is the index (`Object.keys(state.institutes)`), and old files that still carry
|
|
358
|
+
// an `order` key are simply ignored.
|
|
359
|
+
function initState() { return { v: PROJECTION_VERSION, institutes: {} } }
|
|
344
360
|
|
|
361
|
+
// S3 (deep-review 4): the returned state now PRESERVES every unknown TOP-LEVEL key from the
|
|
362
|
+
// loaded file (`Object.assign({}, state, …)`) instead of rebuilding `{v, institutes,
|
|
363
|
+
// diagnostics}` and thereby silently erasing anything a NEWER revision wrote at that level. The
|
|
364
|
+
// institute level already behaved that way (the fold copies the institute object), so this makes
|
|
365
|
+
// the two levels consistent. `order` stays gone for good (it was written-but-never-read and was
|
|
366
|
+
// removed in the previous round), so nothing is re-added here.
|
|
345
367
|
function withInstitute(state, key, mut) {
|
|
346
368
|
const cur = state.institutes[key] || emptyInstitute(key, '', '')
|
|
347
369
|
const nextInst = mut(cur)
|
|
348
370
|
if (nextInst === cur) return state
|
|
349
371
|
const institutes = Object.assign({}, state.institutes)
|
|
350
372
|
institutes[key] = nextInst
|
|
351
|
-
|
|
352
|
-
return { v: state.v, institutes, order, diagnostics: state.diagnostics }
|
|
373
|
+
return Object.assign({}, state, { institutes })
|
|
353
374
|
}
|
|
354
375
|
|
|
355
376
|
// Fold ONE event. Unknown/malformed events are SKIPPED and recorded in
|
|
@@ -357,6 +378,41 @@ export function apply(ctx) {
|
|
|
357
378
|
// log purism, and a malformed event is a code defect that tests must catch.
|
|
358
379
|
// (DSH's own team projection latches `state.failure` forever instead — a shape
|
|
359
380
|
// v5 deliberately does not copy.)
|
|
381
|
+
// ── in-fold id allocation (deep-review HIGH 1) ───────────────────────────────
|
|
382
|
+
// Ids used to be minted OUTSIDE the fold (read `counters` → `msg-N` → `await putMessage`), so
|
|
383
|
+
// two same-tick writers could mint the SAME id; the fold then deduped (`messages`) or upserted
|
|
384
|
+
// (`members`/`tasks`), silently dropping or overwriting one object while BOTH callers reported
|
|
385
|
+
// success. An event may now carry `d.make(alloc)` instead of a ready object: it runs INSIDE the
|
|
386
|
+
// fold, allocates its id from the fold's OWN counters, and returns the object(s) to
|
|
387
|
+
// append/upsert — the fold applies the object AND the bumped counters in the same step, so two
|
|
388
|
+
// concurrent appends cannot collide. Already-identified objects (`d.member`/`d.task`/
|
|
389
|
+
// `d.message`/`d.index`, the update paths) keep working unchanged.
|
|
390
|
+
const ID_PREFIX = { message: 'msg-', task: 't-', meeting: 'mt-', researcher: 'r-', temp: 't-' }
|
|
391
|
+
// MEDIUM 4 (deep review): the OFFICE is not a member, so `to:'voters'` never reaches it — yet
|
|
392
|
+
// `addResearcher`/`startMeeting` told the caller "已向所办提议" while only voters were addressed
|
|
393
|
+
// (and only the office may approve either). Office-addressed requests are now a first-class
|
|
394
|
+
// target: a durable message under this pseudo-id, surfaced by `status().officeRequests` and by
|
|
395
|
+
// `report()`, capped so the queue cannot grow without bound.
|
|
396
|
+
const OFFICE_INBOX = 'office'
|
|
397
|
+
const OFFICE_REQUEST_CAP = 10
|
|
398
|
+
function makeIdAllocator(inst) {
|
|
399
|
+
const counters = Object.assign({}, inst.counters)
|
|
400
|
+
let dirty = false
|
|
401
|
+
return {
|
|
402
|
+
next(kind) {
|
|
403
|
+
if (kind === 'academician') {
|
|
404
|
+
counters.academician = Math.max(1, Number(counters.academician) || 0)
|
|
405
|
+
dirty = true
|
|
406
|
+
return 'acad'
|
|
407
|
+
}
|
|
408
|
+
const n = Math.max(0, Number(counters[kind]) || 0) + 1
|
|
409
|
+
counters[kind] = n
|
|
410
|
+
dirty = true
|
|
411
|
+
return (ID_PREFIX[kind] || kind + '-') + n
|
|
412
|
+
},
|
|
413
|
+
counters() { return dirty ? counters : inst.counters },
|
|
414
|
+
}
|
|
415
|
+
}
|
|
360
416
|
function applyV5Event(state, event) {
|
|
361
417
|
try {
|
|
362
418
|
if (!event || typeof event.type !== 'string') return state
|
|
@@ -377,6 +433,9 @@ export function apply(ctx) {
|
|
|
377
433
|
if (patch.runId !== undefined) n.runId = String(patch.runId)
|
|
378
434
|
if (patch.lastProgressAt !== undefined) n.lastProgressAt = Number(patch.lastProgressAt) || 0
|
|
379
435
|
if (patch.artifactCount !== undefined) n.artifactCount = Number(patch.artifactCount) || 0
|
|
436
|
+
// F6 / F4 (status/report review): two durable markers the published surfaces read.
|
|
437
|
+
if (patch.meetingOpen !== undefined) n.meetingOpen = patch.meetingOpen === null ? null : Object.assign({}, patch.meetingOpen)
|
|
438
|
+
if (patch.officeRequestsDropped !== undefined) n.officeRequestsDropped = Number(patch.officeRequestsDropped) || 0
|
|
380
439
|
// The final-paper state. A FUNCTION is a MUTATION applied inside the fold (the same
|
|
381
440
|
// trick the verify queue uses): the paper stage, its parts and its office-consultation
|
|
382
441
|
// counters are updated from several places (member replies, office messages, meetings),
|
|
@@ -392,30 +451,79 @@ export function apply(ctx) {
|
|
|
392
451
|
}
|
|
393
452
|
if (t === EV.member) {
|
|
394
453
|
return withInstitute(state, key, (inst) => {
|
|
395
|
-
const
|
|
396
|
-
|
|
454
|
+
const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
|
|
455
|
+
const made = alloc ? d.make(alloc) : d.member
|
|
456
|
+
const list = (Array.isArray(made) ? made : [made]).filter(Boolean)
|
|
457
|
+
if (!list.length) return inst
|
|
397
458
|
const members = inst.members.slice()
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
459
|
+
let changed = false
|
|
460
|
+
for (const m of list) {
|
|
461
|
+
if (!m || typeof m.id !== 'string' || !m.id) continue
|
|
462
|
+
const i = members.findIndex((x) => x.id === m.id)
|
|
463
|
+
if (i === -1) members.push(m); else members[i] = m
|
|
464
|
+
changed = true
|
|
465
|
+
}
|
|
466
|
+
if (!changed) return inst
|
|
467
|
+
const out = Object.assign({}, inst, { members })
|
|
468
|
+
if (alloc) out.counters = alloc.counters()
|
|
469
|
+
return out
|
|
401
470
|
})
|
|
402
471
|
}
|
|
403
472
|
if (t === EV.task) {
|
|
404
473
|
return withInstitute(state, key, (inst) => {
|
|
405
|
-
const
|
|
406
|
-
|
|
474
|
+
const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
|
|
475
|
+
const made = alloc ? d.make(alloc) : d.task
|
|
476
|
+
const list = (Array.isArray(made) ? made : [made]).filter(Boolean)
|
|
477
|
+
if (!list.length) return inst
|
|
407
478
|
const tasks = inst.tasks.slice()
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
479
|
+
let changed = false
|
|
480
|
+
for (const task of list) {
|
|
481
|
+
if (!task || typeof task.id !== 'string' || !task.id) continue
|
|
482
|
+
const i = tasks.findIndex((x) => x.id === task.id)
|
|
483
|
+
if (i === -1) tasks.push(task); else tasks[i] = task
|
|
484
|
+
changed = true
|
|
485
|
+
}
|
|
486
|
+
if (!changed) return inst
|
|
487
|
+
const out = Object.assign({}, inst, { tasks })
|
|
488
|
+
if (alloc) out.counters = alloc.counters()
|
|
489
|
+
return out
|
|
411
490
|
})
|
|
412
491
|
}
|
|
413
492
|
if (t === EV.message) {
|
|
414
493
|
return withInstitute(state, key, (inst) => {
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
494
|
+
const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
|
|
495
|
+
const made = alloc ? d.make(alloc) : d.message
|
|
496
|
+
const list = (Array.isArray(made) ? made : [made]).filter(Boolean)
|
|
497
|
+
if (!list.length) return inst
|
|
498
|
+
const messages = inst.messages.slice()
|
|
499
|
+
let changed = false
|
|
500
|
+
for (const msg of list) {
|
|
501
|
+
if (!msg || typeof msg.id !== 'string' || !msg.id) continue
|
|
502
|
+
if (messages.some((x) => x.id === msg.id)) continue
|
|
503
|
+
messages.push(msg)
|
|
504
|
+
changed = true
|
|
505
|
+
}
|
|
506
|
+
if (!changed) return inst
|
|
507
|
+
// MEDIUM 4 (deep review): office-addressed requests (a member proposal that ONLY the
|
|
508
|
+
// office can approve) are never "delivered" — the office is not a member and has no
|
|
509
|
+
// inbox drain — so they are capped here, newest kept, oldest dropped. Without that cap
|
|
510
|
+
// `messages` would grow for the whole run.
|
|
511
|
+
let self = messages
|
|
512
|
+
let dropped = 0
|
|
513
|
+
const officeMsgs = self.filter((m) => m.to === OFFICE_INBOX)
|
|
514
|
+
if (officeMsgs.length > OFFICE_REQUEST_CAP) {
|
|
515
|
+
const drop = new Set(officeMsgs.slice(0, officeMsgs.length - OFFICE_REQUEST_CAP).map((m) => m.id))
|
|
516
|
+
self = self.filter((m) => !drop.has(m.id))
|
|
517
|
+
dropped = drop.size
|
|
518
|
+
}
|
|
519
|
+
// F4 (status/report review): the truncation is COUNTED, so `status()`/`report()` can say
|
|
520
|
+
// "显示最新 N / 累计丢弃 K" instead of presenting a truncated list as complete.
|
|
521
|
+
const out = Object.assign({}, inst, {
|
|
522
|
+
messages: self,
|
|
523
|
+
officeRequestsDropped: Number(inst.officeRequestsDropped || 0) + dropped,
|
|
524
|
+
})
|
|
525
|
+
if (alloc) out.counters = alloc.counters()
|
|
526
|
+
return out
|
|
419
527
|
})
|
|
420
528
|
}
|
|
421
529
|
if (t === EV.delivered) {
|
|
@@ -441,11 +549,14 @@ export function apply(ctx) {
|
|
|
441
549
|
}
|
|
442
550
|
if (t === EV.meeting) {
|
|
443
551
|
return withInstitute(state, key, (inst) => {
|
|
444
|
-
const
|
|
445
|
-
|
|
552
|
+
const alloc = typeof d.make === 'function' ? makeIdAllocator(inst) : null
|
|
553
|
+
const idx = alloc ? d.make(alloc) : d.index
|
|
554
|
+
if (!idx || typeof idx.id !== 'string' || !idx.id) return inst
|
|
446
555
|
if (inst.meetings.some((x) => x.id === idx.id)) return inst
|
|
447
556
|
const meetings = inst.meetings.concat([idx])
|
|
448
|
-
|
|
557
|
+
const out = Object.assign({}, inst, { meetings: meetings.length > 200 ? meetings.slice(meetings.length - 200) : meetings })
|
|
558
|
+
if (alloc) out.counters = alloc.counters()
|
|
559
|
+
return out
|
|
449
560
|
})
|
|
450
561
|
}
|
|
451
562
|
if (t === EV.debate) {
|
|
@@ -469,9 +580,23 @@ export function apply(ctx) {
|
|
|
469
580
|
return withInstitute(state, key, (inst) => {
|
|
470
581
|
if (!d.target || typeof d.target !== 'string') return inst
|
|
471
582
|
const formal = Object.assign({}, inst.formal)
|
|
472
|
-
|
|
583
|
+
// MEDIUM 6 (deep review): `record` and `todo` accept a FUNCTION (a mutation applied
|
|
584
|
+
// inside the fold), exactly like `EV.queue`/`patch.paper`. The old shape built them from
|
|
585
|
+
// reads taken OUTSIDE the fold (`formalTodo().filter(...)`) and replaced the durable
|
|
586
|
+
// value wholesale, so two same-tick writers for different targets lost one entry —
|
|
587
|
+
// the lost-update class the queue fix at this file's queue branch already removed.
|
|
588
|
+
if (typeof d.record === 'function') {
|
|
589
|
+
const target = d.target
|
|
590
|
+
const next = d.record(formal[target] === undefined ? null : formal[target])
|
|
591
|
+
if (next === null || next === undefined) delete formal[target]
|
|
592
|
+
else formal[target] = next
|
|
593
|
+
} else if (d.record === null) delete formal[d.target]
|
|
473
594
|
else formal[d.target] = d.record
|
|
474
|
-
const todo = d.todo === undefined
|
|
595
|
+
const todo = d.todo === undefined
|
|
596
|
+
? (inst.todo || [])
|
|
597
|
+
: (typeof d.todo === 'function'
|
|
598
|
+
? (d.todo(inst.todo || []) || inst.todo || [])
|
|
599
|
+
: (Array.isArray(d.todo) ? d.todo : (inst.todo || [])))
|
|
475
600
|
return Object.assign({}, inst, { formal, todo })
|
|
476
601
|
})
|
|
477
602
|
}
|
|
@@ -491,8 +616,19 @@ export function apply(ctx) {
|
|
|
491
616
|
return Object.assign({}, inst, { queue: list })
|
|
492
617
|
})
|
|
493
618
|
}
|
|
494
|
-
if (t === EV.
|
|
495
|
-
|
|
619
|
+
if (t === EV.solve) {
|
|
620
|
+
// HIGH 3: one durable place for the "is it solved?" tally. `d.clear` resets it (a new
|
|
621
|
+
// solve question), `d.member`+`d.value` records/withdraws ONE member's answer (`null`
|
|
622
|
+
// withdraws, mirroring how `fire` drops a dismissed member's ballot).
|
|
623
|
+
return withInstitute(state, key, (inst) => {
|
|
624
|
+
if (d.clear) return Object.assign({}, inst, { solve: {} })
|
|
625
|
+
const member = String(d.member || '')
|
|
626
|
+
if (!member) return inst
|
|
627
|
+
const cur = Object.assign({}, inst.solve || {})
|
|
628
|
+
if (d.value === null || d.value === undefined) delete cur[member]
|
|
629
|
+
else cur[member] = d.value === true
|
|
630
|
+
return Object.assign({}, inst, { solve: cur })
|
|
631
|
+
})
|
|
496
632
|
}
|
|
497
633
|
if (t === EV.progress) {
|
|
498
634
|
return withInstitute(state, key, (inst) => Object.assign({}, inst, {
|
|
@@ -505,7 +641,8 @@ export function apply(ctx) {
|
|
|
505
641
|
// Never throw out of the fold: one bad event must not break every later read.
|
|
506
642
|
try {
|
|
507
643
|
const diagnostics = (state.diagnostics || []).concat([{ at: now(), type: String(event && event.type), error: String((e && e.message) || e) }])
|
|
508
|
-
|
|
644
|
+
// S3: keep every unknown top-level key here too (see `withInstitute`).
|
|
645
|
+
return Object.assign({}, state, { v: state.v, institutes: state.institutes, diagnostics: diagnostics.slice(-50) })
|
|
509
646
|
} catch (e2) { return state }
|
|
510
647
|
}
|
|
511
648
|
}
|
|
@@ -525,7 +662,7 @@ export function apply(ctx) {
|
|
|
525
662
|
// default path, after which `vibe_v5_configure {institute:'alpha'}` never read alpha's
|
|
526
663
|
// file and wrote an EMPTY institute over it. Switching paths now re-reads the new path
|
|
527
664
|
// first, and if that read fails the backend refuses to write instead of clobbering.
|
|
528
|
-
function makeFileBackend(readTextAbs, writeTextAbs, pathOf) {
|
|
665
|
+
function makeFileBackend(readTextAbs, writeTextAbs, pathOf, onWriteFailure, onLoadProblem) {
|
|
529
666
|
let mem = initState()
|
|
530
667
|
let chain = Promise.resolve(true)
|
|
531
668
|
let loadedPath // the path whose file is currently folded into `mem`
|
|
@@ -535,22 +672,40 @@ export function apply(ctx) {
|
|
|
535
672
|
// Fold ONE path into `mem`. `readTextAbs` returning undefined means "no file yet", which
|
|
536
673
|
// is the only case that licences a later write; anything else (a throw, a parse error, a
|
|
537
674
|
// version mismatch) leaves `loadOk` false so commit() refuses to clobber it.
|
|
675
|
+
// S2 (deep-review 4): the REASON is recorded (`loadProblem`), because "no file" and "a file we
|
|
676
|
+
// could not use" used to look identical to every reader — an operator saw an empty institute
|
|
677
|
+
// with no hint that their state file exists and was rejected, and the note only appeared after
|
|
678
|
+
// a WRITE was attempted.
|
|
679
|
+
let loadProblem = ''
|
|
538
680
|
async function doLoad(p) {
|
|
539
681
|
let ok = false
|
|
682
|
+
loadProblem = ''
|
|
540
683
|
try {
|
|
541
684
|
const raw = await readTextAbs(p)
|
|
542
685
|
if (raw === undefined || raw === null || raw === '') ok = true
|
|
543
686
|
else {
|
|
544
|
-
|
|
545
|
-
|
|
687
|
+
let parsed = null
|
|
688
|
+
try { parsed = JSON.parse(raw) } catch (e) { loadProblem = 'invalid JSON (truncated or corrupt)' }
|
|
689
|
+
if (parsed) {
|
|
690
|
+
if (parsed.v === PROJECTION_VERSION) { mem = parsed; ok = true }
|
|
691
|
+
else loadProblem = 'schema version mismatch: file has v=' + JSON.stringify(parsed.v) + ', this build reads v=' + PROJECTION_VERSION
|
|
692
|
+
}
|
|
546
693
|
}
|
|
547
|
-
} catch (e) {
|
|
694
|
+
} catch (e) { loadProblem = 'unreadable: ' + String((e && e.message) || e) }
|
|
548
695
|
loadedPath = p
|
|
549
696
|
loadOk = ok
|
|
550
697
|
loadPromise = null
|
|
551
698
|
loadPendingPath = null
|
|
552
699
|
return mem
|
|
553
700
|
}
|
|
701
|
+
// S2: the actionable text for a file that EXISTS but could not be used (vs "no file yet").
|
|
702
|
+
function loadProblemText(where) {
|
|
703
|
+
if (!loadProblem) return ''
|
|
704
|
+
return 'the state file ' + where + ' exists but could NOT be used (' + loadProblem + '); '
|
|
705
|
+
+ 'writes are refused so it is never clobbered. Fix: back it up, then either restore a v'
|
|
706
|
+
+ PROJECTION_VERSION + ' file or remove/rename it to start a fresh institute in that path '
|
|
707
|
+
+ '(the current file is left untouched).'
|
|
708
|
+
}
|
|
554
709
|
const backend = {
|
|
555
710
|
kind: 'file',
|
|
556
711
|
async load() {
|
|
@@ -564,6 +719,9 @@ export function apply(ctx) {
|
|
|
564
719
|
loadPendingPath = path
|
|
565
720
|
loadPromise = doLoad(path)
|
|
566
721
|
await loadPromise
|
|
722
|
+
// S2: a file that EXISTS but was rejected is reported on the READ path too (not only when
|
|
723
|
+
// a write is refused), so `/v5 status` can never silently look like "nothing here yet".
|
|
724
|
+
if (!loadOk && loadProblem) { try { onLoadProblem(loadProblemText(path)) } catch (e) { /* diagnostics must not break the load */ } }
|
|
567
725
|
if (loadedPath === pathOf()) return mem
|
|
568
726
|
// The path moved again while we were reading: hand back the state for the path
|
|
569
727
|
// that is current NOW rather than one for a directory we are no longer using.
|
|
@@ -599,7 +757,17 @@ export function apply(ctx) {
|
|
|
599
757
|
// late writer always lands the FULL newest state and can never overwrite with
|
|
600
758
|
// a stale subset (v4 §27 writeJson defect).
|
|
601
759
|
chain = chain.then(async () => {
|
|
602
|
-
|
|
760
|
+
// MEDIUM 7 (deep review): the write used to be wrapped in `catch { /* best effort */ }`,
|
|
761
|
+
// and `writeTextAbs` already swallows its own failures (it returns `undefined`), so a
|
|
762
|
+
// failed write was INVISIBLE: the in-memory state advanced, every caller reported
|
|
763
|
+
// success, and the operator believed the run was persisted. The fold cannot be rolled
|
|
764
|
+
// back here, so the failure is made durable + visible instead (`onWriteFailure` feeds the
|
|
765
|
+
// same `diagnostics` list that `report()` prints under `## ⚠ 状态诊断`) and also logged.
|
|
766
|
+
const outcome = await writeTextAbs(pathOf(), JSON.stringify(snapshot, null, 2))
|
|
767
|
+
if (outcome === undefined) {
|
|
768
|
+
try { onWriteFailure(pathOf()) } catch (e) { /* diagnostics must never break the chain */ }
|
|
769
|
+
try { console.error('vibe-math-v5: state write FAILED for ' + pathOf() + ' — the in-memory state advanced but the file is STALE') } catch (e) { /* ignore */ }
|
|
770
|
+
}
|
|
603
771
|
return true
|
|
604
772
|
})
|
|
605
773
|
return mem
|
|
@@ -741,6 +909,15 @@ export function apply(ctx) {
|
|
|
741
909
|
const roundsSinceCompact = new Map()
|
|
742
910
|
const contextPct = new Map()
|
|
743
911
|
const needReanchor = new Set()
|
|
912
|
+
// G1 (mailbox): ids already PREPENDED to a prompt for this member but not yet acked. The prompt can
|
|
913
|
+
// legitimately be built more than once before the wake (that was the v4 §24 duplicate-read defect),
|
|
914
|
+
// so the injection mark keeps "at most one inbox section per prompt" while the messages stay pending
|
|
915
|
+
// until the send actually succeeds. Cleared on every concluded wake attempt.
|
|
916
|
+
const inboxInjected = new Map()
|
|
917
|
+
// G1: members whose mail `promptFor` is prepending IN THIS BUILD — `briefBlock` must then omit its
|
|
918
|
+
// own `[新到的消息/通知]` block, or the same messages appear twice in one prompt (the duplicate-read
|
|
919
|
+
// defect the prompt-v5-integrity suite pins). Only set while a block is actually being prepended.
|
|
920
|
+
const inboxSuppressed = new Set()
|
|
744
921
|
const seeds = new Map() // memberId -> condensed self-summary seed
|
|
745
922
|
const lastActiveAt = new Map()
|
|
746
923
|
let currentMember = ''
|
|
@@ -760,10 +937,43 @@ export function apply(ctx) {
|
|
|
760
937
|
let backend = null
|
|
761
938
|
let stateCache = null
|
|
762
939
|
|
|
940
|
+
// G-7 (installer ROUND2, safety): the host's `sandboxPolicy.resolve()` may fall back to a WIDER
|
|
941
|
+
// root than this session's workspace (the host lib catches and resolves `{}` on its own errors).
|
|
942
|
+
// v5 cannot override the host fence — it passes the policy straight to `fs.writeText` — but it
|
|
943
|
+
// CAN compare the root it believes in (the session cwd, which every member-facing path is built
|
|
944
|
+
// from) with the root the policy advertises, and surface a NAMED warning when they disagree.
|
|
945
|
+
// Recorded once per session (a diagnostic, never a write refusal: refusing would silently stop
|
|
946
|
+
// persisting legitimate work, and v5 has no way to widen or narrow the host's fence itself).
|
|
947
|
+
let sandboxPolicyChecked = false
|
|
948
|
+
let sandboxPolicyMismatch = null
|
|
949
|
+
function sessionCwd() {
|
|
950
|
+
try { if (rootAgent && rootAgent.session && rootAgent.session.header && rootAgent.session.header.cwd) return String(rootAgent.session.header.cwd) } catch (e) { /* unknown */ }
|
|
951
|
+
return ''
|
|
952
|
+
}
|
|
953
|
+
const normRoot = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
954
|
+
function checkSandboxPolicyRoot() {
|
|
955
|
+
if (sandboxPolicyChecked) return
|
|
956
|
+
sandboxPolicyChecked = true
|
|
957
|
+
try {
|
|
958
|
+
const sp = sandboxPolicyOf()
|
|
959
|
+
const policyRoot = sp && sp.workspaceRoot ? String(sp.workspaceRoot) : ''
|
|
960
|
+
const expected = sessionCwd()
|
|
961
|
+
if (!policyRoot || !expected) return
|
|
962
|
+
if (normRoot(policyRoot) !== normRoot(expected)) {
|
|
963
|
+
sandboxPolicyMismatch = {
|
|
964
|
+
at: now(), expected, policyRoot,
|
|
965
|
+
note: '沙箱策略声明的可写根与会话工作目录不一致:v5 用会话工作目录拼所有路径(成员提示词/状态/Formal/Verified),'
|
|
966
|
+
+ '若宿主按策略根解析写操作,产物可能落在预期之外的那棵树。请核对 sandbox 策略的 workspaceRoot。',
|
|
967
|
+
}
|
|
968
|
+
console.error('vibe-math-v5: ⚠ 沙箱策略根与会话工作目录不一致(策略=' + policyRoot + ',会话=' + expected + '):写盘位置可能不是你预期的那棵树')
|
|
969
|
+
}
|
|
970
|
+
} catch (e) { /* a diagnostic must never break path resolution */ }
|
|
971
|
+
}
|
|
763
972
|
function workspaceRoot() {
|
|
764
|
-
|
|
973
|
+
const cwd = sessionCwd()
|
|
974
|
+
if (cwd) { checkSandboxPolicyRoot(); return cwd }
|
|
765
975
|
const sp = sandboxPolicyOf()
|
|
766
|
-
if (sp && sp.workspaceRoot) return sp.workspaceRoot
|
|
976
|
+
if (sp && sp.workspaceRoot) { checkSandboxPolicyRoot(); return sp.workspaceRoot }
|
|
767
977
|
return '.'
|
|
768
978
|
}
|
|
769
979
|
const vibeRoot = () => (workspaceRoot() + '/VibeMath').replace(/\\/g, '/')
|
|
@@ -777,6 +987,49 @@ export function apply(ctx) {
|
|
|
777
987
|
// writes keep going through `instRoot()`. `instRel()` is the one place that composes it.
|
|
778
988
|
const instRootRel = () => 'VibeMath/Projects/' + project + '/Institutes/' + instituteName
|
|
779
989
|
const instRel = (rel) => instRootRel() + '/' + String(rel == null ? '' : rel).replace(/^\/+/, '')
|
|
990
|
+
// CLASS GUARD (lead's re-verification round): "documented member write paths must be paths the
|
|
991
|
+
// framework scans/reads". `instRootRel()` (what the member-facing text advertises) and
|
|
992
|
+
// `instRoot()` (what `writeTextRel`/`readTextRel`/`readLibrary`/`findCardRel`/`mkdirs` actually
|
|
993
|
+
// use) are two COMPOSITIONS of the same layout: if a future edit changes one and not the other,
|
|
994
|
+
// the text advertises a directory nothing writes to (exactly the v4 defect found in 58fff7f).
|
|
995
|
+
// Comparing the two compositions — instead of trusting two hand-copied strings — is what makes
|
|
996
|
+
// that class impossible to reintroduce silently.
|
|
997
|
+
function memberPathContractOk() {
|
|
998
|
+
const probe = 'Members/__probe__/Progress/progress.md'
|
|
999
|
+
const documented = instRel(probe)
|
|
1000
|
+
const abs = (instRoot() + '/' + probe).replace(/\\/g, '/')
|
|
1001
|
+
const ws = String(workspaceRoot() || '').replace(/\\/g, '/')
|
|
1002
|
+
if (!ws || ws === '.') return true // no cwd to strip: nothing to compare
|
|
1003
|
+
const actual = abs.indexOf(ws) === 0 ? abs.slice(ws.length).replace(/^\/+/, '') : abs
|
|
1004
|
+
if (documented !== actual) {
|
|
1005
|
+
console.error('vibe-math-v5: member library path contract BROKEN — the member-facing text says "'
|
|
1006
|
+
+ documented + '" but the framework writes/reads "' + actual + '"')
|
|
1007
|
+
return false
|
|
1008
|
+
}
|
|
1009
|
+
return true
|
|
1010
|
+
}
|
|
1011
|
+
// LEAN CLASS GUARD (L1 of the Lean deep review, same spirit as `memberPathContractOk`): the path
|
|
1012
|
+
// the Lean prompts tell a member to write must be RESOLVABLE BY THE TOOL THAT CONSUMES IT.
|
|
1013
|
+
// `leanAbsPath` is called long after this point, so it is looked up lazily via the closure (it is
|
|
1014
|
+
// a function declaration inside this scope, hence hoisted) and the comparison is between the
|
|
1015
|
+
// COMPOSITIONS on both sides — never two hand-copied strings.
|
|
1016
|
+
function leanPathContractOk() {
|
|
1017
|
+
const probe = 'Formal/__probe__.lean'
|
|
1018
|
+
const expected = normalizeAbsPath(instRoot() + '/' + probe)
|
|
1019
|
+
// The EXACT composition the prompt prints (`formalRootRel()`), plus the two spellings a member
|
|
1020
|
+
// may pass to a tool. All three must name the file the framework uses.
|
|
1021
|
+
const printedProbe = formalRootRel().replace(/\/+$/, '') + '/__probe__.lean'
|
|
1022
|
+
const viaPrinted = leanAbsPath(printedProbe)
|
|
1023
|
+
const viaDocumented = leanAbsPath(instRel(probe)) // what the prompt advertises (cwd-relative)
|
|
1024
|
+
const viaShort = leanAbsPath(probe) // the tools' short form (institute-relative)
|
|
1025
|
+
if (viaPrinted !== expected || viaDocumented !== expected || viaShort !== expected) {
|
|
1026
|
+
console.error('vibe-math-v5: Lean path contract BROKEN — printed "' + printedProbe
|
|
1027
|
+
+ '" resolves to ' + String(viaPrinted) + ', documented "' + instRel(probe) + '" to '
|
|
1028
|
+
+ String(viaDocumented) + ', short form to ' + String(viaShort) + ', but the framework uses ' + expected)
|
|
1029
|
+
return false
|
|
1030
|
+
}
|
|
1031
|
+
return true
|
|
1032
|
+
}
|
|
780
1033
|
|
|
781
1034
|
function getPolicy() {
|
|
782
1035
|
const sp = sandboxPolicyOf()
|
|
@@ -803,7 +1056,9 @@ export function apply(ctx) {
|
|
|
803
1056
|
async function writeTextRel(rel, content) { return (await writeTextAbs(instRoot() + '/' + rel, content)) !== undefined }
|
|
804
1057
|
|
|
805
1058
|
function installBackend() {
|
|
806
|
-
backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json'
|
|
1059
|
+
backend = makeFileBackend(readTextAbs, writeTextAbs, () => instRoot() + '/State/' + instituteName + '.v5state.json',
|
|
1060
|
+
(p) => noteWriteProblem(p), // MEDIUM 7: a failed write must be visible, never swallowed
|
|
1061
|
+
(text) => { lastLoadProblem = text; noteLoadProblem(text) }) // S2: a rejected file is visible on READS too
|
|
807
1062
|
return backend
|
|
808
1063
|
}
|
|
809
1064
|
// The ONE call site that awaits the backend's load (the sensitivity probe for
|
|
@@ -817,9 +1072,24 @@ export function apply(ctx) {
|
|
|
817
1072
|
// backend's `mem` is still the empty initial state, so a fresh process would report
|
|
818
1073
|
// an empty roster and `resume` would refuse with "no active member to resume" —
|
|
819
1074
|
// i.e. the fallback would silently lose the whole institute across a restart.
|
|
1075
|
+
// MEDIUM 8 (deep review): "a read must be preceded by an awaited load" was only a CONVENTION.
|
|
1076
|
+
// `state()` stays synchronous by design (the whole fold reads through it), so it can only
|
|
1077
|
+
// START the load — a read that lands before that load settles sees the empty/previous `mem`.
|
|
1078
|
+
// Enforcement is impossible without making every read async, so the rule is now explicit and
|
|
1079
|
+
// OBSERVABLE:
|
|
1080
|
+
// • SAFE (they all `await ready()` first): every `registerTool` handler, the `/v5` command,
|
|
1081
|
+
// `commit()` (it awaits `awaitBackendLoad()` before touching `mem`) and `schedulePass()`
|
|
1082
|
+
// (see its `ready()` call).
|
|
1083
|
+
// • UNSAFE by construction: any other synchronous consumer of the session API
|
|
1084
|
+
// (`inst()`/`state()` called from inside ANOTHER plugin, or from a raw host callback that
|
|
1085
|
+
// does not go through a tool wrapper). Those get a counter (`prematureReads`) surfaced by
|
|
1086
|
+
// `status()`/`report()` so the gap is visible instead of silent.
|
|
1087
|
+
let loadSettled = false
|
|
1088
|
+
let prematureReads = 0
|
|
820
1089
|
async function ready() {
|
|
821
1090
|
if (!backend) installBackend()
|
|
822
1091
|
await awaitBackendLoad()
|
|
1092
|
+
loadSettled = true
|
|
823
1093
|
// Crash recovery for the Lean queue runs ONCE per session, after the state file is
|
|
824
1094
|
// folded in: a leftover Formal/Jobs/*.json is re-queued or explicitly interrupted, and
|
|
825
1095
|
// is NEVER silently turned into `passed` (docs/formal-verification.md §7-7).
|
|
@@ -829,6 +1099,11 @@ export function apply(ctx) {
|
|
|
829
1099
|
// The math availability line is derived at session start (and on every tuning that
|
|
830
1100
|
// touches the six math_* keys); `probeMathEngines` caches per host, so this is cheap.
|
|
831
1101
|
try { await refreshMathLine() } catch (e) { console.error('vibe-math-v5: math probe: ' + String((e && e.message) || e)) }
|
|
1102
|
+
// The member-library path contract (documented root == the root the framework writes/reads).
|
|
1103
|
+
// A broken contract is recorded in `diagnostics` so `report()` shows it instead of leaving a
|
|
1104
|
+
// silent "members write where nothing reads" state.
|
|
1105
|
+
try { if (!memberPathContractOk()) noteLoadProblem('member library path contract broken (documented root != framework root)') } catch (e) { /* never break ready() */ }
|
|
1106
|
+
try { if (!leanPathContractOk()) noteLoadProblem('Lean path contract broken (a documented Lean path is not resolvable by the tool that consumes it)') } catch (e) { /* never break ready() */ }
|
|
832
1107
|
return true
|
|
833
1108
|
}
|
|
834
1109
|
// Load the CURRENT state path before its first read. `state()` is synchronous by design
|
|
@@ -846,6 +1121,9 @@ export function apply(ctx) {
|
|
|
846
1121
|
// not been folded in yet (the 2.4.1 audit's H1: a read-less `state()` was half of the
|
|
847
1122
|
// overwrite). `ready()`/`commit()` await the same load; here it only has to START.
|
|
848
1123
|
ensureLoaded()
|
|
1124
|
+
// MEDIUM 8: a read that lands before the first successful load is counted (and reported),
|
|
1125
|
+
// instead of silently answering from the empty/previous snapshot.
|
|
1126
|
+
if (!loadSettled) prematureReads += 1
|
|
849
1127
|
stateCache = backend.read()
|
|
850
1128
|
return stateCache
|
|
851
1129
|
}
|
|
@@ -871,8 +1149,28 @@ export function apply(ctx) {
|
|
|
871
1149
|
if (t && pendingLoadNotes.indexOf(t) === -1) pendingLoadNotes.push(t)
|
|
872
1150
|
if (pendingLoadNotes.length > 5) pendingLoadNotes = pendingLoadNotes.slice(-5)
|
|
873
1151
|
}
|
|
1152
|
+
// MEDIUM 7 (deep review): a FAILED state write cannot be rolled back (the fold already
|
|
1153
|
+
// advanced the in-memory state), so it must at least be impossible to miss. Counted for
|
|
1154
|
+
// `status()`/`report()` AND queued for the next allowed commit's diagnostics.
|
|
1155
|
+
let stateWriteFailures = 0
|
|
1156
|
+
// S2: the ACTIONABLE reason a state file that exists was rejected (empty while everything is
|
|
1157
|
+
// fine). Surfaced by `status()`/`report()` on the read path, not only after a refused write.
|
|
1158
|
+
let lastLoadProblem = ''
|
|
1159
|
+
// F1 (status/report review): a SESSION-LEVEL log of load/write problems. `drainLoadNotes` used to
|
|
1160
|
+
// write them into `stateCache` only — which the very next `state()` read replaced with the
|
|
1161
|
+
// backend's `mem` — so they could vanish between commits and, worse, they were never the array
|
|
1162
|
+
// `report()` printed (`report()` read the INSTITUTE-level diagnostics). Keeping a session log is
|
|
1163
|
+
// what lets the diagnostics section show the sources its heading advertises.
|
|
1164
|
+
let loadProblemLog = []
|
|
1165
|
+
function noteWriteProblem(p) {
|
|
1166
|
+
stateWriteFailures += 1
|
|
1167
|
+
noteLoadProblem('state write FAILED (the file is STALE): ' + String(p || '?'))
|
|
1168
|
+
}
|
|
874
1169
|
function drainLoadNotes() {
|
|
875
|
-
if (!pendingLoadNotes.length
|
|
1170
|
+
if (!pendingLoadNotes.length) return
|
|
1171
|
+
loadProblemLog = loadProblemLog.concat(pendingLoadNotes.map((t) => ({ at: now(), type: 'vibe5/load', error: t })))
|
|
1172
|
+
if (loadProblemLog.length > 10) loadProblemLog = loadProblemLog.slice(-10)
|
|
1173
|
+
if (!stateCache) { pendingLoadNotes = []; return }
|
|
876
1174
|
const list = (stateCache.diagnostics || []).concat(pendingLoadNotes.map((t) => ({ at: now(), type: 'vibe5/load', error: t })))
|
|
877
1175
|
pendingLoadNotes = []
|
|
878
1176
|
stateCache = Object.assign({}, stateCache, { diagnostics: list.slice(-50) })
|
|
@@ -896,6 +1194,12 @@ export function apply(ctx) {
|
|
|
896
1194
|
const cur = stateCache.institutes[key]
|
|
897
1195
|
if (cur) {
|
|
898
1196
|
phase = cur.phase || phase
|
|
1197
|
+
// S1 (deep-review 4 / repro: `_oneoff/s1-solved-reload.mjs`): the CONCLUDED marker is
|
|
1198
|
+
// durable state, not a session memory. `autoDone` used to live only in memory, so a fresh
|
|
1199
|
+
// session over a `phase:'solved'` file had `autoDone=false` and `/v5 start` re-founded the
|
|
1200
|
+
// concluded institute (new runId, new members on top of the old roster). Mirroring the
|
|
1201
|
+
// persisted phase makes every existing `autoDone` guard correct again on reload.
|
|
1202
|
+
autoDone = String(phase) === 'solved'
|
|
899
1203
|
// Restored params must go through the SAME normalisation as `vibe_v5_set`: a hand-edited
|
|
900
1204
|
// or foreign-shaped state file (e.g. mathEngines as a comma STRING, a bogus enum, a
|
|
901
1205
|
// sub-1000 timeout) would otherwise reach `visibleParams()` and v5's own
|
|
@@ -913,15 +1217,22 @@ export function apply(ctx) {
|
|
|
913
1217
|
const patchInstitute = (patch) => commit(EV.institute, { patch })
|
|
914
1218
|
const putMember = (member) => commit(EV.member, { member })
|
|
915
1219
|
const putTask = (task) => commit(EV.task, { task })
|
|
916
|
-
|
|
1220
|
+
// HIGH 1: creation goes through an in-fold allocator — the callback runs INSIDE the fold, so
|
|
1221
|
+
// the id it hands out and the object it returns are committed in one step (makeIdAllocator).
|
|
1222
|
+
// The old whole-object creation setters (`putMessage`/`putMeeting`/`putCounters`) are GONE on
|
|
1223
|
+
// purpose: reusing one would mint an id from a read taken outside the fold and reintroduce the
|
|
1224
|
+
// silent-loss race this fix removes. Updates use `putMember`/`putTask` (already-identified
|
|
1225
|
+
// objects) and, for messages, the same allocator.
|
|
1226
|
+
const putMessageMake = (make) => commit(EV.message, { make })
|
|
1227
|
+
// HIGH 3: the "is it solved?" tally is durable state, not an in-memory Map.
|
|
1228
|
+
const putSolve = (patch) => commit(EV.solve, patch || {})
|
|
917
1229
|
const ackDelivered = (ids) => commit(EV.delivered, { ids })
|
|
918
|
-
const putMeeting = (index) => commit(EV.meeting, { index })
|
|
919
1230
|
const putDebate = (index) => commit(EV.debate, { index })
|
|
920
1231
|
const putVerdict = (target, record) => commit(EV.verdict, { target, record })
|
|
921
1232
|
// The verify queue has NO whole-array setter any more: both of its mutations go through
|
|
922
1233
|
// `appendToQueue`/`takeQueueHead` below, which hand a FUNCTION to the event fold so the
|
|
923
|
-
// read-modify-write is atomic (the lost-proposal race).
|
|
924
|
-
|
|
1234
|
+
// read-modify-write is atomic (the lost-proposal race). Counters are bumped by the object
|
|
1235
|
+
// folds themselves (`makeIdAllocator`), so there is no `counters` setter either.
|
|
925
1236
|
const markProgress = async () => {
|
|
926
1237
|
lastProgressAt = now()
|
|
927
1238
|
await commit(EV.progress, { at: lastProgressAt, artifactCount: inst().artifactCount })
|
|
@@ -1063,7 +1374,7 @@ export function apply(ctx) {
|
|
|
1063
1374
|
].join('\n')
|
|
1064
1375
|
}
|
|
1065
1376
|
const LIB_SPEC = [
|
|
1066
|
-
' · Progress
|
|
1377
|
+
' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。',
|
|
1067
1378
|
' 主要内容是:尝试过的各方法、路线、历程、进度;当前研究进展/进度;将来的计划与打算;',
|
|
1068
1379
|
' 及各路线、过程中遇到的障碍及其原因;对各路线、方法的看法、可行性评估;自己研究过程',
|
|
1069
1380
|
' 中的一些有价值看法、感想、猜想、理解。以及其它各种你认为有价值的值得记录的事物、',
|
|
@@ -1077,20 +1388,20 @@ export function apply(ctx) {
|
|
|
1077
1388
|
' ▸ 写法建议:按时间追加,每次记一小节;把"结论/进展"与"理由/证据"分开写;',
|
|
1078
1389
|
' 悬而未决的问题明确标出。',
|
|
1079
1390
|
'',
|
|
1080
|
-
' · Propos
|
|
1391
|
+
' · Members/<你>/Propos/<id>.md —— **你的命题/引理**。格式:',
|
|
1081
1392
|
' - ID: p-<id>; - 状态: 未定论; - 概率: <0-1>; - 价值程度: <0-1>; - 动机用途计划: <为何重要/打算怎么用>',
|
|
1082
1393
|
' 然后 ## 陈述 <完整陈述>;## 证明尝试;## 证伪尝试。',
|
|
1083
|
-
' · Methods
|
|
1394
|
+
' · Members/<你>/Methods/<id>.md —— **你的理论/方法/工具**。格式:',
|
|
1084
1395
|
' - ID: m-<id>; - 状态: 经验; - 可信断言: []; - 价值程度: <0-1>; - 动机用途计划: ...',
|
|
1085
1396
|
' 然后 ## 核心内容;## 定义与记号;## 应用记录;## 改进历史。',
|
|
1086
|
-
' · Subproblems
|
|
1397
|
+
' · Members/<你>/Subproblems/<id>.md —— **你的子问题**。格式:',
|
|
1087
1398
|
' - ID: s-<id>; - 状态: 求解中; - 价值程度: <0-1>; - 动机用途计划: ...',
|
|
1088
1399
|
' 然后 ## 陈述;## 进度。',
|
|
1089
1400
|
'',
|
|
1090
1401
|
' 三条硬要求:',
|
|
1091
1402
|
' ① 凡入库必须写明 **价值程度 / 动机用途计划 / 你对该对象为真的概率估计**(缺一不可);',
|
|
1092
1403
|
' ② **只写自己的库**;读别人的库是允许且被鼓励的;',
|
|
1093
|
-
' ③ 推荐**直接用 fs
|
|
1404
|
+
' ③ 推荐**直接用 fs 写你自己的文件**(路径必须带成员库根,见上:Members/<你>/…);vibe_v5_record_* 只是便捷记录器,不是必需。',
|
|
1094
1405
|
].join('\n')
|
|
1095
1406
|
|
|
1096
1407
|
// Computed per hire (a FUNCTION, not a frozen const): the text names the live
|
|
@@ -1259,8 +1570,8 @@ export function apply(ctx) {
|
|
|
1259
1570
|
// ── 三、libraries ───────────────────────────────────────────────────
|
|
1260
1571
|
L.push('【三、你的资料库、progress 与卡片格式】')
|
|
1261
1572
|
L.push(' 你的资料库根目录(**相对会话工作目录**):' + instRel('Members/' + member.id + '/'))
|
|
1262
|
-
L.push('
|
|
1263
|
-
L.push(' ⚠
|
|
1573
|
+
L.push(' (`<你>` = 你自己的成员 id:下面每条都已带完整库根,可直接照抄。你**只写自己的库**,但可以读任何人的对应目录。)')
|
|
1574
|
+
L.push(' ⚠ 路径基准:你自己的文件工具的**相对路径以会话工作目录为基准**,所以直接读写文件时必须用**带库根的完整路径**(把下面每条里的 `Members/<你>/` 展开成你自己的 id,例如 Members/acad/Progress/progress.md);')
|
|
1264
1575
|
L.push(' 若只想记录成果,直接用 vibe_v5_record_progress / vibe_v5_record_proposition / vibe_v5_record_method / vibe_v5_record_subproblem,框架会写到正确位置。')
|
|
1265
1576
|
L.push('')
|
|
1266
1577
|
L.push(LIB_SPEC)
|
|
@@ -1392,13 +1703,17 @@ export function apply(ctx) {
|
|
|
1392
1703
|
// only AFTER the subagent had already been started, every member's induction brief
|
|
1393
1704
|
// named the PREVIOUSLY founded member (the academician was told it was "?"). The
|
|
1394
1705
|
// model's whole self-model, its library path and its vote were therefore wrong.
|
|
1395
|
-
|
|
1706
|
+
// `roundNo` is an explicit override for the round this block is being built FOR. The founding
|
|
1707
|
+
// prompt needs it: `spawnMember` now counts the round only after `startContinuable` succeeds, so
|
|
1708
|
+
// without the override the very first prompt would announce 轮次 0 instead of 轮次 1 (spawnMember/
|
|
1709
|
+
// G2-class: a failed start must not consume a round, but the first prompt must still show round 1).
|
|
1710
|
+
function briefBlock(member, roundNo) {
|
|
1396
1711
|
if (!member || typeof member.id !== 'string' || !member.id) {
|
|
1397
1712
|
throw v5err('V5_INTERNAL', 'briefBlock: a member is required (a status block must never be built for an unknown identity)')
|
|
1398
1713
|
}
|
|
1399
1714
|
const ms = activeMembers()
|
|
1400
1715
|
const b = []
|
|
1401
|
-
b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (rounds.get(member.id) || 0) +
|
|
1716
|
+
b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (roundNo !== undefined ? roundNo : (rounds.get(member.id) || 0)) +
|
|
1402
1717
|
'|法定票数 m=' + (voterCount() > 0 ? quorumM() : '未启动') + '|有表决权者 ' + voterCount() + ' 人')
|
|
1403
1718
|
b.push('[在册] ' + (ms.length ? ms.map((x) => x.id).join('、') : '(无)'))
|
|
1404
1719
|
// Members that are on the books but NOT on the floor. Silently omitting them made a
|
|
@@ -1431,7 +1746,7 @@ export function apply(ctx) {
|
|
|
1431
1746
|
if (t.description) b.push(' ' + String(t.description).split('\n')[0])
|
|
1432
1747
|
}
|
|
1433
1748
|
}
|
|
1434
|
-
const pending = pendingFor(member.id)
|
|
1749
|
+
const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)
|
|
1435
1750
|
if (pending.length) {
|
|
1436
1751
|
b.push('[新到的消息/通知]')
|
|
1437
1752
|
for (const p of pending) b.push(' ' + p.line)
|
|
@@ -1555,22 +1870,28 @@ export function apply(ctx) {
|
|
|
1555
1870
|
let targets
|
|
1556
1871
|
if (to === 'all' || to === '') targets = activeMembers().filter((m) => m.id !== from)
|
|
1557
1872
|
else if (to === 'voters') targets = voters().filter((m) => m.id !== from)
|
|
1558
|
-
else {
|
|
1873
|
+
else if (to === OFFICE_INBOX) {
|
|
1874
|
+
// MEDIUM 4: an office-addressed request. The office has no member id and is never woken,
|
|
1875
|
+
// so this is a durable note the office reads from `status()`/`report()` on its next turn.
|
|
1876
|
+
targets = [{ id: OFFICE_INBOX }]
|
|
1877
|
+
} else {
|
|
1559
1878
|
const t = memberById(to)
|
|
1560
1879
|
if (!t || t.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + to + '" not found' }
|
|
1561
1880
|
if (t.id === from) return { ok: false, code: 'V5_SELF_MESSAGE', message: 'cannot message yourself' }
|
|
1562
1881
|
targets = [t]
|
|
1563
1882
|
}
|
|
1564
1883
|
if (!targets.length) return { ok: true, delivered: 0, note: 'no other active member' }
|
|
1565
|
-
const counters = Object.assign({}, inst().counters)
|
|
1566
|
-
let n = Math.max(Number(counters.message) || 0, 0)
|
|
1567
1884
|
const at = now()
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1885
|
+
// HIGH 1 (deep review): the ids are allocated INSIDE the fold and all recipients go into ONE
|
|
1886
|
+
// commit. The old shape read `counters` here, minted `msg-N` locally and awaited one
|
|
1887
|
+
// `putMessage` per recipient: a second same-tick `say` minted the SAME ids, and the
|
|
1888
|
+
// `EV.message` fold deduped them away — the message vanished while this function still
|
|
1889
|
+
// returned `ok:true`.
|
|
1890
|
+
const made = []
|
|
1891
|
+
await putMessageMake((alloc) => {
|
|
1892
|
+
for (const t of targets) made.push({ id: alloc.next('message'), from, to: t.id, kind, text, at })
|
|
1893
|
+
return made
|
|
1894
|
+
})
|
|
1574
1895
|
// The office talking to the institute is HALF of the `paperEditor='office'` consultation
|
|
1575
1896
|
// requirement; counting it here (the one place a message really is queued) means the gate
|
|
1576
1897
|
// cannot be satisfied by merely intending to consult (docs/final-paper.md §7).
|
|
@@ -1580,7 +1901,7 @@ export function apply(ctx) {
|
|
|
1580
1901
|
// recipient promptly instead of waiting for the digest window. Plain chat stays
|
|
1581
1902
|
// batched because deliveryDecision gates it — the kick only starts the pass.
|
|
1582
1903
|
scheduleNext().catch(() => {})
|
|
1583
|
-
return { ok: true, delivered: targets.length, to: targets.map((t) => t.id).join(',') }
|
|
1904
|
+
return { ok: true, delivered: targets.length, to: targets.map((t) => t.id).join(','), ids: made.map((m) => m.id) }
|
|
1584
1905
|
}
|
|
1585
1906
|
// A framework NOTICE to one member. This must NOT be sent as the member itself:
|
|
1586
1907
|
// `say()` refuses a self-addressed message (V5_SELF_MESSAGE), so the previous
|
|
@@ -1654,14 +1975,11 @@ export function apply(ctx) {
|
|
|
1654
1975
|
// DSH's "names are immortal" rule) so a re-hired temp can never inherit a
|
|
1655
1976
|
// dismissed member's archives or task ownership.
|
|
1656
1977
|
async function newMember(kind, opts) {
|
|
1657
|
-
const counters = Object.assign({}, inst().counters)
|
|
1658
|
-
let id
|
|
1659
|
-
if (kind === 'academician') { counters.academician = Math.max(1, Number(counters.academician) || 0); id = 'acad' }
|
|
1660
|
-
else if (kind === 'researcher') { counters.researcher = (Number(counters.researcher) || 0) + 1; id = 'r-' + counters.researcher }
|
|
1661
|
-
else { counters.temp = (Number(counters.temp) || 0) + 1; id = 't-' + counters.temp }
|
|
1662
|
-
await putCounters(counters)
|
|
1663
1978
|
const member = {
|
|
1664
|
-
id
|
|
1979
|
+
// HIGH 1: the id is EMPTY here on purpose — the fold's allocator fills it (and bumps
|
|
1980
|
+
// `counters.<kind>`) inside the same commit, so two concurrent hires cannot mint the same
|
|
1981
|
+
// `r-N`/`t-N` and silently overwrite each other's roster entry.
|
|
1982
|
+
id: '', kind,
|
|
1665
1983
|
childId: '',
|
|
1666
1984
|
phase: 'provisioning',
|
|
1667
1985
|
direction: String((opts && opts.direction) || ''),
|
|
@@ -1669,17 +1987,21 @@ export function apply(ctx) {
|
|
|
1669
1987
|
term: (opts && opts.term) || '',
|
|
1670
1988
|
provider: String((opts && opts.provider) || 'spawn'),
|
|
1671
1989
|
// The charter is FROZEN at hire time — it says "你入职时的在册编制(这是一份**快照**)".
|
|
1672
|
-
// It therefore has to be CAPTURED
|
|
1990
|
+
// It therefore has to be CAPTURED inside the fold, right after the id is known: a member
|
|
1673
1991
|
// the host's live-child cap refused is recorded `failed` and only spawned later by
|
|
1674
1992
|
// `resume`, and rebuilding the charter then would describe the resume-time roster
|
|
1675
1993
|
// (`memberPersona(member)` would also silently drop any `staffPersona` change).
|
|
1676
|
-
persona:
|
|
1994
|
+
persona: '',
|
|
1677
1995
|
error: '',
|
|
1678
1996
|
createdAt: now(),
|
|
1679
1997
|
dismissedAt: 0,
|
|
1680
1998
|
dismissReason: '',
|
|
1681
1999
|
}
|
|
1682
|
-
await
|
|
2000
|
+
await commit(EV.member, { make: (alloc) => {
|
|
2001
|
+
member.id = alloc.next(kind)
|
|
2002
|
+
member.persona = memberPersona(member)
|
|
2003
|
+
return member
|
|
2004
|
+
} })
|
|
1683
2005
|
return member
|
|
1684
2006
|
}
|
|
1685
2007
|
function memberPersona(member) {
|
|
@@ -1773,15 +2095,18 @@ export function apply(ctx) {
|
|
|
1773
2095
|
wakeKind.set(member.id, kind)
|
|
1774
2096
|
currentMember = member.id
|
|
1775
2097
|
lastActiveAt.set(member.id, now())
|
|
1776
|
-
|
|
1777
|
-
|
|
2098
|
+
// G2-class (spawnMember): the counters are COMPUTED here but applied only after `startContinuable`
|
|
2099
|
+
// succeeds (see below). `startRound` is also passed into `initialPrompt` explicitly, so the founding
|
|
2100
|
+
// prompt announces the round it is actually starting instead of 0.
|
|
2101
|
+
const startRound = (rounds.get(member.id) || 0) + 1
|
|
2102
|
+
const startRoundsSinceCompact = (roundsSinceCompact.get(member.id) || 0) + 1
|
|
1778
2103
|
// The charter is FROZEN at hire time (it is the durable "seal" record and it says
|
|
1779
2104
|
// "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
|
|
1780
2105
|
// hire-time snapshot into a resume-time one and make the sentence untrue. `newMember`
|
|
1781
2106
|
// captures it, so every member has one by the time it can be spawned.
|
|
1782
2107
|
const persona = member.persona || memberPersona(member)
|
|
1783
2108
|
member.persona = persona
|
|
1784
|
-
const prompt = initialPrompt(member, initialTask, mode)
|
|
2109
|
+
const prompt = initialPrompt(member, initialTask, mode, startRound)
|
|
1785
2110
|
await putMember(member)
|
|
1786
2111
|
await mkdirs()
|
|
1787
2112
|
await writeRosterMirror()
|
|
@@ -1803,10 +2128,11 @@ export function apply(ctx) {
|
|
|
1803
2128
|
// Roll the in-memory marks back so a failed provisioning leaves no phantom
|
|
1804
2129
|
// "busy, round 1" member behind; the member record itself goes to `failed` and
|
|
1805
2130
|
// the caller's catch reports it.
|
|
2131
|
+
// The counters are NOT touched: they were never applied on this attempt (they are set only
|
|
2132
|
+
// after a successful start), and the previous rollback used `rounds.delete(...)`, which also
|
|
2133
|
+
// ERASED the count of a member being RESUMED — a failed resume then restarted its numbering.
|
|
1806
2134
|
busy.delete(member.id)
|
|
1807
2135
|
wakeKind.delete(member.id)
|
|
1808
|
-
rounds.delete(member.id)
|
|
1809
|
-
roundsSinceCompact.delete(member.id)
|
|
1810
2136
|
// The host's live-child cap is a HOST limit (maxActiveSubagents on the `subagent` row), not
|
|
1811
2137
|
// a defect in this member: remember the ceiling, report it BY NAME instead of relaying the
|
|
1812
2138
|
// opaque host string, and throw the TYPED error so the founding loop / hire / addResearcher
|
|
@@ -1814,12 +2140,16 @@ export function apply(ctx) {
|
|
|
1814
2140
|
const limitHit = isActivationLimitReached(e)
|
|
1815
2141
|
if (limitHit) { noteChildLimit(e); noteSpawnLimitOnce(member) }
|
|
1816
2142
|
const message = limitHit ? activationLimitText(hostChildLimit) : String((e && e.message) || e)
|
|
1817
|
-
await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: message }))
|
|
2143
|
+
await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: message, failReason: limitHit ? 'activation-limit' : 'other' }))
|
|
1818
2144
|
if (limitHit) throw v5err('ACTIVATION_LIMIT_REACHED', message)
|
|
1819
2145
|
throw e
|
|
1820
2146
|
}
|
|
1821
2147
|
member.childId = started.childId
|
|
1822
2148
|
childOwner.set(started.childId, sessionId)
|
|
2149
|
+
// G2-class (spawnMember): the child really exists now, so THIS is the point where the round counts.
|
|
2150
|
+
// A failed/invalid start above left both counters untouched (and no longer erased a resume's count).
|
|
2151
|
+
rounds.set(member.id, startRound)
|
|
2152
|
+
roundsSinceCompact.set(member.id, startRoundsSinceCompact)
|
|
1823
2153
|
// Register the FOUNDING turn as in-flight, exactly like a normal wake does.
|
|
1824
2154
|
// Without this the child's first `subagent/end` has no token to match, so
|
|
1825
2155
|
// onMemberEnd would ignore it: the founding round would never be processed and
|
|
@@ -1844,8 +2174,11 @@ export function apply(ctx) {
|
|
|
1844
2174
|
wakeKind.set(member.id, kind || 'normal')
|
|
1845
2175
|
currentMember = member.id
|
|
1846
2176
|
lastActiveAt.set(member.id, now())
|
|
1847
|
-
|
|
1848
|
-
|
|
2177
|
+
// G2 (F10, ported from v4:1930-1931 — "a failed send must not consume a round"): the two counters
|
|
2178
|
+
// are applied AFTER the successful send (below), not here. The values the soft-compact comparison
|
|
2179
|
+
// would have seen under the old ordering are computed here so the trigger boundary is unchanged.
|
|
2180
|
+
const nextRound = (rounds.get(member.id) || 0) + 1
|
|
2181
|
+
const nextRoundsSinceCompact = (roundsSinceCompact.get(member.id) || 0) + 1
|
|
1849
2182
|
// Context directives. TWO distinct needs, and confusing them is what made
|
|
1850
2183
|
// '[核心规则重申]+[CONTEXT COMPACT]' repeat at the head of nearly every prompt
|
|
1851
2184
|
// (v4 §24.1-③):
|
|
@@ -1862,16 +2195,17 @@ export function apply(ctx) {
|
|
|
1862
2195
|
// are excluded because their replies are a different shape, and a directive there
|
|
1863
2196
|
// could never be acknowledged.
|
|
1864
2197
|
const wake = kind || 'normal'
|
|
2198
|
+
let softInjected = false
|
|
1865
2199
|
if (wake === 'normal' || wake === 'checkpoint') {
|
|
1866
2200
|
const soft = (contextPct.get(member.id) || 0) >= Number(params.compactThreshold) ||
|
|
1867
|
-
|
|
2201
|
+
nextRoundsSinceCompact >= Number(params.compactAfterRounds)
|
|
1868
2202
|
if (soft) {
|
|
1869
2203
|
prompt = coreRules() + '\n[CONTEXT COMPACT — 你的对话已接近上限。不要重新推导历史。\n' +
|
|
1870
2204
|
'请把当前工作状态浓缩成一段自述(已有发现、当前方向、已记录的关键成果、下一步具体动作、未决问题),' +
|
|
1871
2205
|
'然后照常以 JSON 回答本轮。请在回复里填 "contextPct": 15 与 "compacted": true。]\n\n' + prompt
|
|
1872
|
-
//
|
|
1873
|
-
//
|
|
1874
|
-
|
|
2206
|
+
// G2: the reset is applied only AFTER the send succeeds (below). A directive that never
|
|
2207
|
+
// reached the member must not clear the trigger it was asking the member to answer.
|
|
2208
|
+
softInjected = true
|
|
1875
2209
|
}
|
|
1876
2210
|
}
|
|
1877
2211
|
if (needReanchor.has(member.id)) {
|
|
@@ -1881,6 +2215,11 @@ export function apply(ctx) {
|
|
|
1881
2215
|
try {
|
|
1882
2216
|
if (typeof subagents.sendMessage !== 'function') throw new Error('no subagents.sendMessage continuation API')
|
|
1883
2217
|
await subagents.sendMessage(rootAgent, member.childId, [textBlock(prompt)], { signal: makeSignal(params.activityTimeoutMs) })
|
|
2218
|
+
// G2 (F10): the turn is really in flight NOW — only count it here, so a failed/invalid send
|
|
2219
|
+
// leaves both counters untouched (the old code counted before the send AND cleared the
|
|
2220
|
+
// soft-compact trigger on a send that never happened).
|
|
2221
|
+
rounds.set(member.id, nextRound)
|
|
2222
|
+
roundsSinceCompact.set(member.id, softInjected ? 0 : nextRoundsSinceCompact)
|
|
1884
2223
|
return true
|
|
1885
2224
|
} catch (e) {
|
|
1886
2225
|
console.error('vibe-math-v5: wake ' + member.id + ' failed: ' + String((e && e.message) || e))
|
|
@@ -1937,6 +2276,12 @@ export function apply(ctx) {
|
|
|
1937
2276
|
function replySpec(kind) {
|
|
1938
2277
|
const L = []
|
|
1939
2278
|
L.push('结束时请**只**输出一个 JSON 对象(放在 ```json 围栏内,围栏外不要有文字)。支持以下字段,除特别说明外都可省略:')
|
|
2279
|
+
// F2 (deep-review 5): the block below is a FIELD CATALOGUE — it carries `←` explanations and
|
|
2280
|
+
// `|`/`或` value hints, so it is deliberately NOT parseable JSON, while the sentence above
|
|
2281
|
+
// demands "only a JSON object". Saying that out loud (and giving a strictly valid template at
|
|
2282
|
+
// the end of the catalogue) removes the contradiction instead of leaving a model to guess.
|
|
2283
|
+
L.push('(下面是**字段目录**:`←` 后是说明,`|`/`或` 是取值提示,因此这一段并非合法 JSON;'
|
|
2284
|
+
+ '**你真正要输出的对象必须严格合法**,最小形态见目录末尾的样例。)')
|
|
1940
2285
|
L.push('{')
|
|
1941
2286
|
L.push(' "say": "你想对全所说的话(群聊)" 或 {"to":"r-2","text":"…"}(私信) 或 {"to":"voters","text":"…"}(只对表决者),')
|
|
1942
2287
|
L.push(' "progress": "本轮进展叙述(会被追加到你的 Progress/progress.md)",')
|
|
@@ -1972,22 +2317,37 @@ export function apply(ctx) {
|
|
|
1972
2317
|
L.push(' 全体表决者(任务仍会执行,但你的理由不会被埋掉),')
|
|
1973
2318
|
if (kind !== 'temp') {
|
|
1974
2319
|
L.push(' "hire": {"purpose":"…","initial_task":"…","direction":"…"} ← 雇佣一名临时工(说明用途与初始任务),')
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
2320
|
+
// MEDIUM 5 (deep review): the hint must match the authority matrix EXACTLY. It used to say
|
|
2321
|
+
// "雇主/院士" without the `academicianLeads` precondition, and implied the academician could
|
|
2322
|
+
// dismiss a PERMANENT researcher (only the office can — `fire` refuses otherwise).
|
|
2323
|
+
L.push(' "fire": {"id":"t-2","reason":"…"} ← 解雇临时工(仅三种人:雇它的雇主本人、所办、以及 academicianLeads=true 时的院士;**常驻研究员只能由所办解聘**,成员只能向所办提议),')
|
|
2324
|
+
}
|
|
2325
|
+
// P1a (deep-review 4, re-scoped after the lead's correction): the unanimity rule DOES live
|
|
2326
|
+
// in the delivered charter (【十、停止】) and in the meeting frame (:2268), but NOT next to
|
|
2327
|
+
// this per-round field description — a member reading only the reply spec can misjudge what
|
|
2328
|
+
// a partial yes does. One truthful clause, right where the field is described.
|
|
2329
|
+
L.push(' "vote_solved": true|false, ← 你是否认为**原问题已解决**(会议/结题表决用;必须诚实)。'
|
|
2330
|
+
+ '**只要有一位有表决权者没有填 true(漏填或填 false)就不会结题**——本所继续推进;'
|
|
2331
|
+
+ '只有全体有表决权者都 true 时才会停止。')
|
|
1978
2332
|
L.push(' "solved": false, ← 你这一轮的个人判断(框架据此了解全所收敛度)')
|
|
1979
2333
|
L.push(' "contextPct": 40, ← 你当前上下文的占用百分比(0-100)')
|
|
1980
2334
|
L.push(' "compacted": false ← 若框架要求你压缩,填 true 并在 progress 里写下浓缩后的工作状态')
|
|
1981
2335
|
L.push('}')
|
|
2336
|
+
// F2: a strictly valid template, so "only a JSON object" has a concrete, parseable shape even
|
|
2337
|
+
// though the catalogue above is not JSON. Keep it minimal and legal (no comments, no unions).
|
|
2338
|
+
L.push('最小合法样例(可直接照抄,字段可增删):')
|
|
2339
|
+
L.push('```json')
|
|
2340
|
+
L.push('{"say":"…","progress":"…","solved":false,"contextPct":40}')
|
|
2341
|
+
L.push('```')
|
|
1982
2342
|
return L.join('\n')
|
|
1983
2343
|
}
|
|
1984
2344
|
// Every prompt builder below passes the member it is addressing. There is
|
|
1985
2345
|
// deliberately NO fallback to "the last member we happened to touch": guessing the
|
|
1986
2346
|
// identity is what produced the wrong-identity briefs in the first place.
|
|
1987
|
-
function stateBlock(member) {
|
|
1988
|
-
return briefBlock(member)
|
|
2347
|
+
function stateBlock(member, roundNo) {
|
|
2348
|
+
return briefBlock(member, roundNo)
|
|
1989
2349
|
}
|
|
1990
|
-
function initialPrompt(member, initialTask, mode) {
|
|
2350
|
+
function initialPrompt(member, initialTask, mode, roundNo) {
|
|
1991
2351
|
const L = []
|
|
1992
2352
|
const resume = mode === 'resume'
|
|
1993
2353
|
L.push(resume
|
|
@@ -2008,7 +2368,7 @@ export function apply(ctx) {
|
|
|
2008
2368
|
if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
|
|
2009
2369
|
mathPushLine(L)
|
|
2010
2370
|
L.push('------------')
|
|
2011
|
-
L.push(stateBlock(member))
|
|
2371
|
+
L.push(stateBlock(member, roundNo))
|
|
2012
2372
|
L.push('------------')
|
|
2013
2373
|
L.push(replySpec(member.kind))
|
|
2014
2374
|
return L.join('\n')
|
|
@@ -2063,6 +2423,14 @@ export function apply(ctx) {
|
|
|
2063
2423
|
L.push('')
|
|
2064
2424
|
} else {
|
|
2065
2425
|
L.push('(你是本次会议的第一位发言者,目前还没有别人发言。)')
|
|
2426
|
+
// F3 (deep-review 5; v4's counterpart states this): "first speaker" alone left two things
|
|
2427
|
+
// unsaid — when the others' opinions arrive, and how the meeting ends. Both statements below
|
|
2428
|
+
// match the code: `askMeetingRound` wakes up to `maxParallel` members per round and
|
|
2429
|
+
// `continueMeetingRound` keeps collecting until nobody is missing (the stall watchdog
|
|
2430
|
+
// handles a member that never answers), then `finalizeMeeting` writes the minutes.
|
|
2431
|
+
L.push('(流程:框架**每轮最多同时征询 maxParallel 名成员**(默认 3),把**已经收集到的**发言'
|
|
2432
|
+
+ '附在唤醒提示里,直到所有在册成员都发过言;卡住的成员由会议看门狗处理。'
|
|
2433
|
+
+ '收束时纪要与结论写入 Shared/Meetings/<会议id>.md 并同步到群聊。)')
|
|
2066
2434
|
L.push('')
|
|
2067
2435
|
}
|
|
2068
2436
|
L.push('请就议程发表你的意见。分工、优先级、下一步做什么、是否认为原问题已解决,都可以说。')
|
|
@@ -2220,6 +2588,11 @@ export function apply(ctx) {
|
|
|
2220
2588
|
const leanInitiative = () => (['off', 'normal', 'eager'].indexOf(String(params.leanInitiative)) !== -1 ? String(params.leanInitiative) : 'normal')
|
|
2221
2589
|
const leanDailyOn = () => leanInitiative() !== 'off' && (formalOn() || leanInitiative() === 'eager')
|
|
2222
2590
|
const formalRoot = () => instRoot() + '/Formal'
|
|
2591
|
+
// L1 (deep-review 5): the CWD-RELATIVE spelling of the institute's Lean work directory — the
|
|
2592
|
+
// one a member can actually produce with their own file tools (which resolve against the
|
|
2593
|
+
// session cwd). The prompts advertise THIS, and `leanAbsPath` accepts it as well as the
|
|
2594
|
+
// institute-relative short form (`Formal/x.lean`).
|
|
2595
|
+
const formalRootRel = () => instRootRel() + '/Formal'
|
|
2223
2596
|
const formalLibRoot = () => vibeRoot() + '/Formal/Lib'
|
|
2224
2597
|
const formalProvedRoot = () => vibeRoot() + '/Formal/Proved'
|
|
2225
2598
|
const verifiedLeanRoot = () => instRoot() + '/Verified/Lean'
|
|
@@ -2264,7 +2637,14 @@ export function apply(ctx) {
|
|
|
2264
2637
|
function leanAbsPath(rel) {
|
|
2265
2638
|
const raw = String(rel == null ? '' : rel).trim()
|
|
2266
2639
|
if (!raw) return null
|
|
2267
|
-
|
|
2640
|
+
// L1 (deep-review 5): BOTH accepted spellings must name the SAME file, because the member
|
|
2641
|
+
// text documents the CWD-relative complete path (what a member's own file tools produce)
|
|
2642
|
+
// while the tools' own short form is institute-relative. Stripping the documented prefix here
|
|
2643
|
+
// is what makes the prompt's path resolvable by the tool that consumes it.
|
|
2644
|
+
const documentedPrefix = instRootRel() + '/'
|
|
2645
|
+
let body = raw.replace(/^\.\//, '')
|
|
2646
|
+
if (body.indexOf(documentedPrefix) === 0) body = body.slice(documentedPrefix.length)
|
|
2647
|
+
const abs = (body.charAt(0) === '/' || /^[a-z]:/i.test(body)) ? body : instRoot() + '/' + body
|
|
2268
2648
|
const norm = normalizeAbsPath(abs)
|
|
2269
2649
|
const root = normalizeAbsPath(vibeRoot())
|
|
2270
2650
|
if (norm !== root && norm.indexOf(root + '/') !== 0) return null
|
|
@@ -2369,18 +2749,24 @@ export function apply(ctx) {
|
|
|
2369
2749
|
async function formalSetRun(target, run) {
|
|
2370
2750
|
const t = idSafe(String(target || ''))
|
|
2371
2751
|
if (!t) return
|
|
2372
|
-
|
|
2752
|
+
// MEDIUM 6 (deep review): the record is built INSIDE the fold (`record` as a function), so a
|
|
2753
|
+
// concurrent writer for the SAME target (e.g. the async Lean queue completing while a sync
|
|
2754
|
+
// run reports) can no longer overwrite it from a stale read.
|
|
2755
|
+
//
|
|
2373
2756
|
// Running a file NEVER changes an already-decided status: a green run does not by
|
|
2374
2757
|
// itself make an object `passed` (only archiving a proof does), and a failing scratch
|
|
2375
2758
|
// run must not silently erase a recorded `passed`/`blocked` decision. Everything else
|
|
2376
2759
|
// becomes `attempted`, which is the honest "we tried, see the compiler output" state.
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
status:
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2760
|
+
await putFormal(t, (prev0) => {
|
|
2761
|
+
const prev = prev0 || { status: 'none' }
|
|
2762
|
+
const keep = (prev.status === 'passed' || prev.status === 'blocked') ? prev.status : 'attempted'
|
|
2763
|
+
return Object.assign({}, prev, {
|
|
2764
|
+
status: keep,
|
|
2765
|
+
file: run.file || prev.file || '',
|
|
2766
|
+
run: { at: now(), ok: !!run.ok, exitCode: run.exitCode === undefined ? null : run.exitCode, ms: run.ms || 0, stdoutTail: tail(run.stdout, 800), stderrTail: tail(run.stderr, 800) },
|
|
2767
|
+
updatedAt: now(),
|
|
2768
|
+
})
|
|
2769
|
+
})
|
|
2384
2770
|
}
|
|
2385
2771
|
// Withdrawing an archived proof means the file must stop looking like the object's proof.
|
|
2386
2772
|
// The POLICY-COMPLIANT route is an overwrite through the injected `fs` service (it carries
|
|
@@ -2417,16 +2803,25 @@ export function apply(ctx) {
|
|
|
2417
2803
|
const why = String(note || '').trim()
|
|
2418
2804
|
if (!t) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'target is required' }
|
|
2419
2805
|
if (!why) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "decision='defect' 必须写明 note(具体偏差)" }
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2806
|
+
// MEDIUM 6 (deep review): BOTH halves are mutations applied inside the fold — the record
|
|
2807
|
+
// (built from the record the fold sees) and the TODO list (the old shape read
|
|
2808
|
+
// `formalTodo()`, pushed and wrote the array back, so a concurrent writer for another
|
|
2809
|
+
// target lost an entry).
|
|
2810
|
+
const proofRel = String(formalOf(t).proof || ('Verified/Lean/' + t + '.lean'))
|
|
2811
|
+
// Adv-verify S3 (found by `tests/audit-v5-lean-abstention.mjs`): this used to REPLACE the
|
|
2812
|
+
// whole `fidelity` object, so a SECOND member reporting a defect in the same round wiped the
|
|
2813
|
+
// first member's enforced-abstention marker (the audit's "marker length 2" assertion caught
|
|
2814
|
+
// it). The defect fields are updated, the marker history is preserved.
|
|
2815
|
+
await putFormal(t, (prev0) => Object.assign({}, prev0 || { status: 'none' }, {
|
|
2423
2816
|
status: 'attempted', proof: '', decision: 'defect', note: why,
|
|
2424
|
-
fidelity: { at: now(), by: String(memberId || ''), note: why }
|
|
2425
|
-
|
|
2817
|
+
fidelity: Object.assign({}, (prev0 && prev0.fidelity) || {}, { at: now(), by: String(memberId || ''), note: why }),
|
|
2818
|
+
updatedAt: now(),
|
|
2819
|
+
}), (todo0) => {
|
|
2820
|
+
const list = (todo0 || []).filter((x) => x.id !== t)
|
|
2821
|
+
list.push({ id: t, at: now(), why: '形式化不合格(忠实性缺陷):' + why })
|
|
2822
|
+
return list
|
|
2823
|
+
})
|
|
2426
2824
|
const removed = await removeArchivedProof(proofRel)
|
|
2427
|
-
const list = formalTodo().filter((x) => x.id !== t)
|
|
2428
|
-
list.push({ id: t, at: now(), why: '形式化不合格(忠实性缺陷):' + why })
|
|
2429
|
-
await putFormal(t, formalOf(t), list)
|
|
2430
2825
|
await writeFormalTodo()
|
|
2431
2826
|
await writeFormalIndex()
|
|
2432
2827
|
// `removeArchivedProof` can fail on a host whose shell cannot delete AND whose
|
|
@@ -2656,6 +3051,12 @@ export function apply(ctx) {
|
|
|
2656
3051
|
file: job.rel,
|
|
2657
3052
|
proof: settledOk ? 'Verified/Lean/' + target + '.lean' : '',
|
|
2658
3053
|
decision: 'used',
|
|
3054
|
+
// L7 (deep-review 5): `decision:'used'` is written by BOTH the member's reply and this
|
|
3055
|
+
// settle path; `decisionSource` says which one. Fallout #4 (F1d): it used to be
|
|
3056
|
+
// WRITE-ONLY — nothing read or asserted it — so it is now part of the readable surfaces
|
|
3057
|
+
// (`status().formal.objects[]`, `vibe_v5_lean_lib.objects`, the report's 形式化决定 line)
|
|
3058
|
+
// and it is asserted by `_oneoff/decision-source.mjs`.
|
|
3059
|
+
decisionSource: 'job-settle',
|
|
2659
3060
|
note: (String(prev.note || '') + stale).trim(),
|
|
2660
3061
|
run: job.run, async: asyncRec, updatedAt: now(),
|
|
2661
3062
|
})
|
|
@@ -2699,6 +3100,14 @@ export function apply(ctx) {
|
|
|
2699
3100
|
// (the "announcement point is the next prompt" discipline).
|
|
2700
3101
|
function takeLeanNoticesFor(memberId) {
|
|
2701
3102
|
if (!leanNotices.length) return []
|
|
3103
|
+
// L8 (deep-review 5): a notice whose member is no longer active can NEVER be delivered — it
|
|
3104
|
+
// used to sit in the 20-slot queue until a newer result evicted it. Prune those first; their
|
|
3105
|
+
// content stays reachable through `status().leanNotices`, `Formal/Jobs/<jobId>.json` and
|
|
3106
|
+
// `vibe_v5_lean_job {jobId}` (see L4), so nothing is lost, only the slot is freed.
|
|
3107
|
+
for (let i = leanNotices.length - 1; i >= 0; i--) {
|
|
3108
|
+
const m = memberById(leanNotices[i].member)
|
|
3109
|
+
if (!m || m.phase !== 'active') leanNotices.splice(i, 1)
|
|
3110
|
+
}
|
|
2702
3111
|
const mine = leanNotices.filter((n) => n.member === memberId)
|
|
2703
3112
|
if (!mine.length) return []
|
|
2704
3113
|
for (const n of mine) { const i = leanNotices.indexOf(n); if (i !== -1) leanNotices.splice(i, 1) }
|
|
@@ -3090,7 +3499,12 @@ export function apply(ctx) {
|
|
|
3090
3499
|
L.push(' · 形式化只写你有把握的版本;没把握就记 blocked 并写清难点——不要用形式化掩盖不确定。')
|
|
3091
3500
|
L.push(' · 该对象若已有后台编译在队列中,**不得**在它落地前声称已通过或走忠实性审查;等 vibe_v5_lean_lib 显示 passed 再审。')
|
|
3092
3501
|
L.push(' · 工具:vibe_v5_lean_run(执行)· vibe_v5_lean_archive(归档)· vibe_v5_lean_lib(查已有可复用库)')
|
|
3093
|
-
|
|
3502
|
+
// L1 (deep-review 5): the work directory is documented as the CWD-RELATIVE complete path
|
|
3503
|
+
// (what the member's own file tools produce), and the tools accept it as well as the short
|
|
3504
|
+
// `Formal/…` form — the two name the same file (`leanAbsPath` strips the prefix).
|
|
3505
|
+
L.push(' · 工作目录(**相对会话工作目录**,你自己的文件工具按这个基准解析):' + formalRootRel() + '/')
|
|
3506
|
+
L.push(' (= 研究所根下的 Formal/;v5 工具的 file 参数两种写法都接受:上面这条完整路径,或短的 Formal/xxx.lean。)')
|
|
3507
|
+
L.push(' 可复用定义放 ' + formalLibRoot().replace(/\\/g, '/') + '/,')
|
|
3094
3508
|
L.push(' 已证引理放 ' + formalProvedRoot().replace(/\\/g, '/') + '/;写之前先 vibe_v5_lean_lib 查重。')
|
|
3095
3509
|
L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义 / 对象 / 条件 / 假设 / 结论是否与')
|
|
3096
3510
|
L.push(' 命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
|
|
@@ -3225,7 +3639,9 @@ export function apply(ctx) {
|
|
|
3225
3639
|
return {
|
|
3226
3640
|
ok: true, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId,
|
|
3227
3641
|
file: pre.rel, target: target || undefined,
|
|
3228
|
-
message: '已入队后台编译;你可以继续工作。结果会写入 Formal/
|
|
3642
|
+
message: '已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在**你**下一轮提示里公告一次;'
|
|
3643
|
+
+ '长期查询用 vibe_v5_lean_job {jobId} 或 Formal/Jobs/<jobId>.json(vibe_v5_lean_lib 的 jobs 字段也会列出)。'
|
|
3644
|
+
+ '注意:公告只投给发起者、且重启后不再补发,所以别的成员(或重启后的你)请用上面两条长期路径查看。',
|
|
3229
3645
|
}
|
|
3230
3646
|
}
|
|
3231
3647
|
const run = await leanRunFile(rel, args.timeout_ms)
|
|
@@ -3351,7 +3767,7 @@ export function apply(ctx) {
|
|
|
3351
3767
|
// Dedupe (§5.3): the object is already `passed` AND the work file still holds EXACTLY
|
|
3352
3768
|
// this content ⇒ the compile result is already known: skip rewrite + recompile.
|
|
3353
3769
|
if (prev.status === 'passed' && (await leanHashFile(workRel)) === sha) {
|
|
3354
|
-
return { ok: true, deduped: true, kind, target, file: workRel, sha256: sha, proof: prev.proof || ('Verified/Lean/' + target + '.lean'), note: '内容与已归档版本一致,跳过重写与重编译' }
|
|
3770
|
+
return { ok: true, deduped: true, kind, target, file: workRel, sha256: sha, proof: prev.proof || ('Verified/Lean/' + target + '.lean'), fidelity: prev.fidelity || null, abstainedCount: Number((prev.fidelity || {}).abstainedCount || 0), note: '内容与已归档版本一致,跳过重写与重编译' }
|
|
3355
3771
|
}
|
|
3356
3772
|
if (!await writeTextRel(workRel, body)) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + workRel }
|
|
3357
3773
|
if (params.leanAsync !== false) {
|
|
@@ -3376,7 +3792,7 @@ export function apply(ctx) {
|
|
|
3376
3792
|
await rebuildLeanLibIndexes()
|
|
3377
3793
|
await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
|
|
3378
3794
|
+ '(已入队后台编译;落地为“通过”之前**不会**写 Verified/Lean/、也不会转为忠实性审查)')
|
|
3379
|
-
return { ok: true, kind, target, file: workRel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, status: rec0.status, note: '已入队后台编译;只有作业落地为“通过”才会写 Verified/Lean/ 并转为忠实性审查' }
|
|
3795
|
+
return { ok: true, kind, target, file: workRel, sha256: sha, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId, status: rec0.status, fidelity: rec0.fidelity || prev.fidelity || null, abstainedCount: Number(((rec0.fidelity || prev.fidelity) || {}).abstainedCount || 0), note: '已入队后台编译;只有作业落地为“通过”才会写 Verified/Lean/ 并转为忠实性审查' }
|
|
3380
3796
|
}
|
|
3381
3797
|
// No toolchain: fall through to the synchronous path (honest NO_SUBPROCESS record).
|
|
3382
3798
|
}
|
|
@@ -3402,7 +3818,7 @@ export function apply(ctx) {
|
|
|
3402
3818
|
await rebuildLeanLibIndexes()
|
|
3403
3819
|
await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
|
|
3404
3820
|
+ '(运行 ' + (passed ? '**通过**,已归档到 ' + rec.proof + ',验证转为忠实性审查' : '**未通过**:' + tail(run.stderr || run.message, 160)) + ')')
|
|
3405
|
-
return { ok: true, kind, target, file: workRel, sha256: sha, proof: rec.proof, passed, run, status: rec.status, async: null }
|
|
3821
|
+
return { ok: true, kind, target, file: workRel, sha256: sha, proof: rec.proof, passed, run, status: rec.status, async: null, fidelity: rec.fidelity || prev.fidelity || null, abstainedCount: Number(((rec.fidelity || prev.fidelity) || {}).abstainedCount || 0) }
|
|
3406
3822
|
}
|
|
3407
3823
|
if (kind === 'blocked') {
|
|
3408
3824
|
const target = idSafe(String(args.target || ''))
|
|
@@ -3564,10 +3980,9 @@ export function apply(ctx) {
|
|
|
3564
3980
|
}
|
|
3565
3981
|
|
|
3566
3982
|
// ---- task board (compare-and-set DAG, ported from DSH agent-teams) ----
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
}
|
|
3983
|
+
// `t-N` ids are minted by the fold's allocator inside `taskCreate` (HIGH 1); there is no
|
|
3984
|
+
// read-side id preview any more, because an id read outside the fold is exactly what allowed
|
|
3985
|
+
// two concurrent creates to mint the same one.
|
|
3571
3986
|
// DAG validation: self-reference, duplicates, and missing/deleted blockers are
|
|
3572
3987
|
// refused up front; a cycle is detected over the WHOLE candidate graph, exactly
|
|
3573
3988
|
// like the DSH original, so a bad dependency can never be stored.
|
|
@@ -3615,11 +4030,8 @@ export function apply(ctx) {
|
|
|
3615
4030
|
if (scopes.indexOf(n) === -1) scopes.push(n)
|
|
3616
4031
|
}
|
|
3617
4032
|
const blockedBy = (args.blocked_by || args.blockedBy || []).map(String)
|
|
3618
|
-
const { id, n } = nextTaskId()
|
|
3619
|
-
validateDeps(id, blockedBy)
|
|
3620
|
-
const counters = Object.assign({}, inst().counters); counters.task = n
|
|
3621
4033
|
const task = {
|
|
3622
|
-
id, revision: 1, subject, description,
|
|
4034
|
+
id: '', revision: 1, subject, description,
|
|
3623
4035
|
status: 'pending', ownerId: '',
|
|
3624
4036
|
blockedBy, writeScopes: scopes,
|
|
3625
4037
|
priority: Number.isFinite(Number(args.priority)) ? Number(args.priority) : 0,
|
|
@@ -3627,8 +4039,17 @@ export function apply(ctx) {
|
|
|
3627
4039
|
assignedBy: '', why: '', acceptance: '',
|
|
3628
4040
|
createdAt: now(), updatedAt: now(),
|
|
3629
4041
|
}
|
|
3630
|
-
|
|
3631
|
-
|
|
4042
|
+
// HIGH 1: the id and the `counters.task` bump happen INSIDE the fold. The dependency check
|
|
4043
|
+
// runs there too, against the FINAL id; a rejection returns null so the fold writes nothing
|
|
4044
|
+
// (an earlier shape allocated the id here and committed the bumped counter separately, so
|
|
4045
|
+
// two same-tick creates could mint the same `t-N` and the fold's upsert overwrote one).
|
|
4046
|
+
const got = { err: null }
|
|
4047
|
+
await commit(EV.task, { make: (alloc) => {
|
|
4048
|
+
task.id = alloc.next('task')
|
|
4049
|
+
try { validateDeps(task.id, blockedBy) } catch (e) { got.err = e; return null }
|
|
4050
|
+
return task
|
|
4051
|
+
} })
|
|
4052
|
+
if (got.err) throw got.err
|
|
3632
4053
|
await writeTaskboardMirror()
|
|
3633
4054
|
await markProgress()
|
|
3634
4055
|
notifyActivity()
|
|
@@ -3648,7 +4069,10 @@ export function apply(ctx) {
|
|
|
3648
4069
|
if (!t) throw v5err('V5_TASK_NOT_FOUND', 'task ' + id + ' not found')
|
|
3649
4070
|
return taskView(t)
|
|
3650
4071
|
}
|
|
3651
|
-
|
|
4072
|
+
// `meta` is an INTERNAL positional argument (not part of the tool's `args`, so the advertised key
|
|
4073
|
+
// surface of `vibe_v5_task_update` is unchanged): it lets an in-product caller fold extra fields into
|
|
4074
|
+
// the SAME compare-and-set write instead of issuing a second, unprotected one.
|
|
4075
|
+
async function taskUpdate(memberId, o, meta) {
|
|
3652
4076
|
// An unknown caller must be refused HERE as well: `owner = task.ownerId === memberId` is true
|
|
3653
4077
|
// for an UNOWNED task (ownerId '') when memberId is '', so without this guard an
|
|
3654
4078
|
// unidentifiable caller would count as the owner of every unclaimed task (audit L6 follow-up).
|
|
@@ -3661,7 +4085,10 @@ export function apply(ctx) {
|
|
|
3661
4085
|
const expected = Number(args.expected_revision !== undefined ? args.expected_revision : args.expectedRevision)
|
|
3662
4086
|
if (!Number.isFinite(expected)) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'expected_revision is required' }
|
|
3663
4087
|
if (expected !== task.revision) {
|
|
3664
|
-
|
|
4088
|
+
// F6 (deep-review 5): this message is routed into Chinese frames (`【框架提示】…`), where a
|
|
4089
|
+
// raw English diagnostic reads as noise. The CODE stays (`V5_TASK_STALE_REVISION`) and the
|
|
4090
|
+
// revision numbers stay, but the sentence is Chinese now for the member reading it.
|
|
4091
|
+
return { ok: false, code: 'V5_TASK_STALE_REVISION', message: '任务 ' + id + ' 当前 revision 是 ' + task.revision + ',不是 ' + expected + ' —— 请先用 vibe_v5_task_get 重新读取(code: V5_TASK_STALE_REVISION)' }
|
|
3665
4092
|
}
|
|
3666
4093
|
const action = String(args.action || '')
|
|
3667
4094
|
const office = isOffice(memberId)
|
|
@@ -3736,6 +4163,15 @@ export function apply(ctx) {
|
|
|
3736
4163
|
} catch (e) {
|
|
3737
4164
|
return { ok: false, code: e.code || 'V5_INVALID_ARGUMENT', message: String((e && e.message) || e) }
|
|
3738
4165
|
}
|
|
4166
|
+
// G4: assignment metadata travels INSIDE this CAS-protected write. The old shape wrote the task a
|
|
4167
|
+
// second time afterwards (`putTask(withMeta)`), reusing the revision it had just read and never
|
|
4168
|
+
// bumping it — a concurrent `task_update` landing in that window was silently swallowed and left no
|
|
4169
|
+
// revision trace. Gated to `reassign` so no other action can be given out-of-band fields.
|
|
4170
|
+
if (meta && action === 'reassign') {
|
|
4171
|
+
if (meta.assignedBy !== undefined) next.assignedBy = String(meta.assignedBy)
|
|
4172
|
+
if (meta.why !== undefined) next.why = String(meta.why)
|
|
4173
|
+
if (meta.acceptance !== undefined) next.acceptance = String(meta.acceptance)
|
|
4174
|
+
}
|
|
3739
4175
|
next.revision = task.revision + 1
|
|
3740
4176
|
next.updatedAt = now()
|
|
3741
4177
|
await putTask(next)
|
|
@@ -3776,12 +4212,12 @@ export function apply(ctx) {
|
|
|
3776
4212
|
if (cur && (cur.blockedBy || []).length && !taskReady(cur)) {
|
|
3777
4213
|
return { ok: false, code: 'V5_TASK_BLOCKED', message: 'task ' + taskId + ' still has incomplete blockers' }
|
|
3778
4214
|
}
|
|
3779
|
-
const r = await taskUpdate(memberId, { task_id: taskId, expected_revision: (cur ? cur.revision : 1), action: 'reassign', owner: to }
|
|
4215
|
+
const r = await taskUpdate(memberId, { task_id: taskId, expected_revision: (cur ? cur.revision : 1), action: 'reassign', owner: to },
|
|
4216
|
+
{ assignedBy: isOffice(memberId) ? 'office' : memberId, why, acceptance })
|
|
3780
4217
|
if (!r.ok) return r
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
await writeTaskboardMirror()
|
|
4218
|
+
// G4: NO second write here — the metadata went into the CAS write above (one task commit, so an
|
|
4219
|
+
// interleaved task_update can neither be swallowed nor silently lose its revision).
|
|
4220
|
+
const withMeta = inst().tasks.find((t) => t.id === taskId) || cur
|
|
3785
4221
|
const assignerIsOffice = isOffice(memberId)
|
|
3786
4222
|
await say(assignerIsOffice ? 'office' : memberId, {
|
|
3787
4223
|
to, kind: 'assign',
|
|
@@ -3846,7 +4282,7 @@ export function apply(ctx) {
|
|
|
3846
4282
|
const lines = ['# 研究所编制表(人读镜像)|' + instituteName + '|' + fmtTime(), '',
|
|
3847
4283
|
'> 权威状态在 State/<研究所>.v5state.json 里;本文件只是快照,勿手改。', '']
|
|
3848
4284
|
lines.push('- 求真门槛:' + (voterCount() > 0 ? 'm = ' + quorumM() : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
|
|
3849
|
-
lines.push('-
|
|
4285
|
+
lines.push('- ' + phaseFactLine())
|
|
3850
4286
|
lines.push('')
|
|
3851
4287
|
lines.push('| 代号 | 职位 | 状态 | 雇主 | 方向/用途 | 轮次 | 上下文% |')
|
|
3852
4288
|
lines.push('|---|---|---|---|---|---|---|')
|
|
@@ -4170,16 +4606,27 @@ export function apply(ctx) {
|
|
|
4170
4606
|
// reason, put the object on the formalization TODO, and say so in the group chat. The
|
|
4171
4607
|
// object keeps its mean probability and its debate record, so nothing is lost.
|
|
4172
4608
|
async function deferForFormal(vs, j, isTrue) {
|
|
4173
|
-
|
|
4174
|
-
|
|
4609
|
+
// MEDIUM 6 (deep review): both the record and the TODO list are built INSIDE the fold, so a
|
|
4610
|
+
// concurrent writer (a Lean run finishing for another target while this verdict settles)
|
|
4611
|
+
// cannot lose the other's entry.
|
|
4612
|
+
const why = 'formal-required:尚未取得 Lean 形式化通过,也没有显式阻塞记录(当前状态 '
|
|
4613
|
+
+ String((formalOf(vs.target).status) || 'none') + ')'
|
|
4175
4614
|
await writeDebateDoc(vs, false, j)
|
|
4176
4615
|
await putVerdict(vs.target, Object.assign({}, vs, {
|
|
4177
4616
|
closed: true, outcome: 'undecided', reason: why, mean: j.mean, formalDeferred: true,
|
|
4178
4617
|
m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
|
|
4618
|
+
// LOW (deep review): record the ELECTORATE too. judgeVerdict had it (ase.voters) and
|
|
4619
|
+
// the closed record used to drop it, so a completed decision could not be audited for who
|
|
4620
|
+
// was counted once the roster changed.
|
|
4621
|
+
voters: j.voters,
|
|
4179
4622
|
}))
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4623
|
+
await putFormal(vs.target,
|
|
4624
|
+
(prev0) => ((prev0 && prev0.status && prev0.status !== 'none') ? prev0 : { status: 'none', deferredAt: now() }),
|
|
4625
|
+
(todo0) => {
|
|
4626
|
+
const list = (todo0 || []).filter((t) => t.id !== vs.target)
|
|
4627
|
+
list.push({ id: vs.target, at: now(), why, verdict: isTrue ? 1 : 0 })
|
|
4628
|
+
return list
|
|
4629
|
+
})
|
|
4183
4630
|
await writeFormalTodo()
|
|
4184
4631
|
await writeFormalIndex()
|
|
4185
4632
|
await saveChatLine('【形式化】' + vs.target + ' 的表决结果为 ' + (isTrue ? '真' : '假')
|
|
@@ -4197,6 +4644,10 @@ export function apply(ctx) {
|
|
|
4197
4644
|
await putVerdict(vs.target, Object.assign({}, vs, {
|
|
4198
4645
|
closed: true, outcome: 'undecided', reason: j.reason, mean: j.mean,
|
|
4199
4646
|
m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
|
|
4647
|
+
// LOW (deep review): record the ELECTORATE too. judgeVerdict had it (ase.voters) and
|
|
4648
|
+
// the closed record used to drop it, so a completed decision could not be audited for who
|
|
4649
|
+
// was counted once the roster changed.
|
|
4650
|
+
voters: j.voters,
|
|
4200
4651
|
}))
|
|
4201
4652
|
await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: 'undecided' })
|
|
4202
4653
|
await saveChatLine('【求真表决】' + vs.target + ' 未达门槛(' + j.reason + ');留库为未定论,平均概率 ' +
|
|
@@ -4214,6 +4665,10 @@ export function apply(ctx) {
|
|
|
4214
4665
|
await putVerdict(vs.target, Object.assign({}, vs, {
|
|
4215
4666
|
closed: true, outcome: isTrue ? 'true' : 'false', mean: isTrue ? 1 : 0,
|
|
4216
4667
|
m: j.m, P: j.P, bTrue: j.bTrue, bFalse: j.bFalse, abstain: j.abstain, closedAt: now(),
|
|
4668
|
+
// LOW (deep review): record the ELECTORATE too. judgeVerdict had it (ase.voters) and
|
|
4669
|
+
// the closed record used to drop it, so a completed decision could not be audited for who
|
|
4670
|
+
// was counted once the roster changed.
|
|
4671
|
+
voters: j.voters,
|
|
4217
4672
|
}))
|
|
4218
4673
|
await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: isTrue ? 'true' : 'false' })
|
|
4219
4674
|
await saveChatLine('【求真结论】' + vs.target + ' 经 ' + (isTrue ? j.bTrue : j.bFalse) + ' 名有表决权者一致判' +
|
|
@@ -4347,13 +4802,22 @@ export function apply(ctx) {
|
|
|
4347
4802
|
}
|
|
4348
4803
|
|
|
4349
4804
|
// ---- chat log / meeting plumbing --------------------------------------
|
|
4805
|
+
// LOW (deep review): the day's chat MIRROR was an unsynchronized read-modify-write — two lines
|
|
4806
|
+
// produced in the same tick both read `prev`, and the second write dropped the first. Appends
|
|
4807
|
+
// are now serialized through one promise chain (the authoritative record is `inst().messages`;
|
|
4808
|
+
// this file is the human-readable mirror, which is why this is a LOW).
|
|
4809
|
+
let chatChain = Promise.resolve(true)
|
|
4350
4810
|
async function saveChatLine(text) {
|
|
4351
4811
|
const line = String(text || '').trim()
|
|
4352
4812
|
if (!line) return false
|
|
4353
4813
|
const day = fmtTime().slice(0, 10)
|
|
4354
4814
|
const rel = 'Shared/Chat/' + day + '.md'
|
|
4355
|
-
const
|
|
4356
|
-
|
|
4815
|
+
const run = async () => {
|
|
4816
|
+
const prev = (await readTextRel(rel)) || ('# 研究所群聊记录|' + instituteName + '|' + day + '\n\n')
|
|
4817
|
+
return await writeTextRel(rel, prev + '- ' + fmtTime().slice(11) + '|' + line + '\n')
|
|
4818
|
+
}
|
|
4819
|
+
chatChain = chatChain.then(run, run)
|
|
4820
|
+
return await chatChain
|
|
4357
4821
|
}
|
|
4358
4822
|
// Wake a member only when it is not already running. Never called while paused
|
|
4359
4823
|
// (a paused institute must not be nudged into new work — v4 §29-T36).
|
|
@@ -4368,7 +4832,12 @@ export function apply(ctx) {
|
|
|
4368
4832
|
// meeting request is PARKED (first one wins; later requests do not overwrite it)
|
|
4369
4833
|
// and resumed once the verification clears. The watchdog clock starts only when
|
|
4370
4834
|
// the meeting ACTUALLY begins (v4 §26/§27).
|
|
4371
|
-
|
|
4835
|
+
// HIGH 3 (deep review): the "is the original problem solved?" tally is DURABLE state
|
|
4836
|
+
// (`inst().solve`), not an in-memory Map. It gates 结题 and is published by status/report, so a
|
|
4837
|
+
// restart or a second session must see the same answers; the old Map made cross-session
|
|
4838
|
+
// unanimity impossible and let the published view disagree with the durable one.
|
|
4839
|
+
const solveVotesOf = () => inst().solve || {}
|
|
4840
|
+
const solveVotesList = () => Object.entries(solveVotesOf()).map(([k, v]) => k + '=' + v)
|
|
4372
4841
|
async function startMeeting(callerId, opts) {
|
|
4373
4842
|
const o = opts || {}
|
|
4374
4843
|
const agenda = String(o.agenda || '').trim()
|
|
@@ -4385,8 +4854,11 @@ export function apply(ctx) {
|
|
|
4385
4854
|
// r-2 and the voters replied to the wrong person.
|
|
4386
4855
|
if (callerId) {
|
|
4387
4856
|
await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })
|
|
4857
|
+
// MEDIUM 4: the office is the only other actor allowed to convene, so the proposal is
|
|
4858
|
+
// ALSO addressed to it (voters alone never reached it).
|
|
4859
|
+
await say(callerId, { to: OFFICE_INBOX, kind: 'office-request', text: '请所办裁定:是否召开会议「' + agenda + '」(类型:' + kind + '|提议人:' + callerId + ')' })
|
|
4388
4860
|
}
|
|
4389
|
-
return { ok: true, proposed: true, message: '
|
|
4861
|
+
return { ok: true, proposed: true, message: '已向院士/所办提议开会(只有院士或所办可以直接召开;所办会在 status/report 的 officeRequests 里看到该提议)' }
|
|
4390
4862
|
}
|
|
4391
4863
|
if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute has already concluded; start a new run to convene again' }
|
|
4392
4864
|
if (!running) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
|
|
@@ -4399,21 +4871,39 @@ export function apply(ctx) {
|
|
|
4399
4871
|
}
|
|
4400
4872
|
return await beginMeeting({ agenda, kind, target: String(o.target || ''), by: office ? 'office' : callerId })
|
|
4401
4873
|
}
|
|
4874
|
+
// F13 (status/report review): ONE composition for the phase/running/concluded facts, used by
|
|
4875
|
+
// both `report()` and `State/README.md` (`writeStateReadme`) — the two documents used to print
|
|
4876
|
+
// the same three facts with different wording, so a reader had to compare them by hand.
|
|
4877
|
+
function phaseFactLine() {
|
|
4878
|
+
return '阶段:' + phase + '|运行中:' + (running ? '是' : '否') + '|已结题:' + (autoDone ? '是' : '否')
|
|
4879
|
+
}
|
|
4402
4880
|
async function beginMeeting(opts) {
|
|
4403
|
-
const counters = Object.assign({}, inst().counters)
|
|
4404
|
-
counters.meeting = (Number(counters.meeting) || 0) + 1
|
|
4405
|
-
await putCounters(counters)
|
|
4406
|
-
const id = 'mt-' + counters.meeting
|
|
4407
4881
|
const order = activeMembers().map((m) => m.id)
|
|
4408
4882
|
// Rotate who speaks first: with a fixed order the same member always speaks
|
|
4409
4883
|
// before it can see the others (v4 §24.1-④).
|
|
4410
4884
|
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 }
|
|
4885
|
+
const idx = { id: '', agenda: opts.agenda, kind: opts.kind || 'sync', at: 0, file: '' }
|
|
4886
|
+
// HIGH 1: `mt-N` and the `counters.meeting` bump are allocated inside the fold (the old
|
|
4887
|
+
// shape read the counter here and committed it separately, so two concurrent convenings
|
|
4888
|
+
// could both become `mt-3` and the fold's dedupe dropped the second one).
|
|
4889
|
+
await commit(EV.meeting, { make: (alloc) => {
|
|
4890
|
+
idx.id = alloc.next('meeting')
|
|
4891
|
+
idx.at = now()
|
|
4892
|
+
idx.file = 'Shared/Meetings/' + idx.id + '.md'
|
|
4893
|
+
return idx
|
|
4894
|
+
} })
|
|
4895
|
+
const id = idx.id
|
|
4411
4896
|
meeting = {
|
|
4412
4897
|
id, agenda: opts.agenda, kind: opts.kind || 'sync', target: opts.target || '',
|
|
4413
4898
|
by: opts.by || 'office', order, inputs: {}, extras: {}, lastInputAt: now(), startedAt: now(),
|
|
4414
4899
|
}
|
|
4415
|
-
|
|
4416
|
-
|
|
4900
|
+
// F6 (status/report review): mark the meeting OPEN durably. `finalizeMeeting` clears it, so a
|
|
4901
|
+
// meeting that never finished (crash/restart/stop) stays visible as "未收束" instead of
|
|
4902
|
+
// silently counting as history with no minutes.
|
|
4903
|
+
await patchInstitute({ meetingOpen: { id, at: now(), agenda: meeting.agenda } })
|
|
4904
|
+
// HIGH 3: a NEW solve question starts with an empty DURABLE tally (a clear has to be
|
|
4905
|
+
// persisted too, otherwise a reload would resurrect the previous question's answers).
|
|
4906
|
+
await putSolve({ clear: true })
|
|
4417
4907
|
await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
|
|
4418
4908
|
// A meeting that ACTUALLY begins and was convened by the office is the other half of the
|
|
4419
4909
|
// `paperEditor='office'` consultation requirement (docs/final-paper.md §7). Counted here, not at the
|
|
@@ -4497,6 +4987,8 @@ export function apply(ctx) {
|
|
|
4497
4987
|
}
|
|
4498
4988
|
async function finalizeMeeting(mn) {
|
|
4499
4989
|
meeting = null
|
|
4990
|
+
// F6: the meeting is closed ⇒ clear the durable OPEN marker (see `beginMeeting`).
|
|
4991
|
+
try { await patchInstitute({ meetingOpen: null }) } catch (e) { /* the minutes below matter more */ }
|
|
4500
4992
|
const lines = []
|
|
4501
4993
|
for (const id of mn.order) {
|
|
4502
4994
|
const text = mn.inputs[id]
|
|
@@ -4541,7 +5033,8 @@ export function apply(ctx) {
|
|
|
4541
5033
|
if (autoDone) return true
|
|
4542
5034
|
const vs = voters().map((m) => m.id)
|
|
4543
5035
|
if (!vs.length) return false
|
|
4544
|
-
|
|
5036
|
+
const sv = solveVotesOf()
|
|
5037
|
+
if (!vs.every((id) => sv[id] === true)) return false
|
|
4545
5038
|
// ── THE FINAL PAPER PHASE COMES FIRST (docs/final-paper.md §3) ────────────────────────────────
|
|
4546
5039
|
// `finishRun` flips phase='solved' / autoDone / running=false, after which the machinery
|
|
4547
5040
|
// REFUSES: `wakeIfIdle` returns false, `startMeeting` rejects a concluded institute and
|
|
@@ -4563,7 +5056,9 @@ export function apply(ctx) {
|
|
|
4563
5056
|
const m = memberById(memberId)
|
|
4564
5057
|
if (!m) return
|
|
4565
5058
|
if (m.kind === 'temp') return // no vote
|
|
4566
|
-
|
|
5059
|
+
// HIGH 3: the answer is committed to the durable tally (it survives a reload and is visible
|
|
5060
|
+
// to a second session), not to a process-local Map.
|
|
5061
|
+
await putSolve({ member: memberId, value: val === true })
|
|
4567
5062
|
// Evaluate the stop condition on EVERY solve vote, not only when a meeting
|
|
4568
5063
|
// finalizes. A vote that lands after the meeting closed — a late reply, or an
|
|
4569
5064
|
// ordinary round carrying vote_solved — would otherwise be recorded and never
|
|
@@ -4630,13 +5125,23 @@ export function apply(ctx) {
|
|
|
4630
5125
|
await patchInstitute({ paper: (cur) => { const next = fn(cur === undefined ? null : cur); return next === undefined ? (cur === undefined ? null : cur) : next } })
|
|
4631
5126
|
return paper()
|
|
4632
5127
|
}
|
|
5128
|
+
// F6 (v4-G3's v5 cousin): ONE named warning per failed REQUIRED artifact write. `files` stays the
|
|
5129
|
+
// honest list of what actually landed, and the recovery path is the idempotent refill
|
|
5130
|
+
// (`refillPaperArtifacts`, reached by re-triggering the paper) which only writes what is missing.
|
|
5131
|
+
function paperWriteFailureWarning(names) {
|
|
5132
|
+
return '产物写入失败(未落盘):' + names.join('、') + ' —— 它们不在本次定稿的 files 产物清单里;' +
|
|
5133
|
+
'重跑同一篇论文(/v5 paper,幂等)会由 refillPaperArtifacts 只补写缺失产物。'
|
|
5134
|
+
}
|
|
4633
5135
|
async function paperLog(title, body) {
|
|
4634
5136
|
const p = paper()
|
|
4635
5137
|
const id = (p && p.id) || paperIdFromState()
|
|
4636
5138
|
const rel = PAPER_FILE(id, 'paper.log.md')
|
|
4637
5139
|
const prev = (await readTextRel(rel)) || ('# 最终论文流程日志|' + id + '\n\n')
|
|
4638
|
-
|
|
4639
|
-
|
|
5140
|
+
// F6: the outcome is RETURNED so the finalisation can turn a failure into a named warning. It used
|
|
5141
|
+
// to be discarded, which is how a "finalized" paper could be announced without its log and with no
|
|
5142
|
+
// trace in `warnings`. No caller read the old `rel` return value.
|
|
5143
|
+
const ok = await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
|
|
5144
|
+
return { rel, ok }
|
|
4640
5145
|
}
|
|
4641
5146
|
function paperAskEvery() { return posMs(params.activityTimeoutMs, 120000) }
|
|
4642
5147
|
function paperNextStep(p) {
|
|
@@ -5455,13 +5960,18 @@ export function apply(ctx) {
|
|
|
5455
5960
|
const finalizedAt = now()
|
|
5456
5961
|
const S = paperSections(p, active, skipped, ev, { final: p.final, conclusionText })
|
|
5457
5962
|
const files = []
|
|
5963
|
+
// F6: every REQUIRED artifact write is checked. A failure does NOT block finalisation (the paper
|
|
5964
|
+
// is still delivered from whatever landed) but it must be NAMED, not silently dropped from `files`.
|
|
5965
|
+
const writeFailures = []
|
|
5458
5966
|
if (p.format !== 'tex') {
|
|
5459
5967
|
if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) files.push('paper.md')
|
|
5968
|
+
else writeFailures.push('paper.md')
|
|
5460
5969
|
}
|
|
5461
5970
|
let tex = ''
|
|
5462
5971
|
if (p.format !== 'md') {
|
|
5463
5972
|
tex = renderPaperTex(p, S)
|
|
5464
5973
|
if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) files.push('paper.tex')
|
|
5974
|
+
else writeFailures.push('paper.tex')
|
|
5465
5975
|
}
|
|
5466
5976
|
// `paperFormat=md` with `paperCompilePdf=true` must NOT warn about a missing tex: no tex
|
|
5467
5977
|
// was produced, so compilation is skipped silently (docs/final-paper.md §8).
|
|
@@ -5477,11 +5987,17 @@ export function apply(ctx) {
|
|
|
5477
5987
|
const warnings = (p.warnings || []).slice()
|
|
5478
5988
|
if (compile.status === 'failed') warnings.push('LaTeX 编译失败(已保留 paper.tex 与 paper.md,不阻塞定稿):' + (compile.attempts || []).map((a) => a.engine + '/' + a.label + ' exit=' + a.exitCode + (a.message ? '(' + a.message + ')' : '')).join(';'))
|
|
5479
5989
|
if (compile.status === 'not-detected') warnings.push('未检测到 LaTeX 引擎(' + (compile.reason || '') + '):只交付 paper.tex 与 paper.md。')
|
|
5990
|
+
// F6: the failed REQUIRED writes are named ONCE (for this finalisation), before the meta is built,
|
|
5991
|
+
// so the warning lands in `meta.warnings`, in the flow log, in the state and in the return value.
|
|
5992
|
+
if (writeFailures.length) warnings.push(paperWriteFailureWarning(writeFailures))
|
|
5480
5993
|
const meta = paperMeta(Object.assign({}, p, { finalizedAt, compile: compile.status, engine: compile.engine || '' }), {
|
|
5481
5994
|
finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', attempts: compile.attempts || [] }, files,
|
|
5482
5995
|
})
|
|
5483
5996
|
if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')) files.push('paper.meta.json')
|
|
5484
|
-
|
|
5997
|
+
// `meta.warnings` is the SAME array, so a meta write failure still reaches the state, the log and
|
|
5998
|
+
// the tool's return value even though the file that should have carried it is the one missing.
|
|
5999
|
+
else warnings.push(paperWriteFailureWarning(['paper.meta.json']))
|
|
6000
|
+
const logRes = await paperLog('定稿(' + reason + ')', [
|
|
5485
6001
|
'- 目录:Paper/' + id + '/',
|
|
5486
6002
|
'- 产物:' + files.join('、'),
|
|
5487
6003
|
'- 编译:' + compile.status + (compile.engine ? '(' + compile.engine + ')' : '') + '|尝试 ' + (compile.attempts || []).length + ' 次',
|
|
@@ -5490,6 +6006,14 @@ export function apply(ctx) {
|
|
|
5490
6006
|
(p.final && p.final.note ? '- 定稿说明:' + p.final.note : ''),
|
|
5491
6007
|
(warnings.length ? '- 警告:\n - ' + warnings.join('\n - ') : ''),
|
|
5492
6008
|
].filter(Boolean).join('\n'))
|
|
6009
|
+
// F6: a failed flow-log write is named too — the log is a required artifact of the paper flow. The
|
|
6010
|
+
// meta is REWRITTEN here because it was serialised BEFORE this attempt: without the rewrite the
|
|
6011
|
+
// durable meta would be the one artifact whose `warnings` does not mention the missing log. (If the
|
|
6012
|
+
// meta path is refused as well the rewrite fails silently — its own warning is already recorded.)
|
|
6013
|
+
if (!logRes.ok) {
|
|
6014
|
+
warnings.push(paperWriteFailureWarning(['paper.log.md']))
|
|
6015
|
+
await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
6016
|
+
}
|
|
5493
6017
|
await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
|
|
5494
6018
|
status: 'finalized', stage: 'finalized', finalizedAt, compile: compile.status,
|
|
5495
6019
|
engine: compile.engine || '', warnings, artifacts: files, updatedAt: now(),
|
|
@@ -5506,6 +6030,10 @@ export function apply(ctx) {
|
|
|
5506
6030
|
}
|
|
5507
6031
|
|
|
5508
6032
|
// ---- hire / fire -------------------------------------------------------
|
|
6033
|
+
// INVARIANT (HIGH 2): an ACTIVE temp always has an ACTIVE employer — `fire` dismisses a
|
|
6034
|
+
// member's temps with it (`dismissTempsOf`), and the office idempotently catches up on state
|
|
6035
|
+
// written before that cascade existed. `maxTempTotal` is therefore counted exactly over the
|
|
6036
|
+
// temps a running institute can still use.
|
|
5509
6037
|
function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
|
|
5510
6038
|
// ANY academician or permanently-employed researcher may hire its own temp
|
|
5511
6039
|
// workers, and may fire the ones it hired. This is the requirement the official
|
|
@@ -5535,11 +6063,12 @@ export function apply(ctx) {
|
|
|
5535
6063
|
try {
|
|
5536
6064
|
await spawnMember(member, initialTask)
|
|
5537
6065
|
} catch (e) {
|
|
5538
|
-
await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
|
|
6066
|
+
await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
|
|
5539
6067
|
// Name the host's live-child cap (maxActiveSubagents) instead of relaying its opaque
|
|
5540
6068
|
// "subagent limit reached (active child limit: N)" string, and use the typed code so the
|
|
5541
|
-
// hirer can tell a HOST ceiling from a broken provider.
|
|
5542
|
-
|
|
6069
|
+
// hirer can tell a HOST ceiling from a broken provider. The durable `failReason` above is
|
|
6070
|
+
// what `retryPendingSpawns()` (G-6) reads to re-create this temp when capacity frees up.
|
|
6071
|
+
if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: '临时工创建失败:' + activationLimitText(hostChildLimit) + '(已登记为待补建:容量释放后框架会在下一次调度轮次自动重试)' } }
|
|
5543
6072
|
return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '临时工创建失败:' + String((e && e.message) || e) }
|
|
5544
6073
|
}
|
|
5545
6074
|
await saveChatLine('【雇佣】' + (office ? '所办' : callerId) + ' 雇入临时工 ' + member.id + ',用途:' + purpose)
|
|
@@ -5547,26 +6076,55 @@ export function apply(ctx) {
|
|
|
5547
6076
|
notifyActivity()
|
|
5548
6077
|
return { ok: true, id: member.id, kind: 'temp', purpose, note: '现在可以用 vibe_v5_say {to:"' + member.id + '"} 或 vibe_v5_assign 给它派活' }
|
|
5549
6078
|
}
|
|
6079
|
+
// HIGH 2 (deep review): a dismissed member's TEMP WORKERS are dismissed WITH IT. They carry
|
|
6080
|
+
// `hiredBy: <employer id>`, so once the employer is dismissed the employer check
|
|
6081
|
+
// (`target.hiredBy === callerId`) can never match again — only the office could ever clear
|
|
6082
|
+
// them — while `employedTemps()` kept counting them against `maxTempTotal` and the scheduler
|
|
6083
|
+
// kept waking them (a dismissed member's team quietly burning budget). The rule is explicit:
|
|
6084
|
+
// **a member's temps belong to that member's tenure**. The cascade is one level deep because
|
|
6085
|
+
// temps may not hire (`hire` refuses a temp caller), so no deeper recursion is possible.
|
|
6086
|
+
async function dismissTempsOf(employerId, callerId, note) {
|
|
6087
|
+
const mine = inst().members.filter((m) => m.kind === 'temp' && m.phase === 'active' && m.hiredBy === employerId)
|
|
6088
|
+
const done = []
|
|
6089
|
+
for (const t of mine) {
|
|
6090
|
+
const r = await fire(callerId, { id: t.id, reason: note })
|
|
6091
|
+
if (r && r.ok) done.push(t.id)
|
|
6092
|
+
}
|
|
6093
|
+
return done
|
|
6094
|
+
}
|
|
5550
6095
|
// Firing is REAL: the current turn is cancelled, the resident continuable child is
|
|
5551
|
-
// released, its tasks are reclaimed, its queued mail is dropped
|
|
5552
|
-
// dismissed. Its id is never reused, so a re-hire can
|
|
6096
|
+
// released, its tasks are reclaimed, its queued mail is dropped, its own temp workers are
|
|
6097
|
+
// dismissed with it, and it is marked dismissed. Its id is never reused, so a re-hire can
|
|
6098
|
+
// never inherit its archives.
|
|
5553
6099
|
async function fire(callerId, o) {
|
|
5554
6100
|
const args = o || {}
|
|
5555
6101
|
const id = String(args.id || args.member || '').trim()
|
|
5556
6102
|
const target = memberById(id)
|
|
5557
6103
|
if (!target) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member ' + id }
|
|
5558
|
-
if (target.phase === 'dismissed')
|
|
6104
|
+
if (target.phase === 'dismissed') {
|
|
6105
|
+
// Idempotent cleanup for state written before the cascade existed (or a hand-edited
|
|
6106
|
+
// file): only the office may act here, because a dismissed non-temp target skips the
|
|
6107
|
+
// permission check below.
|
|
6108
|
+
const cleaned = isOffice(callerId) ? await dismissTempsOf(id, callerId, '雇主已被解雇(补做级联)') : []
|
|
6109
|
+
return { ok: true, already: true, cascadedTemps: cleaned, message: id + ' 已被解雇' }
|
|
6110
|
+
}
|
|
5559
6111
|
const office = isOffice(callerId)
|
|
5560
6112
|
const acad = isAcademician(callerId)
|
|
5561
6113
|
const allowed = office || (acad && params.academicianLeads && target.kind === 'temp') || (target.kind === 'temp' && target.hiredBy === callerId)
|
|
5562
6114
|
if (!allowed) {
|
|
5563
|
-
|
|
6115
|
+
// MEDIUM 5 (deep review): the refusal names the ACTUAL matrix. The old text promised the
|
|
6116
|
+
// academician a power it only has when `academicianLeads` is on, and implied it could
|
|
6117
|
+
// dismiss a permanent researcher (it cannot — see the next guard).
|
|
6118
|
+
return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: '拒绝:能解雇它的只有 ①所办 ②该临时工的雇主本人 ③academicianLeads=true 时的院士(且仅限临时工);常驻研究员只能由所办解聘,成员只能向所办提议' }
|
|
5564
6119
|
}
|
|
5565
6120
|
if (target.kind !== 'temp' && !office) {
|
|
5566
6121
|
return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)' }
|
|
5567
6122
|
}
|
|
5568
6123
|
const reason = String(args.reason || '').trim()
|
|
5569
6124
|
const reclaimed = await releaseTasksOf(id, 'dismissed: ' + reason)
|
|
6125
|
+
// Cascade BEFORE the employer is marked dismissed: the temps are still reachable and the
|
|
6126
|
+
// caller's authority (office / leading academician) is the same one that dismissed them.
|
|
6127
|
+
const cascaded = target.kind === 'temp' ? [] : await dismissTempsOf(id, callerId, '雇主 ' + id + ' 被解雇(级联)')
|
|
5570
6128
|
if (target.childId) {
|
|
5571
6129
|
try { if (typeof subagents.interrupt === 'function') subagents.interrupt(target.childId, { kind: 'ancestor', agent: rootAgent }) } catch (e) { /* fire-and-return */ }
|
|
5572
6130
|
try {
|
|
@@ -5577,7 +6135,9 @@ export function apply(ctx) {
|
|
|
5577
6135
|
liveAgents.delete(target.childId)
|
|
5578
6136
|
}
|
|
5579
6137
|
busy.delete(id)
|
|
5580
|
-
|
|
6138
|
+
// HIGH 3: withdraw the dismissed member's durable solve answer, exactly like its ballot is
|
|
6139
|
+
// dropped from every open verdict below (an answer from a former member must not carry).
|
|
6140
|
+
await putSolve({ member: id, value: null })
|
|
5581
6141
|
rounds.delete(id)
|
|
5582
6142
|
roundsSinceCompact.delete(id)
|
|
5583
6143
|
contextPct.delete(id)
|
|
@@ -5605,11 +6165,12 @@ export function apply(ctx) {
|
|
|
5605
6165
|
}))
|
|
5606
6166
|
await writeRosterMirror()
|
|
5607
6167
|
await saveChatLine('【解雇】' + id + ' 已由 ' + (office ? '所办' : callerId) + ' 解雇(原因:' + (reason || '未说明') +
|
|
5608
|
-
')。代号永不复用;其未完成任务已收回' + (reclaimed.length ? '(' + reclaimed.join('、') + ')' : '') +
|
|
6168
|
+
')。代号永不复用;其未完成任务已收回' + (reclaimed.length ? '(' + reclaimed.join('、') + ')' : '') +
|
|
6169
|
+
(cascaded.length ? ';随其解雇的临时工 ' + cascaded.join('、') + '(雇主离场 ⇒ 其临时工一并解雇)' : '') + '。')
|
|
5609
6170
|
await markProgress()
|
|
5610
6171
|
notifyActivity()
|
|
5611
6172
|
await scheduleNext()
|
|
5612
|
-
return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
|
|
6173
|
+
return { ok: true, dismissed: id, reclaimedTasks: reclaimed, cascadedTemps: cascaded, reason }
|
|
5613
6174
|
}
|
|
5614
6175
|
async function nudge(callerId, o) {
|
|
5615
6176
|
if (!callerId) return memberDiagnosis('督办(vibe_v5_nudge)', callerId)
|
|
@@ -5643,13 +6204,36 @@ export function apply(ctx) {
|
|
|
5643
6204
|
// prepended inbox block and once inside its own [状态] block.
|
|
5644
6205
|
async function promptFor(member, baseFn) {
|
|
5645
6206
|
const pending = pendingFor(member.id)
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
6207
|
+
// G1: only NEWLY pending messages are prepended. A second build before the wake must not repeat the
|
|
6208
|
+
// inbox block (the duplicate-read defect), and the mark is what makes that true while the ack is
|
|
6209
|
+
// deferred to the wake.
|
|
6210
|
+
const seen = inboxInjected.get(member.id) || new Set()
|
|
6211
|
+
const fresh = pending.filter((p) => !seen.has(p.id))
|
|
6212
|
+
const inbox = fresh.length ? composeInbox(fresh) : ''
|
|
6213
|
+
if (fresh.length) { for (const p of fresh) seen.add(p.id); inboxInjected.set(member.id, seen) }
|
|
6214
|
+
// While a block IS being prepended, the base build must not embed the same mail a second time
|
|
6215
|
+
// (briefBlock's `[新到的消息/通知]`). When nothing is prepended the base block still shows whatever
|
|
6216
|
+
// is pending — so a member reads each message exactly once either way.
|
|
6217
|
+
if (inbox) inboxSuppressed.add(member.id)
|
|
6218
|
+
let base
|
|
6219
|
+
try { base = typeof baseFn === 'function' ? baseFn() : baseFn }
|
|
6220
|
+
finally { inboxSuppressed.delete(member.id) }
|
|
6221
|
+
// G1 (mailbox): the pending list is RETURNED, not acked here — the ack moved to wakeWithInbox,
|
|
6222
|
+
// after the send actually succeeded. Acking at prompt-build time removed the messages from
|
|
6223
|
+
// `messages` (EV.delivered) BEFORE the send, so a failed/invalid wake lost them silently.
|
|
6224
|
+
return { text: (inbox ? inbox + '\n\n' : '') + base, pending }
|
|
5650
6225
|
}
|
|
5651
6226
|
async function wakeWithInbox(member, baseFn, kind) {
|
|
5652
|
-
|
|
6227
|
+
// G1 (mailbox) invariant: "not acked => still redeliverable". `wakeMember` returns false when it
|
|
6228
|
+
// never sent (no childId / member not active) or when `sendMessage` threw, so acking only on
|
|
6229
|
+
// `ok` keeps the message in the durable queue for the next successful wake.
|
|
6230
|
+
const prompt = await promptFor(member, baseFn)
|
|
6231
|
+
const ok = await wakeMember(member, prompt.text, kind)
|
|
6232
|
+
// The attempt is concluded either way: drop the injection mark so the NEXT attempt re-prepends
|
|
6233
|
+
// whatever is still pending (a failed wake must not leave the message suppressed for ever).
|
|
6234
|
+
inboxInjected.delete(member.id)
|
|
6235
|
+
if (ok && prompt.pending.length) await ackPending(prompt.pending)
|
|
6236
|
+
return ok
|
|
5653
6237
|
}
|
|
5654
6238
|
|
|
5655
6239
|
// ---- scheduling / graded keep-alive (ported and upgraded from v4 §25) ---
|
|
@@ -5668,7 +6252,13 @@ export function apply(ctx) {
|
|
|
5668
6252
|
}
|
|
5669
6253
|
if (cur && cur.project) project = cur.project
|
|
5670
6254
|
if (cur && cur.institute) instituteName = cur.institute
|
|
5671
|
-
if (cur)
|
|
6255
|
+
if (cur) {
|
|
6256
|
+
phase = cur.phase || phase
|
|
6257
|
+
// S1: keep the concluded marker in sync with the persisted phase on EVERY sync (this is
|
|
6258
|
+
// the path `status()`/`report()`/`configure()` go through), so no view can show a
|
|
6259
|
+
// concluded institute as ongoing, or vice versa.
|
|
6260
|
+
autoDone = String(phase) === 'solved'
|
|
6261
|
+
}
|
|
5672
6262
|
}
|
|
5673
6263
|
// Scheduling TRAMPOLINE. The consensus finalizers call `scheduleNext()` themselves
|
|
5674
6264
|
// (a settled verification should immediately drive whatever comes next), so a
|
|
@@ -5694,8 +6284,78 @@ export function apply(ctx) {
|
|
|
5694
6284
|
scheduling = false
|
|
5695
6285
|
}
|
|
5696
6286
|
}
|
|
6287
|
+
// ---- G-6 (installer review): refused member spawns are DEFERRED, not lost ----------------
|
|
6288
|
+
// v4 queued a cap-refused spawn in `pendingSpawns` and retried it on the next heartbeat; v5 had
|
|
6289
|
+
// no such queue (only the manual `resume` path retried cap-class failures), so a refusal whose
|
|
6290
|
+
// capacity later freed up produced "nothing happens". The queue here is DERIVED from durable
|
|
6291
|
+
// state instead of being a second source of truth: a refused member is already persisted as
|
|
6292
|
+
// `phase:'failed'` with `failReason:'activation-limit'`, so a restart cannot lose the intent and
|
|
6293
|
+
// a successful retry clears it by construction (the member becomes `active`). Only the bounded
|
|
6294
|
+
// attempt counter is session memory.
|
|
6295
|
+
// Only the attempt counter is session memory; the queue itself derives from durable state. The
|
|
6296
|
+
// retry is bounded by the STOPPING RULE, not by a session counter (v4's shape): a still-capped
|
|
6297
|
+
// member IS retried on the next pass — that attempt is free, because `spawnMember` answers from
|
|
6298
|
+
// the recorded ceiling without asking the host (v5:2037) — while any NON-capacity provisioning
|
|
6299
|
+
// error stops the auto-retry for that member (a broken provider must not be resurrected).
|
|
6300
|
+
const spawnRetryAttempts = new Map()
|
|
6301
|
+
// ONE predicate for "this member was refused by the host's live-child CAP" — used by the
|
|
6302
|
+
// automatic retry here AND by `resume`'s rebuild path, so the two can never disagree about
|
|
6303
|
+
// which failures are deferred work (an ordinary broken-provider failure must never be
|
|
6304
|
+
// resurrected). The explicit durable `failReason` is checked first; the recorded error text is
|
|
6305
|
+
// the fallback for records written before it existed / by paths that only had the message.
|
|
6306
|
+
function isCapRefusedMember(m) {
|
|
6307
|
+
if (!m || m.phase !== 'failed') return false
|
|
6308
|
+
if (m.failReason === 'activation-limit') return true
|
|
6309
|
+
// TEST SEAM (documented; NOT a product surface): with the text fallback disabled, ONLY the
|
|
6310
|
+
// durable reason counts — running the same scenario with `V5_SPAWN_RETRY_STRICT=1` proves that
|
|
6311
|
+
// every cap-refusal path records `failReason` and that no behaviour depends on error-string
|
|
6312
|
+
// matching. Read per call so a suite can toggle it around one scenario.
|
|
6313
|
+
if (String(process.env.V5_SPAWN_RETRY_STRICT || '') === '1') return false
|
|
6314
|
+
return /active child limit|maxActiveSubagents/.test(String(m.error || ''))
|
|
6315
|
+
}
|
|
6316
|
+
function pendingSpawnMembers() {
|
|
6317
|
+
return inst().members.filter((m) => isCapRefusedMember(m) && !m.childId)
|
|
6318
|
+
}
|
|
6319
|
+
async function retryPendingSpawns() {
|
|
6320
|
+
// TEST SEAM (documented; NOT a product surface): `V5_SPAWN_RETRY=manual` disables the
|
|
6321
|
+
// automatic retry for this process, so a suite can pin the MANUAL `resume` rebuild path
|
|
6322
|
+
// without racing the scheduler. Read per call, so a test can toggle it around one section.
|
|
6323
|
+
if (String(process.env.V5_SPAWN_RETRY || '').toLowerCase() === 'manual') return 0
|
|
6324
|
+
if (!running || autoDone) return 0
|
|
6325
|
+
const pending = pendingSpawnMembers()
|
|
6326
|
+
if (!pending.length) return 0
|
|
6327
|
+
let spawned = 0
|
|
6328
|
+
for (const m of pending) {
|
|
6329
|
+
const n = spawnRetryAttempts.get(m.id) || 0
|
|
6330
|
+
spawnRetryAttempts.set(m.id, n + 1)
|
|
6331
|
+
try {
|
|
6332
|
+
await spawnMember(m, null)
|
|
6333
|
+
spawnRetryAttempts.delete(m.id)
|
|
6334
|
+
spawned += 1
|
|
6335
|
+
await saveChatLine('【编制】容量已释放:重试创建成员 ' + m.id + ' 成功(第 ' + (n + 1) + ' 次尝试)。')
|
|
6336
|
+
await markProgress()
|
|
6337
|
+
} catch (e) {
|
|
6338
|
+
// Still capped: keep it queued and re-check on the next pass. Any OTHER provisioning error
|
|
6339
|
+
// is not a capacity problem — stop auto-retrying it (the operator gets a notice instead).
|
|
6340
|
+
if (!isActivationLimitReached(e)) {
|
|
6341
|
+
spawnRetryAttempts.delete(m.id)
|
|
6342
|
+
await notice('office', '成员 ' + m.id + ' 的自动重试已停止(非容量类失败):' + String((e && e.message) || e))
|
|
6343
|
+
}
|
|
6344
|
+
}
|
|
6345
|
+
}
|
|
6346
|
+
return spawned
|
|
6347
|
+
}
|
|
5697
6348
|
async function schedulePass() {
|
|
5698
6349
|
dbg.passes += 1
|
|
6350
|
+
// MEDIUM 8 (deep review): EVERY scheduling pass reads state, and a pass can be armed by a
|
|
6351
|
+
// timer or an event callback that never went through a tool wrapper. Awaiting the load here
|
|
6352
|
+
// is what makes the internal paths safe by construction instead of by convention (a pass
|
|
6353
|
+
// fired before the state file was folded in would otherwise see an empty roster and could
|
|
6354
|
+
// schedule against it).
|
|
6355
|
+
if (!loadSettled) { try { await ready() } catch (e) { /* the pass still runs; status reports it */ } }
|
|
6356
|
+
// G-6: deferred spawns are retried BEFORE anything else is scheduled, so freed capacity is
|
|
6357
|
+
// spent on the work the cap previously refused (v4's `retryPendingSpawns()` position).
|
|
6358
|
+
try { await retryPendingSpawns() } catch (e) { console.error('vibe-math-v5: pending-spawn retry failed: ' + String((e && e.message) || e)) }
|
|
5699
6359
|
clearHeartbeat()
|
|
5700
6360
|
syncParamsFromState()
|
|
5701
6361
|
// A verification that is ALREADY in flight is served first: the two coordination
|
|
@@ -5824,6 +6484,10 @@ export function apply(ctx) {
|
|
|
5824
6484
|
// one path and silently dropped on another.
|
|
5825
6485
|
async function handleReply(member, parsed, kind) {
|
|
5826
6486
|
const p = parsed || {}
|
|
6487
|
+
// L2 option A (user decision): objects for which THIS reply recorded a fidelity defect. The
|
|
6488
|
+
// ballot for such an object counts as an abstention (see the verdict branch below). Scoped to
|
|
6489
|
+
// one reply on purpose — no cross-round state.
|
|
6490
|
+
const defectTargetsThisReply = new Set()
|
|
5827
6491
|
postmark(member, p)
|
|
5828
6492
|
// (1) speech (named `speech`, not `s`: `s` is the session API in this scope)
|
|
5829
6493
|
const speech = p.say
|
|
@@ -5877,17 +6541,51 @@ export function apply(ctx) {
|
|
|
5877
6541
|
// `formalOn()` gates it: off mode is a TRUE no-op, and the field is not even offered in the
|
|
5878
6542
|
// reply contract there, so a stray / stale / hallucinated reply must not create Lean state.
|
|
5879
6543
|
// (The TOOLS stay usable in off mode on purpose — a tool call is deliberate.)
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
6544
|
+
// Adv-verify F2 (LOW): `formal` may be an ARRAY of entries (a member recording defects for two
|
|
6545
|
+
// objects in one reply). The old code read `p.formal.target` only, so an array was silently
|
|
6546
|
+
// ignored — no notice, no enforced abstention, and the same reply's `1` counted (exactly the
|
|
6547
|
+
// hole L2 closed). The array form is now SUPPORTED entry by entry; a non-object entry is
|
|
6548
|
+
// refused loudly instead of being dropped in silence.
|
|
6549
|
+
// NOTE: the gate below keeps its literal two-term shape on purpose — `audit-prompt-invariants`
|
|
6550
|
+
// I8 matches that EXACT source text ("off must be a TRUE no-op"), and the first version of this
|
|
6551
|
+
// fix (a ternary on `formalOn()`) broke the invariant while behaving identically. The array
|
|
6552
|
+
// normalisation therefore lives INSIDE the guarded block, after the gate.
|
|
6553
|
+
// (This comment deliberately does NOT spell the pattern out: I8 reads the RAW source, so a
|
|
6554
|
+
// comment quoting the gate would satisfy the invariant even with the gate removed — the mutant
|
|
6555
|
+
// `M1` in `tests/audit-v5-lean-abstention.mutants.mjs` is what keeps this honest.)
|
|
6556
|
+
if (formalOn() && p.formal) {
|
|
6557
|
+
const formalEntries = Array.isArray(p.formal) ? p.formal : (typeof p.formal === 'object' ? [p.formal] : [])
|
|
6558
|
+
for (const f of formalEntries) {
|
|
6559
|
+
if (!f || typeof f !== 'object') {
|
|
6560
|
+
await notice(member.id, 'formal 的每个条目都必须是对象(收到 ' + JSON.stringify(f) + ')——本条已忽略。')
|
|
6561
|
+
continue
|
|
6562
|
+
}
|
|
6563
|
+
const target = idSafe(String(f.target || ''))
|
|
6564
|
+
if (!target) {
|
|
6565
|
+
await notice(member.id, 'formal 条目缺少 target(对象 id)——本条已忽略。')
|
|
6566
|
+
continue
|
|
6567
|
+
}
|
|
6568
|
+
{
|
|
6569
|
+
const decision = String(f.decision || '').trim()
|
|
5885
6570
|
const note = String(f.note || '').trim()
|
|
5886
6571
|
if (decision === 'blocked' || decision === 'defect') {
|
|
5887
6572
|
if (!note) await notice(member.id, "formal.decision='" + decision + "' 必须写明 note(难度判断/阻塞原因/具体偏差)——本次未记录。")
|
|
5888
6573
|
else if (decision === 'defect') {
|
|
5889
6574
|
const r = await recordFidelityDefect(member.id, target, note)
|
|
5890
6575
|
if (r.ok === false) await notice(member.id, '记录忠实性缺陷失败(' + (r.code || '') + '):' + (r.message || ''))
|
|
6576
|
+
else {
|
|
6577
|
+
// L2 option A (user decision): a reply that RECORDS a fidelity defect for an object
|
|
6578
|
+
// must not also assert that object true in the same round. The framework therefore
|
|
6579
|
+
// counts THIS member's verdict for THIS object as an ABSTENTION (the verdict branch
|
|
6580
|
+
// below reads `defectTargetsThisReply`). Only the declaring member and only this
|
|
6581
|
+
// round are affected: no cross-round state, no gate change, other ballots untouched.
|
|
6582
|
+
defectTargetsThisReply.add(target)
|
|
6583
|
+
// Observable note (the member must not be silently overridden): member notice + a
|
|
6584
|
+
// durable marker in the formal record, which `lean_lib`/`report()` surface via `note`.
|
|
6585
|
+
await notice(member.id, '框架规则:同一条回复里记录了忠实性缺陷(对象 ' + target + '),'
|
|
6586
|
+
+ '你**对该对象的 verdict 已按弃权计入**(不投真也不投假);缺陷本身照常撤回证明。'
|
|
6587
|
+
+ '若你确认命题为假,请在**下一轮**用独立理由投 0。')
|
|
6588
|
+
}
|
|
5891
6589
|
} else {
|
|
5892
6590
|
const r = await leanArchive(member.id, { kind: 'blocked', target, note })
|
|
5893
6591
|
if (r.ok === false) await notice(member.id, '记录形式化阻塞失败(' + (r.code || '') + '):' + (r.message || ''))
|
|
@@ -5897,7 +6595,7 @@ export function apply(ctx) {
|
|
|
5897
6595
|
const prev = formalOf(target)
|
|
5898
6596
|
await putFormal(target, Object.assign({}, prev, {
|
|
5899
6597
|
status: prev.status === 'passed' || prev.status === 'blocked' ? prev.status : 'attempted',
|
|
5900
|
-
file, decision: 'used', note: String(f.note || prev.note || ''), updatedAt: now(),
|
|
6598
|
+
file, decision: 'used', decisionSource: 'member-reply', note: String(f.note || prev.note || ''), updatedAt: now(),
|
|
5901
6599
|
}))
|
|
5902
6600
|
await writeFormalIndex()
|
|
5903
6601
|
} else if (decision) {
|
|
@@ -5905,6 +6603,7 @@ export function apply(ctx) {
|
|
|
5905
6603
|
}
|
|
5906
6604
|
}
|
|
5907
6605
|
}
|
|
6606
|
+
}
|
|
5908
6607
|
if (p.propose_verify) {
|
|
5909
6608
|
const pv = typeof p.propose_verify === 'string' ? { target: p.propose_verify } : p.propose_verify
|
|
5910
6609
|
if (pv && pv.target) {
|
|
@@ -5913,10 +6612,42 @@ export function apply(ctx) {
|
|
|
5913
6612
|
}
|
|
5914
6613
|
}
|
|
5915
6614
|
if (p.verdict && typeof p.verdict === 'object') {
|
|
5916
|
-
const
|
|
5917
|
-
if (
|
|
6615
|
+
const declared = normVerdictNumber(p.verdict.verdict)
|
|
6616
|
+
if (declared === undefined) await notice(member.id, 'verdict 必须是 0-1 的数值;本轮的票未被记录。')
|
|
5918
6617
|
else {
|
|
5919
|
-
const
|
|
6618
|
+
const vTarget = idSafe(String(p.verdict.target || ''))
|
|
6619
|
+
// L2 option A: the same reply declared a fidelity defect for this object ⇒ this member's
|
|
6620
|
+
// ballot counts as an ABSTENTION (0.5) instead of a boolean assertion. A value that is
|
|
6621
|
+
// ALREADY a proper abstention stays exactly as sent (no double counting); the durable
|
|
6622
|
+
// note is attached to the formal record so the override is visible in the receipt.
|
|
6623
|
+
// Adv-verify S7: only an ACTUAL voter has a boolean ballot to abstain from — a temp
|
|
6624
|
+
// worker's opinion is relayed, never counted, so it must NOT get an "abstention" marker
|
|
6625
|
+
// (that would claim the framework changed a vote that never existed).
|
|
6626
|
+
const isVoterHere = voters().some((m) => m.id === member.id)
|
|
6627
|
+
const enforced = isVoterHere && defectTargetsThisReply.has(vTarget) && (declared === 0 || declared === 1)
|
|
6628
|
+
const n = enforced ? 0.5 : declared
|
|
6629
|
+
if (enforced) {
|
|
6630
|
+
try {
|
|
6631
|
+
await putFormal(vTarget, (prev0) => {
|
|
6632
|
+
const fid = Object.assign({}, (prev0 && prev0.fidelity) || {})
|
|
6633
|
+
// Adv-verify F1 (MED): the marker used to be a SINGLE value, so two members
|
|
6634
|
+
// recording a defect in the same round overwrote each other and only the last was
|
|
6635
|
+
// auditable. It is an ARRAY now (old object-shaped records are folded in), with a
|
|
6636
|
+
// matching `abstainedCount`.
|
|
6637
|
+
const prevList = Array.isArray(fid.voteAbstainedByFramework)
|
|
6638
|
+
? fid.voteAbstainedByFramework
|
|
6639
|
+
: (fid.voteAbstainedByFramework ? [fid.voteAbstainedByFramework] : [])
|
|
6640
|
+
const list = prevList.concat([{ at: now(), by: member.id, declared }])
|
|
6641
|
+
fid.voteAbstainedByFramework = list
|
|
6642
|
+
fid.abstainedCount = list.length
|
|
6643
|
+
return Object.assign({}, prev0 || { status: 'none' }, {
|
|
6644
|
+
fidelity: fid,
|
|
6645
|
+
note: (String((prev0 && prev0.note) || '') + ';本轮同回复的 verdict=' + declared + ' 已由框架按弃权计入').trim(),
|
|
6646
|
+
})
|
|
6647
|
+
})
|
|
6648
|
+
} catch (e) { /* the vote still counts as an abstention even if the note cannot be stored */ }
|
|
6649
|
+
}
|
|
6650
|
+
const r = await castVerdict(member.id, vTarget, n, p.verdict.reason)
|
|
5920
6651
|
if (r && r.ok === false) await notice(member.id, '本轮的票未被记录(' + (r.code || '') + '):' + (r.message || ''))
|
|
5921
6652
|
}
|
|
5922
6653
|
}
|
|
@@ -5976,7 +6707,7 @@ export function apply(ctx) {
|
|
|
5976
6707
|
'- 时间: ' + fmtTime(),
|
|
5977
6708
|
'- 记录者: ' + member.id,
|
|
5978
6709
|
'- 该成员认为原问题已解决: ' + (p.solved === true),
|
|
5979
|
-
'- 有表决权的解决票: ' +
|
|
6710
|
+
'- 有表决权的解决票: ' + (solveVotesList().join('、') || '(无)'),
|
|
5980
6711
|
'',
|
|
5981
6712
|
].join('\n'))
|
|
5982
6713
|
}
|
|
@@ -6179,7 +6910,15 @@ export function apply(ctx) {
|
|
|
6179
6910
|
// round-4 item 4: only a NEWLY recorded entry warns, and the record goes through the same
|
|
6180
6911
|
// retention as every other diagnostics writer in this preset (slice(-50)).
|
|
6181
6912
|
if (duplicate) return keys
|
|
6182
|
-
|
|
6913
|
+
// F1 (status/report review): the entry carries the SAME field names as every other
|
|
6914
|
+
// diagnostics record (`at`/`type`/`error`) plus its own `kind`/`where`/`keys`. It used to be
|
|
6915
|
+
// `{kind, where, keys}` only, so `report()` printed `undefined|undefined` for a record it
|
|
6916
|
+
// was supposed to explain.
|
|
6917
|
+
target.diagnostics.push({
|
|
6918
|
+
at: now(), type: 'vibe5/dropped-keys',
|
|
6919
|
+
error: '忽略未知参数键(未写入状态):' + keys.join(', ') + '(来源:' + where + ')',
|
|
6920
|
+
kind: 'state-dropped-keys', where: where, keys: keys,
|
|
6921
|
+
})
|
|
6183
6922
|
while (target.diagnostics.length > 50) target.diagnostics.shift()
|
|
6184
6923
|
}
|
|
6185
6924
|
console.warn('vibe-math-v5: 忽略未知参数键(不静默丢失,已记入 diagnostics):' + keys.join(', ') + '(来源:' + where + ')')
|
|
@@ -6246,10 +6985,23 @@ export function apply(ctx) {
|
|
|
6246
6985
|
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)' }
|
|
6247
6986
|
}
|
|
6248
6987
|
const patch = {}
|
|
6988
|
+
let identitySwitched = false
|
|
6249
6989
|
if (a.project !== undefined && String(a.project).trim()) patch.project = String(a.project).trim()
|
|
6250
6990
|
if (a.institute !== undefined && String(a.institute).trim()) patch.institute = String(a.institute).trim()
|
|
6251
6991
|
if (a.problem !== undefined) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
|
|
6252
6992
|
if (a.params && typeof a.params === 'object') patch.params = Object.assign({}, params, normalizeParams(a.params))
|
|
6993
|
+
// C1 (deep-review 4): a configure with NOTHING to apply used to still commit
|
|
6994
|
+
// `patch.phase = 'idle'` below — a no-arg `/v5 configure` silently regressed a CONCLUDED
|
|
6995
|
+
// institute (`report()` then showed 已结题 false). A no-op configure is now a true no-op:
|
|
6996
|
+
// no commit, no phase change, and the answer says exactly that + the usage.
|
|
6997
|
+
if (patch.project === undefined && patch.institute === undefined && patch.problem === undefined && patch.params === undefined) {
|
|
6998
|
+
return {
|
|
6999
|
+
ok: true, noop: true, project, institute: instituteName,
|
|
7000
|
+
phase: String(inst().phase || phase),
|
|
7001
|
+
note: 'configure 没有收到任何参数:未改动任何状态(当前研究所 ' + instituteName + ',phase=' + String(inst().phase || phase) + ')。'
|
|
7002
|
+
+ '用法:/v5 configure <研究所名> <问题…>(可在同一行里带 problem;切换研究所名会用一个新的所)。',
|
|
7003
|
+
}
|
|
7004
|
+
}
|
|
6253
7005
|
if (patch.project !== undefined || patch.institute !== undefined) {
|
|
6254
7006
|
const np = patch.project !== undefined ? patch.project : project
|
|
6255
7007
|
const ni = patch.institute !== undefined ? patch.institute : instituteName
|
|
@@ -6264,18 +7016,29 @@ export function apply(ctx) {
|
|
|
6264
7016
|
instituteName = ni
|
|
6265
7017
|
patch.project = np
|
|
6266
7018
|
patch.institute = ni
|
|
7019
|
+
identitySwitched = true
|
|
6267
7020
|
} else if (!inst().members.length) {
|
|
6268
7021
|
key = nkey
|
|
6269
7022
|
project = np
|
|
6270
7023
|
instituteName = ni
|
|
6271
7024
|
patch.project = np
|
|
6272
7025
|
patch.institute = ni
|
|
7026
|
+
identitySwitched = true
|
|
6273
7027
|
} else {
|
|
6274
7028
|
return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
|
|
6275
7029
|
}
|
|
6276
7030
|
}
|
|
6277
7031
|
}
|
|
6278
|
-
|
|
7032
|
+
// F5 (status/report review, S1 class): `phase='idle'` belongs to FOUNDING THE SETUP, not to
|
|
7033
|
+
// every configure. A partial configure (`problem`-only / `params`-only / institute-only with
|
|
7034
|
+
// the SAME name) may still apply its patch, but it must never rewrite the phase of an
|
|
7035
|
+
// institute that already has a life of its own — that is what made a concluded institute read
|
|
7036
|
+
// "阶段:idle|已结题:false". The phase is reset only when this call is actually (re)founding:
|
|
7037
|
+
// a REAL identity switch (`nkey !== key`), or a session that holds no institute yet.
|
|
7038
|
+
// (`institute-only` with an unchanged name is therefore also a no-phase-change: `patch.institute`
|
|
7039
|
+
// being present is not the same as the identity having moved.)
|
|
7040
|
+
const holdsInstitute = inst().members.length > 0 || String(inst().phase || '') !== 'idle'
|
|
7041
|
+
if (identitySwitched || !holdsInstitute) patch.phase = 'idle'
|
|
6279
7042
|
await patchInstitute(patch)
|
|
6280
7043
|
syncParamsFromState()
|
|
6281
7044
|
await mkdirs()
|
|
@@ -6320,7 +7083,17 @@ export function apply(ctx) {
|
|
|
6320
7083
|
async function doStart(args) {
|
|
6321
7084
|
const a = args || {}
|
|
6322
7085
|
if (running && !autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'already running' }
|
|
6323
|
-
|
|
7086
|
+
// S1 (repro `_oneoff/s1-solved-reload.mjs`): the concluded state is durable (`phase:'solved'`
|
|
7087
|
+
// in the state file), so the guard checks the PERSISTED phase as well as the in-memory flag.
|
|
7088
|
+
// The refusal names the concrete next step instead of only the code.
|
|
7089
|
+
if (autoDone || String(phase) === 'solved') {
|
|
7090
|
+
return {
|
|
7091
|
+
ok: false, code: 'V5_INSTITUTE_STATE',
|
|
7092
|
+
message: '本所已结题(状态文件里的 phase = solved,runId = ' + String(inst().runId || '') + '),不能再 start:'
|
|
7093
|
+
+ '结题运行的结论/论文/成员都已归档在这个所里。要继续工作请二选一 —— ①在新会话里 `/v5 configure <新研究所名> <问题>` 再 `/v5 start`(推荐);'
|
|
7094
|
+
+ '②同一会话里也要先 `/v5 configure <新研究所名> <问题>`(本所已有成员,故 configure 只接受切换到一个**新的**研究所名)。',
|
|
7095
|
+
}
|
|
7096
|
+
}
|
|
6324
7097
|
const patch = {}
|
|
6325
7098
|
if (a.problem !== undefined && String(a.problem).trim()) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
|
|
6326
7099
|
const p = Object.assign({}, params, normalizeParams(a.params || {}))
|
|
@@ -6370,7 +7143,11 @@ export function apply(ctx) {
|
|
|
6370
7143
|
spawned.push(m.id)
|
|
6371
7144
|
} catch (e) {
|
|
6372
7145
|
if (isActivationLimitReached(e)) limitRefused = true
|
|
6373
|
-
|
|
7146
|
+
// G-6b (installer follow-up): the founding catch RE-writes the record that `spawnMember`'s
|
|
7147
|
+
// own catch already wrote — it must not drop the durable cap marker, otherwise the queue
|
|
7148
|
+
// would depend on matching the error TEXT for this path (the exact divergence the shared
|
|
7149
|
+
// predicate exists to prevent).
|
|
7150
|
+
await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
|
|
6374
7151
|
}
|
|
6375
7152
|
}
|
|
6376
7153
|
for (let i = 0; i < params.researcherCount; i++) {
|
|
@@ -6381,7 +7158,7 @@ export function apply(ctx) {
|
|
|
6381
7158
|
spawned.push(m.id)
|
|
6382
7159
|
} catch (e) {
|
|
6383
7160
|
if (isActivationLimitReached(e)) limitRefused = true
|
|
6384
|
-
await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
|
|
7161
|
+
await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
|
|
6385
7162
|
}
|
|
6386
7163
|
}
|
|
6387
7164
|
if (!spawned.length) {
|
|
@@ -6436,7 +7213,7 @@ export function apply(ctx) {
|
|
|
6436
7213
|
// "queued" half of the cap handling, retried in this round — gated on the RECORDED cap error
|
|
6437
7214
|
// so an ordinary failed member (broken provider, rejected tool filter) is never resurrected
|
|
6438
7215
|
// by a resume. `members` keeps its meaning for the return value below.
|
|
6439
|
-
const queued = inst().members.filter((m) =>
|
|
7216
|
+
const queued = inst().members.filter((m) => isCapRefusedMember(m))
|
|
6440
7217
|
if (!members.length && !queued.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
|
|
6441
7218
|
let respawned = 0
|
|
6442
7219
|
beginSpawnRound() // the whole re-spawn loop is ONE round for the cap notice
|
|
@@ -6499,7 +7276,9 @@ export function apply(ctx) {
|
|
|
6499
7276
|
// (v4 §30-T38).
|
|
6500
7277
|
meeting = null
|
|
6501
7278
|
pendingMeeting = null
|
|
6502
|
-
|
|
7279
|
+
// HIGH 3: a NEW solve question starts with an empty DURABLE tally (a clear has to be
|
|
7280
|
+
// persisted too, otherwise a reload would resurrect the previous question's answers).
|
|
7281
|
+
await putSolve({ clear: true })
|
|
6503
7282
|
busy.clear()
|
|
6504
7283
|
wakeKind.clear()
|
|
6505
7284
|
currentMember = ''
|
|
@@ -6518,6 +7297,19 @@ export function apply(ctx) {
|
|
|
6518
7297
|
ok: true,
|
|
6519
7298
|
institute: instituteName, project, key, phase,
|
|
6520
7299
|
running, autoDone, runId: s.runId,
|
|
7300
|
+
// L4 (deep-review 5): the `【形式化结果】` announcements are delivered ONCE, to the job's
|
|
7301
|
+
// initiator, and live in memory. F2 (status/report review): the field is therefore labelled
|
|
7302
|
+
// as SESSION-scoped, because after a restart it is empty although the durable per-job
|
|
7303
|
+
// records in Formal/Jobs/<jobId>.json are still there.
|
|
7304
|
+
leanNotices: leanNotices.map((n) => ({ member: n.member, jobId: n.jobId, line: n.line })),
|
|
7305
|
+
leanNoticesScope: 'session-memory(重启后为空;耐久事实在 Formal/Jobs/<jobId>.json 与 vibe_v5_lean_job)',
|
|
7306
|
+
// F2/F3 (status/report review): one place that names the SESSION-scoped fields inside this
|
|
7307
|
+
// payload, so a reader (or an operator comparing two sessions) cannot mistake them for
|
|
7308
|
+
// durable state. Durable = derived from the state file; session = rebuilt on load.
|
|
7309
|
+
fieldScopes: {
|
|
7310
|
+
session: ['running', 'autoDone(session mirror of phase)', 'leanNotices', 'debug', 'members[].rounds', 'members[].busy', 'members[].contextPct', 'members[].childId', 'meeting', 'parkedMeeting', 'persistence.writeFailures', 'persistence.prematureReads', 'persistence.loadProblem', 'pendingSpawns[].attempts'],
|
|
7311
|
+
durable: ['phase', 'runId', 'quorum', 'members[] (except the three session fields)', 'members[].failReason', 'tasks', 'failedMembers', 'pendingSpawns[] (derived from durable failed members)', 'chat', 'officeRequests', 'verify', 'verifyQueue', 'verified', 'undecided', 'solveVotes', 'formal', 'paper', 'lastProgressAt', 'params'],
|
|
7312
|
+
},
|
|
6521
7313
|
backend: backend ? backend.kind : 'uninitialized',
|
|
6522
7314
|
// Skipped/malformed events AND state-file load problems. Without this the two
|
|
6523
7315
|
// failure modes that silently drop state were invisible in the operator's view.
|
|
@@ -6528,23 +7320,72 @@ export function apply(ctx) {
|
|
|
6528
7320
|
id: m.id, kind: m.kind, phase: m.phase, direction: m.direction, hiredBy: m.hiredBy,
|
|
6529
7321
|
rounds: rounds.get(m.id) || 0, busy: busy.has(m.id), contextPct: contextPct.get(m.id) || 0,
|
|
6530
7322
|
childId: m.childId ? m.childId.slice(0, 12) : '', error: m.error || '',
|
|
7323
|
+
// G-6b: the durable reason a failed member is deferred work (`activation-limit` = the host
|
|
7324
|
+
// cap refused it, and the framework will retry it) — needed to audit the queue without
|
|
7325
|
+
// re-deriving it from the error text.
|
|
7326
|
+
failReason: m.failReason || '',
|
|
6531
7327
|
})),
|
|
6532
7328
|
tasks: listTasks(),
|
|
7329
|
+
// LOW (deep review): a member whose provisioning failed stays on the roster forever (ids
|
|
7330
|
+
// are never reused, so the entry must stay) — but `resume` only retries the ones refused by
|
|
7331
|
+
// the HOST's live-child cap. Everything else is surfaced here with the remedy, instead of
|
|
7332
|
+
// being a silent, permanent `phase:'failed'` row an operator has to notice by luck.
|
|
7333
|
+
failedMembers: s.members.filter((m) => m.phase === 'failed').map((m) => ({ id: m.id, kind: m.kind, failReason: m.failReason || '', error: String(m.error || '').slice(0, 200) })),
|
|
7334
|
+
failedMembersNote: 'resume 只重试「live-child 上限」类失败;其它 provisioning 失败请由所办重新招聘(新 id,旧 id 永不复用)',
|
|
7335
|
+
// G-6 (installer review): the DEFERRED spawns — refused by the host's live-child cap, queued
|
|
7336
|
+
// by construction (durable `phase:'failed'` + `failReason:'activation-limit'`) and retried on
|
|
7337
|
+
// every scheduling pass until capacity frees up or the session's attempt budget runs out.
|
|
7338
|
+
// v4 exposed the same fact as `pendingSpawns`; without it a refusal was invisible work.
|
|
7339
|
+
pendingSpawns: pendingSpawnMembers().map((m) => ({
|
|
7340
|
+
id: m.id, kind: m.kind, attempts: spawnRetryAttempts.get(m.id) || 0,
|
|
7341
|
+
error: String(m.error || '').slice(0, 200),
|
|
7342
|
+
})),
|
|
7343
|
+
pendingSpawnsNote: '容量(宿主 live-child 上限)拒绝的成员:已登记为待补建,框架在每次调度轮次自动重试(容量未释放时该次重试不询问宿主,零成本);成功后该成员转为 active,本列表自动清空。非容量类的 provisioning 失败不会自动重试(会通知所办)。',
|
|
7344
|
+
// F8 (status/report review): the two counts have different scopes — `pending` is the
|
|
7345
|
+
// INSTITUTE-WIDE number of not-yet-acknowledged messages (not "my unread"), and `delivered`
|
|
7346
|
+
// is a CAPPED acknowledgement ledger. Both are named accordingly here.
|
|
6533
7347
|
chat: { pending: s.messages.length, delivered: s.delivered.length },
|
|
7348
|
+
chatScope: 'pending = 全所未确认投递的消息数(非"我的未读");delivered = 封顶账本(DELIVERED_CAP,到顶后不再增长)',
|
|
7349
|
+
// MEDIUM 4 (deep review): the requests only the OFFICE can approve. `to:'voters'` never
|
|
7350
|
+
// reaches the office (it is not a member), so an unapproved proposal used to be invisible
|
|
7351
|
+
// to the one actor able to act on it.
|
|
7352
|
+
officeRequests: s.messages.filter((m) => m.to === OFFICE_INBOX).map((m) => ({ id: m.id, from: m.from, text: String(m.text || ''), at: m.at })),
|
|
7353
|
+
// F4 (status/report review): the list above is truncated at OFFICE_REQUEST_CAP, so the
|
|
7354
|
+
// truncation is published instead of pretending the list is complete.
|
|
7355
|
+
officeRequestsShown: s.messages.filter((m) => m.to === OFFICE_INBOX).length,
|
|
7356
|
+
officeRequestsCap: OFFICE_REQUEST_CAP,
|
|
7357
|
+
officeRequestsDropped: Number(s.officeRequestsDropped || 0),
|
|
7358
|
+
officeRequestsTruncated: s.messages.filter((m) => m.to === OFFICE_INBOX).length >= OFFICE_REQUEST_CAP,
|
|
7359
|
+
// MEDIUM 7/8 (deep review): persistence health is part of the status. `stateWriteFailures`
|
|
7360
|
+
// > 0 means the in-memory state is AHEAD of the file; `prematureReads` > 0 means some
|
|
7361
|
+
// consumer read state before the first load settled (see the note on `state()`).
|
|
7362
|
+
// F2/F9 (status/report review): both counters are SESSION-scoped (a restart resets them), so
|
|
7363
|
+
// the scope is stated here too, not only in the report line.
|
|
7364
|
+
persistence: { writeFailures: stateWriteFailures, prematureReads: prematureReads, loadSettled: loadSettled, loadProblem: lastLoadProblem, scope: 'session(重启后归零)', sandboxPolicyMismatch: sandboxPolicyMismatch },
|
|
6534
7365
|
meeting: meeting ? { id: meeting.id, agenda: meeting.agenda, kind: meeting.kind, spoke: Object.keys(meeting.inputs), order: meeting.order } : null,
|
|
6535
7366
|
parkedMeeting: pendingMeeting ? { agenda: pendingMeeting.agenda, kind: pendingMeeting.kind } : null,
|
|
6536
7367
|
verify: cv ? { target: cv.target, kind: cv.kind, stage: cv.stage, round: cv.round, voted: Object.keys(cv.votes), m: quorumM(), P: voterCount() } : null,
|
|
6537
7368
|
verifyQueue: s.queue.map((q) => q.target),
|
|
7369
|
+
// F7 (status/report review): the old `verified` meant "concluded (真 OR 假)" while its name
|
|
7370
|
+
// said "verified". It is kept for compatibility but the honest split is published next to it.
|
|
6538
7371
|
verified: Object.keys(s.verdicts).filter((k) => s.verdicts[k] && s.verdicts[k].closed && s.verdicts[k].outcome !== 'undecided'),
|
|
7372
|
+
verifiedTrue: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'true' }),
|
|
7373
|
+
concludedFalse: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'false' }),
|
|
7374
|
+
verifiedNote: 'verified = 已定论(真或假,历史名,保留兼容);请用 verifiedTrue / concludedFalse / undecided 三个精确字段',
|
|
6539
7375
|
undecided: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'undecided' }),
|
|
6540
|
-
solveVotes:
|
|
7376
|
+
solveVotes: solveVotesList(),
|
|
6541
7377
|
// Lean formal verification: mode + per-object status + the formalization TODO. This
|
|
6542
7378
|
// is how an office/human audits "did we really get strict proofs, or only consensus?"
|
|
6543
7379
|
formal: {
|
|
6544
7380
|
mode: formalMode(),
|
|
6545
7381
|
objects: Object.keys(formalRecords()).map((k) => {
|
|
6546
7382
|
const r = formalRecords()[k] || {}
|
|
6547
|
-
|
|
7383
|
+
// F1d (fallout #4): `decision`/`decisionSource` are READ here (member-reply vs
|
|
7384
|
+
// job-settle) instead of being a write-only field justified by a comment.
|
|
7385
|
+
// Adv-verify F3: `fidelity` (the defect record + the enforced-abstention marker) is
|
|
7386
|
+
// exposed too — before this it lived only in the state file, so a tool-surface audit
|
|
7387
|
+
// could not see WHY a ballot was counted as an abstention.
|
|
7388
|
+
return { target: k, status: r.status, decision: r.decision || '', decisionSource: r.decisionSource || '', fidelity: r.fidelity || null, abstainedCount: Number((r.fidelity || {}).abstainedCount || 0) || (Array.isArray((r.fidelity || {}).voteAbstainedByFramework) ? (r.fidelity.voteAbstainedByFramework.length) : ((r.fidelity || {}).voteAbstainedByFramework ? 1 : 0)), file: r.file || '', proof: r.proof || '', note: r.note || '', run: r.run ? { ok: r.run.ok, exitCode: r.run.exitCode, ms: r.run.ms } : null, async: r.async || null }
|
|
6548
7389
|
}),
|
|
6549
7390
|
passed: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'passed'),
|
|
6550
7391
|
blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
|
|
@@ -6566,9 +7407,9 @@ export function apply(ctx) {
|
|
|
6566
7407
|
const L = []
|
|
6567
7408
|
L.push('# 「' + instituteName + '」研究所汇报')
|
|
6568
7409
|
L.push('')
|
|
6569
|
-
L.push('- 项目:' + project + '
|
|
7410
|
+
L.push('- 项目:' + project + '|' + phaseFactLine())
|
|
6570
7411
|
L.push('- 研究对象:' + (s.problem.statement ? s.problem.statement.slice(0, 200) : '(未设定)'))
|
|
6571
|
-
L.push('- 求真门槛:' + (qv.started ? 'm = ' + qv.m : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + qv.voterCount + ' 人|rosterVersion
|
|
7412
|
+
L.push('- 求真门槛:' + (qv.started ? 'm = ' + qv.m : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + qv.voterCount + ' 人|rosterVersion(名册版本,多个视图不一致时用它判断谁过期)' + qv.rosterVersion)
|
|
6572
7413
|
L.push('')
|
|
6573
7414
|
L.push('## 编制')
|
|
6574
7415
|
if (!s.members.length) L.push('(暂无成员)')
|
|
@@ -6576,7 +7417,7 @@ export function apply(ctx) {
|
|
|
6576
7417
|
L.push('- ' + m.id + '|' + kindLabel(m.kind) + '|' + m.phase +
|
|
6577
7418
|
(m.hiredBy ? '|雇主 ' + m.hiredBy : '') +
|
|
6578
7419
|
(m.direction ? '|方向:' + m.direction : '') +
|
|
6579
|
-
'|轮次 ' + (rounds.get(m.id) || 0) +
|
|
7420
|
+
'|轮次 ' + (rounds.get(m.id) || 0) + '(本会话计数,重启归零)' +
|
|
6580
7421
|
(m.error ? '|⚠ ' + m.error : ''))
|
|
6581
7422
|
}
|
|
6582
7423
|
L.push('')
|
|
@@ -6598,19 +7439,63 @@ export function apply(ctx) {
|
|
|
6598
7439
|
'|平均概率 ' + Number(v.mean || 0).toFixed(2) + '|' + (v.reason || '') + ')')
|
|
6599
7440
|
}
|
|
6600
7441
|
const cv = currentVerify()
|
|
6601
|
-
if (cv) L.push('- 进行中:' + cv.target + '|' + cv.stage + ' 第 ' + cv.round + '
|
|
7442
|
+
if (cv) L.push('- 进行中:' + cv.target + '|' + cv.stage + ' 第 ' + cv.round + ' 轮' + (Object.keys(cv.votes).length ? '|已投 ' + Object.keys(cv.votes).join('、') : '|尚无人投票'))
|
|
6602
7443
|
if (s.queue.length) L.push('- 队列:' + s.queue.map((q) => q.target).join('、'))
|
|
6603
7444
|
L.push('')
|
|
6604
7445
|
L.push('## 群聊 / 会议')
|
|
6605
|
-
L.push('-
|
|
6606
|
-
|
|
7446
|
+
L.push('- 未投递消息(全所):' + s.messages.length + '|已确认投递(封顶账本):' + s.delivered.length)
|
|
7447
|
+
// MEDIUM 4 + F4: the office's own inbox — requests that only the office can approve, with the
|
|
7448
|
+
// truncation published (the list is capped at OFFICE_REQUEST_CAP).
|
|
7449
|
+
{
|
|
7450
|
+
const reqs = s.messages.filter((m) => m.to === OFFICE_INBOX)
|
|
7451
|
+
const dropped = Number(s.officeRequestsDropped || 0)
|
|
7452
|
+
L.push('- 待所办裁定:' + (reqs.length
|
|
7453
|
+
? '显示最新 ' + reqs.length + ' 条' + (dropped ? '(上限 ' + OFFICE_REQUEST_CAP + ',累计已丢弃最旧 ' + dropped + ' 条)' : '') + ';最新:' + String(reqs[reqs.length - 1].text || '').slice(0, 120)
|
|
7454
|
+
: '(无)'))
|
|
7455
|
+
}
|
|
7456
|
+
if (meeting) L.push('- 进行中会议:' + meeting.id + '|' + meeting.agenda + (Object.keys(meeting.inputs).length ? '|已发言 ' + Object.keys(meeting.inputs).join('、') : '|尚无人发言'))
|
|
6607
7457
|
if (pendingMeeting) L.push('- 暂存会议:' + pendingMeeting.agenda)
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
7458
|
+
// F6 (status/report review): a meeting whose index entry exists but which never FINALIZED is
|
|
7459
|
+
// not history. `meetingOpen` is durable (set in `beginMeeting`, cleared in `finalizeMeeting`),
|
|
7460
|
+
// so a restart cannot silently drop the minutes while the count still looks normal.
|
|
7461
|
+
{
|
|
7462
|
+
const open = s.meetingOpen || null
|
|
7463
|
+
const finalized = (s.meetings || []).filter((x) => !open || x.id !== open.id)
|
|
7464
|
+
L.push('- 历史会议:' + finalized.length + ' 次|辩论录:' + s.debates.length + ' 份'
|
|
7465
|
+
+ (open ? '|⚠ 有 1 场会议未收束:' + open.id + '(开始于 ' + fmtTime(open.at) + ';本会话未恢复它的发言,纪要可能缺失)' : ''))
|
|
7466
|
+
}
|
|
7467
|
+
// G-6: deferred spawns (host live-child cap refused them; the framework retries each pass).
|
|
7468
|
+
{
|
|
7469
|
+
const pend = pendingSpawnMembers()
|
|
7470
|
+
if (pend.length) {
|
|
7471
|
+
L.push('- 待补建成员:' + pend.map((m) => m.id + '(' + kindLabel(m.kind) + ',已重试 ' + (spawnRetryAttempts.get(m.id) || 0) + ' 次)').join('、')
|
|
7472
|
+
+ '|容量释放后框架会在下一次调度轮次自动重试(容量未释放时该次重试不询问宿主);成功后该成员转为 active 并从本行消失')
|
|
7473
|
+
}
|
|
7474
|
+
}
|
|
7475
|
+
L.push('- 解决票:' + (solveVotesList().length ? solveVotesList().join('、') : '(无)'))
|
|
7476
|
+
// F1 (status/report review, HIGH): the section must read the sources its heading advertises —
|
|
7477
|
+
// (a) skipped/malformed EVENTS (top-level `state.diagnostics`, written by `applyV5Event`'s
|
|
7478
|
+
// catch), (b) state-FILE problems (the session log `loadProblemLog` / `lastLoadProblem`), and
|
|
7479
|
+
// (c) ignored keys (institute-level `s.diagnostics`, written by `reportDroppedStateKeys`).
|
|
7480
|
+
// Every field is printed with an explicit fallback, so the literal `undefined` can never
|
|
7481
|
+
// appear. The section is omitted entirely when there is nothing to report (the
|
|
7482
|
+
// "assemble only when a value exists" rule).
|
|
7483
|
+
const topDiag = (() => { try { return (state().diagnostics || []) } catch (e) { return [] } })()
|
|
7484
|
+
const droppedDiag = (s.diagnostics || []).filter((d) => d && d.kind === 'state-dropped-keys')
|
|
7485
|
+
const eventDiag = topDiag.filter((d) => d && d.type !== 'vibe5/load')
|
|
7486
|
+
const loadDiag = topDiag.filter((d) => d && d.type === 'vibe5/load').concat(loadProblemLog)
|
|
7487
|
+
{
|
|
7488
|
+
const fmtDiag = (d) => fmtTime(d.at) + '|' + String(d.type || '(无 type)') + '|' + String(d.error || '(无 error)')
|
|
7489
|
+
const lines = []
|
|
7490
|
+
for (const d of eventDiag.slice(-10)) lines.push('- [被跳过的事件] ' + fmtDiag(d))
|
|
7491
|
+
for (const d of loadDiag.slice(-10)) lines.push('- [状态文件] ' + fmtDiag(d))
|
|
7492
|
+
for (const d of droppedDiag.slice(-10)) lines.push('- [忽略的键] ' + fmtDiag(d))
|
|
7493
|
+
if (lastLoadProblem && !loadDiag.some((d) => String(d.error || '') === lastLoadProblem)) lines.push('- [状态文件] ' + lastLoadProblem)
|
|
7494
|
+
if (lines.length) {
|
|
7495
|
+
L.push('')
|
|
7496
|
+
L.push('## ⚠ 状态诊断(被跳过的事件 / 状态文件读取问题 / 忽略的键)')
|
|
7497
|
+
for (const ln of lines) L.push(ln)
|
|
7498
|
+
}
|
|
6614
7499
|
}
|
|
6615
7500
|
L.push('')
|
|
6616
7501
|
L.push('## Lean 形式化')
|
|
@@ -6620,6 +7505,23 @@ export function apply(ctx) {
|
|
|
6620
7505
|
L.push('- 已通过:' + (Object.keys(s.formal || {}).filter((k) => (s.formal || {})[k].status === 'passed').join('、') || '(无)'))
|
|
6621
7506
|
L.push('- 已记录阻塞:' + (Object.keys(s.formal || {}).filter((k) => (s.formal || {})[k].status === 'blocked').join('、') || '(无)'))
|
|
6622
7507
|
L.push('- 形式化待办:' + ((s.todo || []).map((t) => t.id).join('、') || '(无)'))
|
|
7508
|
+
// F1d (fallout #4): the member-reported vs job-settled distinction, readable.
|
|
7509
|
+
{
|
|
7510
|
+
const dec = Object.keys(s.formal || {}).filter((k) => (s.formal[k] || {}).decision).map((k) => k + '=' + s.formal[k].decision + '(' + (s.formal[k].decisionSource || '来源未记录') + ')')
|
|
7511
|
+
if (dec.length) L.push('- 形式化决定:' + dec.join('、') + '(来源 member-reply=成员自述,job-settle=后台作业落地)')
|
|
7512
|
+
// Adv-verify F3: the enforced-abstention marker is readable here too (how many ballots the
|
|
7513
|
+
// framework turned into abstentions because the same reply recorded a fidelity defect).
|
|
7514
|
+
const enforced = Object.keys(s.formal || {}).filter((k) => {
|
|
7515
|
+
const fid = (s.formal[k] || {}).fidelity || {}
|
|
7516
|
+
return Number(fid.abstainedCount || 0) > 0 || (Array.isArray(fid.voteAbstainedByFramework) && fid.voteAbstainedByFramework.length) || fid.voteAbstainedByFramework
|
|
7517
|
+
}).map((k) => {
|
|
7518
|
+
const fid = (s.formal[k] || {}).fidelity || {}
|
|
7519
|
+
const n = Number(fid.abstainedCount || 0) || (Array.isArray(fid.voteAbstainedByFramework) ? fid.voteAbstainedByFramework.length : 1)
|
|
7520
|
+
const who = (Array.isArray(fid.voteAbstainedByFramework) ? fid.voteAbstainedByFramework : [fid.voteAbstainedByFramework]).filter(Boolean).map((x) => x.by || '?').join('/')
|
|
7521
|
+
return k + '×' + n + (who ? '(' + who + ')' : '')
|
|
7522
|
+
})
|
|
7523
|
+
if (enforced.length) L.push('- 框架强制弃权:' + enforced.join('、') + '(同回复记录忠实性缺陷 ⇒ 该成员对该对象的布尔票按弃权计入)')
|
|
7524
|
+
}
|
|
6623
7525
|
L.push('- 可复用库:VibeMath/Formal/{Lib,Proved}/(跨项目)|本所形式化:Formal/|归档证明:Verified/Lean/')
|
|
6624
7526
|
}
|
|
6625
7527
|
L.push('')
|
|
@@ -6640,7 +7542,15 @@ export function apply(ctx) {
|
|
|
6640
7542
|
L.push('')
|
|
6641
7543
|
L.push('## 文件位置')
|
|
6642
7544
|
L.push('- 根目录:' + instRoot())
|
|
6643
|
-
L.push('- 工作目录相对路径(你的文件工具的基准):' + instRootRel() + '/|已确立:Verified/|成员库:Members/<id
|
|
7545
|
+
L.push('- 工作目录相对路径(你的文件工具的基准):' + instRootRel() + '/|已确立:Verified/|成员库:Members/<id>/(成员的库文件路径 = Members/<id>/Progress/progress.md 等;文件工具按会话 cwd 解析 ⇒ 请用完整路径)|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
|
|
7546
|
+
// MEDIUM 7/8 + F9: the two persistence facts an operator must not have to guess — with the
|
|
7547
|
+
// SESSION scope stated (a restart resets both counters) and no raw boolean in the sentence.
|
|
7548
|
+
L.push('- 持久化(本会话计数):写失败 ' + stateWriteFailures + ' 次|首次载入前被读取 ' + prematureReads + ' 次|状态文件已载入:' + (loadSettled ? '是' : '否'))
|
|
7549
|
+
if (lastLoadProblem) L.push('- ⚠ 状态文件问题:' + lastLoadProblem)
|
|
7550
|
+
// G-7 (installer ROUND2): the sandbox fence's root vs the session workspace. A named warning,
|
|
7551
|
+
// not a refusal: v5 cannot change the host's fence, and refusing would stop persisting work.
|
|
7552
|
+
if (sandboxPolicyMismatch) L.push('- ⚠ 沙箱策略根与会话工作目录不一致:策略=' + sandboxPolicyMismatch.policyRoot + '|会话=' + sandboxPolicyMismatch.expected
|
|
7553
|
+
+ '(v5 用会话工作目录拼所有路径;若宿主按策略根解析写操作,产物可能落在预期之外的那棵树——请核对 sandbox 策略的 workspaceRoot)')
|
|
6644
7554
|
return { ok: true, report: L.join('\n'), quorum: qv }
|
|
6645
7555
|
}
|
|
6646
7556
|
// Adding/removing a PERMANENT researcher is a change to the institute's public
|
|
@@ -6648,15 +7558,18 @@ export function apply(ctx) {
|
|
|
6648
7558
|
async function addResearcher(callerId, direction) {
|
|
6649
7559
|
if (!isOffice(callerId)) {
|
|
6650
7560
|
await say(callerId, { to: 'voters', kind: 'voters', text: '提议增聘一名常驻研究员(方向:' + String(direction || '未指定') + ')' })
|
|
6651
|
-
|
|
7561
|
+
// MEDIUM 4: the office is the ONLY actor that can execute this, and `to:'voters'` never
|
|
7562
|
+
// reaches it — the proposal is now addressed to the office as well.
|
|
7563
|
+
await say(callerId, { to: OFFICE_INBOX, kind: 'office-request', text: '请所办裁定:增聘一名常驻研究员(方向:' + String(direction || '未指定') + '|提议人:' + callerId + ')' })
|
|
7564
|
+
return { ok: true, proposed: true, message: '已向所办提议增聘常驻研究员(编制变更需所办批准;所办会在 status/report 的 officeRequests 里看到该提议)' }
|
|
6652
7565
|
}
|
|
6653
7566
|
if (!running || autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
|
|
6654
7567
|
const m = await newMember('researcher', { direction: String(direction || '') })
|
|
6655
7568
|
try {
|
|
6656
7569
|
await spawnMember(m, null)
|
|
6657
7570
|
} catch (e) {
|
|
6658
|
-
await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
|
|
6659
|
-
if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: activationLimitText(hostChildLimit) } }
|
|
7571
|
+
await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e), failReason: isActivationLimitReached(e) ? 'activation-limit' : 'other' }))
|
|
7572
|
+
if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: activationLimitText(hostChildLimit) + '(已登记为待补建:容量释放后框架会在下一次调度轮次自动重试)' } }
|
|
6660
7573
|
return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: String((e && e.message) || e) }
|
|
6661
7574
|
}
|
|
6662
7575
|
await saveChatLine('【编制】所办增聘常驻研究员 ' + m.id + (direction ? '(方向:' + direction + ')' : '') +
|
|
@@ -6698,6 +7611,9 @@ export function apply(ctx) {
|
|
|
6698
7611
|
taskCreate, taskList: listTasks, getTask, taskUpdate, taskAssign, taskPrioritize,
|
|
6699
7612
|
// libraries
|
|
6700
7613
|
publishProgress, recordCard, readLibrary,
|
|
7614
|
+
// path helpers: the tool face advertises the CWD-relative spellings (L1), so they must be
|
|
7615
|
+
// reachable from the registration block too.
|
|
7616
|
+
instRel, instRootRel, formalRootRel,
|
|
6701
7617
|
// Lean formal verification (docs/formal-verification.md)
|
|
6702
7618
|
formalMode, formalOn, formalRecords, formalTodo, formalOf, rebuildLeanLibIndexes,
|
|
6703
7619
|
leanArchive, leanRunTool, writeFormalIndex, writeFormalTodo, recordFidelityDefect,
|
|
@@ -6752,7 +7668,7 @@ export function apply(ctx) {
|
|
|
6752
7668
|
*/
|
|
6753
7669
|
function withCaller(s, x, what, fn) {
|
|
6754
7670
|
const caller = s.officeCaller(x)
|
|
6755
|
-
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: ' + what + ' needs a resolved member id or the office (the session root)' }
|
|
7671
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: ' + what + ' needs a resolved member id or the office (the session root)', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
|
|
6756
7672
|
return fn(caller)
|
|
6757
7673
|
}
|
|
6758
7674
|
|
|
@@ -6860,30 +7776,30 @@ export function apply(ctx) {
|
|
|
6860
7776
|
// not the office either — without this check ANY caller's text was delivered as the office
|
|
6861
7777
|
// (audit L6 follow-up; the tool's own description says "from the office/human").
|
|
6862
7778
|
const caller = s.officeCaller(x)
|
|
6863
|
-
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: relaying a message as the office needs the office (the session root)' }
|
|
7779
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: relaying a message as the office needs the office (the session root)', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
|
|
6864
7780
|
if (caller !== 'office') return { ok: false, code: 'V5_NOT_OFFICE', message: 'vibe_v5_message relays the OFFICE voice; a member speaks with vibe_v5_say' }
|
|
6865
7781
|
const to = String(a.to || 'all')
|
|
6866
7782
|
return s.say('office', { to, text: String(a.content), kind: to === 'all' || to === 'voters' ? 'office' : 'dm' })
|
|
6867
7783
|
})
|
|
6868
7784
|
registerTool('vibe_v5_meeting', 'Convene a meeting (office/academician) or propose one (any other member — relayed to the academician/office). Parked automatically while a verification is in flight. kind picks the meeting type: "sync" = routine coordination (default), "division" = split the work, "verify-request" = ask the group to verify an object, "solve-vote" = put "is the original problem solved?" to a vote; target names the object for verify-request/solve-vote.', objParams({ agenda: S, kind: { type: 'string', enum: ['sync', 'division', 'verify-request', 'solve-vote'] }, target: S }, ['agenda']), (s, a, x) => {
|
|
6869
7785
|
const caller = s.officeCaller(x)
|
|
6870
|
-
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may convene/propose a meeting' }
|
|
7786
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may convene/propose a meeting', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
|
|
6871
7787
|
return s.startMeeting(caller, a)
|
|
6872
7788
|
})
|
|
6873
7789
|
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 }))
|
|
6874
7790
|
registerTool('vibe_v5_hire', 'Hire one temp worker (office; the academician and every permanent researcher may also hire their own). Requires purpose and initial_task. term (optional) is the task deadline/period text the worker is told at onboarding; direction (optional) states what it should work on.', objParams({ purpose: S, initial_task: S, direction: S, term: S, to: S }, ['purpose', 'initial_task']), (s, a, x) => {
|
|
6875
7791
|
const caller = a.to ? String(a.to) : s.officeCaller(x)
|
|
6876
|
-
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may hire' }
|
|
7792
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or a member may hire', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
|
|
6877
7793
|
return s.hire(caller, a)
|
|
6878
7794
|
})
|
|
6879
7795
|
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) => {
|
|
6880
7796
|
const caller = s.officeCaller(x)
|
|
6881
|
-
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or the employer may fire' }
|
|
7797
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: only the office (the session root), the academician or the employer may fire', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
|
|
6882
7798
|
return s.fire(caller, a)
|
|
6883
7799
|
})
|
|
6884
7800
|
registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => {
|
|
6885
7801
|
const caller = s.officeCaller(x)
|
|
6886
|
-
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: adding a permanent researcher is the office\'s decision (the session root)' }
|
|
7802
|
+
if (!caller) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member: adding a permanent researcher is the office\'s decision (the session root)', next: { kind: 'member-call', tool: 'vibe_v5_members', hint: 'call this tool from a member subagent; the office (the session root) can read state with vibe_v5_report / vibe_v5_status' } }
|
|
6887
7803
|
return s.addResearcher(caller, a && a.direction)
|
|
6888
7804
|
})
|
|
6889
7805
|
// "Office only" (the tool's own description) must be ENFORCED, not just documented: the handler
|
|
@@ -6975,26 +7891,36 @@ export function apply(ctx) {
|
|
|
6975
7891
|
parts.push('- 距上次实质进展:' + Math.round(idleFor / 1000) + ' 秒')
|
|
6976
7892
|
return { ok: true, overview: parts.join('\n') }
|
|
6977
7893
|
})
|
|
6978
|
-
registerTool('vibe_v5_assign', '(academician) ASSIGN work: create or pick a task and give it to a specific member (including temp workers), stating WHY and the acceptance criteria. The assignee executes by default and may object with reasons (which are broadcast).', objParams({ task_id: S, subject: S, description: S, to: S, why: S, acceptance: S, priority: I, write_scopes: SA }, ['to', 'why', 'acceptance']), (s, a, x) => withCaller(s, x, 'an assignment', (caller) => s.taskAssign(caller, a)))
|
|
6979
|
-
registerTool('vibe_v5_prioritize', '(academician) Set institute-wide priorities: an ordered list of {task_id, priority} plus WHY. This orders work only — it never changes what is true.', objParams({ order: { type: 'array', items: { type: 'object' } }, why: S }), (s, a, x) => withCaller(s, x, 'setting priorities', (caller) => s.taskPrioritize(caller, a)))
|
|
6980
|
-
registerTool('vibe_v5_nudge', '(academician) Supervise: wake one member with a stated reason and a concrete suggested next step. Prefer a specific next step over a bare "hurry up".', objParams({ to: S, why: S, next_step: S }, ['to', 'why']), (s, a, x) => withCaller(s, x, 'a nudge', (caller) => s.nudge(caller, a)))
|
|
7894
|
+
registerTool('vibe_v5_assign', '(office, or the academician when academicianLeads) ASSIGN work: create or pick a task and give it to a specific member (including temp workers), stating WHY and the acceptance criteria. The assignee executes by default and may object with reasons (which are broadcast).', objParams({ task_id: S, subject: S, description: S, to: S, why: S, acceptance: S, priority: I, write_scopes: SA }, ['to', 'why', 'acceptance']), (s, a, x) => withCaller(s, x, 'an assignment', (caller) => s.taskAssign(caller, a)))
|
|
7895
|
+
registerTool('vibe_v5_prioritize', '(office, or the academician when academicianLeads) Set institute-wide priorities: an ordered list of {task_id, priority} plus WHY. This orders work only — it never changes what is true.', objParams({ order: { type: 'array', items: { type: 'object' } }, why: S }), (s, a, x) => withCaller(s, x, 'setting priorities', (caller) => s.taskPrioritize(caller, a)))
|
|
7896
|
+
registerTool('vibe_v5_nudge', '(office, or the academician when academicianLeads) Supervise: wake one member with a stated reason and a concrete suggested next step. Prefer a specific next step over a bare "hurry up".', objParams({ to: S, why: S, next_step: S }, ['to', 'why']), (s, a, x) => withCaller(s, x, 'a nudge', (caller) => s.nudge(caller, a)))
|
|
6981
7897
|
|
|
6982
7898
|
// ── Lean formal verification (docs/formal-verification.md) ────────────────
|
|
6983
7899
|
// These three tools are registered UNCONDITIONALLY: tool registration is static (a
|
|
6984
7900
|
// dynamic registration would depend on a runtime knob and break the effect discipline),
|
|
6985
7901
|
// while the MODE only decides whether the framework TELLS members about them. In 'off'
|
|
6986
7902
|
// mode they still work if a human or agent calls them deliberately.
|
|
6987
|
-
registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. With leanAsync (default true) the compile is ENQUEUED and this returns immediately with async:{jobId,state} — nothing is compiled yet;
|
|
6988
|
-
registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (only once the queued compile settles ok) also Verified/Lean/<target>.lean, marking the object Lean-passed. Re-archiving IDENTICAL content is de-duplicated (deduped:true, no rewrite/recompile). kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
|
|
7903
|
+
registerTool('vibe_v5_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the workspace and report the result. Parameters: file (required; `Formal/x.lean` or the full CWD-relative path — both name the same file), target (optional object id to record the run against), timeout_ms (optional per-run budget). There is NO run/run=false switch here — run-vs-archive are separate tools; use vibe_v5_lean_archive {run:false} to archive WITHOUT compiling. With leanAsync (default true) the compile is ENQUEUED and this returns immediately with async:{jobId,state} — nothing is compiled yet; the result is durable at Formal/Jobs/<jobId>.json (vibe_v5_lean_job {jobId} reads it), is listed by vibe_v5_lean_lib.jobs, and the initiator also gets ONE 【形式化结果】 announcement in its next round. With leanAsync=false it blocks and returns the compiler output (exitCode/stdout/stderr). Never throws: a host with no subprocess service returns NO_SUBPROCESS and a missing toolchain returns LEAN_NOT_FOUND (in both cases the code can still be written down with vibe_v5_lean_archive), a timeout terminates the process and returns LEAN_TIMEOUT. The framework appends `--search-path <VibeMath root>` before the file name (unless leanArgs already sets a search root).', objParams({ file: S, target: S, timeout_ms: I }, ['file']), (s, a, x) => withCaller(s, x, 'a Lean run', (caller) => s.leanRunTool(caller, a)))
|
|
7904
|
+
registerTool('vibe_v5_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (only once the queued compile settles ok) also Verified/Lean/<target>.lean, marking the object Lean-passed. Re-archiving IDENTICAL content is de-duplicated (deduped:true, no rewrite/recompile). kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required). Optional `run` (default true): whether to COMPILE after writing — `run:false` archives the text only (useful when this host has no toolchain; the object then stays `attempted`/unset until something compiles it).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: S, target: S, content: S, from: S, note: S, run: B }, ['kind']), (s, a, x) => withCaller(s, x, 'a Lean archive', (caller) => s.leanArchive(caller, a)))
|
|
6989
7905
|
registerTool('vibe_v5_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: your institute\'s Formal/Index.md (with an import-dependency column), plus the global cross-project Formal/Lib and Formal/Proved indexes, the background-compile jobs (jobs) and the injected search path (paths.searchPath). Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: B }), async (s, a) => {
|
|
6990
7906
|
const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
|
|
6991
7907
|
const st = s.status()
|
|
6992
7908
|
return {
|
|
6993
7909
|
ok: true, mode: s.formalMode(), rebuilt: !(a && a.refresh === false),
|
|
6994
7910
|
counts: r, todo: s.formalTodo(), jobs: s.leanJobsView(),
|
|
6995
|
-
objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note, async: (s.formalRecords()[k] || {}).async || null })),
|
|
6996
|
-
|
|
6997
|
-
|
|
7911
|
+
objects: Object.keys(s.formalRecords()).map((k) => ({ target: k, status: (s.formalRecords()[k] || {}).status, decision: (s.formalRecords()[k] || {}).decision || '', decisionSource: (s.formalRecords()[k] || {}).decisionSource || '', fidelity: (s.formalRecords()[k] || {}).fidelity || null, abstainedCount: Number(((s.formalRecords()[k] || {}).fidelity || {}).abstainedCount || 0), file: (s.formalRecords()[k] || {}).file, proof: (s.formalRecords()[k] || {}).proof, note: (s.formalRecords()[k] || {}).note, async: (s.formalRecords()[k] || {}).async || null })),
|
|
7912
|
+
// L1/L5/L6 (deep-review 5): ONE basis for every entry (cwd-relative, i.e. what a member's own
|
|
7913
|
+
// file tools resolve), an explicit `toolShortForm` for the institute-relative spelling the
|
|
7914
|
+
// tools also accept, and the bare `Lib/` hint replaced by the real library root.
|
|
7915
|
+
paths: {
|
|
7916
|
+
basis: 'all paths below are relative to the SESSION CWD (what your own file tools use)',
|
|
7917
|
+
project: s.instRel('Formal') + '/', toolShortForm: 'Formal/',
|
|
7918
|
+
lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/',
|
|
7919
|
+
proofs: s.instRel('Verified/Lean') + '/', searchPath: s.leanSearchRootView(),
|
|
7920
|
+
},
|
|
7921
|
+
hint: '复用优先:先在 ' + s.instRel('Formal/Lib') + '/ 里找现成定义(vibe_v5_lean_read {name} 取原文);'
|
|
7922
|
+
+ '新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'。'
|
|
7923
|
+
+ '复用已归档内容:import Formal.Lib.<name> / import Formal.Proved.<name>。同内容重复归档会自动去重。',
|
|
6998
7924
|
verify: st.verify ? st.verify.target : null,
|
|
6999
7925
|
}
|
|
7000
7926
|
})
|
|
@@ -7010,7 +7936,7 @@ export function apply(ctx) {
|
|
|
7010
7936
|
// ── /v5 slash command ────────────────────────────────────────────────────
|
|
7011
7937
|
ctx.effect(() => commands.register({
|
|
7012
7938
|
name: 'v5', description: 'control the Vibe Math V5 research institute',
|
|
7013
|
-
input: { hint: '[configure
|
|
7939
|
+
input: { hint: '[configure <研究所名> <问题…>|start|resume|pause|stop|status|report|members|message <收件人|all> <正文…>|meeting <议程…>|hire <用途> <初始任务…>|fire <成员id> [理由…]|add [方向…]|remove <成员id>|set <键>=<值> …|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]]' },
|
|
7014
7940
|
handler: async function (inv) {
|
|
7015
7941
|
const s = getSession(inv && inv.agent)
|
|
7016
7942
|
if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
|
|
@@ -7042,7 +7968,20 @@ export function apply(ctx) {
|
|
|
7042
7968
|
else if (cmd === 'members') r = { ok: true, members: s.status().members }
|
|
7043
7969
|
else if (cmd === 'message') r = await s.say('office', { to: rest[0] || 'all', text: rest.slice(1).join(' '), kind: 'office' })
|
|
7044
7970
|
else if (cmd === 'meeting') r = await s.startMeeting('office', { agenda: rest.join(' '), kind: 'sync' })
|
|
7045
|
-
|
|
7971
|
+
// C3 (deep-review 4): `/v5 hire X` used to FABRICATE `initial_task` from the purpose, while
|
|
7972
|
+
// the tool path requires both (`hire 必须写明 purpose/initial_task`). The CLI now requires
|
|
7973
|
+
// both too, so the member can never receive a meaningless initial task behind an `ok:true`.
|
|
7974
|
+
else if (cmd === 'hire') {
|
|
7975
|
+
if (!rest.length || !rest.slice(1).join(' ').trim()) {
|
|
7976
|
+
r = {
|
|
7977
|
+
ok: false, code: 'V5_INVALID_ARGUMENT',
|
|
7978
|
+
message: '用法:/v5 hire <用途 purpose> <初始任务 initial_task…>(两者都必填,与 vibe_v5_hire 一致)。'
|
|
7979
|
+
+ '例如:/v5 hire 验算大整数分解的边界情形 用 30 分钟写出 50 位以内合数的试除脚本并归档回执。',
|
|
7980
|
+
}
|
|
7981
|
+
} else {
|
|
7982
|
+
r = await s.hire('office', { purpose: rest[0], initial_task: rest.slice(1).join(' ') })
|
|
7983
|
+
}
|
|
7984
|
+
}
|
|
7046
7985
|
else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
|
|
7047
7986
|
else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
|
|
7048
7987
|
else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
|
|
@@ -7069,15 +8008,31 @@ export function apply(ctx) {
|
|
|
7069
8008
|
r = await s.startPaper('manual', { lang: o.lang, format: o.format, editor: o.editor, force })
|
|
7070
8009
|
} else if (cmd === 'set') {
|
|
7071
8010
|
const upd = {}
|
|
8011
|
+
// C2 (deep-review 4): a token WITHOUT `=` used to be skipped silently, so
|
|
8012
|
+
// `/v5 set mathMode typed` answered `ok:true` and changed NOTHING. Every malformed token
|
|
8013
|
+
// is now refused with the accepted shape, instead of a success that lies.
|
|
8014
|
+
const badTokens = []
|
|
7072
8015
|
for (const tok of rest) {
|
|
7073
8016
|
const eq = tok.indexOf('=')
|
|
7074
|
-
if (eq <= 0) continue
|
|
8017
|
+
if (eq <= 0) { badTokens.push(tok); continue }
|
|
7075
8018
|
const k = tok.slice(0, eq), v = tok.slice(eq + 1)
|
|
7076
8019
|
const n = Number(v)
|
|
7077
8020
|
upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
|
|
7078
8021
|
}
|
|
7079
|
-
|
|
7080
|
-
|
|
8022
|
+
if (badTokens.length) {
|
|
8023
|
+
r = {
|
|
8024
|
+
ok: false, code: 'V5_INVALID_ARGUMENT',
|
|
8025
|
+
message: '这些参数缺少 "="(未被解析):' + badTokens.join('、') + '。正确写法:/v5 set <键>=<值> …(例如 mathMode=typed quorumCap=3 formalVerify=require)',
|
|
8026
|
+
}
|
|
8027
|
+
} else if (!Object.keys(upd).length) {
|
|
8028
|
+
r = {
|
|
8029
|
+
ok: false, code: 'V5_INVALID_ARGUMENT',
|
|
8030
|
+
message: '/v5 set 需要至少一个 <键>=<值>(例如 mathMode=typed)。可用键见 vibe_v5_set 的描述与 /v5 status 的 params。',
|
|
8031
|
+
}
|
|
8032
|
+
} else {
|
|
8033
|
+
r = await s.setParams(upd)
|
|
8034
|
+
}
|
|
8035
|
+
} else r = { ok: false, usage: 'configure <研究所名> <问题…>|start|resume|pause|stop|status|report|members|message <收件人|all> <正文…>|meeting <议程…>|hire <用途> <初始任务…>|fire <成员id> [理由…]|add [方向…]|remove <成员id>|set <键>=<值> …|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]' }
|
|
7081
8036
|
// A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
|
|
7082
8037
|
// CommandResult union distinguishes success from error, and returning 'success' made a rejected
|
|
7083
8038
|
// invocation look identical to a successful one (same fix as v2/v3).
|