@event4u/agent-config 9.28.0 → 9.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +6 -6
  3. package/CHANGELOG.md +172 -5
  4. package/README.md +2 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  7. package/dist/agent-src/commands/cost/report.md +2 -2
  8. package/dist/agent-src/commands/explain-run.md +31 -1
  9. package/dist/agent-src/commands/team/adversarial.md +8 -7
  10. package/dist/agent-src/commands/team/delegate.md +14 -12
  11. package/dist/agent-src/commands/team/review.md +9 -8
  12. package/dist/agent-src/commands/team/status.md +9 -8
  13. package/dist/agent-src/commands/team.md +29 -19
  14. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  15. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  17. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +22 -1
  18. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  19. package/dist/agent-src/contexts/execution/subagent-response-contract.md +65 -1
  20. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  21. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  22. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  23. package/dist/agent-src/rules/delegation-policy.md +11 -7
  24. package/dist/agent-src/rules/preservation-guard.md +9 -0
  25. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  26. package/dist/agent-src/rules/token-budget-discipline.md +1 -1
  27. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  28. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  29. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  30. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  31. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  32. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  33. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +17 -5
  34. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  35. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  36. package/dist/agent-src/templates/AGENTS.md +4 -4
  37. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  38. package/dist/agent-src/templates/roadmaps.md +23 -0
  39. package/dist/cli/registry.js +2 -0
  40. package/dist/cli/registry.js.map +1 -1
  41. package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
  42. package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-BTJNRUX3.js} +22 -35
  43. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-DDUNNFYX.js} +24 -17
  44. package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-EQY4FLJB.js} +3 -3
  45. package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-IYTGIG2Y.js} +155 -91
  46. package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
  47. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  48. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  49. package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
  50. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  51. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  52. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  53. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  54. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  55. package/dist/cli-delegate/cmd_conformance.js +2803 -137
  56. package/dist/cli-delegate/cmd_doctor.js +10 -5
  57. package/dist/cli-delegate/cmd_explain.js +2 -2
  58. package/dist/cli-delegate/cmd_export.js +4 -4
  59. package/dist/cli-delegate/cmd_handoff.js +88 -8
  60. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  61. package/dist/cli-delegate/cmd_migrate.js +2 -2
  62. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  63. package/dist/cli-delegate/cmd_prune.js +4 -4
  64. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  65. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  66. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  67. package/dist/cli-delegate/cmd_settings_get.js +2 -8
  68. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  69. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  70. package/dist/cli-delegate/cmd_sync.js +4 -4
  71. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  72. package/dist/cli-delegate/cmd_update.js +1 -1
  73. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  74. package/dist/cli-delegate/cmd_validate.js +4 -4
  75. package/dist/cli-delegate/cmd_versions.js +1 -1
  76. package/dist/discovery/deprecation-report.md +1 -1
  77. package/dist/discovery/discovery-manifest.json +72 -23
  78. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  79. package/dist/discovery/discovery-manifest.summary.md +3 -2
  80. package/dist/discovery/orphan-report.md +1 -1
  81. package/dist/discovery/packs.json +39 -2
  82. package/dist/discovery/trust-report.md +2 -2
  83. package/dist/discovery/workspaces.json +12 -4
  84. package/dist/hooks/dispatch.js +2278 -611
  85. package/dist/install/install.mjs +56 -17
  86. package/dist/mcp/registry-manifest.json +3 -3
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +4122 -699
  89. package/dist/router.json +1 -1
  90. package/dist/scripts/_lib/agent_settings.js +54 -0
  91. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  92. package/dist/scripts/condense.js +147 -2
  93. package/dist/scripts/condense.js.map +1 -1
  94. package/dist/server/schemas/settings.js +12 -7
  95. package/dist/server/schemas/settings.js.map +1 -1
  96. package/dist/shared/settingsCarveOut.js +0 -9
  97. package/dist/shared/settingsCarveOut.js.map +1 -1
  98. package/dist/shared/settingsClasses.js +11 -9
  99. package/dist/shared/settingsClasses.js.map +1 -1
  100. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  101. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  102. package/dist/ui/index.html +1 -1
  103. package/docs/contracts/settings-classes.md +58 -12
  104. package/package.json +1 -1
  105. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  106. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  107. package/src/agent-src/templates/AGENTS.md +4 -4
  108. package/src/config/agent-settings.template.yml +88 -66
  109. package/src/config/discovery/packs.yml +11 -0
  110. package/src/config/discovery/workspaces.yml +1 -1
  111. package/src/config/dispatch-economy-metrics.json +61 -0
  112. package/src/config/evaluator-budgets.json +4 -4
  113. package/src/config/gate-violation-baselines.json +3 -3
  114. package/src/config/hook-token-budget.json +91 -0
  115. package/src/config/recycle-threshold-budget.json +12 -0
  116. package/src/config/rule-enforcement-baseline.json +86 -0
  117. package/src/scripts/_cli/cmd_conformance.ts +31 -44
  118. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  119. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  120. package/src/scripts/_dispatch.bash +23 -0
  121. package/src/scripts/_lib/agent_settings.ts +58 -0
  122. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  123. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  124. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  125. package/src/scripts/_lib/host_capability.ts +63 -6
  126. package/src/scripts/_lib/judgment_ladder.ts +634 -0
  127. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  128. package/src/scripts/_lib/orchestration_record.ts +78 -10
  129. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  130. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  131. package/src/scripts/_lib/self_repair.ts +354 -13
  132. package/src/scripts/_lib/self_repair_store.ts +28 -0
  133. package/src/scripts/_lib/session_eol.ts +230 -0
  134. package/src/scripts/_lib/session_role.ts +42 -0
  135. package/src/scripts/_lib/subagent_capsule.ts +341 -0
  136. package/src/scripts/_lib/subagent_response.ts +94 -0
  137. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  138. package/src/scripts/_lib/subagent_steering.ts +9 -4
  139. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  140. package/src/scripts/_lib/worker_budget.ts +37 -2
  141. package/src/scripts/ai_council/clients.ts +126 -55
  142. package/src/scripts/ai_council/config.ts +94 -6
  143. package/src/scripts/ai_council/handoff.ts +113 -0
  144. package/src/scripts/ai_council/modes.ts +19 -4
  145. package/src/scripts/ai_council/orchestrator.ts +105 -0
  146. package/src/scripts/ai_council/quorum.ts +84 -0
  147. package/src/scripts/ai_council/session.ts +44 -0
  148. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  149. package/src/scripts/ai_team/availability.ts +110 -0
  150. package/src/scripts/ai_team/config.ts +34 -14
  151. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  152. package/src/scripts/ask_transport.ts +221 -0
  153. package/src/scripts/bench_critic_protocol.ts +396 -0
  154. package/src/scripts/bench_hook_injection.ts +234 -0
  155. package/src/scripts/check_adr_frontmatter.ts +84 -40
  156. package/src/scripts/check_branch_freshness.ts +1 -0
  157. package/src/scripts/check_corpus_staleness.ts +18 -4
  158. package/src/scripts/check_detector_corpus.ts +219 -0
  159. package/src/scripts/check_gitignore_freshness.ts +42 -8
  160. package/src/scripts/check_hook_bundle_freshness.ts +24 -1
  161. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  162. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  163. package/src/scripts/check_static_layer_stability.ts +154 -0
  164. package/src/scripts/check_suppression_hygiene.ts +48 -0
  165. package/src/scripts/condense.ts +162 -2
  166. package/src/scripts/council_cli.ts +401 -25
  167. package/src/scripts/dispatch_economy_report.ts +448 -0
  168. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  169. package/src/scripts/explain_run.ts +206 -4
  170. package/src/scripts/forensics_report.ts +333 -0
  171. package/src/scripts/generate_settings_reference.ts +1 -0
  172. package/src/scripts/handoff_context_hook.ts +112 -4
  173. package/src/scripts/hook_manifest.yaml +117 -10
  174. package/src/scripts/hooks/concern_registry.ts +8 -0
  175. package/src/scripts/hooks/delegation_nudge_hook.ts +106 -35
  176. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  177. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  178. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  179. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  180. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  181. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  182. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  183. package/src/scripts/install-hooks.sh +1 -1
  184. package/src/scripts/language_mirror_hook.ts +89 -0
  185. package/src/scripts/lint_decision_impact.ts +14 -1
  186. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  187. package/src/scripts/lint_hook_manifest.ts +82 -0
  188. package/src/scripts/lint_never_silent.ts +276 -0
  189. package/src/scripts/lint_no_activation_gates.ts +271 -0
  190. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  191. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  192. package/src/scripts/measure_turn_end_gate.ts +194 -0
  193. package/src/scripts/orchestration_record.ts +12 -1
  194. package/src/scripts/rebuild_hook_bundle.ts +178 -0
  195. package/src/scripts/release.ts +74 -2
  196. package/src/scripts/report_conformance_funnel.ts +284 -0
  197. package/src/scripts/report_imperative_density.ts +1 -0
  198. package/src/scripts/routing_doctor.ts +118 -36
  199. package/src/scripts/score_skill_selection.ts +19 -5
  200. package/src/scripts/self_repair_cli.ts +337 -59
  201. package/src/scripts/session_eol_report.ts +261 -0
  202. package/src/scripts/team_events_spike.ts +271 -0
  203. package/src/scripts/team_review_gate_hook.ts +31 -9
  204. package/src/server/schemas/settings.ts +24 -17
  205. package/src/shared/settingsCarveOut.ts +0 -10
  206. package/src/shared/settingsClasses.ts +11 -9
  207. package/src/templates/compact-instructions.md +40 -0
  208. package/dist/ui/assets/index-DmR0mbk4.js +0 -26
  209. package/dist/ui/assets/index-DmR0mbk4.js.map +0 -1
