dsh-vibe-math 2.7.1 → 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.
Files changed (114) hide show
  1. package/README.en.md +682 -690
  2. package/README.md +19 -27
  3. package/cordis.patch.yml +112 -16
  4. package/docs/AUDIT-CHECKLIST.md +744 -518
  5. package/docs/COMPAT-AUDIT-ROUND2.md +35 -9
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/final-paper.md +172 -172
  8. package/docs/formal-verification.md +588 -588
  9. package/docs/math-computation.md +194 -163
  10. package/docs/parameter-schema.md +37 -0
  11. package/docs/release-notes/README.en.md +49 -0
  12. package/docs/release-notes/README.md +48 -0
  13. package/docs/release-notes/RELEASE-NOTES-2.7.2.en.md +72 -0
  14. package/docs/release-notes/RELEASE-NOTES-2.7.2.md +72 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.7.3.en.md +67 -0
  16. package/docs/release-notes/RELEASE-NOTES-2.7.3.md +67 -0
  17. package/docs/release-notes/TEMPLATE.md +126 -0
  18. package/docs/status-report-fields.md +167 -0
  19. package/docs/test-timing.md +157 -154
  20. package/installer.js +27 -17
  21. package/package.json +55 -3
  22. package/prompt-corpus-persona/persona-corpus.json +9 -9
  23. package/prompt-corpus-persona/persona-corpus.md +225 -33
  24. package/prompt-corpus-v2/formal-verify-v2.json +305 -113
  25. package/prompt-corpus-v2/formal-verify-v2.md +2399 -279
  26. package/prompt-corpus-v3/formal-verify-v3.json +134 -110
  27. package/prompt-corpus-v3/formal-verify-v3.md +409 -141
  28. package/prompt-corpus-v4/formal-verify-v4.json +18 -18
  29. package/prompt-corpus-v4/formal-verify-v4.md +49 -44
  30. package/prompt-corpus-v5/prompt-corpus-v5.json +82 -82
  31. package/prompt-corpus-v5/prompt-corpus-v5.md +513 -259
  32. package/scripts/update-doc-counts.mjs +71 -0
  33. package/tests/audit-artifact-docs.mjs +72 -0
  34. package/tests/audit-artifact-docs.mutants.mjs +58 -0
  35. package/tests/audit-installer-assertions.mutants.mjs +63 -0
  36. package/tests/audit-installer-compat.test.mjs +52 -4
  37. package/tests/audit-installer-policy.test.mjs +41 -8
  38. package/tests/audit-math-computation-contract.mjs +107 -9
  39. package/tests/audit-math-computation-contract.mutants.mjs +106 -0
  40. package/tests/audit-math-computation-parity.mjs +164 -3
  41. package/tests/audit-math-computation-sensitivity.mjs +6 -4
  42. package/tests/audit-package-membership.mjs +57 -0
  43. package/tests/audit-package-membership.mutants.mjs +28 -0
  44. package/tests/audit-participant-set-parity.mjs +160 -0
  45. package/tests/audit-path-discipline.mjs +83 -0
  46. package/tests/audit-path-discipline.mutants.mjs +51 -0
  47. package/tests/audit-persona-sensitivity.mjs +14 -0
  48. package/tests/audit-persona-surface.mutants.mjs +69 -0
  49. package/tests/audit-persona-surface.test.mjs +63 -22
  50. package/tests/audit-prompt-duplication.mjs +143 -0
  51. package/tests/audit-prompt-duplication.mutants.mjs +50 -0
  52. package/tests/audit-prompt-invariants.mjs +43 -5
  53. package/tests/audit-readme-bilingual.test.mjs +152 -0
  54. package/tests/audit-readme-counts.mjs +95 -0
  55. package/tests/audit-readme-counts.mutants.mjs +52 -0
  56. package/tests/audit-registration.mjs +78 -0
  57. package/tests/audit-status-report-fields.mjs +172 -0
  58. package/tests/audit-status-report-fields.mutants.mjs +64 -0
  59. package/tests/audit-v3-registration-parity.mjs +156 -0
  60. package/tests/audit-v5-integrity.mjs +300 -13
  61. package/tests/audit-v5-lean-abstention.mjs +392 -0
  62. package/tests/audit-v5-lean-abstention.mutants.mjs +106 -0
  63. package/tests/audit-v5-prompt-duplication.mjs +88 -0
  64. package/tests/audit-v5-prompt-duplication.mutants.mjs +34 -0
  65. package/tests/audit-v5-sensitivity.mjs +15 -8
  66. package/tests/e2e-identity-a6.mutants.mjs +65 -0
  67. package/tests/e2e-v5-round2.test.mjs +566 -12
  68. package/tests/formal-verify-v2.test.mjs +91 -5
  69. package/tests/formal-verify-v3.mutants.mjs +117 -0
  70. package/tests/formal-verify-v3.test.mjs +85 -6
  71. package/tests/formal-verify-v4.mutants.mjs +152 -0
  72. package/tests/formal-verify-v4.test.mjs +628 -9
  73. package/tests/formal-verify-v5.test.mjs +68 -9
  74. package/tests/helpers/doc-counts-format.mjs +41 -0
  75. package/tests/helpers/math-computation-fake-seam.mjs +4 -0
  76. package/tests/math-computation-a1.mutants.mjs +72 -0
  77. package/tests/math-computation-archive-rerun.mutants.mjs +65 -0
  78. package/tests/math-computation-discovery.mutants.mjs +76 -0
  79. package/tests/math-computation-shared.test.mjs +396 -2
  80. package/tests/math-computation-v2.test.mjs +767 -0
  81. package/tests/math-computation-v3.test.mjs +759 -0
  82. package/tests/math-computation-v4.mutants.mjs +133 -0
  83. package/tests/math-computation-v4.test.mjs +739 -0
  84. package/tests/prompt-v5-integrity.test.mjs +13 -2
  85. package/tests/run-tests.mjs +139 -9
  86. package/tests/run-tests.mutants.mjs +70 -0
  87. package/tests/selfdrive-v5.mjs +53 -1
  88. package/tests/selfdrive-v5.mutants.mjs +132 -0
  89. package/tests/v2-fix-probes.mutants.mjs +100 -0
  90. package/tests/v2-fix-probes.test.mjs +1039 -0
  91. package/tests/v2-list-agents-and-next-step.mutants.mjs +106 -0
  92. package/tests/v3-fix-probes.mutants.mjs +95 -0
  93. package/tests/v3-fix-probes.test.mjs +891 -0
  94. package/tests/v5-institute-fixes.mutants.mjs +190 -0
  95. package/vibe-math-v2/agent.cordis.yml +62 -6
  96. package/vibe-math-v2/math-computation.js +229 -29
  97. package/vibe-math-v2/math-engines.js +12 -12
  98. package/vibe-math-v2/vibe-math-v2.js +458 -119
  99. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +5 -1
  100. package/vibe-math-v3/agent.cordis.yml +72 -6
  101. package/vibe-math-v3/math-computation.js +229 -29
  102. package/vibe-math-v3/math-engines.js +12 -12
  103. package/vibe-math-v3/vibe-math-v3.js +482 -139
  104. package/vibe-math-v4/agent.cordis.yml +44 -8
  105. package/vibe-math-v4/math-computation.js +229 -29
  106. package/vibe-math-v4/math-engines.js +12 -12
  107. package/vibe-math-v4/vibe-math-v4.js +245 -52
  108. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +2 -0
  109. package/vibe-math-v5/agent.cordis.yml +46 -12
  110. package/vibe-math-v5/math-computation.js +229 -29
  111. package/vibe-math-v5/math-engines.js +12 -12
  112. package/vibe-math-v5/vibe-math-v5.js +1307 -243
  113. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1655
  114. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
@@ -86,7 +86,7 @@ const EV = {
86
86
  debate: 'vibe5/debate',
87
87
  verdict: 'vibe5/verdict',
88
88
  queue: 'vibe5/queue',
89
- counters: 'vibe5/counters',
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
- function initState() { return { v: PROJECTION_VERSION, institutes: {}, order: [] } }
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
- const order = state.order.indexOf(key) === -1 ? state.order.concat([key]) : state.order
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 m = d.member
396
- if (!m || typeof m.id !== 'string') return inst
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
- const i = members.findIndex((x) => x.id === m.id)
399
- if (i === -1) members.push(m); else members[i] = m
400
- return Object.assign({}, inst, { members })
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 task = d.task
406
- if (!task || typeof task.id !== 'string') return inst
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
- const i = tasks.findIndex((x) => x.id === task.id)
409
- if (i === -1) tasks.push(task); else tasks[i] = task
410
- return Object.assign({}, inst, { tasks })
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 msg = d.message
416
- if (!msg || typeof msg.id !== 'string') return inst
417
- if (inst.messages.some((x) => x.id === msg.id)) return inst
418
- return Object.assign({}, inst, { messages: inst.messages.concat([msg]) })
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 idx = d.index
445
- if (!idx || typeof idx.id !== 'string') return inst
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
- return Object.assign({}, inst, { meetings: meetings.length > 200 ? meetings.slice(meetings.length - 200) : meetings })
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
- if (d.record === null) delete formal[d.target]
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 ? (inst.todo || []) : (Array.isArray(d.todo) ? d.todo : (inst.todo || []))
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.counters) {
495
- return withInstitute(state, key, (inst) => Object.assign({}, inst, { counters: Object.assign({}, inst.counters, d.counters || {}) }))
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
- return { v: state.v, institutes: state.institutes, order: state.order, diagnostics: diagnostics.slice(-50) }
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
- const parsed = JSON.parse(raw)
545
- if (parsed && parsed.v === PROJECTION_VERSION) { mem = parsed; ok = true }
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) { /* a corrupt/unreadable file is ignored; it is not authoritative */ }
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
- try { await writeTextAbs(pathOf(), JSON.stringify(snapshot, null, 2)) } catch (e) { /* best effort */ }
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,15 +937,99 @@ 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
- try { if (rootAgent && rootAgent.session && rootAgent.session.header && rootAgent.session.header.cwd) return rootAgent.session.header.cwd } catch (e) { /* fall through */ }
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, '/')
770
980
  const projectRoot = () => vibeRoot() + '/Projects/' + project
771
981
  const instRoot = () => projectRoot() + '/Institutes/' + instituteName
982
+ // DEFECT A (3rd self-test): member-facing text used INSTITUTE-relative paths
983
+ // (`Members/<id>/…`), but a member's OWN file tools resolve a relative path against the
984
+ // SESSION CWD — so a member following that text verbatim created `<cwd>/Members/…`, a stray
985
+ // tree outside `VibeMath/Projects/…` (the self-test's `Members/acad/Propos/p-*.md`). Every
986
+ // member-facing path now uses the CWD-relative form (their real baseline); the framework's own
987
+ // writes keep going through `instRoot()`. `instRel()` is the one place that composes it.
988
+ const instRootRel = () => 'VibeMath/Projects/' + project + '/Institutes/' + instituteName
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
+ }
772
1033
 
