@event4u/agent-config 9.27.0 → 9.29.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 (220) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +13 -12
  3. package/CHANGELOG.md +165 -0
  4. package/README.md +1 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/analyze/conformance.md +24 -0
  7. package/dist/agent-src/commands/cost/report.md +2 -2
  8. package/dist/agent-src/commands/team/adversarial.md +8 -7
  9. package/dist/agent-src/commands/team/delegate.md +13 -12
  10. package/dist/agent-src/commands/team/review.md +9 -8
  11. package/dist/agent-src/commands/team/status.md +9 -8
  12. package/dist/agent-src/commands/team.md +29 -19
  13. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  14. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  16. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +28 -3
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +54 -0
  18. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  19. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  20. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  21. package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
  22. package/dist/agent-src/rules/autonomous-execution.md +1 -1
  23. package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
  24. package/dist/agent-src/rules/context-hygiene.md +3 -1
  25. package/dist/agent-src/rules/council-availability.md +93 -0
  26. package/dist/agent-src/rules/cross-source-consistency.md +3 -0
  27. package/dist/agent-src/rules/delegation-policy.md +38 -10
  28. package/dist/agent-src/rules/design-fidelity.md +3 -0
  29. package/dist/agent-src/rules/evaluator-independence.md +2 -1
  30. package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
  31. package/dist/agent-src/rules/finance-safety-floor.md +1 -1
  32. package/dist/agent-src/rules/git-history-discipline.md +1 -1
  33. package/dist/agent-src/rules/history-discipline.md +3 -1
  34. package/dist/agent-src/rules/legal-safety-floor.md +1 -1
  35. package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
  36. package/dist/agent-src/rules/onboarding-gate.md +3 -1
  37. package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
  38. package/dist/agent-src/rules/role-mode-adherence.md +1 -1
  39. package/dist/agent-src/rules/scale-discipline.md +3 -1
  40. package/dist/agent-src/rules/self-repair-loop.md +103 -0
  41. package/dist/agent-src/rules/session-canary.md +2 -1
  42. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  43. package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
  44. package/dist/agent-src/rules/token-budget-discipline.md +4 -2
  45. package/dist/agent-src/rules/tool-safety.md +5 -1
  46. package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
  47. package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
  48. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  49. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  50. package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
  51. package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
  52. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  53. package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
  54. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  55. package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
  56. package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
  57. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  58. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +7 -5
  59. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  60. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  61. package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  62. package/dist/agent-src/templates/AGENTS.md +8 -12
  63. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  64. package/dist/agent-src/templates/roadmaps.md +23 -0
  65. package/dist/cli/registry.js +7 -0
  66. package/dist/cli/registry.js.map +1 -1
  67. package/dist/cli-delegate/chunk-66OVWGB5.js +270 -0
  68. package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-LCFJIQ4J.js} +89 -7
  69. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  70. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-MMXA5GY7.js} +10 -17
  71. package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-MP3JT3IU.js} +3 -3
  72. package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-NZ5N2XQL.js} +467 -305
  73. package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-OJM3MBJM.js} +1 -1
  74. package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-RCIA67XN.js} +117 -96
  75. package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
  76. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  77. package/dist/cli-delegate/cmd_brand_status.js +134 -0
  78. package/dist/cli-delegate/cmd_conformance.js +10 -47
  79. package/dist/cli-delegate/cmd_converge.js +3 -3
  80. package/dist/cli-delegate/cmd_doctor.js +10 -6
  81. package/dist/cli-delegate/cmd_explain.js +80 -330
  82. package/dist/cli-delegate/cmd_export.js +6 -6
  83. package/dist/cli-delegate/cmd_fleet.js +3 -3
  84. package/dist/cli-delegate/cmd_handoff.js +3 -3
  85. package/dist/cli-delegate/cmd_mcp_available.js +433 -0
  86. package/dist/cli-delegate/cmd_migrate.js +3 -3
  87. package/dist/cli-delegate/cmd_packs_active.js +153 -0
  88. package/dist/cli-delegate/cmd_preflight.js +1 -1
  89. package/dist/cli-delegate/cmd_prune.js +6 -6
  90. package/dist/cli-delegate/cmd_route_audit.js +8 -8
  91. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  92. package/dist/cli-delegate/cmd_settings_get.js +266 -0
  93. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  94. package/dist/cli-delegate/cmd_settings_set.js +8 -2968
  95. package/dist/cli-delegate/cmd_sync.js +6 -6
  96. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  97. package/dist/cli-delegate/cmd_update.js +4 -4
  98. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  99. package/dist/cli-delegate/cmd_validate.js +6 -6
  100. package/dist/cli-delegate/cmd_versions.js +4 -4
  101. package/dist/discovery/deprecation-report.md +1 -1
  102. package/dist/discovery/discovery-manifest.json +164 -55
  103. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  104. package/dist/discovery/discovery-manifest.summary.md +4 -3
  105. package/dist/discovery/orphan-report.md +1 -1
  106. package/dist/discovery/packs.json +44 -5
  107. package/dist/discovery/trust-report.md +11 -11
  108. package/dist/discovery/workspaces.json +16 -6
  109. package/dist/hooks/dispatch.js +9341 -6131
  110. package/dist/install/install.mjs +739 -524
  111. package/dist/mcp/registry-manifest.json +3 -3
  112. package/dist/mcp/server.json +2 -2
  113. package/dist/mcp/server.mjs +777 -281
  114. package/dist/router.json +1 -1
  115. package/dist/scripts/_lib/agent_settings.js +134 -15
  116. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  117. package/dist/scripts/condense.js +147 -2
  118. package/dist/scripts/condense.js.map +1 -1
  119. package/dist/server/schemas/settings.js +6 -7
  120. package/dist/server/schemas/settings.js.map +1 -1
  121. package/dist/shared/settingsCarveOut.js +0 -9
  122. package/dist/shared/settingsCarveOut.js.map +1 -1
  123. package/dist/shared/settingsClasses.js +11 -9
  124. package/dist/shared/settingsClasses.js.map +1 -1
  125. package/dist/ui/assets/{index-DmR0mbk4.js → index-JDAF00bC.js} +2 -2
  126. package/dist/ui/assets/{index-DmR0mbk4.js.map → index-JDAF00bC.js.map} +1 -1
  127. package/dist/ui/index.html +1 -1
  128. package/docs/contracts/settings-classes.md +68 -17
  129. package/docs/guidelines/agent-infra/memory-access.md +14 -3
  130. package/package.json +1 -1
  131. package/src/agent-src/templates/AGENTS.md +8 -12
  132. package/src/config/agent-settings.template.yml +52 -66
  133. package/src/config/budgets.yml +29 -0
  134. package/src/config/discovery/packs.yml +11 -0
  135. package/src/config/discovery/workspaces.yml +1 -1
  136. package/src/config/evaluator-budgets.json +4 -4
  137. package/src/config/gate-coverage.yml +26 -0
  138. package/src/config/gate-violation-baselines.json +3 -3
  139. package/src/scripts/_cli/cmd_brand_status.ts +174 -0
  140. package/src/scripts/_cli/cmd_conformance.ts +14 -42
  141. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  142. package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
  143. package/src/scripts/_cli/cmd_packs_active.ts +225 -0
  144. package/src/scripts/_cli/cmd_settings_get.ts +324 -0
  145. package/src/scripts/_dispatch.bash +57 -0
  146. package/src/scripts/_lib/agent_settings.ts +140 -16
  147. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  148. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  149. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  150. package/src/scripts/_lib/carrier_divergence.ts +135 -0
  151. package/src/scripts/_lib/host_capability.ts +144 -4
  152. package/src/scripts/_lib/judgment_ladder.ts +382 -0
  153. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  154. package/src/scripts/_lib/orchestration_record.ts +37 -0
  155. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  156. package/src/scripts/_lib/prompt_shape.ts +88 -0
  157. package/src/scripts/_lib/review_skipped_record.ts +102 -0
  158. package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
  159. package/src/scripts/_lib/self_repair.ts +489 -0
  160. package/src/scripts/_lib/self_repair_store.ts +128 -0
  161. package/src/scripts/_lib/subagent_capsule.ts +182 -0
  162. package/src/scripts/_lib/subagent_response.ts +19 -0
  163. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  164. package/src/scripts/_lib/subagent_steering.ts +9 -4
  165. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  166. package/src/scripts/_lib/transcript_entry.ts +80 -0
  167. package/src/scripts/_lib/worker_budget.ts +37 -2
  168. package/src/scripts/ai_council/clients.ts +76 -54
  169. package/src/scripts/ai_council/config.ts +94 -6
  170. package/src/scripts/ai_council/handoff.ts +113 -0
  171. package/src/scripts/ai_council/modes.ts +19 -4
  172. package/src/scripts/ai_council/orchestrator.ts +105 -0
  173. package/src/scripts/ai_council/quorum.ts +84 -0
  174. package/src/scripts/ai_council/session.ts +44 -0
  175. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  176. package/src/scripts/ai_team/availability.ts +110 -0
  177. package/src/scripts/ai_team/config.ts +34 -14
  178. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  179. package/src/scripts/bench_critic_protocol.ts +396 -0
  180. package/src/scripts/check_branch_freshness.ts +255 -0
  181. package/src/scripts/check_council_config_location.ts +125 -1
  182. package/src/scripts/check_detector_corpus.ts +202 -0
  183. package/src/scripts/check_hook_bundle_freshness.ts +4 -1
  184. package/src/scripts/check_standing_rule_delivery.ts +314 -0
  185. package/src/scripts/condense.ts +162 -2
  186. package/src/scripts/config/profiles.ts +13 -3
  187. package/src/scripts/conformance_scan.ts +272 -24
  188. package/src/scripts/council_availability_hook.ts +188 -0
  189. package/src/scripts/council_cli.ts +527 -32
  190. package/src/scripts/forensics_report.ts +333 -0
  191. package/src/scripts/hook_manifest.yaml +107 -26
  192. package/src/scripts/hooks/block_no_verify.ts +39 -2
  193. package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
  194. package/src/scripts/hooks/concern_registry.ts +10 -0
  195. package/src/scripts/hooks/delegation_nudge_hook.ts +484 -0
  196. package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
  197. package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
  198. package/src/scripts/install.ts +151 -0
  199. package/src/scripts/language_mirror_hook.ts +30 -37
  200. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  201. package/src/scripts/lint_never_silent.ts +276 -0
  202. package/src/scripts/lint_no_activation_gates.ts +271 -0
  203. package/src/scripts/measure_scope_dedup.ts +19 -13
  204. package/src/scripts/onboarding_gate_hook.ts +40 -1
  205. package/src/scripts/orchestration_record.ts +12 -1
  206. package/src/scripts/preamble_byte_census.ts +9 -20
  207. package/src/scripts/rebuild_hook_bundle.ts +177 -0
  208. package/src/scripts/release.ts +74 -2
  209. package/src/scripts/report_carrier_divergence.ts +364 -0
  210. package/src/scripts/report_skill_activation.ts +257 -0
  211. package/src/scripts/report_skill_obligation_violations.ts +579 -0
  212. package/src/scripts/routing_doctor.ts +95 -39
  213. package/src/scripts/score_skill_selection.ts +19 -5
  214. package/src/scripts/self_repair_cli.ts +470 -0
  215. package/src/scripts/self_repair_hook.ts +241 -0
  216. package/src/scripts/team_events_spike.ts +271 -0
  217. package/src/scripts/team_review_gate_hook.ts +31 -9
  218. package/src/server/schemas/settings.ts +10 -17
  219. package/src/shared/settingsCarveOut.ts +0 -10
  220. package/src/shared/settingsClasses.ts +11 -9
