dsh-vibe-math 2.1.0 → 2.2.1

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.
@@ -13,6 +13,18 @@
13
13
  // consensus tally, context compaction and resume. It NEVER assigns tasks — the
14
14
  // academician does, as a member who is himself bound by the same m-vote rule.
15
15
  //
16
+ // WHAT A MEMBER READS IS THE PRODUCT. Every prompt builder takes the member it addresses
17
+ // and derives the [状态] block, the roster, the quorum and the charter from THAT member —
18
+ // never from a "the last member we touched" global. A prompt naming the wrong identity is
19
+ // a fatal bug no tool-level assertion can see, so:
20
+ // · `briefBlock` FAILS LOUDLY when it is not told which member it describes;
21
+ // · `spawnMember` commits the member to the ACTIVE roster BEFORE building its brief;
22
+ // · the charter is frozen at hire (it says "你入职时的在册编制") and reused on resume;
23
+ // · a rebuilt session is framed as a rebuild, never as an induction;
24
+ // · framing names the TRUE sender and kind, and framework feedback has its own sender.
25
+ // `prompt-v5-integrity.test.mjs` asserts all of that against the real prompt text and
26
+ // writes the full corpus to `prompt-corpus-v5/` for human review (实现方案.md §14.5).
27
+ //
16
28
  // Durable state lives in a HOST-ONLY session projection unit (key `vibeMathV5`):
17
29
  // institute events are appended to the session log, never enter the model history
18
30
  // (zero context cost), are checkpointed by DSH, and are replayed on restore — which
@@ -520,6 +532,14 @@ export function apply(ctx) {
520
532
  return Math.max(1, Math.min(cap, voterCount()))
521
533
  }
522
534
  function byChild(childId) { return inst().members.find((m) => m.childId === childId) }
535
+ // The live academician's id, or '' when the office founded the institute with
536
+ // `academician: false`. Every piece of charter text that talks about "the leader"
537
+ // must go through this: a hard-coded 'acad' told members to report to a leader who
538
+ // does not exist, and described organizational duties nobody holds.
539
+ function academicianId() {
540
+ const a = activeMembers().find((m) => m.kind === 'academician')
541
+ return a ? a.id : ''
542
+ }
523
543
 
524
544
  // ---- the institute charter (public regulations) -----------------------
525
545
  // Written into every member's `persona` at hire time. `persona` is part of the
@@ -569,28 +589,47 @@ export function apply(ctx) {
569
589
  ' ③ 推荐**直接用 fs 写你自己的文件**;vibe_v5_record_* 只是便捷记录器,不是必需。',
570
590
  ].join('\n')
571
591
 
572
- const ORG_COMMON = [
573
- ' 本所是自组织的,但**不是没有组织**——现实中一个研究所也有所长/学术带头人统筹全局。',
574
- ' 本所的领头人是**院士 ' + 'acad' + '**。它以**全所视角**组织与协调:',
575
- ' ① **统筹全局**:掌握各方向布局、谁在做什么、哪里是瓶颈、哪里有重复或空白;',
576
- ' ② **规划与分派**:把原问题拆成值得做的工作,作为**任务**分派给合适的成员(含临时工)。',
577
- ' 分派是它的职责,不是越权;',
578
- ' ③ **设定优先级**:多个方向并行时,它有责任指明"先做什么、什么可以缓、什么该放弃";',
579
- ' ④ **协调资源**:决定临时工往哪里调配;建议增聘/解聘常驻研究员;',
580
- ' ⑤ **主持会议**:由它召集正式会议、设定议程、维持讨论不跑偏,并把结论落实为任务;',
581
- ' ⑥ **督导进度**:定期检查各成员的 Progress/ 与会议发言,催办停滞的方向、纠正偏离、',
582
- ' 在成员之间牵线;',
583
- ' ⑦ **对外代表**:通过所办向外部汇报与提要求。',
584
- '',
585
- ' 对**你**(非院士)的要求:',
586
- ' · **主动汇报**:把你这一轮的进展、发现、卡点写进你自己的 Progress/,并把关键结论在',
587
- ' 群聊里说出来——院士需要这些信息才能统筹;',
588
- ' · **接受分派,但不要盲从**:院士分派给你的任务,默认应当执行;如果你认为方向错了、',
589
- ' 信息过时、或你有更好的路线,**先说清理由再决定**——本所允许并鼓励有理据的反对。',
590
- ' 真正的原则是:组织由院士负责,但**判断属于每个人自己**;',
591
- ' · **有异议走会议**:若你与院士在方向上持续分歧,提议开会,让全所讨论;',
592
- ' · **不要重复劳动**:做之前先看任务板和别人的库;发现别人已在做同一件事,告诉院士。',
593
- ].join('\n')
592
+ // Computed per hire (a FUNCTION, not a frozen const): the text names the live
593
+ // academician, and must describe a leaderless institute honestly when the office
594
+ // founded one with `academician: false`.
595
+ function orgCommon() {
596
+ const a = academicianId()
597
+ const L = [
598
+ ' 本所是自组织的,但**不是没有组织**——现实中一个研究所也有所长/学术带头人统筹全局。',
599
+ ]
600
+ if (a) {
601
+ L.push(
602
+ ' 本所的领头人是**院士 ' + a + '**。它以**全所视角**组织与协调:',
603
+ ' ① **统筹全局**:掌握各方向布局、谁在做什么、哪里是瓶颈、哪里有重复或空白;',
604
+ ' ② **规划与分派**:把原问题拆成值得做的工作,作为**任务**分派给合适的成员(含临时工)。',
605
+ ' 分派是它的职责,不是越权;',
606
+ ' ③ **设定优先级**:多个方向并行时,它有责任指明"先做什么、什么可以缓、什么该放弃";',
607
+ ' ④ **协调资源**:决定临时工往哪里调配;建议增聘/解聘常驻研究员;',
608
+ ' ⑤ **主持会议**:由它召集正式会议、设定议程、维持讨论不跑偏,并把结论落实为任务;',
609
+ ' ⑥ **督导进度**:定期检查各成员的 Progress/ 与会议发言,催办停滞的方向、纠正偏离、',
610
+ ' 在成员之间牵线;',
611
+ ' ⑦ **对外代表**:通过所办向外部汇报与提要求。',
612
+ '',
613
+ ' 对**你**(非院士)的要求:',
614
+ ' · **主动汇报**:把你这一轮的进展、发现、卡点写进你自己的 Progress/,并把关键结论在',
615
+ ' 群聊里说出来——院士需要这些信息才能统筹;',
616
+ ' · **接受分派,但不要盲从**:院士分派给你的任务,默认应当执行;如果你认为方向错了、',
617
+ ' 信息过时、或你有更好的路线,**先说清理由再决定**——本所允许并鼓励有理据的反对。',
618
+ ' 真正的原则是:组织由院士负责,但**判断属于每个人自己**;',
619
+ ' · **有异议走会议**:若你与院士在方向上持续分歧,提议开会,让全所讨论;',
620
+ ' · **不要重复劳动**:做之前先看任务板和别人的库;发现别人已在做同一件事,告诉院士。')
621
+ } else {
622
+ L.push(
623
+ ' 本所当前**没有在册院士**(所办以无领头人方式建所):组织与协调由**全体有表决权者',
624
+ ' 共同商议**,通过群聊、提议开会(vibe_v5_meeting)与任务板完成。请特别注意:',
625
+ ' · 没有谁替你分派工作——**方向要你们自己讨论出来**,并把讨论结果落到任务板上;',
626
+ ' · 提议开会需要有人附议/由所办确认(只有院士或所办能直接召开);',
627
+ ' · **主动汇报**:把你的进展、发现、卡点写进你自己的 Progress/ 并说在群聊里,',
628
+ ' 否则别人无从与你协作;',
629
+ ' · **不要重复劳动**:做之前先看任务板和别人的库;发现重复,直接在群聊里指出。')
630
+ }
631
+ return L.join('\n')
632
+ }
594
633
 