773
1034
  function getPolicy() {
774
1035
  const sp = sandboxPolicyOf()
@@ -795,7 +1056,9 @@ export function apply(ctx) {
795
1056
  async function writeTextRel(rel, content) { return (await writeTextAbs(instRoot() + '/' + rel, content)) !== undefined }
796
1057
 
797
1058
  function installBackend() {
798
- 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
799
1062
  return backend
800
1063
  }
801
1064
  // The ONE call site that awaits the backend's load (the sensitivity probe for
@@ -809,9 +1072,24 @@ export function apply(ctx) {
809
1072
  // backend's `mem` is still the empty initial state, so a fresh process would report
810
1073
  // an empty roster and `resume` would refuse with "no active member to resume" —
811
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
812
1089
  async function ready() {
813
1090
  if (!backend) installBackend()
814
1091
  await awaitBackendLoad()
1092
+ loadSettled = true
815
1093
  // Crash recovery for the Lean queue runs ONCE per session, after the state file is
816
1094
  // folded in: a leftover Formal/Jobs/*.json is re-queued or explicitly interrupted, and
817
1095
  // is NEVER silently turned into `passed` (docs/formal-verification.md §7-7).
@@ -821,6 +1099,11 @@ export function apply(ctx) {
821
1099
  // The math availability line is derived at session start (and on every tuning that
822
1100
  // touches the six math_* keys); `probeMathEngines` caches per host, so this is cheap.
823
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() */ }
824
1107
  return true
825
1108
  }
826
1109
  // Load the CURRENT state path before its first read. `state()` is synchronous by design
@@ -838,6 +1121,9 @@ export function apply(ctx) {
838
1121
  // not been folded in yet (the 2.4.1 audit's H1: a read-less `state()` was half of the
839
1122
  // overwrite). `ready()`/`commit()` await the same load; here it only has to START.
840
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
841
1127
  stateCache = backend.read()
842
1128
  return stateCache
843
1129
  }
@@ -863,8 +1149,28 @@ export function apply(ctx) {
863
1149
  if (t && pendingLoadNotes.indexOf(t) === -1) pendingLoadNotes.push(t)
864
1150
  if (pendingLoadNotes.length > 5) pendingLoadNotes = pendingLoadNotes.slice(-5)
865
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
+ }
866
1169
  function drainLoadNotes() {
867
- if (!pendingLoadNotes.length || !stateCache) return
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 }
868
1174
  const list = (stateCache.diagnostics || []).concat(pendingLoadNotes.map((t) => ({ at: now(), type: 'vibe5/load', error: t })))
869
1175
  pendingLoadNotes = []
870
1176
  stateCache = Object.assign({}, stateCache, { diagnostics: list.slice(-50) })
@@ -888,6 +1194,12 @@ export function apply(ctx) {
888
1194
  const cur = stateCache.institutes[key]
889
1195
  if (cur) {
890
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'
891
1203
  // Restored params must go through the SAME normalisation as `vibe_v5_set`: a hand-edited
892
1204
  // or foreign-shaped state file (e.g. mathEngines as a comma STRING, a bogus enum, a
893
1205
  // sub-1000 timeout) would otherwise reach `visibleParams()` and v5's own
@@ -905,15 +1217,22 @@ export function apply(ctx) {
905
1217
  const patchInstitute = (patch) => commit(EV.institute, { patch })
906
1218
  const putMember = (member) => commit(EV.member, { member })
907
1219
  const putTask = (task) => commit(EV.task, { task })
908
- const putMessage = (message) => commit(EV.message, { message })
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 || {})
909
1229
  const ackDelivered = (ids) => commit(EV.delivered, { ids })
910
- const putMeeting = (index) => commit(EV.meeting, { index })
911
1230
  const putDebate = (index) => commit(EV.debate, { index })
912
1231
  const putVerdict = (target, record) => commit(EV.verdict, { target, record })
913
1232
  // The verify queue has NO whole-array setter any more: both of its mutations go through
914
1233
  // `appendToQueue`/`takeQueueHead` below, which hand a FUNCTION to the event fold so the
915
- // read-modify-write is atomic (the lost-proposal race).
916
- const putCounters = (counters) => commit(EV.counters, { counters })
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.
917
1236
  const markProgress = async () => {
918
1237
  lastProgressAt = now()
919
1238
  await commit(EV.progress, { at: lastProgressAt, artifactCount: inst().artifactCount })
@@ -924,9 +1243,95 @@ export function apply(ctx) {
924
1243
  const memberById = (id) => inst().members.find((m) => m.id === id)
925
1244
  const voters = () => activeMembers().filter((m) => m.kind === 'academician' || m.kind === 'researcher')
926
1245
  const voterCount = () => voters().length
927
- function quorumM() {
1246
+ function quorumMFrom(vc) {
928
1247
  const cap = Math.max(1, Math.floor(Number(params.quorumCap) || 3))
929
- return Math.max(1, Math.min(cap, voterCount()))
1248
+ return Math.max(1, Math.min(cap, vc))
1249
+ }
1250
+ function quorumM() { return quorumMFrom(voterCount()) }
1251
+ // DEFECT B (3rd self-test): the office's digest showed `m=2` while `status`/`acad` showed
1252
+ // `m=3` in the SAME window — two views had computed the voter set at different moments
1253
+ // (roster-snapshot timing). Every quorum-bearing view now reads ONE live snapshot and carries
1254
+ // its `rosterVersion`, so all views at one point in time are provably identical and a stale
1255
+ // reader can be DETECTED instead of silently disagreeing.
1256
+ function rosterVersion() {
1257
+ const ids = voters().map((m) => String(m.id) + ':' + String(m.phase)).sort()
1258
+ return 'r' + String((inst().members || []).length) + ':' + ids.join(',')
1259
+ }
1260
+ function rosterSnapshot() {
1261
+ const vs = voters()
1262
+ const vc = vs.length
1263
+ const started = vc > 0
1264
+ return {
1265
+ version: rosterVersion(),
1266
+ m: started ? quorumMFrom(vc) : 0,
1267
+ voters: vs.map((m) => m.id),
1268
+ voterCount: vc,
1269
+ started: started,
1270
+ }
1271
+ }
1272
+ // DEFECT 2 (v5 self-test §18): before `start` there are no voters, yet the reported quorum
1273
+ // was `m=1` with `voters=0` — it read like "a quorum of 1 is already there". The REPORTED
1274
+ // quorum is therefore coherent and explicit: `started:false`, `m:0`, `voterCount:0` and the
1275
+ // next step. `quorumM()` itself stays >=1 so the consensus arithmetic is unchanged, and
1276
+ // `judgeVerdict` refuses to conclude at all when there are no voters.
1277
+ function quorumView() {
1278
+ const snap = rosterSnapshot()
1279
+ const started = snap.started
1280
+ const view = {
1281
+ m: snap.m,
1282
+ mode: params.quorumMode,
1283
+ voters: snap.voters,
1284
+ voterCount: snap.voterCount,
1285
+ started: started,
1286
+ phase: started ? 'voting' : 'not-started',
1287
+ rosterVersion: snap.version,
1288
+ }
1289
+ if (!started) {
1290
+ view.next = {
1291
+ kind: 'start', tool: 'vibe_v5_start',
1292
+ hint: '研究所尚未启动:有表决权者 0 人,因此 m 记为 0(不是"已满足 1 票门槛")。先 vibe_v5_start(或 /v5 start)建立名册,m 才有意义。',
1293
+ }
1294
+ }
1295
+ return view
1296
+ }
1297
+ // DEFECT 1 (v5 self-test §18): a member-scoped tool called before the institute exists — or
1298
+ // by nobody identifiable — answered a BARE `V5_MEMBER_NOT_FOUND`, so an operator (or the
1299
+ // model) could not tell WHY it failed or WHAT TO DO FIRST. The error CODE is unchanged (all
1300
+ // existing gates keep matching on it); the answer now names the state and the next step, in
1301
+ // the same `next`-hint shape the `math_computation` tool uses.
1302
+ function memberDiagnosis(what, memberId) {
1303
+ const total = (inst().members || []).length
1304
+ const active = activeMembers().length
1305
+ const who = String(memberId || '')
1306
+ if (active === 0 && !running) {
1307
+ return {
1308
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
1309
+ message: what + '失败:研究所尚未启动(在册 ' + total + ' 名、有表决权者 0 人),没有可归属的成员身份。',
1310
+ next: {
1311
+ kind: 'start', tool: 'vibe_v5_start',
1312
+ hint: '先启动研究所(vibe_v5_start 或 /v5 start)让框架派出常驻成员,再由**成员子代理**调用该工具;所办(会话根)本身不是成员。',
1313
+ },
1314
+ }
1315
+ }
1316
+ if (active === 0) {
1317
+ return {
1318
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
1319
+ message: what + '失败:研究所当前没有在册(active)成员。',
1320
+ next: { kind: 'staff', tool: 'vibe_v5_add_researcher', hint: '先用 vibe_v5_add_researcher / vibe_v5_hire 补充成员,再调用该工具。' },
1321
+ }
1322
+ }
1323
+ if (!who) {
1324
+ return {
1325
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
1326
+ message: what + '失败:无法识别调用者(这是成员工具,所办/会话根没有成员身份)。',
1327
+ next: { kind: 'member-call', tool: 'vibe_v5_members', hint: '请由成员子代理调用;所办可用 vibe_v5_message 转达,或直接用所办工具(vibe_v5_report / vibe_v5_status)。' },
1328
+ }
1329
+ }
1330
+ return {
1331
+ ok: false, code: 'V5_MEMBER_NOT_FOUND',
1332
+ message: what + '失败:名册中没有成员 "' + who + '"(可能已被解雇或尚未就位)。',
1333
+ next: { kind: 'roster', tool: 'vibe_v5_members', hint: '先用 vibe_v5_members 查看当前名册与阶段(active / provisioning / dismissed)。' },
1334
+ }
930
1335
  }
931
1336
  function byChild(childId) { return inst().members.find((m) => m.childId === childId) }
932
1337
  // The live academician's id, or '' when the office founded the institute with
@@ -969,7 +1374,7 @@ export function apply(ctx) {
969
1374
  ].join('\n')
970
1375
  }
971
1376
  const LIB_SPEC = [
972
- ' · Progress/<你>/progress.md —— **你的研究日志**(叙述体,可追加)。',
1377
+ ' · Members/<你>/Progress/progress.md —— **你的研究日志**(叙述体,可追加)。',
973
1378
  ' 主要内容是:尝试过的各方法、路线、历程、进度;当前研究进展/进度;将来的计划与打算;',
974
1379
  ' 及各路线、过程中遇到的障碍及其原因;对各路线、方法的看法、可行性评估;自己研究过程',
975
1380
  ' 中的一些有价值看法、感想、猜想、理解。以及其它各种你认为有价值的值得记录的事物、',
@@ -983,20 +1388,20 @@ export function apply(ctx) {
983
1388
  ' ▸ 写法建议:按时间追加,每次记一小节;把"结论/进展"与"理由/证据"分开写;',
984
1389
  ' 悬而未决的问题明确标出。',
985
1390
  '',
986
- ' · Propos/<你>/<id>.md —— **你的命题/引理**。格式:',
1391
+ ' · Members/<你>/Propos/<id>.md —— **你的命题/引理**。格式:',
987
1392
  ' - ID: p-<id>; - 状态: 未定论; - 概率: <0-1>; - 价值程度: <0-1>; - 动机用途计划: <为何重要/打算怎么用>',
988
1393
  ' 然后 ## 陈述 <完整陈述>;## 证明尝试;## 证伪尝试。',
989
- ' · Methods/<你>/<id>.md —— **你的理论/方法/工具**。格式:',
1394
+ ' · Members/<你>/Methods/<id>.md —— **你的理论/方法/工具**。格式:',
990
1395
  ' - ID: m-<id>; - 状态: 经验; - 可信断言: []; - 价值程度: <0-1>; - 动机用途计划: ...',
991
1396
  ' 然后 ## 核心内容;## 定义与记号;## 应用记录;## 改进历史。',
992
- ' · Subproblems/<你>/<id>.md —— **你的子问题**。格式:',
1397
+ ' · Members/<你>/Subproblems/<id>.md —— **你的子问题**。格式:',
993
1398
  ' - ID: s-<id>; - 状态: 求解中; - 价值程度: <0-1>; - 动机用途计划: ...',
994
1399
  ' 然后 ## 陈述;## 进度。',
995
1400
  '',
996
1401
  ' 三条硬要求:',
997
1402
  ' ① 凡入库必须写明 **价值程度 / 动机用途计划 / 你对该对象为真的概率估计**(缺一不可);',
998
1403
  ' ② **只写自己的库**;读别人的库是允许且被鼓励的;',
999
- ' ③ 推荐**直接用 fs 写你自己的文件**;vibe_v5_record_* 只是便捷记录器,不是必需。',
1404
+ ' ③ 推荐**直接用 fs 写你自己的文件**(路径必须带成员库根,见上:Members/<你>/…);vibe_v5_record_* 只是便捷记录器,不是必需。',
1000
1405
  ].join('\n')
1001
1406
 
1002
1407
  // Computed per hire (a FUNCTION, not a frozen const): the text names the live
@@ -1149,7 +1554,7 @@ export function apply(ctx) {
1149
1554
  L.push(' 2. 只有 Verified/ 目录下的结论(以及成果卡中标注"已验证·真/假"的条目)绝对可信。')
1150
1555
  L.push(' 其余一切——他人的推测、你自己的未验结论、Progress/、Methods/ 里的未验证断言——')
1151
1556
  L.push(' 都只是经验性参考,引用时必须注明"未验证"。')
1152
- L.push(' 3. 任何人可以读任何人的成果库;你只能写自己的库(Members/<你>/)。')
1557
+ L.push(' 3. 任何人可以读任何人的成果库;你只能写自己的库(' + instRel('Members/<你>/') + ')。')
1153
1558
  L.push(' 4. 你写下的有价值内容由你自己判断是否入库,但入库必须写明三项:')
1154
1559
  L.push(' 价值程度、动机用途计划、你自己对"该对象为真"的概率估计。')
1155
1560
  L.push(' 5. 你随时可以在群聊里说话;要单独找人可以私信。需要集体决策就提议开会。')
@@ -1164,8 +1569,10 @@ export function apply(ctx) {
1164
1569
  L.push('')
1165
1570
  // ── 三、libraries ───────────────────────────────────────────────────
1166
1571
  L.push('【三、你的资料库、progress 与卡片格式】')
1167
- L.push(' 你的资料库根目录:Members/' + member.id + '/')
1168
- L.push(' (以下路径都相对该目录。你**只写这里**,但可以读任何人的对应目录。)')
1572
+ L.push(' 你的资料库根目录(**相对会话工作目录**):' + instRel('Members/' + member.id + '/'))
1573
+ L.push(' (`<你>` = 你自己的成员 id:下面每条都已带完整库根,可直接照抄。你**只写自己的库**,但可以读任何人的对应目录。)')
1574
+ L.push(' ⚠ 路径基准:你自己的文件工具的**相对路径以会话工作目录为基准**,所以直接读写文件时必须用**带库根的完整路径**(把下面每条里的 `Members/<你>/` 展开成你自己的 id,例如 Members/acad/Progress/progress.md);')
1575
+ L.push(' 若只想记录成果,直接用 vibe_v5_record_progress / vibe_v5_record_proposition / vibe_v5_record_method / vibe_v5_record_subproblem,框架会写到正确位置。')
1169
1576
  L.push('')
1170
1577
  L.push(LIB_SPEC)
1171
1578
  L.push('')
@@ -1296,14 +1703,18 @@ export function apply(ctx) {
1296
1703
  // only AFTER the subagent had already been started, every member's induction brief
1297
1704
  // named the PREVIOUSLY founded member (the academician was told it was "?"). The
1298
1705
  // model's whole self-model, its library path and its vote were therefore wrong.
1299
- function briefBlock(member) {
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) {
1300
1711
  if (!member || typeof member.id !== 'string' || !member.id) {
1301
1712
  throw v5err('V5_INTERNAL', 'briefBlock: a member is required (a status block must never be built for an unknown identity)')
1302
1713
  }
1303
1714
  const ms = activeMembers()
1304
1715
  const b = []
1305
- b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (rounds.get(member.id) || 0) +
1306
- '|法定票数 m=' + quorumM() + '|有表决权者 ' + voterCount() + ' 人')
1716
+ b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (roundNo !== undefined ? roundNo : (rounds.get(member.id) || 0)) +
1717
+ '|法定票数 m=' + (voterCount() > 0 ? quorumM() : '未启动') + '|有表决权者 ' + voterCount() + ' 人')
1307
1718
  b.push('[在册] ' + (ms.length ? ms.map((x) => x.id).join('、') : '(无)'))
1308
1719
  // Members that are on the books but NOT on the floor. Silently omitting them made a
1309
1720
  // failed provision invisible to the whole institute.
@@ -1335,7 +1746,7 @@ export function apply(ctx) {
1335
1746
  if (t.description) b.push(' ' + String(t.description).split('\n')[0])
1336
1747
  }
1337
1748
  }
1338
- const pending = pendingFor(member.id)
1749
+ const pending = inboxSuppressed.has(member.id) ? [] : pendingFor(member.id)
1339
1750
  if (pending.length) {
1340
1751
  b.push('[新到的消息/通知]')
1341
1752
  for (const p of pending) b.push(' ' + p.line)
@@ -1459,22 +1870,28 @@ export function apply(ctx) {
1459
1870
  let targets
1460
1871
  if (to === 'all' || to === '') targets = activeMembers().filter((m) => m.id !== from)
1461
1872
  else if (to === 'voters') targets = voters().filter((m) => m.id !== from)
1462
- 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 {
1463
1878
  const t = memberById(to)
1464
1879
  if (!t || t.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + to + '" not found' }
1465
1880
  if (t.id === from) return { ok: false, code: 'V5_SELF_MESSAGE', message: 'cannot message yourself' }
1466
1881
  targets = [t]
1467
1882
  }
1468
1883
  if (!targets.length) return { ok: true, delivered: 0, note: 'no other active member' }
1469
- const counters = Object.assign({}, inst().counters)
1470
- let n = Math.max(Number(counters.message) || 0, 0)
1471
1884
  const at = now()
1472
- for (const t of targets) {
1473
- n += 1
1474
- await putMessage({ id: 'msg-' + n, from, to: t.id, kind, text, at })
1475
- }
1476
- counters.message = n
1477
- await putCounters(counters)
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
+ })
1478
1895
  // The office talking to the institute is HALF of the `paperEditor='office'` consultation
1479
1896
  // requirement; counting it here (the one place a message really is queued) means the gate
1480
1897
  // cannot be satisfied by merely intending to consult (docs/final-paper.md §7).
@@ -1484,7 +1901,7 @@ export function apply(ctx) {
1484
1901
  // recipient promptly instead of waiting for the digest window. Plain chat stays
1485
1902
  // batched because deliveryDecision gates it — the kick only starts the pass.
1486
1903
  scheduleNext().catch(() => {})
1487
- 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) }
1488
1905
  }
1489
1906
  // A framework NOTICE to one member. This must NOT be sent as the member itself:
1490
1907
  // `say()` refuses a self-addressed message (V5_SELF_MESSAGE), so the previous
@@ -1558,14 +1975,11 @@ export function apply(ctx) {
1558
1975
  // DSH's "names are immortal" rule) so a re-hired temp can never inherit a
1559
1976
  // dismissed member's archives or task ownership.
1560
1977
  async function newMember(kind, opts) {
1561
- const counters = Object.assign({}, inst().counters)
1562
- let id
1563
- if (kind === 'academician') { counters.academician = Math.max(1, Number(counters.academician) || 0); id = 'acad' }
1564
- else if (kind === 'researcher') { counters.researcher = (Number(counters.researcher) || 0) + 1; id = 'r-' + counters.researcher }
1565
- else { counters.temp = (Number(counters.temp) || 0) + 1; id = 't-' + counters.temp }
1566
- await putCounters(counters)
1567
1978
  const member = {
1568
- id, kind,
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,
1569
1983
  childId: '',
1570
1984
  phase: 'provisioning',
1571
1985
  direction: String((opts && opts.direction) || ''),
@@ -1573,17 +1987,21 @@ export function apply(ctx) {
1573
1987
  term: (opts && opts.term) || '',
1574
1988
  provider: String((opts && opts.provider) || 'spawn'),
1575
1989
  // The charter is FROZEN at hire time — it says "你入职时的在册编制(这是一份**快照**)".
1576
- // It therefore has to be CAPTURED here, not at the first successful spawn: a member
1990
+ // It therefore has to be CAPTURED inside the fold, right after the id is known: a member
1577
1991
  // the host's live-child cap refused is recorded `failed` and only spawned later by
1578
1992
  // `resume`, and rebuilding the charter then would describe the resume-time roster
1579
1993
  // (`memberPersona(member)` would also silently drop any `staffPersona` change).
1580
- persona: memberPersona({ id, kind, direction: String((opts && opts.direction) || ''), hiredBy: (opts && opts.hiredBy) || '' }),
1994
+ persona: '',
1581
1995
  error: '',
1582
1996
  createdAt: now(),
1583
1997
  dismissedAt: 0,
1584
1998
  dismissReason: '',
1585
1999
  }
1586
- await putMember(member)
2000
+ await commit(EV.member, { make: (alloc) => {
2001
+ member.id = alloc.next(kind)
2002
+ member.persona = memberPersona(member)
2003
+ return member
2004
+ } })
1587
2005
  return member
1588
2006
  }
1589
2007
  function memberPersona(member) {
@@ -1677,15 +2095,18 @@ export function apply(ctx) {
1677
2095
  wakeKind.set(member.id, kind)
1678
2096
  currentMember = member.id
1679
2097
  lastActiveAt.set(member.id, now())
1680
- rounds.set(member.id, (rounds.get(member.id) || 0) + 1)
1681
- roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
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
1682
2103
  // The charter is FROZEN at hire time (it is the durable "seal" record and it says
1683
2104
  // "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
1684
2105
  // hire-time snapshot into a resume-time one and make the sentence untrue. `newMember`
1685
2106
  // captures it, so every member has one by the time it can be spawned.
1686
2107
  const persona = member.persona || memberPersona(member)
1687
2108
  member.persona = persona
1688
- const prompt = initialPrompt(member, initialTask, mode)
2109
+ const prompt = initialPrompt(member, initialTask, mode, startRound)
1689
2110
  await putMember(member)
1690
2111
  await mkdirs()
1691
2112
  await writeRosterMirror()
@@ -1707,10 +2128,11 @@ export function apply(ctx) {
1707
2128
  // Roll the in-memory marks back so a failed provisioning leaves no phantom
1708
2129
  // "busy, round 1" member behind; the member record itself goes to `failed` and
1709
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.
1710
2134
  busy.delete(member.id)
1711
2135
  wakeKind.delete(member.id)
1712
- rounds.delete(member.id)
1713
- roundsSinceCompact.delete(member.id)
1714
2136
  // The host's live-child cap is a HOST limit (maxActiveSubagents on the `subagent` row), not
1715
2137
  // a defect in this member: remember the ceiling, report it BY NAME instead of relaying the
1716
2138
  // opaque host string, and throw the TYPED error so the founding loop / hire / addResearcher
@@ -1718,12 +2140,16 @@ export function apply(ctx) {
1718
2140
  const limitHit = isActivationLimitReached(e)
1719
2141
  if (limitHit) { noteChildLimit(e); noteSpawnLimitOnce(member) }
1720
2142
  const message = limitHit ? activationLimitText(hostChildLimit) : String((e && e.message) || e)
1721
- 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' }))
1722
2144
  if (limitHit) throw v5err('ACTIVATION_LIMIT_REACHED', message)
1723
2145
  throw e
1724
2146
  }
1725
2147
  member.childId = started.childId
1726
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)
1727
2153
  // Register the FOUNDING turn as in-flight, exactly like a normal wake does.
1728
2154
  // Without this the child's first `subagent/end` has no token to match, so
1729
2155
  // onMemberEnd would ignore it: the founding round would never be processed and
@@ -1748,8 +2174,11 @@ export function apply(ctx) {
1748
2174
  wakeKind.set(member.id, kind || 'normal')
1749
2175
  currentMember = member.id
1750
2176
  lastActiveAt.set(member.id, now())
1751
- rounds.set(member.id, (rounds.get(member.id) || 0) + 1)
1752
- roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
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
1753
2182
  // Context directives. TWO distinct needs, and confusing them is what made
1754
2183
  // '[核心规则重申]+[CONTEXT COMPACT]' repeat at the head of nearly every prompt
1755
2184
  // (v4 §24.1-③):
@@ -1766,25 +2195,31 @@ export function apply(ctx) {
1766
2195
  // are excluded because their replies are a different shape, and a directive there
1767
2196
  // could never be acknowledged.
1768
2197
  const wake = kind || 'normal'
2198
+ let softInjected = false
1769
2199
  if (wake === 'normal' || wake === 'checkpoint') {
1770
2200
  const soft = (contextPct.get(member.id) || 0) >= Number(params.compactThreshold) ||
1771
- (roundsSinceCompact.get(member.id) || 0) >= Number(params.compactAfterRounds)
2201
+ nextRoundsSinceCompact >= Number(params.compactAfterRounds)
1772
2202
  if (soft) {
1773
- prompt = CORE_RULES + '\n[CONTEXT COMPACT — 你的对话已接近上限。不要重新推导历史。\n' +
2203
+ prompt = coreRules() + '\n[CONTEXT COMPACT — 你的对话已接近上限。不要重新推导历史。\n' +
1774
2204
  '请把当前工作状态浓缩成一段自述(已有发现、当前方向、已记录的关键成果、下一步具体动作、未决问题),' +
1775
2205
  '然后照常以 JSON 回答本轮。请在回复里填 "contextPct": 15 与 "compacted": true。]\n\n' + prompt
1776
- // Reset the counter WITH the injection so the directive cannot repeat on the
1777
- // very next round even if the member forgets to report `compacted`.
1778
- roundsSinceCompact.set(member.id, 0)
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
1779
2209
  }
1780
2210
  }
1781
2211
  if (needReanchor.has(member.id)) {
1782
- prompt = CORE_RULES + '\n' + prompt
2212
+ prompt = coreRules() + '\n' + prompt
1783
2213
  needReanchor.delete(member.id)
1784
2214
  }
1785
2215
  try {
1786
2216
  if (typeof subagents.sendMessage !== 'function') throw new Error('no subagents.sendMessage continuation API')
1787
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)
1788
2223
  return true
1789
2224
  } catch (e) {
1790
2225
  console.error('vibe-math-v5: wake ' + member.id + ' failed: ' + String((e && e.message) || e))
@@ -1841,6 +2276,12 @@ export function apply(ctx) {
1841
2276
  function replySpec(kind) {
1842
2277
  const L = []
1843
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
+ + '**你真正要输出的对象必须严格合法**,最小形态见目录末尾的样例。)')
1844
2285
  L.push('{')
1845
2286
  L.push(' "say": "你想对全所说的话(群聊)" 或 {"to":"r-2","text":"…"}(私信) 或 {"to":"voters","text":"…"}(只对表决者),')
1846
2287
  L.push(' "progress": "本轮进展叙述(会被追加到你的 Progress/progress.md)",')
@@ -1861,7 +2302,7 @@ export function apply(ctx) {
1861
2302
  L.push(' "prioritize": {"order":[{"task_id":"t-1","priority":2}],"why":"…"} ← 设定全所优先级,')
1862
2303
  L.push(' "nudge": {"to":"r-2","why":"为何督办","next_step":"建议的具体下一步"},')
1863
2304
  }
1864
- L.push(' "task_create": {"subject":"…","description":"…","blocked_by":["t-1"],"write_scopes":["Members/r-1/Propos"]},')
2305
+ L.push(' "task_create": {"subject":"…","description":"…","blocked_by":["t-1"],"write_scopes":["' + instRel('Members/r-1/Propos') + '"]},')
1865
2306
  L.push(' "task_claim": "t-3",')
1866
2307
  L.push(' "task_done": "t-3",')
1867
2308
  L.push(' "task_update": {"task_id":"t-3","expected_revision":2,"action":"complete|release|reopen|edit|set_dependencies|delete"},')
@@ -1876,22 +2317,37 @@ export function apply(ctx) {
1876
2317
  L.push(' 全体表决者(任务仍会执行,但你的理由不会被埋掉),')
1877
2318
  if (kind !== 'temp') {
1878
2319
  L.push(' "hire": {"purpose":"…","initial_task":"…","direction":"…"} ← 雇佣一名临时工(说明用途与初始任务),')
1879
- L.push(' "fire": {"id":"t-2","reason":"…"} ← 解雇(雇主/院士;你只能解雇你雇的),')
1880
- }
1881
- L.push(' "vote_solved": true|false, ← 你是否认为**原问题已解决**(会议/结题表决用;必须诚实)')
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 时才会停止。')
1882
2332
  L.push(' "solved": false, ← 你这一轮的个人判断(框架据此了解全所收敛度)')
1883
2333
  L.push(' "contextPct": 40, ← 你当前上下文的占用百分比(0-100)')
1884
2334
  L.push(' "compacted": false ← 若框架要求你压缩,填 true 并在 progress 里写下浓缩后的工作状态')
1885
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('```')
1886
2342
  return L.join('\n')
1887
2343
  }
1888
2344
  // Every prompt builder below passes the member it is addressing. There is
1889
2345
  // deliberately NO fallback to "the last member we happened to touch": guessing the
1890
2346
  // identity is what produced the wrong-identity briefs in the first place.
1891
- function stateBlock(member) {
1892
- return briefBlock(member)
2347
+ function stateBlock(member, roundNo) {
2348
+ return briefBlock(member, roundNo)
1893
2349
  }
1894
- function initialPrompt(member, initialTask, mode) {
2350
+ function initialPrompt(member, initialTask, mode, roundNo) {
1895
2351
  const L = []
1896
2352
  const resume = mode === 'resume'
1897
2353
  L.push(resume
@@ -1912,7 +2368,7 @@ export function apply(ctx) {
1912
2368
  if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
1913
2369
  mathPushLine(L)
1914
2370
  L.push('------------')
1915
- L.push(stateBlock(member))
2371
+ L.push(stateBlock(member, roundNo))
1916
2372
  L.push('------------')
1917
2373
  L.push(replySpec(member.kind))
1918
2374
  return L.join('\n')
@@ -1967,6 +2423,14 @@ export function apply(ctx) {
1967
2423
  L.push('')
1968
2424
  } else {
1969
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 并同步到群聊。)')
1970
2434
  L.push('')
1971
2435
  }
1972
2436
  L.push('请就议程发表你的意见。分工、优先级、下一步做什么、是否认为原问题已解决,都可以说。')
@@ -2124,6 +2588,11 @@ export function apply(ctx) {
2124
2588
  const leanInitiative = () => (['off', 'normal', 'eager'].indexOf(String(params.leanInitiative)) !== -1 ? String(params.leanInitiative) : 'normal')
2125
2589
  const leanDailyOn = () => leanInitiative() !== 'off' && (formalOn() || leanInitiative() === 'eager')
2126
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'
2127
2596
  const formalLibRoot = () => vibeRoot() + '/Formal/Lib'
2128
2597
  const formalProvedRoot = () => vibeRoot() + '/Formal/Proved'
2129
2598
  const verifiedLeanRoot = () => instRoot() + '/Verified/Lean'
@@ -2168,7 +2637,14 @@ export function apply(ctx) {
2168
2637
  function leanAbsPath(rel) {
2169
2638
  const raw = String(rel == null ? '' : rel).trim()
2170
2639
  if (!raw) return null
2171
- const abs = (raw.charAt(0) === '/' || /^[a-z]:/i.test(raw)) ? raw : instRoot() + '/' + raw.replace(/^\.\//, '')
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
2172
2648
  const norm = normalizeAbsPath(abs)
2173
2649
  const root = normalizeAbsPath(vibeRoot())
2174
2650
  if (norm !== root && norm.indexOf(root + '/') !== 0) return null
@@ -2273,18 +2749,24 @@ export function apply(ctx) {
2273
2749
  async function formalSetRun(target, run) {
2274
2750
  const t = idSafe(String(target || ''))
2275
2751
  if (!t) return
2276
- const prev = formalOf(t)
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
+ //
2277
2756
  // Running a file NEVER changes an already-decided status: a green run does not by
2278
2757
  // itself make an object `passed` (only archiving a proof does), and a failing scratch
2279
2758
  // run must not silently erase a recorded `passed`/`blocked` decision. Everything else
2280
2759
  // becomes `attempted`, which is the honest "we tried, see the compiler output" state.
2281
- const keep = (prev.status === 'passed' || prev.status === 'blocked') ? prev.status : 'attempted'
2282
- await putFormal(t, Object.assign({}, prev, {
2283
- status: keep,
2284
- file: run.file || prev.file || '',
2285
- 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) },
2286
- updatedAt: now(),
2287
- }))
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
+ })
2288
2770
  }
2289
2771
  // Withdrawing an archived proof means the file must stop looking like the object's proof.
2290
2772
  // The POLICY-COMPLIANT route is an overwrite through the injected `fs` service (it carries
@@ -2321,16 +2803,25 @@ export function apply(ctx) {
2321
2803
  const why = String(note || '').trim()
2322
2804
  if (!t) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'target is required' }
2323
2805
  if (!why) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: "decision='defect' 必须写明 note(具体偏差)" }
2324
- const prev = formalOf(t)
2325
- const proofRel = String(prev.proof || ('Verified/Lean/' + t + '.lean'))
2326
- await putFormal(t, Object.assign({}, prev, {
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' }, {
2327
2816
  status: 'attempted', proof: '', decision: 'defect', note: why,
2328
- fidelity: { at: now(), by: String(memberId || ''), note: why }, updatedAt: now(),
2329
- }))
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
+ })
2330
2824
  const removed = await removeArchivedProof(proofRel)
2331
- const list = formalTodo().filter((x) => x.id !== t)
2332
- list.push({ id: t, at: now(), why: '形式化不合格(忠实性缺陷):' + why })
2333
- await putFormal(t, formalOf(t), list)
2334
2825
  await writeFormalTodo()
2335
2826
  await writeFormalIndex()
2336
2827
  // `removeArchivedProof` can fail on a host whose shell cannot delete AND whose
@@ -2560,6 +3051,12 @@ export function apply(ctx) {
2560
3051
  file: job.rel,
2561
3052
  proof: settledOk ? 'Verified/Lean/' + target + '.lean' : '',
2562
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',
2563
3060
  note: (String(prev.note || '') + stale).trim(),
2564
3061
  run: job.run, async: asyncRec, updatedAt: now(),
2565
3062
  })
@@ -2603,6 +3100,14 @@ export function apply(ctx) {
2603
3100
  // (the "announcement point is the next prompt" discipline).
2604
3101
  function takeLeanNoticesFor(memberId) {
2605
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
+ }
2606
3111
  const mine = leanNotices.filter((n) => n.member === memberId)
2607
3112
  if (!mine.length) return []
2608
3113
  for (const n of mine) { const i = leanNotices.indexOf(n); if (i !== -1) leanNotices.splice(i, 1) }
@@ -2994,7 +3499,12 @@ export function apply(ctx) {
2994
3499
  L.push(' · 形式化只写你有把握的版本;没把握就记 blocked 并写清难点——不要用形式化掩盖不确定。')
2995
3500
  L.push(' · 该对象若已有后台编译在队列中,**不得**在它落地前声称已通过或走忠实性审查;等 vibe_v5_lean_lib 显示 passed 再审。')
2996
3501
  L.push(' · 工具:vibe_v5_lean_run(执行)· vibe_v5_lean_archive(归档)· vibe_v5_lean_lib(查已有可复用库)')
2997
- L.push(' · 工作目录:Formal/(相对研究所根);可复用定义放 ' + formalLibRoot().replace(/\\/g, '/') + '/,')
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, '/') + '/,')
2998
3508
  L.push(' 已证引理放 ' + formalProvedRoot().replace(/\\/g, '/') + '/;写之前先 vibe_v5_lean_lib 查重。')
2999
3509
  L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义 / 对象 / 条件 / 假设 / 结论是否与')
3000
3510
  L.push(' 命题原文逐条一致。请把注意力放在这种核对上,而不是重新做一遍推导。')
@@ -3129,7 +3639,9 @@ export function apply(ctx) {
3129
3639
  return {
3130
3640
  ok: true, async: { jobId: job.jobId, state: 'queued' }, jobId: job.jobId,
3131
3641
  file: pre.rel, target: target || undefined,
3132
- message: '已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在下一轮提示里公告;也可用 vibe_v5_lean_lib 的 jobs 字段随时查看。',
3642
+ message: '已入队后台编译;你可以继续工作。结果会写入 Formal/ 与索引,并在**你**下一轮提示里公告一次;'
3643
+ + '长期查询用 vibe_v5_lean_job {jobId} 或 Formal/Jobs/<jobId>.json(vibe_v5_lean_lib 的 jobs 字段也会列出)。'
3644
+ + '注意:公告只投给发起者、且重启后不再补发,所以别的成员(或重启后的你)请用上面两条长期路径查看。',
3133
3645
  }
3134
3646
  }
3135
3647
  const run = await leanRunFile(rel, args.timeout_ms)
@@ -3255,7 +3767,7 @@ export function apply(ctx) {
3255
3767
  // Dedupe (§5.3): the object is already `passed` AND the work file still holds EXACTLY
3256
3768
  // this content ⇒ the compile result is already known: skip rewrite + recompile.
3257
3769
  if (prev.status === 'passed' && (await leanHashFile(workRel)) === sha) {
3258
- 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: '内容与已归档版本一致,跳过重写与重编译' }
3259
3771
  }
3260
3772
  if (!await writeTextRel(workRel, body)) return { ok: false, code: 'V5_WRITE_FAILED', message: 'could not write ' + workRel }
3261
3773
  if (params.leanAsync !== false) {
@@ -3280,7 +3792,7 @@ export function apply(ctx) {
3280
3792
  await rebuildLeanLibIndexes()
3281
3793
  await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
3282
3794
  + '(已入队后台编译;落地为“通过”之前**不会**写 Verified/Lean/、也不会转为忠实性审查)')
3283
- 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/ 并转为忠实性审查' }
3284
3796
  }
3285
3797
  // No toolchain: fall through to the synchronous path (honest NO_SUBPROCESS record).
3286
3798
  }
@@ -3306,7 +3818,7 @@ export function apply(ctx) {
3306
3818
  await rebuildLeanLibIndexes()
3307
3819
  await saveChatLine('【形式化】' + memberId + ' 为 ' + target + ' 归档形式化证明 ' + workRel
3308
3820
  + '(运行 ' + (passed ? '**通过**,已归档到 ' + rec.proof + ',验证转为忠实性审查' : '**未通过**:' + tail(run.stderr || run.message, 160)) + ')')
3309
- 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) }
3310
3822
  }
3311
3823
  if (kind === 'blocked') {
3312
3824
  const target = idSafe(String(args.target || ''))
@@ -3328,9 +3840,12 @@ export function apply(ctx) {
3328
3840
  // A SHORT core-rules recap, injected ONLY (a) right after a REAL compaction, or
3329
3841
  // (b) in the same wake as a soft-compact directive — never on every round. The
3330
3842
  // charter itself lives in `persona` and needs no reinforcement otherwise.
3331
- const CORE_RULES = '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
3843
+ // A function, not a frozen string: the member-facing library path must reflect the LIVE
3844
+ // project/institute (DEFECT A) — a path captured at session creation could point at the
3845
+ // default project after a `configure`.
3846
+ const coreRules = () => '[核心规则] 只有 Verified/(及标记"已验证·真/假"的卡片)算已确立;' +
3332
3847
  '任何对象要进 Verified/,必须至少有 m 名有表决权者投出布尔值(恰好 1 或恰好 0)**且没有任何一张反向票**,否则留库附平均概率;' +
3333
- '你只写自己的库(Members/<你>/),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
3848
+ '你只写自己的库(' + instRel('Members/<你>/') + '——相对**会话工作目录**),可只读任何人的库;组织与分派由院士负责,但判断属于你自己;' +
3334
3849
  '退出时只输出一个 JSON 对象。'
3335
3850
 
3336
3851
  // ONE place accounts for context usage on EVERY reply (normal, meeting, verify,
@@ -3377,7 +3892,7 @@ export function apply(ctx) {
3377
3892
  return n
3378
3893
  }
3379
3894
  async function publishProgress(memberId, text) {
3380
- if (!memberId || !memberById(memberId)) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
3895
+ if (!memberId || !memberById(memberId)) return memberDiagnosis('记录研究进度(vibe_v5_record_progress)', memberId)
3381
3896
  if (!String(text || '').trim()) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'empty progress' }
3382
3897
  const rel = 'Members/' + memberId + '/Progress/progress.md'
3383
3898
  const prev = (await readTextRel(rel)) || ''
@@ -3390,7 +3905,7 @@ export function apply(ctx) {
3390
3905
  // hard requirements. Missing fields are refused rather than silently defaulted,
3391
3906
  // so the libraries keep their meaning.
3392
3907
  async function recordCard(memberId, kind, o) {
3393
- if (!memberId || !memberById(memberId)) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member' }
3908
+ if (!memberId || !memberById(memberId)) return memberDiagnosis('记录成果卡片(vibe_v5_record_proposition/method/subproblem)', memberId)
3394
3909
  const args = o || {}
3395
3910
  const missing = []
3396
3911
  if (args.value === undefined || args.value === null) missing.push('value(价值程度)')
@@ -3465,10 +3980,9 @@ export function apply(ctx) {
3465
3980
  }
3466
3981
 
3467
3982
  // ---- task board (compare-and-set DAG, ported from DSH agent-teams) ----
3468
- function nextTaskId() {
3469
- const c = Number(inst().counters.task) || 0
3470
- return { id: 't-' + (c + 1), n: c + 1 }
3471
- }
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.
3472
3986
  // DAG validation: self-reference, duplicates, and missing/deleted blockers are
3473
3987
  // refused up front; a cycle is detected over the WHOLE candidate graph, exactly
3474
3988
  // like the DSH original, so a bad dependency can never be stored.
@@ -3516,11 +4030,8 @@ export function apply(ctx) {
3516
4030
  if (scopes.indexOf(n) === -1) scopes.push(n)
3517
4031
  }
3518
4032
  const blockedBy = (args.blocked_by || args.blockedBy || []).map(String)
3519
- const { id, n } = nextTaskId()
3520
- validateDeps(id, blockedBy)
3521
- const counters = Object.assign({}, inst().counters); counters.task = n
3522
4033
  const task = {
3523
- id, revision: 1, subject, description,
4034
+ id: '', revision: 1, subject, description,
3524
4035
  status: 'pending', ownerId: '',
3525
4036
  blockedBy, writeScopes: scopes,
3526
4037
  priority: Number.isFinite(Number(args.priority)) ? Number(args.priority) : 0,
@@ -3528,8 +4039,17 @@ export function apply(ctx) {
3528
4039
  assignedBy: '', why: '', acceptance: '',
3529
4040
  createdAt: now(), updatedAt: now(),
3530
4041
  }
3531
- await putCounters(counters)
3532
- await putTask(task)
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
3533
4053
  await writeTaskboardMirror()
3534
4054
  await markProgress()
3535
4055
  notifyActivity()
@@ -3549,11 +4069,14 @@ export function apply(ctx) {
3549
4069
  if (!t) throw v5err('V5_TASK_NOT_FOUND', 'task ' + id + ' not found')
3550
4070
  return taskView(t)
3551
4071
  }
3552
- async function taskUpdate(memberId, o) {
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) {
3553
4076
  // An unknown caller must be refused HERE as well: `owner = task.ownerId === memberId` is true
3554
4077
  // for an UNOWNED task (ownerId '') when memberId is '', so without this guard an
3555
4078
  // unidentifiable caller would count as the owner of every unclaimed task (audit L6 follow-up).
3556
- if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
4079
+ if (!memberId) return memberDiagnosis('修改任务(vibe_v5_task_update)', memberId)
3557
4080
  const args = o || {}
3558
4081
  const id = String(args.task_id || args.taskId || '')
3559
4082
  const task = inst().tasks.find((x) => x.id === id)
@@ -3562,7 +4085,10 @@ export function apply(ctx) {
3562
4085
  const expected = Number(args.expected_revision !== undefined ? args.expected_revision : args.expectedRevision)
3563
4086
  if (!Number.isFinite(expected)) return { ok: false, code: 'V5_INVALID_ARGUMENT', message: 'expected_revision is required' }
3564
4087
  if (expected !== task.revision) {
3565
- return { ok: false, code: 'V5_TASK_STALE_REVISION', message: 'task ' + id + ' is at revision ' + task.revision + ', not ' + expected + ' — re-read it with vibe_v5_task_get' }
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)' }
3566
4092
  }
3567
4093
  const action = String(args.action || '')
3568
4094
  const office = isOffice(memberId)
@@ -3637,6 +4163,15 @@ export function apply(ctx) {
3637
4163
  } catch (e) {
3638
4164
  return { ok: false, code: e.code || 'V5_INVALID_ARGUMENT', message: String((e && e.message) || e) }
3639
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
+ }
3640
4175
  next.revision = task.revision + 1
3641
4176
  next.updatedAt = now()
3642
4177
  await putTask(next)
@@ -3650,7 +4185,7 @@ export function apply(ctx) {
3650
4185
  // it can never make any statement true, and the assignee may object with reasons
3651
4186
  // (the objection is broadcast, not silently swallowed).
3652
4187
  async function taskAssign(memberId, o) {
3653
- if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
4188
+ if (!memberId) return memberDiagnosis('分派任务(vibe_v5_assign)', memberId)
3654
4189
  if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
3655
4190
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can assign tasks' }
3656
4191
  }
@@ -3677,12 +4212,12 @@ export function apply(ctx) {
3677
4212
  if (cur && (cur.blockedBy || []).length && !taskReady(cur)) {
3678
4213
  return { ok: false, code: 'V5_TASK_BLOCKED', message: 'task ' + taskId + ' still has incomplete blockers' }
3679
4214
  }
3680
- 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 })
3681
4217
  if (!r.ok) return r
3682
- const after = inst().tasks.find((t) => t.id === taskId)
3683
- const withMeta = Object.assign({}, after, { assignedBy: isOffice(memberId) ? 'office' : memberId, why, acceptance })
3684
- await putTask(withMeta)
3685
- 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
3686
4221
  const assignerIsOffice = isOffice(memberId)
3687
4222
  await say(assignerIsOffice ? 'office' : memberId, {
3688
4223
  to, kind: 'assign',
@@ -3694,7 +4229,7 @@ export function apply(ctx) {
3694
4229
  return { ok: true, task: taskView(withMeta) }
3695
4230
  }
3696
4231
  async function taskPrioritize(memberId, o) {
3697
- if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
4232
+ if (!memberId) return memberDiagnosis('设置任务优先级(vibe_v5_task_prioritize)', memberId)
3698
4233
  if (!isOffice(memberId) && !(isAcademician(memberId) && params.academicianLeads)) {
3699
4234
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can set priorities' }
3700
4235
  }
@@ -3746,8 +4281,8 @@ export function apply(ctx) {
3746
4281
  const s = inst()
3747
4282
  const lines = ['# 研究所编制表(人读镜像)|' + instituteName + '|' + fmtTime(), '',
3748
4283
  '> 权威状态在 State/<研究所>.v5state.json 里;本文件只是快照,勿手改。', '']
3749
- lines.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
3750
- lines.push('- 阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
4284
+ lines.push('- 求真门槛:' + (voterCount() > 0 ? 'm = ' + quorumM() : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
4285
+ lines.push('- ' + phaseFactLine())
3751
4286
  lines.push('')
3752
4287
  lines.push('| 代号 | 职位 | 状态 | 雇主 | 方向/用途 | 轮次 | 上下文% |')
3753
4288
  lines.push('|---|---|---|---|---|---|---|')
@@ -3873,6 +4408,9 @@ export function apply(ctx) {
3873
4408
  }
3874
4409
  const mean = all.length ? all.reduce((a, x) => a + x, 0) / all.length : 0.5
3875
4410
  const base = { m, P, bTrue, bFalse, abstain, mean, votedCount: all.length, voters: E }
4411
+ // DEFECT 2 hardening: with NO voters there is nothing to conclude — every `>= m` test
4412
+ // below would be trivially satisfiable if m were ever 0. Consensus needs voters.
4413
+ if (P === 0) return Object.assign(base, { outcome: 'undecided', reason: 'no voters: the institute has no voting members yet' })
3876
4414
  if (params.quorumMode === 'all-unanimous') {
3877
4415
  const allVoted = E.length > 0 && E.every((id) => votes[id])
3878
4416
  if (!allVoted) return Object.assign(base, { outcome: 'undecided', reason: 'not every voter has voted' })
@@ -4068,16 +4606,27 @@ export function apply(ctx) {
4068
4606
  // reason, put the object on the formalization TODO, and say so in the group chat. The
4069
4607
  // object keeps its mean probability and its debate record, so nothing is lost.
4070
4608
  async function deferForFormal(vs, j, isTrue) {
4071
- const rec = formalOf(vs.target)
4072
- const why = 'formal-required:尚未取得 Lean 形式化通过,也没有显式阻塞记录(当前状态 ' + (rec.status || 'none') + ')'
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') + ')'
4073
4614
  await writeDebateDoc(vs, false, j)
4074
4615
  await putVerdict(vs.target, Object.assign({}, vs, {
4075
4616
  closed: true, outcome: 'undecided', reason: why, mean: j.mean, formalDeferred: true,
4076
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,
4077
4622
  }))
4078
- const list = formalTodo().filter((t) => t.id !== vs.target)
4079
- list.push({ id: vs.target, at: now(), why, verdict: isTrue ? 1 : 0 })
4080
- await putFormal(vs.target, rec.status === 'none' ? { status: 'none', deferredAt: now() } : rec, list)
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
+ })
4081
4630
  await writeFormalTodo()
4082
4631
  await writeFormalIndex()
4083
4632
  await saveChatLine('【形式化】' + vs.target + ' 的表决结果为 ' + (isTrue ? '真' : '假')
@@ -4095,6 +4644,10 @@ export function apply(ctx) {
4095
4644
  await putVerdict(vs.target, Object.assign({}, vs, {
4096
4645
  closed: true, outcome: 'undecided', reason: j.reason, mean: j.mean,
4097
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,
4098
4651
  }))
4099
4652
  await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: 'undecided' })
4100
4653
  await saveChatLine('【求真表决】' + vs.target + ' 未达门槛(' + j.reason + ');留库为未定论,平均概率 ' +
@@ -4112,6 +4665,10 @@ export function apply(ctx) {
4112
4665
  await putVerdict(vs.target, Object.assign({}, vs, {
4113
4666
  closed: true, outcome: isTrue ? 'true' : 'false', mean: isTrue ? 1 : 0,
4114
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,
4115
4672
  }))
4116
4673
  await putDebate({ target: vs.target, at: now(), file: 'Shared/Debates/' + vs.target + '.md', outcome: isTrue ? 'true' : 'false' })
4117
4674
  await saveChatLine('【求真结论】' + vs.target + ' 经 ' + (isTrue ? j.bTrue : j.bFalse) + ' 名有表决权者一致判' +
@@ -4219,7 +4776,7 @@ export function apply(ctx) {
4219
4776
  // Record one vote and, when every voter has answered, settle the round.
4220
4777
  async function castVerdict(memberId, target, verdict, reason) {
4221
4778
  const member = memberById(memberId)
4222
- if (!member) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
4779
+ if (!member) return memberDiagnosis('投票(vibe_v5_verdict)', memberId)
4223
4780
  if (member.kind === 'temp') {
4224
4781
  // Temp workers have no vote — but their judgement still matters, so it is
4225
4782
  // relayed to the group instead of being silently dropped.
@@ -4245,13 +4802,22 @@ export function apply(ctx) {
4245
4802
  }
4246
4803
 
4247
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)
4248
4810
  async function saveChatLine(text) {
4249
4811
  const line = String(text || '').trim()
4250
4812
  if (!line) return false
4251
4813
  const day = fmtTime().slice(0, 10)
4252
4814
  const rel = 'Shared/Chat/' + day + '.md'
4253
- const prev = (await readTextRel(rel)) || ('# 研究所群聊记录|' + instituteName + '|' + day + '\n\n')
4254
- return await writeTextRel(rel, prev + '- ' + fmtTime().slice(11) + '|' + line + '\n')
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
4255
4821
  }
4256
4822
  // Wake a member only when it is not already running. Never called while paused
4257
4823
  // (a paused institute must not be nudged into new work — v4 §29-T36).
@@ -4266,7 +4832,12 @@ export function apply(ctx) {
4266
4832
  // meeting request is PARKED (first one wins; later requests do not overwrite it)
4267
4833
  // and resumed once the verification clears. The watchdog clock starts only when
4268
4834
  // the meeting ACTUALLY begins (v4 §26/§27).
4269
- const solveVotes = new Map() // memberId -> boolean, for the current solve question
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)
4270
4841
  async function startMeeting(callerId, opts) {
4271
4842
  const o = opts || {}
4272
4843
  const agenda = String(o.agenda || '').trim()
@@ -4283,8 +4854,11 @@ export function apply(ctx) {
4283
4854
  // r-2 and the voters replied to the wrong person.
4284
4855
  if (callerId) {
4285
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 + ')' })
4286
4860
  }
4287
- return { ok: true, proposed: true, message: '已向院士/所办提议开会(只有院士或所办可以直接召开)' }
4861
+ return { ok: true, proposed: true, message: '已向院士/所办提议开会(只有院士或所办可以直接召开;所办会在 status/report 的 officeRequests 里看到该提议)' }
4288
4862
  }
4289
4863
  if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute has already concluded; start a new run to convene again' }
4290
4864
  if (!running) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
@@ -4297,21 +4871,39 @@ export function apply(ctx) {
4297
4871
  }
4298
4872
  return await beginMeeting({ agenda, kind, target: String(o.target || ''), by: office ? 'office' : callerId })
4299
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
+ }
4300
4880
  async function beginMeeting(opts) {
4301
- const counters = Object.assign({}, inst().counters)
4302
- counters.meeting = (Number(counters.meeting) || 0) + 1
4303
- await putCounters(counters)
4304
- const id = 'mt-' + counters.meeting
4305
4881
  const order = activeMembers().map((m) => m.id)
4306
4882
  // Rotate who speaks first: with a fixed order the same member always speaks
4307
4883
  // before it can see the others (v4 §24.1-④).
4308
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
4309
4896
  meeting = {
4310
4897
  id, agenda: opts.agenda, kind: opts.kind || 'sync', target: opts.target || '',
4311
4898
  by: opts.by || 'office', order, inputs: {}, extras: {}, lastInputAt: now(), startedAt: now(),
4312
4899
  }
4313
- solveVotes.clear()
4314
- await putMeeting({ id, agenda: meeting.agenda, kind: meeting.kind, at: now(), file: 'Shared/Meetings/' + id + '.md' })
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 })
4315
4907
  await saveChatLine('【会议 ' + id + '】召开:' + meeting.agenda + '(类型:' + meeting.kind + '|召集人:' + meeting.by + ')')
4316
4908
  // A meeting that ACTUALLY begins and was convened by the office is the other half of the
4317
4909
  // `paperEditor='office'` consultation requirement (docs/final-paper.md §7). Counted here, not at the
@@ -4395,6 +4987,8 @@ export function apply(ctx) {
4395
4987
  }
4396
4988
  async function finalizeMeeting(mn) {
4397
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 */ }
4398
4992
  const lines = []
4399
4993
  for (const id of mn.order) {
4400
4994
  const text = mn.inputs[id]
@@ -4439,7 +5033,8 @@ export function apply(ctx) {
4439
5033
  if (autoDone) return true
4440
5034
  const vs = voters().map((m) => m.id)
4441
5035
  if (!vs.length) return false
4442
- if (!vs.every((id) => solveVotes.get(id) === true)) return false
5036
+ const sv = solveVotesOf()
5037
+ if (!vs.every((id) => sv[id] === true)) return false
4443
5038
  // ── THE FINAL PAPER PHASE COMES FIRST (docs/final-paper.md §3) ────────────────────────────────
4444
5039
  // `finishRun` flips phase='solved' / autoDone / running=false, after which the machinery
4445
5040
  // REFUSES: `wakeIfIdle` returns false, `startMeeting` rejects a concluded institute and
@@ -4461,7 +5056,9 @@ export function apply(ctx) {
4461
5056
  const m = memberById(memberId)
4462
5057
  if (!m) return
4463
5058
  if (m.kind === 'temp') return // no vote
4464
- solveVotes.set(memberId, val === true)
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 })
4465
5062
  // Evaluate the stop condition on EVERY solve vote, not only when a meeting
4466
5063
  // finalizes. A vote that lands after the meeting closed — a late reply, or an
4467
5064
  // ordinary round carrying vote_solved — would otherwise be recorded and never
@@ -4528,13 +5125,23 @@ export function apply(ctx) {
4528
5125
  await patchInstitute({ paper: (cur) => { const next = fn(cur === undefined ? null : cur); return next === undefined ? (cur === undefined ? null : cur) : next } })
4529
5126
  return paper()
4530
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
+ }
4531
5135
  async function paperLog(title, body) {
4532
5136
  const p = paper()
4533
5137
  const id = (p && p.id) || paperIdFromState()
4534
5138
  const rel = PAPER_FILE(id, 'paper.log.md')
4535
5139
  const prev = (await readTextRel(rel)) || ('# 最终论文流程日志|' + id + '\n\n')
4536
- await writeTextRel(rel, prev + '## ' + title + '\n\n' + String(body || '') + '\n\n')
4537
- return rel
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 }
4538
5145
  }
4539
5146
  function paperAskEvery() { return posMs(params.activityTimeoutMs, 120000) }
4540
5147
  function paperNextStep(p) {
@@ -4578,7 +5185,7 @@ export function apply(ctx) {
4578
5185
  L.push('【最终论文·撰写 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
4579
5186
  L.push('本所对原问题的一致结论已经达成,现在撰写**最终论文**(第 ' + p.round + '/' + PAPER_MAX_ROUNDS + ' 轮)。')
4580
5187
  L.push('请你**只写你自己库里已有证据支撑**的内容:')
4581
- L.push('- 直接引用你的卡片(Members/' + member.id + '/Propos|Methods|Subproblems/)、Members/' + member.id + '/Progress/progress.md、你参与的表决记录;')
5188
+ L.push('- 直接引用你的卡片(' + instRel('Members/' + member.id + '/Propos|Methods|Subproblems/') + ')、' + instRel('Members/' + member.id + '/Progress/progress.md') + '、你参与的表决记录;')
4582
5189
  L.push('- **不得编造**:没有证据的推测不要写成结论;未决 / 被否证的条目必须显式标注“未定论 / 已被否证”;')
4583
5190
  L.push('- 在 evidence 里写清证据路径,附录会逐条索引。')
4584
5191
  if (p.round > 1) {
@@ -4598,7 +5205,7 @@ export function apply(ctx) {
4598
5205
  L.push(' "methods": "你创造/发现的方法、理论、思想、有价值经验、数学理解",')
4599
5206
  L.push(' "rules": "你从这些工作中归纳出的可复用规律",')
4600
5207
  L.push(' "limits": "局限、未决、被否证之处(必须诚实、显式)",')
4601
- L.push(' "evidence": ["Members/' + member.id + '/Propos/p-x.md"] } }')
5208
+ L.push(' "evidence": ["' + instRel('Members/' + member.id + '/Propos/p-x.md') + '"] } }')
4602
5209
  return L.join('\n')
4603
5210
  }
4604
5211
  function paperReviewPrompt(member, p, ofId) {
@@ -5024,8 +5631,10 @@ export function apply(ctx) {
5024
5631
  }
5025
5632
  if (ev.formalKeys.length) out.push('Formal/Index.md', 'Formal/TODO.md')
5026
5633
  for (const m of ev.members) {
5027
- out.push('Members/' + m.id + '/Progress/progress.md(' + m.id + ' 的研究日志)')
5028
- out.push('Members/' + m.id + '/{Propos,Methods,Subproblems}/(' + m.id + ' 的卡片)')
5634
+ // Member-facing evidence paths are CWD-relative (DEFECT A): a member copying one of these
5635
+ // into its own file tool must not create a stray `<cwd>/Members/…` tree.
5636
+ out.push(instRel('Members/' + m.id + '/Progress/progress.md') + '(' + m.id + ' 的研究日志)')
5637
+ out.push(instRel('Members/' + m.id + '/{Propos,Methods,Subproblems}/') + '(' + m.id + ' 的卡片)')
5029
5638
  }
5030
5639
  out.push('Institutes.md(编制镜像)', 'Shared/TaskBoard.md(任务板镜像)')
5031
5640
  out.push('Paper/' + p.id + '/paper.log.md(本论文流程往来)')
@@ -5351,13 +5960,18 @@ export function apply(ctx) {
5351
5960
  const finalizedAt = now()
5352
5961
  const S = paperSections(p, active, skipped, ev, { final: p.final, conclusionText })
5353
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 = []
5354
5966
  if (p.format !== 'tex') {
5355
5967
  if (await writeTextRel(PAPER_FILE(id, 'paper.md'), renderPaperMd(p, S))) files.push('paper.md')
5968
+ else writeFailures.push('paper.md')
5356
5969
  }
5357
5970
  let tex = ''
5358
5971
  if (p.format !== 'md') {
5359
5972
  tex = renderPaperTex(p, S)
5360
5973
  if (await writeTextRel(PAPER_FILE(id, 'paper.tex'), tex)) files.push('paper.tex')
5974
+ else writeFailures.push('paper.tex')
5361
5975
  }
5362
5976
  // `paperFormat=md` with `paperCompilePdf=true` must NOT warn about a missing tex: no tex
5363
5977
  // was produced, so compilation is skipped silently (docs/final-paper.md §8).
@@ -5373,11 +5987,17 @@ export function apply(ctx) {
5373
5987
  const warnings = (p.warnings || []).slice()
5374
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(';'))
5375
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))
5376
5993
  const meta = paperMeta(Object.assign({}, p, { finalizedAt, compile: compile.status, engine: compile.engine || '' }), {
5377
5994
  finalizedAt, warnings, compile: { status: compile.status, engine: compile.engine || '', reason: compile.reason || '', attempts: compile.attempts || [] }, files,
5378
5995
  })
5379
5996
  if (await writeTextRel(PAPER_FILE(id, 'paper.meta.json'), JSON.stringify(meta, null, 2) + '\n')) files.push('paper.meta.json')
5380
- await paperLog('定稿(' + reason + ')', [
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 + ')', [
5381
6001
  '- 目录:Paper/' + id + '/',
5382
6002
  '- 产物:' + files.join('、'),
5383
6003
  '- 编译:' + compile.status + (compile.engine ? '(' + compile.engine + ')' : '') + '|尝试 ' + (compile.attempts || []).length + ' 次',
@@ -5386,6 +6006,14 @@ export function apply(ctx) {
5386
6006
  (p.final && p.final.note ? '- 定稿说明:' + p.final.note : ''),
5387
6007
  (warnings.length ? '- 警告:\n - ' + warnings.join('\n - ') : ''),
5388
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
+ }
5389
6017
  await mutatePaper((cur) => (cur ? Object.assign({}, cur, {
5390
6018
  status: 'finalized', stage: 'finalized', finalizedAt, compile: compile.status,
5391
6019
  engine: compile.engine || '', warnings, artifacts: files, updatedAt: now(),
@@ -5402,6 +6030,10 @@ export function apply(ctx) {
5402
6030
  }
5403
6031
 
5404
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.
5405
6037
  function employedTemps() { return activeMembers().filter((m) => m.kind === 'temp') }
5406
6038
  // ANY academician or permanently-employed researcher may hire its own temp
5407
6039
  // workers, and may fire the ones it hired. This is the requirement the official
@@ -5431,11 +6063,12 @@ export function apply(ctx) {
5431
6063
  try {
5432
6064
  await spawnMember(member, initialTask)
5433
6065
  } catch (e) {
5434
- 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' }))
5435
6067
  // Name the host's live-child cap (maxActiveSubagents) instead of relaying its opaque
5436
6068
  // "subagent limit reached (active child limit: N)" string, and use the typed code so the
5437
- // hirer can tell a HOST ceiling from a broken provider.
5438
- if (isActivationLimitReached(e)) { noteChildLimit(e); return { ok: false, code: 'ACTIVATION_LIMIT_REACHED', message: '临时工创建失败:' + activationLimitText(hostChildLimit) } }
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) + '(已登记为待补建:容量释放后框架会在下一次调度轮次自动重试)' } }
5439
6072
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: '临时工创建失败:' + String((e && e.message) || e) }
5440
6073
  }
5441
6074
  await saveChatLine('【雇佣】' + (office ? '所办' : callerId) + ' 雇入临时工 ' + member.id + ',用途:' + purpose)
@@ -5443,26 +6076,55 @@ export function apply(ctx) {
5443
6076
  notifyActivity()
5444
6077
  return { ok: true, id: member.id, kind: 'temp', purpose, note: '现在可以用 vibe_v5_say {to:"' + member.id + '"} 或 vibe_v5_assign 给它派活' }
5445
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
+ }
5446
6095
  // Firing is REAL: the current turn is cancelled, the resident continuable child is
5447
- // released, its tasks are reclaimed, its queued mail is dropped and it is marked
5448
- // dismissed. Its id is never reused, so a re-hire can never inherit its archives.
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.
5449
6099
  async function fire(callerId, o) {
5450
6100
  const args = o || {}
5451
6101
  const id = String(args.id || args.member || '').trim()
5452
6102
  const target = memberById(id)
5453
6103
  if (!target) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no such member ' + id }
5454
- if (target.phase === 'dismissed') return { ok: true, already: true, message: id + ' 已被解雇' }
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
+ }
5455
6111
  const office = isOffice(callerId)
5456
6112
  const acad = isAcademician(callerId)
5457
6113
  const allowed = office || (acad && params.academicianLeads && target.kind === 'temp') || (target.kind === 'temp' && target.hiredBy === callerId)
5458
6114
  if (!allowed) {
5459
- return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: '你只能解雇你雇的临时工;解雇他人雇的或常驻研究员需由院士/所办执行' }
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 时的院士(且仅限临时工);常驻研究员只能由所办解聘,成员只能向所办提议' }
5460
6119
  }
5461
6120
  if (target.kind !== 'temp' && !office) {
5462
6121
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: '解聘常驻研究员只能向所办提议,由所办批准(成员不能直接执行)' }
5463
6122
  }
5464
6123
  const reason = String(args.reason || '').trim()
5465
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 + ' 被解雇(级联)')
5466
6128
  if (target.childId) {
5467
6129
  try { if (typeof subagents.interrupt === 'function') subagents.interrupt(target.childId, { kind: 'ancestor', agent: rootAgent }) } catch (e) { /* fire-and-return */ }
5468
6130
  try {
@@ -5473,7 +6135,9 @@ export function apply(ctx) {
5473
6135
  liveAgents.delete(target.childId)
5474
6136
  }
5475
6137
  busy.delete(id)
5476
- solveVotes.delete(id)
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 })
5477
6141
  rounds.delete(id)
5478
6142
  roundsSinceCompact.delete(id)
5479
6143
  contextPct.delete(id)
@@ -5501,14 +6165,15 @@ export function apply(ctx) {
5501
6165
  }))
5502
6166
  await writeRosterMirror()
5503
6167
  await saveChatLine('【解雇】' + id + ' 已由 ' + (office ? '所办' : callerId) + ' 解雇(原因:' + (reason || '未说明') +
5504
- ')。代号永不复用;其未完成任务已收回' + (reclaimed.length ? '(' + reclaimed.join('、') + ')' : '') + '。')
6168
+ ')。代号永不复用;其未完成任务已收回' + (reclaimed.length ? '(' + reclaimed.join('、') + ')' : '') +
6169
+ (cascaded.length ? ';随其解雇的临时工 ' + cascaded.join('、') + '(雇主离场 ⇒ 其临时工一并解雇)' : '') + '。')
5505
6170
  await markProgress()
5506
6171
  notifyActivity()
5507
6172
  await scheduleNext()
5508
- return { ok: true, dismissed: id, reclaimedTasks: reclaimed, reason }
6173
+ return { ok: true, dismissed: id, reclaimedTasks: reclaimed, cascadedTemps: cascaded, reason }
5509
6174
  }
5510
6175
  async function nudge(callerId, o) {
5511
- if (!callerId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
6176
+ if (!callerId) return memberDiagnosis('督办(vibe_v5_nudge)', callerId)
5512
6177
  if (!isOffice(callerId) && !(isAcademician(callerId) && params.academicianLeads)) {
5513
6178
  return { ok: false, code: 'V5_NOT_ACADEMICIAN', message: 'only the academician (or the office) can nudge members' }
5514
6179
  }
@@ -5539,13 +6204,36 @@ export function apply(ctx) {
5539
6204
  // prepended inbox block and once inside its own [状态] block.
5540
6205
  async function promptFor(member, baseFn) {
5541
6206
  const pending = pendingFor(member.id)
5542
- const inbox = pending.length ? composeInbox(pending) : ''
5543
- if (pending.length) await ackPending(pending)
5544
- const base = typeof baseFn === 'function' ? baseFn() : baseFn
5545
- return (inbox ? inbox + '\n\n' : '') + base
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 }
5546
6225
  }
5547
6226
  async function wakeWithInbox(member, baseFn, kind) {
5548
- return await wakeMember(member, await promptFor(member, baseFn), kind)
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
5549
6237
  }
5550
6238
 
5551
6239
  // ---- scheduling / graded keep-alive (ported and upgraded from v4 §25) ---
@@ -5564,7 +6252,13 @@ export function apply(ctx) {
5564
6252
  }
5565
6253
  if (cur && cur.project) project = cur.project
5566
6254
  if (cur && cur.institute) instituteName = cur.institute
5567
- if (cur) phase = cur.phase || phase
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
+ }
5568
6262
  }
5569
6263
  // Scheduling TRAMPOLINE. The consensus finalizers call `scheduleNext()` themselves
5570
6264
  // (a settled verification should immediately drive whatever comes next), so a
@@ -5590,8 +6284,78 @@ export function apply(ctx) {
5590
6284
  scheduling = false
5591
6285
  }
5592
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
+ }
5593
6348
  async function schedulePass() {
5594
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)) }
5595
6359
  clearHeartbeat()
5596
6360
  syncParamsFromState()
5597
6361
  // A verification that is ALREADY in flight is served first: the two coordination
@@ -5720,6 +6484,10 @@ export function apply(ctx) {
5720
6484
  // one path and silently dropped on another.
5721
6485
  async function handleReply(member, parsed, kind) {
5722
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()
5723
6491
  postmark(member, p)
5724
6492
  // (1) speech (named `speech`, not `s`: `s` is the session API in this scope)
5725
6493
  const speech = p.say
@@ -5773,17 +6541,51 @@ export function apply(ctx) {
5773
6541
  // `formalOn()` gates it: off mode is a TRUE no-op, and the field is not even offered in the
5774
6542
  // reply contract there, so a stray / stale / hallucinated reply must not create Lean state.
5775
6543
  // (The TOOLS stay usable in off mode on purpose — a tool call is deliberate.)
5776
- if (formalOn() && p.formal && typeof p.formal === 'object') {
5777
- const f = p.formal
5778
- const target = idSafe(String(f.target || ''))
5779
- if (target) {
5780
- const decision = String(f.decision || '').trim()
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()
5781
6570
  const note = String(f.note || '').trim()
5782
6571
  if (decision === 'blocked' || decision === 'defect') {
5783
6572
  if (!note) await notice(member.id, "formal.decision='" + decision + "' 必须写明 note(难度判断/阻塞原因/具体偏差)——本次未记录。")
5784
6573
  else if (decision === 'defect') {
5785
6574
  const r = await recordFidelityDefect(member.id, target, note)
5786
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
+ }
5787
6589
  } else {
5788
6590
  const r = await leanArchive(member.id, { kind: 'blocked', target, note })
5789
6591
  if (r.ok === false) await notice(member.id, '记录形式化阻塞失败(' + (r.code || '') + '):' + (r.message || ''))
@@ -5793,7 +6595,7 @@ export function apply(ctx) {
5793
6595
  const prev = formalOf(target)
5794
6596
  await putFormal(target, Object.assign({}, prev, {
5795
6597
  status: prev.status === 'passed' || prev.status === 'blocked' ? prev.status : 'attempted',
5796
- 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(),
5797
6599
  }))
5798
6600
  await writeFormalIndex()
5799
6601
  } else if (decision) {
@@ -5801,6 +6603,7 @@ export function apply(ctx) {
5801
6603
  }
5802
6604
  }
5803
6605
  }
6606
+ }
5804
6607
  if (p.propose_verify) {
5805
6608
  const pv = typeof p.propose_verify === 'string' ? { target: p.propose_verify } : p.propose_verify
5806
6609
  if (pv && pv.target) {
@@ -5809,10 +6612,42 @@ export function apply(ctx) {
5809
6612
  }
5810
6613
  }
5811
6614
  if (p.verdict && typeof p.verdict === 'object') {
5812
- const n = normVerdictNumber(p.verdict.verdict)
5813
- if (n === undefined) await notice(member.id, 'verdict 必须是 0-1 的数值;本轮的票未被记录。')
6615
+ const declared = normVerdictNumber(p.verdict.verdict)
6616
+ if (declared === undefined) await notice(member.id, 'verdict 必须是 0-1 的数值;本轮的票未被记录。')
5814
6617
  else {
5815
- const r = await castVerdict(member.id, String(p.verdict.target || ''), n, p.verdict.reason)
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)
5816
6651
  if (r && r.ok === false) await notice(member.id, '本轮的票未被记录(' + (r.code || '') + '):' + (r.message || ''))
5817
6652
  }
5818
6653
  }
@@ -5872,7 +6707,7 @@ export function apply(ctx) {
5872
6707
  '- 时间: ' + fmtTime(),
5873
6708
  '- 记录者: ' + member.id,
5874
6709
  '- 该成员认为原问题已解决: ' + (p.solved === true),
5875
- '- 有表决权的解决票: ' + Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、'),
6710
+ '- 有表决权的解决票: ' + (solveVotesList().join('、') || '(无)'),
5876
6711
  '',
5877
6712
  ].join('\n'))
5878
6713
  }
@@ -6075,7 +6910,15 @@ export function apply(ctx) {
6075
6910
  // round-4 item 4: only a NEWLY recorded entry warns, and the record goes through the same
6076
6911
  // retention as every other diagnostics writer in this preset (slice(-50)).
6077
6912
  if (duplicate) return keys
6078
- target.diagnostics.push({ kind: 'state-dropped-keys', where: where, keys: keys })
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
+ })
6079
6922
  while (target.diagnostics.length > 50) target.diagnostics.shift()
6080
6923
  }
6081
6924
  console.warn('vibe-math-v5: 忽略未知参数键(不静默丢失,已记入 diagnostics):' + keys.join(', ') + '(来源:' + where + ')')
@@ -6103,7 +6946,7 @@ export function apply(ctx) {
6103
6946
  memberMayRejectAssign: params.memberMayRejectAssign,
6104
6947
  researcherCount: params.researcherCount,
6105
6948
  quorumCap: params.quorumCap, quorumMode: params.quorumMode,
6106
- m: quorumM(), voterCount: voterCount(),
6949
+ m: quorumView().m, voterCount: voterCount(), started: voterCount() > 0,
6107
6950
  verdictMaxRounds: params.verdictMaxRounds,
6108
6951
  maxTempPerMember: params.maxTempPerMember, maxTempTotal: params.maxTempTotal,
6109
6952
  compactThreshold: params.compactThreshold, compactAfterRounds: params.compactAfterRounds,
@@ -6142,10 +6985,23 @@ export function apply(ctx) {
6142
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)' }
6143
6986
  }
6144
6987
  const patch = {}
6988
+ let identitySwitched = false
6145
6989
  if (a.project !== undefined && String(a.project).trim()) patch.project = String(a.project).trim()
6146
6990
  if (a.institute !== undefined && String(a.institute).trim()) patch.institute = String(a.institute).trim()
6147
6991
  if (a.problem !== undefined) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
6148
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
+ }
6149
7005
  if (patch.project !== undefined || patch.institute !== undefined) {
6150
7006
  const np = patch.project !== undefined ? patch.project : project
6151
7007
  const ni = patch.institute !== undefined ? patch.institute : instituteName
@@ -6160,18 +7016,29 @@ export function apply(ctx) {
6160
7016
  instituteName = ni
6161
7017
  patch.project = np
6162
7018
  patch.institute = ni
7019
+ identitySwitched = true
6163
7020
  } else if (!inst().members.length) {
6164
7021
  key = nkey
6165
7022
  project = np
6166
7023
  instituteName = ni
6167
7024
  patch.project = np
6168
7025
  patch.institute = ni
7026
+ identitySwitched = true
6169
7027
  } else {
6170
7028
  return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this session already holds an institute; use a new session to found another' }
6171
7029
  }
6172
7030
  }
6173
7031
  }
6174
- patch.phase = 'idle'
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'
6175
7042
  await patchInstitute(patch)
6176
7043
  syncParamsFromState()
6177
7044
  await mkdirs()
@@ -6216,7 +7083,17 @@ export function apply(ctx) {
6216
7083
  async function doStart(args) {
6217
7084
  const a = args || {}
6218
7085
  if (running && !autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'already running' }
6219
- if (autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'this institute already concluded; configure a new one in a new session' }
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
+ }
6220
7097
  const patch = {}
6221
7098
  if (a.problem !== undefined && String(a.problem).trim()) patch.problem = { id: slugify(String(a.problem).slice(0, 40)) || 'problem', statement: String(a.problem) }
6222
7099
  const p = Object.assign({}, params, normalizeParams(a.params || {}))
@@ -6266,7 +7143,11 @@ export function apply(ctx) {
6266
7143
  spawned.push(m.id)
6267
7144
  } catch (e) {
6268
7145
  if (isActivationLimitReached(e)) limitRefused = true
6269
- await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
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' }))
6270
7151
  }
6271
7152
  }
6272
7153
  for (let i = 0; i < params.researcherCount; i++) {
@@ -6277,7 +7158,7 @@ export function apply(ctx) {
6277
7158
  spawned.push(m.id)
6278
7159
  } catch (e) {
6279
7160
  if (isActivationLimitReached(e)) limitRefused = true
6280
- 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' }))
6281
7162
  }
6282
7163
  }
6283
7164
  if (!spawned.length) {
@@ -6332,7 +7213,7 @@ export function apply(ctx) {
6332
7213
  // "queued" half of the cap handling, retried in this round — gated on the RECORDED cap error
6333
7214
  // so an ordinary failed member (broken provider, rejected tool filter) is never resurrected
6334
7215
  // by a resume. `members` keeps its meaning for the return value below.
6335
- const queued = inst().members.filter((m) => m.phase === 'failed' && /active child limit|maxActiveSubagents/.test(String(m.error || '')))
7216
+ const queued = inst().members.filter((m) => isCapRefusedMember(m))
6336
7217
  if (!members.length && !queued.length) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'no active member to resume' }
6337
7218
  let respawned = 0
6338
7219
  beginSpawnRound() // the whole re-spawn loop is ONE round for the cap notice
@@ -6395,7 +7276,9 @@ export function apply(ctx) {
6395
7276
  // (v4 §30-T38).
6396
7277
  meeting = null
6397
7278
  pendingMeeting = null
6398
- solveVotes.clear()
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 })
6399
7282
  busy.clear()
6400
7283
  wakeKind.clear()
6401
7284
  currentMember = ''
@@ -6414,33 +7297,95 @@ export function apply(ctx) {
6414
7297
  ok: true,
6415
7298
  institute: instituteName, project, key, phase,
6416
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
+ },
6417
7313
  backend: backend ? backend.kind : 'uninitialized',
6418
7314
  // Skipped/malformed events AND state-file load problems. Without this the two
6419
7315
  // failure modes that silently drop state were invisible in the operator's view.
6420
7316
  diagnostics: s.diagnostics || [],
6421
7317
  debug: Object.assign({ scheduling, reschedule }, dbg),
6422
- quorum: { m: quorumM(), mode: params.quorumMode, voters: voters().map((m) => m.id), voterCount: voterCount() },
7318
+ quorum: quorumView(),
6423
7319
  members: s.members.map((m) => ({
6424
7320
  id: m.id, kind: m.kind, phase: m.phase, direction: m.direction, hiredBy: m.hiredBy,
6425
7321
  rounds: rounds.get(m.id) || 0, busy: busy.has(m.id), contextPct: contextPct.get(m.id) || 0,
6426
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 || '',
6427
7327
  })),
6428
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.
6429
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 },
6430
7365
  meeting: meeting ? { id: meeting.id, agenda: meeting.agenda, kind: meeting.kind, spoke: Object.keys(meeting.inputs), order: meeting.order } : null,
6431
7366
  parkedMeeting: pendingMeeting ? { agenda: pendingMeeting.agenda, kind: pendingMeeting.kind } : null,
6432
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,
6433
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.
6434
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 三个精确字段',
6435
7375
  undecided: Object.keys(s.verdicts).filter((k) => { const v = s.verdicts[k]; return v && v.closed && v.outcome === 'undecided' }),
6436
- solveVotes: Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v),
7376
+ solveVotes: solveVotesList(),
6437
7377
  // Lean formal verification: mode + per-object status + the formalization TODO. This
6438
7378
  // is how an office/human audits "did we really get strict proofs, or only consensus?"
6439
7379
  formal: {
6440
7380
  mode: formalMode(),
6441
7381
  objects: Object.keys(formalRecords()).map((k) => {
6442
7382
  const r = formalRecords()[k] || {}
6443
- return { target: k, status: r.status, 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 }
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 }
6444
7389
  }),
6445
7390
  passed: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'passed'),
6446
7391
  blocked: Object.keys(formalRecords()).filter((k) => (formalRecords()[k] || {}).status === 'blocked'),
@@ -6456,12 +7401,15 @@ export function apply(ctx) {
6456
7401
  function report() {
6457
7402
  syncParamsFromState()
6458
7403
  const s = inst()
7404
+ // ONE snapshot for the whole report: the text line and the returned `quorum` object can
7405
+ // never disagree (DEFECT B), and `rosterVersion` lets a reader detect a stale copy.
7406
+ const qv = quorumView()
6459
7407
  const L = []
6460
7408
  L.push('# 「' + instituteName + '」研究所汇报')
6461
7409
  L.push('')
6462
- L.push('- 项目:' + project + '|阶段:' + phase + '|运行中:' + running + '|已结题:' + autoDone)
7410
+ L.push('- 项目:' + project + '|' + phaseFactLine())
6463
7411
  L.push('- 研究对象:' + (s.problem.statement ? s.problem.statement.slice(0, 200) : '(未设定)'))
6464
- L.push('- 求真门槛:m = ' + quorumM() + '(模式 ' + params.quorumMode + ')|有表决权者 ' + voterCount() + ' 人')
7412
+ L.push('- 求真门槛:' + (qv.started ? 'm = ' + qv.m : '未启动(有表决权者 0 人,m 未定义)') + '(模式 ' + params.quorumMode + ')|有表决权者 ' + qv.voterCount + ' 人|rosterVersion(名册版本,多个视图不一致时用它判断谁过期)' + qv.rosterVersion)
6465
7413
  L.push('')
6466
7414
  L.push('## 编制')
6467
7415
  if (!s.members.length) L.push('(暂无成员)')
@@ -6469,7 +7417,7 @@ export function apply(ctx) {
6469
7417
  L.push('- ' + m.id + '|' + kindLabel(m.kind) + '|' + m.phase +
6470
7418
  (m.hiredBy ? '|雇主 ' + m.hiredBy : '') +
6471
7419
  (m.direction ? '|方向:' + m.direction : '') +
6472
- '|轮次 ' + (rounds.get(m.id) || 0) +
7420
+ '|轮次 ' + (rounds.get(m.id) || 0) + '(本会话计数,重启归零)' +
6473
7421
  (m.error ? '|⚠ ' + m.error : ''))
6474
7422
  }
6475
7423
  L.push('')
@@ -6491,19 +7439,63 @@ export function apply(ctx) {
6491
7439
  '|平均概率 ' + Number(v.mean || 0).toFixed(2) + '|' + (v.reason || '') + ')')
6492
7440
  }
6493
7441
  const cv = currentVerify()
6494
- if (cv) L.push('- 进行中:' + cv.target + '|' + cv.stage + ' 第 ' + cv.round + ' 轮|已投 ' + Object.keys(cv.votes).join('、'))
7442
+ if (cv) L.push('- 进行中:' + cv.target + '|' + cv.stage + ' 第 ' + cv.round + ' 轮' + (Object.keys(cv.votes).length ? '|已投 ' + Object.keys(cv.votes).join('、') : '|尚无人投票'))
6495
7443
  if (s.queue.length) L.push('- 队列:' + s.queue.map((q) => q.target).join('、'))
6496
7444
  L.push('')
6497
7445
  L.push('## 群聊 / 会议')
6498
- L.push('- 未读消息:' + s.messages.length + '|已投递:' + s.delivered.length)
6499
- if (meeting) L.push('- 进行中会议:' + meeting.id + '|' + meeting.agenda + '|已发言 ' + Object.keys(meeting.inputs).join('、'))
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('、') : '|尚无人发言'))
6500
7457
  if (pendingMeeting) L.push('- 暂存会议:' + pendingMeeting.agenda)
6501
- L.push('- 历史会议:' + s.meetings.length + ' 次|辩论录:' + s.debates.length + ' 份')
6502
- L.push('- 解决票:' + (solveVotes.size ? Array.from(solveVotes.entries()).map(([k, v]) => k + '=' + v).join('、') : '(无)'))
6503
- if ((s.diagnostics || []).length) {
6504
- L.push('')
6505
- L.push('## ⚠ 状态诊断(被跳过的事件 / 状态文件读取问题)')
6506
- for (const d of s.diagnostics.slice(-10)) L.push('- ' + fmtTime(d.at) + '|' + d.type + '|' + d.error)
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
+ }
6507
7499
  }
6508
7500
  L.push('')
6509
7501
  L.push('## Lean 形式化')
@@ -6513,6 +7505,23 @@ export function apply(ctx) {
6513
7505
  L.push('- 已通过:' + (Object.keys(s.formal || {}).filter((k) => (s.formal || {})[k].status === 'passed').join('、') || '(无)'))
6514
7506
  L.push('- 已记录阻塞:' + (Object.keys(s.formal || {}).filter((k) => (s.formal || {})[k].status === 'blocked').join('、') || '(无)'))
6515
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
+ }
6516
7525
  L.push('- 可复用库:VibeMath/Formal/{Lib,Proved}/(跨项目)|本所形式化:Formal/|归档证明:Verified/Lean/')
6517
7526
  }
6518
7527
  L.push('')
@@ -6533,23 +7542,34 @@ export function apply(ctx) {
6533
7542
  L.push('')
6534
7543
  L.push('## 文件位置')
6535
7544
  L.push('- 根目录:' + instRoot())
6536
- L.push('- 已确立:Verified/|成员库:Members/<id>/|群聊:Shared/Chat/|会议:Shared/Meetings/|辩论:Shared/Debates/')
6537
- return { ok: true, report: L.join('\n') }
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)')
7554
+ return { ok: true, report: L.join('\n'), quorum: qv }
6538
7555
  }
6539
7556
  // Adding/removing a PERMANENT researcher is a change to the institute's public
6540
7557
  // structure, so members may only propose it; the office decides and executes.
6541
7558
  async function addResearcher(callerId, direction) {
6542
7559
  if (!isOffice(callerId)) {
6543
7560
  await say(callerId, { to: 'voters', kind: 'voters', text: '提议增聘一名常驻研究员(方向:' + String(direction || '未指定') + ')' })
6544
- return { ok: true, proposed: true, message: '已向所办提议增聘常驻研究员(编制变更需所办批准)' }
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 里看到该提议)' }
6545
7565
  }
6546
7566
  if (!running || autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
6547
7567
  const m = await newMember('researcher', { direction: String(direction || '') })
6548
7568
  try {
6549
7569
  await spawnMember(m, null)
6550
7570
  } catch (e) {
6551
- await putMember(Object.assign({}, memberById(m.id) || m, { phase: 'failed', error: String((e && e.message) || e) }))
6552
- 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) + '(已登记为待补建:容量释放后框架会在下一次调度轮次自动重试)' } }
6553
7573
  return { ok: false, code: 'V5_PROVISIONING_CONFLICT', message: String((e && e.message) || e) }
6554
7574
  }
6555
7575
  await saveChatLine('【编制】所办增聘常驻研究员 ' + m.id + (direction ? '(方向:' + direction + ')' : '') +
@@ -6591,6 +7611,9 @@ export function apply(ctx) {
6591
7611
  taskCreate, taskList: listTasks, getTask, taskUpdate, taskAssign, taskPrioritize,
6592
7612
  // libraries
6593
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,
6594
7617
  // Lean formal verification (docs/formal-verification.md)
6595
7618
  formalMode, formalOn, formalRecords, formalTodo, formalOf, rebuildLeanLibIndexes,
6596
7619
  leanArchive, leanRunTool, writeFormalIndex, writeFormalTodo, recordFidelityDefect,
@@ -6606,6 +7629,8 @@ export function apply(ctx) {
6606
7629
  startPaper, paperStatus: paperSummary, finalizePaperByOffice,
6607
7630
  // authorization helpers (used by tool handlers)
6608
7631
  memberIdOfAgent, isOffice, isAcademician, isProvablyOffice, officeCaller, memberById, activeMembers,
7632
+ // diagnosis helpers (defects 1/2 of the architecture self-test)
7633
+ memberDiagnosis, quorumM, quorumView, voterCount,
6609
7634
  }
6610
7635
  }
6611
7636
 
@@ -6643,7 +7668,7 @@ export function apply(ctx) {
6643
7668
  */
6644
7669
  function withCaller(s, x, what, fn) {
6645
7670
  const caller = s.officeCaller(x)
6646
- 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' } }
6647
7672
  return fn(caller)
6648
7673
  }
6649
7674
 
@@ -6751,30 +7776,30 @@ export function apply(ctx) {
6751
7776
  // not the office either — without this check ANY caller's text was delivered as the office
6752
7777
  // (audit L6 follow-up; the tool's own description says "from the office/human").
6753
7778
  const caller = s.officeCaller(x)
6754
- 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' } }
6755
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' }
6756
7781
  const to = String(a.to || 'all')
6757
7782
  return s.say('office', { to, text: String(a.content), kind: to === 'all' || to === 'voters' ? 'office' : 'dm' })
6758
7783
  })
6759
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) => {
6760
7785
  const caller = s.officeCaller(x)
6761
- 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' } }
6762
7787
  return s.startMeeting(caller, a)
6763
7788
  })
6764
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 }))
6765
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) => {
6766
7791
  const caller = a.to ? String(a.to) : s.officeCaller(x)
6767
- 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' } }
6768
7793
  return s.hire(caller, a)
6769
7794
  })
6770
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) => {
6771
7796
  const caller = s.officeCaller(x)
6772
- 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' } }
6773
7798
  return s.fire(caller, a)
6774
7799
  })
6775
7800
  registerTool('vibe_v5_add_researcher', 'Office only: hire another PERMANENT researcher (members may only propose this).', objParams({ direction: S }), (s, a, x) => {
6776
7801
  const caller = s.officeCaller(x)
6777
- 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' } }
6778
7803
  return s.addResearcher(caller, a && a.direction)
6779
7804
  })
6780
7805
  // "Office only" (the tool's own description) must be ENFORCED, not just documented: the handler
@@ -6800,7 +7825,7 @@ export function apply(ctx) {
6800
7825
  // ── member-facing controls ────────────────────────────────────────────────
6801
7826
  registerTool('vibe_v5_say', '(member) Speak in the group chat (omit "to"), send a private message ("to":"r-2"), or address only the voters ("to":"voters").', objParams({ text: S, to: S }, ['text']), (s, a, x) => {
6802
7827
  const from = s.memberIdOfAgent(x)
6803
- if (!from) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
7828
+ if (!from) return s.memberDiagnosis('发言(vibe_v5_say)', from)
6804
7829
  const to = a.to || 'all'
6805
7830
  return s.say(from, { to, text: a.text, kind: to === 'voters' ? 'voters' : (a.to ? 'dm' : 'chat') })
6806
7831
  })
@@ -6866,26 +7891,36 @@ export function apply(ctx) {
6866
7891
  parts.push('- 距上次实质进展:' + Math.round(idleFor / 1000) + ' 秒')
6867
7892
  return { ok: true, overview: parts.join('\n') }
6868
7893
  })
6869
- 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)))
6870
- 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)))
6871
- 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)))
6872
7897
 