@@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
18
18
 
19
19
  import { project_settings_path, resolve_project_root } from './_lib/agent_settings.js';
20
20
  import { load_agent_settings } from './_lib/agent_settings.js';
21
+ import { detectEnvironment, type EnvironmentReport } from './_lib/environment_detector.js';
21
22
 
22
23
  import {
23
24
  BundleTooLarge,
@@ -63,13 +64,19 @@ import { format_install_hints } from './ai_council/cli_hints.js';
63
64
  import {
64
65
  type AdvisorConfig,
65
66
  type CouncilConfig,
67
+ type QuorumSetting,
68
+ COUNCIL_CONFIG_ENV,
69
+ COUNCIL_CONFIG_USER_GLOBAL_REL,
66
70
  CouncilConfigError,
67
71
  load_council_config,
68
72
  resolve_api_key,
69
73
  resolve_config_path,
70
74
  } from './ai_council/config.js';
71
75
  import { AuthCache, select_solo_member } from './ai_council/solo_dispatch.js';
72
- import { InvalidModeError, resolve_global_mode, resolve_mode } from './ai_council/modes.js';
76
+ import { InvalidModeError, resolve_global_mode } from './ai_council/modes.js';
77
+ import { resolveMemberTransport } from './ai_council/transport_resolver.js';
78
+ import { absentReasonFromCliFailure, classifyCliFailure, type AbsentReason } from './ai_council/transport_resolver.js';
79
+ import { evaluateQuorum, type QuorumResult } from './ai_council/quorum.js';
73
80
  import { appendEvent } from './ai_council/events_log.js';
74
81
  import {
75
82
  type ClassificationResult,
@@ -80,7 +87,8 @@ import {
80
87
  educate_message,
81
88
  } from './ai_council/necessity.js';
82
89
  import type {
83
- DebateCheckpoint} from './ai_council/orchestrator.js';
90
+ DebateCheckpoint,
91
+ RenderAbsentMember} from './ai_council/orchestrator.js';
84
92
  import {
85
93
  ConsensusResult,
86
94
  CostBudget,
@@ -126,6 +134,8 @@ import {
126
134
  render_deanonymization_block,
127
135
  with_chairman_fields,
128
136
  } from './ai_council/blind_review.js';
137
+ import { tally_stances } from './ai_council/stance_tally.js';
138
+ import { buildHandoffFromStanceTally, type HandoffEnvelope } from './ai_council/handoff.js';
129
139
 
130
140
  // ── argparse-style exit plumbing ────────────────────────────────────
131
141
  // Mirror CPython argparse: a `prog: error: …` line on stderr + exit 2.
@@ -501,6 +511,13 @@ function _synthesize_ai_council_block(cfg: CouncilConfig): Dict {
501
511
  max_calls_per_day: _mapToObject(cfg.cli_call_budget.max_calls_per_day),
502
512
  warn_at: cfg.cli_call_budget.warn_at,
503
513
  },
514
+ // Phase 3.3 (road-to-always-on-orchestration): was validated by
515
+ // `_build_config`/`_build_quorum` but never forwarded into the
516
+ // synthesized block, so `build_members` always saw `undefined` and
517
+ // silently fell back to `'majority'` regardless of the user's
518
+ // configured `quorum: <k>`. Additive key — callers that never read
519
+ // it observe no change.
520
+ quorum: cfg.quorum,
504
521
  necessity_classifier: {
505
522
  enabled: cfg.necessity_classifier.enabled,
506
523
  mode: cfg.necessity_classifier.mode,
@@ -548,6 +565,118 @@ interface BuildMembersOptions {
548
565
  model_overrides?: Record<string, string> | null;
549
566
  siblings_overrides?: Record<string, string[]> | null;
550
567
  skipped?: Dict[] | null;
568
+ /**
569
+ * Out-param (road-to-always-on-orchestration Phase 3.3): when supplied,
570
+ * `.result` is populated with this call's quorum verdict — enabled
571
+ * members vs. the count actually constructed (an "absent" push into
572
+ * `skipped` counts against it same as a hard construction failure).
573
+ * The object is mutated in place so a caller declares it once and reads
574
+ * it after `build_members` returns; `.result` stays `null` when the
575
+ * caller passes no ref, matching `SessionManifest.quorum`'s own
576
+ * "null = never evaluated" convention.
577
+ */
578
+ quorum_out?: { result: QuorumResult | null } | null;
579
+ /**
580
+ * Test-only injection point, mirroring the `settings`/`members`/`table`
581
+ * DI pattern the CLI commands already use. Production callers never set
582
+ * this — `build_members` falls back to the real, process-memoised
583
+ * `detectEnvironment()` — but a fixed `EnvironmentReport` is what lets a
584
+ * unit test exercise the `auto` chain (present / absent / quorum)
585
+ * deterministically instead of poking real `$PATH` / `$HOME` / env-key
586
+ * state that differs per machine and per CI run.
587
+ */
588
+ environment_report?: EnvironmentReport | null;
589
+ }
590
+
591
+ /**
592
+ * The member's own `api_key_ref`, resolved directly — never the generic
593
+ * per-provider environment guess `resolveTransport`'s `auto` chain falls
594
+ * back to. A member with a non-default ref (`env:MY_CUSTOM_KEY`, a renamed
595
+ * key file) would otherwise read as keyless even when it is fully
596
+ * configured. Returns `undefined` when no ref is set at all, letting
597
+ * `resolveMemberTransport` fall through to its own generic detection.
598
+ */
599
+ function _member_api_key_present(cfg: Dict): boolean | undefined {
600
+ const ref = cfg['api_key_ref'];
601
+ if (typeof ref !== 'string' || ref.trim() === '') {
602
+ return undefined;
603
+ }
604
+ try {
605
+ resolve_api_key(ref);
606
+ return true;
607
+ } catch {
608
+ return false;
609
+ }
610
+ }
611
+
612
+ /**
613
+ * `ai['quorum']` as forwarded by `_synthesize_ai_council_block` (already
614
+ * validated by `config.ts::_build_quorum` on that path) — or, for a caller
615
+ * that hands `build_members` a hand-built dict bypassing the loader
616
+ * entirely, a defensive re-check that fails soft to `'majority'` rather
617
+ * than throwing. `build_members` is not the config-validation layer;
618
+ * `evaluateQuorum` degrades an unrecognisable setting toward
619
+ * `inconclusive`, never toward a false `concluded`, so failing soft here
620
+ * costs nothing on the safety side.
621
+ */
622
+ function _quorum_setting_from(ai: Dict): QuorumSetting {
623
+ const raw = ai['quorum'];
624
+ if (raw === 'majority') {
625
+ return 'majority';
626
+ }
627
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) {
628
+ return raw;
629
+ }
630
+ return 'majority';
631
+ }
632
+
633
+ /**
634
+ * Re-derive quorum AFTER the provider calls, over what was actually USABLE —
635
+ * never what merely CONSTRUCTED (M3, independent-review finding). `members`
636
+ * and `responses` must be index-aligned (`consult()`'s own contract: one
637
+ * `CouncilResponse` per member, from the final round); `members.length` is
638
+ * the `n` — deliberately the roster that actually ran (post `--single` /
639
+ * `--siblings` filtering), never the full config roster a filtered run
640
+ * never attempted. A response carrying `.error` (or a missing response
641
+ * entry altogether) counts as absent, classified through the same
642
+ * `AbsentReason` vocabulary a static (pre-call) resolution failure uses, so
643
+ * one artefact buckets every absence — construction-time and mid-flight —
644
+ * under one taxonomy.
645
+ */
646
+ function _postRunQuorum(
647
+ members: ExternalAIClient[],
648
+ responses: CouncilResponse[],
649
+ ai_cfg: Dict,
650
+ ): { quorum: QuorumResult; absent: Dict[] } {
651
+ const absent: Dict[] = [];
652
+ let present = 0;
653
+ for (let i = 0; i < members.length; i++) {
654
+ const m = members[i] as ExternalAIClient;
655
+ const r = responses[i];
656
+ if (r !== undefined && !r.error) {
657
+ present += 1;
658
+ continue;
659
+ }
660
+ const raw = r?.error ?? 'no response';
661
+ const failure = classifyCliFailure(raw);
662
+ absent.push({
663
+ member: m.name,
664
+ reason: absentReasonFromCliFailure(failure) ?? 'unavailable',
665
+ detail: raw,
666
+ });
667
+ }
668
+ return { quorum: evaluateQuorum(members.length, present, _quorum_setting_from(ai_cfg)), absent };
669
+ }
670
+
671
+ /**
672
+ * One-line k-of-n banner, mirrored from `orchestrator.ts::_render_quorum_line`
673
+ * but for the CLI's own stdout stream rather than the rendered report —
674
+ * "attendance is telemetry, never a silent drop" applies to the estimate
675
+ * preview too, not only to a completed pass.
676
+ */
677
+ function _format_quorum_line(q: QuorumResult): string {
678
+ const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
679
+ return `council:quorum · ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.`;
551
680
  }
552
681
 
553
682
  function build_members(settings: Dict, opts: BuildMembersOptions = {}): ExternalAIClient[] {
@@ -555,12 +684,31 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
555
684
  const model_overrides = opts.model_overrides ?? null;
556
685
  const siblings_overrides = opts.siblings_overrides ?? null;
557
686
  const skipped = opts.skipped ?? null;
687
+ const quorum_out = opts.quorum_out ?? null;
688
+ // Read-only, per-process-memoised (`detectEnvironment` caches the
689
+ // no-argument call) — cheap to call once per `build_members` invocation.
690
+ // A caller may inject a fixed report (tests only); production leaves
691
+ // this unset.
692
+ const report = opts.environment_report ?? detectEnvironment();
558
693
 
559
694
  const ai = _isDict(settings) ? ((settings['ai_council'] as Dict) || {}) : {};
560
695
  if (!_pyBool(ai['enabled'])) {
696
+ // The file this message names is load-bearing, and it named the wrong one
697
+ // for months. Measured across 10 sessions in one consumer project: the
698
+ // agent reasoned from `.agent-settings.yml` six times, concluded no
699
+ // council existed, and substituted a weaker path — and one session
700
+ // "fixed" it by COPYING the user-global config into the project tree,
701
+ // because this message told it that was where the switch lived. An error
702
+ // from the authoritative tool is the strongest signal in the room; when
703
+ // it points at the wrong file it does not merely fail to help, it
704
+ // manufactures the wrong belief. Name the real path (ADR-104: always
705
+ // user-global, never project-local) and name the verb that reports it.
561
706
  throw new CouncilDisabledError(
562
- 'ai_council.enabled is false in .agent-settings.yml — ' +
563
- 'flip it on before invoking council:* commands.',
707
+ `enabled is false in the council config (${COUNCIL_CONFIG_USER_GLOBAL_REL} ` +
708
+ 'under the user-global agent-config root) — flip it on before invoking ' +
709
+ 'council:* commands. This config is NEVER project-local (ADR-104), so do ' +
710
+ 'not look for it, or create it, in the project tree. ' +
711
+ '`agent-config council:status` prints the resolved path.',
564
712
  );
565
713
  }
566
714
  const members_cfg = (ai['members'] as Dict) || {};
@@ -602,26 +750,58 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
602
750
  );
603
751
  }
604
752
  const members: ExternalAIClient[] = [];
753
+ // Enabled member-config ENTRIES this pass considered — the `n` in
754
+ // quorum's k-of-n. One entry always resolves to exactly one of: a
755
+ // successful construction (>=1 pushed client; siblings fan-out still
756
+ // counts as one entry), or exactly one absent count — never both, never
757
+ // neither — so `total_enabled - absent_count` is `present` without a
758
+ // second pass over `members`. `absent_count` is tracked independently of
759
+ // the caller-optional `skipped` out-param (which may be `null` when a
760
+ // caller wants `quorum_out` without the human-readable skip list) —
761
+ // counting `skipped.length` instead silently overcounted `present` for
762
+ // that caller shape.
763
+ let total_enabled = 0;
764
+ let absent_count = 0;
605
765
  for (const name of Object.keys(members_cfg)) {
606
766
  const cfg = ((members_cfg[name] as Dict) || {}) as Dict;
607
767
  if (!_pyBool(cfg['enabled'])) {
608
768
  if (name in siblings) {
609
769
  throw new CouncilDisabledError(
610
770
  `--siblings targets member ${_pyReprStr(name)} but it is not ` +
611
- `enabled in .agent-settings.yml (ai_council.members.${name}.enabled).`,
771
+ `enabled in the council config (members.${name}.enabled in ` +
772
+ `${COUNCIL_CONFIG_USER_GLOBAL_REL} under the user-global root).`,
612
773
  );
613
774
  }
614
775
  continue;
615
776
  }
616
- const mode = resolve_mode(name, {
777
+ total_enabled += 1;
778
+ // road-to-always-on-orchestration Phase 3.1: the single reconciled
779
+ // entry point — composes `resolve_mode` (which layer decided the
780
+ // mode) with `resolveTransport` (what `auto` expands to on THIS
781
+ // machine). The hand-rolled `mode === 'api' | 'cli' | 'manual'`
782
+ // switch this replaces had no `'auto'` case, so every invocation
783
+ // broke the moment the loader default flipped `api` → `auto`
784
+ // (`config.ts::_build_defaults`) — every member fell through to the
785
+ // final `else` below and killed the whole pass.
786
+ const api_key_present = _member_api_key_present(cfg);
787
+ const resolved = resolveMemberTransport({
788
+ provider: name,
789
+ report,
617
790
  invocationMode: invocation_mode,
618
791
  memberSettings: cfg,
619
792
  globalMode: global_mode ?? null,
793
+ binaryOverride: (cfg['binary'] as string | null) ?? null,
794
+ // `exactOptionalPropertyTypes` rejects an explicit `undefined` —
795
+ // see `resolveMemberTransport`'s own forwarding call for the
796
+ // same pattern.
797
+ ...(api_key_present !== undefined ? { apiKeyPresent: api_key_present } : {}),
620
798
  });
621
799
  if (name in siblings) {
622
- if (mode !== 'api') {
800
+ if (resolved.transport !== 'api') {
623
801
  throw new CouncilDisabledError(
624
- `--siblings requires mode=api for member ${_pyReprStr(name)} (got ${_pyReprStr(mode)}).`,
802
+ `--siblings requires mode=api for member ${_pyReprStr(name)} ` +
803
+ `(configured mode=${_pyReprStr(resolved.configuredMode)}, resolved transport=` +
804
+ `${_pyReprStr(resolved.transport ?? 'none')}).`,
625
805
  );
626
806
  }
627
807
  const api_key_ref = (cfg['api_key_ref'] as string | null) ?? null;
@@ -642,19 +822,69 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
642
822
  }
643
823
  continue;
644
824
  }
825
+ if (resolved.transport === null) {
826
+ // Phase 3.2 — graded degradation: a member whose transport
827
+ // resolves to ∅ (no CLI + no key under `auto`) is recorded
828
+ // `absent` with a machine-readable `AbsentReason` and the pass
829
+ // continues, instead of killing the whole invocation the way
830
+ // the old unconditional `else { throw }` below did.
831
+ const detail = resolved.reason ?? `member ${name} has no available transport`;
832
+ const entry: Dict = {
833
+ member: name,
834
+ reason: resolved.absentReason ?? 'unavailable',
835
+ detail,
836
+ };
837
+ absent_count += 1;
838
+ if (skipped !== null) {
839
+ skipped.push(entry);
840
+ }
841
+ _stderr(`[council] SKIP ${name}: ${detail}\n`);
842
+ continue;
843
+ }
645
844
  const model = (overrides[name] as string | undefined) || (cfg['model'] as string | undefined) || null;
646
- if (mode === 'api' && _API_PROVIDERS.has(name)) {
647
- members.push(
648
- _construct_api_member(name, model, {
649
- api_key_ref: (cfg['api_key_ref'] as string | null) ?? null,
650
- // Council opts in explicitly (client-default OFF); on unless
651
- // the operator sets `prompt_cache: false`.
652
- enable_prompt_cache: cfg['prompt_cache'] !== false,
653
- // road-to-cache-economy Phase 4 — see the siblings branch above.
654
- prompt_cache_ttl: (cfg['prompt_cache_ttl'] as '5m' | '1h' | undefined) ?? undefined,
655
- }),
656
- );
657
- } else if (mode === 'cli' && _CLI_PROVIDERS.has(name)) {
845
+ if (resolved.transport === 'api' && _API_PROVIDERS.has(name)) {
846
+ // m2 fix (independent-review finding) — `resolveMemberTransport`'s
847
+ // `auto` chain treats "a key-file or env-key auth record exists
848
+ // for this provider" as sufficient to resolve transport=`api`
849
+ // (its own default `apiKeyPresent` read off the environment
850
+ // report, used whenever the member config carries no explicit
851
+ // `api_key_ref` at all — see `_member_api_key_present` above,
852
+ // which returns `undefined`, not `false`, in exactly that case).
853
+ // But gemini/xai/perplexity's OWN construction contract is
854
+ // stricter: `_construct_api_member` requires an EXPLICIT
855
+ // `api_key_ref` for those three and refuses the legacy-fallback
856
+ // path other providers get, throwing `CouncilDisabledError`. A
857
+ // generic env var (e.g. `GEMINI_API_KEY` set for an unrelated
858
+ // tool) satisfying the permissive auto-chain read while
859
+ // `api_key_ref` was never wired into the council config is
860
+ // exactly this mismatch — previously an UNCAUGHT throw here
861
+ // killed the entire pass. Same catch-and-skip shape the CLI
862
+ // branch below already uses for its own construction failure.
863
+ try {
864
+ members.push(
865
+ _construct_api_member(name, model, {
866
+ api_key_ref: (cfg['api_key_ref'] as string | null) ?? null,
867
+ // Council opts in explicitly (client-default OFF); on unless
868
+ // the operator sets `prompt_cache: false`.
869
+ enable_prompt_cache: cfg['prompt_cache'] !== false,
870
+ // road-to-cache-economy Phase 4 — see the siblings branch above.
871
+ prompt_cache_ttl: (cfg['prompt_cache_ttl'] as '5m' | '1h' | undefined) ?? undefined,
872
+ }),
873
+ );
874
+ } catch (exc) {
875
+ if (!(exc instanceof CouncilDisabledError)) {
876
+ throw exc;
877
+ }
878
+ const detail = exc.message;
879
+ const entry: Dict = { member: name, reason: 'no_auth', detail };
880
+ absent_count += 1;
881
+ if (skipped !== null) {
882
+ skipped.push(entry);
883
+ }
884
+ _stderr(`[council] SKIP ${name}: ${detail}\n`);
885
+ continue;
886
+ }
887
+ } else if (resolved.transport === 'cli' && _CLI_PROVIDERS.has(name)) {
658
888
  try {
659
889
  members.push(
660
890
  _construct_cli_member(name, model, {
@@ -680,30 +910,32 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
680
910
  reason: 'binary_missing',
681
911
  detail,
682
912
  };
913
+ absent_count += 1;
683
914
  if (skipped !== null) {
684
915
  skipped.push(entry);
685
916
  }
686
917
  _stderr(`[council] SKIP ${name}: ${detail}\n`);
687
918
  continue;
688
919
  }
689
- } else if (mode === 'cli') {
920
+ } else if (resolved.transport === 'cli') {
690
921
  throw new CouncilDisabledError(
691
922
  `member ${_pyReprStr(name)} resolves to mode=cli but no CLI client is ` +
692
923
  `wired (known: ${_pyReprStrList(_pySortedStr(_CLI_PROVIDERS))}).`,
693
924
  );
694
- } else if (mode === 'manual') {
925
+ } else if (resolved.transport === 'manual') {
695
926
  members.push(new ManualClient({ name, model: model || 'manual' }));
696
- } else if (mode === 'playwright') {
697
- throw new CouncilDisabledError(
698
- `member ${_pyReprStr(name)} resolves to mode=playwright (Phase 2c, not wired).`,
699
- );
700
927
  } else {
701
928
  throw new CouncilDisabledError(
702
- `member ${_pyReprStr(name)} has no transport — mode=${mode}, ` +
703
- `name not in ${_pyReprStrList(_pySortedStr(_API_PROVIDERS))}.`,
929
+ `member ${_pyReprStr(name)} has no transport — resolved=${_pyReprStr(resolved.transport)}, ` +
930
+ `configured mode=${_pyReprStr(resolved.configuredMode)}, name not in ` +
931
+ `${_pyReprStrList(_pySortedStr(_API_PROVIDERS))}.`,
704
932
  );
705
933
  }
706
934
  }
935
+ if (quorum_out !== null) {
936
+ const present = total_enabled - absent_count;
937
+ quorum_out.result = evaluateQuorum(total_enabled, present, _quorum_setting_from(ai));
938
+ }
707
939
  if (members.length === 0) {
708
940
  if (skipped && skipped.length > 0) {
709
941
  const names = skipped.map((s) => String(s['member'])).join(', ');
@@ -714,8 +946,10 @@ function build_members(settings: Dict, opts: BuildMembersOptions = {}): External
714
946
  );
715
947
  }
716
948
  throw new CouncilDisabledError(
717
- 'no council member has `enabled: true` — enable at least one in ' +
718
- '.agent-settings.yml under ai_council.members.*.',
949
+ 'no council member has `enabled: true` — enable at least one under ' +
950
+ `members.* in ${COUNCIL_CONFIG_USER_GLOBAL_REL} (user-global root; ` +
951
+ 'never the project tree, ADR-104). `agent-config council:status` ' +
952
+ 'prints the resolved path.',
719
953
  );
720
954
  }
721
955
  return members;
@@ -1316,6 +1550,105 @@ function _objToMap(o: Dict): Map<string, string> {
1316
1550
  return m;
1317
1551
  }
1318
1552
 
1553
+ /**
1554
+ * Phase 4.1 — read a persisted `handoff` block back off a saved responses
1555
+ * JSON. Defensive against a payload written before this field existed
1556
+ * (`undefined`) and against a malformed one (a non-string `decision`, a
1557
+ * `rejected_alternatives` entry missing `option`/`reason`) — either
1558
+ * degrades that one field to `null` rather than throwing; a stale or
1559
+ * hand-edited artefact never blocks a re-render.
1560
+ */
1561
+ function _deserialise_handoff(data: unknown): HandoffEnvelope | null {
1562
+ if (!_isDict(data)) {
1563
+ return null;
1564
+ }
1565
+ const d = data as Dict;
1566
+ const decision = typeof d['decision'] === 'string' ? (d['decision'] as string) : null;
1567
+ const rawAlts = d['rejected_alternatives'];
1568
+ const alts = Array.isArray(rawAlts)
1569
+ ? (rawAlts as unknown[])
1570
+ .filter(
1571
+ (a): a is Dict =>
1572
+ _isDict(a) && typeof (a as Dict)['option'] === 'string' && typeof (a as Dict)['reason'] === 'string',
1573
+ )
1574
+ .map((a) => ({ option: a['option'] as string, reason: a['reason'] as string }))
1575
+ : [];
1576
+ const rawConstraints = d['constraints'];
1577
+ const constraints = Array.isArray(rawConstraints)
1578
+ ? (rawConstraints as unknown[]).filter((c): c is string => typeof c === 'string')
1579
+ : [];
1580
+ return {
1581
+ decision,
1582
+ rejected_alternatives: alts.length > 0 ? alts : null,
1583
+ constraints: constraints.length > 0 ? constraints : null,
1584
+ };
1585
+ }
1586
+
1587
+ const _VALID_ABSENT_REASONS: ReadonlySet<AbsentReason> = new Set([
1588
+ 'no_binary',
1589
+ 'no_auth',
1590
+ 'timeout',
1591
+ 'quota',
1592
+ ]);
1593
+
1594
+ /**
1595
+ * M4 (independent-review finding) — `cmd_run` writes `payload['quorum']` /
1596
+ * `payload['absent_members']` (Phase 3.2/3.3), but `cmd_render` never read
1597
+ * either back, so a `council:render` off a saved payload silently dropped
1598
+ * the graded-degradation picture the JSON already carried — same shape as
1599
+ * the `handoff` round-trip gap `_deserialise_handoff` above closes. Same
1600
+ * defensive style: a payload written before these fields existed, or a
1601
+ * malformed entry, degrades to `null`/a shorter list rather than throwing —
1602
+ * a stale or hand-edited artefact never blocks a re-render. `reason` values
1603
+ * outside the `AbsentReason` enum (e.g. `build_members`'s own
1604
+ * `'unavailable'`/`'binary_missing'` skip reasons, which predate and are
1605
+ * broader than the enum `render()` types against) degrade to `null` — the
1606
+ * `member`/`detail` text still renders either way.
1607
+ */
1608
+ function _deserialise_absent_members(data: unknown): readonly RenderAbsentMember[] | null {
1609
+ if (!Array.isArray(data)) {
1610
+ return null;
1611
+ }
1612
+ const out: RenderAbsentMember[] = [];
1613
+ for (const entry of data) {
1614
+ if (!_isDict(entry)) {
1615
+ continue;
1616
+ }
1617
+ const d = entry as Dict;
1618
+ const member = d['member'];
1619
+ const detail = d['detail'];
1620
+ if (typeof member !== 'string' || typeof detail !== 'string') {
1621
+ continue;
1622
+ }
1623
+ const raw = d['reason'];
1624
+ const reason = typeof raw === 'string' && _VALID_ABSENT_REASONS.has(raw as AbsentReason)
1625
+ ? (raw as AbsentReason)
1626
+ : null;
1627
+ out.push({ member, reason, detail });
1628
+ }
1629
+ return out.length > 0 ? out : null;
1630
+ }
1631
+
1632
+ function _deserialise_quorum(data: unknown): QuorumResult | null {
1633
+ if (!_isDict(data)) {
1634
+ return null;
1635
+ }
1636
+ const d = data as Dict;
1637
+ const status = d['status'];
1638
+ const threshold = d['threshold'];
1639
+ const total = d['total'];
1640
+ const present = d['present'];
1641
+ if (
1642
+ (status !== 'concluded' && status !== 'inconclusive') ||
1643
+ typeof threshold !== 'number' ||
1644
+ typeof total !== 'number' ||
1645
+ typeof present !== 'number'
1646
+ ) {
1647
+ return null;
1648
+ }
1649
+ return { status, threshold, total, present };
1650
+ }
1651
+
1319
1652
  // ── round / depth / token resolution ────────────────────────────────
1320
1653
 
1321
1654
  function _resolve_rounds(args: Args, ai_cfg: Dict): number {
@@ -1362,12 +1695,14 @@ function cmd_estimate(
1362
1695
  const advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT);
1363
1696
  const explicit_overrides = _parse_model_overrides(_getattr<string[] | null>(args, 'model', null));
1364
1697
  const skipped: Dict[] = [];
1698
+ const quorum_out: { result: QuorumResult | null } = { result: null };
1365
1699
  if (members === null) {
1366
1700
  members = build_members(settings, {
1367
1701
  invocation_mode: args.mode_override,
1368
1702
  model_overrides: _advisor_model_overrides(advisor_plans, explicit_overrides),
1369
1703
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
1370
1704
  skipped,
1705
+ quorum_out,
1371
1706
  });
1372
1707
  }
1373
1708
  if (table === null) {
@@ -1406,6 +1741,9 @@ function cmd_estimate(
1406
1741
  if (skipped.length > 0) {
1407
1742
  _stdout(format_install_hints(skipped) + '\n');
1408
1743
  }
1744
+ if (quorum_out.result !== null) {
1745
+ _stdout(_format_quorum_line(quorum_out.result) + '\n');
1746
+ }
1409
1747
  _stdout(
1410
1748
  format_estimate_table(billable, estimates, {
1411
1749
  consensus_delta_usd: extra_usd,
@@ -1855,6 +2193,95 @@ function _emit_shadow_slo_banner(): void {
1855
2193
  }
1856
2194
  }
1857
2195
 
2196
+ /**
2197
+ * `council:status` — answer "is a council configured, and from where" without
2198
+ * spending anything, so no caller has to infer it from a filename.
2199
+ *
2200
+ * WHY THIS EXISTS. Measured 2026-08-08: an agent working in a consumer project
2201
+ * announced "Kein Council konfiguriert (keine `.agent-settings.yml`)" and
2202
+ * substituted a weaker subagent fan-out. The council was configured the whole
2203
+ * time, user-globally. The inference was wrong twice over — the council config
2204
+ * has not lived in `.agent-settings.yml` since the Phase-0 migration, and per
2205
+ * ADR-104 the project tree is **never** searched, so the presence or absence of
2206
+ * any project file says nothing at all about council availability.
2207
+ *
2208
+ * The guidance was not missing: `/council default` states this emphatically in
2209
+ * four places. But a command file only loads when the command is invoked, and
2210
+ * the agent was deciding *whether a capability existed* — a question it reached
2211
+ * before invoking anything. The always-loaded layer says "when the council is
2212
+ * enabled" and never says how to find out.
2213
+ *
2214
+ * So the fix is not more prose. It is a check that costs nothing to run and
2215
+ * leaves nothing to infer. Exit is always 0 — this reports a state, it does not
2216
+ * gate — and the verdict line is machine-greppable in both directions.
2217
+ */
2218
+ export function cmd_status(args: Args, opts: { env?: Record<string, string | undefined> } = {}): number {
2219
+ const env = opts.env ?? process.env;
2220
+ const override = env[COUNCIL_CONFIG_ENV];
2221
+ const path_ = resolve_config_path(REPO_ROOT, { env: env as never });
2222
+ const exists = fs.existsSync(String(path_));
2223
+ const provenance = override ? `${COUNCIL_CONFIG_ENV} override` : 'user-global';
2224
+
2225
+ let cfg: CouncilConfig | null = null;
2226
+ let parse_error: string | null = null;
2227
+ if (exists) {
2228
+ try {
2229
+ cfg = load_council_config(path_);
2230
+ } catch (exc) {
2231
+ parse_error = exc instanceof Error ? exc.message : String(exc);
2232
+ }
2233
+ }
2234
+
2235
+ const enabledMembers = cfg === null ? [] : [...cfg.members.entries()].filter(([, m]) => m.enabled);
2236
+ // "Configured" deliberately means: a readable file whose `enabled` is true and
2237
+ // which has at least one enabled member. A file that parses but enables
2238
+ // nothing is the case a caller most needs told apart from a missing file,
2239
+ // because only one of the two is fixed by writing a config.
2240
+ const configured = cfg !== null && cfg.enabled && enabledMembers.length > 0;
2241
+
2242
+ if (_getattr(args, 'json', false)) {
2243
+ _stdout(
2244
+ `${JSON.stringify({
2245
+ configured,
2246
+ path: String(path_),
2247
+ exists,
2248
+ provenance,
2249
+ enabled: cfg?.enabled ?? null,
2250
+ members_total: cfg === null ? null : cfg.members.size,
2251
+ members_enabled: cfg === null ? null : enabledMembers.length,
2252
+ member_names: enabledMembers.map(([n]) => n),
2253
+ parse_error,
2254
+ project_tree_consulted: false,
2255
+ })}\n`,
2256
+ );
2257
+ return 0;
2258
+ }
2259
+
2260
+ _stdout(`council:status · ${configured ? 'CONFIGURED' : 'NOT CONFIGURED'}\n`);
2261
+ _stdout(` config path ${String(path_)}\n`);
2262
+ _stdout(` resolved by ${provenance}\n`);
2263
+ _stdout(` file exists ${exists ? 'yes' : 'no'}\n`);
2264
+ if (parse_error !== null) {
2265
+ _stdout(` parse error ${parse_error}\n`);
2266
+ }
2267
+ if (cfg !== null) {
2268
+ _stdout(` enabled ${cfg.enabled ? 'yes' : 'no'}\n`);
2269
+ _stdout(` members ${enabledMembers.length} enabled of ${cfg.members.size}\n`);
2270
+ if (enabledMembers.length > 0) {
2271
+ _stdout(` ${enabledMembers.map(([n]) => n).join(', ')}\n`);
2272
+ }
2273
+ }
2274
+ _stdout('\n');
2275
+ _stdout(' The project tree is NEVER consulted (ADR-104). No project file —\n');
2276
+ _stdout(' `.agent-settings.yml` included — indicates council availability in\n');
2277
+ _stdout(' either direction. Do not infer it from one; run this instead.\n');
2278
+ if (!configured) {
2279
+ _stdout(`\n To configure: write ${COUNCIL_CONFIG_USER_GLOBAL_REL} under the user-global\n`);
2280
+ _stdout(' agent-config root, with `enabled: true` and at least one enabled member.\n');
2281
+ }
2282
+ return 0;
2283
+ }
2284
+
1858
2285
  function _apply_solo_dispatch(members: ExternalAIClient[]): [ExternalAIClient[], string | null] {
1859
2286
  let cfg: CouncilConfig;
1860
2287
  try {
@@ -1928,12 +2355,14 @@ function cmd_run(
1928
2355
  const advisor_plans = _build_advisor_plans(ai_cfg, REPO_ROOT);
1929
2356
  const explicit_overrides = _parse_model_overrides(_getattr<string[] | null>(args, 'model', null));
1930
2357
  const skipped: Dict[] = [];
2358
+ const quorum_out: { result: QuorumResult | null } = { result: null };
1931
2359
  if (members === null) {
1932
2360
  members = build_members(settings, {
1933
2361
  invocation_mode: args.mode_override,
1934
2362
  model_overrides: _advisor_model_overrides(advisor_plans, explicit_overrides),
1935
2363
  siblings_overrides: _parse_siblings_overrides(_getattr<string[] | null>(args, 'siblings', null)),
1936
2364
  skipped,
2365
+ quorum_out,
1937
2366
  });
1938
2367
  }
1939
2368
  if (_getattr(args, 'single', false)) {
@@ -1996,6 +2425,9 @@ function cmd_run(
1996
2425
  if (skipped.length > 0) {
1997
2426
  _stdout(format_install_hints(skipped) + '\n');
1998
2427
  }
2428
+ if (quorum_out.result !== null) {
2429
+ _stdout(_format_quorum_line(quorum_out.result) + '\n');
2430
+ }
1999
2431
  _stdout(
2000
2432
  format_estimate_table(billable, estimates, {
2001
2433
  consensus_delta_usd: extra_usd,
@@ -2091,6 +2523,38 @@ function cmd_run(
2091
2523
  stance_repairs.push(r);
2092
2524
  },
2093
2525
  });
2526
+ // M3 fix (independent-review finding) — quorum measures MEMBERS THAT
2527
+ // ACTUALLY PRODUCED A USABLE RESPONSE, per `quorum.ts`'s own docstring
2528
+ // ("at least k of n enabled members produced a usable response"). Before
2529
+ // this, `quorum_out.result` stayed the PRE-RUN value `build_members`
2530
+ // set — constructibility, not usability: a member that constructed fine
2531
+ // and then failed mid-flight (auth expired, timeout, quota exhausted)
2532
+ // still counted as `present`, so a pass with every provider erroring
2533
+ // could still report `concluded`. `_postRunQuorum` re-derives presence
2534
+ // over `responses` (index-aligned with `members` — `consult()`'s own
2535
+ // contract, one `CouncilResponse` per member from the final round) and
2536
+ // this overwrites BOTH the shared `quorum_out` out-param (the
2537
+ // payload-construction code below already reads it) and `skipped`
2538
+ // (mid-flight failures appended to the same array construction-time
2539
+ // skips already populate, so `payload['absent_members']` needs no
2540
+ // separate wiring either).
2541
+ const post_run = _postRunQuorum(members, responses, ai_cfg);
2542
+ quorum_out.result = post_run.quorum;
2543
+ skipped.push(...post_run.absent);
2544
+ // Phase 4.1 — verdict → handoff envelope. Same tally `render()` computes
2545
+ // internally for the Vote Tally block (mirrored here, not imported from
2546
+ // there, because `render()` re-derives it from the SAVED payload on a
2547
+ // later `council:render` — this pass needs its own copy to WRITE into
2548
+ // the payload in the first place). `buildHandoffFromStanceTally` returns
2549
+ // the honest all-null envelope when stance tally never ran or split, so
2550
+ // this is always additive, never a fabricated decision.
2551
+ const handoff: HandoffEnvelope = buildHandoffFromStanceTally(
2552
+ stance_tally_on
2553
+ ? tally_stances(
2554
+ responses.filter((r) => !r.error).map((r) => ({ member: `${r.provider}:${r.model}`, text: r.text })),
2555
+ )
2556
+ : null,
2557
+ );
2094
2558
  const persona_labels = build_persona_labels(advisor_plans, billable);
2095
2559
  const peer_review = _maybe_run_peer_review(
2096
2560
  ai_cfg,
@@ -2172,6 +2636,21 @@ function cmd_run(
2172
2636
  if (blind_review_map !== null) {
2173
2637
  payload['blind_review_map'] = _mapToObject(blind_review_map);
2174
2638
  }
2639
+ // Phase 3.3 — the response artefact carries the same k-of-n verdict the
2640
+ // stdout banner already showed; a `null` (no ref supplied) writes
2641
+ // nothing, so a caller that never asks for quorum sees no schema change.
2642
+ if (quorum_out.result !== null) {
2643
+ payload['quorum'] = quorum_out.result;
2644
+ }
2645
+ // Phase 3.2 — machine-readable graded degradation alongside the
2646
+ // stdout `format_install_hints` banner; empty when nothing was absent.
2647
+ if (skipped.length > 0) {
2648
+ payload['absent_members'] = skipped;
2649
+ }
2650
+ // Phase 4.1 — always written (a stable key beats a conditionally-present
2651
+ // one for a machine consumer), even when every field is `null`: the
2652
+ // work-order envelope for whatever executes on this verdict next.
2653
+ payload['handoff'] = handoff;
2175
2654
  const out_path = _validate_council_output_path(args.output as string, {
2176
2655
  kind: 'responses',
2177
2656
  subcommand: 'run',
@@ -2601,6 +3080,9 @@ function cmd_render(args: Args): number {
2601
3080
  chairman: _isDict(payload['chairman'])
2602
3081
  ? (payload['chairman'] as { member: string | null; annotation: string; text: string | null })
2603
3082
  : null,
3083
+ handoff: _deserialise_handoff(payload['handoff']),
3084
+ absent_members: _deserialise_absent_members(payload['absent_members']),
3085
+ quorum: _deserialise_quorum(payload['quorum']),
2604
3086
  });
2605
3087
  if (_getattr<string | null>(args, 'output', null)) {
2606
3088
  const out_path = _validate_council_output_path(args.output as string, {
@@ -2826,13 +3308,15 @@ interface Args {
2826
3308
  // shadow-report
2827
3309
  log: string | null;
2828
3310
  window_days: number;
3311
+ // status
3312
+ json: boolean;
2829
3313
  }
2830
3314
 
2831
- const _SUBCOMMANDS = ['estimate', 'run', 'debate', 'render', 'replay', 'quota', 'shadow-report'];
3315
+ const _SUBCOMMANDS = ['estimate', 'run', 'debate', 'render', 'replay', 'quota', 'shadow-report', 'status'];
2832
3316
 
2833
3317
  const _TOP_USAGE =
2834
3318
  `usage: ${_PROG} [-h]\n` +
2835
- ` {estimate,run,debate,render,replay,quota,shadow-report}\n` +
3319
+ ` {estimate,run,debate,render,replay,quota,shadow-report,status}\n` +
2836
3320
  ` ...\n`;
2837
3321
 
2838
3322
  function _topError(message: string): never {
@@ -2886,6 +3370,7 @@ function _defaultArgs(): Args {
2886
3370
  reset: null,
2887
3371
  log: null,
2888
3372
  window_days: 7,
3373
+ json: false,
2889
3374
  };
2890
3375
  }
2891
3376
 
@@ -3009,6 +3494,12 @@ function _specsFor(cmd: string): { positionals: string[]; opts: OptSpec[]; requi
3009
3494
  ],
3010
3495
  requiredOpts: [],
3011
3496
  };
3497
+ case 'status':
3498
+ return {
3499
+ positionals: [],
3500
+ opts: [{ flag: '--json', takesValue: false, apply: (o) => (o.json = true) }],
3501
+ requiredOpts: [],
3502
+ };
3012
3503
  default:
3013
3504
  return { positionals: [], opts: [], requiredOpts: [] };
3014
3505
  }
@@ -3213,6 +3704,9 @@ function main(argv: string[] | null = null): number {
3213
3704
  if (args.cmd === 'shadow-report') {
3214
3705
  return cmd_shadow_report(args);
3215
3706
  }
3707
+ if (args.cmd === 'status') {
3708
+ return cmd_status(args);
3709
+ }
3216
3710
  } catch (exc) {
3217
3711
  if (exc instanceof CouncilDisabledError) {
3218
3712
  _stderr(`❌ council:${args.cmd}: ${exc.message}\n`);
@@ -3284,5 +3778,6 @@ export {
3284
3778
  _parse_model_overrides,
3285
3779
  _parse_siblings_overrides,
3286
3780
  _synthesize_ai_council_block,
3781
+ _postRunQuorum,
3287
3782
  CouncilDisabledError,
3288
3783
  };