@@ -21,9 +21,12 @@
21
21
  * a locally-installed CLI under subscription auth (free for
22
22
  * first-party CLIs, billable for community wrappers); `auto` = pick
23
23
  * per provider per invocation (cli → api → unavailable), resolved by
24
- * `transport_resolver.ts`. `auto` is opt-in — it is never the shipped
25
- * default, because flipping it would move an existing user's spend
26
- * from per-token dollars onto subscription quota with no config edit.
24
+ * `transport_resolver.ts`. `auto` is the SHIPPED DEFAULT
25
+ * (road-to-always-on-orchestration Phase 3.1 — CLI-first is the
26
+ * owner-set transport doctrine: ride the vendor CLI under subscription
27
+ * auth first, fall to the metered API only where no CLI resolves).
28
+ * `manual` and a pinned `api`/`cli` remain valid explicit per-member or
29
+ * per-invocation overrides — `auto` never displaces one.
27
30
  * 3. `members.<name>` keys are restricted to the known provider set.
28
31
  * 4. `cost_budget.*` numeric fields are >= 0.
29
32
  * 5. Enabled members carry a non-empty `model` and `api_key_ref` when
@@ -37,7 +40,18 @@
37
40
  * 8. `binary:` is only valid when the member's effective mode is `cli` or
38
41
  * `auto` (auto may resolve to the cli rung);