6873
7898
  // ── Lean formal verification (docs/formal-verification.md) ────────────────
6874
7899
  // These three tools are registered UNCONDITIONALLY: tool registration is static (a
6875
7900
  // dynamic registration would depend on a runtime knob and break the effect discipline),
6876
7901
  // while the MODE only decides whether the framework TELLS members about them. In 'off'
6877
7902
  // mode they still work if a human or agent calls them deliberately.
6878
- 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; inspect it via vibe_v5_lean_lib.jobs or wait for the next round\'s 【形式化结果】 line. 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. Pass target=<object id> to also record the run against that object. 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)))
6879
- 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)))
6880
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) => {
6881
7906
  const r = a && a.refresh === false ? { lib: null, proved: null, objects: Object.keys(s.formalRecords()).length } : await s.rebuildLeanLibIndexes()
6882
7907
  const st = s.status()
6883
7908
  return {
6884
7909
  ok: true, mode: s.formalMode(), rebuilt: !(a && a.refresh === false),
6885
7910
  counts: r, todo: s.formalTodo(), jobs: s.leanJobsView(),
6886
- 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 })),
6887
- paths: { project: 'Formal/(相对研究所根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/', searchPath: s.leanSearchRootView() },
6888
- hint: '复用优先:先在 Lib/ 里找现成定义;新定义用 vibe_v5_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'。复用已归档内容:import Formal.Lib.<name> / import Formal.Proved.<name>,或用 vibe_v5_lean_read {name} 取原文逐字复制。同内容重复归档会自动去重。',
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>。同内容重复归档会自动去重。',
6889
7924
  verify: st.verify ? st.verify.target : null,