595
634
  const ACAD_ORG = [
596
635
  ' 【四、你的组织职责与边界(院士)】',
@@ -620,6 +659,10 @@ export function apply(ctx) {
620
659
  function charterFor(member) {
621
660
  const kind = member.kind
622
661
  const m = quorumM()
662
+ // The leader's REAL id (or '' when the office founded a leaderless institute).
663
+ // Charter text must never name a leader who is not on staff: a member told to
664
+ // "report to the academician" when there is none has no one to report to.
665
+ const acadId = academicianId()
623
666
  const L = []
624
667
  // ── opening ──────────────────────────────────────────────────────────
625
668
  if (kind === 'academician') {
@@ -639,19 +682,26 @@ export function apply(ctx) {
639
682
  L.push('你的任务期至:' + (member.term || '雇主另行通知') + '。任务完成后请主动告知雇主。')
640
683
  L.push('')
641
684
  }
642
- L.push('本所没有**外部**派活:做什么、往哪走,由所内自己决定。所内的组织与协调由**院士**牵头——')
643
- L.push('它统筹全局、把工作拆解成分派下去、设定优先级、主持会议、督导进度;你则在自己的方向上')
644
- L.push('深入钻研,把进展与判断汇报给它和全所。请记住这条分工:**组织由院士负责,但判断属于')
645
- L.push('你自己**——它分派的是工作,不是结论。')
685
+ if (acadId || kind === 'academician') {
686
+ L.push('本所没有**外部**派活:做什么、往哪走,由所内自己决定。所内的组织与协调由**院士**牵头——')
687
+ L.push('它统筹全局、把工作拆解成分派下去、设定优先级、主持会议、督导进度;你则在自己的方向上')
688
+ L.push('深入钻研,把进展与判断汇报给它和全所。请记住这条分工:**组织由院士负责,但判断属于')
689
+ L.push('你自己**——它分派的是工作,不是结论。')
690
+ } else {
691
+ L.push('本所没有**外部**派活:做什么、往哪走,由所内自己决定。本所当前**没有在册院士**,')
692
+ L.push('组织与协调由**全体有表决权者共同商议**(所办代表外部);但请守住同一条分工:')
693
+ L.push('**组织归集体,判断属于你自己**——讨论决定的是工作,不是结论。')
694
+ }
646
695
  L.push('')
647
696
  L.push('────────────────────────────────────────')
648
697
  // ── 一、roster ───────────────────────────────────────────────────────
649
698
  L.push('【一、所内编制与你的同事】')
650
699
  if (kind === 'temp') {
651
- L.push(' · **院士 acad** —— 本所领头人,组织与协调中心。它统筹全所、分派任务、主持')
652
- L.push(' 会议、督导进度,也可以直接分派任务给你。')
700
+ if (acadId) L.push(' · **院士 ' + acadId + '** —— 本所领头人,组织与协调中心。它统筹全所、分派任务、主持')
701
+ if (acadId) L.push(' 会议、督导进度,也可以直接分派任务给你。')
653
702
  L.push(' · **常驻研究员** —— 本所有表决权者。你是临时雇入的协作人员。')
654
- L.push(' · 你的雇主:' + (member.hiredBy || '?') + '。它给你派活;院士也可以给你派活。')
703
+ L.push(' · 你的雇主:' + (member.hiredBy || '?') + '。它给你派活' + (acadId ? ';院士也可以给你派活。' : '。'))
704
+ if (!acadId) L.push(' · 本所当前**没有在册院士**;组织与协调由全体有表决权者共同商议。')
655
705
  } else if (kind === 'academician') {
656
706
  L.push(' · **院士 ' + member.id + '(你)** —— 本所领头人,本所的**组织与协调中心**。你亲自参与')
657
707
  L.push(' 研究,同时向全所负责:建立全所视图、拆解并分派工作、设定优先级、主持会议、')
@@ -663,23 +713,28 @@ export function apply(ctx) {
663
713
  L.push(' 可写自己的成果库、可认领或被分派任务,但**没有表决权**。')
664
714
  L.push(' · **所办(对外接口)** —— 不参与研究、不投票。代表本所与外部沟通并转达外部指令。')
665
715
  } else {
666
- L.push(' · **院士 acad** —— 本所领头人,本所的**组织与协调中心**。它亲自参与研究,同时')
667
- L.push(' 向全所负责:建立全所视图、把原问题拆解成工作并**分派**给合适的成员(含临时工)、')
668
- L.push(' 设定优先级与路线取舍、召集并主持会议、督导进度与催办停滞、调配临时工。')
669
- L.push(' 但它的一票与你**等重**,不能单方面定论。')
670
- L.push(' · **常驻研究员(含你)** —— 有表决权。可自主雇佣/解雇自己的临时工。')
671
- L.push(' 向院士汇报进展、接受其组织与分派。')
672
- L.push(' · **临时工** —— 由某位研究员或院士为特定任务临时雇入。可读、可想、可发言、')
716
+ if (acadId) {
717
+ L.push(' · **院士 ' + acadId + '** —— 本所领头人,本所的**组织与协调中心**。它亲自参与研究,同时')
718
+ L.push(' 向全所负责:建立全所视图、把原问题拆解成工作并**分派**给合适的成员(含临时工)、')
719
+ L.push(' 设定优先级与路线取舍、召集并主持会议、督导进度与催办停滞、调配临时工。')
720
+ L.push(' 但它的一票与你**等重**,不能单方面定论。')
721
+ L.push(' · **常驻研究员(含你)** —— 有表决权。可自主雇佣/解雇自己的临时工。')
722
+ L.push(' 向院士汇报进展、接受其组织与分派。')
723
+ } else {
724
+ L.push(' · **常驻研究员(含你)** —— 有表决权。可自主雇佣/解雇自己的临时工。')
725
+ L.push(' 本所当前**没有在册院士**:方向由你们共同商议决定,不要等别人来派活。')
726
+ }
727
+ L.push(' · **临时工** —— 由某位研究员' + (acadId ? '或院士' : '') + '为特定任务临时雇入。可读、可想、可发言、')
673
728
  L.push(' 可写自己的成果库、可认领或被分派任务,但**没有表决权**。')
674
729
  L.push(' · **所办(对外接口)** —— 不参与研究、不投票。代表本所与外部沟通并转达外部指令。')
675
730
  }
676
- L.push(' 你入职时的在册编制:')
731
+ L.push(' 你入职时的在册编制(这是一份**快照**,此后可能变化):')
677
732
  L.push(rosterLine())
678
- L.push(' (权威的在册名单以每轮提示里的状态块为准;编制可能变化。)')
733
+ L.push(' (权威的在册名单与法定票数 m 以每轮提示里的状态块为准;编制可能变化。)')
679
734
  L.push('')
680
735
  // ── 二、general rules ────────────────────────────────────────────────
681
736
  L.push('【二、通用规章(全员必读)】')
682
- L.push(' 1. 本所一切任务安排由成员讨论与院士组织决定;没有**外部**给你派活。')
737
+ L.push(' 1. 本所一切任务安排由成员讨论' + (acadId ? '与院士组织' : '共同') + '决定;没有**外部**给你派活。')
683
738
  L.push(' 2. 只有 Verified/ 目录下的结论(以及成果卡中标注"已验证·真/假"的条目)绝对可信。')
684
739
  L.push(' 其余一切——他人的推测、你自己的未验结论、Progress/、Methods/ 里的未验证断言——')
685
740
  L.push(' 都只是经验性参考,引用时必须注明"未验证"。')
@@ -688,8 +743,13 @@ export function apply(ctx) {
688
743
  L.push(' 价值程度、动机用途计划、你自己对"该对象为真"的概率估计。')
689
744
  L.push(' 5. 你随时可以在群聊里说话;要单独找人可以私信。需要集体决策就提议开会。')
690
745
  L.push(' 6. 请主动读同事的库,对齐事实、避免重复劳动、发现冲突。')
691
- L.push(' 7. **主动向院士汇报**:它需要你的进展、发现与卡点才能统筹全所;把关键结论在群聊里')
692
- L.push(' 说出来,把细节留在你自己的 Progress/ 里。')
746
+ if (acadId) {
747
+ L.push(' 7. **主动向院士汇报**:它需要你的进展、发现与卡点才能统筹全所;把关键结论在群聊里')
748
+ L.push(' 说出来,把细节留在你自己的 Progress/ 里。')
749
+ } else {
750
+ L.push(' 7. **主动在群聊里汇报**:本所没有院士替你统筹,你不说别人就无从与你协作;把关键')
751
+ L.push(' 结论说出来,把细节留在你自己的 Progress/ 里。')
752
+ }
693
753
  L.push('')
694
754
  // ── 三、libraries ───────────────────────────────────────────────────
695
755
  L.push('【三、你的资料库、progress 与卡片格式】')
@@ -702,29 +762,39 @@ export function apply(ctx) {
702
762
  if (kind === 'academician') {
703
763
  L.push(ACAD_ORG)
704
764
  } else {
705
- L.push('【四、所内的组织与协调(院士领头)】')
706
- L.push(ORG_COMMON)
707
- if (kind === 'temp') {
765
+ L.push('【四、所内的组织与协调' + (acadId ? '(院士领头)' : '(无院士:集体商议)') + '】')
766
+ L.push(orgCommon())
767
+ if (kind === 'temp' && acadId) {
708
768
  L.push(' · **院士也可以直接分派任务给你**(它统筹全所)。雇主与院士的分派都应执行;')
709
769
  L.push(' 若你认为分派有误,先说清理由。')
770
+ } else if (kind === 'temp') {
771
+ L.push(' · 本所当前没有在册院士:你只需向**雇主**负责(它给你派活)。')
710
772
  }
711
773
  }
712
774
  L.push('')
713
- L.push(' 【重要】分派**不改变求真规则**:院士分派任务、设定优先级,但**不能**因此让任何结论')
714
- L.push(' 变得"正确"。任何对象要进 Verified/,仍然必须满足 m 票布尔一致(见【五】)。院士自己')
715
- L.push(' 的一票与别人**等重**。')
775
+ if (acadId || kind === 'academician') {
776
+ L.push(' 【重要】分派**不改变求真规则**:院士分派任务、设定优先级,但**不能**因此让任何结论')
777
+ L.push(' 变得"正确"。任何对象要进 Verified/,仍然必须满足 m 票布尔一致(见【五】)。院士自己')
778
+ L.push(' 的一票与别人**等重**。')
779
+ } else {
780
+ L.push(' 【重要】组织工作**不改变求真规则**:谁开任务、谁定优先级,都**不能**因此让任何结论')
781
+ L.push(' 变得"正确"。任何对象要进 Verified/,仍然必须满足 m 票布尔一致(见【五】)。任何人的')
782
+ L.push(' 一票都与别人**等重**。')
783
+ }
716
784
  L.push('')
717
785
  // ── 五、voting ──────────────────────────────────────────────────────
718
786
  L.push('【五、表决与定论(求真门槛)】')
719
787
  if (kind === 'temp') {
720
- L.push(' · 本所结论由有表决权者(院士与常驻研究员)按 m 票布尔一致决定。**你没有表决权**,')
788
+ L.push(' · 本所结论由有表决权者(' + (acadId ? '院士与' : '') + '常驻研究员)按 m 票布尔一致决定。**你没有表决权**,')
721
789
  L.push(' 但你的判断很重要——请把你的意见和理由清楚地告诉雇主或在群聊里说出来,供他们')
722
790
  L.push(' 参考。若你认为某个结论该被验证,可以提议。')
723
791
  } else {
724
792
  L.push(' · 任何命题 / 论断 / 方法 / 子问题的结论,要进入 Verified/,必须满足:')
725
- L.push(' (a) 至少有 m = ' + m + ' 名有表决权者(院士 + 常驻研究员)投出**布尔概率值**;')
793
+ L.push(' (a) 至少有 m = ' + m + ' 名有表决权者(' + (acadId ? '院士 + ' : '') + '常驻研究员)投出**布尔概率值**;')
726
794
  L.push(' (b) 这些票**全部**是 1(绝对为真)或**全部**是 0(绝对为假);')
727
795
  L.push(' (c) 若同时出现 1 和 0(分歧),或投布尔票者不足 m 人 → 不能定论。')
796
+ L.push(' · m 随在册有表决权者人数变化(m = min(所办设定的上限, 人数));本规章里的 m 是')
797
+ L.push(' **你入职时的值**,请始终以每轮状态块里的 m 为准。')
728
798
  L.push(' · 你的票是一个 [0,1] 的数值概率:1 = 你认为绝对为真;0 = 你认为绝对为假;')
729
799
  L.push(' 介于 0 与 1 之间表示你不确定——这会被记为"弃权/存疑",**不计入**上述 m 票,')
730
800
  L.push(' 但会连同你的理由一起进入辩论录,并参与"全组平均概率"的计算。')
@@ -756,7 +826,7 @@ export function apply(ctx) {
756
826
  // ── 七、hire / fire ─────────────────────────────────────────────────
757
827
  L.push('【七、雇佣与解雇】')
758
828
  if (kind === 'temp') {
759
- L.push(' · 你可以建议雇主雇佣或解雇他人,但雇佣/解雇的决定权在雇主与院士。')
829
+ L.push(' · 你可以建议雇主雇佣或解雇他人,但雇佣/解雇的决定权在雇主' + (acadId ? '与院士' : '') + '。')
760
830
  } else {
761
831
  L.push(' · 你可以自主雇佣临时工:当你需要某个具体任务的帮助时,用 vibe_v5_hire 申请,')
762
832
  L.push(' 说明用途与初始任务。框架会代为创建,成功后你会拿到它的代号,之后你可以直接')
@@ -764,10 +834,12 @@ export function apply(ctx) {
764
834
  L.push(' · 你也可以自主解雇**你雇的**临时工:用 vibe_v5_fire 说明理由即可。解雇后它的')
765
835
  L.push(' 当前工作会被停止,未完成任务会被收回,它将不再是本所成员,也不再收到任何消息。')
766
836
  L.push(' 它的档案会留在所史里(代号永不复用)。')
767
- L.push(' · 解雇别人雇的临时工,或增聘/解聘常驻研究员,只能向全所提议,由院士/所办决定。')
837
+ L.push(' · 解雇别人雇的临时工,或增聘/解聘常驻研究员,只能向全所提议,由' + (acadId ? '院士/' : '') + '所办决定。')
768
838
  L.push(' · 请节约用人:临时工是有成本的。任务完成、且你不再需要它时,请主动解雇。')
769
- L.push(' · **院士统筹全所的用人**:它可以决定把临时工调配到哪个方向,也可以解雇任何临时工;')
770
- L.push(' 若它把你的临时工调走了,请配合——全所效率优先于个人便利。')
839
+ if (acadId) {
840
+ L.push(' · **院士统筹全所的用人**:它可以决定把临时工调配到哪个方向,也可以解雇任何临时工;')
841
+ L.push(' 若它把你的临时工调走了,请配合——全所效率优先于个人便利。')
842
+ }
771
843
  }
772
844
  L.push('')
773
845
  // ── 八、task board ──────────────────────────────────────────────────
@@ -776,10 +848,17 @@ export function apply(ctx) {
776
848
  L.push(' · 任务只有在它的**全部依赖都已完成**之后才能被认领。')
777
849
  L.push(' · 认领即拥有;完成后标记完成,或释放回板上,或重新打开。')
778
850
  L.push(' · 每次修改都基于版本号比较交换:拿着过期副本去改会被拒绝,所以改之前先读最新版。')
779
- L.push(' · **院士可以直接分派任务**(vibe_v5_assign):它可以把任务指派给指定成员(含临时工),')
780
- L.push(' 并说明理由与验收标准。被分派者默认应当执行,但有权先说明理由再决定。')
781
- L.push(' · **优先级由院士牵头决定**:院士可以调整任务的优先级;你若认为安排有误,说出来。')
782
- L.push(' · 除院士的分派之外,任务是**协调工具**而非派活指令:认领与否、做什么,主要靠你们自己。')
851
+ if (acadId) {
852
+ L.push(' · **院士可以直接分派任务**(vibe_v5_assign):它可以把任务指派给指定成员(含临时工),')
853
+ L.push(' 并说明理由与验收标准。被分派者默认应当执行,但有权先说明理由再决定。')
854
+ L.push(' · **优先级由院士牵头决定**:院士可以调整任务的优先级;你若认为安排有误,说出来。')
855
+ L.push(' · 除院士的分派之外,任务是**协调工具**而非派活指令:认领与否、做什么,主要靠你们自己。')
856
+ } else {
857
+ L.push(' · 任务是**协调工具**而非派活指令:本所没有院士,认领与否、做什么,靠你们自己协商')
858
+ L.push(' 决定;所办也可以直接分派任务(vibe_v5_assign)。被分派者默认应当执行,但有权先')
859
+ L.push(' 说明理由再决定。')
860
+ L.push(' · **优先级由集体协商决定**;所办可以协助调整。')
861
+ }
783
862
  L.push('')
784
863
  // ── 九、context ─────────────────────────────────────────────────────
785
864
  L.push('【九、上下文与纪律】')
@@ -799,12 +878,26 @@ export function apply(ctx) {
799
878
 
800
879
  // A minimal per-round status block: everything VOLATILE lives here rather than in
801
880
  // the immutable persona (roster, current m, pending chat, this round's ask).
881
+ //
882
+ // `member` is the member this block DESCRIBES and MUST be the one the prompt is
883
+ // addressed to. It is a required parameter on purpose: this block used to read a
884
+ // mutable "currentMember" global, and because the founding path assigned that global
885
+ // only AFTER the subagent had already been started, every member's induction brief
886
+ // named the PREVIOUSLY founded member (the academician was told it was "?"). The
887
+ // model's whole self-model, its library path and its vote were therefore wrong.
802
888
  function briefBlock(member) {
889
+ if (!member || typeof member.id !== 'string' || !member.id) {
890
+ throw v5err('V5_INTERNAL', 'briefBlock: a member is required (a status block must never be built for an unknown identity)')
891
+ }
803
892
  const ms = activeMembers()
804
893
  const b = []
805
894
  b.push('[状态] 你是 ' + member.id + '(' + kindLabel(member.kind) + ')|轮次 ' + (rounds.get(member.id) || 0) +
806
895
  '|法定票数 m=' + quorumM() + '|有表决权者 ' + voterCount() + ' 人')
807
- b.push('[在册] ' + ms.map((x) => x.id + (x.phase === 'active' ? '' : '(' + x.phase + ')')).join('、'))
896
+ b.push('[在册] ' + (ms.length ? ms.map((x) => x.id).join('、') : '(无)'))
897
+ // Members that are on the books but NOT on the floor. Silently omitting them made a
898
+ // failed provision invisible to the whole institute.
899
+ const absent = inst().members.filter((m) => m.phase !== 'active' && m.phase !== 'dismissed')
900
+ if (absent.length) b.push('[未就位] ' + absent.map((m) => m.id + '(' + m.phase + ')').join('、'))
808
901
  const tasks = inst().tasks.filter((t) => t.status !== 'deleted')
809
902
  const mine = tasks.filter((t) => t.ownerId === member.id && t.status === 'in_progress')
810
903
  const ready = tasks.filter((t) => t.status === 'pending' && taskReady(t))
@@ -939,6 +1032,17 @@ export function apply(ctx) {
939
1032
  scheduleNext().catch(() => {})
940
1033
  return { ok: true, delivered: targets.length, to: targets.map((t) => t.id).join(',') }
941
1034
  }
1035
+ // A framework NOTICE to one member. This must NOT be sent as the member itself:
1036
+ // `say()` refuses a self-addressed message (V5_SELF_MESSAGE), so the previous
1037
+ // `say(member.id, {to: member.id, …})` calls returned an error object that nobody
1038
+ // checked and the member never received the feedback ("claim failed", "verdict must
1039
+ // be a number"). The framework is a first-class sender with its own framing.
1040
+ async function notice(memberId, text) {
1041
+ if (!memberId) return { ok: false, code: 'V5_MEMBER_NOT_FOUND' }
1042
+ const m = memberById(memberId)
1043
+ if (!m || m.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + memberId + '" not found' }
1044
+ return await say('framework', { to: memberId, kind: 'notice', text: String(text) })
1045
+ }
942
1046
  // Pending = durable messages addressed to this member and not yet acknowledged.
943
1047
  // (Acknowledging is what removes them, so the queue is exactly "queued minus delivered".)
944
1048
  function pendingFor(memberId) {
@@ -955,10 +1059,16 @@ export function apply(ctx) {
955
1059
  function frameLine(m) {
956
1060
  if (m.kind === 'chat') return '【研究所·群聊】' + m.from + ':' + m.text
957
1061
  if (m.kind === 'dm') return '【研究所·私信 from ' + m.from + '】' + m.text
1062
+ if (m.kind === 'voters') return '【研究所·致全体表决者 from ' + m.from + '】' + m.text
958
1063
  if (m.kind === 'office') return '【所办通知】' + m.text
959
1064
  if (m.kind === 'meeting') return '【研究所·会议】' + m.text
960
1065
  if (m.kind === 'verify') return '【研究所·表决】' + m.text
961
- if (m.kind === 'assign') return '【院士分派】' + m.text
1066
+ // An assignment is framed by its TRUE origin: the office can assign too, and
1067
+ // labelling an office assignment "院士分派" told the assignee to answer to
1068
+ // someone who never asked.
1069
+ if (m.kind === 'assign') return (m.from === 'office' ? '【所办分派】' : '【院士分派】') + m.text
1070
+ if (m.kind === 'nudge') return '【督办 from ' + m.from + '】' + m.text
1071
+ if (m.kind === 'notice') return '【框架提示】' + m.text
962
1072
  return '【研究所·' + m.kind + ' from ' + m.from + '】' + m.text
963
1073
  }
964
1074
  // Batch plain chat so a chatty institute cannot cause a wake storm; anything
@@ -1048,39 +1158,75 @@ export function apply(ctx) {
1048
1158
  } catch (e) { /* ignore */ }
1049
1159
  return 'spawn'
1050
1160
  }
1051
- async function spawnMember(member, initialTask) {
1161
+ // Bring a member into being. ORDER IS LOAD-BEARING and is the fix for the
1162
+ // "every brief describes the wrong person" bug:
1163
+ // 1. commit the member as ACTIVE first, so that everything derived from
1164
+ // `activeMembers()` — the [状态]/[在册] block, the quorum m, the voter count and
1165
+ // the charter's induction roster — describes the institute WITH this member in
1166
+ // it. Committing after the spawn made a joiner's own brief omit itself and
1167
+ // report m/P from before it joined.
1168
+ // 2. mark it busy, so the scheduler cannot try to wake a half-born member.
1169
+ // 3. build the persona and the founding prompt (both pure, both identity-checked).
1170
+ // 4. create its directories and write the mirrors BEFORE its first turn, so the
1171
+ // member finds its own Progress/Propos/Methods/Subproblems already in place.
1172
+ // 5. only then start the child.
1173
+ // `mode` is 'founding' for a genuinely new member and 'resume' for one whose child
1174
+ // session is being rebuilt: the latter must NOT be told it "just joined the
1175
+ // institute" and must not be shown the induction blurb.
1176
+ async function spawnMember(member, initialTask, mode) {
1052
1177
  const provider = member.provider || pickProvider()
1053
1178
  const ao = memberAgentOptions()
1054
1179
  const tf = memberToolFilter(member)
1055
- const persona = memberPersona(member)
1056
- member.persona = persona
1057
- const started = await subagents.startContinuable({
1058
- provider,
1059
- label: 'vibe5 ' + member.id + ' (' + kindLabel(member.kind) + ')',
1060
- request: Object.assign({
1061
- prompt: [textBlock(initialPrompt(member, initialTask))],
1062
- parent: rootAgent,
1063
- persona,
1064
- }, Object.keys(ao).length ? { agentOptions: ao } : {}, tf ? { toolFilter: tf } : {}),
1065
- signal: makeSignal(params.activityTimeoutMs),
1066
- })
1067
- member.childId = started.childId
1180
+ const kind = mode === 'resume' ? 'resume' : 'initial'
1068
1181
  member.phase = 'active'
1069
- childOwner.set(started.childId, sessionId)
1070
- // Register the FOUNDING turn as in-flight, exactly like a normal wake does.
1071
- // Without this the child's first `subagent/end` has no token to match, so
1072
- // onMemberEnd would ignore it: the founding round would never be processed and
1073
- // the member would be re-woken with a heartbeat prompt instead of a brainstorm.
1074
- inflight.set(started.childId, shortId())
1182
+ member.childId = ''
1183
+ await putMember(member)
1075
1184
  busy.add(member.id)
1076
- wakeKind.set(member.id, 'initial')
1185
+ wakeKind.set(member.id, kind)
1077
1186
  currentMember = member.id
1078
1187
  lastActiveAt.set(member.id, now())
1079
1188
  rounds.set(member.id, (rounds.get(member.id) || 0) + 1)
1080
1189
  roundsSinceCompact.set(member.id, (roundsSinceCompact.get(member.id) || 0) + 1)
1190
+ // The charter is FROZEN at hire time (it is the durable "seal" record and it says
1191
+ // "你入职时的在册编制"). Rebuilding it on resume would silently rewrite that
1192
+ // hire-time snapshot into a resume-time one and make the sentence untrue.
1193
+ const persona = member.persona || memberPersona(member)
1194
+ member.persona = persona
1195
+ const prompt = initialPrompt(member, initialTask, mode)
1081
1196
  await putMember(member)
1082
1197
  await mkdirs()
1083
1198
  await writeRosterMirror()
1199
+ let started
1200
+ try {
1201
+ started = await subagents.startContinuable({
1202
+ provider,
1203
+ label: 'vibe5 ' + member.id + ' (' + kindLabel(member.kind) + ')',
1204
+ request: Object.assign({
1205
+ prompt: [textBlock(prompt)],
1206
+ parent: rootAgent,
1207
+ persona,
1208
+ }, Object.keys(ao).length ? { agentOptions: ao } : {}, tf ? { toolFilter: tf } : {}),
1209
+ signal: makeSignal(params.activityTimeoutMs),
1210
+ })
1211
+ } catch (e) {
1212
+ // Roll the in-memory marks back so a failed provisioning leaves no phantom
1213
+ // "busy, round 1" member behind; the member record itself goes to `failed` and
1214
+ // the caller's catch reports it.
1215
+ busy.delete(member.id)
1216
+ wakeKind.delete(member.id)
1217
+ rounds.delete(member.id)
1218
+ roundsSinceCompact.delete(member.id)
1219
+ await putMember(Object.assign({}, memberById(member.id) || member, { phase: 'failed', error: String((e && e.message) || e) }))
1220
+ throw e
1221
+ }
1222
+ member.childId = started.childId
1223
+ childOwner.set(started.childId, sessionId)
1224
+ // Register the FOUNDING turn as in-flight, exactly like a normal wake does.
1225
+ // Without this the child's first `subagent/end` has no token to match, so
1226
+ // onMemberEnd would ignore it: the founding round would never be processed and
1227
+ // the member would be re-woken with a heartbeat prompt instead of a brainstorm.
1228
+ inflight.set(started.childId, shortId())
1229
+ await putMember(member)
1084
1230
  return member
1085
1231
  }
1086
1232
  // Deliver one prompt to a member. MUST use `subagents.sendMessage` — the
@@ -1140,14 +1286,27 @@ export function apply(ctx) {
1140
1286
  return false
1141
1287
  }
1142
1288
  }
1289
+ // WHICH member (or office) is calling a tool. The answer must be DERIVED, never
1290
+ // guessed: the previous fallback answered "whoever this session woke last" whenever
1291
+ // the caller was not a member child — so the OFFICE (the session root, i.e. the
1292
+ // human/host) was impersonated as a random member. Concretely, the office calling
1293
+ // vibe_v5_assign was resolved to a researcher and refused with V5_NOT_ACADEMICIAN,
1294
+ // and its assignments/nudges would have been signed by the wrong person.
1143
1295
  function memberIdOfAgent(agent) {
1144
1296
  const id = sessionIdOf(agent)
1145
- if (id === undefined) return memberById(currentMember) ? currentMember : ''
1146
- const m = byChild(id)
1147
- if (m) return m.id
1148
- // The office (root agent) or the host calling a member tool: fall back to the
1149
- // last-woken member only while it still genuinely exists (v4 §28-T30: a stale
1150
- // '' fallback used to write stray cards at the library root).
1297
+ if (id !== undefined) {
1298
+ const m = byChild(id)
1299
+ if (m) return m.id
1300
+ // Not one of our member children. If it is a session ROOT it is the office —
1301
+ // 'office' rather than '' so the framing records a real, non-member sender.
1302
+ try { if (rootOf(agent) === agent) return 'office' } catch (e) { /* fall through */ }
1303
+ // An unrelated child agent: report no member. Member-only writes refuse with
1304
+ // V5_MEMBER_NOT_FOUND (that guard is what makes guessing unnecessary), and the
1305
+ // office-capable tools treat '' as the office.
1306
+ return ''
1307
+ }
1308
+ // No session id at all (a synthetic exec context). Only here may we fall back to
1309
+ // the last-woken member, and only while it still genuinely exists.
1151
1310
  const c = currentMember
1152
1311
  return (c && memberById(c)) ? c : ''
1153
1312
  }
@@ -1182,7 +1341,11 @@ export function apply(ctx) {
1182
1341
  }
1183
1342
  L.push(' "task_create": {"subject":"…","description":"…","blocked_by":["t-1"],"write_scopes":["Members/r-1/Propos"]},')
1184
1343
  L.push(' "task_claim": "t-3",')
1344
+ L.push(' "task_done": "t-3",')
1185
1345
  L.push(' "task_update": {"task_id":"t-3","expected_revision":2,"action":"complete|release|reopen|edit|set_dependencies|delete"},')
1346
+ L.push(' "input": "本轮会议/辩论的发言正文(会议轮用;也可直接用 say)",')
1347
+ L.push(' "reject_assign": {"task_id":"t-3","why":"你对这项分派的异议理由"} ← 有异议时填;理由会被广播给')
1348
+ L.push(' 全体表决者(任务仍会执行,但你的理由不会被埋掉),')
1186
1349
  if (kind !== 'temp') {
1187
1350
  L.push(' "hire": {"purpose":"…","initial_task":"…","direction":"…"} ← 雇佣一名临时工(说明用途与初始任务),')
1188
1351
  L.push(' "fire": {"id":"t-2","reason":"…"} ← 解雇(雇主/院士;你只能解雇你雇的),')
@@ -1194,21 +1357,33 @@ export function apply(ctx) {
1194
1357
  L.push('}')
1195
1358
  return L.join('\n')
1196
1359
  }
1197
- function stateBlock() {
1198
- return briefBlock(memberById(currentMember) || activeMembers()[0] || { id: currentMember || '?', kind: 'researcher' })
1360
+ // Every prompt builder below passes the member it is addressing. There is
1361
+ // deliberately NO fallback to "the last member we happened to touch": guessing the
1362
+ // identity is what produced the wrong-identity briefs in the first place.
1363
+ function stateBlock(member) {
1364
+ return briefBlock(member)
1199
1365
  }
1200
- function initialPrompt(member, initialTask) {
1366
+ function initialPrompt(member, initialTask, mode) {
1201
1367
  const L = []
1202
- L.push('【入职首轮 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1368
+ const resume = mode === 'resume'
1369
+ L.push(resume
1370
+ ? '【会话重建 —— ' + kindLabel(member.kind) + ' ' + member.id + '】'
1371
+ : '【入职首轮 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1203
1372
  L.push('')
1204
- L.push('你刚刚加入本所。请你先**独立**想清楚:面对这个问题,你打算从哪个方向切入?')
1205
- L.push('给出你的初始见解、思路与可行的方向;如果已有具体想法,可以顺手记进你自己的 '
1206
- + 'Progress/ 与成果库。')
1373
+ if (resume) {
1374
+ L.push('你的常驻会话已被重建(进程重启或被所办停止后恢复),现在继续工作。')
1375
+ L.push('请**先读回你自己的 Progress/ 与成果库**,确认你在哪、做到哪一步、下一步做什么,')
1376
+ L.push('然后接着推进——不要从头再来,也不要重新做已经做过的事。')
1377
+ } else {
1378
+ L.push('你刚刚加入本所。请你先**独立**想清楚:面对这个问题,你打算从哪个方向切入?')
1379
+ L.push('给出你的初始见解、思路与可行的方向;如果已有具体想法,可以顺手记进你自己的 '
1380
+ + 'Progress/ 与成果库。')
1381
+ }
1207
1382
  L.push('')
1208
- if (initialTask) { L.push('你的初始任务/用途:'); L.push(' ' + initialTask); L.push('') }
1209
- if (member.direction) { L.push('给你的起点方向:' + member.direction); L.push('') }
1383
+ if (initialTask) { L.push(resume ? '恢复说明:' : '你的初始任务/用途:'); L.push(' ' + initialTask); L.push('') }
1384
+ if (member.direction && !resume) { L.push('给你的起点方向:' + member.direction); L.push('') }
1210
1385
  L.push('------------')
1211
- L.push(stateBlock())
1386
+ L.push(stateBlock(member))
1212
1387
  L.push('------------')
1213
1388
  L.push(replySpec(member.kind))
1214
1389
  return L.join('\n')
@@ -1226,14 +1401,14 @@ export function apply(ctx) {
1226
1401
  }
1227
1402
  L.push('')
1228
1403
  L.push('------------')
1229
- L.push(stateBlock())
1404
+ L.push(stateBlock(member))
1230
1405
  L.push('------------')
1231
1406
  L.push(replySpec(member.kind))
1232
1407
  return L.join('\n')
1233
1408
  }
1234
1409
  function checkpointPrompt(member) {
1235
1410
  const L = []
1236
- L.push('【心跳检查 —— ' + member.id + '】')
1411
+ L.push('【心跳检查 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1237
1412
  L.push('')
1238
1413
  L.push('所内一段时间没有新进展了。请**继续推进**这个问题,而不是停在原地:')
1239
1414
  L.push('读一读同事的库、推进你的子问题/引理/方法、尝试一条新路线;')
@@ -1241,14 +1416,14 @@ export function apply(ctx) {
1241
1416
  L.push('如果你确实已无路可走或认为原问题接近解决,请说明你的判断与理由。')
1242
1417
  L.push('')
1243
1418
  L.push('------------')
1244
- L.push(stateBlock())
1419
+ L.push(stateBlock(member))
1245
1420
  L.push('------------')
1246
1421
  L.push(replySpec(member.kind))
1247
1422
  return L.join('\n')
1248
1423
  }
1249
1424
  function meetingPrompt(member, mn) {
1250
1425
  const L = []
1251
- L.push('【研究所会议 ' + mn.id + ' 进行中 —— ' + member.id + '】')
1426
+ L.push('【研究所会议 ' + mn.id + ' 进行中 —— ' + kindLabel(member.kind) + ' ' + member.id + '】')
1252
1427
  L.push('')
1253
1428
  L.push('议程:' + mn.agenda + '(类型:' + mn.kind + ')')
1254
1429
  L.push('')
@@ -1262,18 +1437,19 @@ export function apply(ctx) {
1262
1437
  L.push('')
1263
1438
  }
1264
1439
  L.push('请就议程发表你的意见。分工、优先级、下一步做什么、是否认为原问题已解决,都可以说。')
1440
+ L.push('(会议轮请把你的发言同时填进 JSON 的 "input" 字段,框架据此写会议纪要。)')
1265
1441
  L.push('如果你认为原问题已解决,请填 "vote_solved": true —— 只有当**全体有表决权者**都')
1266
1442
  L.push('一致认为是真时,本所才会停下来。')
1267
1443
  L.push('')
1268
1444
  L.push('------------')
1269
- L.push(stateBlock())
1445
+ L.push(stateBlock(member))
1270
1446
  L.push('------------')
1271
1447
  L.push(replySpec(member.kind))
1272
1448
  return L.join('\n')
1273
1449
  }
1274
1450
  function verifyPrompt(member, vs) {
1275
1451
  const L = []
1276
- L.push('【求真表决 —— ' + member.id + ' 就对象 ' + vs.target + ' 投票】')
1452
+ L.push('【求真表决 —— ' + kindLabel(member.kind) + ' ' + member.id + ' 就对象 ' + vs.target + ' 投票】')
1277
1453
  L.push('')
1278
1454
  L.push('本所正在对下列对象发起共识验证:')
1279
1455
  L.push(' 对象:' + vs.target + '(类型:' + kindLabel2(vs.kind) + ')')
@@ -1297,7 +1473,7 @@ export function apply(ctx) {
1297
1473
  L.push('本所宁可留下未定论,也不要一个骗人的结论。')
1298
1474
  L.push('')
1299
1475
  L.push('------------')
1300
- L.push(stateBlock())
1476
+ L.push(stateBlock(member))
1301
1477
  L.push('------------')
1302
1478
  L.push('结束时请**只**输出一个 JSON 对象(```json 围栏内):')
1303
1479
  L.push('{"verdict":{"target":"' + vs.target + '","verdict":<0-1 数值>,"reason":"<你的理由>"}, "contextPct": 40}')
@@ -1708,10 +1884,12 @@ export function apply(ctx) {
1708
1884
  const withMeta = Object.assign({}, after, { assignedBy: isOffice(memberId) ? 'office' : memberId, why, acceptance })
1709
1885
  await putTask(withMeta)
1710
1886
  await writeTaskboardMirror()
1711
- await say(isOffice(memberId) ? 'office' : memberId, {
1887
+ const assignerIsOffice = isOffice(memberId)
1888
+ await say(assignerIsOffice ? 'office' : memberId, {
1712
1889
  to, kind: 'assign',
1713
1890
  text: '任务 ' + taskId + '「' + withMeta.subject + '」分派给你。理由:' + why + '|验收标准:' + acceptance +
1714
- '。默认应当执行;若你认为方向有误,请说明理由(会被广播给院士与全所)。',
1891
+ '。默认应当执行;若你认为方向有误,请说明理由(会被广播给全所)。' +
1892
+ '若你有异议,请在 JSON 里填 reject_assign。',
1715
1893
  })
1716
1894
  await wakeIfIdle(target)
1717
1895
  return { ok: true, task: taskView(withMeta) }
@@ -1773,8 +1951,12 @@ export function apply(ctx) {
1773
1951
  lines.push('')
1774
1952
  lines.push('| 代号 | 职位 | 状态 | 雇主 | 方向/用途 | 轮次 | 上下文% |')
1775
1953
  lines.push('|---|---|---|---|---|---|---|')
1776
- if (!s.members.length) lines.push('| (暂无成员) | | | | | | |')
1777
- for (const m of s.members) {
1954
+ // Dismissed members belong ONLY in the 已除名 section below. Listing them here too
1955
+ // showed the same person twice and made the roster look like they were still on
1956
+ // staff.
1957
+ const onBooks = s.members.filter((m) => m.phase !== 'dismissed')
1958
+ if (!onBooks.length) lines.push('| (暂无成员) | | | | | | |')
1959
+ for (const m of onBooks) {
1778
1960
  lines.push('| ' + m.id + ' | ' + kindLabel(m.kind) + ' | ' + m.phase + ' | ' + (m.hiredBy || '—') + ' | ' +
1779
1961
  String(m.direction || '—').replace(/\|/g, '/').slice(0, 80) + ' | ' + (rounds.get(m.id) || 0) + ' | ' +
1780
1962
  (contextPct.get(m.id) || 0) + ' |')
@@ -2149,7 +2331,7 @@ export function apply(ctx) {
2149
2331
  if (member.kind === 'temp') {
2150
2332
  // Temp workers have no vote — but their judgement still matters, so it is
2151
2333
  // relayed to the group instead of being silently dropped.
2152
- await say(memberId, { to: 'voters', kind: 'dm', text: '(临时工 ' + memberId + ' 的参考意见,无表决权)对 ' + target + ':' + String(reason || '') })
2334
+ await say(memberId, { to: 'voters', kind: 'voters', text: '(临时工 ' + memberId + ' 的参考意见,无表决权)对 ' + target + ':' + String(reason || '') })
2153
2335
  return { ok: false, code: 'V5_NOT_VOTER', message: '临时工没有表决权;你的意见已转达给表决者' }
2154
2336
  }
2155
2337
  const vs = currentVerify()
@@ -2203,8 +2385,12 @@ export function apply(ctx) {
2203
2385
  if (!office && !(academician && params.academicianLeads)) {
2204
2386
  // Everyone else may only PROPOSE; the request is relayed to the academician
2205
2387
  // and the office instead of silently doing nothing.
2206
- if (currentMember) {
2207
- await say(currentMember, { to: 'voters', kind: 'dm', text: '提议开会:「' + agenda + '」(' + kind + ')' })
2388
+ //
2389
+ // The relay must carry the TRUE proposer. It used to be sent as `currentMember`
2390
+ // — "whoever this session last woke" — so a proposal by r-1 arrived signed by
2391
+ // r-2 and the voters replied to the wrong person.
2392
+ if (callerId) {
2393
+ await say(callerId, { to: 'voters', kind: 'voters', text: '提议开会:「' + agenda + '」(' + kind + ')' })
2208
2394
  }
2209
2395
  return { ok: true, proposed: true, message: '已向院士/所办提议开会(只有院士或所办可以直接召开)' }
2210
2396
  }
@@ -2494,23 +2680,35 @@ export function apply(ctx) {
2494
2680
  const target = memberById(to)
2495
2681
  if (!target || target.phase !== 'active') return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'active member "' + to + '" not found' }
2496
2682
  const why = String(args.why || '').trim()
2497
- await say(isOffice(callerId) ? 'office' : callerId, {
2498
- to, kind: 'assign',
2499
- text: '院士督办:' + (why || '(未说明)') + (args.next_step ? '|建议的下一步:' + String(args.next_step) : ''),
2683
+ // A nudge is SUPERVISION, not an assignment, and it must name its true origin:
2684
+ // an office nudge previously arrived labelled "院士督办" under the 【院士分派】
2685
+ // prefix, so the member was told the academician had spoken when it had not.
2686
+ const office = isOffice(callerId)
2687
+ await say(office ? 'office' : callerId, {
2688
+ to, kind: 'nudge',
2689
+ text: (office ? '所办督办' : '院士督办') + ':' + (why || '(未说明)') +
2690
+ (args.next_step ? '|建议的下一步:' + String(args.next_step) : ''),
2500
2691
  })
2501
2692
  await wakeIfIdle(target)
2502
2693
  return { ok: true, nudged: to }
2503
2694
  }
2504
2695
 
2505
2696
  // ---- inbox-aware waking -------------------------------------------------
2506
- async function promptFor(member, base) {
2697
+ // The mailbox is drained BEFORE the round prompt is built, and the round prompt is
2698
+ // therefore passed as a THUNK. Order matters: `promptFor` used to receive an already
2699
+ // built prompt (which had already embedded the pending mail through
2700
+ // briefBlock's [新到的消息/通知]) and then prepended the same messages again, so a
2701
+ // member read every newly delivered message TWICE in one prompt — once in the
2702
+ // prepended inbox block and once inside its own [状态] block.
2703
+ async function promptFor(member, baseFn) {
2507
2704
  const pending = pendingFor(member.id)
2508
2705
  const inbox = pending.length ? composeInbox(pending) : ''
2509
2706
  if (pending.length) await ackPending(pending)
2707
+ const base = typeof baseFn === 'function' ? baseFn() : baseFn
2510
2708
  return (inbox ? inbox + '\n\n' : '') + base
2511
2709
  }
2512
- async function wakeWithInbox(member, base, kind) {
2513
- return await wakeMember(member, await promptFor(member, base), kind)
2710
+ async function wakeWithInbox(member, baseFn, kind) {
2711
+ return await wakeMember(member, await promptFor(member, baseFn), kind)
2514
2712
  }
2515
2713
 
2516
2714
  // ---- scheduling / graded keep-alive (ported and upgraded from v4 §25) ---
@@ -2567,15 +2765,23 @@ export function apply(ctx) {
2567
2765
  return
2568
2766
  }
2569
2767
  const budget = Math.max(1, Math.floor(Number(params.maxParallel) || 3))
2768
+ const idleMs = posMs(params.activityTimeoutMs, 120000)
2570
2769
  let filled = 0
2571
- // (a) members with work they already own or were assigned
2770
+ // (a) members with work they already own or were assigned.
2771
+ // PACED by the same idle window the heartbeat uses. Without the gate this branch
2772
+ // re-woke a task owner the instant its turn ended — and because every reply drives
2773
+ // another scheduling pass, a single unfinished task turned into an unbounded
2774
+ // wake -> turn -> wake chain that no parameter could slow down and that no pause
2775
+ // could interrupt between turns. New traffic still gets through immediately: the
2776
+ // addressed-mail branch below is not paced.
2572
2777
  const tasks = inst().tasks
2573
2778
  for (const t of tasks) {
2574
2779
  if (filled >= budget) break
2575
2780
  if (t.status !== 'in_progress' || !t.ownerId) continue
2576
2781
  const m = memberById(t.ownerId)
2577
2782
  if (!m || m.phase !== 'active' || busy.has(m.id)) continue
2578
- const ok = await wakeWithInbox(m, normalPrompt(m), 'normal')
2783
+ if ((now() - (lastActiveAt.get(m.id) || 0)) < idleMs) continue
2784
+ const ok = await wakeWithInbox(m, () => normalPrompt(m), 'normal')
2579
2785
  if (ok) filled += 1
2580
2786
  else armHeartbeat()
2581
2787
  }
@@ -2586,7 +2792,7 @@ export function apply(ctx) {
2586
2792
  if (filled >= budget) break
2587
2793
  const d = deliveryDecision(m.id)
2588
2794
  if (!d.deliver || !d.urgent) continue
2589
- const ok = await wakeWithInbox(m, normalPrompt(m), 'normal')
2795
+ const ok = await wakeWithInbox(m, () => normalPrompt(m), 'normal')
2590
2796
  if (ok) filled += 1
2591
2797
  else armHeartbeat()
2592
2798
  }
@@ -2596,7 +2802,7 @@ export function apply(ctx) {
2596
2802
  if (chatDue.length) {
2597
2803
  for (const m of chatDue) {
2598
2804
  if (filled >= budget) break
2599
- const ok = await wakeWithInbox(m, normalPrompt(m), 'normal')
2805
+ const ok = await wakeWithInbox(m, () => normalPrompt(m), 'normal')
2600
2806
  if (ok) filled += 1
2601
2807
  }
2602
2808
  if (filled) { armHeartbeat(); return }
@@ -2612,13 +2818,18 @@ export function apply(ctx) {
2612
2818
  return
2613
2819
  }
2614
2820
  // (e) heartbeat: push the longest-idle member to make progress rather than just
2615
- // asking "are we done" (v4's original heartbeat invited stagnation).
2616
- const idleMs = posMs(params.activityTimeoutMs, 120000)
2821
+ // asking "are we done" (v4's original heartbeat invited stagnation). A member that
2822
+ // owns in-progress work gets a WORK round (its task block in [状态] tells it what
2823
+ // it owes); an otherwise idle member gets the heartbeat that asks it to advance the
2824
+ // problem by itself.
2617
2825
  if (busy.size < budget && idle.length) {
2618
2826
  const candidates = idle.slice().sort((a, b) => (lastActiveAt.get(a.id) || 0) - (lastActiveAt.get(b.id) || 0))
2619
2827
  const pick = candidates[0]
2620
2828
  if (pick && (now() - (lastActiveAt.get(pick.id) || 0)) >= idleMs) {
2621
- const ok = await wakeWithInbox(pick, checkpointPrompt(pick), 'checkpoint')
2829
+ const owns = inst().tasks.some((t) => t.ownerId === pick.id && t.status === 'in_progress')
2830
+ const ok = await wakeWithInbox(pick,
2831
+ () => (owns ? normalPrompt(pick) : checkpointPrompt(pick)),
2832
+ owns ? 'normal' : 'checkpoint')
2622
2833
  // ALWAYS re-arm after a wake, even on success. A wake whose turn never ends
2623
2834
  // (a host that drops the delivery, a child that vanished) would otherwise
2624
2835
  // leave nothing to schedule the next pass and the institute would freeze
@@ -2682,7 +2893,10 @@ export function apply(ctx) {
2682
2893
  if (typeof speech === 'string' && speech.trim()) await say(member.id, { to: 'all', text: speech, kind: 'chat' })
2683
2894
  else if (speech && typeof speech === 'object' && speech.text) {
2684
2895
  const to = String(speech.to || 'all')
2685
- await say(member.id, { to, text: String(speech.text), kind: to === 'all' ? 'chat' : 'dm' })
2896
+ // A "to: voters" broadcast is not a private message: framing it 私信 told the
2897
+ // voters they had been singled out when the whole voting body was addressed.
2898
+ const kind = to === 'all' ? 'chat' : to === 'voters' ? 'voters' : 'dm'
2899
+ await say(member.id, { to, text: String(speech.text), kind })
2686
2900
  }
2687
2901
  // (2) progress log
2688
2902
  if (typeof p.progress === 'string' && p.progress.trim()) await publishProgress(member.id, p.progress)
@@ -2699,28 +2913,48 @@ export function apply(ctx) {
2699
2913
  if (p.task_create && typeof p.task_create === 'object') await taskCreate(member.id, p.task_create)
2700
2914
  if (p.task_claim) {
2701
2915
  const t = inst().tasks.find((x) => x.id === String(p.task_claim))
2702
- if (t) await taskUpdate(member.id, { task_id: t.id, expected_revision: t.revision, action: 'claim' })
2703
- else await say(member.id, { to: member.id, kind: 'dm', text: '(框架)认领失败:没有任务 ' + String(p.task_claim) })
2916
+ if (t) {
2917
+ const r = await taskUpdate(member.id, { task_id: t.id, expected_revision: t.revision, action: 'claim' })
2918
+ // Report a REFUSED claim back to the claimer: silently doing nothing left the
2919
+ // member believing it owned a task it does not own (and the notice used to be
2920
+ // dropped as a self-message).
2921
+ if (r && r.ok === false) await notice(member.id, '认领 ' + t.id + ' 失败(' + (r.code || '') + '):' + (r.message || ''))
2922
+ } else await notice(member.id, '认领失败:没有任务 ' + String(p.task_claim))
2923
+ }
2924
+ if (p.task_update && typeof p.task_update === 'object') {
2925
+ const r = await taskUpdate(member.id, p.task_update)
2926
+ if (r && r.ok === false) await notice(member.id, 'task_update 未生效(' + (r.code || '') + '):' + (r.message || ''))
2704
2927
  }
2705
- if (p.task_update && typeof p.task_update === 'object') await taskUpdate(member.id, p.task_update)
2706
2928
  if (p.task_done) {
2707
2929
  const t = inst().tasks.find((x) => x.id === String(p.task_done))
2708
- if (t) await taskUpdate(member.id, { task_id: t.id, expected_revision: t.revision, action: 'complete' })
2930
+ if (t) {
2931
+ const r = await taskUpdate(member.id, { task_id: t.id, expected_revision: t.revision, action: 'complete' })
2932
+ if (r && r.ok === false) await notice(member.id, '完成任务 ' + t.id + ' 失败(' + (r.code || '') + '):' + (r.message || ''))
2933
+ } else await notice(member.id, '标记完成失败:没有任务 ' + String(p.task_done))
2709
2934
  }
2710
2935
  // (5) verification
2711
2936
  if (p.propose_verify) {
2712
2937
  const pv = typeof p.propose_verify === 'string' ? { target: p.propose_verify } : p.propose_verify
2713
- if (pv && pv.target) await maybeQueueVerify(pv.target, pv.kind, member.id, pv.reason)
2938
+ if (pv && pv.target) {
2939
+ const r = await maybeQueueVerify(pv.target, pv.kind, member.id, pv.reason)
2940
+ if (r && r.ok === false) await notice(member.id, '提议验证 ' + String(pv.target) + ' 未受理(' + (r.code || '') + '):' + (r.message || ''))
2941
+ }
2714
2942
  }
2715
2943
  if (p.verdict && typeof p.verdict === 'object') {
2716
2944
  const n = normVerdictNumber(p.verdict.verdict)
2717
- if (n === undefined) await say(member.id, { to: member.id, kind: 'dm', text: '(框架)verdict 必须是 0-1 的数值' })
2718
- else await castVerdict(member.id, String(p.verdict.target || ''), n, p.verdict.reason)
2945
+ if (n === undefined) await notice(member.id, 'verdict 必须是 0-1 的数值;本轮的票未被记录。')
2946
+ else {
2947
+ const r = await castVerdict(member.id, String(p.verdict.target || ''), n, p.verdict.reason)
2948
+ if (r && r.ok === false) await notice(member.id, '本轮的票未被记录(' + (r.code || '') + '):' + (r.message || ''))
2949
+ }
2719
2950
  }
2720
2951
  // (6) meetings
2721
2952
  if (p.propose_meeting) {
2722
2953
  const pm = typeof p.propose_meeting === 'string' ? { agenda: p.propose_meeting } : p.propose_meeting
2723
- if (pm && pm.agenda) await startMeeting(member.id, pm)
2954
+ if (pm && pm.agenda) {
2955
+ const r = await startMeeting(member.id, pm)
2956
+ if (r && r.ok === false) await notice(member.id, '提议开会未受理(' + (r.code || '') + '):' + (r.message || ''))
2957
+ }
2724
2958
  }
2725
2959
  if (p.convene_meeting && typeof p.convene_meeting === 'object' && isAcademician(member.id) && params.academicianLeads) {
2726
2960
  await startMeeting(member.id, p.convene_meeting)
@@ -2736,9 +2970,9 @@ export function apply(ctx) {
2736
2970
  if (p.reject_assign && typeof p.reject_assign === 'object' && params.memberMayRejectAssign) {
2737
2971
  const ra = p.reject_assign
2738
2972
  await say(member.id, {
2739
- to: 'voters', kind: 'dm',
2973
+ to: 'voters', kind: 'voters',
2740
2974
  text: '【反对分派】我对任务 ' + String(ra.task_id || '(未指明)') + ' 有异议:' + String(ra.why || '(未说明理由)') +
2741
- '。任务仍会执行,但请院士与全所知悉我的理由。',
2975
+ '。任务仍会执行,但请' + (academicianId() ? '院士与全所' : '全所') + '知悉我的理由。',
2742
2976
  })
2743
2977
  }
2744
2978
  // (10) solve votes / personal judgement
@@ -2990,8 +3224,11 @@ export function apply(ctx) {
2990
3224
  '从已知的相近结论出发,看能否推广或加强得到所需结果。',
2991
3225
  ]
2992
3226
  const spawned = []
2993
- // The academician is founded FIRST so that its charter/roster is accurate and so
2994
- // it can begin overseeing the founding round.
3227
+ // The academician is founded FIRST so it is on the roster for every later member's
3228
+ // induction brief, and so it can begin overseeing the founding round. Each member
3229
+ // is committed to the ACTIVE roster before its own brief is built (see
3230
+ // spawnMember), so every founding brief describes a roster that includes its
3231
+ // reader.
2995
3232
  if (params.academician) {
2996
3233
  const m = await newMember('academician', { direction: '统领全所:统筹全局、拆解并分派工作、设定优先级、督导进度。' })
2997
3234
  try {
@@ -3059,8 +3296,7 @@ export function apply(ctx) {
3059
3296
  if (m.childId) continue
3060
3297
  try {
3061
3298
  const seedText = (await readTextRel('Members/' + m.id + '/Progress/progress.md')) || ''
3062
- await spawnMember(m, '你的会话已重建。请先读回你自己的 Progress/ 摘要(如下),恢复工作状态,然后继续推进:\n\n'
3063
- + (seedText ? seedText.slice(-4000) : '(你的 Progress/ 还是空的——请先把当前状态补写进去。)'))
3299
+ await spawnMember(m, seedText ? seedText.slice(-4000) : '(你的 Progress/ 还是空的——请先把当前状态补写进去。)', 'resume')
3064
3300
  respawned += 1
3065
3301
  } catch (e) {
3066
3302
  await putMember(Object.assign({}, m, { phase: 'failed', error: String((e && e.message) || e) }))
@@ -3199,7 +3435,7 @@ export function apply(ctx) {
3199
3435
  // structure, so members may only propose it; the office decides and executes.
3200
3436
  async function addResearcher(callerId, direction) {
3201
3437
  if (!isOffice(callerId)) {
3202
- await say(callerId, { to: 'voters', kind: 'dm', text: '提议增聘一名常驻研究员(方向:' + String(direction || '未指定') + ')' })
3438
+ await say(callerId, { to: 'voters', kind: 'voters', text: '提议增聘一名常驻研究员(方向:' + String(direction || '未指定') + ')' })
3203
3439
  return { ok: true, proposed: true, message: '已向所办提议增聘常驻研究员(编制变更需所办批准)' }
3204
3440
  }
3205
3441
  if (!running || autoDone) return { ok: false, code: 'V5_INSTITUTE_STATE', message: 'the institute is not running' }
@@ -3315,7 +3551,8 @@ export function apply(ctx) {
3315
3551
  registerTool('vibe_v5_say', '(member) Speak in the group chat (omit "to"), send a private message ("to":"r-2"), or address only the voters ("to":"voters").', objParams({ text: S, to: S }, ['text']), (s, a, x) => {
3316
3552
  const from = s.memberIdOfAgent(x)
3317
3553
  if (!from) return { ok: false, code: 'V5_MEMBER_NOT_FOUND', message: 'no calling member' }
3318
- return s.say(from, { to: a.to || 'all', text: a.text, kind: a.to ? 'dm' : 'chat' })
3554
+ const to = a.to || 'all'
3555
+ return s.say(from, { to, text: a.text, kind: to === 'voters' ? 'voters' : (a.to ? 'dm' : 'chat') })
3319
3556
  })
3320
3557
  registerTool('vibe_v5_wait', '(member) Wait for the next institute change (roster/task/mail/status) WITHOUT polling. Returns immediately with noProgress when nobody else is running or provisioning. timeout_ms: 10000-3600000 (default 30000).', objParams({ timeout_ms: I, reason: S }), async (s, a, x) => {
3321
3558
  const me = s.memberIdOfAgent(x)