39
42
  * `cli_call_budget.max_calls_per_day.<provider>` keys must be valid
40
- * providers.
43
+ * providers. `max_calls_per_day` SHIPS POPULATED with a generous
44
+ * per-provider default (50/day, road-to-always-on-orchestration
45
+ * Phase 3.4) for every known provider — a guard against a silent
46
+ * always-on pass exhausting a subscription's plan quota, not a brake;
47
+ * an explicit per-provider entry overrides just that provider's default.
48
+ * 9. `quorum` ∈ {`"majority"`, a positive integer} (default `"majority"`,
49
+ * road-to-always-on-orchestration Phase 3.3). `"majority"` at n members
50
+ * resolves to `ceil(n / 2)` — a SIMPLE majority, deliberately NOT
51
+ * "more than half": at n=2 that is 1-of-2, because 2-of-2 turns any
52
+ * single absent member into a deadlocked release gate (council-verified
53
+ * 2026-08-09). See `quorum.ts` for the resolver a caller applies to its
54
+ * own present/total member counts.
41
55
  *
42
56
  * Parity notes (intentional, documented):
43
57
  * - YAML is parsed with `yaml` (npm) at `version: '1.1'`, matching
@@ -562,6 +576,15 @@ export interface CliCallBudgetConfig {
562
576
  readonly warn_at: number;
563
577
  }
564
578
 