6890
7925
  }
6891
7926
  })
@@ -6901,7 +7936,7 @@ export function apply(ctx) {
6901
7936
  // ── /v5 slash command ────────────────────────────────────────────────────
6902
7937
  ctx.effect(() => commands.register({
6903
7938
  name: 'v5', description: 'control the Vibe Math V5 research institute',
6904
- input: { hint: '[configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=en] [format=tex] [editor=office] [force]]' },
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]]' },
6905
7940
  handler: async function (inv) {
6906
7941
  const s = getSession(inv && inv.agent)
6907
7942
  if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no session' }) }
@@ -6933,7 +7968,20 @@ export function apply(ctx) {
6933
7968
  else if (cmd === 'members') r = { ok: true, members: s.status().members }
6934
7969
  else if (cmd === 'message') r = await s.say('office', { to: rest[0] || 'all', text: rest.slice(1).join(' '), kind: 'office' })
6935
7970
  else if (cmd === 'meeting') r = await s.startMeeting('office', { agenda: rest.join(' '), kind: 'sync' })
6936
- else if (cmd === 'hire') r = await s.hire('office', { purpose: rest[0] || '', initial_task: rest.slice(1).join(' ') || rest[0] || '' })
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
+ }
6937
7985
  else if (cmd === 'fire') r = await s.fire('office', { id: rest[0] || '', reason: rest.slice(1).join(' ') })
6938
7986
  else if (cmd === 'add') r = await s.addResearcher('office', rest.join(' '))
6939
7987
  else if (cmd === 'remove') r = await s.removeResearcher(rest[0] || '')
@@ -6960,15 +8008,31 @@ export function apply(ctx) {
6960
8008
  r = await s.startPaper('manual', { lang: o.lang, format: o.format, editor: o.editor, force })
6961
8009
  } else if (cmd === 'set') {
6962
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 = []
6963
8015
  for (const tok of rest) {
6964
8016
  const eq = tok.indexOf('=')
6965
- if (eq <= 0) continue
8017
+ if (eq <= 0) { badTokens.push(tok); continue }
6966
8018
  const k = tok.slice(0, eq), v = tok.slice(eq + 1)
6967
8019
  const n = Number(v)
6968
8020
  upd[k] = Number.isFinite(n) && v !== '' ? n : (v === 'true' ? true : v === 'false' ? false : v)
6969
8021
  }
6970
- r = await s.setParams(upd)
6971
- } else r = { ok: false, usage: 'configure|start|resume|pause|stop|status|report|members|message|meeting|hire|fire|add|remove|set|paper [lang=zh|en] [format=both|md|tex] [editor=office|academician] [force]' }
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]' }
6972
8036
  // A business failure (the dispatch result's own ok:false) is a FAILED command: the host's
6973
8037
  // CommandResult union distinguishes success from error, and returning 'success' made a rejected
6974
8038
  // invocation look identical to a successful one (same fix as v2/v3).