579
+ /**
580
+ * `"majority"` (default) or a positive integer k — the number of members a
581
+ * pass needs to conclude (road-to-always-on-orchestration Phase 3.3). See
582
+ * `quorum.ts::resolveQuorumThreshold` for how a caller turns this into a
583
+ * concrete threshold against its own total-member count, and the module
584
+ * docstring rule 9 for why `"majority"` is `ceil(n / 2)`, not `> n / 2`.
585
+ */
586
+ export type QuorumSetting = 'majority' | number;
587
+
565
588
  export interface CouncilConfig {
566
589
  readonly enabled: boolean;
567
590
  readonly defaults: DefaultsConfig;
@@ -570,6 +593,7 @@ export interface CouncilConfig {
570
593
  readonly advisors: ReadonlyMap<string, AdvisorConfig>;
571
594
  readonly consensus_scoring: ConsensusScoringConfig;
572
595
  readonly cli_call_budget: CliCallBudgetConfig;
596
+ readonly quorum: QuorumSetting;
573
597
  readonly necessity_classifier: NecessityClassifierConfig;
574
598
  readonly model_downgrade: ModelDowngradeConfig;
575
599
  readonly debate: DebateConfig;
@@ -578,6 +602,7 @@ export interface CouncilConfig {
578
602
  readonly chairman: ChairmanConfig;
579
603
  readonly debate_gates: DebateGatesConfig;
580
604
  readonly restate: RestateConfig;
605
+ readonly critic_protocol: CriticProtocol;
581
606
  readonly decision_resolution: DecisionResolutionConfig;
582
607
  readonly routing: RoutingConfig;
583
608
  readonly low_impact: LowImpactConfig;
@@ -796,6 +821,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
796
821
  const cli_call_budget = _build_cli_call_budget(
797
822
  _asDict(_getOr(raw, 'cli_call_budget', {})),
798
823
  );
824
+ const quorum = _build_quorum(_get(raw, 'quorum', 'majority'));
799
825
  const necessity_classifier = _build_necessity_classifier(
800
826
  _asDict(_getOr(raw, 'necessity_classifier', {})),
801
827
  );
@@ -821,6 +847,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
821
847
  'debate_gates',
822
848
  );
823
849
  const restate = _build_restate(_asDict(_getOr(raw, 'restate', {})), 'restate');
850
+ const critic_protocol = _build_critic_protocol(_getOr(raw, 'critic_protocol', 'legacy'));
824
851
  const decision_resolution = _build_decision_resolution(
825
852
  _asDict(_getOr(raw, 'decision_resolution', {})),
826
853
  );
@@ -843,6 +870,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
843
870
  advisors,
844
871
  consensus_scoring: consensus,
845
872
  cli_call_budget,
873
+ quorum,
846
874
  necessity_classifier,
847
875
  model_downgrade,
848
876
  debate,
@@ -851,6 +879,7 @@ export function _build_config(raw: Dict, source_path: PathLike): CouncilConfig {
851
879
  chairman,
852
880
  debate_gates,
853
881
  restate,
882
+ critic_protocol,
854
883
  decision_resolution,
855
884
  routing,
856
885
  low_impact,
@@ -1115,6 +1144,27 @@ function _build_restate(d: Dict, scope: string): RestateConfig {
1115
1144
  return { enabled: Boolean(enabled) };
1116
1145
  }
1117
1146
 
1147
+ /**
1148
+ * `critic_protocol` (road-to-judgment-and-forensic-evidence Phase 2) — which
1149
+ * critic posture adversarial/skeptic review passes use. `legacy` is the
1150
+ * shipped free-hunt skeptic; `load_bearing` is the fixed protocol that must
1151
+ * name the single load-bearing assumption and may return "holds". Default
1152
+ * `legacy` regardless of the A/B outcome (promotion is a separate decision).
1153
+ */
1154
+ export type CriticProtocol = 'legacy' | 'load_bearing';
1155
+
1156
+ const _VALID_CRITIC_PROTOCOLS: ReadonlySet<string> = new Set(['legacy', 'load_bearing']);
1157
+
1158
+ function _build_critic_protocol(v: unknown): CriticProtocol {
1159
+ if (v === undefined || v === null) return 'legacy';
1160
+ if (!(_isStr(v) && _VALID_CRITIC_PROTOCOLS.has(v))) {
1161
+ throw new CouncilConfigError(
1162
+ `\`critic_protocol\`=${_pyRepr(v)} not in ${_sortedListRepr(_VALID_CRITIC_PROTOCOLS)}.`,
1163
+ );
1164
+ }
1165
+ return v as CriticProtocol;
1166
+ }
1167
+
1118
1168
  const _VALID_RESOLUTION_MODES: ReadonlySet<string> = new Set([
1119
1169
  'agent',
1120
1170
  'council',
@@ -1427,7 +1477,11 @@ function _build_defaults(d: Dict): DefaultsConfig {
1427
1477
  if (!_isDict(d)) {
1428
1478
  throw new CouncilConfigError('`defaults` must be a mapping.');
1429
1479
  }
1430
- const mode = _get(d, 'mode', 'api');
1480
+ // road-to-always-on-orchestration Phase 3.1: CLI-first is the shipped
1481
+ // default (was `api`) — see the module docstring rule 2. `manual` and a
1482
+ // pinned `api`/`cli` remain valid overrides at every layer resolve_mode
1483
+ // checks; this only changes what a config that never sets `mode` gets.
1484
+ const mode = _get(d, 'mode', 'auto');
1431
1485
  if (!(_isStr(mode) && _VALID_MODES.has(mode))) {
1432
1486
  throw new CouncilConfigError(
1433
1487
  `defaults.mode=${_pyRepr(mode)} not in ${_sortedListRepr(_VALID_MODES)}.`,
@@ -1630,7 +1684,13 @@ function _build_cost_budget(d: Dict): CostBudgetConfig {
1630
1684
  function _build_member(
1631
1685
  name: string,
1632
1686
  cfg: Dict,
1633
- default_mode = 'api',
1687
+ // road-to-always-on-orchestration Phase 3.1: kept in sync with
1688
+ // `_build_defaults`'s `auto` default for consistency. The sole call site
1689
+ // (`_build_config`) always passes `defaults.mode` explicitly, so this
1690
+ // parameter default is unreachable on the live path today; it exists so
1691
+ // a future caller that omits the third argument observes the same
1692
+ // doctrine rather than silently reverting to the retired `api` default.
1693
+ default_mode = 'auto',
1634
1694
  ): MemberConfig {
1635
1695
  if (!_VALID_PROVIDERS.has(name)) {
1636
1696
  throw new CouncilConfigError(
@@ -1775,6 +1835,18 @@ function _build_member(
1775
1835
  };
1776
1836
  }
1777
1837
 
1838
+ /**
1839
+ * Generous per-provider guard for `mode: cli` / `mode: auto` members
1840
+ * (road-to-always-on-orchestration Phase 3.4). Before this default the map
1841
+ * shipped empty and an unlisted provider ran uncapped — a plan-quota guard
1842
+ * that only existed if the user remembered to opt in. Every known provider
1843
+ * now carries this floor unless a config entry overrides it; sized as a
1844
+ * GUARD against silent quota exhaustion on an always-on pass, not a brake on
1845
+ * normal use (see the template's own worked-example sizing for the
1846
+ * reasoning behind a TIGHTER per-provider number).
1847
+ */
1848
+ const _DEFAULT_CLI_CALLS_PER_DAY = 50;
1849
+
1778
1850
  function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
1779
1851
  if (!_isDict(d)) {
1780
1852
  throw new CouncilConfigError('`cli_call_budget` must be a mapping.');
@@ -1786,6 +1858,9 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
1786
1858
  );
1787
1859
  }
1788
1860
  const caps = new Map<string, number>();
1861
+ for (const provider of _VALID_PROVIDERS) {
1862
+ caps.set(provider, _DEFAULT_CLI_CALLS_PER_DAY);
1863
+ }
1789
1864
  for (const [provider, value] of Object.entries(raw_caps)) {
1790
1865
  if (!_VALID_PROVIDERS.has(provider)) {
1791
1866
  throw new CouncilConfigError(
@@ -1817,6 +1892,19 @@ function _build_cli_call_budget(d: Dict): CliCallBudgetConfig {
1817
1892
  return { max_calls_per_day: caps, warn_at };
1818
1893
  }
1819
1894
 
1895
+ function _build_quorum(raw: Json): QuorumSetting {
1896
+ if (raw === 'majority') {
1897
+ return 'majority';
1898
+ }
1899
+ if (_isInt(raw) && !_isBool(raw) && (raw as number) >= 1) {
1900
+ return raw as number;
1901
+ }
1902
+ throw new CouncilConfigError(
1903
+ `\`quorum\`=${_pyRepr(raw)} must be 'majority' or an integer >= 1 ` +
1904
+ `(got ${_pyTypeName(raw)}).`,
1905
+ );
1906
+ }
1907
+
1820
1908
  function _build_advisor(name: string, cfg: Dict): AdvisorConfig {
1821
1909
  if (!_isDict(cfg)) {
1822
1910
  throw new CouncilConfigError(`advisors.${name}: must be a mapping.`);
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Verdict → handoff envelope (Phase 4.1 of road-to-always-on-orchestration).
3
+ *
4
+ * A council pass produces a verdict a human currently has to read and
5
+ * re-type into whatever executes next. This module is the machine-readable
6
+ * bridge: an additive `HandoffEnvelope` — decision, rejected alternatives
7
+ * with reasons, binding constraints — injectable as the dispatched work
8
+ * order of an implementing subagent, so deliberation output becomes
9
+ * execution input without a human transcription step.
10
+ *
11
+ * ## The honesty constraint
12
+ *
13
+ * A council pass has exactly ONE place in this codebase where "the
14
+ * decision" is a discrete, machine-parsed value rather than free-text prose
15
+ * a human (or an NLP guess) would have to interpret: the option-level
16
+ * stance tally (`stance_tally.ts`, opt-in via `ai_council.stance_tally`).
17
+ * Every other synthesis path — the default template, a chairman's prose
18
+ * verdict, consensus scoring's qualitative Strong/Findings/Minority
19
+ * buckets — has no comparable discrete "decision" field to extract from
20
+ * without parsing prose, which is exactly the kind of confident-looking
21
+ * fabrication this module refuses to do.
22
+ *
23
+ * So: `buildHandoffFromStanceTally` is the ONLY builder this module ships.
24
+ * No tally (the common case — the flag defaults off) or a tally that split
25
+ * (no option cleared the consensus threshold) → every field is `null`,
26
+ * never a guessed decision. A future builder over a different structured
27
+ * source (a chairman synthesis that itself emits structured fields, a
28
+ * consensus-scoring extension) is additive — it does not change this one.
29
+ *
30
+ * @see docs/contracts/ai-council-config.md § Handoff envelope
31
+ */
32
+
33
+ import type { StanceTallyResult } from './stance_tally.js';
34
+
35
+ /** One option the tally counted but did not conclude on. */
36
+ export interface RejectedAlternative {
37
+ readonly option: string;
38
+ /**
39
+ * A factual, derived-from-the-tally sentence — never invented prose.
40
+ * States the option's own backer count and weight against the
41
+ * threshold the winner had to clear, nothing else.
42
+ */
43
+ readonly reason: string;
44
+ }
45
+
46
+ /**
47
+ * The machine-readable work order a council verdict hands to whatever
48
+ * executes next. Every field is independently nullable: `null` means "no
49
+ * structured source existed to populate this field this pass", never a
50
+ * fabricated placeholder. A consumer checks for `null`, not for an empty
51
+ * array — an empty array would claim "checked, found none", which this
52
+ * module never asserts when it never actually checked.
53
+ */
54
+ export interface HandoffEnvelope {
55
+ readonly decision: string | null;
56
+ readonly rejected_alternatives: readonly RejectedAlternative[] | null;
57
+ /**
58
+ * Binding constraints the decision carries. No structured source for
59
+ * this exists anywhere in the current synthesis pipeline (a
60
+ * dealbreaker count describes objection PRESSURE on an option, not a
61
+ * named constraint on the winner) — always `null` until one does.
62
+ */
63
+ readonly constraints: readonly string[] | null;
64
+ }
65
+
66
+ /** An envelope with every field `null` — the honest "nothing to hand off" shape. */
67
+ export const EMPTY_HANDOFF: HandoffEnvelope = {
68
+ decision: null,
69
+ rejected_alternatives: null,
70
+ constraints: null,
71
+ };
72
+
73
+ /** True when every field is `null` — nothing worth rendering or dispatching. */
74
+ export function isEmptyHandoff(h: HandoffEnvelope): boolean {
75
+ return h.decision === null && h.rejected_alternatives === null && h.constraints === null;
76
+ }
77
+
78
+ /**
79
+ * Build a `HandoffEnvelope` from a stance tally, or the honest empty
80
+ * envelope when there is nothing structured to extract:
81
+ *
82
+ * - `tally` is `null` — stance tally never ran this pass (the default,
83
+ * opt-in-off case).
84
+ * - `tally.consensus` is `null` — the tally ran but split; no option
85
+ * cleared the two-thirds threshold, so there is no decision to report,
86
+ * only a split (which is quorum's/the render's job to surface, not this
87
+ * module's).
88
+ *
89
+ * Otherwise: `decision` is the winning option's display label;
90
+ * `rejected_alternatives` lists every OTHER non-abstain option the tally
91
+ * counted, each with a reason sentence built strictly from that option's
92
+ * own tally numbers (backer count, weight, the threshold the winner
93
+ * cleared) — never a narrative guess at WHY members backed it.
94
+ */
95
+ export function buildHandoffFromStanceTally(tally: StanceTallyResult | null): HandoffEnvelope {
96
+ if (tally === null || tally.consensus === null) {
97
+ return EMPTY_HANDOFF;
98
+ }
99
+ const winner = tally.consensus;
100
+ const rejected: RejectedAlternative[] = tally.options
101
+ .filter((o) => o !== winner)
102
+ .map((o) => ({
103
+ option: o.label,
104
+ reason:
105
+ `backed by ${o.backers.length} member(s), weight ${o.weight.toFixed(2)} of ` +
106
+ `${tally.threshold.toFixed(2)} needed to conclude`,
107
+ }));
108
+ return {
109
+ decision: winner.label,
110
+ rejected_alternatives: rejected.length > 0 ? rejected : null,
111
+ constraints: null,
112
+ };
113
+ }
@@ -33,21 +33,36 @@
33
33
  * conflated them. They are now named separately:
34
34
  *
35
35
  * - **Loader default** — when a config file omits `defaults.mode` entirely,
36
- * `config.ts::_build_defaults` fills `api`. Unchanged; this is what every
37
- * real config observes, because the loader always populates the key.
36
+ * `config.ts::_build_defaults` fills `auto` (road-to-always-on-orchestration
37
+ * Phase 3.1 — CLI-first is now the owner-set transport doctrine; before this
38
+ * flip the loader default was `api`). This is what every real config
39
+ * observes, because the loader always populates the key.
38
40
  * - **Built-in fallback** — when NO layer supplies a mode at all (a settings
39
41
  * dict handed straight to the resolver, no config file involved), the answer
40
42
  * is `manual`, below. That is the fail-closed direction the no-silent-spend
41
43
  * Iron Law requires: a caller who supplied no transport preference has not
42
44
  * asked to spend money, so the free transport wins.
45
+ *
46
+ * ### Why `DEFAULT_MODE` did NOT flip to `auto` alongside the loader default
47
+ *
48
+ * `auto` still resolves to a paying rung (the api fallback) when no CLI is
49
+ * usable — it is a routing rule, not a free transport. `DEFAULT_MODE` is only
50
+ * reached by a caller that bypasses the config loader entirely (no
51
+ * `.ai-council.yml`, a hand-built settings dict handed straight to
52
+ * `resolve_mode`/`resolve_modes`). Flipping it to `auto` would let such a
53
+ * caller spend money on a config that never asked for `auto` in the first
54
+ * place — the same no-silent-spend violation the built-in fallback exists to
55
+ * prevent. Every real, file-backed config always has `defaults.mode`
56
+ * populated by the loader (now `auto`), so `DEFAULT_MODE` is never consulted
57
+ * on that path — verified in modes.test.ts and config.test.ts.
43
58
  */
44
59
 
45
60
  export const VALID_MODES: ReadonlySet<string> = new Set(['api', 'manual', 'cli', 'auto']);
46
61
 
47
62
  /**
48
63
  * Built-in fallback when no layer supplies a mode. `manual` — free, no key,
49
- * no spend. NOT the same as the loader's `defaults.mode` default (`api`); see
50
- * the module header.
64
+ * no spend. NOT the same as the loader's `defaults.mode` default (`auto`
65
+ * since road-to-always-on-orchestration Phase 3.1); see the module header.
51
66
  */
52
67
  export const DEFAULT_MODE = 'manual';
53
68
 
@@ -69,6 +69,9 @@ import {
69
69
  import { count_dissenters, dissent_quota_met, is_near_duplicate } from './debate_gates.js';
70
70
  import { parse_stance_line, render_vote_tally, tally_stances } from './stance_tally.js';
71
71
  import { CHAIRMAN_FIELDS_ADDENDUM, render_deanonymization_block } from './blind_review.js';
72
+ import type { AbsentReason } from './transport_resolver.js';
73
+ import type { QuorumResult } from './quorum.js';
74
+ import { isEmptyHandoff, type HandoffEnvelope } from './handoff.js';
72
75
 
73
76
  // ── Python-format / stdlib parity helpers ────────────────────────────────
74
77
  //
@@ -1811,6 +1814,38 @@ export interface RenderOptions {
1811
1814
  * directly in `chairman.text`. Default `null` → byte-identical.
1812
1815
  */
1813
1816
  blind?: { label_to_source: ReadonlyMap<string, string> } | null;
1817
+ /**
1818
+ * Phase 3.2 (road-to-always-on-orchestration): members that produced no
1819
+ * usable response this pass, rendered as a trailing section so the
1820
+ * human-readable report shows the same graded-degradation picture the
1821
+ * session manifest carries structurally. Default `null`/empty →
1822
+ * byte-identical — no existing caller passes this, so no existing
1823
+ * output changes.
1824
+ */
1825
+ absent_members?: readonly RenderAbsentMember[] | null;
1826
+ /**
1827
+ * Phase 3.3: this pass's quorum verdict, rendered as one line right
1828
+ * before the absent-members section (or standalone when there is
1829
+ * nothing absent to report). Default `null` → byte-identical.
1830
+ */
1831
+ quorum?: QuorumResult | null;
1832
+ /**
1833
+ * Phase 4.1: the verdict → handoff envelope, rendered as a `### Handoff`
1834
+ * section right after the Convergence/Divergence slot — closest to the
1835
+ * synthesis it was extracted from, ahead of the trailing quorum/absent
1836
+ * bookkeeping. Rendered only when at least one field is non-`null`
1837
+ * (`isEmptyHandoff` — an all-null envelope has nothing worth a section
1838
+ * for, same "only show when there's substance" call `absent_members`
1839
+ * makes). Default `null` → byte-identical.
1840
+ */
1841
+ handoff?: HandoffEnvelope | null;
1842
+ }
1843
+
1844
+ /** The minimal shape `render()` needs for one absent-member row. */
1845
+ export interface RenderAbsentMember {
1846
+ readonly member: string;
1847
+ readonly reason: AbsentReason | null;
1848
+ readonly detail: string;
1814
1849
  }
1815
1850
 
1816
1851
  /**
@@ -1918,14 +1953,84 @@ export function render(responses: CouncilResponse[], opts: RenderOptions = {}):
1918
1953
  blocks.push(render_vote_tally(tally));
1919
1954
  }
1920
1955
  blocks.push(`## Convergence / Divergence\n\n${body}`);
1956
+ // Phase 4.1: closest to the synthesis it was extracted from — ahead of
1957
+ // the de-anonymization map and the trailing quorum/absent bookkeeping.
1958
+ // An all-null envelope renders nothing (`isEmptyHandoff`), so a caller
1959
+ // that always attaches one (the common shape once a producer wires
1960
+ // this in) does not spam every response with "nothing to hand off".
1961
+ if (opts.handoff && !isEmptyHandoff(opts.handoff)) {
1962
+ blocks.push(_render_handoff(opts.handoff));
1963
+ }
1921
1964
  if (blind !== null) {
1922
1965
  // Ü1: de-anonymization AFTER the synthesis slot — blind is only at
1923
1966
  // decision time, never the archive.
1924
1967
  blocks.push(render_deanonymization_block('### De-anonymization map', blind.label_to_source));
1925
1968
  }
1969
+ // Phase 3.2/3.3: trailing, opt-in sections — absent no caller passes
1970
+ // either option, so the join below adds nothing and every existing
1971
+ // render() output stays byte-identical.
1972
+ if (opts.quorum) {
1973
+ blocks.push(_render_quorum_line(opts.quorum));
1974
+ }
1975
+ const absent = opts.absent_members ?? [];
1976
+ if (absent.length > 0) {
1977
+ blocks.push(_render_absent_members(absent));
1978
+ }
1926
1979
  return blocks.join('\n\n---\n\n');
1927
1980
  }
1928
1981
 
1982
+ /** One line naming the k-of-n outcome — machine-parseable, human-readable. */
1983
+ function _render_quorum_line(q: QuorumResult): string {
1984
+ const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
1985
+ return `**Quorum:** ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.`;
1986
+ }
1987
+
1988
+ /**
1989
+ * `### Absent Members` — one row per member that never produced a usable
1990
+ * response this pass, naming the machine-readable reason when classified.
1991
+ */
1992
+ function _render_absent_members(absent: readonly RenderAbsentMember[]): string {
1993
+ const lines = ['### Absent Members', ''];
1994
+ for (const a of absent) {
1995
+ const tag = a.reason !== null ? ` (${a.reason})` : '';
1996
+ lines.push(`- **${a.member}**${tag} — ${a.detail}`);
1997
+ }
1998
+ return lines.join('\n');
1999
+ }
2000
+
2001
+ /**
2002
+ * `### Handoff` — the verdict → work-order envelope (Phase 4.1). Only
2003
+ * called once the caller already confirmed `!isEmptyHandoff(h)`, so at
2004
+ * least one field is populated; the other two still render their own
2005
+ * honest "none recorded" line rather than being silently dropped, so a
2006
+ * reader sees exactly what this pass did and did not extract.
2007
+ */
2008
+ function _render_handoff(h: HandoffEnvelope): string {
2009
+ const lines = ['### Handoff', ''];
2010
+ lines.push(`**Decision:** ${h.decision ?? 'none recorded — no structured verdict this pass.'}`);
2011
+ lines.push('');
2012
+ if (h.rejected_alternatives !== null && h.rejected_alternatives.length > 0) {
2013
+ lines.push('**Rejected alternatives:**');
2014
+ lines.push('');
2015
+ for (const alt of h.rejected_alternatives) {
2016
+ lines.push(`- **${alt.option}** — ${alt.reason}`);
2017
+ }
2018
+ } else {
2019
+ lines.push('**Rejected alternatives:** none recorded.');
2020
+ }
2021
+ lines.push('');
2022
+ if (h.constraints !== null && h.constraints.length > 0) {
2023
+ lines.push('**Constraints:**');
2024
+ lines.push('');
2025
+ for (const c of h.constraints) {
2026
+ lines.push(`- ${c}`);
2027
+ }
2028
+ } else {
2029
+ lines.push('**Constraints:** none recorded.');
2030
+ }
2031
+ return lines.join('\n');
2032
+ }
2033
+
1929
2034
  /**
1930
2035
  * Render the peer-review block under deterministic Reviewer labels.
1931
2036
  *
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Quorum resolution for a council pass (Phase 3.3 of
3
+ * road-to-always-on-orchestration).
4
+ *
5
+ * A pass concludes when at least `k` of `n` enabled members produced a
6
+ * usable response ("present"); below `k` the pass is `inconclusive`. At a
7
+ * release gate an inconclusive pass HOLDS the gate for a human — it is
8
+ * NEVER silently downgraded to advisory (council-verified, 2026-08-09).
9
+ * Deciding what to DO with an `inconclusive` status (block a merge, retry,
10
+ * surface to the user) is the caller's job; this module only answers
11
+ * whether the threshold was met.
12
+ *
13
+ * `majority` is a SIMPLE majority — `ceil(n / 2)` — deliberately NOT the
14
+ * traditional "more than half" (`floor(n / 2) + 1`). At n=2 those two
15
+ * definitions diverge: `ceil(2/2)=1` vs `floor(2/2)+1=2`. The stricter
16
+ * 2-of-2 reading turns any single absent member into a deadlocked release
17
+ * gate, which is exactly the failure mode council members flagged for the
18
+ * n=2 case this module is built around — 1-of-2 is the deliberate choice,
19
+ * not an off-by-one.
20
+ *
21
+ * This module is pure — no filesystem, no environment, no config parsing.
22
+ * `config.ts::QuorumSetting` is the validated shape a loaded
23
+ * `.ai-council.yml` supplies; a caller reads `cfg.quorum` and passes it
24
+ * straight through to `evaluateQuorum`.
25
+ */
26
+
27
+ import type { QuorumSetting } from './config.js';
28
+
29
+ export type QuorumStatus = 'concluded' | 'inconclusive';
30
+
31
+ export interface QuorumResult {
32
+ /** `concluded` when `present >= threshold`; `inconclusive` otherwise. */
33
+ readonly status: QuorumStatus;
34
+ /** The concrete k this pass needed, after resolving `setting` against `total`. */
35
+ readonly threshold: number;
36
+ /** Total enabled members configured for this pass — the `n`. */
37
+ readonly total: number;
38
+ /** Members that actually produced a usable response. */
39
+ readonly present: number;
40
+ }
41
+
42
+ /**
43
+ * Resolve `setting` into a concrete integer threshold for `total` members.
44
+ *
45
+ * `'majority'` → `ceil(total / 2)` (0 when `total` is 0 — there is nothing
46
+ * to require attendance from). A fixed numeric `setting` is clamped to
47
+ * `[1, total]` so neither a misconfigured cap above `total` (structurally
48
+ * unwinnable) nor one below 1 (would resolve trivially) can escape the
49
+ * config validation `config.ts::_build_quorum` already runs at load time —
50
+ * this is a second, cheap floor for any caller that builds a `QuorumSetting`
51
+ * by hand instead of through the loader.
52
+ */
53
+ export function resolveQuorumThreshold(total: number, setting: QuorumSetting = 'majority'): number {
54
+ if (!Number.isInteger(total) || total < 0) {
55
+ throw new RangeError(
56
+ `resolveQuorumThreshold: total must be a non-negative integer (got ${total}).`,
57
+ );
58
+ }
59
+ if (total === 0) {
60
+ return 0;
61
+ }
62
+ if (setting === 'majority') {
63
+ return Math.ceil(total / 2);
64
+ }
65
+ return Math.min(Math.max(setting, 1), total);
66
+ }
67
+
68
+ /**
69
+ * Evaluate whether a pass met quorum.
70
+ *
71
+ * `present` is clamped into `[0, total]` before comparison — a caller that
72
+ * miscounts (more "present" than were ever configured) cannot manufacture a
73
+ * `concluded` verdict past what `total` actually allows.
74
+ */
75
+ export function evaluateQuorum(
76
+ total: number,
77
+ present: number,
78
+ setting: QuorumSetting = 'majority',
79
+ ): QuorumResult {
80
+ const threshold = resolveQuorumThreshold(total, setting);
81
+ const clampedPresent = Math.min(Math.max(present, 0), total);
82
+ const status: QuorumStatus = clampedPresent >= threshold ? 'concluded' : 'inconclusive';
83
+ return { status, threshold, total, present: clampedPresent };
84
+ }
@@ -46,6 +46,24 @@ import { PyFloat, py_json_dumps_indent2 } from '../_lib/security_lint.js';
46
46
  import { pyRound } from '../_lib/value_ladder.js';
47
47
  import { CouncilResponse } from './clients.js';
48
48
  import { render } from './orchestrator.js';
49
+ import type { AbsentReason } from './transport_resolver.js';
50
+ import type { QuorumResult } from './quorum.js';
51
+
52
+ /**
53
+ * A member that produced no usable response this pass
54
+ * (road-to-always-on-orchestration Phase 3.2) — never constructed (no CLI +
55
+ * no key), or constructed but lost mid-run (timeout, quota exhaustion). The
56
+ * caller assembling the manifest is the one that knows which; this record
57
+ * only carries what it decided.
58
+ */
59
+ export interface AbsentMemberRecord {
60
+ /** Provider name (or "provider/model" — whatever the caller's roster used). */
61
+ readonly member: string;
62
+ /** Machine-readable bucket, or `null` when the failure did not classify. */
63
+ readonly reason: AbsentReason | null;
64
+ /** Human-readable detail — the CLI-hints / install-instructions text. */
65
+ readonly detail: string;
66
+ }
49
67
 
50
68
  // src/scripts/ai_council/session.py → parents[3] == repo root.
51
69
  const _HERE = fileURLToPath(import.meta.url);
@@ -76,6 +94,19 @@ export class SessionManifest {
76
94
  rounds: number;
77
95
  cost_usd_estimated: number;
78
96
  cost_usd_actual: number;
97
+ /**
98
+ * Members that never produced a usable response this pass (Phase 3.2).
99
+ * Empty by default — a caller that never populates it (every caller
100
+ * before this field existed) writes the same manifest shape it always
101
+ * did, plus one empty array.
102
+ */
103
+ absent_members: AbsentMemberRecord[];
104
+ /**
105
+ * Quorum verdict for this pass (Phase 3.3), or `null` when the caller
106
+ * did not evaluate one (e.g. a single-member solo dispatch, where
107
+ * quorum has nothing to say).
108
+ */
109
+ quorum: QuorumResult | null;
79
110
  extra: Record<string, unknown>;
80
111
 
81
112
  constructor(opts: {
@@ -86,6 +117,8 @@ export class SessionManifest {
86
117
  rounds?: number;
87
118
  cost_usd_estimated?: number;
88
119
  cost_usd_actual?: number;
120
+ absent_members?: AbsentMemberRecord[];
121
+ quorum?: QuorumResult | null;
89
122
  extra?: Record<string, unknown>;
90
123
  }) {
91
124
  this.mode = opts.mode;
@@ -95,6 +128,8 @@ export class SessionManifest {
95
128
  this.rounds = opts.rounds ?? 1;
96
129
  this.cost_usd_estimated = opts.cost_usd_estimated ?? 0.0;
97
130
  this.cost_usd_actual = opts.cost_usd_actual ?? 0.0;
131
+ this.absent_members = opts.absent_members ?? [];
132
+ this.quorum = opts.quorum ?? null;
98
133
  // Python dataclass uses `field(default_factory=dict)` — each instance
99
134
  // gets its own fresh dict.
100
135
  this.extra = opts.extra ?? {};
@@ -504,6 +539,15 @@ export function save(opts: {
504
539
  responses_per_round: rounds_data.map((round_responses) =>
505
540
  round_responses.map((r) => _serialise_response(r)),
506
541
  ),
542
+ // Phase 3.2/3.3 — graded degradation + quorum, both machine-readable.
543
+ // Empty array / `null` when the caller never populated them, so an
544
+ // old caller's manifest keeps its existing keys plus these two.
545
+ absent_members: manifest.absent_members.map((a) => ({
546
+ member: a.member,
547
+ reason: a.reason,
548
+ detail: a.detail,
549
+ })),
550
+ quorum: manifest.quorum,
507
551
  ...manifest.extra,
508
552
  };
509
553